diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index fe388946..6105c7c9 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -2,31 +2,17 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; -import { - Ban, - Check, - Eye, - EyeOff, - History, - LayoutGrid, - Pencil, - RefreshCw, - Table, - UserMinus, - X, -} from "lucide-react"; +import { Ban, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react"; import { api, type ManagedChannel } from "../lib/api"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; -import { formatEta, formatTotalHours, relativeTime } from "../lib/format"; -import { subsColumn } from "./channelColumns"; +import { formatEta } from "../lib/format"; +import { buildChannelColumns } from "./channelColumns"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; -import { PriorityStepper } from "./PriorityStepper"; -import DataTable, { type Column } from "./DataTable"; +import DataTable from "./DataTable"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; import { LoadingState, StateMessage } from "./QueryState"; -import { ChannelTagsControl } from "./ChannelTags"; import { useChannelTagActions } from "../lib/useChannelTagActions"; import { useChannelPriority } from "../lib/useChannelPriority"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; @@ -36,8 +22,6 @@ import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout"; import { sortRows } from "../lib/columnSort"; import { useCardPager } from "../lib/useCardPager"; import { PageToolbar } from "./PageShell"; -import ChannelLink from "./ChannelLink"; -import AboutCell from "./AboutCell"; import ChannelDiscovery from "./ChannelDiscovery"; import TagManager from "./TagManager"; import { useConfirm } from "./ConfirmProvider"; @@ -284,159 +268,23 @@ export default function Channels() { .sort((a, b) => b - a) .map((p) => ({ value: String(p), label: String(p) })); - const columns: Column[] = [ - { - key: "priority", - header: t("channels.cols.priority"), - align: "center", - width: "56px", - sortable: true, - hideInCard: true, - sortValue: (c) => c.priority, - filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v }, - render: (c) => ( - bumpPriority(c.id, d)} - onSet={(v) => setPriority(c.id, v)} - /> - ), - }, - { - key: "actions", - header: t("channels.cols.actions"), - align: "left", - 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)} - /> - ), - }, - { - 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, - // Cap the width so a long-titled channel can't stretch the column (table-layout:auto ignores a - // cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses). - // Without this a single long name blows the column ~289→429px and overflows the centered table. - render: (c) => ( -
- onView(c)} - /> -
- ), - }, - { - key: "about", - header: t("channels.cols.about"), - hideInCard: true, - render: (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()} - ), - }, - subsColumn(t), - { - 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) => ( - - {formatTotalHours(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, - filter: { - kind: "select", - options: [ - { value: "fully", label: t("channels.filters.fullySynced") }, - { value: "needsFull", label: t("channels.filters.needsFull") }, - ], - test: (c, v) => - v === "fully" - ? c.recent_synced && c.backfill_done - : v === "needsFull" - ? !c.backfill_done - : true, - }, - render: (c) => , - }, - { - key: "tags", - header: t("channels.cols.tags"), - cardLabel: false, - // Bounded width so the cell can't widen the column and reflow the whole table; the control - // fills it and collapses overflow into "+N" (see ChannelTagsControl). - width: "12rem", - filter: { - kind: "multi", - options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })), - test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))), - }, - render: (c) => ( - toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} - onCreate={(name) => createAndAttachTag(c.id, name)} - onChipClick={onFilterByTag} - /> - ), - }, - ]; + const columns = buildChannelColumns({ + t, + isAdmin, + canWrite, + prioOptions, + userTags, + bumpPriority, + setPriority, + onHide: (c) => patch.mutate({ id: c.id, body: { hidden: !c.hidden } }), + onDeep: (c) => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }), + onReset, + onUnsub, + onView, + toggleTag, + createAndAttachTag, + onFilterByTag, + }); // Status chips sit in the table's controls row (next to paging) — compact and close to // where they act. @@ -762,144 +610,3 @@ function Stat({ label, value, hint }: { label: string; value: string | number; h ); } - -function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) { - return ( - - - {ok && } - {label} - - - ); -} - -// 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 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 && ( - - - - )} -
- ); -} diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index bac1cb57..856876c5 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -1,6 +1,15 @@ +import type { ComponentProps } from "react"; import type { TFunction } from "i18next"; -import { formatCountOrDash } from "../lib/format"; +import { useTranslation } from "react-i18next"; +import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react"; +import type { ManagedChannel } from "../lib/api"; +import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format"; import type { Column } from "./DataTable"; +import Tooltip from "./Tooltip"; +import { PriorityStepper } from "./PriorityStepper"; +import ChannelLink from "./ChannelLink"; +import AboutCell from "./AboutCell"; +import { ChannelTagsControl } from "./ChannelTags"; /** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical * in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different @@ -18,3 +27,338 @@ export function subsColumn(t: TFu ), }; } + +function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) { + return ( + + + {ok && } + {label} + + + ); +} + +// 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 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 && ( + + + + )} +
+ ); +} + +/** Everything the Channels-manager column set closes over. The manager owns the state + mutations; + * this factory just wires them into `Column` render/filter/sort defs so the ~150-line + * column block lives next to its cells instead of inside the page component. */ +export interface ChannelColumnDeps { + t: TFunction; + isAdmin: boolean; + canWrite: boolean; + prioOptions: { value: string; label: string }[]; + userTags: ComponentProps["userTags"]; + bumpPriority: (id: string, delta: number) => void; + setPriority: (id: string, value: number) => void; + onHide: (c: ManagedChannel) => void; + onDeep: (c: ManagedChannel) => void; + onReset: (c: ManagedChannel) => void; + onUnsub: (c: ManagedChannel) => void; + onView: (c: ManagedChannel) => void; + toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void; + createAndAttachTag: (channelId: string, name: string) => void; + onFilterByTag: (tagId: number, name: string) => void; +} + +export function buildChannelColumns(deps: ChannelColumnDeps): Column[] { + const { + t, + isAdmin, + canWrite, + prioOptions, + userTags, + bumpPriority, + setPriority, + onHide, + onDeep, + onReset, + onUnsub, + onView, + toggleTag, + createAndAttachTag, + onFilterByTag, + } = deps; + return [ + { + key: "priority", + header: t("channels.cols.priority"), + align: "center", + width: "56px", + sortable: true, + hideInCard: true, + sortValue: (c) => c.priority, + filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v }, + render: (c) => ( + bumpPriority(c.id, d)} + onSet={(v) => setPriority(c.id, v)} + /> + ), + }, + { + key: "actions", + header: t("channels.cols.actions"), + align: "left", + nowrap: true, + width: "104px", + cardLabel: false, + render: (c) => ( + onHide(c)} + onDeep={() => onDeep(c)} + onReset={() => onReset(c)} + onUnsubscribe={() => onUnsub(c)} + /> + ), + }, + { + 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, + // Cap the width so a long-titled channel can't stretch the column (table-layout:auto ignores a + // cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses). + // Without this a single long name blows the column ~289→429px and overflows the centered table. + render: (c) => ( +
+ onView(c)} + /> +
+ ), + }, + { + key: "about", + header: t("channels.cols.about"), + hideInCard: true, + render: (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()} + ), + }, + subsColumn(t), + { + 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) => ( + + {formatTotalHours(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, + filter: { + kind: "select", + options: [ + { value: "fully", label: t("channels.filters.fullySynced") }, + { value: "needsFull", label: t("channels.filters.needsFull") }, + ], + test: (c, v) => + v === "fully" + ? c.recent_synced && c.backfill_done + : v === "needsFull" + ? !c.backfill_done + : true, + }, + render: (c) => , + }, + { + key: "tags", + header: t("channels.cols.tags"), + cardLabel: false, + // Bounded width so the cell can't widen the column and reflow the whole table; the control + // fills it and collapses overflow into "+N" (see ChannelTagsControl). + width: "12rem", + filter: { + kind: "multi", + options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })), + test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))), + }, + render: (c) => ( + toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} + onCreate={(name) => createAndAttachTag(c.id, name)} + onChipClick={onFilterByTag} + /> + ), + }, + ]; +}