"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"; import { RACK_IDENTITY, RACK_LAYERS } from "./rackLayout"; import type { ChannelColumnId, ChannelSortKey, } from "./channelColumnPresets"; // Re-exported for the client importers; a SERVER importer must take these from // ./channelColumnPresets.ts directly (see there). export { DASHBOARD_COLUMNS, RACK_COLUMNS, WORK_COLUMNS, type ChannelColumnId, type ChannelSortKey, type PipelineColumn, } from "./channelColumnPresets"; // 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`. // 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>; 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 ; 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; // Set on locale-formatted date cells; see Td in ./ChannelsTable.tsx. suppressHydrationWarning?: 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; 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, ChannelColumn > = { slug: { label: "Slug", sortKey: "slug", th: { stickyClassName: `sticky ${RACK_IDENTITY.slugLeft} ${RACK_LAYERS.identity} bg-muted shadow-[1px_0_0_var(--color-border)]`, }, cell: (c, ctx) => ({ className: ctx.sticky ? `sticky ${RACK_IDENTITY.slugLeft} ${RACK_LAYERS.identity} whitespace-nowrap font-mono shadow-[1px_0_0_var(--color-border)] ${ctx.stickyBg}` : "whitespace-nowrap font-mono", content: ( {c.slug} ), }), }, 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: ( ), }), }, 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: ( ), }), }, 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", suppressHydrationWarning: true, // 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.", }, // The rack says only the state word when the report cannot be trusted; a // work table ("Last report") keeps the date beside it — how stale is what // decides whether that row's count can be believed. cell: (c, ctx) => { const stamp = formatStamp(c.report.generatedAt); return { ariaLabel: `report age for ${c.slug}`, className: `whitespace-nowrap text-xs tabular-nums ${ c.report.state === "current" ? "text-muted-foreground" : "text-warning" }`, title: stamp || undefined, suppressHydrationWarning: true, content: c.report.state === "current" ? stamp : !ctx.sticky && c.report.state === "stale" && stamp ? `stale · ${stamp}` : 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 ?? (
), }), }, };