"use client"; import Link from "next/link"; import { useState } from "react"; import { usePathname, useSearchParams } from "next/navigation"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; // One spelling of the param, shared with the /storage row that writes the link. // A value import from views/, which is client-safe: every import in that module // is a type import. import { LOCATION_FILTER_PARAM } from "yt-dlp-transcript-common/views/storage"; // THE VOLUME BRIDGE — how much room each disk has, said ONCE, and the filter. // // The operator's ask, verbatim (2026-09-20): "I'd like to see disk space and // current storage volume as columns on the channels menu, and let me filter by // volume for easy checking of things that may need moving or other processing." // // The volume of a ROW is a column (ChannelsTable's Location cell). Free space // is NOT: it is a fact about a disk, not about a channel, and repeating "67 GB // free" down 71 rows would be 71 copies of one number — the same mistake the // per-row focus sentence made before the focus bar took it. So it lives here, // one chip per volume, above the rack. // // THE CHIP IS THE FILTER, and it is a LINK. `?location=` is a URL param because // the page is a server component, the filter changes what the server sends, // and a link is shareable — /storage links straight to a filtered list. Client state would also lose the race with // the global AutoRefresh's router.refresh(), which is why the sort is the one // thing here that stays local. export type ChannelVolume = { // A location id, "internal" for the corpus volume, or "" for a mediaDir under // a root nobody named. id: string; label: string; channels: number; // Media bytes on this volume across the channels on screen, and how many of // them had no figure in their report. The second number is why the first is // honest — see storageBytesText. bytes: number; unmeasured: number; // statfs of the root, or undefined when the root is not there (an unmounted // drive). Never inferred from a parent — see volumeFreeBytes. freeBytes?: number; // "not answering since 11:35", while the health probe finds the drive not // answering (its free space is then not asked either). notAnswering?: string; // With `notAnswering`: how many clean checks clear it, in words ("twice in a // row"; settings.storage.health.clearAfterCleanPasses). clears?: string; }; export function ChannelVolumeBar({ volumes, active, onFreeUp, }: { volumes: ChannelVolume[]; // The id currently filtered to, or null for "every volume". active: string | null; // "Free up N GB": tick the largest in-place channels until the target is met. // IT LIVES HERE AND NOT ON THE SELECTION DECK because the deck only exists // once something is ticked, and this is the control whose whole job is to do // the ticking. The deck shows what it came to, beside the Move button that // acts on it. onFreeUp?: (targetGB: number) => void; }) { const pathname = usePathname(); const params = useSearchParams(); // THE SITE SCOPE SURVIVES THE VOLUME FILTER. They are two independent // questions ("whose channels" and "which disk"). The stored scope is a cookie // and not in the URL; a `?site=` link's scope is, and a chip that silently // dropped it would answer one question by discarding the other. const href = (id: string | null): string => { const next = new URLSearchParams(params.toString()); if (id === null) next.delete(LOCATION_FILTER_PARAM); else next.set(LOCATION_FILTER_PARAM, id); // A new filter is a new list; the sort /storage asked for belongs to the // link that carried it. next.delete("sort"); const q = next.toString(); return q ? `${pathname}?${q}` : pathname; }; if (volumes.length <= 1 && active === null && !onFreeUp) return null; return (