import type { ComponentProps } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react"; import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api"; import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format"; import type { Column } from "./ui/DataTable"; import Tooltip from "./ui/Tooltip"; import { PriorityStepper } from "./ui/PriorityStepper"; import ChannelLink from "./ChannelLink"; import AboutCell from "./AboutCell"; import { ChannelTagsControl, type SuggestedTag } 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 * channel row shapes. */ export function subsColumn(t: TFunction): Column { return { key: "subs", header: t("channels.cols.subs"), align: "right", nowrap: true, sortable: true, sortValue: (c) => c.subscriber_count ?? -1, render: (c) => ( {formatCountOrDash(c.subscriber_count)} ), }; } // A channel is "fully synced" when its recent uploads AND full back-catalogue are both in — the // predicate the retired Sync column used, now surfaced as a ✓ charm on the Stored count and driven // by the top sync-filter chips. const isFullySynced = (c: ManagedChannel) => c.recent_synced && c.backfill_done; /** Sync-status pre-filter for the subscribed table. The Sync COLUMN is gone (R14) — its status folded * into a charm on Stored — so the top chips' `filters.sync` value is applied here instead of via a * column predicate. Same vocab as the old column filter: "fully" | "needsFull" | "" (all). */ export function applySyncFilter(rows: ManagedChannel[], value: unknown): ManagedChannel[] { if (value === "fully") return rows.filter(isFullySynced); if (value === "needsFull") return rows.filter((c) => !c.backfill_done); return rows; } /** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't * personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that), * so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean { if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0; return values.some((v) => c.tag_ids.includes(Number(v))); } // The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and // full history are both in — folding the retired Sync column's at-a-glance status into the value it // describes. Full detail stays in the hover tooltip. function StoredCell({ c }: { c: ManagedChannel }) { const { t } = useTranslation(); return ( {isFullySynced(c) && ( )} {c.stored_videos.toLocaleString()} ); } 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; // E8·S4 — suggested user tags per channel (resolved to Tag + confidence), plus accept/dismiss. suggestionsFor: (channelId: string) => SuggestedTag[]; onAcceptSuggestion: (channelId: string, tagId: number) => void; onDismissSuggestion: (channelId: string, tagId: number) => void; } export function buildChannelColumns(deps: ChannelColumnDeps): Column[] { const { t, isAdmin, canWrite, prioOptions, userTags, bumpPriority, setPriority, onHide, onDeep, onReset, onUnsub, onView, toggleTag, createAndAttachTag, onFilterByTag, suggestionsFor, onAcceptSuggestion, onDismissSuggestion, } = deps; return [ { key: "priority", header: t("channels.cols.priority"), align: "center", width: "5.5rem", 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: "5.5rem", 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, // A FLUID column (R14): no width cap. Under the manager table's fixed layout this cell takes a // share of the leftover space and ChannelLink truncates the name to fit (full name on hover via // title), so a long name can't push the table wider than its container at any zoom. render: (c) => (
onView(c)} />
), }, { key: "about", header: t("channels.cols.about"), hideInCard: true, // The other FLUID column — AboutCell already truncates to its cell. render: (c) => , }, { key: "stored", header: t("channels.cols.stored"), align: "right", nowrap: true, width: "5rem", sortable: true, sortValue: (c) => c.stored_videos, // The stored count carries the "fully synced" ✓ charm (R14 folded the retired Sync column in). render: (c) => , }, { ...subsColumn(t), width: "6rem" }, { key: "lastUpload", header: t("channels.cols.lastUpload"), nowrap: true, width: "6rem", 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, width: "4.5rem", sortable: true, sortValue: (c) => c.total_duration_seconds, render: (c) => ( {formatTotalHours(c.total_duration_seconds)} ), }, { 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: "9rem", filter: { kind: "multi", options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })), test: tagsColumnTest, }, render: (c) => ( toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} onCreate={(name) => createAndAttachTag(c.id, name)} onChipClick={onFilterByTag} suggestions={suggestionsFor(c.id)} onAcceptSuggestion={(tagId) => onAcceptSuggestion(c.id, tagId)} onDismissSuggestion={(tagId) => onDismissSuggestion(c.id, tagId)} /> ), }, ]; }