import { useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react"; import { api, type MyStatus } from "../lib/api"; import { formatViews } from "../lib/format"; import { useDismiss } from "../lib/useDismiss"; // 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); const chipRef = useRef(null); const popRef = useRef(null); // Fixed viewport coords: the popover is portaled out of the header, whose glass backdrop-filter // would otherwise trap its stacking and positioning. const [pos, setPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 }); useDismiss(open, () => setOpen(false), [chipRef, popRef]); const { data } = useQuery({ queryKey: ["my-status"], 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: ["my-status"] }), }); 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 ? ( {t("header.sync.paused")} ) : active ? ( {syncing > 0 ? t("header.sync.syncing", { count: syncing }) : notFull > 0 ? t("header.sync.backfillingHistory") : t("header.sync.working")} ) : syncing > 0 ? ( {t("header.sync.recentQueued", { count: syncing })} ) : ( // A small check anchors the "all synced" state so it doesn't read as orphaned text. {t("header.sync.allSynced")} ); const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && ( ); // 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 (
{open && createPortal(
{formatViews(data.my_videos)}{" "} {t("header.sync.yours")} / {formatViews(data.total_videos)} {t("header.sync.total")}
{showMain && (
{stateNode} {pauseBtn}
)} {notFull > 0 && (
{!showMain && pauseBtn}
)}
, document.body )}
); }