import type { ComponentProps, ReactNode } from "react"; import type { TFunction } from "i18next"; import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../lib/api"; import { tagPickLabel, type TagList } from "../lib/tagPicks"; import { formatCountOrDash, formatTotalHours } from "../lib/format"; import type { Column } from "./ui/DataTable"; import { PriorityStepper } from "./ui/PriorityStepper"; import ChannelLink from "./ChannelLink"; import AboutCell from "./AboutCell"; import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags"; import { StoredCell } from "./channelCells"; import { isFullySynced } from "./channelSync"; import { RelativeTime } from "./ui/RelativeTime"; import type { FilterChip } from "./TagFilterChips"; /** 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, lang: string, ): 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, lang)} ), }; } /** Sync-status pre-filter for the subscribed table. The Sync COLUMN is gone — 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 manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one * of the user's tags, then each user tag, then the "No tag" sentinel. A stored pick can outlive its * tag (deleted here or on another device) and still narrows the table to nothing, so it stays a * visible, removable chip — FIRST, so the row's "+N" overflow never folds the reason away — * and so does every pick while the tag list loads or after it failed, named by lib/tagPicks' rule * (a placeholder, then the name, "Deleted tag" or the id), not hidden. */ export function tagFilterChips(o: { userTags: Tag[]; tags: TagList; picked: string[]; noTagOn: boolean; toggle: (tagId: number) => void; toggleNoTag: () => void; t: TFunction; }): FilterChip[] { const mine = new Set(o.userTags.map((tg) => String(tg.id))); const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id)); return [ ...others.map((id) => ({ key: `t${id}`, label: tagPickLabel(Number(id), o.tags, o.t), pending: o.tags.isPending, active: true, onToggle: () => o.toggle(Number(id)), })), ...o.userTags.map((tg) => ({ key: `t${tg.id}`, label: tg.name, active: o.picked.includes(String(tg.id)), onToggle: () => o.toggle(tg.id), })), { key: NO_TAG_SENTINEL, label: o.t("channels.tags.noTag"), active: o.noTagOn, onToggle: o.toggleNoTag, }, ]; } /** 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 outside the page component (its cell components are in channelCells). */ export interface ChannelColumnDeps { t: TFunction; /** The UI language, for the numbers — the compiler re-builds the columns when it changes. */ lang: string; prioOptions: { value: string; label: string }[]; userTags: ComponentProps["userTags"]; bumpPriority: (id: string, delta: number) => void; setPriority: (id: string, value: number) => void; /** The row's "…" — the door to the channel's one action list (`useChannelActions`). */ more: (c: ManagedChannel) => ReactNode; onView: (c: ManagedChannel) => void; toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void; createAndAttachTag: (channelId: string, name: string) => void; onFilterByTag: (tagId: number, name: string) => void; // 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, lang, prioOptions, userTags, bumpPriority, setPriority, more, onView, toggleTag, createAndAttachTag, onFilterByTag, suggestionsFor, onAcceptSuggestion, onDismissSuggestion, } = deps; return [ { key: "priority", header: t("channels.cols.priority"), align: "center", // Sized to the longer language's label + the funnel + a sort arrow ("Prioritás ▼ ⧩"): fixed layout // clips a header that outgrows its column instead of widening it. width: "7.5rem", // Off on a tablet-wide table, with About and Length: the row's "…" menu sets the priority too. tabletHidden: true, 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)} hint={t("channels.row.priorityHint")} /> ), }, { 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: 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, tabletHidden: 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 (the retired Sync column folded in). render: (c) => , }, { ...subsColumn(t, lang), width: "6rem" }, { key: "lastUpload", header: t("channels.cols.lastUpload"), nowrap: true, width: "8rem", // "Utolsó feltöltés" + a sort arrow, as the priority column above sortable: true, sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0), render: (c) => , }, { key: "length", header: t("channels.cols.length"), align: "right", nowrap: true, width: "4.5rem", tabletHidden: true, sortable: true, sortValue: (c) => c.total_duration_seconds, render: (c) => ( {formatTotalHours(c.total_duration_seconds, lang)} ), }, { 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)} /> ), }, { key: "actions", header: "", align: "right", // Room for the "…" at 44px on touch (`.touch-target`), not just its 28px under a mouse. width: "3.25rem", nowrap: true, cardLabel: false, // The row's full-history, hide and unsubscribe buttons went into the menu: the row // keeps what is read or tuned all the time — priority, tags, the ✓ — and ends in its "…". render: more, }, ]; }