From 123b024b190aa78f1b13bd762ce7860cbbfc7836 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 17 Jun 2026 19:16:23 +0200 Subject: [PATCH 1/3] feat(ui): reusable DataTable component Generic client-side table: per-column sort, in-header filters (text/select/multi), built-in pagination with a user-set page size (incl. All) and an editable jump-to-page box. Sort/filter/page/size persist to localStorage (survive F5). Falls back to a compact card list below md. Intended for reuse across modules (channels now, playlists next). --- frontend/src/components/DataTable.tsx | 421 ++++++++++++++++++++ frontend/src/i18n/locales/de/datatable.json | 15 + frontend/src/i18n/locales/en/datatable.json | 15 + frontend/src/i18n/locales/hu/datatable.json | 15 + 4 files changed, 466 insertions(+) create mode 100644 frontend/src/components/DataTable.tsx create mode 100644 frontend/src/i18n/locales/de/datatable.json create mode 100644 frontend/src/i18n/locales/en/datatable.json create mode 100644 frontend/src/i18n/locales/hu/datatable.json diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx new file mode 100644 index 0000000..ef1d5a8 --- /dev/null +++ b/frontend/src/components/DataTable.tsx @@ -0,0 +1,421 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react"; + +// A reusable, client-side data table: per-column sort + filter (in the header) + pagination, +// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps +// the view. Columns are declared by the caller; the table is generic over the row type, so +// other modules (e.g. the playlist manager) can reuse it. Below `md` it falls back to a card +// list built from the same column definitions. + +export type ColumnFilter = + | { kind: "text"; get: (row: T) => string } + | { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean } + | { kind: "multi"; options: { value: string; label: string }[]; test: (row: T, values: string[]) => boolean }; + +export interface Column { + key: string; + header: string; + render: (row: T) => ReactNode; + align?: "left" | "right" | "center"; + width?: string; + sortable?: boolean; + sortValue?: (row: T) => string | number; + filter?: ColumnFilter; + // Card fallback (below md): `cardPrimary` renders as the card heading (no label); `hideInCard` + // omits the column; `cardLabel:false` shows the value without its header label. + cardPrimary?: boolean; + hideInCard?: boolean; + cardLabel?: boolean; +} + +type SortState = { key: string; dir: "asc" | "desc" } | null; +type FilterMap = Record; +interface Persisted { + sort: SortState; + filters: FilterMap; + page: number; + size?: number; +} + +function loadPersist(key?: string): Persisted { + const empty: Persisted = { sort: null, filters: {}, page: 0 }; + if (!key) return empty; + try { + const v = JSON.parse(localStorage.getItem(key) || "{}"); + return { sort: v.sort ?? null, filters: v.filters ?? {}, page: v.page ?? 0, size: v.size }; + } catch { + return empty; + } +} + +function isActive(value: string | string[] | undefined): boolean { + return Array.isArray(value) ? value.length > 0 : !!value; +} + +export default function DataTable({ + rows, + columns, + rowKey, + pageSize = 10, + pageSizeOptions = [10, 25, 50, 100], + persistKey, + emptyText, + rowClassName, + controlsPosition = "bottom", + controlsLeading, +}: { + rows: T[]; + columns: Column[]; + rowKey: (row: T) => string; + pageSize?: number; + pageSizeOptions?: number[]; + persistKey?: string; + emptyText?: string; + rowClassName?: (row: T) => string; + controlsPosition?: "top" | "bottom" | "both"; + controlsLeading?: ReactNode; +}) { + const { t } = useTranslation(); + const initial = loadPersist(persistKey); + const [sort, setSort] = useState(initial.sort); + const [filters, setFilters] = useState(initial.filters); + const [page, setPage] = useState(initial.page); + const [size, setSize] = useState(initial.size ?? pageSize); + const [openFilter, setOpenFilter] = useState(null); + // Local text for the editable "jump to page" box (committed on Enter/blur). + const [pageInput, setPageInput] = useState("1"); + const popRef = useRef(null); + + useEffect(() => { + if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size })); + }, [persistKey, sort, filters, page, size]); + + useEffect(() => { + if (!openFilter) return; + function onDown(e: MouseEvent) { + if (!popRef.current?.contains(e.target as Node)) setOpenFilter(null); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpenFilter(null); + } + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [openFilter]); + + const filtered = rows.filter((row) => + columns.every((col) => { + const f = col.filter; + if (!f) return true; + const val = filters[col.key]; + if (!isActive(val)) return true; + if (f.kind === "text") return f.get(row).toLowerCase().includes(String(val).toLowerCase()); + if (f.kind === "select") return f.test(row, String(val)); + return f.test(row, val as string[]); + }) + ); + + const sortCol = sort ? columns.find((c) => c.key === sort.key) : undefined; + const sorted = + sort && sortCol?.sortValue + ? [...filtered].sort((a, b) => { + const av = sortCol.sortValue!(a); + const bv = sortCol.sortValue!(b); + const cmp = + typeof av === "number" && typeof bv === "number" + ? av - bv + : String(av).localeCompare(String(bv)); + return sort.dir === "asc" ? cmp : -cmp; + }) + : filtered; + + // size <= 0 means "All" — one page with every row. + const allRows = size <= 0; + const totalPages = allRows ? 1 : Math.max(1, Math.ceil(sorted.length / size)); + const safePage = Math.min(page, totalPages - 1); + const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size); + + useEffect(() => setPageInput(String(safePage + 1)), [safePage]); + function commitPageInput() { + const n = parseInt(pageInput, 10); + if (!isNaN(n)) setPage(Math.min(totalPages - 1, Math.max(0, n - 1))); + else setPageInput(String(safePage + 1)); + } + + function toggleSort(key: string) { + setSort((s) => + !s || s.key !== key ? { key, dir: "asc" } : s.dir === "asc" ? { key, dir: "desc" } : null + ); + setPage(0); + } + function applyFilter(key: string, value: string | string[]) { + setFilters((f) => ({ ...f, [key]: value })); + setPage(0); + } + + const align = (a?: "left" | "right" | "center") => + a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left"; + + function FilterPopover({ col }: { col: Column }) { + const f = col.filter!; + const val = filters[col.key]; + return ( +
+ {f.kind === "text" && ( + applyFilter(col.key, e.target.value)} + placeholder={col.header} + className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" + /> + )} + {f.kind === "select" && ( +
+ + {f.options.map((o) => ( + + ))} +
+ )} + {f.kind === "multi" && ( +
+ {f.options.length === 0 && ( + {t("datatable.noOptions")} + )} + {f.options.map((o) => { + const arr = (val as string[]) ?? []; + const on = arr.includes(o.value); + return ( + + ); + })} +
+ )} + {isActive(val) && ( + + )} +
+ ); + } + + const controls = + controlsLeading != null || sorted.length > 0 ? ( +
+
{controlsLeading}
+
+ {totalPages > 1 && ( +
+ + + {t("datatable.pager.pageLabel")} + setPageInput(e.target.value)} + onBlur={commitPageInput} + onKeyDown={(e) => e.key === "Enter" && commitPageInput()} + aria-label={t("datatable.pager.pageLabel")} + className="w-14 bg-card border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums outline-none focus:border-accent" + /> + {t("datatable.pager.ofTotal", { total: totalPages })} + + +
+ )} + {sorted.length > 0 && ( + + )} +
+
+ ) : null; + + return ( +
+ {(controlsPosition === "top" || controlsPosition === "both") && controls} + {/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */} +
+ + + + {columns.map((col) => { + const sorted_ = sort?.key === col.key; + const filterOn = isActive(filters[col.key]); + return ( + + ); + })} + + + + {paged.map((row) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ + + {col.filter && ( + + )} + + {openFilter === col.key && col.filter && } +
+ {col.render(row)} +
+
+ + {/* Narrow screens: a compact card per row — the primary column as the heading, the + rest flowing in a single wrapping meta line (no full-width label/value gaps). */} +
+ {paged.map((row) => ( +
+ {columns + .filter((c) => c.cardPrimary) + .map((col) => ( +
+ {col.render(row)} +
+ ))} +
+ {columns + .filter((c) => !c.hideInCard && !c.cardPrimary) + .map((col) => ( + + {col.cardLabel !== false && ( + {col.header} + )} + {col.render(row)} + + ))} +
+
+ ))} +
+ + {paged.length === 0 && ( +
{emptyText ?? t("datatable.empty")}
+ )} + + {(controlsPosition === "bottom" || controlsPosition === "both") && controls} +
+ ); +} diff --git a/frontend/src/i18n/locales/de/datatable.json b/frontend/src/i18n/locales/de/datatable.json new file mode 100644 index 0000000..5e67f41 --- /dev/null +++ b/frontend/src/i18n/locales/de/datatable.json @@ -0,0 +1,15 @@ +{ + "filter": "Filtern", + "clear": "Löschen", + "all": "Alle", + "rowsPerPage": "Zeilen pro Seite", + "noOptions": "Keine Optionen", + "empty": "Nichts anzuzeigen.", + "pager": { + "prev": "Zurück", + "next": "Weiter", + "page": "Seite {{page}} von {{total}}", + "pageLabel": "Seite", + "ofTotal": "von {{total}}" + } +} diff --git a/frontend/src/i18n/locales/en/datatable.json b/frontend/src/i18n/locales/en/datatable.json new file mode 100644 index 0000000..84c78e7 --- /dev/null +++ b/frontend/src/i18n/locales/en/datatable.json @@ -0,0 +1,15 @@ +{ + "filter": "Filter", + "clear": "Clear", + "all": "All", + "rowsPerPage": "Rows per page", + "noOptions": "No options", + "empty": "Nothing to show.", + "pager": { + "prev": "Previous", + "next": "Next", + "page": "Page {{page}} of {{total}}", + "pageLabel": "Page", + "ofTotal": "of {{total}}" + } +} diff --git a/frontend/src/i18n/locales/hu/datatable.json b/frontend/src/i18n/locales/hu/datatable.json new file mode 100644 index 0000000..269704c --- /dev/null +++ b/frontend/src/i18n/locales/hu/datatable.json @@ -0,0 +1,15 @@ +{ + "filter": "Szűrés", + "clear": "Törlés", + "all": "Mind", + "rowsPerPage": "Sor/oldal", + "noOptions": "Nincs lehetőség", + "empty": "Nincs megjeleníthető elem.", + "pager": { + "prev": "Előző", + "next": "Következő", + "page": "{{page}}. oldal / {{total}}", + "pageLabel": "Oldal", + "ofTotal": "/ {{total}}" + } +} From 2941832566fa4035c2430d10e5381a4d58dbbc60 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 17 Jun 2026 19:16:23 +0200 Subject: [PATCH 2/3] feat(channels): admin reset / re-backfill endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/channels/{id}/reset-backfill (admin-only): clear the channel's backfill markers, re-opt into deep backfill and re-run its recent pull now — a re-fetch-from- scratch trigger regardless of current sync state. Idempotent (videos upsert by id). --- backend/app/routes/channels.py | 25 +++++++++++++++++++++++++ frontend/src/lib/api.ts | 2 ++ 2 files changed, 27 insertions(+) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index fc4e0d3..04ad3f7 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -9,6 +9,7 @@ from app import quota from app.auth import current_user, has_write_scope from app.db import get_db from app.models import Channel, ChannelTag, Subscription, Tag, User, Video +from app.routes.admin import admin_user from app.sync.runner import run_recent_backfill from app.youtube.client import YouTubeClient, YouTubeError @@ -134,6 +135,30 @@ def update_channel( } +@router.post("/{channel_id}/reset-backfill") +def reset_backfill( + channel_id: str, + user: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + """Admin-only reset: re-fetch this channel from scratch regardless of its current sync + state (a "reset" trigger). Clears the channel's backfill markers, opts it back into deep + backfill, and re-runs its recent pull immediately; the deep scheduler re-pages the full + back-catalog on its next run. Idempotent — videos upsert by id, so nothing duplicates.""" + channel = db.get(Channel, channel_id) + if channel is None: + raise HTTPException(status_code=404, detail="Unknown channel") + sub = _user_subscription(db, user, channel_id) + channel.backfill_done = False + channel.backfill_cursor = None + channel.recent_synced_at = None + sub.deep_requested = True + db.commit() + with quota.attribute(user.id, "backfill_recent"): + run_recent_backfill(db, [channel], max_channels=1) + return {"id": channel_id, "reset": True} + + @router.delete("/{channel_id}/subscription") def unsubscribe( channel_id: str, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0615f19..e13f097 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -381,6 +381,8 @@ export const api = { id: string, patch: { priority?: number; hidden?: boolean; deep_requested?: boolean } ) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), + resetChannelBackfill: (id: string) => + req(`/api/channels/${id}/reset-backfill`, { method: "POST" }), deepAll: (on = true) => req(`/api/sync/deep-all?on=${on}`, { method: "POST" }), attachChannelTag: (id: string, tagId: number) => From cab700bca53218705377d0a8eb4d30b849a7ef3e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 17 Jun 2026 19:16:23 +0200 Subject: [PATCH 3/3] 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 && (