2026-06-11 04:15:25 +02:00
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
2026-06-15 00:30:34 +02:00
|
|
|
import { useTranslation } from "react-i18next";
|
2026-06-19 02:57:45 +02:00
|
|
|
import { Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
2026-06-19 03:24:45 +02:00
|
|
|
import { api, type MyStatus } from "../lib/api";
|
2026-06-11 04:15:25 +02:00
|
|
|
import { formatViews } from "../lib/format";
|
2026-06-14 07:08:59 +02:00
|
|
|
import Tooltip from "./Tooltip";
|
2026-06-11 04:15:25 +02:00
|
|
|
|
2026-06-14 06:55:18 +02:00
|
|
|
// Per-user status (not the global catalog): shows the number of videos available to *this*
|
2026-06-14 07:08:59 +02:00
|
|
|
// 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,
|
2026-07-01 23:08:25 +02:00
|
|
|
variant = "bar",
|
|
|
|
|
collapsed = false,
|
2026-06-14 07:08:59 +02:00
|
|
|
}: {
|
|
|
|
|
isAdmin: boolean;
|
|
|
|
|
onGoToFullHistory: () => void;
|
2026-07-01 23:08:25 +02:00
|
|
|
// "bar" = the legacy horizontal top-bar row. "rail" = a compact block for the top of the
|
|
|
|
|
// left nav sidebar (stacked; icon-only with a tooltip when the rail is collapsed).
|
|
|
|
|
variant?: "bar" | "rail";
|
|
|
|
|
collapsed?: boolean;
|
2026-06-14 07:08:59 +02:00
|
|
|
}) {
|
2026-06-15 00:30:34 +02:00
|
|
|
const { t } = useTranslation();
|
2026-06-11 04:15:25 +02:00
|
|
|
const qc = useQueryClient();
|
|
|
|
|
const { data } = useQuery({
|
2026-06-14 06:55:18 +02:00
|
|
|
queryKey: ["my-status"],
|
|
|
|
|
queryFn: api.myStatus,
|
2026-06-15 17:00:06 +02:00
|
|
|
// 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
|
2026-06-19 03:24:45 +02:00
|
|
|
// "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;
|
|
|
|
|
},
|
2026-06-15 17:00:06 +02:00
|
|
|
refetchIntervalInBackground: true,
|
|
|
|
|
refetchOnWindowFocus: true,
|
2026-06-19 03:24:45 +02:00
|
|
|
staleTime: 5_000,
|
2026-06-11 04:15:25 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const toggle = useMutation({
|
|
|
|
|
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
|
2026-06-14 06:55:18 +02:00
|
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
|
2026-06-11 04:15:25 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!data) return null;
|
|
|
|
|
|
2026-06-14 06:55:18 +02:00
|
|
|
const syncing = data.channels_recent_pending;
|
2026-06-14 07:08:59 +02:00
|
|
|
const notFull = data.channels_deep_pending; // your channels without full history yet
|
2026-06-19 02:57:45 +02:00
|
|
|
// 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;
|
2026-06-14 06:55:18 +02:00
|
|
|
|
2026-07-01 23:08:25 +02:00
|
|
|
// Calm one-liner describing the current sync state; shared by the bar and rail layouts.
|
|
|
|
|
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>
|
|
|
|
|
) : (
|
|
|
|
|
<span>{t("header.sync.allSynced")}</span>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => toggle.mutate()}
|
|
|
|
|
disabled={toggle.isPending}
|
|
|
|
|
title={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
|
|
|
|
|
className="p-1 rounded-md hover:bg-card hover:text-fg transition"
|
|
|
|
|
>
|
|
|
|
|
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Rail: a compact block at the top of the left nav. Collapsed → a single icon (spinner while
|
|
|
|
|
// syncing) with the counts in a tooltip; a small accent dot flags paused / missing-history.
|
|
|
|
|
if (variant === "rail") {
|
|
|
|
|
const countsText = `${formatViews(data.my_videos)} ${t("header.sync.yours")} / ${formatViews(
|
|
|
|
|
data.total_videos
|
|
|
|
|
)} ${t("header.sync.total")}`;
|
|
|
|
|
if (collapsed) {
|
|
|
|
|
return (
|
|
|
|
|
<Tooltip hint={countsText}>
|
|
|
|
|
<div className="relative grid place-items-center py-1 text-muted">
|
|
|
|
|
{active ? (
|
|
|
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Database className="w-4 h-4" />
|
|
|
|
|
)}
|
|
|
|
|
{(data.paused || notFull > 0) && (
|
|
|
|
|
<span className="absolute top-0 right-1 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<div className="text-[11px] text-muted leading-snug space-y-1">
|
|
|
|
|
<Tooltip hint={t("header.sync.countTooltip")}>
|
|
|
|
|
<span className="flex items-center gap-1.5 cursor-default">
|
|
|
|
|
<Database className="w-3.5 h-3.5 shrink-0" />
|
|
|
|
|
<span className="truncate">
|
|
|
|
|
<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>
|
|
|
|
|
</span>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
{showMain && <div className="flex items-center gap-1.5">{stateNode}</div>}
|
|
|
|
|
{notFull > 0 && (
|
|
|
|
|
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onGoToFullHistory}
|
|
|
|
|
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
|
|
|
|
|
>
|
|
|
|
|
<History className="w-3.5 h-3.5" />
|
|
|
|
|
{t("header.sync.withoutFullHistory", { count: notFull })}
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)}
|
|
|
|
|
{pauseBtn && <div className="pt-0.5">{pauseBtn}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 04:15:25 +02:00
|
|
|
return (
|
|
|
|
|
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
|
2026-06-15 00:30:34 +02:00
|
|
|
<Tooltip hint={t("header.sync.countTooltip")}>
|
2026-06-14 18:42:55 +02:00
|
|
|
<span className="flex items-center gap-1.5 cursor-default">
|
|
|
|
|
<Database className="w-3.5 h-3.5" />
|
|
|
|
|
<span>
|
2026-06-15 00:30:34 +02:00
|
|
|
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
|
|
|
|
|
{t("header.sync.yours")}
|
2026-06-14 18:42:55 +02:00
|
|
|
</span>
|
|
|
|
|
<span className="opacity-40">/</span>
|
2026-06-15 00:30:34 +02:00
|
|
|
<span>
|
|
|
|
|
{formatViews(data.total_videos)} {t("header.sync.total")}
|
|
|
|
|
</span>
|
2026-06-14 18:42:55 +02:00
|
|
|
</span>
|
|
|
|
|
</Tooltip>
|
2026-06-19 02:57:45 +02:00
|
|
|
{showMain && (
|
|
|
|
|
<>
|
|
|
|
|
<span className="opacity-40">·</span>
|
|
|
|
|
{data.paused ? (
|
|
|
|
|
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
|
|
|
|
|
) : active ? (
|
|
|
|
|
// A sync job is running right now — spin and name what it's doing.
|
|
|
|
|
<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 ? (
|
|
|
|
|
// Recent uploads queued for the next run, but nothing running now — static.
|
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
<Clock className="w-3.5 h-3.5" />
|
|
|
|
|
{t("header.sync.recentQueued", { count: syncing })}
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span>{t("header.sync.allSynced")}</span>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
2026-06-11 04:15:25 +02:00
|
|
|
)}
|
2026-06-14 07:08:59 +02:00
|
|
|
{notFull > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<span className="opacity-40">·</span>
|
2026-06-15 00:30:34 +02:00
|
|
|
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
|
2026-06-14 07:08:59 +02:00
|
|
|
<button
|
|
|
|
|
onClick={onGoToFullHistory}
|
|
|
|
|
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
|
|
|
|
|
>
|
|
|
|
|
<History className="w-3.5 h-3.5" />
|
2026-06-15 00:30:34 +02:00
|
|
|
{t("header.sync.withoutFullHistory", { count: notFull })}
|
2026-06-14 07:08:59 +02:00
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-06-15 04:30:27 +02:00
|
|
|
{/* Pause only makes sense when there's work to pause (recent OR deep backfill);
|
|
|
|
|
Resume must always show while paused so it can be lifted. Hidden entirely when
|
|
|
|
|
idle and not paused. */}
|
|
|
|
|
{isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
|
2026-06-11 04:15:25 +02:00
|
|
|
<button
|
|
|
|
|
onClick={() => toggle.mutate()}
|
|
|
|
|
disabled={toggle.isPending}
|
2026-06-15 00:30:34 +02:00
|
|
|
title={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
|
2026-06-11 04:15:25 +02:00
|
|
|
className="ml-1 p-1 rounded-md hover:bg-card hover:text-fg transition"
|
|
|
|
|
>
|
|
|
|
|
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|