215 lines
5.3 KiB
Svelte
215 lines
5.3 KiB
Svelte
<script lang="ts">
|
|
// Domino's-pizza-tracker-style progress for a background media import: four
|
|
// ordered steps, the running one pulses, finished ones show how long they
|
|
// took. Fed by the `imports` store (import://progress events). Renders nothing
|
|
// until the first tick arrives. Design per ui-ux-pro-max: color is never the
|
|
// only signal (icon + label + time), tabular figures for the timers, and the
|
|
// pulse is dropped under prefers-reduced-motion.
|
|
import { imports } from "../stores/imports.svelte";
|
|
import { t } from "../i18n/index.svelte";
|
|
import { AudioLines, Captions, Users, FileCheck2, Check, X } from "@lucide/svelte";
|
|
|
|
let { meetingId }: { meetingId: string } = $props();
|
|
|
|
const run = $derived(imports.get(meetingId));
|
|
|
|
const ICONS = {
|
|
prepare: AudioLines,
|
|
transcribe: Captions,
|
|
diarize: Users,
|
|
finalize: FileCheck2,
|
|
} as const;
|
|
|
|
// ms → compact, human duration for a finished step ("820 ms", "4.3s", "2m 05s").
|
|
function fmtDur(ms: number | null): string {
|
|
if (ms == null) return "";
|
|
if (ms < 1000) return `${ms} ms`;
|
|
const s = ms / 1000;
|
|
if (s < 60) return `${s.toFixed(1)}s`;
|
|
const m = Math.floor(s / 60);
|
|
const rem = Math.round(s % 60);
|
|
return `${m}m ${String(rem).padStart(2, "0")}s`;
|
|
}
|
|
</script>
|
|
|
|
{#if run}
|
|
<section class="tracker" aria-label={t("import.tracker.label")}>
|
|
<header>
|
|
{#if run.error}
|
|
<span class="head err">{t("import.tracker.failed")}</span>
|
|
{:else if run.done}
|
|
<span class="head ok">{t("import.tracker.done")}</span>
|
|
{:else}
|
|
<span class="head">{t("import.tracker.running")}</span>
|
|
{/if}
|
|
</header>
|
|
|
|
<ol class="steps" aria-live="polite">
|
|
{#each run.phases as p (p.phase)}
|
|
{@const Icon = ICONS[p.phase]}
|
|
<li class="step {p.state}">
|
|
<div class="node">
|
|
{#if p.state === "done"}
|
|
<Check size={18} aria-hidden="true" />
|
|
{:else if p.state === "error"}
|
|
<X size={18} aria-hidden="true" />
|
|
{:else}
|
|
<Icon size={18} aria-hidden="true" />
|
|
{/if}
|
|
</div>
|
|
<div class="meta">
|
|
<span class="name">{t(`import.phase.${p.phase}`)}</span>
|
|
<span class="time">
|
|
{#if p.state === "done"}{fmtDur(p.elapsedMs)}
|
|
{:else if p.state === "active"}{t("import.tracker.working")}
|
|
{:else if p.state === "error"}{t("import.tracker.stopped")}
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ol>
|
|
|
|
{#if run.error}
|
|
<p class="msg">{run.error}</p>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
|
|
<style>
|
|
.tracker {
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--panel, var(--bg-elevated));
|
|
padding: 0.85rem 1rem 1rem;
|
|
}
|
|
header {
|
|
margin-bottom: 0.9rem;
|
|
}
|
|
.head {
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--fg);
|
|
}
|
|
.head.ok {
|
|
color: var(--success);
|
|
}
|
|
.head.err {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.steps {
|
|
display: flex;
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
.step {
|
|
flex: 1;
|
|
position: relative;
|
|
text-align: center;
|
|
min-width: 0;
|
|
}
|
|
/* Connector from the previous node's center to this one's (each step is the
|
|
same width, so -50%→+50% spans center to center), sitting behind the node. */
|
|
.step::before {
|
|
content: "";
|
|
position: absolute;
|
|
top: 17px;
|
|
left: -50%;
|
|
width: 100%;
|
|
height: 2px;
|
|
background: var(--border);
|
|
z-index: 0;
|
|
}
|
|
.step:first-child::before {
|
|
display: none;
|
|
}
|
|
.step.done::before,
|
|
.step.active::before,
|
|
.step.error::before {
|
|
background: var(--accent);
|
|
}
|
|
|
|
.node {
|
|
position: relative;
|
|
z-index: 1;
|
|
width: 36px;
|
|
height: 36px;
|
|
margin: 0 auto 0.45rem;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: var(--radius-full);
|
|
border: 2px solid var(--border);
|
|
background: var(--bg);
|
|
color: var(--muted);
|
|
}
|
|
.step.active .node {
|
|
border-color: var(--accent);
|
|
background: var(--accent-soft, transparent);
|
|
color: var(--accent);
|
|
animation: pulse 1.4s ease-out infinite;
|
|
}
|
|
.step.done .node {
|
|
border-color: var(--success);
|
|
background: var(--success);
|
|
color: #fff;
|
|
}
|
|
.step.error .node {
|
|
border-color: var(--danger);
|
|
background: var(--danger);
|
|
color: #fff;
|
|
}
|
|
|
|
.meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.1rem;
|
|
padding: 0 0.2rem;
|
|
}
|
|
.name {
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
color: var(--muted);
|
|
line-height: 1.2;
|
|
}
|
|
.step.active .name,
|
|
.step.done .name {
|
|
color: var(--fg);
|
|
}
|
|
.time {
|
|
font-size: 0.72rem;
|
|
color: var(--muted);
|
|
font-variant-numeric: tabular-nums;
|
|
min-height: 1em;
|
|
}
|
|
.step.active .time {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.msg {
|
|
margin: 0.85rem 0 0;
|
|
font-size: 0.8rem;
|
|
color: var(--danger);
|
|
word-break: break-word;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0% {
|
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
|
|
}
|
|
70% {
|
|
box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent) 0%, transparent);
|
|
}
|
|
100% {
|
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
|
|
}
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.step.active .node {
|
|
animation: none;
|
|
box-shadow: 0 0 0 3px var(--accent-soft, transparent);
|
|
}
|
|
}
|
|
</style>
|