"use client";
import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
import { formatBytes } from "yt-dlp-transcript-common/lib/format";
import type {
StorageActionKind,
StorageRow,
StorageRowsPayload,
} from "yt-dlp-transcript-common/views/storage";
import { cancelJobAction } from "../../jobs/actions";
import {
addStorageLocationAction,
deleteStorageLocationAction,
editStorageLocationAction,
mountStorageLocationAction,
refreshStorageLocationAction,
repointStorageLocationAction,
} from "../actions";
import { LocationForm } from "./LocationForm";
import { SavedVideosStoreCard } from "./SavedVideosStoreCard";
import { ClipWindowsCard } from "./ClipWindowsCard";
// THE LOCATIONS, ONE CARD EACH, AND WHAT MAY BE DONE TO THEM.
//
// Every judgement on this screen was already made on the server:
// `common/views/storage.ts` decided each row's status, its counts, which of the
// five actions are offered and the sentence explaining each one that is not.
// This file renders that and calls the actions. It contains no rule — which is
// the point of the views layer, and what makes those rules unit-testable
// without a browser.
//
// TYPE-ONLY IMPORTS FROM views/storage. It is a "use client" file: the storage
// controller and `lib/storageVolumes.ts` both import execa, and a value import
// of either would fail `next build` with `node:child_process` in the client
// bundle.
//
// ⚠️ THE RE-POINT PANEL IS NEVER UNMOUNTED BY ITS OWN RESULT. StreamActionLog
// holds its streamed log in React state and calls router.refresh() the instant
// a run ends (plans/FACTS.md) — and that refresh re-renders this list with the
// location's status flipped from `mounted-elsewhere` to `available`, i.e. with
// the Re-point action no longer offered. A naive `{offered && }` would
// therefore delete the log of the run that just succeeded, at the exact moment
// the operator wants to read it. Same shape as StorageStage: a `ranHere` flag
// set inside the trigger keeps the panel mounted, and the now-cleared condition
// is fed to `disabled` so the panel that stayed for its log is not a second Run
// button.
export function StorageLocationsTable({ payload }: { payload: StorageRowsPayload }) {
const router = useRouter();
const [adding, setAdding] = useState(false);
return (
{payload.rows.length === 0 && (
No storage locations yet. A location is a named place a channel’s
media may live — usually a second drive. Add one below, then move a
channel onto it from its Storage panel.
)}
{payload.rows.map((row) => (
router.refresh()} />
))}
{/* AFTER THE LOCATIONS, BEFORE THE FORM. The store is a thing that lives
on a location, so it reads after the list of them — and before "Add a
location", which is the page's trailing affordance. */}
{payload.savedVideos && (
)}
{/* THE OTHER CORPUS-WIDE PILE OF BYTES, beside the store and for the same
reason: it is not a fact about any one location (windows are wherever
the channels are, on every drive at once) and it is the only control
that removes one. The total is the rows' own clip figures summed —
computed here rather than carried on the payload, because it is a
fold of numbers the payload already has. */}
n + r.clipsBytes, 0)}
/>
{/* The DISCLOSURE, not the submit. `add storage location` is the
form's own button (LocationForm), and two controls sharing one
accessible name is a screen reader reading the same sentence
for two different acts. */}
{/* "location path" and "volume identity", NOT "location root" and
"location identity". The form on this same page labels its fields
"location root" and "location id", and Playwright's getByLabel is a
SUBSTRING match resolved against every match: "location root" would
have been ambiguous with the read-out of the same name, and
"location id" is contained in "location identity". Filling the form
targeted this read-only
instead — the first e2e run failed
exactly there. Two accessible names where one contains the other are
a trap for a screen reader user for the same reason. */}
Root
{row.root}
Volume
{row.kind === "internal"
? "the corpus volume"
: (row.identity ??
"unknown — nothing here can ask (no findmnt, or a container)")}
Channels
{/* THE COUNT AND THE LINK ARE TWO ELEMENTS, not one. `location channels`
is the read-out's accessible name and the suite asserts its exact
text; a link folded into it appends "— list them" to that text and
breaks every caller. The link gets its own `
` on the next row of
the grid. */}
{row.channelsText}
{row.channels.total > 0 && (
<>
Browse
{/* THE ROW'S OWN LIST, largest first. A summary that cannot be
opened is a number the operator has to go and re-derive by
hand, which on this page is opening 71 channel pages. */}
the {row.channels.total} channel(s) on this location
>
)}
Media
{row.bytesText}
{/* THE INTERNAL ROW'S BREAKDOWN (release 17): every channel's text
and clip windows stay on the corpus volume, plus the media of the
channels in place. A location row's figure is its media tier
alone, and carries no breakdown. */}
{row.tiersText && (
{row.tiersText}
)}
{/* THE CLIPS SHARE, inside the figure and never beside it as a
second total: on the internal row (clips/ is never tiered), it is
already in the number above; what this adds is that some of it is
a CACHE nothing prunes. Absent when there are none. */}
{row.clipsText && (
{row.clipsText}
)}
The volume is mounted at a different place than this location’s
root. Re-pointing rewrites each channel’s media{" "}
symlink and its mediaDir, then the root.{" "}
No bytes move — the media is already there.
)}
);
}
// "4 s ago" / "3 m ago". Deliberately coarse: the number is there to say
// whether the row is answering about now or about the last time anybody looked,
// and a second-accurate age of a ten-second memo would be false precision.
function formatAge(ms: number): string {
if (ms < 1_000) return "just now";
const s = Math.round(ms / 1000);
if (s < 90) return `${s} s ago`;
const m = Math.round(s / 60);
if (m < 90) return `${m} m ago`;
return `${Math.round(m / 60)} h ago`;
}