From cab700bca53218705377d0a8eb4d30b849a7ef3e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 17 Jun 2026 19:16:23 +0200 Subject: [PATCH] feat(channels): data-table channel manager Replace the stacked card rows with the reusable DataTable: sortable columns, in-header Channel/Tags filters, status chips + page controls in one row, wider layout. Tag toggle is now optimistic (no full refetch). Sync column collapses to one 'fully synced' chip; Actions column carries hide/unsubscribe + a backfill control (admin reset / per-user full-history opt-in). Channel status filter persists across reloads. --- frontend/src/App.tsx | 14 +- frontend/src/components/Channels.tsx | 631 +++++++++++++-------- frontend/src/i18n/locales/de/channels.json | 30 +- frontend/src/i18n/locales/en/channels.json | 30 +- frontend/src/i18n/locales/hu/channels.json | 30 +- 5 files changed, 476 insertions(+), 259 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 35b2825..f3feb95 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -93,7 +93,18 @@ export default function App() { const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); const [page, setPageState] = useState(loadInitialPage); const [wizardOpen, setWizardOpen] = useState(false); - const [channelFilter, setChannelFilter] = useState("all"); + const CHANNEL_FILTER_KEY = "siftlode.channelFilter"; + const [channelFilter, setChannelFilterState] = useState(() => { + const v = localStorage.getItem(CHANNEL_FILTER_KEY); + return v === "needs_full" || v === "fully_synced" || v === "hidden" || v === "all" + ? v + : "all"; + }); + // Persist the channel status chip so a reload (F5) keeps it. + const setChannelFilter = (f: ChannelStatusFilter) => { + setChannelFilterState(f); + localStorage.setItem(CHANNEL_FILTER_KEY, f); + }; const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); @@ -266,6 +277,7 @@ export default function App() { {page === "channels" ? ( setWizardOpen(true)} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index dc37a27..e9eb9a0 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -11,7 +11,6 @@ import { History, Plus, RefreshCw, - Search, UserMinus, X, } from "lucide-react"; @@ -20,6 +19,7 @@ import { formatEta } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import Avatar from "./Avatar"; +import DataTable, { type Column } from "./DataTable"; import { useConfirm } from "./ConfirmProvider"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; @@ -33,12 +33,14 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ export default function Channels({ canWrite, + isAdmin, onViewChannel, statusFilter, setStatusFilter, onOpenWizard, }: { canWrite: boolean; + isAdmin: boolean; onViewChannel: (id: string, name: string) => void; statusFilter: ChannelStatusFilter; setStatusFilter: (f: ChannelStatusFilter) => void; @@ -64,7 +66,6 @@ export default function Channels({ 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 [q, setQ] = useState(""); const [newTag, setNewTag] = useState(""); const invalidate = () => { @@ -102,14 +103,26 @@ export default function Channels({ .catch(() => qc.invalidateQueries({ queryKey: ["channels"] })); }; - const attach = useMutation({ - mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId), - onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }), - }); - const detach = useMutation({ - mutationFn: (v: { id: string; tagId: number }) => api.detachChannelTag(v.id, v.tagId), - onSuccess: () => 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 }) => { @@ -138,6 +151,15 @@ export default function Channels({ }, 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 }) => { @@ -151,62 +173,184 @@ export default function Channels({ onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"), }); - const channels = (channelsQuery.data ?? []) - .filter((c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase())) - .filter((c) => - statusFilter === "needs_full" - ? !c.backfill_done - : statusFilter === "fully_synced" - ? c.backfill_done - : statusFilter === "hidden" - ? c.hidden - : true - ); + // 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; - return ( -
- {/* Per-user sync status */} - {s && ( -
- - - - {s.deep_pending_count > 0 && ( - - )} - - -
- )} + 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); + }; - {/* Toolbar */} -
-
- - setQ(e.target.value)} - placeholder={t("channels.filterPlaceholder")} - className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" - /> + 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", + width: "84px", + sortable: true, + sortValue: (c) => c.stored_videos, + render: (c) => {c.stored_videos.toLocaleString()}, + }, + { + key: "subs", + header: t("channels.cols.subs"), + align: "right", + width: "92px", + sortable: true, + sortValue: (c) => c.subscriber_count ?? -1, + render: (c) => ( + + {c.subscriber_count != null ? c.subscriber_count.toLocaleString() : "—"} + + ), + }, + { + key: "sync", + header: t("channels.cols.sync"), + width: "130px", + cardLabel: false, + render: (c) => , + }, + { + key: "tags", + header: t("channels.cols.tags"), + width: "360px", + 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))} + /> + ), + }, + { + key: "actions", + header: t("channels.cols.actions"), + align: "right", + 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) => ( + + ))} +
+ ); + + return ( +
+ {/* 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 && ( + + )} + + + + )}
+
-
- - {/* Status filter */} -
- {STATUS_FILTERS.map((f) => ( - - ))} +

@@ -300,42 +428,20 @@ export default function Channels({

- {/* Channel list */} + {/* Channel table */} {channelsQuery.isLoading ? (
{t("channels.loading")}
- ) : channels.length === 0 ? ( -
{t("channels.empty")}
) : ( -
- {channels.map((c) => ( - { - 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); - }} - onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} - onPriority={(d) => bumpPriority(c.id, c.priority, d)} - onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })} - onDeep={() => - patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }) - } - onToggleTag={(tagId) => - c.tag_ids.includes(tagId) - ? detach.mutate({ id: c.id, tagId }) - : attach.mutate({ id: c.id, tagId }) - } - /> - ))} -
+ c.id} + persistKey="siftlode.channelsTable" + controlsPosition="top" + controlsLeading={statusChips} + rowClassName={(c) => (c.hidden ? "opacity-60" : "")} + emptyText={t("channels.empty")} + /> )}
); @@ -374,153 +480,180 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str ); } -function ChannelRow({ - c, - userTags, - canWrite, - onUnsubscribe, - onView, - onPriority, - onHide, - onDeep, - onToggleTag, -}: { - c: ManagedChannel; - userTags: Tag[]; - canWrite: boolean; - onUnsubscribe: () => void; - onView: () => void; - onPriority: (delta: number) => void; - onHide: () => void; - onDeep: () => void; - onToggleTag: (tagId: number) => void; -}) { +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 ( -
- -
- - {c.priority} - -
-
- - - -
-
- - - - - - -
-
- {t("channels.row.stored", { count: c.stored_videos, formatted: c.stored_videos.toLocaleString() })} - {c.subscriber_count != null && · {t("channels.row.subs", { count: c.subscriber_count, formatted: c.subscriber_count.toLocaleString() })}} -
-
- - {c.backfill_done ? ( - - ) : c.deep_requested ? ( - - - - ) : c.deep_in_queue ? ( - - - - {t("channels.row.fullHistoryComing")} - - - ) : ( - - - - )} - {userTags.map((t) => { - const on = c.tag_ids.includes(t.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, +}: { + c: ManagedChannel; + userTags: Tag[]; + onToggleTag: (tagId: number) => void; +}) { + if (userTags.length === 0) return null; + return ( +
+ {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 && (