Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 91e4583e2c1517cc7b2f7ff9f24c758724f60427
parent a4dfb0cd256b886b0344490a694ecdad0c5964c3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 24 Sep 2026 12:59:04 -0400

channels: the rack splits into ChannelsRack + a shared ChannelsTable

`ChannelsTable.tsx` is now the one channel table (thead, tbodies, rows,
cells, sort; selection, sections, sticky and row labels are props) and
`ChannelsRack.tsx` is the /channels chrome around it (focus bar, volume bar,
instrument bar, the scroll region and its --thead-h observer, selection
state + free-up, the deck outside the scroll box). Cells live in a client
column REGISTRY (`channelColumns.tsx`, ids + presets), because a server
shell cannot pass `cell: (row) => ReactNode`. The page builds its rows with
`buildChannelRowView`, so no channel config reaches the client row any more.
The group header's colSpan is counted from the drawn columns.

Rack invariants kept verbatim: sticky classes, the meter bridge, dim per
cell (never the <tr>) with Tier undimmed, `?sort=size`, selection ∩ visible,
one thead row, every accessible name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Aeditor/app/channels/components/ChannelsRack.tsx | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/components/ChannelsTable.tsx | 1005+++++++++++++++++++++++++++++--------------------------------------------------
Aeditor/app/channels/components/channelColumns.tsx | 416+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/page.tsx | 107+++++++++++++++++++++++++------------------------------------------------------
4 files changed, 1067 insertions(+), 711 deletions(-)

diff --git a/editor/app/channels/components/ChannelsRack.tsx b/editor/app/channels/components/ChannelsRack.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow"; +import { BandLegend } from "../../components/pipelines/StateBand"; +import type { ChannelGroupSection } from "yt-dlp-transcript-common/views/channelGroupSections"; +import { ChannelFocusBar, type FocusSite } from "./ChannelFocusBar"; +import { + ChannelSelectionDeck, + type BulkDestination, +} from "./ChannelSelectionDeck"; +import { ChannelVolumeBar, type ChannelVolume } from "./ChannelVolumeBar"; +import { formatBytes } from "yt-dlp-transcript-common/lib/format"; +import { selectToFreeBytes } from "yt-dlp-transcript-common/views/freeUpSelection"; +// A VALUE import from views/, and it is safe: every import in that module is a +// type import, so what reaches the client bundle is two string constants. The +// point is that "internal" and "location" have ONE spelling across /storage +// (which writes the link) and /channels (which reads it). +import { INTERNAL_ROW_ID } from "yt-dlp-transcript-common/views/storage"; +import { ChannelsTable } from "./ChannelsTable"; +import { RACK_COLUMNS, type PipelineColumn } from "./channelColumns"; + +// THE /channels RACK: the page's chrome around the shared channel table — the +// focus line, the volume bar, the instrument bar (grouping, band legend), the +// one scroll region the table pins inside, and the selection deck below it. +// The rows, cells and sort are ./ChannelsTable.tsx, which the dashboard and the +// operation pages' work sections draw too; this component turns every feature +// of it on. + +export function ChannelsRack({ + channels, + columns, + sections = null, + siteId, + mediaDestinations = [], + defaultLocationId = "", + sites = [], + focusLabel = null, + volumes = [], + locationFilter = null, + initialSort = null, +}: { + channels: ChannelRowView[]; + // Which pipelines to draw, 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 to do. + columns: PipelineColumn[]; + // Null under "all sites" (or with no sites configured): groups only partition + // a SINGLE site's channels, so there is no one grouping to render across the + // pool. That path is today's flat table, unchanged. + sections?: ChannelGroupSection[] | null; + siteId?: string; + // The configured storage locations, resolved on the server. The selection + // deck's destination list; empty when none are configured, which leaves the + // deck's free-text root box as the whole control. + mediaDestinations?: BulkDestination[]; + // `settings.storage.defaultLocationId` — which destination the deck opens on. + defaultLocationId?: string; + // Every configured site, for the "Focus site" control. Not the same list as + // the page's scope selector: a focus is corpus-wide, so it can name a site + // whose channels are not the ones on screen. + sites?: FocusSite[]; + // What the focus selector currently names, resolved on the server, or null. + focusLabel?: string | null; + // One entry per storage volume the channels on screen live on, with its free + // space. Rendered ONCE above the rack, never per row — see ChannelVolumeBar. + volumes?: ChannelVolume[]; + // `?location=`, already validated by the server against what is on screen. + locationFilter?: string | null; + // The sort the URL asked for (`?sort=size`, which is how /storage links to a + // "largest first" list). Only the initial value: sorting is client state + // from then on, because a router.replace races the global AutoRefresh. + initialSort?: "size" | null; +}) { + // Slugs ticked for a bulk edit. A Set of SLUGS, not indices, so a + // re-render that reorders or drops a row cannot retarget the selection — + // the same reason SyncConsole keys its selection this way. + const [selected, setSelected] = useState<ReadonlySet<string>>(new Set()); + // Plain component state, deliberately NOT the URL: router.replace races the + // global AutoRefresh's router.refresh() and gets dropped. + const [grouped, setGrouped] = useState(true); + + // Always the intersection with what is on screen: a slug can leave the table + // between renders (a scope change, a deletion), and a bulk edit must not act + // on a row nobody can see. + const selectedSlugs = channels + .map((c) => c.slug) + .filter((s) => selected.has(s)); + // "FREE UP N GB" — the arithmetic the operator was doing by hand. + // + // The rule lives in common/views/freeUpSelection.ts (pure, unit-tested); this + // is the wire between it and the tick boxes. It SELECTS and does not act: + // what moves anything is the deck's existing Move button, with its existing + // destination and its existing per-channel skips. + // + // Always over `channels` — what is on screen — so a volume filter or a site + // scope narrows the proposal exactly as the operator expects. + const [freeUpNote, setFreeUpNote] = useState<string | null>(null); + function freeUp(targetGB: number) { + const result = selectToFreeBytes( + channels.map((c) => ({ + slug: c.slug, + bytes: c.mediaBytes, + inPlace: c.volumeId === INTERNAL_ROW_ID, + // A MARKER IS A GUARANTEED SKIP. The bulk move refuses a channel whose + // media is in transition by name, so proposing one would put a row in + // the deck that cannot move and count its bytes toward a total the + // Move will never deliver. + inTransition: c.media?.status === "in-transition", + })), + targetGB * 1024 ** 3, + ); + setSelected(new Set(result.slugs)); + setFreeUpNote(`${formatBytes(result.bytes)} selected. ${result.note}`); + } + + function toggleOne(slug: string) { + setSelected((prev) => { + const next = new Set(prev); + if (!next.delete(slug)) next.add(slug); + return next; + }); + } + + // A GROUP HEADER PINS UNDER THE COLUMN HEADER, not over it. The offset is the + // thead's real height rather than a guess, because the head grows a line when + // the pipeline block's eyebrow renders and shrinks again when settings switch + // every derived operation off. `top-9` is the fallback the class carries for + // the first paint and for a browser with no ResizeObserver. + const regionRef = useRef<HTMLDivElement>(null); + const theadRef = useRef<HTMLTableSectionElement>(null); + useEffect(() => { + const region = regionRef.current; + const thead = theadRef.current; + if (!region || !thead || typeof ResizeObserver === "undefined") return; + const measure = () => + region.style.setProperty("--thead-h", `${thead.offsetHeight}px`); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(thead); + return () => observer.disconnect(); + }, []); + + // HOW MANY ROWS THE FOCUS IS HOLDING, for the focus line's one-line summary. + // The reason itself stays per-row (each held chip carries the full sentence + // as its screen-reader text); the COUNT is the corpus-wide fact, and stating + // it once above the table is what lets the rows stop repeating the sentence. + const heldCount = channels.filter((c) => c.priority.heldReason).length; + + return ( + // On md+ this is a pane of the page's viewport-height flex column: the + // instrument bar and the deck are fixed chrome and only the region between + // them scrolls. Below md it has no height of its own and the document + // scrolls, exactly as it always has. + <div className="flex min-h-0 flex-1 flex-col"> + <ChannelFocusBar + sites={sites} + focusLabel={focusLabel} + heldCount={heldCount} + /> + <ChannelVolumeBar + volumes={volumes} + active={locationFilter} + onFreeUp={freeUp} + /> + {/* THE INSTRUMENT BAR: what the rack is showing (grouping) on the left, + how to read it (the band legend, and the two-route explainer behind a + disclosure) on the right. Both used to live in an 11px stack UNDER 67 + rows, where the legend for a chart is of no use to anyone. */} + <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1 py-2 text-xs text-muted-foreground"> + {sections && sections.length > 0 ? ( + <label className="flex items-center gap-2"> + <input + type="checkbox" + checked={grouped} + onChange={(e) => setGrouped(e.target.checked)} + className="accent-primary" + /> + Group by section + </label> + ) : ( + <span /> + )} + <div className="flex flex-wrap items-center gap-x-4 gap-y-1"> + <BandLegend /> + {columns.some((c) => c.id.startsWith("attribution-")) && ( + <details className="max-w-prose"> + <summary className="cursor-pointer hover:text-foreground"> + Names&middot;A / Names&middot;T + </summary> + <p className="pt-1"> + Speaker names come from the audio (Names&middot;A) or the + transcript (Names&middot;T) &mdash; two routes to the same + field, counted separately because they cost different things. + </p> + </details> + )} + </div> + </div> + {/* THE RACK. One scroll box: the thead pins to its top, the checkbox and + Slug cells pin to its left, and the sixteen columns move underneath + them. The table itself must NOT clip (`overflow-hidden` would make it + the sticky ancestor and nothing would pin) — the rounded corners are + the region's. */} + <div + ref={regionRef} + className="relative -mx-4 min-h-0 flex-1 overflow-auto border-y border-border md:mx-0 md:rounded-md md:border" + > + <ChannelsTable + rows={channels} + columns={RACK_COLUMNS} + pipelineColumns={columns} + sort={{ initialKey: initialSort }} + selection={{ + selected, + onToggle: toggleOne, + onToggleAll: (checked) => + setSelected( + checked ? new Set(channels.map((c) => c.slug)) : new Set(), + ), + }} + sections={grouped ? sections : null} + siteId={siteId} + sticky + theadRef={theadRef} + /> + </div> + {/* OUTSIDE the overflow box, always: inside it, `sticky bottom-0` pins to + the table's scroll box rather than to the screen, which is what put + the old bar off the bottom of a phone. */} + <ChannelSelectionDeck + slugs={selectedSlugs} + onClear={() => { + setSelected(new Set()); + setFreeUpNote(null); + }} + destinations={mediaDestinations} + defaultLocationId={defaultLocationId} + // THE NOTE, NOT THE CONTROL. The helper itself lives in the volume bar + // (it is about volumes and free space, and it has to be reachable with + // nothing ticked — the deck does not exist then). What belongs down + // here is what the proposal actually came to, beside the Move button + // that will act on it. + freeUpNote={freeUpNote} + /> + </div> + ); +} + diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx @@ -1,125 +1,40 @@ "use client"; -import Link from "next/link"; -import { useEffect, useMemo, useRef, useState } from "react"; -import type { ChannelStat } from "yt-dlp-transcript-common/controller/channels"; +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 { BandLegend, StateBand } from "../../components/pipelines/StateBand"; import type { ChannelGroupSection } 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 { ChannelAvailabilityButton } from "./ChannelAvailabilityButton"; -import { ChannelBuildToggle } from "./ChannelBuildToggle"; -import { ChannelSyncButton } from "./ChannelSyncButton"; -import ChannelTierSelect from "./ChannelTierSelect"; -import { ChannelFocusBar, type FocusSite } from "./ChannelFocusBar"; -import { InlineActionButton } from "../../components/actions/InlineActionButton"; import { - MediaLocationBadge, - type MediaBadgeInput, -} from "../../components/MediaLocationBadge"; -import { - ChannelSelectionDeck, - type BulkDestination, -} from "./ChannelSelectionDeck"; -import { ChannelVolumeBar, type ChannelVolume } from "./ChannelVolumeBar"; -import { formatBytes } from "yt-dlp-transcript-common/lib/format"; -import { selectToFreeBytes } from "yt-dlp-transcript-common/views/freeUpSelection"; -// A VALUE import from views/, and it is safe: every import in that module is a -// type import, so what reaches the client bundle is two string constants. The -// point is that "internal" and "location" have ONE spelling across /storage -// (which writes the link) and /channels (which reads it). -import { INTERNAL_ROW_ID } from "yt-dlp-transcript-common/views/storage"; -import { - tierOrder, - type PriorityOperation, - type StoredChannelTier, -} from "yt-dlp-transcript-common/lib/channelPriority"; - -// One row's share of the channel priority document, resolved on the server. -// The row never reads the model itself: `focused` and `heldReason` are facts -// about the corpus-wide focus selector, which no single row can answer. -export type ChannelRowPriority = { - tier: StoredChannelTier; - rank: number | null; - overrides: Partial<Record<PriorityOperation, StoredChannelTier>>; - focused: boolean; - heldReason: string | null; - // Why the machine paused it, or null. See autoPauseReasonOf. - autoPausedReason: string | null; -}; + CHANNEL_COLUMNS, + type ChannelCellCtx, + type ChannelColumnId, + type ChannelColumnLabels, + type ChannelRowExtras, + type ChannelSortKey, + type PipelineColumn, +} from "./channelColumns"; -// A row is a stat plus its pipeline bands, in column order. The bands are -// projected on the server from the same snapshot the counts come from, so a -// figure in a band and the count beside it cannot disagree. -export type ChannelRow = ChannelStat & { - pipelines: OperationBand[]; - // Its tier, its pins, and whether the active focus is holding it. Replaces - // the row's sync-inclusion flag: one ordered model instead of one boolean. - priority: ChannelRowPriority; - // How old this channel's report is. Every count and every band on this row is - // projected from that report, so its age is the caveat on all of them — which - // is why it belongs beside them rather than on a page of its own. - report: { - generatedAt: string | null; - state: "current" | "stale" | "missing"; - }; - // Where this channel's media physically is, from inspectChannelMedia on the - // server. Null for an in-place channel — the overwhelming majority — so the - // badge column is empty for them and the two that matter stand out. See - // components/MediaLocationBadge.tsx. - media: MediaBadgeInput | null; - // WHICH VOLUME, as an id the filter can name: a location id, "internal" for - // the corpus volume, or "" for a dataDir under a root nobody named. Derived - // on the server from `config.dataDir` — a pure prefix match, never a probe. - volumeId: string; - volumeLabel: string; - // `snapshot.totalMediaBytes`. NULL, not 0, for a report written before the - // field existed: the Size cell renders that as "—" and the sort puts it - // first on ascending, because a 400 GB channel that has not been measured - // must not sort as the smallest thing on the disk. - mediaBytes: number | null; -}; - -// A column heading for one pipeline. Comes off the operation registry on the -// server (shortLabel, costBasis) rather than being abbreviated here — see -// operations.ts. -export type PipelineColumn = { - id: string; - shortLabel: string; - label: string; - costBasis: string; - // The two externally-dispatched pipelines keep their existing count column - // semantics: the cell's text node is the bare number the suite asserts on, - // under this accessible name. The slug is appended by the cell, so this is - // the prefix — `"downloads count"` becomes `"downloads count for foo"`, which - // is the name channels-counts.spec.ts has always addressed these cells by. - // Null for the derived operations, which have never had a count column and - // get the band's sentence as their text. - countLabel: string | null; -}; - -// Sorting a pipeline column sorts by `reachable` — "which channel has the most -// diarizable audio left right now", a question this page has never been able to -// answer without opening 68 channel pages one at a time. -type SortKey = - | "slug" - | "name" - | "handling" - | "build" - | "tier" - | "playlist" - | "lastSync" - | "report" - | "location" - | "size" - | `op:${string}`; +// 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"; -type SortState = { key: SortKey; dir: SortDir } | null; +export type ChannelSortState = { key: ChannelSortKey; dir: SortDir } | null; const DEFAULT_DIR: Record<string, SortDir> = { slug: "asc", @@ -143,11 +58,11 @@ const DEFAULT_DIR: Record<string, SortDir> = { // 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. -function defaultDirFor(key: SortKey): SortDir { +export function defaultDirFor(key: ChannelSortKey): SortDir { return DEFAULT_DIR[key] ?? "desc"; } -function bandOf(row: ChannelRow, id: string): OperationBand | undefined { +function bandOf(row: ChannelRowView, id: string): OperationBand | undefined { return row.pipelines.find((b) => b.id === id); } @@ -172,7 +87,10 @@ function compareNumbers( return (a as number) - (b as number); } -function compareDates(a: string | undefined, b: string | undefined): 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; @@ -196,7 +114,7 @@ function compareBools(a: boolean, b: boolean): number { return (a ? 1 : 0) - (b ? 1 : 0); } -function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number { +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); @@ -205,14 +123,11 @@ function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number { case "slug": return a.slug.localeCompare(b.slug); case "name": - return compareStrings(a.config.name, b.config.name); + return compareStrings(a.name, b.name); case "handling": - return compareStrings(a.config.handling, b.config.handling); + return compareStrings(a.handling, b.handling); case "build": - return compareBools( - a.config.excludeFromBuild === true, - b.config.excludeFromBuild === true, - ); + 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 @@ -227,12 +142,9 @@ function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number { case "playlist": return compareNumbers(a.playlistCount, b.playlistCount); case "lastSync": - return compareDates(a.config.lastSyncedAt, b.config.lastSyncedAt); + return compareDates(a.lastSyncedAt, b.lastSyncedAt); case "report": - return compareDates( - a.report.generatedAt ?? undefined, - b.report.generatedAt ?? undefined, - ); + 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. @@ -254,130 +166,98 @@ function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number { // 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<ChannelRow>, - sort: SortState, -): ReadonlyArray<ChannelRow> { + list: ReadonlyArray<ChannelRowView>, + sort: ChannelSortState, +): ReadonlyArray<ChannelRowView> { 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<string>; + onToggle: (slug: string) => void; + onToggleAll: (checked: boolean) => void; +}; + export function ChannelsTable({ - channels, + rows: channels, columns, + pipelineColumns = [], + rowLabelPrefix, + rowExtras, + labels = {}, + sort: sortConfig, + selection, sections = null, siteId, - mediaDestinations = [], - defaultLocationId = "", - sites = [], - focusLabel = null, - volumes = [], - locationFilter = null, - initialSort = null, + sticky = false, + theadRef, + now = null, }: { - channels: ChannelRow[]; - // Which pipelines to draw, 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 to do. - columns: PipelineColumn[]; - // Null under "all sites" (or with no sites configured): groups only partition - // a SINGLE site's channels, so there is no one grouping to render across the - // pool. That path is today's flat table, unchanged. + 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[]; + // `<tr aria-label="${prefix} row ${slug}">` — the dashboard's `channel row`, + // a work section's `<id> row`. The rack's rows carry none. + rowLabelPrefix?: string; + // Per-row data (and rendered elements) a server caller adds; see + // ChannelRowExtras. + rowExtras?: Record<string, ChannelRowExtras>; + 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?: ChannelGroupSection[] | null; siteId?: string; - // The configured storage locations, resolved on the server. The selection - // deck's destination list; empty when none are configured, which leaves the - // deck's free-text root box as the whole control. - mediaDestinations?: BulkDestination[]; - // `settings.storage.defaultLocationId` — which destination the deck opens on. - defaultLocationId?: string; - // Every configured site, for the "Focus site" control. Not the same list as - // the page's scope selector: a focus is corpus-wide, so it can name a site - // whose channels are not the ones on screen. - sites?: FocusSite[]; - // What the focus selector currently names, resolved on the server, or null. - focusLabel?: string | null; - // One entry per storage volume the channels on screen live on, with its free - // space. Rendered ONCE above the rack, never per row — see ChannelVolumeBar. - volumes?: ChannelVolume[]; - // `?location=`, already validated by the server against what is on screen. - locationFilter?: string | null; - // The sort the URL asked for (`?sort=size`, which is how /storage links to a - // "largest first" list). Only the initial value: sorting is client state - // from then on, because a router.replace races the global AutoRefresh. - initialSort?: "size" | null; + // 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<HTMLTableSectionElement>; + // The live clock for relative dates; null until mounted. + now?: number | null; }) { - const [sort, setSort] = useState<SortState>( - initialSort ? { key: initialSort, dir: defaultDirFor(initialSort) } : null, + const initialKey = sortConfig ? (sortConfig.initialKey ?? null) : null; + const [sort, setSort] = useState<ChannelSortState>( + initialKey ? { key: initialKey, dir: defaultDirFor(initialKey) } : null, ); - // Slugs ticked for a bulk edit. A Set of SLUGS, not indices, so a - // re-render that reorders or drops a row cannot retarget the selection — - // the same reason SyncConsole keys its selection this way. - const [selected, setSelected] = useState<ReadonlySet<string>>(new Set()); - // Plain component state, deliberately NOT the URL: router.replace races the - // global AutoRefresh's router.refresh() and gets dropped. - const [grouped, setGrouped] = useState(true); + const sortable = sortConfig !== false; const rows = useMemo(() => applySort(channels, sort), [channels, sort]); - // A section lists ChannelStats; the bands live on the ChannelRow the page - // built for 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. + // 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 = grouped && !!sections && sections.length > 0 && !!siteId; - // The select column, ten fixed columns (Location and Size joined the eight), - // one per pipeline, then Actions. - const colSpan = 12 + columns.length; - // Always the intersection with what is on screen: a slug can leave the table - // between renders (a scope change, a deletion), and a bulk edit must not act - // on a row nobody can see. - const selectedSlugs = channels - .map((c) => c.slug) - .filter((s) => selected.has(s)); + 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 = - channels.length > 0 && selectedSlugs.length === channels.length; - - // "FREE UP N GB" — the arithmetic the operator was doing by hand. - // - // The rule lives in common/views/freeUpSelection.ts (pure, unit-tested); this - // is the wire between it and the tick boxes. It SELECTS and does not act: - // what moves anything is the deck's existing Move button, with its existing - // destination and its existing per-channel skips. - // - // Always over `channels` — what is on screen — so a volume filter or a site - // scope narrows the proposal exactly as the operator expects. - const [freeUpNote, setFreeUpNote] = useState<string | null>(null); - function freeUp(targetGB: number) { - const result = selectToFreeBytes( - channels.map((c) => ({ - slug: c.slug, - bytes: c.mediaBytes, - inPlace: c.volumeId === INTERNAL_ROW_ID, - // A MARKER IS A GUARANTEED SKIP. The bulk move refuses a channel whose - // media is in transition by name, so proposing one would put a row in - // the deck that cannot move and count its bytes toward a total the - // Move will never deliver. - inTransition: c.media?.status === "in-transition", - })), - targetGB * 1024 ** 3, - ); - setSelected(new Set(result.slugs)); - setFreeUpNote(`${formatBytes(result.bytes)} selected. ${result.note}`); - } - - function toggleOne(slug: string) { - setSelected((prev) => { - const next = new Set(prev); - if (!next.delete(slug)) next.add(slug); - return next; - }); - } + selection !== false && + channels.length > 0 && + channels.every((c) => selection.selected.has(c.slug)); - function onHeaderClick(key: SortKey) { + function onHeaderClick(key: ChannelSortKey) { setSort((prev) => { if (prev && prev.key === key) { return { key, dir: prev.dir === "asc" ? "desc" : "asc" }; @@ -386,268 +266,156 @@ export function ChannelsTable({ }); } - // A GROUP HEADER PINS UNDER THE COLUMN HEADER, not over it. The offset is the - // thead's real height rather than a guess, because the head grows a line when - // the pipeline block's eyebrow renders and shrinks again when settings switch - // every derived operation off. `top-9` is the fallback the class carries for - // the first paint and for a browser with no ResizeObserver. - const regionRef = useRef<HTMLDivElement>(null); - const theadRef = useRef<HTMLTableSectionElement>(null); - useEffect(() => { - const region = regionRef.current; - const thead = theadRef.current; - if (!region || !thead || typeof ResizeObserver === "undefined") return; - const measure = () => - region.style.setProperty("--thead-h", `${thead.offsetHeight}px`); - measure(); - const observer = new ResizeObserver(measure); - observer.observe(thead); - return () => observer.disconnect(); - }, []); + 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( + <th + key="select" + className={`${sticky ? "sticky left-0 z-10 bg-muted " : ""}w-9 px-2 py-1.5 align-bottom`} + > + <input + type="checkbox" + aria-label="select all channels" + checked={allSelected} + onChange={(e) => + selection !== false && selection.onToggleAll(e.target.checked) + } + className="accent-primary" + /> + </th>, + ); + 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 = + "w-24 min-w-20" + + (i === 0 ? " border-l border-border" : "") + + (i === pipelineColumns.length - 1 ? " border-r border-border" : ""); + head.push( + sortable ? ( + <SortableTh + key={col.id} + label={col.shortLabel} + sortKey={`op:${col.id}`} + sort={sort} + onClick={onHeaderClick} + eyebrow={i === 0 ? "Pipeline" : undefined} + className={className} + title={`${col.label} — ${col.costBasis}. Sorts by what can run now.`} + /> + ) : ( + <th + key={col.id} + className={`text-left ${pad} font-medium ${className}`} + title={`${col.label} — ${col.costBasis}`} + > + {col.shortLabel} + </th> + ), + ); + }); + continue; + } + const col = CHANNEL_COLUMNS[id]; + const label = + labels[id] ?? + (typeof col.label === "function" + ? col.label({ labels, sticky }) + : col.label); + const className = [ + col.th?.className ?? "", + sticky ? (col.th?.stickyClassName ?? "") : "", + ] + .filter(Boolean) + .join(" "); + if (sortable && col.sortKey) { + head.push( + <SortableTh + key={id} + label={label} + sortKey={col.sortKey} + sort={sort} + onClick={onHeaderClick} + align={col.align} + className={className} + title={col.th?.title} + />, + ); + } else { + head.push( + <th + key={id} + className={`${col.align === "right" ? "text-right" : "text-left"} ${pad}${sticky ? " align-bottom" : ""} font-medium ${className}`} + title={col.th?.title} + > + {label} + </th>, + ); + } + } - // HOW MANY ROWS THE FOCUS IS HOLDING, for the focus line's one-line summary. - // The reason itself stays per-row (each held chip carries the full sentence - // as its screen-reader text); the COUNT is the corpus-wide fact, and stating - // it once above the table is what lets the rows stop repeating the sentence. - const heldCount = channels.filter((c) => c.priority.heldReason).length; + const renderRow = (c: ChannelRowView) => ( + <ChannelTableRow + key={c.slug} + channel={c} + columns={visibleColumns} + pipelineColumns={pipelineColumns} + selection={selection} + extras={rowExtras?.[c.slug]} + rowLabelPrefix={rowLabelPrefix} + sticky={sticky} + now={now} + /> + ); - return ( - // On md+ this is a pane of the page's viewport-height flex column: the - // instrument bar and the deck are fixed chrome and only the region between - // them scrolls. Below md it has no height of its own and the document - // scrolls, exactly as it always has. - <div className="flex min-h-0 flex-1 flex-col"> - <ChannelFocusBar - sites={sites} - focusLabel={focusLabel} - heldCount={heldCount} - /> - <ChannelVolumeBar - volumes={volumes} - active={locationFilter} - onFreeUp={freeUp} - /> - {/* THE INSTRUMENT BAR: what the rack is showing (grouping) on the left, - how to read it (the band legend, and the two-route explainer behind a - disclosure) on the right. Both used to live in an 11px stack UNDER 67 - rows, where the legend for a chart is of no use to anyone. */} - <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1 py-2 text-xs text-muted-foreground"> - {sections && sections.length > 0 ? ( - <label className="flex items-center gap-2"> - <input - type="checkbox" - checked={grouped} - onChange={(e) => setGrouped(e.target.checked)} - className="accent-primary" - /> - Group by section - </label> - ) : ( - <span /> - )} - <div className="flex flex-wrap items-center gap-x-4 gap-y-1"> - <BandLegend /> - {columns.some((c) => c.id.startsWith("attribution-")) && ( - <details className="max-w-prose"> - <summary className="cursor-pointer hover:text-foreground"> - Names&middot;A / Names&middot;T - </summary> - <p className="pt-1"> - Speaker names come from the audio (Names&middot;A) or the - transcript (Names&middot;T) &mdash; two routes to the same - field, counted separately because they cost different things. - </p> - </details> - )} - </div> - </div> - {/* THE RACK. One scroll box: the thead pins to its top, the checkbox and - Slug cells pin to its left, and the sixteen columns move underneath - them. The table itself must NOT clip (`overflow-hidden` would make it - the sticky ancestor and nothing would pin) — the rounded corners are - the region's. */} - <div - ref={regionRef} - className="relative -mx-4 min-h-0 flex-1 overflow-auto border-y border-border md:mx-0 md:rounded-md md:border" + const table = ( + <table + className={ + sticky + ? "w-full text-sm" + : "text-sm border-y md:border border-border md:rounded-md md:overflow-hidden w-full" + } + > + <thead + ref={theadRef} + className={sticky ? "sticky top-0 z-30 bg-muted" : "bg-muted"} > - <table className="w-full text-sm"> - <thead ref={theadRef} className="sticky top-0 z-30 bg-muted"> - <tr> - <th className="sticky left-0 z-10 w-9 bg-muted px-2 py-1.5 align-bottom"> - <input - type="checkbox" - aria-label="select all channels" - checked={allSelected} - onChange={(e) => - setSelected( - e.target.checked - ? new Set(channels.map((c) => c.slug)) - : new Set(), - ) - } - className="accent-primary" - /> - </th> - <SortableTh - label="Slug" - sortKey="slug" - sort={sort} - onClick={onHeaderClick} - className="sticky left-8 z-20 bg-muted shadow-[1px_0_0_var(--color-border)]" - /> - <SortableTh - label="Name" - sortKey="name" - sort={sort} - onClick={onHeaderClick} - /> - <SortableTh - label="Handling" - sortKey="handling" - sort={sort} - onClick={onHeaderClick} - /> - <SortableTh - label="Build" - sortKey="build" - sort={sort} - onClick={onHeaderClick} - /> - <SortableTh - label="Tier" - sortKey="tier" - sort={sort} - onClick={onHeaderClick} - title="Channel priority: Normal, Low or Paused, with per-operation pins behind Advanced. The four auto-queue trees are compiled from this column." - /> - <SortableTh - label="Playlist" - sortKey="playlist" - sort={sort} - onClick={onHeaderClick} - align="right" - /> - <SortableTh - label="Last sync" - sortKey="lastSync" - sort={sort} - onClick={onHeaderClick} - className="whitespace-nowrap" - /> - <SortableTh - label="Report" - sortKey="report" - sort={sort} - onClick={onHeaderClick} - className="whitespace-nowrap" - title="When this channel's report was last generated. Every count and band on the row is read from it — stale or missing means those numbers may be wrong." - /> - {/* THE TWO STORAGE COLUMNS. Which disk the media is on, and how - much of it there is — the pair that makes "what should I move" - answerable without opening 71 channel pages. Free space is - deliberately NOT here: it is a fact about a volume, not about - a row, and it is stated once in the volume bar above. */} - <SortableTh - label="Location" - sortKey="location" - sort={sort} - onClick={onHeaderClick} - className="whitespace-nowrap" - title="Which storage volume this channel's media is on. Internal is the corpus disk; anything else is a location configured on /storage." - /> - <SortableTh - label="Size" - sortKey="size" - sort={sort} - onClick={onHeaderClick} - align="right" - className="whitespace-nowrap" - title="Every byte under this channel's data/ — audio, transcripts, cues, metadata — from its last report. Sorts biggest first." - /> - {/* 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. */} - {columns.map((col, i) => ( - <SortableTh - key={col.id} - label={col.shortLabel} - sortKey={`op:${col.id}`} - sort={sort} - onClick={onHeaderClick} - eyebrow={i === 0 ? "Pipeline" : undefined} - className={ - "w-24 min-w-20" + - (i === 0 ? " border-l border-border" : "") + - (i === columns.length - 1 ? " border-r border-border" : "") - } - title={`${col.label} — ${col.costBasis}. Sorts by what can run now.`} - /> - ))} - <th className="whitespace-nowrap px-2 py-1.5 text-left align-bottom font-medium"> - Actions - </th> - </tr> - </thead> - {showSections ? ( - // One sort state, applied independently per section — which IS - // "sorts within each group". - sections.map((section) => ( - <tbody - key={section.group.id} - aria-labelledby={`group-${section.group.id}`} - > - <ChannelGroupHeaderRow - section={section} - siteId={siteId as string} - colSpan={colSpan} - /> - {applySort( - section.channels.flatMap((c) => rowBySlug.get(c.slug) ?? []), - sort, - ).map((c) => ( - <ChannelTableRow - key={c.slug} - channel={c} - columns={columns} - selected={selected.has(c.slug)} - onToggle={toggleOne} - /> - ))} - </tbody> - )) - ) : ( - <tbody> - {rows.map((c) => ( - <ChannelTableRow - key={c.slug} - channel={c} - columns={columns} - selected={selected.has(c.slug)} - onToggle={toggleOne} - /> - ))} - </tbody> - )} - </table> - </div> - {/* OUTSIDE the overflow box, always: inside it, `sticky bottom-0` pins to - the table's scroll box rather than to the screen, which is what put - the old bar off the bottom of a phone. */} - <ChannelSelectionDeck - slugs={selectedSlugs} - onClear={() => { - setSelected(new Set()); - setFreeUpNote(null); - }} - destinations={mediaDestinations} - defaultLocationId={defaultLocationId} - // THE NOTE, NOT THE CONTROL. The helper itself lives in the volume bar - // (it is about volumes and free space, and it has to be reachable with - // nothing ticked — the deck does not exist then). What belongs down - // here is what the proposal actually came to, beside the Move button - // that will act on it. - freeUpNote={freeUpNote} - /> + <tr>{head}</tr> + </thead> + {showSections ? ( + // One sort state, applied independently per section — which IS + // "sorts within each group". + sections.map((section) => ( + <tbody + key={section.group.id} + aria-labelledby={`group-${section.group.id}`} + > + <ChannelGroupHeaderRow + section={section} + siteId={siteId as string} + colSpan={colSpan} + /> + {applySort( + section.channels.flatMap((c) => rowBySlug.get(c.slug) ?? []), + sort, + ).map(renderRow)} + </tbody> + )) + ) : ( + <tbody>{rows.map(renderRow)}</tbody> + )} + </table> + ); + if (sticky) return table; + return ( + <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible"> + {table} </div> ); } @@ -672,6 +440,7 @@ function PipelineCell({ band, count, className = "", + pad, }: { slug: string; column: PipelineColumn; @@ -687,12 +456,18 @@ function PipelineCell({ // 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 <Td className={`text-muted-foreground ${className}`}>&mdash;</Td>; + return ( + <Td pad={pad} className={`text-muted-foreground ${className}`}> + &mdash; + </Td> + ); const sentence = bandSentence(band); return ( <Td + pad={pad} className={className} ariaLabel={`${column.countLabel ?? column.label} for ${slug}`} title={`${column.label} — ${sentence}`} @@ -705,24 +480,35 @@ function PipelineCell({ ); } -// One channel's row, shared by the flat and the sectioned render so the two -// can never drift apart column-for-column. +// 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, - selected, - onToggle, + pipelineColumns, + selection, + extras, + rowLabelPrefix, + sticky, + now, }: { - channel: ChannelRow; - columns: PipelineColumn[]; - selected: boolean; - onToggle: (slug: string) => void; + 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 w-24 min-w-20"; + 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 @@ -734,152 +520,93 @@ function ChannelTableRow({ // `absolute z-30` Advanced popover (ChannelTierSelect) 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. Moving this back - // onto the row re-breaks the menu (operator report 2026-09-17). + // 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). const dim = - c.config.excludeFromBuild || c.priority.tier === "paused" - ? " opacity-60" - : ""; - return ( - <tr className={"border-t border-border " + (selected ? "bg-accent " : "")}> - <td - className={`sticky left-0 z-10 w-9 px-2 py-1.5 align-middle ${stickyBg}${dim}`} - > - <input - type="checkbox" - aria-label={`select ${c.slug}`} - checked={selected} - onChange={() => onToggle(c.slug)} - className="accent-primary" - /> - </td> - <Td - className={`sticky left-8 z-20 whitespace-nowrap font-mono shadow-[1px_0_0_var(--color-border)] ${stickyBg}${dim}`} - > - <span className="inline-flex items-center gap-1.5"> - <Link - href={`/channels/${c.slug}`} - className="underline hover:text-foreground" - > - {c.slug} - </Link> - <MediaLocationBadge media={c.media} compact /> - </span> - </Td> - <Td className={`max-w-48 truncate${dim}`}>{c.config.name ?? ""}</Td> - <Td className={`font-mono text-[11px] text-muted-foreground${dim}`}> - {c.config.handling} - </Td> - <Td className={dim}> - <ChannelBuildToggle - slug={c.slug} - excluded={c.config.excludeFromBuild === true} - /> - </Td> - <Td> - <ChannelTierSelect - slug={c.slug} - tier={c.priority.tier} - overrides={c.priority.overrides} - focused={c.priority.focused} - heldReason={c.priority.heldReason} - autoPausedReason={c.priority.autoPausedReason} - /> - </Td> - <Td - className={`text-right tabular-nums${dim}`} - ariaLabel={`playlist count for ${c.slug}`} - > - {c.playlistCount ?? "—"} - </Td> - <Td - className={`whitespace-nowrap text-xs tabular-nums text-muted-foreground${dim}`} - > - {formatStamp(c.config.lastSyncedAt)} - </Td> - <Td - ariaLabel={`report age for ${c.slug}`} - className={`whitespace-nowrap text-xs tabular-nums ${ - c.report.state === "current" - ? "text-muted-foreground" - : "text-warning" - }${dim}`} - > - {c.report.state === "current" - ? formatStamp(c.report.generatedAt) - : c.report.state} - </Td> + 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( + <Td + key="select" + pad="px-2 py-1.5" + className={`${sticky ? `sticky left-0 z-10 ${stickyBg} ` : ""}w-9${dim}`} + > + <input + type="checkbox" + aria-label={`select ${c.slug}`} + checked={selected} + onChange={() => ctx.onToggle(c.slug)} + className="accent-primary" + /> + </Td>, + ); + continue; + } + if (id === "pipelines") { + pipelineColumns.forEach((col, i) => { + cells.push( + <PipelineCell + key={col.id} + pad={pad} + slug={c.slug} + column={col} + band={bandOf(c, col.id)} + className={ + bridge + + (i === 0 ? " border-l border-border" : "") + + (i === pipelineColumns.length - 1 + ? " border-r border-border" + : "") + + dim + } + count={ + col.id === "download" + ? c.downloadCount + : col.id === "transcription" + ? c.transcriptCount + : null + } + />, + ); + }); + continue; + } + const col = CHANNEL_COLUMNS[id]; + const cell = col.cell(c, ctx); + cells.push( <Td - ariaLabel={`media location for ${c.slug}`} - className={`whitespace-nowrap text-xs${dim}`} - title={c.media?.target ?? undefined} + key={id} + pad={pad} + className={`${cell.className ?? ""}${col.dim === false ? "" : dim}`} + ariaLabel={cell.ariaLabel} + title={cell.title} > - {/* THE LABEL ONLY — NO SECOND BADGE. The unreachable / in-transition - marking is the badge's, and the badge is already on this row, in the - Slug cell beside the channel's name; drawing another here put two - elements with the same accessible name on every relocated row, which - is a strict-mode violation for the suite and a screen reader reading - the same sentence twice for a human. The COLUMN answers "which - disk"; the badge answers "can it be reached", and one of each per - row is the right number. */} - {c.volumeLabel} - </Td> - <Td - ariaLabel={`media size for ${c.slug}`} - className={`whitespace-nowrap text-right tabular-nums${dim}`} - title={ - c.mediaBytes === null - ? "No size in this channel's report yet — refresh it for a figure." - : undefined - } - > - {c.mediaBytes === null ? "—" : formatBytes(c.mediaBytes)} - </Td> - {columns.map((col, i) => ( - <PipelineCell - key={col.id} - slug={c.slug} - column={col} - band={bandOf(c, col.id)} - className={ - bridge + - (i === 0 ? " border-l border-border" : "") + - (i === columns.length - 1 ? " border-r border-border" : "") + - dim - } - count={ - col.id === "download" - ? c.downloadCount - : col.id === "transcription" - ? c.transcriptCount - : null - } - /> - ))} - <Td className={`whitespace-nowrap${dim}`}> - <div className="flex items-center gap-2"> - <ChannelSyncButton slug={c.slug} disabled={!c.config.url} /> - <ChannelAvailabilityButton slug={c.slug} disabled={!c.config.url} /> - <InlineActionButton - variant={{ kind: "refreshReport", slug: c.slug }} - /> - </div> - </Td> + {cell.content} + </Td>, + ); + } + return ( + <tr + aria-label={rowLabelPrefix ? `${rowLabelPrefix} row ${c.slug}` : undefined} + className={"border-t border-border " + (selected ? "bg-accent " : "")} + > + {cells} </tr> ); } -// A 40px row has one line for a date, so the date has to fit one line. The -// locale default spells the year and the seconds out ("11/09/2026, 10:24:31"); -// medium/short is the same instant in the same locale, two thirds the width. -function formatStamp(iso: string | null | undefined): string { - if (!iso) return ""; - return new Date(iso).toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }); -} - function SortableTh({ label, sortKey, @@ -891,9 +618,9 @@ function SortableTh({ eyebrow, }: { label: string; - sortKey: SortKey; - sort: SortState; - onClick: (key: SortKey) => void; + sortKey: ChannelSortKey; + sort: ChannelSortState; + onClick: (key: ChannelSortKey) => void; align?: "left" | "right"; className?: string; title?: string; @@ -948,15 +675,17 @@ function Td({ className = "", ariaLabel, title, + pad, }: { - children: React.ReactNode; + children: ReactNode; className?: string; ariaLabel?: string; title?: string; + pad: string; }) { return ( <td - className={`px-2 py-1.5 align-middle ${className}`} + className={`${pad} align-middle ${className}`} aria-label={ariaLabel} title={title} > diff --git a/editor/app/channels/components/channelColumns.tsx b/editor/app/channels/components/channelColumns.tsx @@ -0,0 +1,416 @@ +"use client"; + +import Link from "next/link"; +import type { ReactNode } from "react"; +import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow"; +import { formatBytes } from "yt-dlp-transcript-common/lib/format"; +import { MediaLocationBadge } from "../../components/MediaLocationBadge"; +import { InlineActionButton } from "../../components/actions/InlineActionButton"; +import { fmtTime } from "../../widget/lib/relativeTime"; +import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton"; +import { ChannelBuildToggle } from "./ChannelBuildToggle"; +import { ChannelSyncButton } from "./ChannelSyncButton"; +import ChannelTierSelect from "./ChannelTierSelect"; + +// THE CHANNEL COLUMN REGISTRY. +// +// A REGISTRY BY ID, NOT COLUMN OBJECTS PASSED AS PROPS. The work tables are +// drawn by a SERVER shell (ChannelWorkTable — its section configs hold +// functions that return elements), and a function cannot cross the +// server/client boundary as a prop. So a caller names columns by id — a preset +// below, or its own list — and the cells live here, on the client, where the +// shared table (./ChannelsTable.tsx) looks them up. What a server caller needs +// to put INTO a cell (a count, an extra figure, an actions cluster) travels as +// data or as an already-rendered element in `rowExtras`, never as a function. +// +// Every accessible name a spec addresses a cell by is spelled here, once: +// `playlist count for`, `report age for`, `media location for`, `media size +// for`, and — in the table's pipeline cell — `${countLabel} for`. + +export type ChannelColumnId = + | "select" + | "slug" + | "name" + | "handling" + | "build" + | "tier" + | "playlist" + | "videos" + | "digestReachable" + | "count" + | "extra" + | "lastSync" + | "lastSyncRelative" + | "report" + | "location" + | "size" + | "pipelines" + | "actions"; + +// Sorting a pipeline column sorts by `reachable` — "which channel has the most +// diarizable audio left right now". +export type ChannelSortKey = + | "slug" + | "name" + | "handling" + | "build" + | "tier" + | "playlist" + | "lastSync" + | "report" + | "location" + | "size" + | `op:${string}`; + +// A column heading for one pipeline. Comes off the operation registry on the +// server (shortLabel, costBasis) rather than being abbreviated here — see +// operations.ts. +export type PipelineColumn = { + id: string; + shortLabel: string; + label: string; + costBasis: string; + // The two externally-dispatched pipelines keep their existing count column + // semantics: the cell's text node is the bare number the suite asserts on, + // under this accessible name. The slug is appended by the cell, so this is + // the prefix — `"downloads count"` becomes `"downloads count for foo"`, which + // is the name channels-counts.spec.ts has always addressed these cells by. + // Null for the derived operations, which have never had a count column and + // get the band's sentence as their text. + countLabel: string | null; +}; + +// What a server caller adds to one row: the section's count and extra figure, +// and its actions cluster as an ELEMENT (elements cross the boundary; the +// functions that built them stay on the server). +export type ChannelRowExtras = { + count?: number; + extra?: string; + actions?: ReactNode; +}; + +export type ChannelColumnLabels = Partial<Record<ChannelColumnId, string>>; + +export type ChannelCellCtx = { + // " opacity-60" when the row is out of a pipeline (build-excluded or + // paused), else "". Applied by the TABLE, per cell, to every column whose + // `dim` is not false — never to the <tr>; see ChannelsTable. + dim: string; + // The rack's pinned-column background (a sticky cell paints over the row + // behind it, so it must carry the row's background explicitly). + stickyBg: string; + sticky: boolean; + selected: boolean; + onToggle: (slug: string) => void; + extras: ChannelRowExtras | undefined; + // The relative-time clock; null until mounted. + now: number | null; +}; + +export type ChannelCell = { + content: ReactNode; + className?: string; + ariaLabel?: string; + title?: string; +}; + +export type ChannelHeadCtx = { + labels: ChannelColumnLabels; + sticky: boolean; +}; + +export type ChannelColumn = { + // A caller's `labels[id]` wins over this — a work section names its count + // column ("undownloaded") and calls the report column "Last report". + label: string | ((ctx: ChannelHeadCtx) => string); + align?: "left" | "right"; + sortKey?: ChannelSortKey; + // `stickyClassName` applies only in the rack (`sticky`), where the Slug + // column pins left under the pinned header. + th?: { className?: string; stickyClassName?: string; title?: string }; + cell: (row: ChannelRowView, ctx: ChannelCellCtx) => ChannelCell; + // False for the one cell that must not dim: Tier hosts the Advanced popover, + // and a dimmed cell is a stacking context that would trap it. + dim?: boolean; +}; + +// A 40px row has one line for a date, so the date has to fit one line. The +// locale default spells the year and the seconds out ("11/09/2026, 10:24:31"); +// medium/short is the same instant in the same locale, two thirds the width. +export function formatStamp(iso: string | null | undefined): string { + if (!iso) return ""; + return new Date(iso).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + +function toMs(iso: string | null): number | null { + if (!iso) return null; + const n = Date.parse(iso); + return Number.isNaN(n) ? null : n; +} + +export const CHANNEL_COLUMNS: Record< + Exclude<ChannelColumnId, "select" | "pipelines">, + ChannelColumn +> = { + slug: { + label: "Slug", + sortKey: "slug", + th: { + stickyClassName: + "sticky left-8 z-20 bg-muted shadow-[1px_0_0_var(--color-border)]", + }, + cell: (c, ctx) => ({ + className: ctx.sticky + ? `sticky left-8 z-20 whitespace-nowrap font-mono shadow-[1px_0_0_var(--color-border)] ${ctx.stickyBg}` + : "whitespace-nowrap font-mono", + content: ( + <span className="inline-flex items-center gap-1.5"> + <Link + href={`/channels/${c.slug}`} + className="underline hover:text-foreground" + > + {c.slug} + </Link> + <MediaLocationBadge media={c.media} compact /> + </span> + ), + }), + }, + name: { + label: "Name", + sortKey: "name", + cell: (c) => ({ className: "max-w-48 truncate", content: c.name }), + }, + handling: { + label: "Handling", + sortKey: "handling", + cell: (c) => ({ + className: "font-mono text-[11px] text-muted-foreground", + content: c.handling, + }), + }, + build: { + label: "Build", + sortKey: "build", + cell: (c) => ({ + content: ( + <ChannelBuildToggle slug={c.slug} excluded={c.excludeFromBuild} /> + ), + }), + }, + tier: { + label: "Tier", + sortKey: "tier", + th: { + title: + "Channel priority: Normal, Low or Paused, with per-operation pins behind Advanced. The four auto-queue trees are compiled from this column.", + }, + dim: false, + cell: (c) => ({ + content: ( + <ChannelTierSelect + slug={c.slug} + tier={c.priority.tier} + overrides={c.priority.overrides} + focused={c.priority.focused} + heldReason={c.priority.heldReason} + autoPausedReason={c.priority.autoPausedReason} + /> + ), + }), + }, + playlist: { + label: "Playlist", + sortKey: "playlist", + align: "right", + cell: (c) => ({ + className: "text-right tabular-nums", + ariaLabel: `playlist count for ${c.slug}`, + content: c.playlistCount ?? "—", + }), + }, + // From the snapshot rather than a corpus walk, so it lags a sync by the + // snapshot scheduler's debounce. + videos: { + label: "Videos", + align: "right", + cell: (c) => ({ + className: "text-right tabular-nums", + content: c.work.videos, + }), + }, + // Muted: during the backfill this is nearly every video, so it is a + // coverage readout rather than a call to action. + digestReachable: { + label: "Digest to do", + align: "right", + th: { + className: "whitespace-nowrap", + title: + "Videos the digest lane can work on now — the same reachable figure the /operations rail draws; blocked and deferred are not in it", + }, + cell: (c) => ({ + className: "text-right tabular-nums text-xs text-muted-foreground", + content: c.work.digestReachable, + }), + }, + // A work section's own figure — undownloaded, untranscribed, reclaimable — + // computed by the section's `getCount` on the server and carried here as data. + count: { + label: "Count", + align: "right", + cell: (_c, ctx) => ({ + className: "text-right tabular-nums", + content: ctx.extras?.count ?? "", + }), + }, + extra: { + label: "Extra", + align: "right", + th: { className: "whitespace-nowrap" }, + cell: (_c, ctx) => ({ + className: "text-right tabular-nums whitespace-nowrap text-muted-foreground", + content: ctx.extras?.extra ?? "", + }), + }, + lastSync: { + label: "Last sync", + sortKey: "lastSync", + th: { className: "whitespace-nowrap" }, + cell: (c, ctx) => ({ + className: "whitespace-nowrap text-xs tabular-nums text-muted-foreground", + // The rack leaves a never-synced cell empty; a work table says so. + content: formatStamp(c.lastSyncedAt) || (ctx.sticky ? "" : "never"), + }), + }, + // The dashboard's live "5m ago", ticking off the cockpit's clock. + lastSyncRelative: { + label: "Last sync", + th: { className: "whitespace-nowrap" }, + cell: (c, ctx) => { + const ms = toMs(c.lastSyncedAt); + return { + className: "whitespace-nowrap text-xs tabular-nums text-muted-foreground", + content: ms === null ? "never" : (fmtTime(ms, ctx.now, false) ?? "…"), + }; + }, + }, + report: { + label: "Report", + sortKey: "report", + th: { + className: "whitespace-nowrap", + title: + "When this channel's report was last generated. Every count and band on the row is read from it — stale or missing means those numbers may be wrong.", + }, + cell: (c) => ({ + ariaLabel: `report age for ${c.slug}`, + className: `whitespace-nowrap text-xs tabular-nums ${ + c.report.state === "current" ? "text-muted-foreground" : "text-warning" + }`, + content: + c.report.state === "current" + ? formatStamp(c.report.generatedAt) + : c.report.state, + }), + }, + // THE TWO STORAGE COLUMNS. Which disk the media is on, and how much of it + // there is — the pair that makes "what should I move" answerable without + // opening 71 channel pages. Free space is deliberately NOT here: it is a fact + // about a volume, not about a row, and it is stated once in the volume bar. + location: { + label: "Location", + sortKey: "location", + th: { + className: "whitespace-nowrap", + title: + "Which storage volume this channel's media is on. Internal is the corpus disk; anything else is a location configured on /storage.", + }, + // THE LABEL ONLY — NO SECOND BADGE. The unreachable / in-transition marking + // is the badge's, and the badge is already on this row, in the Slug cell; + // drawing another here put two elements with the same accessible name on + // every relocated row (a strict-mode violation for the suite, and a screen + // reader reading the same sentence twice). The COLUMN answers "which disk"; + // the badge answers "can it be reached". + cell: (c) => ({ + ariaLabel: `media location for ${c.slug}`, + className: "whitespace-nowrap text-xs", + title: c.media?.target ?? undefined, + content: c.volumeLabel, + }), + }, + size: { + label: "Size", + sortKey: "size", + align: "right", + th: { + className: "whitespace-nowrap", + title: + "Every byte under this channel's data/ — audio, transcripts, cues, metadata — from its last report. Sorts biggest first.", + }, + cell: (c) => ({ + ariaLabel: `media size for ${c.slug}`, + className: "whitespace-nowrap text-right tabular-nums", + title: + c.mediaBytes === null + ? "No size in this channel's report yet — refresh it for a figure." + : undefined, + content: c.mediaBytes === null ? "—" : formatBytes(c.mediaBytes), + }), + }, + // The rack's own cluster unless the caller rendered one for this row. + actions: { + label: "Actions", + th: { className: "whitespace-nowrap" }, + cell: (c, ctx) => ({ + className: "whitespace-nowrap", + content: ctx.extras?.actions ?? ( + <div className="flex items-center gap-2"> + <ChannelSyncButton slug={c.slug} disabled={!c.hasUrl} /> + <ChannelAvailabilityButton slug={c.slug} disabled={!c.hasUrl} /> + <InlineActionButton variant={{ kind: "refreshReport", slug: c.slug }} /> + </div> + ), + }), + }, +}; + +// THE PRESETS. The rack draws everything; the dashboard and a work section +// each name the handful they have always drawn. +export const RACK_COLUMNS: readonly ChannelColumnId[] = [ + "select", + "slug", + "name", + "handling", + "build", + "tier", + "playlist", + "lastSync", + "report", + "location", + "size", + "pipelines", + "actions", +]; + +export const DASHBOARD_COLUMNS: readonly ChannelColumnId[] = [ + "slug", + "handling", + "videos", + "digestReachable", + "lastSyncRelative", + "actions", +]; + +// `extra` is dropped by the caller when the section has no extra column. +export const WORK_COLUMNS: readonly ChannelColumnId[] = [ + "slug", + "count", + "extra", + "report", + "lastSync", + "actions", +]; diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -32,26 +32,23 @@ import { type SiteSettings, } from "yt-dlp-transcript-common/lib/settings"; import { - locationOfDataDir, - type StorageLocation, -} from "yt-dlp-transcript-common/lib/storageLocations"; -import { INTERNAL_LOCATION_ID, INTERNAL_LOCATION_LABEL, volumeFreeBytes, } from "yt-dlp-transcript-common/controller/storageLocations"; import { buildChannelBands } from "yt-dlp-transcript-common/views/pipeline/buildBands"; import { EXTERNAL_BAND_IDS } from "yt-dlp-transcript-common/views/pipeline/buildBands"; +import { ChannelsRack } from "./components/ChannelsRack"; +import type { PipelineColumn } from "./components/channelColumns"; import { - ChannelsTable, - type ChannelRow, - type PipelineColumn, -} from "./components/ChannelsTable"; + buildChannelRowView, + channelVolumeOf, + type ChannelRowView, +} from "yt-dlp-transcript-common/views/channelRow"; import type { ChannelVolume } from "./components/ChannelVolumeBar"; import { buildChannelGroupSections } from "yt-dlp-transcript-common/views/channelGroupSections"; import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton"; import { RefreshAllReportsButton } from "./components/RefreshAllReportsButton"; -import { reportStateOf } from "../lib/actionable/loadActionable"; import { resolveActiveSite } from "../lib/activeSite"; export const dynamic = "force-dynamic"; @@ -155,28 +152,6 @@ function focusLabelOf( : `${focusSlugs.length} channels`; } -// WHICH VOLUME A CHANNEL'S MEDIA IS ON, as a filterable id. -// -// Same derivation the badge uses (`config.dataDir` under a location's root, -// longest match wins) with one addition: no `dataDir` at all means the corpus -// volume, which is the row the operator is trying to empty and therefore the -// one they most need to filter to. A `dataDir` under a root NOBODY named is -// neither — it gets "" and falls out of every volume filter, which is the -// honest answer and the nudge to name that root on /storage. -function volumeOf( - dataDir: string | undefined, - locations: StorageLocation[], -): { id: string; label: string } { - const trimmed = dataDir?.trim(); - if (!trimmed) { - return { id: INTERNAL_LOCATION_ID, label: "Internal" }; - } - const found = locationOfDataDir(trimmed, locations); - return found - ? { id: found.id, label: found.label || found.id } - : { id: "", label: "Elsewhere" }; -} - export default async function ChannelsPage({ searchParams, }: { @@ -242,44 +217,23 @@ export default async function ChannelsPage({ // TWO SYSCALLS PER VOLUME, NOT A PROBE. See volumeFreeBytes: this table draws // 71 rows on every auto-refresh and the rule is that tables never shell out. const freeByVolume = await volumeFreeBytes({ paths, locations }); - const all: ChannelRow[] = stats.map((stat) => { + // ONE ROW PER CHANNEL, off the shared builder (common/views/channelRow.ts): + // the dashboard and the operation pages build theirs the same way. The view + // carries no `config` — this table is a client component, and a channel + // config holds cookie paths and yt-dlp args no cell draws. + const all: ChannelRowView[] = stats.map((stat) => { const brief = briefBySlug.get(stat.slug); - const media = mediaBySlug.get(stat.slug); - const volume = volumeOf(brief?.config.dataDir, locations); - return { - ...stat, - // WHERE THE BYTES ARE, AND HOW MANY. Both off the snapshot already in - // hand — no walk, no probe. `mediaBytes` is null for a report written - // before the field existed, and the cell renders that as "—" rather than - // "0 B": a zero would sort a 400 GB channel to the bottom of the very - // list the operator opened to find it. - volumeId: volume.id, - volumeLabel: volume.label, - mediaBytes: brief?.snapshot?.totalMediaBytes ?? null, - pipelines: buildChannelBands(snapshots.get(stat.slug) ?? null, ids), - report: { - generatedAt: brief?.snapshot?.generatedAt ?? null, - state: brief ? reportStateOf(brief) : ("missing" as const), - }, - media: - media && media.status !== "in-place" - ? { - status: media.status, - target: media.target, - detail: media.detail, - // WHICH NAMED LOCATION — a pure prefix match of the recorded - // target against the configured roots, done here because the - // settings are here and the table is a client component. NEVER a - // probe: this table draws one badge per row. Undefined for a root - // nobody named, which renders exactly what it rendered before. - // The row already derived it once for the Location column; - // re-deriving it here would be a second answer to the same - // question. "" is the root nobody named — the badge takes - // undefined for that, which renders exactly what it rendered - // before locations existed. - locationLabel: volume.id === "" ? undefined : volume.label, - } - : null, + return buildChannelRowView({ + slug: stat.slug, + config: stat.config, + snapshot: snapshots.get(stat.slug) ?? null, + playlistCount: stat.playlistCount, + bands: buildChannelBands(snapshots.get(stat.slug) ?? null, ids), + // WHERE THE MEDIA IS: inspectChannelMedia's result; the builder drops an + // in-place one and names the location from the volume, which is the + // same prefix match the Location column shows — one answer, not two. + media: mediaBySlug.get(stat.slug) ?? null, + volume: channelVolumeOf(brief?.config.dataDir, locations), priority: { tier: tierOf(priority, stat.slug), rank: rankOf(priority, stat.slug), @@ -303,7 +257,7 @@ export default async function ChannelsPage({ ? `Held — focus: ${focusLabel}` : null, }, - }; + }); }); // Scope to the active site's membership; "all sites" shows the full pool. // @@ -351,14 +305,21 @@ export default async function ChannelsPage({ locationFilter === null ? inSite : inSite.filter((c) => c.volumeId === locationFilter); + const shownSlugs = new Set(channels.map((c) => c.slug)); // GROUPS ARE A PARTITION OF A SITE, so a volume filter and the grouped render // cannot both be true: half a group is not a group. Filtering flattens. const sections = activeSite && locationFilter === null - ? buildChannelGroupSections(activeSite, channels, briefs, settings) + ? buildChannelGroupSections( + activeSite, + stats.filter((st) => shownSlugs.has(st.slug)), + briefs, + settings, + ) : null; - const shown = new Set(channels.map((c) => c.slug)); - const freshness = summariseFreshness(briefs.filter((b) => shown.has(b.slug))); + const freshness = summariseFreshness( + briefs.filter((b) => shownSlugs.has(b.slug)), + ); return ( // ON md+ THE DOCUMENT STOPS SCROLLING. The page is a flex column exactly // the height of the viewport (main carries py-6, hence -3rem) so that the @@ -448,7 +409,7 @@ export default async function ChannelsPage({ : "No channels yet."} </p> ) : ( - <ChannelsTable + <ChannelsRack channels={channels} columns={columns} sections={sections}