import { useEffect, useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDown, ArrowUp, Check, ExternalLink, Eye, EyeOff, History, Pencil, Plus, RefreshCw, UserMinus, } from "lucide-react"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { formatEta, formatViews, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import Avatar from "./Avatar"; import DataTable, { type Column } from "./DataTable"; import ChannelDiscovery from "./ChannelDiscovery"; import TagManager from "./TagManager"; import { useConfirm } from "./ConfirmProvider"; type ChannelsView = "subscribed" | "discovery"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; // Compact total-duration label for a whole channel (hours-scale, so H:MM:SS would be huge). function fmtTotalDuration(sec: number): string { if (!sec) return "—"; const h = Math.round(sec / 3600); return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`; } const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ { id: "all", labelKey: "channels.filters.all" }, { id: "needs_full", labelKey: "channels.filters.needsFull" }, { id: "fully_synced", labelKey: "channels.filters.fullySynced" }, { id: "hidden", labelKey: "channels.filters.hidden" }, ]; export default function Channels({ canWrite, isAdmin, onViewChannel, onFilterByTag, onFocusChannel, focusChannelName, statusFilter, setStatusFilter, onOpenWizard, }: { canWrite: boolean; isAdmin: boolean; onViewChannel: (id: string, name: string) => void; onFilterByTag: (tagId: number, name: string) => void; onFocusChannel: (name: string) => void; focusChannelName: string | null; statusFilter: ChannelStatusFilter; setStatusFilter: (f: ChannelStatusFilter) => void; onOpenWizard: () => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); // Which tab is showing: the user's subscriptions, or channels discovered from their // playlists. Persisted so a reload keeps the active tab (see other siftlode.* keys). const [view, setView] = useState(() => localStorage.getItem("siftlode.channelsView") === "discovery" ? "discovery" : "subscribed" ); useEffect(() => { localStorage.setItem("siftlode.channelsView", view); }, [view]); // A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't // granted the needed scope — surface that with a "Connect" action instead of a vague fail. const notifyActionError = (err: unknown, fallbackKey: string) => { if (err instanceof HttpError && err.status === 403) { notify({ level: "error", message: t("channels.notify.needYouTube"), action: { label: t("channels.notify.connect"), onClick: onOpenWizard }, }); } else { notify({ level: "error", message: t(fallbackKey) }); } }; const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const [tagManagerOpen, setTagManagerOpen] = useState(false); const invalidate = () => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["tags"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); }; const userTags = (tagsQuery.data ?? []).filter((t) => !t.system); const patch = useMutation({ mutationFn: (v: { id: string; body: { priority?: number; hidden?: boolean; deep_requested?: boolean }; }) => api.updateChannel(v.id, v.body), // Requesting full history may have just pulled in a channel's recent uploads, so // refresh the per-user status and feed too — not only the channel list. onSuccess: () => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["feed-count"] }); }, }); // Priority is updated optimistically in place and NOT re-sorted/refetched, so the list // doesn't jump (the server list is priority-sorted; refetching would reorder rows and // lose your scroll position). The new order takes effect next time the page loads. const bumpPriority = (id: string, current: number, delta: number) => { const next = current + delta; qc.setQueryData(["channels"], (old) => (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)) ); api .updateChannel(id, { priority: next }) .catch(() => qc.invalidateQueries({ queryKey: ["channels"] })); }; // Tagging is updated optimistically in place (no refetch of the whole channel list, which // made the toggle feel ~2s slow); only revert by refetching if the server call fails. const toggleTag = (id: string, tagId: number, hasTag: boolean) => { qc.setQueryData(["channels"], (old) => (old ?? []).map((ch) => ch.id === id ? { ...ch, tag_ids: hasTag ? ch.tag_ids.filter((x) => x !== tagId) : [...ch.tag_ids, tagId], } : ch ) ); const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId); call.catch(() => qc.invalidateQueries({ queryKey: ["channels"] })); }; const syncSubs = useMutation({ mutationFn: () => api.syncSubscriptions(), onSuccess: (r: { subscriptions?: number }) => { invalidate(); notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) }); }, onError: (e) => notifyActionError(e, "channels.notify.syncFailed"), }); const unsubscribe = useMutation({ mutationFn: (id: string) => api.unsubscribeChannel(id), onSuccess: () => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); notify({ level: "success", message: t("channels.notify.unsubscribed") }); }, onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"), }); const resetBackfill = useMutation({ mutationFn: (id: string) => api.resetChannelBackfill(id), onSuccess: () => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); notify({ level: "success", message: t("channels.notify.resetDone") }); }, onError: (e) => notifyActionError(e, "channels.notify.resetFailed"), }); const deepAll = useMutation({ mutationFn: () => api.deepAll(true), onSuccess: (r: { updated?: number }) => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); notify({ level: "success", message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), }); }, onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"), }); // The Sync-status filter stays a top-level chip set (not a column filter) because the // header's "go to full history" deep-link drives it; the DataTable then handles name // search, tag filtering, sort and pagination over whatever the status chip leaves. const channels = (channelsQuery.data ?? []).filter((c) => statusFilter === "needs_full" ? !c.backfill_done : statusFilter === "fully_synced" ? c.backfill_done : statusFilter === "hidden" ? c.hidden : true ); const s = statusQuery.data; const onView = (c: ManagedChannel) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")); const onUnsub = async (c: ManagedChannel) => { const ok = await confirm({ title: t("channels.row.unsubscribeOnYoutube"), message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }), confirmLabel: t("channels.row.unsubscribeOnYoutube"), danger: true, }); if (ok) unsubscribe.mutate(c.id); }; const onReset = async (c: ManagedChannel) => { const ok = await confirm({ title: t("channels.row.backfillThis"), message: t("channels.confirmReset", { name: c.title ?? c.id }), confirmLabel: t("channels.row.backfillThis"), }); if (ok) resetBackfill.mutate(c.id); }; const columns: Column[] = [ { key: "priority", header: t("channels.cols.priority"), align: "center", width: "56px", sortable: true, hideInCard: true, sortValue: (c) => c.priority, render: (c) => bumpPriority(c.id, c.priority, d)} />, }, { key: "channel", header: t("channels.cols.channel"), sortable: true, sortValue: (c) => (c.title ?? c.id).toLowerCase(), filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` }, cardPrimary: true, render: (c) => onView(c)} />, }, { key: "stored", header: t("channels.cols.stored"), align: "right", nowrap: true, sortable: true, sortValue: (c) => c.stored_videos, render: (c) => {c.stored_videos.toLocaleString()}, }, { key: "subs", header: t("channels.cols.subs"), align: "right", nowrap: true, sortable: true, sortValue: (c) => c.subscriber_count ?? -1, render: (c) => ( {c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"} ), }, { key: "lastUpload", header: t("channels.cols.lastUpload"), nowrap: true, sortable: true, sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0), render: (c) => {relativeTime(c.last_video_at) || "—"}, }, { key: "length", header: t("channels.cols.length"), align: "right", nowrap: true, sortable: true, sortValue: (c) => c.total_duration_seconds, render: (c) => {fmtTotalDuration(c.total_duration_seconds)}, }, { key: "types", header: t("channels.cols.types"), nowrap: true, render: (c) => ( {c.count_normal} / {c.count_short} / {c.count_live} ), }, { key: "sync", header: t("channels.cols.sync"), nowrap: true, cardLabel: false, render: (c) => , }, { key: "tags", header: t("channels.cols.tags"), cardLabel: false, filter: { kind: "multi", options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })), // OR semantics: show channels carrying any of the selected tags. test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))), }, render: (c) => ( toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} onTagClick={onFilterByTag} /> ), }, { key: "actions", header: t("channels.cols.actions"), align: "right", nowrap: true, width: "104px", cardLabel: false, render: (c) => ( patch.mutate({ id: c.id, body: { hidden: !c.hidden } })} onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })} onReset={() => onReset(c)} onUnsubscribe={() => onUnsub(c)} /> ), }, ]; // Status chips sit in the table's controls row (next to paging) — compact and close to // where they act. const statusChips = (
{STATUS_FILTERS.map((f) => ( ))}
); const tabs = (
{(["subscribed", "discovery"] as ChannelsView[]).map((v) => ( ))}
); if (view === "discovery") { return ( <> {tabs} ); } return ( <> {tabs}
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering now lives in the table headers). */}
{s && ( <> {s.deep_pending_count > 0 && ( )} )}

, , , ]} />

{/* Your tags — read-only overview; add/rename/delete live in the manager dialog. */}
{t("channels.tags.yourTags")} {userTags.map((tg) => ( {tg.name} ))}
{tagManagerOpen && ( setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> )} {/* Channel table */} {channelsQuery.isLoading ? (
{t("channels.loading")}
) : ( c.id} persistKey="siftlode.channelsTable" controlsPosition="top" controlsLeading={statusChips} externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null} rowClassName={(c) => (c.hidden ? "opacity-60" : "")} emptyText={t("channels.empty")} /> )}
); } function Stat({ label, value, hint, }: { label: string; value: string | number; hint?: string; }) { return ( {value} {label} ); } function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) { return ( {ok && } {label} ); } function PriorityCell({ c, onPriority }: { c: ManagedChannel; onPriority: (delta: number) => void }) { const { t } = useTranslation(); return (
{c.priority}
); } function NameCell({ c, onView }: { c: ManagedChannel; onView: () => void }) { const { t } = useTranslation(); const ytUrl = c.handle ? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}` : `https://www.youtube.com/channel/${c.id}`; return (
); } // Status only — the backfill *action* lives in the Actions column. When both recent and // full history are in, collapse to a single "fully synced" chip; otherwise show what's // present plus the missing/queued state. function SyncCell({ c }: { c: ManagedChannel }) { const { t } = useTranslation(); if (c.recent_synced && c.backfill_done) { return ; } return (
{c.backfill_done ? ( ) : c.deep_requested ? ( ) : c.deep_in_queue ? ( ) : ( )}
); } function TagsCell({ c, userTags, onToggleTag, onTagClick, }: { c: ManagedChannel; userTags: Tag[]; onToggleTag: (tagId: number) => void; onTagClick: (tagId: number, name: string) => void; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [open]); if (userTags.length === 0) return null; // Show only the tags actually attached to this channel; the “+” opens a per-channel // picker to attach/detach (kept the convenient "kirakás" without listing every tag // on every row). Tag create/delete still lives in the top "YOUR TAGS" row. const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id)); return (
{attached.map((tg) => ( ))} {open && (
{userTags.map((tg) => { const on = c.tag_ids.includes(tg.id); return ( ); })}
)}
); } function ActionsCell({ c, isAdmin, canWrite, onHide, onDeep, onReset, onUnsubscribe, }: { c: ManagedChannel; isAdmin: boolean; canWrite: boolean; onHide: () => void; onDeep: () => void; onReset: () => void; onUnsubscribe: () => void; }) { const { t } = useTranslation(); // Admins get a "reset" trigger that re-fetches the channel from scratch regardless of // state — always actionable. Regular users get the per-user "request full history" opt-in, // which only applies until the back-catalog is in (or already coming for everyone). const done = c.backfill_done; const requested = !done && c.deep_requested; const optInActionable = !done && !c.deep_in_queue; const backfillHint = isAdmin ? t("channels.row.resetHint") : c.deep_in_queue ? t("channels.row.queuedByOtherHint") : requested ? t("channels.row.queuedRequestedHint") : done ? t("channels.row.fullHint") : t("channels.row.backfillThisHint"); const enabled = isAdmin || optInActionable; return (
{canWrite && ( )}
); }