"use client"; // THE /channels SELECTION DECK — one docked panel for every bulk verb. // // It replaces TWO bars that both said "N selected" and both offered Clear: // `ChannelBulkBar` (tier + focus), which floated `sticky bottom-0`, and // `ChannelStorageBulkBar` (move media), which was a plain block after 67 rows // and could only be reached by scrolling to the very bottom. One selection with // two places to act on it is a mode the operator has to discover; the deck is // the console's master section instead — it appears with a selection, carries // every verb group at once, and unmounts when nothing is ticked. // // The verb groups are named by eyebrows (TIER · FOCUS · MEDIA) rather than // separated by rules alone, because they encode something true: these are // groups of controls over the same selection, not a toolbar of peers. // // ONE ACCESSIBLE HANDLE, deliberately kept: the root is still // `aria-label="channel priority bulk"`, which is what channel-priority.spec.ts // scopes `Apply tier` and `Focus these` to. The storage controls inside it are // addressed by their own labels, exactly as they were on the bar they came // from. // // NO PREVIEW GATE on the media move, unlike the channel's own Storage panel, // and the asymmetry is deliberate: a preview measures ONE channel's tree // against one volume, and the useful answer for a batch ("will all of these // fit") is not the sum of the previews — the root fills up as the jobs run. // Each job therefore re-checks space when it STARTS, and because every // relocation in the process shares one queue key the jobs run ONE AT A TIME — // so that check measures a root no other move is writing to, and a root that // fills partway refuses the remainder one job at a time with the reason in that // job's log. What the deck owes the operator is the count and the skips, not a // number that would be stale before the second job. import Link from "next/link"; import { useState, useTransition } from "react"; import { STORED_CHANNEL_TIERS, type StoredChannelTier, } from "yt-dlp-transcript-common/lib/channelPriority"; import { focusChannelsAction, setChannelTierAction } from "../actions"; import { bulkRelocateChannelMediaAction, type BulkRelocateResult, } from "../bulkStorageActions"; import type { MoveDestination } from "../lib/moveDestination"; import { useBarAction } from "./ChannelFocusBar"; import { RACK_LAYERS } from "./rackLayout"; const TIER_LABEL: Record = { normal: "Normal", low: "Low", paused: "Paused", }; // The page's structural device, shared with the nav groups and the lane strip: // a micro-caps mono eyebrow naming what the controls beside it do. const EYEBROW = "font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground"; // Each verb group after the first is fenced with a rule rather than a gap, so // the eye reads three groups and not eight loose buttons. const GROUP = "flex items-center gap-2 pl-4 border-l border-border"; // One configured destination, projected by the server that built the page. // Label and root only — the deck names a place and sends its id; whether that // drive is mounted is /storage's report, and a probe per location is not // something a table of 67 rows should pay for on every render. export type BulkDestination = { id: string; label: string; root: string }; // The select's escape hatch: the option that reveals the free-text root box. // Not a legal location id, so it can never collide with one. const CUSTOM = "__custom"; export function ChannelSelectionDeck({ slugs, onClear, destinations, defaultLocationId, freeUpNote = null, }: { slugs: string[]; onClear: () => void; // The configured storage locations, read on the server. Empty when none are // configured, which leaves the free-text box as the whole control and the // action refusing with "no destination root" as the reason. destinations: BulkDestination[]; // Which one the select opens on — `settings.storage.defaultLocationId`. defaultLocationId: string; // What "Free up N GB" came to, when that is how this selection was made. // The CONTROL is in the volume bar (it has to work with nothing ticked, and // the deck does not exist then); what belongs here is the answer, beside the // Move button that will act on it. freeUpNote?: string | null; }) { // Two runners, because they are two writers: the priority actions clear the // selection on success, the relocate queues jobs and keeps it. const { pending: priorityPending, error: priorityError, run, } = useBarAction(); const [tier, setTier] = useState("normal"); const [movePending, startMove] = useTransition(); const [destId, setDestId] = useState(() => destinations.some((d) => d.id === defaultLocationId) ? defaultLocationId : (destinations[0]?.id ?? CUSTOM), ); const [root, setRoot] = useState(""); const [result, setResult] = useState(null); const [moveError, setMoveError] = useState(null); if (slugs.length === 0) return null; const chosen = destinations.find((d) => d.id === destId) ?? null; const custom = destId === CUSTOM || chosen === null; const trimmed = root.trim(); // The id alone for a configured location; the typed root only for `__custom`. const destination: MoveDestination = custom ? { kind: "custom", root: trimmed } : { kind: "location", locationId: chosen.id }; const named = custom ? trimmed !== "" : true; const busy = priorityPending || movePending; return (
{slugs.length} selected
Tier
Focus
Media {/* ONE destination for the whole batch: a per-row destination is a per-row decision, and that is what the channel's own Storage panel is for. The destination is on the deck rather than in the button's label — `Move media to /mnt/platter/archilyzer-media` is what used to truncate to "Move media to…" and leave the operator unable to read where the files were going. */} {destinations.length > 0 && ( )} {chosen && ( {chosen.root} )} {custom && ( setRoot(e.target.value)} placeholder="/mnt/platter/archilyzer-media" className="w-full max-w-72 md:w-80 md:max-w-none rounded-md border border-border bg-card px-2 py-1 text-xs font-mono disabled:opacity-50" /> )}
{freeUpNote && (

{freeUpNote}

)} {(result || moveError || priorityError) && (
{result && ( `${s.slug}: ${s.reason}`) .join("\n") } > Queued {result.queued.length} · skipped {result.skipped.length} ·{" "} view jobs )} {moveError && ( {moveError} )} {priorityError && ( {priorityError} )}
)}
); }