"use client"; import { useMemo, useState, type ReactNode, type Ref } from "react"; import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow"; import { bandSentence, type OperationBand, } from "yt-dlp-transcript-common/views/pipeline/band"; import type { ChannelGroupSectionView } from "yt-dlp-transcript-common/views/channelGroupSections"; import { tierOrder } from "yt-dlp-transcript-common/lib/channelPriority"; import { StateBand } from "../../components/pipelines/StateBand"; import { ChannelGroupHeaderRow } from "./ChannelGroupHeaderRow"; import { CHANNEL_COLUMNS, type ChannelCellCtx, type ChannelColumnId, type ChannelColumnLabels, type ChannelRowExtras, type ChannelSortKey, type PipelineColumn, } from "./channelColumns"; import { RACK_BRIDGE, RACK_IDENTITY, RACK_LAYERS } from "./rackLayout"; // ONE CHANNEL TABLE — the /channels rack, the dashboard's channels table and // every operation page's work section draw their rows here, off the one // `ChannelRowView` (common/views/channelRow.ts), with whichever columns the // caller names from the registry in ./channelColumns.tsx. The rack's chrome — // focus bar, volume bar, instrument bar, scroll region, selection deck — is // ./ChannelsRack.tsx; this file is the thead, the tbodies, the rows and the // sort. // // Rows are a PROP, never state: the global AutoRefresh re-renders the server // page and hands down fresh rows, and a table that copied them into state would // draw the first render's numbers forever. type SortDir = "asc" | "desc"; export type ChannelSortState = { key: ChannelSortKey; dir: SortDir } | null; const DEFAULT_DIR: Record = { slug: "asc", name: "asc", handling: "asc", build: "asc", tier: "asc", playlist: "desc", lastSync: "asc", // Ascending, and missing dates sort as oldest: the first click puts the // channels whose numbers cannot be trusted at the top. report: "asc", location: "asc", // THE ONLY REASON TO SORT BY SIZE is to find what is worth moving, so the // first click is biggest-first. (Descending is the record's default anyway; // it is stated here because this column is the page's whole storage story and // an implicit default is a thing to get wrong later.) size: "desc", }; // A pipeline column defaults to `reachable` descending: the first click puts the // channels with work at the top, which is the only reason to sort by a pipeline // at all. export function defaultDirFor(key: ChannelSortKey): SortDir { return DEFAULT_DIR[key] ?? "desc"; } function bandOf(row: ChannelRowView, id: string): OperationBand | undefined { return row.pipelines.find((b) => b.id === id); } function compareStrings(a: string | undefined, b: string | undefined): number { const aHas = a !== undefined && a !== ""; const bHas = b !== undefined && b !== ""; if (!aHas && !bHas) return 0; if (!aHas) return -1; if (!bHas) return 1; return (a as string).localeCompare(b as string); } function compareNumbers( a: number | null | undefined, b: number | null | undefined, ): number { const aHas = a !== null && a !== undefined; const bHas = b !== null && b !== undefined; if (!aHas && !bHas) return 0; if (!aHas) return -1; if (!bHas) return 1; return (a as number) - (b as number); } function compareDates( a: string | null | undefined, b: string | null | undefined, ): number { // Missing dates sort as oldest (so stale channels surface first on asc). const aHas = !!a; const bHas = !!b; if (!aHas && !bHas) return 0; if (!aHas) return -1; if (!bHas) return 1; return new Date(a as string).getTime() - new Date(b as string).getTime(); } // Unranked sorts AFTER every ranked sibling, which is `orderWithin`'s rule in // the compiler — not compareNumbers', which sorts a missing value first. function compareRanks(a: number | null, b: number | null): number { if (a === b) return 0; if (a === null) return 1; if (b === null) return -1; return a - b; } function compareBools(a: boolean, b: boolean): number { // false (included) < true (excluded), so "Included" sorts first on asc. return (a ? 1 : 0) - (b ? 1 : 0); } function cmp(a: ChannelRowView, b: ChannelRowView, key: ChannelSortKey): number { if (key.startsWith("op:")) { const id = key.slice(3); return compareNumbers(bandOf(a, id)?.reachable, bandOf(b, id)?.reachable); } switch (key) { case "slug": return a.slug.localeCompare(b.slug); case "name": return compareStrings(a.name, b.name); case "handling": return compareStrings(a.handling, b.handling); case "build": return compareBools(a.excludeFromBuild, b.excludeFromBuild); case "tier": // THE COMPILED ORDER, not the stored one: focus is a position the focus // selector produces, so a focused channel sorts above every normal one // exactly as it does in the lane tree. Then rank (unranked last), then // slug — the same three keys `compileLaneRoot` orders a group by. return ( tierOrder(a.priority.focused ? "focus" : a.priority.tier) - tierOrder(b.priority.focused ? "focus" : b.priority.tier) || compareRanks(a.priority.rank, b.priority.rank) || a.slug.localeCompare(b.slug) ); case "playlist": return compareNumbers(a.playlistCount, b.playlistCount); case "lastSync": return compareDates(a.lastSyncedAt, b.lastSyncedAt); case "report": return compareDates(a.report.generatedAt, b.report.generatedAt); case "location": // By LABEL, then slug: the operator reads names, and a stable tiebreak // keeps the two halves of a volume from shuffling between renders. return ( a.volumeLabel.localeCompare(b.volumeLabel) || a.slug.localeCompare(b.slug) ); case "size": // compareNumbers sorts a missing value FIRST, which on descending (the // default here) puts the unmeasured channels last — behind everything // whose size is known, which is where a row that cannot be ranked // belongs. return compareNumbers(a.mediaBytes, b.mediaBytes); default: return 0; } } // Pure, and module-level so the grouped render can apply the SAME sort state // independently to each section — which is what "sorts within each group" means. function applySort( list: ReadonlyArray, sort: ChannelSortState, ): ReadonlyArray { if (!sort) return list; const sorted = [...list].sort((a, b) => cmp(a, b, sort.key)); return sort.dir === "desc" ? sorted.reverse() : sorted; } export type ChannelSelection = { selected: ReadonlySet; onToggle: (slug: string) => void; onToggleAll: (checked: boolean) => void; }; export function ChannelsTable({ rows: channels, columns, pipelineColumns = [], rowLabelPrefix, rowExtras, labels = {}, sort: sortConfig, selection, sections = null, siteId, sticky = false, theadRef, now = null, }: { rows: ChannelRowView[]; // Which columns, in order, by registry id (./channelColumns.tsx). columns: readonly ChannelColumnId[]; // What the `pipelines` column expands into, in group order, resolved on the // server from the live settings. A switched-off feature is simply absent — // which is the honest rendering: an empty column because nobody enabled // attribution is not the same as an attribution lane with nothing left. pipelineColumns?: PipelineColumn[]; // `` — the dashboard's `channel row`, // a work section's ` row`. The rack's rows carry none. rowLabelPrefix?: string; // Per-row data (and rendered elements) a server caller adds; see // ChannelRowExtras. rowExtras?: Record; labels?: ChannelColumnLabels; // False: plain headers, rows in the order given. Otherwise the header // buttons sort (client state — a router.replace races the AutoRefresh), from // `initialKey` when the URL asked for one (`?sort=size`). sort: false | { initialKey?: ChannelSortKey | null }; // False: no checkbox column. The rack owns the set (its deck acts on it). selection: false | ChannelSelection; // One tbody per group, each with its header row. Null (or no siteId) is the // flat table: groups only partition a SINGLE site's channels. sections?: ChannelGroupSectionView[] | null; siteId?: string; // The rack: the thead pins to the scroll region's top and the checkbox and // Slug cells pin to its left. The caller owns the scroll region. sticky?: boolean; theadRef?: Ref; // The live clock for relative dates; null until mounted. now?: number | null; }) { const initialKey = sortConfig ? (sortConfig.initialKey ?? null) : null; const [sort, setSort] = useState( initialKey ? { key: initialKey, dir: defaultDirFor(initialKey) } : null, ); const sortable = sortConfig !== false; const rows = useMemo(() => applySort(channels, sort), [channels, sort]); // A section lists ChannelStats; the row the page built carries the same // slug. Joined by slug, never by index — pairing one channel's counts with // another channel's bands is exactly the silent wrongness the row type // exists to remove. const rowBySlug = useMemo( () => new Map(channels.map((c) => [c.slug, c])), [channels], ); const showSections = !!sections && sections.length > 0 && !!siteId; const visibleColumns = columns.filter( (id) => id !== "select" || selection !== false, ); // Computed from what is drawn, never a literal: every column counts one, // except `pipelines`, which counts one per pipeline. const colSpan = visibleColumns.reduce( (n, id) => n + (id === "pipelines" ? pipelineColumns.length : 1), 0, ); const allSelected = selection !== false && channels.length > 0 && channels.every((c) => selection.selected.has(c.slug)); function onHeaderClick(key: ChannelSortKey) { setSort((prev) => { if (prev && prev.key === key) { return { key, dir: prev.dir === "asc" ? "desc" : "asc" }; } return { key, dir: defaultDirFor(key) }; }); } const pad = sticky ? "px-2 py-1.5" : "px-3 py-2"; const head: ReactNode[] = []; for (const id of visibleColumns) { if (id === "select") { head.push( selection !== false && selection.onToggleAll(e.target.checked) } className="accent-primary" /> , ); continue; } if (id === "pipelines") { // THE METER BRIDGE. Six loose grey dashes become one block: the band // columns share an eyebrow naming them, and the rules that open and // close the block run the full height of the rack. pipelineColumns.forEach((col, i) => { const className = RACK_BRIDGE + (i === 0 ? " border-l border-border" : "") + (i === pipelineColumns.length - 1 ? " border-r border-border" : ""); head.push( sortable ? ( ) : ( {col.shortLabel} ), ); }); continue; } const col = CHANNEL_COLUMNS[id]; const label = labels[id] ?? col.label; const className = [ col.th?.className ?? "", sticky ? (col.th?.stickyClassName ?? "") : "", ] .filter(Boolean) .join(" "); if (sortable && col.sortKey) { head.push( , ); } else { head.push( {label} , ); } } const renderRow = (c: ChannelRowView) => ( ); const table = ( {head} {showSections ? ( // One sort state, applied independently per section — which IS // "sorts within each group". sections.map((section) => ( {applySort( section.channels.flatMap((c) => rowBySlug.get(c.slug) ?? []), sort, ).map(renderRow)} )) ) : ( {rows.map(renderRow)} )}
); if (sticky) return table; return (
{table}
); } // ONE CELL OF THE STRIP. // // THE SIGNATURE OF THIS TABLE: six bands across, 68 rows down, and what you // actually see is a vertical stripe of identical texture running the height of // the page — because on this corpus every channel IS in the same state. That // uniformity is the finding, not a failure of the chart. A channel that breaks // the stripe (audio still retained, digests actually done) is the only thing // your eye lands on, and finding it used to mean opening 68 pages. // // NO PERCENTAGE TEXT, deliberately. Digest is at 0 done on every large channel // and diarization is 99.96% media-gone, so "% complete" renders 0% on all 68 // rows and says nothing. What varies is the SHAPE of the remainder, which is // what the band draws. The exact figures live in `title` and in the cell's own // text, so nothing is hidden — it is just not repeated 408 times. function PipelineCell({ slug, column, band, count, className = "", pad, }: { slug: string; column: PipelineColumn; band: OperationBand | undefined; // The bare number for a column that already had one. THE SUITE ASSERTS ON // THIS: channels-counts.spec.ts does getByLabel("transcripts count for X") // .toHaveText("3"), so the labelled cell keeps a text node holding the raw // count. It moves to sr-only beside an aria-hidden band, which is strictly // better than the bare it replaces — a screen reader now gets the number // AND a label saying what it counts. count: number | null; // The bridge's own chrome: the surface it sits on and the rules that open and // close the block. Owned by the row, because only the row knows which cell is // first and which is last. className?: string; pad: string; }) { if (!band) return ( — ); const sentence = bandSentence(band); return ( {count === null ? sentence : String(count)} ); } // One channel's row, shared by the flat and the sectioned render — and by // every table that draws a channel — so they can never drift apart // column-for-column. function ChannelTableRow({ channel: c, columns, pipelineColumns, selection, extras, rowLabelPrefix, sticky, now, }: { channel: ChannelRowView; columns: readonly ChannelColumnId[]; pipelineColumns: PipelineColumn[]; selection: false | ChannelSelection; extras: ChannelRowExtras | undefined; rowLabelPrefix: string | undefined; sticky: boolean; now: number | null; }) { const selected = selection !== false && selection.selected.has(c.slug); // A sticky cell paints over the row behind it, so it cannot inherit the row's // background — it has to carry the same one explicitly or the rows would show // through the pinned identity column while the rest scrolls. const stickyBg = selected ? "bg-accent" : "bg-background"; const bridge = `bg-surface ${RACK_BRIDGE}`; const pad = sticky ? "px-2 py-1.5" : "px-3 py-2"; // Dimmed for the two things that take the row out of a pipeline: it is // excluded from the export build, or its base tier is Paused. (The sync // exclusion flag that used to dim it is now a `sync` pin, which is a // per-operation fact and not a property of the whole row.) // // THE DIM IS PER CELL, NEVER ON THE ``. `opacity` below 1 creates a // STACKING CONTEXT, and a stacking context confines every positioned // descendant to it: put `opacity-60` on the row and the Tier cell's // `absolute` Advanced popover (ChannelTierSelect, `RACK_LAYERS.popover`) can no longer paint // above the rows that follow, however high its z-index — every later row // draws over it and swallows the clicks. So the Tier cell — the one that // hosts the popover — is the one cell that is NOT dimmed (its registry entry // says `dim: false`). Moving this back onto the row re-breaks the menu // (operator report 2026-09-17). // // RACK ONLY. The dashboard and the work tables never dimmed a row: their // priority is neutral, and build exclusion only affects the export — dimming // a channel in an "undownloaded" list would read as "out of the pipeline" // when it is not. const dim = sticky && (c.excludeFromBuild || c.priority.tier === "paused") ? " opacity-60" : ""; const ctx: ChannelCellCtx = { dim, stickyBg, sticky, selected, onToggle: selection === false ? () => {} : selection.onToggle, extras, now, }; const cells: ReactNode[] = []; for (const id of columns) { if (id === "select") { cells.push( ctx.onToggle(c.slug)} className="accent-primary" /> , ); continue; } if (id === "pipelines") { pipelineColumns.forEach((col, i) => { cells.push( , ); }); continue; } const col = CHANNEL_COLUMNS[id]; const cell = col.cell(c, ctx); cells.push( {cell.content} , ); } return ( {cells} ); } function SortableTh({ label, sortKey, sort, onClick, align = "left", className = "", title, eyebrow, }: { label: string; sortKey: ChannelSortKey; sort: ChannelSortState; onClick: (key: ChannelSortKey) => void; align?: "left" | "right"; className?: string; title?: string; // Names the BLOCK this header opens, not the column: the first band header // carries "Pipeline" for the six that follow it. Every header cell is // bottom-aligned so the extra line never pushes its neighbours' labels out of // line. eyebrow?: string; }) { const active = sort?.key === sortKey; const indicator = active ? (sort?.dir === "asc" ? "▲" : "▼") : ""; const justify = align === "right" ? "justify-end" : "justify-start"; const textAlign = align === "right" ? "text-right" : "text-left"; return ( {eyebrow && ( {eyebrow} )} ); } function Td({ children, className = "", ariaLabel, title, pad, suppressHydrationWarning, }: { children: ReactNode; className?: string; ariaLabel?: string; title?: string; pad: string; // Locale-formatted dates: the server's locale/timezone need not be the // browser's (a Docker runtime is UTC), as JobRow's date cells already allow. suppressHydrationWarning?: boolean; }) { return ( {children} ); }