"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 { ChannelGroupSectionView } 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?: ChannelGroupSectionView[] | 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>(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(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(null); const theadRef = useRef(null); useEffect(() => { const region = regionRef.current; const thead = theadRef.current; if (!region || !thead || typeof ResizeObserver === "undefined") return; // `--rack-w` is the region's visible width: a group header's content pins // to the region's left edge and is capped at this, so its name and // stations stay on screen however far the columns scroll across // (ChannelGroupHeaderRow). const measure = () => { region.style.setProperty("--thead-h", `${thead.offsetHeight}px`); region.style.setProperty("--rack-w", `${region.clientWidth}px`); }; measure(); const observer = new ResizeObserver(measure); observer.observe(thead); observer.observe(region); 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.
{/* 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. */}
{sections && sections.length > 0 ? ( ) : ( )}
{columns.some((c) => c.id.startsWith("attribution-")) && (
Names·A / Names·T

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.

)}
{/* 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. `isolate` makes the region its own stacking context, so the whole ladder (rackLayout.ts) is ordered INSIDE it: without it the pinned cells and the thead competed with the page itself, and below md the thead scrolling under the screen-pinned selection deck (RACK_LAYERS.deck) painted over it. */}
setSelected( checked ? new Set(channels.map((c) => c.slug)) : new Set(), ), }} sections={grouped ? sections : null} siteId={siteId} sticky theadRef={theadRef} />
{/* 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. */} { 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} />
); }