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:
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·A / Names·T
+ </summary>
+ <p className="pt-1">
+ Speaker names come from the audio (Names·A) or the
+ transcript (Names·T) — 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·A / Names·T
- </summary>
- <p className="pt-1">
- Speaker names come from the audio (Names·A) or the
- transcript (Names·T) — 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}`}>—</Td>;
+ return (
+ <Td pad={pad} className={`text-muted-foreground ${className}`}>
+ —
+ </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}