70 lines
2.4 KiB
TypeScript
70 lines
2.4 KiB
TypeScript
// Initial-sync progress UX (SPEC §12 M2). Shows a small floating card while any
|
|||
|
|
// account is importing its history; reads the accounts query, which the WS
|
||
|
|
// `sync.progress` event invalidates so the bar advances live.
|
||
|
|
|
||
|
|
import { useQuery } from "@tanstack/react-query";
|
||
|
|
import { useEffect, useState } from "react";
|
||
|
|
|
||
|
|
import { useT } from "../i18n";
|
||
|
|
import { api } from "../lib/api";
|
||
|
|
|
||
|
|
export function BackfillBanner() {
|
||
|
|
const t = useT();
|
||
|
|
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||
|
|
|
||
|
|
const importing = (accounts.data ?? []).filter(
|
||
|
|
(a) => a.state === "backfilling" && a.backfill_total > 0,
|
||
|
|
);
|
||
|
|
const active = importing.length > 0;
|
||
|
|
|
||
|
|
// Dissolve the card 10s after it appears; the sidebar ring keeps showing
|
||
|
|
// progress until the sync finishes.
|
||
|
|
const [dismissed, setDismissed] = useState(false);
|
||
|
|
useEffect(() => {
|
||
|
|
if (!active) {
|
||
|
|
setDismissed(false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const timer = window.setTimeout(() => setDismissed(true), 10000);
|
||
|
|
return () => window.clearTimeout(timer);
|
||
|
|
}, [active]);
|
||
|
|
|
||
|
|
if (!active) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={`fixed bottom-4 left-4 z-40 w-72 space-y-2 transition-opacity duration-700 ${
|
||
|
|
dismissed ? "pointer-events-none opacity-0" : "opacity-100"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{importing.map((a) => {
|
||
|
|
const done = Math.min(a.backfill_done, a.backfill_total);
|
||
|
|
const pct = Math.round((done / a.backfill_total) * 100);
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={a.id}
|
||
|
|
className="rounded-lg border border-app-border bg-app-surface p-3 shadow-lg"
|
||
|
|
>
|
||
|
|
<div className="mb-1 flex items-center gap-2">
|
||
|
|
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
|
||
|
|
<span className="truncate text-xs font-medium text-app-fg">{a.email}</span>
|
||
|
|
</div>
|
||
|
|
<div className="mb-1.5 text-xs text-app-muted">
|
||
|
|
{t("backfill.importing", {
|
||
|
|
done: done.toLocaleString(),
|
||
|
|
total: a.backfill_total.toLocaleString(),
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-app-hover">
|
||
|
|
<div
|
||
|
|
className="h-full rounded-full bg-app-accent transition-all duration-500"
|
||
|
|
style={{ width: `${pct}%` }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|