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/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 && ( + {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/channels.json b/frontend/src/i18n/locales/de/channels.json index f57b703..94bfd9a 100644 --- a/frontend/src/i18n/locales/de/channels.json +++ b/frontend/src/i18n/locales/de/channels.json @@ -1,7 +1,7 @@ { "intro": "Lege die <0>Priorität eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags an, um den Feed zu filtern, oder <2>blende einen Kanal <2>aus, um ihn ohne Abbestellung aus dem Feed zu entfernen.", "filterPlaceholder": "Kanäle filtern…", - "syncSubscriptions": "Abos synchronisieren", + "syncSubscriptions": "Abos von YouTube lesen", "syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.", "backfillEverything": "Alles nachladen", "backfillEverythingHint": "Fordert das vollständige Nachladen des gesamten Katalogs für jeden abonnierten Kanal an. Ältere Videos und die Suche werden vollständig, soweit das gemeinsame Tageskontingent es zulässt — das kann eine Weile dauern.", @@ -19,6 +19,20 @@ }, "loading": "Kanäle werden geladen…", "empty": "Keine Kanäle.", + "pager": { + "prev": "Zurück", + "next": "Weiter", + "page": "Seite {{page}} von {{total}}" + }, + "cols": { + "priority": "Prio", + "channel": "Kanal", + "stored": "Gespeichert", + "subs": "Abonnenten", + "sync": "Sync", + "tags": "Tags", + "actions": "Aktionen" + }, "stats": { "channels": "Kanäle", "channelsHint": "Kanäle, die du abonniert hast.", @@ -45,6 +59,13 @@ "recentNotSyncedHint": "Neueste Uploads noch nicht geladen.", "full": "vollständig", "fullHint": "Vollständiger Verlauf geladen.", + "fullNotFetchedHint": "Das vollständige Archiv ist noch nicht geladen — nutze die Backfill-Aktion in der Spalte Aktionen.", + "fullySynced": "vollständig synchron", + "fullySyncedHint": "Aktuelle Uploads und das vollständige Archiv sind beide vorhanden.", + "backfillThis": "Diesen Kanal vollständig laden", + "backfillThisHint": "Das vollständige Archiv dieses Kanals laden (ältere Videos + komplette Suche). Erneut klicken, um die Anfrage zurückzuziehen.", + "resetBackfill": "Kanal zurücksetzen & neu laden", + "resetHint": "Admin: diesen Kanal von Grund auf neu laden (aktuelle + vollständiges Archiv), unabhängig vom aktuellen Sync-Status.", "fullHistoryQueued": "vollständiger Verlauf in Warteschlange", "fullHistoryComing": "vollständiger Verlauf unterwegs", "queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.", @@ -67,7 +88,10 @@ "fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert", "fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden", "needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.", - "connect": "Verbinden" + "connect": "Verbinden", + "resetDone": "Kanal zurückgesetzt — wird neu geladen", + "resetFailed": "Kanal konnte nicht zurückgesetzt werden" }, - "confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus." + "confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.", + "confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent." } 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/channels.json b/frontend/src/i18n/locales/en/channels.json index 0adc427..f5adebd 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -1,7 +1,7 @@ { "intro": "Set a channel's <0>priority to push its videos up when you sort by “Channel priority”, attach your own <1>tags to filter the feed, or <2>hide a channel to drop it from the feed without unsubscribing.", "filterPlaceholder": "Filter channels…", - "syncSubscriptions": "Sync subscriptions", + "syncSubscriptions": "Read subscriptions from YouTube", "syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.", "backfillEverything": "Backfill everything", "backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while.", @@ -19,6 +19,20 @@ }, "loading": "Loading channels…", "empty": "No channels.", + "pager": { + "prev": "Previous", + "next": "Next", + "page": "Page {{page}} of {{total}}" + }, + "cols": { + "priority": "Prio", + "channel": "Channel", + "stored": "Stored", + "subs": "Subscribers", + "sync": "Sync", + "tags": "Tags", + "actions": "Actions" + }, "stats": { "channels": "Channels", "channelsHint": "Channels you're subscribed to.", @@ -45,6 +59,13 @@ "recentNotSyncedHint": "Recent uploads not fetched yet.", "full": "full", "fullHint": "Full history fetched.", + "fullNotFetchedHint": "Full back-catalog not fetched yet — use the backfill action in the Actions column.", + "fullySynced": "fully synced", + "fullySyncedHint": "Recent uploads and the full back-catalog are both in.", + "backfillThis": "Backfill this channel", + "backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.", + "resetBackfill": "Reset & re-fetch this channel", + "resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.", "fullHistoryQueued": "full history queued", "fullHistoryComing": "full history coming", "queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.", @@ -67,7 +88,10 @@ "fullHistoryRequested": "Full history requested for {{count}} channels", "fullHistoryFailed": "Couldn't request full history", "needYouTube": "Connect your YouTube account to do this.", - "connect": "Connect" + "connect": "Connect", + "resetDone": "Channel reset — re-fetching now", + "resetFailed": "Couldn't reset this channel" }, - "confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead." + "confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.", + "confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota." } 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/channels.json b/frontend/src/i18n/locales/hu/channels.json index fc36ce7..802bb13 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -1,7 +1,7 @@ { "intro": "Állíts be egy csatornának <0>prioritást, hogy a videói előrébb kerüljenek, amikor „Csatorna prioritás” szerint rendezel, csatolj saját <1>címkéket a hírfolyam szűréséhez, vagy <2>rejts el egy csatornát, hogy leiratkozás nélkül kivedd a hírfolyamból.", "filterPlaceholder": "Csatornák szűrése…", - "syncSubscriptions": "Feliratkozások szinkronizálása", + "syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról", "syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.", "backfillEverything": "Minden letöltése", "backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.", @@ -19,6 +19,20 @@ }, "loading": "Csatornák betöltése…", "empty": "Nincsenek csatornák.", + "pager": { + "prev": "Előző", + "next": "Következő", + "page": "{{page}}. oldal / {{total}}" + }, + "cols": { + "priority": "Prio", + "channel": "Csatorna", + "stored": "Tárolt", + "subs": "Feliratkozók", + "sync": "Szinkron", + "tags": "Címkék", + "actions": "Műveletek" + }, "stats": { "channels": "Csatorna", "channelsHint": "Csatornák, amelyekre feliratkoztál.", @@ -45,6 +59,13 @@ "recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.", "full": "teljes", "fullHint": "Teljes előzmény letöltve.", + "fullNotFetchedHint": "A teljes archívum még nincs letöltve — használd a Műveletek oszlop backfill gombját.", + "fullySynced": "teljesen szinkronban", + "fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.", + "backfillThis": "Csatorna teljes letöltése", + "backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés). Újra kattintva visszavonod a kérést.", + "resetBackfill": "Csatorna resetelése és újraletöltése", + "resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.", "fullHistoryQueued": "teljes előzmény sorban", "fullHistoryComing": "teljes előzmény úton", "queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.", @@ -67,7 +88,10 @@ "fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához", "fullHistoryFailed": "Nem sikerült teljes előzményt kérni", "needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.", - "connect": "Csatlakoztatás" + "connect": "Csatlakoztatás", + "resetDone": "Csatorna resetelve — újraletöltés folyamatban", + "resetFailed": "Nem sikerült resetelni a csatornát" }, - "confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el." + "confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.", + "confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt." } 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}}" + } +} 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) =>