Files
siftlode/frontend/src/components/SyncStatus.tsx
T

185 lines
7.4 KiB
TypeScript

import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { formatViews } from "../lib/format";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full
// history (clickable -> channel manager filtered to those). The pause control is admin-only.
export default function SyncStatus({
isAdmin,
onGoToFullHistory,
}: {
isAdmin: boolean;
onGoToFullHistory: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
// A read-out with a couple of controls rather than a list of choices, so it is a `dialog`
// popover. It portals out of the header, whose glass backdrop-filter would otherwise trap its
// stacking and positioning.
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "dialog",
role: "dialog",
placement: "bottom-end",
offset: 8,
});
const { data } = useQuery({
queryKey: qk.myStatus(),
queryFn: api.myStatus,
// Track the scheduler near-real-time: poll even when the tab is backgrounded, and
// refresh immediately on tab focus (the global default disables focus refetch), so the
// "N without full history" count keeps ticking down without a manual reload. Poll faster
// while a job is running or work is pending — so the live "syncing" state is actually
// caught — and ease back to a slow idle tick once everything's settled.
refetchInterval: (query) => {
const d = query.state.data as MyStatus | undefined;
const busy =
!!d && (d.sync_active || d.channels_recent_pending > 0 || d.channels_deep_pending > 0);
return busy ? 8_000 : 30_000;
},
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
staleTime: 5_000,
});
const toggle = useMutation({
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
onError: (e) =>
notifyActionError(
e,
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
),
});
if (!data) return null;
const syncing = data.channels_recent_pending;
const notFull = data.channels_deep_pending; // your channels without full history yet
// Spin only while a sync job is actually running; otherwise pending work shows as a calm,
// static state (or, for deep history, just the "N without full history" link below).
const active = data.sync_active;
const showMain = data.paused || active || syncing > 0 || notFull === 0;
// Calm one-liner describing the current sync state.
const stateNode = data.paused ? (
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
) : active ? (
<span className="flex items-center gap-1">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{syncing > 0
? t("header.sync.syncing", { count: syncing })
: notFull > 0
? t("header.sync.backfillingHistory")
: t("header.sync.working")}
</span>
) : syncing > 0 ? (
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{t("header.sync.recentQueued", { count: syncing })}
</span>
) : (
// A small check anchors the "all synced" state so it doesn't read as orphaned text.
<span className="flex items-center gap-1 text-success">
<CheckCircle2 className="w-3.5 h-3.5" />
<span className="text-muted">{t("header.sync.allSynced")}</span>
</span>
);
const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
<IconButton
onClick={() => toggle.mutate()}
loading={toggle.isPending}
label={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
>
{data.paused ? <Play /> : <Pause />}
</IconButton>
);
// Chip: the headline count rides in the header; the state, the history link and pause live in a
// popover. An accent dot flags the only two things worth interrupting you for (paused / missing
// history) so the collapsed chip still tells you when to look.
const needsAttention = data.paused || notFull > 0;
const countsText = `${formatViews(data.my_videos)} ${t("header.sync.yours")} / ${formatViews(
data.total_videos,
)} ${t("header.sync.total")}`;
return (
<div className="pointer-events-auto shrink-0">
<Pressable
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// Short label only: the popover already explains the numbers, and the long one overflowed
// the viewport edge from here.
title={countsText}
className={`${SURFACE.floating} glass-hover relative flex h-10 items-center gap-1.5 px-3 text-xs text-muted`}
>
{active ? (
<Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin text-accent" />
) : (
<Database className="w-3.5 h-3.5 shrink-0" />
)}
<span className="text-fg font-medium tabular-nums">{formatViews(data.my_videos)}</span>
{needsAttention && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
)}
</Pressable>
<PopoverSurface
popover={pop}
ariaLabel={countsText}
className={`${SURFACE.popover} w-64 p-3 text-xs text-muted animate-[popIn_0.16s_ease]`}
>
<div
title={t("header.sync.countTooltip")}
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
>
<Database className="w-3.5 h-3.5 shrink-0" />
<span>
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
<span className="opacity-40"> / </span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</div>
{showMain && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
{pauseBtn}
</div>
)}
{notFull > 0 && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<Button
variant="link-muted"
icon={<History className="size-3.5!" />}
onClick={() => {
setOpen(false);
onGoToFullHistory();
}}
title={t("header.sync.fullHistoryTooltip")}
className="gap-1 underline decoration-dotted decoration-muted/40 underline-offset-4"
>
{t("header.sync.withoutFullHistory", { count: notFull })}
</Button>
{!showMain && pauseBtn}
</div>
)}
</PopoverSurface>
</div>
);
}