fix(files): review round 1, the page — honest states, the keyboard, the tray's words

- A library past the list's cap is searched and sorted on the server; the count says "the newest".
- Each folder and each search opens on its first page (DataTable pageScope, useCardPager scope).
- A file dropped where the page takes none never reaches the browser (which would unload the app).
- The viewer's arrows wait while a layer opened from it is on top, and scroll a wide text line;
  a text shows its first 5000 lines and says where the rest is.
- Confirms name the file (no more "your downloads"); the asking menu rows end in "…".
- The tray names a running step beside its bar, shows a pause mark for paused work, polls slowly
  while only paused work waits, and forgets a finished file that was deleted.
- An unchanged rename sends nothing; a paste opens the download dialog only with nothing on top;
  the link box never replaces what was typed.
- Honest states: folders that failed to load, a file no longer there, the storage figure; the rail
  says "nothing downloaded" only when there is nothing.
- Focus after deleting the open folder and when the format form opens; the format name's label.
- ui/Meter (one danger line for both quota bars) and ui/RenameField in the platform layer.
- Copy fixes, stale comments and the speculative hub key; a contract test for the list's
  parameters; e2e: the Files page and the paste intake.
This commit is contained in:
2026-10-13 00:14:09 +02:00
parent 815083bcc4
commit 8b2260c9a8
41 changed files with 920 additions and 218 deletions
+1
View File
@@ -780,3 +780,4 @@ of a full-width panel and nothing on the right.
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing | | 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu | | 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin | | 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from "@playwright/test";
import { gotoPage, openApp } from "./helpers";
// The Files library end to end, against the real app: the page opens on the whole library, and a
// link — typed into its box, or pasted onto any page — opens the one download dialog on it. Nothing
// is downloaded: the dialog is closed, so the run leaves the E2E account's library as it found it.
test.describe("Files", () => {
const LINK = "https://www.youtube.com/watch?v=jNQXAC9IVRw";
const dialog = (page: import("@playwright/test").Page) =>
page.getByRole("dialog", { name: "Download video" });
test("opens on the whole library, and its link box hands a link to the download dialog", async ({
page,
}) => {
await openApp(page);
await gotoPage(page, "files");
await expect(page.getByRole("heading", { name: "All files" })).toBeVisible();
const box = page.getByRole("textbox", { name: "A link to download" });
await box.fill(LINK);
await box.press("Enter");
await expect(dialog(page)).toBeVisible();
await expect(dialog(page)).toContainText("jNQXAC9IVRw");
await page.keyboard.press("Escape");
await expect(dialog(page)).toBeHidden();
});
test("a link pasted onto a page (not into a field) opens the download dialog", async ({
page,
}) => {
await openApp(page);
await page.evaluate((text) => {
const e = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(e, "clipboardData", {
value: { getData: (type: string) => (type === "text/plain" ? text : "") },
});
document.body.dispatchEvent(e);
}, LINK);
await expect(dialog(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog(page)).toBeHidden();
});
});
+1 -1
View File
@@ -10,7 +10,7 @@ test.describe("page switching", () => {
// `nav-admin`) that opens a panel of them — the spec still listed them as rail rows of their own, // `nav-admin`) that opens a panel of them — the spec still listed them as rail rows of their own,
// and it failed on every run for a while without anyone seeing it, because e2e was in no // and it failed on every run for a while without anyone seeing it, because e2e was in no
// required gate (it is now part of `release`). // required gate (it is now part of `release`).
const RAIL = ["channels", "playlists", "stats", "settings", "feed"]; const RAIL = ["channels", "playlists", "files", "stats", "settings", "feed"];
const ADMIN = ["scheduler", "config", "users", "audit"]; const ADMIN = ["scheduler", "config", "users", "audit"];
test("each rail item activates its page", async ({ page }) => { test("each rail item activates its page", async ({ page }) => {
@@ -24,6 +24,7 @@ import DownloadIntake from "./DownloadIntake";
import { LinkBox } from "./LinkBox"; import { LinkBox } from "./LinkBox";
import { clearPendingDownload, requestLinkBox } from "../lib/downloadIntake"; import { clearPendingDownload, requestLinkBox } from "../lib/downloadIntake";
import { ensureNavId } from "../lib/history"; import { ensureNavId } from "../lib/history";
import { pushLayer, removeLayer } from "../lib/layerStack";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -79,6 +80,18 @@ describe("DownloadIntake · a pasted link", () => {
expect(dialog()).toBeNull(); expect(dialog()).toBeNull();
}); });
it("with a player or a menu on top (a layer, no aria-modal), a paste is not a download", async () => {
render(<DownloadIntake />);
const layer = pushLayer();
try {
paste(document.body, "https://a.example/1");
await act(async () => {});
expect(dialog()).toBeNull();
} finally {
removeLayer(layer);
}
});
it("closing the dialog closes it", async () => { it("closing the dialog closes it", async () => {
render(<DownloadIntake />); render(<DownloadIntake />);
paste(document.body, "https://a.example/1"); paste(document.body, "https://a.example/1");
@@ -102,6 +115,20 @@ describe("LinkBox", () => {
expect((field as HTMLInputElement).value).toBe(""); expect((field as HTMLInputElement).value).toBe("");
}); });
it("a clipboard link read on focus never replaces what was typed while it was read", async () => {
let answer: (text: string) => void = () => {};
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { readText: () => new Promise<string>((resolve) => (answer = resolve)) },
});
render(<LinkBox />);
const box = screen.getByRole("textbox");
fireEvent.focus(box);
fireEvent.change(box, { target: { value: "https://typed.example/1" } });
await act(async () => answer("https://clipboard.example/2"));
expect((box as HTMLInputElement).value).toBe("https://typed.example/1");
});
it("an empty box downloads nothing", async () => { it("an empty box downloads nothing", async () => {
render( render(
<> <>
@@ -17,6 +17,7 @@ const h = vi.hoisted(() => ({
share: vi.fn(async () => "https://x/watch/t"), share: vi.fn(async () => "https://x/watch/t"),
notify: vi.fn(), notify: vi.fn(),
prefs: new Map<string, unknown>(), prefs: new Map<string, unknown>(),
intervalMs: ((_d: unknown) => 0) as (d: unknown) => number,
})); }));
vi.mock("react-i18next", async (importOriginal) => ({ vi.mock("react-i18next", async (importOriginal) => ({
@@ -28,13 +29,16 @@ vi.mock("react-i18next", async (importOriginal) => ({
}), }),
})); }));
vi.mock("../lib/useLiveQuery", () => ({ vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: () => ({ useLiveQuery: (_key: unknown, _fn: unknown, opts: { intervalMs: (d: unknown) => number }) => (
data: h.jobs, (h.intervalMs = opts.intervalMs),
dataUpdatedAt: h.updatedAt, {
isError: false, data: h.jobs,
isLoading: false, dataUpdatedAt: h.updatedAt,
refetch: vi.fn(), isError: false,
}), isLoading: false,
refetch: vi.fn(),
}
),
})); }));
// The prefs as state over a map that outlives a mount — a reload, as far as the tray can tell. How // The prefs as state over a map that outlives a mount — a reload, as far as the tray can tell. How
// usePref stores them is its own business (usePref.dom.test). // usePref stores them is its own business (usePref.dom.test).
@@ -137,6 +141,31 @@ describe("DownloadTray", () => {
expect(within(rowOf("Clip 2")).getByText("downloads.status.queued")).toBeTruthy(); expect(within(rowOf("Clip 2")).getByText("downloads.status.queued")).toBeTruthy();
}); });
it("a running job names its step: the second stream's bar does not read as a restart", () => {
poll([job(1, "running", { phase: "audio" }), job(2, "running", { phase: "merging" })]);
mount();
const [audio, merging] = screen.getAllByRole("progressbar");
expect(within(rowOf("Clip 1")).getByText("downloads.phase.audio")).toBeTruthy();
expect(audio!.getAttribute("aria-valuenow")).toBe("40");
// A step that reports no bytes has no % to claim.
expect(within(rowOf("Clip 2")).getByText("downloads.phase.merging")).toBeTruthy();
expect(merging!.getAttribute("aria-valuenow")).toBeNull();
expect(within(rowOf("Clip 2")).queryByText("40%")).toBeNull();
});
it("a paused download is waited on slowly, and the tray neither spins nor says done", () => {
poll([job(1, "paused")]);
mount();
// Nothing moves until its user resumes it: no fast polling for that.
expect(h.intervalMs([job(1, "paused")])).toBe(60000);
expect(h.intervalMs([job(1, "paused"), job(2, "queued")])).toBe(2000);
const marks = [...tray()!.querySelectorAll("svg")].map((i) => i.getAttribute("class") ?? "");
expect(marks.some((c) => /lucide-(circle-check|check-circle)/.test(c))).toBe(false);
expect(
marks.some((c) => /lucide-(circle-pause|pause-circle)/.test(c) && /text-warn/.test(c)),
).toBe(true);
});
it("a failure says why, and its Retry resumes it", () => { it("a failure says why, and its Retry resumes it", () => {
const resume = vi.spyOn(api, "resumeDownload").mockResolvedValue({} as never); const resume = vi.spyOn(api, "resumeDownload").mockResolvedValue({} as never);
poll([job(3, "error", { error_code: "login_required" })]); poll([job(3, "error", { error_code: "login_required" })]);
+49 -16
View File
@@ -9,6 +9,7 @@ import {
FolderOpen, FolderOpen,
Link, Link,
Loader2, Loader2,
PauseCircle,
RotateCw, RotateCw,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -17,6 +18,8 @@ import { quickShareLink } from "../lib/downloads";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure"; import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import { import {
IN_FLIGHT, IN_FLIGHT,
DOWNLOAD_PHASES,
MOVING,
clearFinished, clearFinished,
doneRevealing, doneRevealing,
fetchQueue, fetchQueue,
@@ -66,22 +69,39 @@ const BYTE_PHASES = ["video", "audio", "editing", "converting"];
const keyOf = (job: QueueJob) => job.id; const keyOf = (job: QueueJob) => job.id;
// The step a running job is on is named beside its bar: a download fetches its video and its audio
// as two streams, so the bar fills twice — without the name, the second start reads as a restart.
function Progress({ job }: { job: QueueJob }) { function Progress({ job }: { job: QueueJob }) {
if (job.phase && !BYTE_PHASES.includes(job.phase)) const { t } = useTranslation();
return ( const bytes = !job.phase || BYTE_PHASES.includes(job.phase);
<span className="block h-1 overflow-hidden rounded-full bg-fg/10"> const step =
<span className="block h-full w-full animate-pulse bg-accent/60" /> job.phase && DOWNLOAD_PHASES.includes(job.phase) ? t(`downloads.phase.${job.phase}`) : null;
</span>
);
return ( return (
<span className="flex items-center gap-2 text-xs text-muted"> <span className="flex min-w-0 items-center gap-2 text-xs text-muted">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-fg/10"> {/* One column for the step, so every row's bar starts on the same line. */}
<span {step && (
className="block h-full rounded-full bg-accent transition-all" <span className="w-36 shrink-0 truncate" title={step}>
style={{ width: `${job.progress}%` }} {step}
/> </span>
)}
<span
role="progressbar"
aria-label={step ?? t("downloads.status.running")}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={bytes ? job.progress : undefined}
className="h-1 min-w-12 flex-1 overflow-hidden rounded-full bg-fg/10"
>
{bytes ? (
<span
className="block h-full rounded-full bg-accent transition-all"
style={{ width: `${job.progress}%` }}
/>
) : (
<span className="block h-full w-full animate-pulse bg-accent/60" />
)}
</span> </span>
<span className="w-8 text-right tabular-nums">{job.progress}%</span> {bytes && <span className="w-8 shrink-0 text-right tabular-nums">{job.progress}%</span>}
</span> </span>
); );
} }
@@ -95,7 +115,7 @@ export default function DownloadTray() {
// Every page polls it now, so the idle cadence is slow: a job started here refetches at once // Every page polls it now, so the idle cadence is slow: a job started here refetches at once
// (invalidateDownloads), and one started on another device shows within a minute. // (invalidateDownloads), and one started on another device shows within a minute.
const queueQ = useLiveQuery(qk.downloads(), fetchQueue, { const queueQ = useLiveQuery(qk.downloads(), fetchQueue, {
intervalMs: (data) => (data?.some((j) => IN_FLIGHT.has(j.status)) ? 2000 : 60000), intervalMs: (data) => (data?.some((j) => MOVING.has(j.status)) ? 2000 : 60000),
}); });
const finished = useFinished(); const finished = useFinished();
const active = (queueQ.data ?? []).filter((j) => IN_FLIGHT.has(j.status) || j.status === "error"); const active = (queueQ.data ?? []).filter((j) => IN_FLIGHT.has(j.status) || j.status === "error");
@@ -225,13 +245,26 @@ export default function DownloadTray() {
const meanProgress = running.length const meanProgress = running.length
? Math.round(running.reduce((sum, j) => sum + j.progress, 0) / running.length) ? Math.round(running.reduce((sum, j) => sum + j.progress, 0) / running.length)
: null; : null;
const StateIcon = counts.running ? Loader2 : counts.error ? AlertTriangle : CheckCircle2; // The tray's state, worst news first; a paused job is neither working nor done.
const StateIcon = counts.running
? Loader2
: counts.error
? AlertTriangle
: counts.paused
? PauseCircle
: CheckCircle2;
const stateIcon = ( const stateIcon = (
<StateIcon <StateIcon
aria-hidden aria-hidden
className={clsx( className={clsx(
"size-4 shrink-0", "size-4 shrink-0",
counts.running ? "animate-spin text-accent" : counts.error ? "text-danger" : "text-success", counts.running
? "animate-spin text-accent"
: counts.error
? "text-danger"
: counts.paused
? "text-warn"
: "text-success",
)} )}
/> />
); );
@@ -21,6 +21,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
import FilePreview from "./FilePreview"; import FilePreview from "./FilePreview";
import { pushLayer, removeLayer } from "../lib/layerStack";
const file = (id: number, preview: string | null, extra: Record<string, unknown> = {}) => const file = (id: number, preview: string | null, extra: Record<string, unknown> = {}) =>
({ ({
@@ -97,6 +98,21 @@ describe("FilePreview · what it shows", () => {
]); ]);
}); });
it("lays out a long text's first lines only, and says where the rest is", async () => {
// Every line is a row of the page: a log of tens of thousands would stall the viewer.
const text = Array.from({ length: 6000 }, (_, i) => `line ${i + 1}`).join(
String.fromCharCode(10),
);
vi.spyOn(api, "previewText").mockResolvedValue({ text, truncated: false });
mount([file(9, "text")], 9);
await screen.findByText("line 1");
expect(document.querySelectorAll("li")).toHaveLength(5000);
expect(screen.getByText(/files\.preview\.firstLines/)).toBeTruthy();
expect(screen.getByText("files.preview.downloadWhole").closest("a")!.getAttribute("href")).toBe(
api.fileUrl(9),
);
});
it("says when it shows only the start of a long text", async () => { it("says when it shows only the start of a long text", async () => {
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a", truncated: true }); vi.spyOn(api, "previewText").mockResolvedValue({ text: "a", truncated: true });
mount([file(4, "text")], 4); mount([file(4, "text")], 4);
@@ -141,6 +157,30 @@ describe("FilePreview · stepping", () => {
expect(onFileChange).toHaveBeenLastCalledWith(1); expect(onFileChange).toHaveBeenLastCalledWith(1);
}); });
it("a layer opened from it (a menu, a confirm) keeps its arrows — the viewer stays on its file", () => {
const { onFileChange } = mount(list, 2);
const confirm = pushLayer();
try {
fireEvent.keyDown(window, { key: "ArrowRight" });
expect(onFileChange).not.toHaveBeenCalled();
} finally {
removeLayer(confirm);
}
fireEvent.keyDown(window, { key: "ArrowRight" });
expect(onFileChange).toHaveBeenLastCalledWith(3);
});
it("a text line wider than the stage scrolls sideways under ← / →, it does not page", async () => {
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a very long line", truncated: false });
const { onFileChange } = mount([file(1, "image"), file(8, "text"), file(9, "text")], 8);
await screen.findByText("a very long line");
const region = screen.getByRole("region");
Object.defineProperty(region, "scrollWidth", { value: 900 });
Object.defineProperty(region, "clientWidth", { value: 300 });
fireEvent.keyDown(region, { key: "ArrowRight" });
expect(onFileChange).not.toHaveBeenCalled();
});
it("stops at either end", () => { it("stops at either end", () => {
const { onFileChange } = mount(list, 1); const { onFileChange } = mount(list, 1);
fireEvent.keyDown(window, { key: "ArrowLeft" }); fireEvent.keyDown(window, { key: "ArrowLeft" });
+27 -3
View File
@@ -12,6 +12,7 @@ import {
import { api, HttpError, type FilePreviewKind, type LibraryFile } from "../lib/api"; import { api, HttpError, type FilePreviewKind, type LibraryFile } from "../lib/api";
import { formatBytes } from "../lib/format"; import { formatBytes } from "../lib/format";
import { dispatch, useKeyScope } from "../lib/keymap"; import { dispatch, useKeyScope } from "../lib/keymap";
import { useLayer } from "../lib/layerStack";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { jobTitle } from "./downloadTitle"; import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "./ui/ActionMenu"; import { type ActionMenuItem } from "./ui/ActionMenu";
@@ -32,6 +33,8 @@ import { useContextMenu } from "./ui/useContextMenu";
// //
// Every kind fills the same stage, so a step never resizes the dialog: its ‹ › stay under the pointer. // Every kind fills the same stage, so a step never resizes the dialog: its ‹ › stay under the pointer.
const STAGE = "h-[min(75dvh,56rem)] w-full"; const STAGE = "h-[min(75dvh,56rem)] w-full";
// How many lines of a text file the viewer lays out (see TextStage).
const TEXT_LINES = 5000;
/** A text file's head (one read, shared by the viewer's header and its stage). */ /** A text file's head (one read, shared by the viewer's header and its stage). */
function useTextPreview(id: number, enabled: boolean) { function useTextPreview(id: number, enabled: boolean) {
@@ -74,7 +77,17 @@ export default function FilePreview({
if (next) onFileChange(next.id); if (next) onFileChange(next.id);
}; };
useKeyScope("fileViewer"); useKeyScope("fileViewer");
// The viewer's own place on the layer stack (above its dialog): ← / → are its only while nothing is
// on top of it — its "…" menu, a confirm, the Share or Details dialog opened from that menu. There an
// arrow moves through the layer's own buttons; paging the viewer under it would show one file while
// the dialog acts on another.
const layer = useLayer(true, undefined, { passive: true });
const onKey = useEffectEvent((e: KeyboardEvent) => { const onKey = useEffectEvent((e: KeyboardEvent) => {
if (layer.blockingAbove()) return;
// The text, scrolled sideways by its arrows: a line wider than the stage is read that way.
const scroller =
e.target instanceof Element ? e.target.closest("[data-scrolls-sideways]") : null;
if (scroller && scroller.scrollWidth > scroller.clientWidth) return;
dispatch(e, "fileViewer", { dispatch(e, "fileViewer", {
prevFile: (ev) => { prevFile: (ev) => {
ev.preventDefault(); ev.preventDefault();
@@ -217,8 +230,11 @@ function TextStage({ file }: { file: LibraryFile }) {
</StateMessage> </StateMessage>
); );
if (!q.data) return <LoadingState className={`${STAGE} justify-center`} />; if (!q.data) return <LoadingState className={`${STAGE} justify-center`} />;
// A file's last line ends in a newline; that is no line of its own. // A file's last line ends in a newline; that is no line of its own. Only the first TEXT_LINES are
const lines = q.data.text.replace(/\r?\n$/, "").split(/\r?\n/); // laid out: every line is a row of the page, and a head of short lines (a log) runs to tens of
// thousands of them — a stall on open and on every wrap toggle. The rest is behind Download.
const all = q.data.text.replace(/\r?\n$/, "").split(/\r?\n/);
const lines = all.length > TEXT_LINES ? all.slice(0, TEXT_LINES) : all;
return ( return (
<div className={`${STAGE} flex flex-col gap-2`}> <div className={`${STAGE} flex flex-col gap-2`}>
<div className="flex items-center gap-2 text-xs text-muted"> <div className="flex items-center gap-2 text-xs text-muted">
@@ -239,8 +255,10 @@ function TextStage({ file }: { file: LibraryFile }) {
</IconButton> </IconButton>
</div> </div>
<div <div
// Scrolls by keyboard too: a focusable region with a name. // Scrolls by keyboard too: a focusable region with a name — sideways as well, its ← / →
// taken from the viewer's paging while a line runs past the edge.
tabIndex={0} tabIndex={0}
data-scrolls-sideways
role="region" role="region"
aria-label={jobTitle(file)} aria-label={jobTitle(file)}
className="min-h-0 flex-1 overflow-auto rounded-md border border-border bg-bg font-mono text-xs leading-relaxed" className="min-h-0 flex-1 overflow-auto rounded-md border border-border bg-bg font-mono text-xs leading-relaxed"
@@ -262,6 +280,12 @@ function TextStage({ file }: { file: LibraryFile }) {
</li> </li>
))} ))}
</ol> </ol>
{lines.length < all.length && (
<p className="m-0 border-t border-border px-3 py-2 font-sans text-muted">
{t("files.preview.firstLines", { lines: TEXT_LINES })}{" "}
<LinkButton href={api.fileUrl(file.id)}>{t("files.preview.downloadWhole")}</LinkButton>
</p>
)}
</div> </div>
</div> </div>
); );
+193 -33
View File
@@ -26,6 +26,14 @@ const mocks = vi.hoisted(() => ({
confirm: vi.fn(async (_o?: unknown) => true), confirm: vi.fn(async (_o?: unknown) => true),
keys: [] as unknown[], keys: [] as unknown[],
fetching: false, fetching: false,
titleArgs: [] as unknown[],
searched: [] as unknown[],
foldersFailed: false,
notify: vi.fn(),
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: mocks.notify,
})); }));
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
@@ -34,19 +42,39 @@ vi.mock("react-i18next", async (importOriginal) => {
...actual, ...actual,
useTranslation: () => ({ useTranslation: () => ({
i18n: { language: "en" }, i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) => t: (key: string, o?: Record<string, unknown>) => {
o && "folder" in o ? `${key}:${String(o.folder)}` : key, if (o) mocks.titleArgs.push([key, o]);
return o && "folder" in o ? `${key}:${String(o.folder)}` : key;
},
}), }),
}; };
}); });
vi.mock("./LibraryPlayer", () => ({
default: ({ job, fromStart }: { job: { id: number }; fromStart?: boolean }) => (
<div
role="dialog"
aria-label="player"
>{`playing ${job.id}${fromStart ? " from the start" : ""}`}</div>
),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({ vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: (key: readonly unknown[]) => { useLiveQuery: (key: readonly unknown[], _fn: unknown, opts?: { enabled?: boolean }) => {
if (opts?.enabled === false) return { data: undefined, isError: false, isLoading: false };
mocks.keys.push(key); mocks.keys.push(key);
const asked = key[1] as { q?: string; sort?: string } | undefined;
return { return {
data: data:
key[0] === "files" ? mocks.files : key[0] === "file-folders" ? mocks.folders : undefined, key[0] === "files"
isError: false, ? asked?.q || asked?.sort
? mocks.searched
: mocks.files
: key[0] === "file-folders"
? mocks.foldersFailed
? undefined
: mocks.folders
: undefined,
isError: key[0] === "file-folders" && mocks.foldersFailed,
isLoading: false, isLoading: false,
isFetching: mocks.fetching, isFetching: mocks.fetching,
refetch: vi.fn(), refetch: vi.fn(),
@@ -59,6 +87,7 @@ vi.mock("./ui/PageShell", async (importOriginal) => ({
})); }));
import Files from "./Files"; import Files from "./Files";
import { FILES_LIST_CAP } from "../lib/api";
const file = (id: number, extra: Partial<LibraryFile> = {}): LibraryFile => const file = (id: number, extra: Partial<LibraryFile> = {}): LibraryFile =>
({ ({
@@ -172,10 +201,10 @@ describe("Files · my file's row", () => {
"files.actions.move", "files.actions.move",
"files.actions.keep", "files.actions.keep",
"files.actions.details", "files.actions.details",
"downloads.actions.redownload", "files.actions.redownload",
"common.copy.title", "common.copy.title",
"files.actions.copySourceLink", "files.actions.copySourceLink",
"downloads.actions.delete", "files.actions.delete",
]); ]);
}); });
@@ -265,6 +294,23 @@ describe("Files · my file's row", () => {
await waitFor(() => expect(update).toHaveBeenLastCalledWith(2, { name: null })); await waitFor(() => expect(update).toHaveBeenLastCalledWith(2, { name: null }));
}); });
it("a rename left as it was sends nothing — a name never set stays unset", async () => {
// The field starts on the title the file shows; saving that would pin it as a name of its own.
mocks.files = [file(2, { display_name: null, asset: { title: "Its own title" } } as never)];
mocks.folders = [YT];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Its own title")));
fireEvent.click(menuItem("files.actions.rename"));
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
expect((field as HTMLInputElement).value).toBe("Its own title");
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() =>
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull(),
);
expect(update).not.toHaveBeenCalled();
});
it("Escape leaves the name as it was", async () => { it("Escape leaves the name as it was", async () => {
mocks.files = [file(2)]; mocks.files = [file(2)];
mocks.folders = [YT]; mocks.folders = [YT];
@@ -329,15 +375,39 @@ describe("Files · my file's row", () => {
mocks.confirm.mockResolvedValueOnce(false); mocks.confirm.mockResolvedValueOnce(false);
mount(); mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.delete")); fireEvent.click(menuItem("files.actions.delete"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
expect(del).not.toHaveBeenCalled(); expect(del).not.toHaveBeenCalled();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.delete")); fireEvent.click(menuItem("files.actions.delete"));
await waitFor(() => expect(del).toHaveBeenCalledWith(2)); await waitFor(() => expect(del).toHaveBeenCalledWith(2));
}); });
}); });
describe("Files · a library past the list's cap", () => {
it("a search under the cap runs on the rows; one past it asks the server, so an old file is found", () => {
mocks.folders = [YT];
mocks.files = [file(1, { display_name: "Recept" }), file(2)];
mocks.keys = [];
mount(ALL_FILES, vi.fn(), "recept");
expect(rowOf("Recept")).toBeTruthy();
expect(
mocks.keys.some(
(k) => (k as unknown[])[0] === "files" && "q" in ((k as unknown[])[1] as object),
),
).toBe(false);
cleanup();
// The newest FILES_LIST_CAP files came back: the one searched for may be older than all of them.
mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
mocks.searched = [file(9999, { display_name: "Régi recept" })];
mocks.keys = [];
mount(ALL_FILES, vi.fn(), "recept");
expect(rowOf("Régi recept")).toBeTruthy();
expect(mocks.keys).toContainEqual(["files", { q: "recept" }]);
});
});
describe("Files · the confirms", () => { describe("Files · the confirms", () => {
it("label the confirm button with the action, not the dialog's question", async () => { it("label the confirm button with the action, not the dialog's question", async () => {
mocks.files = [file(2)]; mocks.files = [file(2)];
@@ -345,10 +415,10 @@ describe("Files · the confirms", () => {
mocks.confirm.mockResolvedValue(false); mocks.confirm.mockResolvedValue(false);
mount(); mount();
await openMenu(() => fireEvent.click(moreOf("Clip 2"))); await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.redownload")); fireEvent.click(menuItem("files.actions.redownload"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
await openMenu(() => fireEvent.click(moreOf("Clip 2"))); await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.delete")); fireEvent.click(menuItem("files.actions.delete"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2)); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
expect( expect(
mocks.confirm.mock.calls.map(([o]) => { mocks.confirm.mock.calls.map(([o]) => {
@@ -363,20 +433,21 @@ describe("Files · the confirms", () => {
}); });
it.each([ it.each([
["upload", "downloads.confirm.deleteUploadTitle", "downloads.confirm.deleteUploadBody"], ["upload", "downloads.confirm.deleteUploadBody"],
["edit", "downloads.confirm.deleteEditTitle", "downloads.confirm.deleteEditBody"], ["edit", "downloads.confirm.deleteEditBody"],
["download", "downloads.confirm.deleteTitle", "downloads.confirm.deleteBody"], ["download", "downloads.confirm.deleteBody"],
] as const)("deleting a %s says what happens to its file", async (kind, title, body) => { ] as const)("deleting a %s names it and says what happens to its file", async (kind, body) => {
// Only a download's file may stay cached for other users; an upload's or a clip's is theirs alone. // Only a download's file may stay for other users; an upload's or a clip's is theirs alone.
mocks.files = [file(5, { job_kind: kind })]; mocks.files = [file(5, { job_kind: kind })];
mocks.folders = [YT]; mocks.folders = [YT];
mocks.confirm.mockResolvedValueOnce(false); mocks.confirm.mockResolvedValueOnce(false);
mount(); mount();
await openMenu(() => fireEvent.click(moreOf("Clip 5"))); await openMenu(() => fireEvent.click(moreOf("Clip 5")));
fireEvent.click(menuItem("downloads.actions.delete")); fireEvent.click(menuItem("files.actions.delete"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
const opts = mocks.confirm.mock.calls[0]![0] as { title: string; message?: string }; const opts = mocks.confirm.mock.calls[0]![0] as { title: string; message?: string };
expect([opts.title, opts.message]).toEqual([title, body]); expect([opts.title, opts.message]).toEqual(["downloads.confirm.deleteTitle", body]);
expect(mocks.titleArgs).toContainEqual(["downloads.confirm.deleteTitle", { name: "Clip 5" }]);
}); });
}); });
@@ -393,9 +464,9 @@ describe("Files · a shared file's row", () => {
"downloads.actions.download", "downloads.actions.download",
"downloads.actions.edit", "downloads.actions.edit",
"common.copy.title", "common.copy.title",
"downloads.actions.removeShared", "files.actions.removeShared",
]); ]);
fireEvent.click(menuItem("downloads.actions.removeShared")); fireEvent.click(menuItem("files.actions.removeShared"));
await waitFor(() => expect(remove).toHaveBeenCalledWith(4)); await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
// The shared list is its own read, and nothing can be uploaded into it. // The shared list is its own read, and nothing can be uploaded into it.
expect(mocks.keys).toContainEqual(["files", { shared: true }]); expect(mocks.keys).toContainEqual(["files", { shared: true }]);
@@ -547,7 +618,7 @@ describe("Files · the grid", () => {
expect(rowOf("Clip 2").textContent).toContain("files.retention.kept"); expect(rowOf("Clip 2").textContent).toContain("files.retention.kept");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toContain("files.actions.move"); expect(menuRows()).toContain("files.actions.move");
expect(menuRows()).toContain("downloads.actions.delete"); expect(menuRows()).toContain("files.actions.delete");
}); });
it("is remembered per folder", async () => { it("is remembered per folder", async () => {
@@ -765,17 +836,17 @@ describe("Files · arriving at one file", () => {
}); });
}); });
describe("Files · dropping a file on the page", () => { const drag = (type: string, files: File[] = []) => {
const drag = (type: string, files: File[] = []) => { const e = new Event(type, { bubbles: true, cancelable: true });
const e = new Event(type, { bubbles: true, cancelable: true }); const list = Object.assign([...files], { item: (i: number) => files[i] ?? null });
const list = Object.assign([...files], { item: (i: number) => files[i] ?? null }); Object.defineProperty(e, "dataTransfer", { value: { types: ["Files"], files: list } });
Object.defineProperty(e, "dataTransfer", { value: { types: ["Files"], files: list } }); act(() => {
act(() => { window.dispatchEvent(e);
window.dispatchEvent(e); });
}); return e;
return e; };
};
describe("Files · dropping a file on the page", () => {
it("names the folder it lands in, and opens Upload with the file", async () => { it("names the folder it lands in, and opens Upload with the file", async () => {
mocks.files = []; mocks.files = [];
mocks.folders = [YT, OWN]; mocks.folders = [YT, OWN];
@@ -789,11 +860,100 @@ describe("Files · dropping a file on the page", () => {
expect(await screen.findByText("recept.pdf")).toBeTruthy(); expect(await screen.findByText("recept.pdf")).toBeTruthy();
}); });
it("is no target where nothing can be uploaded", () => { it("is no target where nothing can be uploaded — but the browser never gets the file", () => {
mocks.files = []; mocks.files = [];
mocks.folders = [YT]; mocks.folders = [YT];
mount({ kind: "shared" }); mount({ kind: "shared" });
expect(drag("dragenter").defaultPrevented).toBe(false); drag("dragenter");
expect(screen.queryByText(/files\.drop\.here/)).toBeNull(); expect(screen.queryByText(/files\.drop\.here/)).toBeNull();
// Left to the browser, a dropped file opens in the tab and the app is gone.
const drop = drag("drop", [new File(["x"], "recept.pdf")]);
expect(drop.defaultPrevented).toBe(true);
expect(screen.queryByText("recept.pdf")).toBeNull();
});
it("a second file dropped beside the open Upload dialog does not unload the app", async () => {
mocks.files = [];
mocks.folders = [YT, OWN];
vi.spyOn(api, "filesUsage").mockResolvedValue({ upload_max_bytes: 1_000_000 } as never);
mount({ kind: "folder", id: 2 });
drag("drop", [new File(["x"], "recept.pdf")]);
expect(await screen.findByText("recept.pdf")).toBeTruthy();
expect(drag("dragover").defaultPrevented).toBe(true);
expect(drag("drop", [new File(["y"], "masik.pdf")]).defaultPrevented).toBe(true);
});
});
describe("Files · when what it needs is missing", () => {
afterEach(() => {
mocks.foldersFailed = false;
mocks.fetching = false;
mocks.notify.mockClear();
});
it("folders that failed to load are said to be missing, never shown as none — and take no drop", async () => {
mocks.files = [file(2)];
mocks.foldersFailed = true;
mount({ kind: "folder", id: 2 });
expect(screen.getByText("files.folderUnknown")).toBeTruthy();
// Dropped now, the file would land in Uploads instead of the folder on screen.
drag("dragenter");
expect(screen.queryByText(/files\.drop\.here/)).toBeNull();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.move"));
expect(await screen.findByText("files.actions.foldersUnknown")).toBeTruthy();
expect(screen.queryByText("files.actions.noOwnFolders")).toBeNull();
});
it("a folder the cached list does not have yet is waited for, not taken for gone", () => {
mocks.files = [file(2)];
mocks.folders = [YT];
mocks.fetching = true;
const setView = mount({ kind: "folder", id: 7 });
expect(setView).not.toHaveBeenCalled();
});
it("opening a file that is no longer there says so", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
mount(ALL_FILES, vi.fn(), "", { focus: { id: 9, token: 1 } });
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith({ level: "info", message: "files.arrival.gone" }),
);
});
});
describe("Files · the open folder deleted", () => {
it("hands the keyboard to the page's heading, not to <body>", async () => {
// The deleted folder's "…" held the focus and is gone with it.
mocks.files = [];
mocks.folders = [YT];
(document.activeElement as HTMLElement | null)?.blur();
mount({ kind: "folder", id: 2 });
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "files.rail.all" })),
);
});
});
describe("Files · a file watched part way", () => {
it("leads with Continue and offers Start over, which plays it from the start", async () => {
mocks.files = [file(2, { position_seconds: 120 })];
mocks.folders = [YT];
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
fireEvent.click(menuItem("card.restartTitle"));
expect(await screen.findByText("playing 2 from the start")).toBeTruthy();
});
it("one never played has no Start over, and plays where it is", async () => {
mocks.files = [file(3)];
mocks.folders = [YT];
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 3")));
expect(menuRows()).not.toContain("card.restartTitle");
fireEvent.click(menuItem("downloads.actions.play"));
expect(await screen.findByText("playing 3")).toBeTruthy();
}); });
}); });
+86 -23
View File
@@ -1,7 +1,15 @@
import { lazy, Suspense, useEffect, useEffectEvent, useRef, useState } from "react"; import { lazy, Suspense, useEffect, useEffectEvent, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Download, LayoutGrid, Link, List, Scissors, Share2, UploadCloud } from "lucide-react"; import { Download, LayoutGrid, Link, List, Scissors, Share2, UploadCloud } from "lucide-react";
import { api, type FileFolder, type FileListParams, type LibraryFile } from "../lib/api"; import {
api,
FILE_SORTS,
FILES_LIST_CAP,
type FileFolder,
type FileListParams,
type FileSort,
type LibraryFile,
} from "../lib/api";
import { type SortState, sortRows } from "../lib/columnSort"; import { type SortState, sortRows } from "../lib/columnSort";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions"; import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { formatBytes, formatDateTime, relativeTime } from "../lib/format"; import { formatBytes, formatDateTime, relativeTime } from "../lib/format";
@@ -10,6 +18,7 @@ import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { LS, accountKey } from "../lib/storage"; import { LS, accountKey } from "../lib/storage";
import { useCardPager } from "../lib/useCardPager"; import { useCardPager } from "../lib/useCardPager";
import { useDebounced } from "../lib/useDebounced";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { usePref } from "../lib/usePref"; import { usePref } from "../lib/usePref";
import { JobThumb } from "./downloadRow"; import { JobThumb } from "./downloadRow";
@@ -21,7 +30,7 @@ import { folderLabel } from "./fileFolders";
import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext"; import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
import { useFileActions } from "./useFileActions"; import { useFileActions } from "./useFileActions";
import { useFolderActions } from "./useFolderActions"; import { useFolderActions } from "./useFolderActions";
import { RenameField } from "./RenameField"; import { RenameField } from "./ui/RenameField";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import { Badge } from "./ui/Badge"; import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
@@ -86,8 +95,8 @@ function shownFileElement(within: HTMLElement, id: number): HTMLElement | null {
return all.find((el) => el.getClientRects().length > 0) ?? all[0] ?? null; return all.find((el) => el.getClientRects().length > 0) ?? all[0] ?? null;
} }
/** The list a view reads. The search is applied here, on the rows (the list is the whole library, /** The list a view reads. Below the server's cap it holds every file of the view, so the search and
* capped server-side): a request per keystroke would buy nothing the rows do not already hold. */ * the sort run here, on the rows; a view that fills the cap asks the server for both (see below). */
function listParams(view: FilesView): FileListParams { function listParams(view: FilesView): FileListParams {
if (view.kind === "shared") return { shared: true }; if (view.kind === "shared") return { shared: true };
if (view.kind === "folder") return { folderId: view.id }; if (view.kind === "folder") return { folderId: view.id };
@@ -165,18 +174,49 @@ export default function Files() {
const folders = foldersQ.data ?? []; const folders = foldersQ.data ?? [];
// A remembered or bookmarked folder that is gone (deleted, or another account's id) opens the whole // A remembered or bookmarked folder that is gone (deleted, or another account's id) opens the whole
// library instead of an empty page that names nothing; the provider is told, so the rail agrees. // library instead of an empty page that names nothing; the provider is told, so the rail agrees.
const gone = view.kind === "folder" && !!foldersQ.data && !folders.some((f) => f.id === view.id); // Decided on a fresh list only: a folder made since the cached one (a first download from a new
// site, opened from its notice) is not gone, just not in the cache yet.
const gone =
view.kind === "folder" &&
!!foldersQ.data &&
!foldersQ.isFetching &&
!folders.some((f) => f.id === view.id);
const shown = gone ? ALL_FILES : view; const shown = gone ? ALL_FILES : view;
// A folder deleted from its own "…" (or its rail entry) takes the focused button with it: the
// keyboard goes to the page's heading, never to <body>, where Tab would start the page over.
const heading = useRef<HTMLHeadingElement>(null);
useEffect(() => { useEffect(() => {
if (gone) setView(ALL_FILES); if (!gone) return;
setView(ALL_FILES);
const lost = document.activeElement;
if (!lost || lost === document.body || !lost.isConnected) heading.current?.focus();
}, [gone, setView]); }, [gone, setView]);
// One sort for both paints: the table's headers and the grid's dropdown move the same state.
const [sort, setSort] = usePref<SortState>("filesSort", null, { localKey: LS.filesSort });
const params = listParams(shown); const params = listParams(shown);
// Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own. // Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own.
const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 }); const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 });
const all = filesQ.data ?? [];
const q = search.trim(); const q = search.trim();
const rows = all.filter((f) => matches(f, q, lang)); // A view that fills the server's cap holds only its newest files: searching or sorting those rows
// would miss an older file, so the server does both, over the whole view. Asked once the typing
// pauses; a sort the server does not know (the source, the retention) stays on the rows.
const capped = (filesQ.data?.length ?? 0) >= FILES_LIST_CAP;
const typed = useDebounced(q, 300);
const serverSort = sort && (FILE_SORTS as readonly string[]).includes(sort.key) ? sort : null;
const wide = capped && (!!typed || !!serverSort);
const wideParams: FileListParams = {
...params,
q: typed,
...(serverSort && { sort: serverSort.key as FileSort, order: serverSort.dir }),
};
const wideQ = useLiveQuery(qk.files(wideParams), () => api.files(wideParams), {
intervalMs: 15000,
enabled: wide,
});
const listQ = wide ? wideQ : filesQ;
const all = listQ.data ?? [];
const rows = wide ? all : all.filter((f) => matches(f, q, lang));
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const [renamingId, setRenamingId] = useState<number | null>(null); const [renamingId, setRenamingId] = useState<number | null>(null);
@@ -199,7 +239,7 @@ export default function Files() {
const [viewing, setViewing] = useState<number | null>(null); const [viewing, setViewing] = useState<number | null>(null);
const actions = useFileActions({ const actions = useFileActions({
folders, folders: foldersQ.data ?? null,
onPlay: play, onPlay: play,
onView: (f) => setViewing(f.id), onView: (f) => setViewing(f.id),
onEdit: setEditFile, onEdit: setEditFile,
@@ -233,9 +273,13 @@ export default function Files() {
: shown.kind === "folder" : shown.kind === "folder"
? open ? open
? folderLabel(open, t) ? folderLabel(open, t)
: "" : foldersQ.isError && !foldersQ.data
? t("files.folderUnknown")
: ""
: t("files.rail.all"); : t("files.rail.all");
const canUpload = shown.kind !== "shared"; // Not before the folders are known: what a folder may take (`uploadFolder`) is read from them, and
// a guess would send the file to Uploads instead of the folder on screen.
const canUpload = shown.kind !== "shared" && (shown.kind !== "folder" || !!foldersQ.data);
const uploadTarget = uploadFolder(shown, folders); const uploadTarget = uploadFolder(shown, folders);
const uploadTargetName = const uploadTargetName =
uploadTarget !== null && open ? folderLabel(open, t) : t("files.source.upload"); uploadTarget !== null && open ? folderLabel(open, t) : t("files.source.upload");
@@ -252,8 +296,6 @@ export default function Files() {
const viewKey = viewParam(shown); const viewKey = viewParam(shown);
const layout: FilesLayout = layouts[viewKey] ?? "list"; const layout: FilesLayout = layouts[viewKey] ?? "list";
const isGrid = layout === "grid"; const isGrid = layout === "grid";
// One sort for both paints: the table's headers and the grid's dropdown move the same state.
const [sort, setSort] = usePref<SortState>("filesSort", null, { localKey: LS.filesSort });
// ── Arrival at one file ───────────────────────────────────────────────────────────────────────── // ── Arrival at one file ─────────────────────────────────────────────────────────────────────────
// A pending arrival (`focus`) is looked for once the list is in: found here, it becomes `arrived` // A pending arrival (`focus`) is looked for once the list is in: found here, it becomes `arrived`
@@ -267,8 +309,12 @@ export default function Files() {
useEffect(() => { useEffect(() => {
if (lookEverywhere) setView(ALL_FILES); if (lookEverywhere) setView(ALL_FILES);
else if (here) consumeFocus(viewKey); else if (here) consumeFocus(viewKey);
else if (missing) consumeFocus(null); else if (missing) {
}, [here, missing, lookEverywhere, viewKey, setView, consumeFocus]); consumeFocus(null);
// Said, not swallowed: an old notice's file may have expired or been deleted since.
notify({ level: "info", message: t("files.arrival.gone") });
}
}, [here, missing, lookEverywhere, viewKey, setView, consumeFocus, t]);
const arrival = arrived && arrived.view === viewKey ? arrived : null; const arrival = arrived && arrived.view === viewKey ? arrived : null;
const ringed = arrival?.id ?? null; const ringed = arrival?.id ?? null;
const reveal = arrival && { key: String(arrival.id), seq: arrival.token }; const reveal = arrival && { key: String(arrival.id), seq: arrival.token };
@@ -285,13 +331,19 @@ export default function Files() {
// ── A file dropped anywhere on the page ───────────────────────────────────────────────────────── // ── A file dropped anywhere on the page ─────────────────────────────────────────────────────────
// The page is the target, not a box on it: a short folder leaves most of the screen empty. Window // The page is the target, not a box on it: a short folder leaves most of the screen empty. Window
// listeners, since the drag starts outside the app. While the dialog is open its own zone takes the // listeners, since the drag starts outside the app. While the dialog is open its own zone takes the
// drop; a drag that carries no files (a link, selected text) is not ours. // drop; a drag that carries no files (a link, selected text) is not ours. A file dropped where the
// page takes none (beside the open dialog, on the shared list) is still kept from the browser,
// which would open it in the tab — unloading the app and any upload under way.
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const dragDepth = useRef(0); const dragDepth = useRef(0);
const dropReady = canUpload && upload === null; const dropReady = canUpload && upload === null;
const onDrag = useEffectEvent((e: DragEvent) => { const onDrag = useEffectEvent((e: DragEvent) => {
if (!dropReady || !e.dataTransfer?.types.includes("Files")) return; if (!e.dataTransfer?.types.includes("Files")) return;
e.preventDefault(); e.preventDefault();
if (!dropReady) {
e.dataTransfer.dropEffect = "none";
return;
}
if (e.type === "dragenter") dragDepth.current += 1; if (e.type === "dragenter") dragDepth.current += 1;
if (e.type === "dragleave") dragDepth.current = Math.max(0, dragDepth.current - 1); if (e.type === "dragleave") dragDepth.current = Math.max(0, dragDepth.current - 1);
if (e.type === "drop") { if (e.type === "drop") {
@@ -480,6 +532,8 @@ export default function Files() {
}, },
]; ];
// Each folder and each search is a list of its own: it opens on its first page.
const pageScope = `${viewKey}|${q}`;
// The grid sorts and pages here; the table does both itself (from the same sort). // The grid sorts and pages here; the table does both itself (from the same sort).
const cards = isGrid ? sortRows(rows, columns, sort, lang) : rows; const cards = isGrid ? sortRows(rows, columns, sort, lang) : rows;
const { paged, pagerProps } = useCardPager( const { paged, pagerProps } = useCardPager(
@@ -488,6 +542,7 @@ export default function Files() {
isGrid, isGrid,
reveal && { seq: reveal.seq, match: (f) => String(f.id) === reveal.key }, reveal && { seq: reveal.seq, match: (f) => String(f.id) === reveal.key },
t("files.perPage"), t("files.perPage"),
`${pageScope}|${JSON.stringify(sort)}`,
); );
const emptyText = q const emptyText = q
@@ -526,11 +581,11 @@ export default function Files() {
); );
const body = const body =
filesQ.isError && !filesQ.data ? ( listQ.isError && !listQ.data ? (
<StateMessage tone="error" onRetry={() => filesQ.refetch()}> <StateMessage tone="error" onRetry={() => listQ.refetch()}>
{t("common.loadError")} {t("common.loadError")}
</StateMessage> </StateMessage>
) : filesQ.isLoading ? ( ) : listQ.isLoading ? (
<LoadingState shape={isGrid ? "grid" : "list"} /> <LoadingState shape={isGrid ? "grid" : "list"} />
) : isGrid ? ( ) : isGrid ? (
grid grid
@@ -547,6 +602,7 @@ export default function Files() {
onSortChange={setSort} onSortChange={setSort}
reveal={reveal} reveal={reveal}
narrowedBy={q} narrowedBy={q}
pageScope={pageScope}
rowProps={(f) => ({ ...rowMenu.triggerProps(f), "data-file-id": f.id })} rowProps={(f) => ({ ...rowMenu.triggerProps(f), "data-file-id": f.id })}
// `group`: the row's quick icons show while it is hovered or holds the focus. // `group`: the row's quick icons show while it is hovered or holds the focus.
rowClassName={(f) => rowClassName={(f) =>
@@ -578,7 +634,13 @@ export default function Files() {
}} }}
/> />
) : ( ) : (
<h2 className="min-w-0 truncate text-lg font-bold">{title}</h2> <h2
ref={heading}
tabIndex={-1}
className="min-w-0 truncate text-lg font-bold outline-none"
>
{title}
</h2>
)} )}
{open && !renamingFolder && ( {open && !renamingFolder && (
<MoreButton <MoreButton
@@ -589,9 +651,10 @@ export default function Files() {
menuProps={folderMenu.buttonProps(open)} menuProps={folderMenu.buttonProps(open)}
/> />
)} )}
{filesQ.data && ( {listQ.data && (
<span className="text-xs tabular-nums text-muted"> <span className="text-xs tabular-nums text-muted">
{t("files.count", { count: rows.length })} {/* A capped list is the newest files of the view, and says so. */}
{t(capped && !wide ? "files.countNewest" : "files.count", { count: rows.length })}
</span> </span>
)} )}
{isGrid && ( {isGrid && (
@@ -155,3 +155,31 @@ describe("FilesRail · folders", () => {
await waitFor(() => expect(document.activeElement).toBe(entry("Receptek"))); await waitFor(() => expect(document.activeElement).toBe(entry("Receptek")));
}); });
}); });
describe("FilesRail · what it says when a part is empty or missing", () => {
it("says nothing was downloaded only when nothing is there — not when it all moved into own folders", async () => {
await mount([folder(1, { source_key: "youtube", count: 0 }), { ...OWN, count: 3 }]);
expect(screen.queryByText("files.rail.noSources")).toBeNull();
cleanup();
await mount([folder(1, { source_key: "youtube", count: 0 }), { ...OWN, count: 0 }]);
expect(screen.getByText("files.rail.noSources")).toBeTruthy();
});
it("a storage figure that failed to load says so, rather than the meter quietly not being there", async () => {
vi.spyOn(api, "fileFolders").mockResolvedValue([YT, OWN]);
vi.spyOn(api, "filesUsage").mockRejectedValue(new Error("down"));
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<FilesRail
layout={defaultLayout("files")}
setLayout={vi.fn()}
collapsed={false}
onToggleCollapse={vi.fn()}
/>
</QueryClientProvider>,
);
expect(await screen.findByText("files.usage.failed")).toBeTruthy();
});
});
+12 -11
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Meter } from "./ui/Meter";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import clsx from "clsx"; import clsx from "clsx";
@@ -12,7 +13,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import FilterRail from "./FilterRail"; import FilterRail from "./FilterRail";
import { folderIcon, folderLabel } from "./fileFolders"; import { folderIcon, folderLabel } from "./fileFolders";
import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext"; import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext";
import { RenameField } from "./RenameField"; import { RenameField } from "./ui/RenameField";
import { useFolderActions } from "./useFolderActions"; import { useFolderActions } from "./useFolderActions";
import { ContextMenu } from "./ui/ContextMenu"; import { ContextMenu } from "./ui/ContextMenu";
import { Pressable } from "./ui/Pressable"; import { Pressable } from "./ui/Pressable";
@@ -77,9 +78,13 @@ function StorageMeter() {
intervalMs: (u) => (u?.running_jobs ? 2000 : 15000), intervalMs: (u) => (u?.running_jobs ? 2000 : 15000),
}); });
const u = usage.data; const u = usage.data;
if (!u) return null; // A failed read says so where the figures would be, rather than the meter quietly not being there.
const pct = if (!u)
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100); return usage.isError ? (
<p className="mt-3 px-2 text-xs text-muted" data-testid="storage-meter">
{t("files.usage.failed")}
</p>
) : null;
return ( return (
<div className="mt-3 flex flex-col gap-1 px-2 text-xs text-muted" data-testid="storage-meter"> <div className="mt-3 flex flex-col gap-1 px-2 text-xs text-muted" data-testid="storage-meter">
<span className="flex justify-between gap-2"> <span className="flex justify-between gap-2">
@@ -90,12 +95,7 @@ function StorageMeter() {
</span> </span>
</span> </span>
{!u.unlimited && ( {!u.unlimited && (
<span className="h-1 overflow-hidden rounded-full bg-fg/10"> <Meter value={u.footprint_bytes} max={u.max_bytes} label={t("files.usage.title")} />
<span
className={clsx("block h-full rounded-full", pct > 90 ? "bg-danger" : "bg-accent")}
style={{ width: `${pct}%` }}
/>
</span>
)} )}
</div> </div>
); );
@@ -202,7 +202,8 @@ export default function FilesRail({
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{failed} {failed}
{sources.map(entry)} {sources.map(entry)}
{foldersQ.data && sources.length === 0 && ( {/* Only when there is nothing at all: files moved into own folders were still downloaded. */}
{foldersQ.data && sources.length === 0 && total === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noSources")}</span> <span className="px-2 py-1 text-xs text-muted">{t("files.rail.noSources")}</span>
)} )}
</div> </div>
+3 -1
View File
@@ -41,7 +41,9 @@ export function LinkBox({ className }: { className?: string }) {
const paste = async (force: boolean) => { const paste = async (force: boolean) => {
if (!force && url.trim()) return; if (!force && url.trim()) return;
const link = asDownloadLink(await clipboardText()); const link = asDownloadLink(await clipboardText());
if (link) setUrl(link); // Asked again once the read is back: it can wait on a permission prompt, and what was typed
// meanwhile is the reader's.
if (link) setUrl((typed) => (force || !typed.trim() ? link : typed));
}; };
// The palette's "Download from link…" (lib/downloadIntake): the request wakes this effect, the // The palette's "Download from link…" (lib/downloadIntake): the request wakes this effect, the
@@ -111,7 +111,9 @@ describe("DownloadFormats", () => {
await mount(); await mount();
await openMenu(rowOf("Phone 480")); await openMenu(rowOf("Phone 480"));
pick("downloads.profiles.duplicate"); pick("downloads.profiles.duplicate");
const name = await screen.findByPlaceholderText("downloads.profiles.namePlaceholder"); // The form opens with the keyboard in its name, the field named by its label.
const name = await screen.findByRole("textbox", { name: "downloads.profiles.name" });
await waitFor(() => expect(document.activeElement).toBe(name));
expect((name as HTMLInputElement).value).toBe(""); expect((name as HTMLInputElement).value).toBe("");
fireEvent.change(name, { target: { value: "Phone copy" } }); fireEvent.change(name, { target: { value: "Phone copy" } });
fireEvent.click(screen.getByText("downloads.profiles.create")); fireEvent.click(screen.getByText("downloads.profiles.create"));
+17 -2
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
@@ -93,6 +93,14 @@ export function DownloadFormats() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [spec, setSpec] = useState<DownloadSpec>(BLANK); const [spec, setSpec] = useState<DownloadSpec>(BLANK);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
// Opening the form (New, Edit, Duplicate) puts the keyboard in its name: the form opens away from
// the control that asked for it, and "New format" is replaced by it.
const [opened, setOpened] = useState(0);
const nameField = useRef<HTMLInputElement>(null);
const nameId = useId();
useEffect(() => {
if (opened) nameField.current?.focus();
}, [opened]);
const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadProfiles() }); const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
const startNew = () => { const startNew = () => {
@@ -100,12 +108,14 @@ export function DownloadFormats() {
setName(""); setName("");
setSpec(BLANK); setSpec(BLANK);
setFormOpen(true); setFormOpen(true);
setOpened((n) => n + 1);
}; };
const startEdit = (p: DownloadProfile) => { const startEdit = (p: DownloadProfile) => {
setEditing(p); setEditing(p);
setName(p.name); setName(p.name);
setSpec({ ...BLANK, ...p.spec }); setSpec({ ...BLANK, ...p.spec });
setFormOpen(true); setFormOpen(true);
setOpened((n) => n + 1);
}; };
// A new format from an existing one: its settings, and a name of its own to give. // A new format from an existing one: its settings, and a name of its own to give.
const startCopy = (p: DownloadProfile) => { const startCopy = (p: DownloadProfile) => {
@@ -113,6 +123,7 @@ export function DownloadFormats() {
setName(""); setName("");
setSpec({ ...BLANK, ...p.spec }); setSpec({ ...BLANK, ...p.spec });
setFormOpen(true); setFormOpen(true);
setOpened((n) => n + 1);
}; };
const save = useMutation({ const save = useMutation({
@@ -261,8 +272,12 @@ export function DownloadFormats() {
) : ( ) : (
<div className="rounded-xl border border-border p-4 space-y-3"> <div className="rounded-xl border border-border p-4 space-y-3">
<div> <div>
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.name")}</label> <label htmlFor={nameId} className="block text-sm font-medium mb-1">
{t("downloads.profiles.name")}
</label>
<Input <Input
id={nameId}
ref={nameField}
className="w-full" className="w-full"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
+4 -4
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { invalidateDownloads } from "../lib/downloads";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -280,8 +281,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// so trying again queued the first two a second time. Say how many made it, and deselect them // so trying again queued the first two a second time. Say how many made it, and deselect them
// so another try adds only the rest. // so another try adds only the rest.
if (done.length) { if (done.length) {
qc.invalidateQueries({ queryKey: qk.downloads() }); invalidateDownloads(qc);
qc.invalidateQueries({ queryKey: qk.storageUsage() });
setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s))); setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s)));
// Its own toast, not notifyActionError: a quota or edit refusal has already opened req's // Its own toast, not notifyActionError: a quota or edit refusal has already opened req's
// error dialog and marked the error, so that helper would say nothing — and the count is // error dialog and marked the error, so that helper would say nothing — and the count is
@@ -298,8 +298,8 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
return { files: N }; return { files: N };
}, },
onSuccess: ({ files }) => { onSuccess: ({ files }) => {
qc.invalidateQueries({ queryKey: qk.downloads() }); // The whole library moves with it: an edit the user already made is done at once, and lists.
qc.invalidateQueries({ queryKey: qk.storageUsage() }); invalidateDownloads(qc);
notify({ level: "success", message: t("editor.created", { count: files }) }); notify({ level: "success", message: t("editor.created", { count: files }) });
onClose(); onClose();
}, },
+7 -15
View File
@@ -1,8 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { Meter } from "./ui/Meter";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import clsx from "clsx";
import { ExternalLink, RotateCcw, Settings2 } from "lucide-react"; import { ExternalLink, RotateCcw, Settings2 } from "lucide-react";
import Modal, { ModalFooter } from "./ui/Modal"; import Modal, { ModalFooter } from "./ui/Modal";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
@@ -441,22 +441,14 @@ function QuotaFill({ user, lang }: { user: AdminStorageUser; lang: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (user.unlimited || !user.max_bytes) if (user.unlimited || !user.max_bytes)
return <span className="text-xs text-muted">{t("downloads.admin.unlimitedShort")}</span>; return <span className="text-xs text-muted">{t("downloads.admin.unlimitedShort")}</span>;
const pct = Math.min(100, Math.round((user.footprint_bytes / user.max_bytes) * 100));
return ( return (
<span className="flex flex-col gap-1"> <span className="flex flex-col gap-1">
<span <Meter
className="h-1.5 overflow-hidden rounded-full bg-fg/10" value={user.footprint_bytes}
role="meter" max={user.max_bytes}
aria-valuemin={0} label={t("downloads.admin.columns.quota")}
aria-valuemax={100} className="h-1.5"
aria-valuenow={pct} />
aria-label={t("downloads.admin.columns.quota")}
>
<span
className={clsx("block h-full rounded-full", pct >= 90 ? "bg-danger" : "bg-accent")}
style={{ width: `${pct}%` }}
/>
</span>
<span className="text-2xs text-muted tabular-nums"> <span className="text-2xs text-muted tabular-nums">
{t("downloads.admin.ofQuota", { size: formatBytes(user.max_bytes, lang) })} {t("downloads.admin.ofQuota", { size: formatBytes(user.max_bytes, lang) })}
</span> </span>
+4 -20
View File
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { File, Film, Music } from "lucide-react"; import { File, Film, Music } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import { DOWNLOAD_PHASES } from "../lib/downloadQueue";
import { api, type DownloadJob } from "../lib/api"; import { api, type DownloadJob } from "../lib/api";
import { IconButton } from "./ui/IconButton"; import { IconButton } from "./ui/IconButton";
import { MediaProgress } from "./ui/MediaCard"; import { MediaProgress } from "./ui/MediaCard";
@@ -19,24 +20,11 @@ const STATUS_CLS: Record<string, string> = {
canceled: "text-muted", canceled: "text-muted",
}; };
const PHASE_KEYS = [
"video",
"audio",
"editing",
"converting",
"merging",
"audio_extract",
"thumbnail",
"sponsorblock",
"metadata",
"processing",
];
export function StatusPill({ job }: { job: DownloadJob }) { export function StatusPill({ job }: { job: DownloadJob }) {
const { t } = useTranslation(); const { t } = useTranslation();
// While downloading, show the concrete phase (video / audio / merging) so the user isn't // While downloading, show the concrete phase (video / audio / merging) so the user isn't
// confused by the bar resetting between the separate video and audio streams. // confused by the bar resetting between the separate video and audio streams.
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) { if (job.status === "running" && job.phase && DOWNLOAD_PHASES.includes(job.phase)) {
return ( return (
<span className="text-sm font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span> <span className="text-sm font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
); );
@@ -48,13 +36,9 @@ export function StatusPill({ job }: { job: DownloadJob }) {
); );
} }
// The row answers to its OWN width (a container query), not the window's: the same list sits in a // A picture's box when the caller gives none: a thumbnail-sized 16:9 frame.
// phone, a split-screen tablet and a desktop column. Below 40rem the actions no longer leave the title
// column room for the meta line, so the row stacks (the owner's pick A): the picture beside a
// two-line title and the channel; the meta line across the whole row; then the source link and the
// actions on one wrapping line.
const THUMB_BOX = const THUMB_BOX =
"w-28 @max-[40rem]/dlrow:w-24 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border"; "w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border";
/** A job's picture: its poster, else the source's thumbnail, else a glyph of what it is. `box` sizes /** A job's picture: its poster, else the source's thumbnail, else a glyph of what it is. `box` sizes
* it (the Files list draws a smaller one). */ * it (the Files list draws a smaller one). */
+3 -5
View File
@@ -1,9 +1,9 @@
import { Film, Folder, Globe, Server, Upload, type LucideIcon } from "lucide-react"; import { Film, Folder, Globe, Upload, type LucideIcon } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons"; import { YouTubeIcon } from "../lib/brandIcons";
import type { FileFolder } from "../lib/api"; import type { FileFolder } from "../lib/api";
// A library folder's and a file's origin, named and drawn. The backend sends a source folder's KEY // A library folder's and a file's origin, named and drawn. The backend sends a source folder's KEY
// (`youtube`, `plex`, `upload`, `hub`, `site:<host>`, see backend downloads/folders.py) and leaves // (`youtube`, `plex`, `upload`, `site:<host>`, see backend downloads/folders.py) and leaves
// its name to the client, in the reader's language; an own folder carries the name its user gave it. // its name to the client, in the reader's language; an own folder carries the name its user gave it.
const SITE_PREFIX = "site:"; const SITE_PREFIX = "site:";
@@ -13,8 +13,7 @@ type T = (key: string) => string;
/** The name of a source: a site is its host, the rest are words of the UI. */ /** The name of a source: a site is its host, the rest are words of the UI. */
export function sourceLabel(key: string, t: T): string { export function sourceLabel(key: string, t: T): string {
if (key.startsWith(SITE_PREFIX)) return key.slice(SITE_PREFIX.length); if (key.startsWith(SITE_PREFIX)) return key.slice(SITE_PREFIX.length);
if (key === "youtube" || key === "plex" || key === "upload" || key === "hub") if (key === "youtube" || key === "plex" || key === "upload") return t(`files.source.${key}`);
return t(`files.source.${key}`);
return key; return key;
} }
@@ -22,7 +21,6 @@ export function sourceIcon(key: string): LucideIcon | typeof YouTubeIcon {
if (key === "youtube") return YouTubeIcon; if (key === "youtube") return YouTubeIcon;
if (key === "plex") return Film; if (key === "plex") return Film;
if (key === "upload") return Upload; if (key === "upload") return Upload;
if (key === "hub") return Server;
return Globe; return Globe;
} }
@@ -36,4 +36,27 @@ describe("DataTable pager", () => {
expect(screen.getByText("row 12")).toBeTruthy(); expect(screen.getByText("row 12")).toBeTruthy();
expect(screen.queryByText("datatable.rowsPerPage")).toBeNull(); expect(screen.queryByText("datatable.rowsPerPage")).toBeNull();
}); });
it("another list (pageScope) starts on its first page; a remembered page comes back only for its own", () => {
const table = (scope: string) => (
<DataTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
pageSize={10}
persistKey="t"
pageScope={scope}
/>
);
localStorage.setItem("t", JSON.stringify({ size: 5, page: 2, pageScope: "folder 1" }));
const { rerender } = render(table("folder 1"));
expect(screen.getByText("row 11")).toBeTruthy(); // the page it was on, in the list it was on
rerender(table("folder 2"));
expect(screen.getByText("row 1")).toBeTruthy();
expect(screen.queryByText("row 11")).toBeNull();
cleanup();
localStorage.setItem("t", JSON.stringify({ size: 5, page: 2, pageScope: "folder 1" }));
render(table("a search"));
expect(screen.getByText("row 1")).toBeTruthy();
});
}); });
+18 -4
View File
@@ -31,6 +31,8 @@ interface Persisted {
filters: FilterMap; filters: FilterMap;
page: number; page: number;
size?: number; size?: number;
// The `pageScope` the page was on: it is taken back only for the same list.
pageScope?: string;
} }
// Stable empty-map identity for the controlled-but-value-omitted fallback, so `filters` keeps a // Stable empty-map identity for the controlled-but-value-omitted fallback, so `filters` keeps a
@@ -38,12 +40,13 @@ interface Persisted {
// render). Mirrors controlled sort's stable `null` fallback. // render). Mirrors controlled sort's stable `null` fallback.
const EMPTY_FILTERS: FilterMap = {}; const EMPTY_FILTERS: FilterMap = {};
function loadPersist(key?: string): Persisted { function loadPersist(key?: string, pageScope?: string): Persisted {
const empty: Persisted = { sort: null, filters: {}, page: 0 }; const empty: Persisted = { sort: null, filters: {}, page: 0 };
if (!key) return empty; if (!key) return empty;
try { try {
const v = JSON.parse(localStorage.getItem(key) || "{}"); const v = JSON.parse(localStorage.getItem(key) || "{}");
return { sort: v.sort ?? null, filters: v.filters ?? {}, page: v.page ?? 0, size: v.size }; const page = pageScope === undefined || v.pageScope === pageScope ? (v.page ?? 0) : 0;
return { sort: v.sort ?? null, filters: v.filters ?? {}, page, size: v.size };
} catch { } catch {
return empty; return empty;
} }
@@ -114,6 +117,7 @@ export default function DataTable<T>({
reveal, reveal,
surfacedFilters, surfacedFilters,
narrowedBy = "", narrowedBy = "",
pageScope,
}: { }: {
rows: T[]; rows: T[];
columns: Column<T>[]; columns: Column<T>[];
@@ -181,10 +185,13 @@ export default function DataTable<T>({
// in the shared top bar): a change starts a long page over from its first rows, as the table's own // in the shared top bar): a change starts a long page over from its first rows, as the table's own
// sort and filter changes do (progressiveRows). Without it every key re-rendered the whole page. // sort and filter changes do (progressiveRows). Without it every key re-rendered the whole page.
narrowedBy?: string; narrowedBy?: string;
// Which list `rows` are, when one table shows several by turns (a folder, then another; a search):
// another list starts on its first page, and a remembered page is only taken back for its own list.
pageScope?: string;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const initial = loadPersist(persistKey); const initial = loadPersist(persistKey, pageScope);
// Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise // Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise
// the table keeps its own, seeded from and saved to persistKey. // the table keeps its own, seeded from and saved to persistKey.
const controlledSort = onSortChange !== undefined; const controlledSort = onSortChange !== undefined;
@@ -196,6 +203,12 @@ export default function DataTable<T>({
const [internalFilters, setInternalFilters] = useState<FilterMap>(initial.filters); const [internalFilters, setInternalFilters] = useState<FilterMap>(initial.filters);
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters; const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
const [page, setPage] = useState(initial.page); const [page, setPage] = useState(initial.page);
// Adjusted during render against the last list seen, so the first frame of a new list is its first page.
const [scopeSeen, setScopeSeen] = useState(pageScope);
if (pageScope !== scopeSeen) {
setScopeSeen(pageScope);
setPage(0);
}
// 0 = "All": an unpaged table is one page of every row. // 0 = "All": an unpaged table is one page of every row.
const [size, setSize] = useState(pager ? (initial.size ?? pageSize) : 0); const [size, setSize] = useState(pager ? (initial.size ?? pageSize) : 0);
const wide = useMediaQuery("(min-width: 48rem)"); const wide = useMediaQuery("(min-width: 48rem)");
@@ -236,9 +249,10 @@ export default function DataTable<T>({
filters: controlledFilters ? {} : filters, filters: controlledFilters ? {} : filters,
page, page,
size, size,
pageScope,
}), }),
); );
}, [persistKey, controlledSort, controlledFilters, sort, filters, page, size]); }, [persistKey, controlledSort, controlledFilters, sort, filters, page, size, pageScope]);
// Destructured so the deps are the values the body actually reads. `externalFilter` itself is an // Destructured so the deps are the values the body actually reads. `externalFilter` itself is an
// object literal at every call site, so depending on it would re-apply the filter — and reset the // object literal at every call site, so depending on it would re-apply the filter — and reset the
@@ -0,0 +1,31 @@
// @vitest-environment jsdom
//
// One meter for every place that shows how full a quota is: it turns to a warning at the share it
// SHOWS (the rounded percent), so the user's own rail and the admin's Storage tab never disagree
// about the same fill, and it reads as a meter with that percent.
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { Meter } from "./Meter";
afterEach(cleanup);
const fill = () => screen.getByRole("meter").firstElementChild!;
describe("Meter", () => {
it.each([
[896, "90", true],
[894, "89", false],
[2000, "100", true],
])("%i of 1000 shows its percent and warns from 90", (value, shown, danger) => {
render(<Meter value={value} max={1000} label="Storage" />);
expect(screen.getByRole("meter", { name: "Storage" }).getAttribute("aria-valuenow")).toBe(
shown,
);
expect(fill().classList.contains("bg-danger")).toBe(danger);
});
it("an unset limit is an empty meter, never a division by zero", () => {
render(<Meter value={5} max={0} label="Storage" />);
expect(screen.getByRole("meter").getAttribute("aria-valuenow")).toBe("0");
});
});
+46
View File
@@ -0,0 +1,46 @@
import clsx from "clsx";
/** How full something is against its limit, from this share up drawn as a warning. */
const METER_DANGER_PCT = 90;
/** The share of `max` that `value` fills, as the whole percent a reader is shown (0–100). */
function meterPct(value: number, max: number): number {
return max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0;
}
// A thin bar of how full something is (a storage quota): one drawing and one danger line for every
// place that shows the same measure, and a `meter` to assistive tech with the percent it shows — two
// hand-painted bars once turned red at different fills for the same quota.
export function Meter({
value,
max,
label,
className,
}: {
value: number;
max: number;
/** The meter's name for assistive tech (what it measures). */
label: string;
/** Its height and place, e.g. `h-1.5`; the track and the fill are the meter's. */
className?: string;
}) {
const pct = meterPct(value, max);
return (
<span
role="meter"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
aria-label={label}
className={clsx("block overflow-hidden rounded-full bg-fg/10", className ?? "h-1")}
>
<span
className={clsx(
"block h-full rounded-full",
pct >= METER_DANGER_PCT ? "bg-danger" : "bg-accent",
)}
style={{ width: `${pct}%` }}
/>
</span>
);
}
+7 -7
View File
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here ## What lives here
| area | components | | area | components |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` | | overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` | | actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) | | data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` | | page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` | | state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
## The size contract (E10 S1c) ## The size contract (E10 S1c)
+1 -1
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Input } from "./ui/Input"; import { Input } from "./Input";
// A name being typed in place of the name itself — a library file's in its row, a folder's in the rail // A name being typed in place of the name itself — a library file's in its row, a folder's in the rail
// or the page title, a new folder's. Enter or leaving the field saves, Escape cancels: unmounting the // or the page title, a new folder's. Enter or leaving the field saves, Escape cancels: unmounting the
@@ -42,8 +42,6 @@ const CANCEL_COPY: Record<DownloadJobKind, typeof CANCEL_DOWNLOAD> = {
edit: CANCEL_EDIT, edit: CANCEL_EDIT,
clip: CANCEL_EDIT, // both are a clip being cut: the same words clip: CANCEL_EDIT, // both are a clip being cut: the same words
}; };
// Deleting a download may leave its shared cache file for other holders. An upload and a clip are
// their owner's alone (a clip's asset is keyed by its user), so deleting one removes the file for good.
export function useDownloadActions() { export function useDownloadActions() {
const qc = useQueryClient(); const qc = useQueryClient();
const { t } = useTranslation(); const { t } = useTranslation();
+44 -24
View File
@@ -20,6 +20,7 @@ import {
import { api, type DownloadJobKind, type FileFolder, type LibraryFile } from "../lib/api"; import { api, type DownloadJobKind, type FileFolder, type LibraryFile } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads"; import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { forgetFinished } from "../lib/downloadQueue";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions"; import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { useConfirm } from "./confirmContext"; import { useConfirm } from "./confirmContext";
import { useCopyItems } from "./copyItems"; import { useCopyItems } from "./copyItems";
@@ -33,14 +34,13 @@ import { menuGroups } from "./ui/menuGroups";
// shared with me get different sets: a shared file is the owner's, so it is played, saved, cut or // shared with me get different sets: a shared file is the owner's, so it is played, saved, cut or
// dropped from my list, never renamed, moved or deleted. // dropped from my list, never renamed, moved or deleted.
const DELETE_COPY: Record<DownloadJobKind, { title: string; body: string }> = { // A destructive confirm names the file (DESIGN.md "Copy"); what the delete takes depends on the kind:
download: { title: "downloads.confirm.deleteTitle", body: "downloads.confirm.deleteBody" }, // a download's file may stay for others who have it, an upload or a clip is its owner's alone.
upload: { const DELETE_BODY: Record<DownloadJobKind, string> = {
title: "downloads.confirm.deleteUploadTitle", download: "downloads.confirm.deleteBody",
body: "downloads.confirm.deleteUploadBody", upload: "downloads.confirm.deleteUploadBody",
}, edit: "downloads.confirm.deleteEditBody",
edit: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" }, clip: "downloads.confirm.deleteEditBody",
clip: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
}; };
type Patch = { name?: string | null; folder_id?: number; kept?: boolean }; type Patch = { name?: string | null; folder_id?: number; kept?: boolean };
@@ -61,8 +61,8 @@ export function useFileActions({
onEditDetails, onEditDetails,
onManageShares, onManageShares,
}: { }: {
/** The caller's folders — the Move submenu's places. */ /** The caller's folders — the Move submenu's places; null while they are not loaded. */
folders: readonly FileFolder[]; folders: readonly FileFolder[] | null;
onPlay: (file: LibraryFile, fromStart?: boolean) => void; onPlay: (file: LibraryFile, fromStart?: boolean) => void;
/** Open a file that is not media in the Files viewer. */ /** Open a file that is not media in the Files viewer. */
onView: (file: LibraryFile) => void; onView: (file: LibraryFile) => void;
@@ -83,7 +83,10 @@ export function useFileActions({
}); });
const remove = useMutation({ const remove = useMutation({
mutationFn: (id: number) => api.deleteFile(id), mutationFn: (id: number) => api.deleteFile(id),
onSuccess: () => invalidateDownloads(qc), onSuccess: (_r, id) => {
forgetFinished(id);
void invalidateDownloads(qc);
},
meta: { failTitle: "errors.writeFailed.file.delete" }, meta: { failTitle: "errors.writeFailed.file.delete" },
}); });
const redownload = useMutation({ const redownload = useMutation({
@@ -168,6 +171,21 @@ export function useFileActions({
// Move: every own folder (the one it is in, ticked), then home — its own source folder — when it // Move: every own folder (the one it is in, ticked), then home — its own source folder — when it
// has left it. The server refuses any other place (`folders.move`), so none is offered. // has left it. The server refuses any other place (`folders.move`), so none is offered.
const moveTo = (file: LibraryFile): ActionMenuItem => { const moveTo = (file: LibraryFile): ActionMenuItem => {
// Unknown (a failed read) is not "none": the row says the folders are missing, not that there are none.
if (folders === null)
return {
id: "move",
label: t("files.actions.move"),
icon: icon(FolderInput),
items: [
{
id: "move-unknown",
label: t("files.actions.foldersUnknown"),
disabled: true,
onSelect: () => {},
},
],
};
const own = folders.filter((f) => f.kind === "own"); const own = folders.filter((f) => f.kind === "own");
const home = folders.find((f) => f.kind === "source" && f.source_key === file.source_key); const home = folders.find((f) => f.kind === "source" && f.source_key === file.source_key);
const to = (f: FileFolder) => () => update.mutate({ id: file.id, patch: { folder_id: f.id } }); const to = (f: FileFolder) => () => update.mutate({ id: file.id, patch: { folder_id: f.id } });
@@ -246,11 +264,11 @@ export function useFileActions({
? [ ? [
{ {
id: "redownload", id: "redownload",
label: t("downloads.actions.redownload"), label: t("files.actions.redownload"),
icon: icon(RotateCcw), icon: icon(RotateCcw),
onSelect: () => onSelect: () =>
void confirmThen( void confirmThen(
t("downloads.confirm.redownloadTitle"), t("downloads.confirm.redownloadTitle", { name: jobTitle(file) }),
t("downloads.confirm.redownloadBody"), t("downloads.confirm.redownloadBody"),
t("downloads.confirm.redownloadAction"), t("downloads.confirm.redownloadAction"),
() => redownload.mutate(file.id), () => redownload.mutate(file.id),
@@ -263,15 +281,16 @@ export function useFileActions({
[ [
{ {
id: "delete", id: "delete",
label: t("downloads.actions.delete"), label: t("files.actions.delete"),
icon: icon(Trash2), icon: icon(Trash2),
danger: true, danger: true,
onSelect: () => { onSelect: () =>
const c = DELETE_COPY[file.job_kind ?? "download"]; void confirmThen(
void confirmThen(t(c.title), t(c.body), t("downloads.actions.delete"), () => t("downloads.confirm.deleteTitle", { name: jobTitle(file) }),
remove.mutate(file.id), t(DELETE_BODY[file.job_kind ?? "download"]),
); t("downloads.actions.delete"),
}, () => remove.mutate(file.id),
),
}, },
], ],
); );
@@ -290,12 +309,12 @@ export function useFileActions({
[ [
{ {
id: "remove-shared", id: "remove-shared",
label: t("downloads.actions.removeShared"), label: t("files.actions.removeShared"),
icon: icon(Trash2), icon: icon(Trash2),
danger: true, danger: true,
onSelect: () => onSelect: () =>
void confirmThen( void confirmThen(
t("downloads.confirm.removeSharedTitle"), t("downloads.confirm.removeSharedTitle", { name: jobTitle(file) }),
t("downloads.confirm.removeSharedBody"), t("downloads.confirm.removeSharedBody"),
t("downloads.actions.removeShared"), t("downloads.actions.removeShared"),
() => removeShared.mutate(file.id), () => removeShared.mutate(file.id),
@@ -313,10 +332,11 @@ export function useFileActions({
/** The row's Copy-link icon: the menu's row, one click. */ /** The row's Copy-link icon: the menu's row, one click. */
const share = (file: LibraryFile) => void quickShare(file); const share = (file: LibraryFile) => void quickShare(file);
/** Save the inline rename: an empty name gives the file its own title back. */ /** Save the inline rename: an empty name gives the file its own title back. The field starts on
* the name the file shows, so leaving it as it was changes nothing — not even a name never set. */
const rename = (file: LibraryFile, name: string) => { const rename = (file: LibraryFile, name: string) => {
const next = name.trim(); const next = name.trim();
if (next === (file.display_name ?? "")) return; if (next === jobTitle(file) || next === (file.display_name ?? "")) return;
update.mutate({ id: file.id, patch: { name: next || null } }); update.mutate({ id: file.id, patch: { name: next || null } });
}; };
+5 -8
View File
@@ -63,7 +63,6 @@
"download": "Save to my device", "download": "Save to my device",
"play": "Play", "play": "Play",
"edit": "Edit / trim…", "edit": "Edit / trim…",
"redownload": "Re-download (rebuild file)",
"removeShared": "Remove from my list" "removeShared": "Remove from my list"
}, },
"player": { "player": {
@@ -87,7 +86,7 @@
"done": "Shared with {{email}}", "done": "Shared with {{email}}",
"failed": "Couldn’t share", "failed": "Couldn’t share",
"userSection": "Share with a user", "userSection": "Share with a user",
"userHint": "They’ll find it under “Shared with me”.", "userHint": "They’ll find it in Files, under “Shared with me”.",
"pickPlaceholder": "Search a user…", "pickPlaceholder": "Search a user…",
"noUsers": "No other users yet.", "noUsers": "No other users yet.",
"linkSection": "Share a link", "linkSection": "Share a link",
@@ -130,15 +129,13 @@
"cancelEditTitle": "Cancel clip?", "cancelEditTitle": "Cancel clip?",
"cancelEditBody": "This stops making the clip and removes it from your queue.", "cancelEditBody": "This stops making the clip and removes it from your queue.",
"cancelEditAction": "Stop making this clip", "cancelEditAction": "Stop making this clip",
"deleteTitle": "Delete download?", "deleteTitle": "Delete “{{name}}”?",
"deleteBody": "This deletes it from your downloads. The file may stay cached for other users until it expires.", "deleteBody": "It leaves your library. Anyone else who has the same file keeps theirs.",
"deleteUploadTitle": "Delete upload?",
"deleteUploadBody": "The uploaded file is removed from the server. This can't be undone.", "deleteUploadBody": "The uploaded file is removed from the server. This can't be undone.",
"deleteEditTitle": "Delete clip?",
"deleteEditBody": "The clip is removed from the server. This can't be undone.", "deleteEditBody": "The clip is removed from the server. This can't be undone.",
"removeSharedTitle": "Remove from your list?", "removeSharedTitle": "Remove “{{name}}” from your list?",
"removeSharedBody": "This removes it from your shared list only — it won't delete the owner's file.", "removeSharedBody": "This removes it from your shared list only — it won't delete the owner's file.",
"redownloadTitle": "Re-download this file?", "redownloadTitle": "Re-download “{{name}}”?",
"redownloadBody": "The current file is deleted and downloaded again under the latest format and naming rules. Any share links you've sent keep working and will point to the new file (a failed re-download leaves the link broken).", "redownloadBody": "The current file is deleted and downloaded again under the latest format and naming rules. Any share links you've sent keep working and will point to the new file (a failed re-download leaves the link broken).",
"redownloadAction": "Re-download" "redownloadAction": "Re-download"
}, },
+18 -6
View File
@@ -3,7 +3,13 @@
"searchPlaceholder": "Search files…", "searchPlaceholder": "Search files…",
"count_one": "{{count}} item", "count_one": "{{count}} item",
"count_other": "{{count}} items", "count_other": "{{count}} items",
"countNewest_one": "The newest {{count}} item",
"countNewest_other": "The newest {{count}} items",
"rowMenu": "File actions", "rowMenu": "File actions",
"folderUnknown": "This folder could not be loaded",
"arrival": {
"gone": "That file is no longer in your library"
},
"phoneOk": "Plays on a phone too", "phoneOk": "Plays on a phone too",
"rail": { "rail": {
"library": "Library", "library": "Library",
@@ -17,11 +23,11 @@
"source": { "source": {
"youtube": "YouTube", "youtube": "YouTube",
"plex": "Plex clips", "plex": "Plex clips",
"upload": "Uploads", "upload": "Uploads"
"hub": "Hub"
}, },
"usage": { "usage": {
"title": "Storage" "title": "Storage",
"failed": "Storage use could not be loaded"
}, },
"columns": { "columns": {
"name": "Name", "name": "Name",
@@ -34,7 +40,7 @@
"retention": { "retention": {
"kept": "Kept", "kept": "Kept",
"never": "Kept until you delete it", "never": "Kept until you delete it",
"until": "Goes on {{when}}", "until": "Deleted on {{when}}",
"days_one": "{{count}} day", "days_one": "{{count}} day",
"days_other": "{{count}} days" "days_other": "{{count}} days"
}, },
@@ -52,11 +58,15 @@
"move": "Move to", "move": "Move to",
"moveHome": "Back to {{folder}}", "moveHome": "Back to {{folder}}",
"noOwnFolders": "No folders of your own yet", "noOwnFolders": "No folders of your own yet",
"foldersUnknown": "Your folders could not be loaded",
"keep": "Keep", "keep": "Keep",
"unkeep": "Stop keeping", "unkeep": "Stop keeping",
"details": "Details…", "details": "Details…",
"upload": "Upload", "upload": "Upload",
"view": "Preview" "view": "Preview",
"delete": "Delete…",
"removeShared": "Remove from my list…",
"redownload": "Re-download (rebuild file)…"
}, },
"folder": { "folder": {
"menu": "Folder actions", "menu": "Folder actions",
@@ -101,6 +111,8 @@
"none": "There is no preview for this file", "none": "There is no preview for this file",
"asText": "Shown as plain text", "asText": "Shown as plain text",
"truncated": "Showing the start of this {{size}} file", "truncated": "Showing the start of this {{size}} file",
"wrap": "Wrap long lines" "wrap": "Wrap long lines",
"firstLines": "Showing the first {{lines}} lines.",
"downloadWhole": "Download the whole file"
} }
} }
+5 -8
View File
@@ -63,7 +63,6 @@
"download": "Mentés a gépemre", "download": "Mentés a gépemre",
"play": "Lejátszás", "play": "Lejátszás",
"edit": "Szerkesztés / vágás…", "edit": "Szerkesztés / vágás…",
"redownload": "Újraletöltés (fájl újraépítése)",
"removeShared": "Eltávolítás a listámból" "removeShared": "Eltávolítás a listámból"
}, },
"player": { "player": {
@@ -87,7 +86,7 @@
"done": "Megosztva vele: {{email}}", "done": "Megosztva vele: {{email}}",
"failed": "Nem sikerült megosztani", "failed": "Nem sikerült megosztani",
"userSection": "Megosztás felhasználóval", "userSection": "Megosztás felhasználóval",
"userHint": "A címzett a „Velem megosztva” fülön találja meg.", "userHint": "A címzett a Fájlok oldalon, a „Megosztva velem” alatt találja meg.",
"pickPlaceholder": "Keress egy felhasználót…", "pickPlaceholder": "Keress egy felhasználót…",
"noUsers": "Nincs más felhasználó.", "noUsers": "Nincs más felhasználó.",
"linkSection": "Megosztás linkkel", "linkSection": "Megosztás linkkel",
@@ -130,15 +129,13 @@
"cancelEditTitle": "Megszakítod a klipet?", "cancelEditTitle": "Megszakítod a klipet?",
"cancelEditBody": "Ez leállítja a klip készítését és eltávolítja a sorból.", "cancelEditBody": "Ez leállítja a klip készítését és eltávolítja a sorból.",
"cancelEditAction": "Klip készítésének leállítása", "cancelEditAction": "Klip készítésének leállítása",
"deleteTitle": "Törlöd a letöltést?", "deleteTitle": "Törlöd: „{{name}}”?",
"deleteBody": "Ez törli a letöltéseid közül. A fájl a lejáratáig még a gyorsítótárban maradhat mások számára.", "deleteBody": "Kikerül a könyvtáradból. Akinek ugyanez a fájl megvan, annál megmarad.",
"deleteUploadTitle": "Törlöd a feltöltést?",
"deleteUploadBody": "A feltöltött fájl törlődik a szerverről. Ez nem vonható vissza.", "deleteUploadBody": "A feltöltött fájl törlődik a szerverről. Ez nem vonható vissza.",
"deleteEditTitle": "Törlöd a klipet?",
"deleteEditBody": "A klip törlődik a szerverről. Ez nem vonható vissza.", "deleteEditBody": "A klip törlődik a szerverről. Ez nem vonható vissza.",
"removeSharedTitle": "Eltávolítod a listádból?", "removeSharedTitle": "Eltávolítod a listádból: „{{name}}”?",
"removeSharedBody": "Csak a te megosztott listádból tűnik el — a tulajdonos fájlját nem törli.", "removeSharedBody": "Csak a te megosztott listádból tűnik el — a tulajdonos fájlját nem törli.",
"redownloadTitle": "Újraletöltöd ezt a fájlt?", "redownloadTitle": "Újraletöltöd: „{{name}}”?",
"redownloadBody": "A jelenlegi fájl törlődik és újra letöltődik a legfrissebb formátum- és elnevezési szabályokkal. A már elküldött megosztási linkek működnek tovább, és az új fájlra fognak mutatni (sikertelen újraletöltés esetén a link törötten marad).", "redownloadBody": "A jelenlegi fájl törlődik és újra letöltődik a legfrissebb formátum- és elnevezési szabályokkal. A már elküldött megosztási linkek működnek tovább, és az új fájlra fognak mutatni (sikertelen újraletöltés esetén a link törötten marad).",
"redownloadAction": "Újraletöltés" "redownloadAction": "Újraletöltés"
}, },
+18 -6
View File
@@ -3,7 +3,13 @@
"searchPlaceholder": "Keresés a fájlok között…", "searchPlaceholder": "Keresés a fájlok között…",
"count_one": "{{count}} elem", "count_one": "{{count}} elem",
"count_other": "{{count}} elem", "count_other": "{{count}} elem",
"countNewest_one": "A legújabb {{count}} elem",
"countNewest_other": "A legújabb {{count}} elem",
"rowMenu": "Fájl műveletei", "rowMenu": "Fájl műveletei",
"folderUnknown": "Ez a mappa nem tölthető be",
"arrival": {
"gone": "Ez a fájl már nincs a könyvtáradban"
},
"phoneOk": "Telefonon is lejátszható", "phoneOk": "Telefonon is lejátszható",
"rail": { "rail": {
"library": "Könyvtár", "library": "Könyvtár",
@@ -17,11 +23,11 @@
"source": { "source": {
"youtube": "YouTube", "youtube": "YouTube",
"plex": "Plex klipek", "plex": "Plex klipek",
"upload": "Feltöltések", "upload": "Feltöltések"
"hub": "Hub"
}, },
"usage": { "usage": {
"title": "Tárhely" "title": "Tárhely",
"failed": "A tárhelyhasználat nem tölthető be"
}, },
"columns": { "columns": {
"name": "Név", "name": "Név",
@@ -52,11 +58,15 @@
"move": "Áthelyezés", "move": "Áthelyezés",
"moveHome": "Vissza ide: {{folder}}", "moveHome": "Vissza ide: {{folder}}",
"noOwnFolders": "Még nincs saját mappád", "noOwnFolders": "Még nincs saját mappád",
"keep": "Megtartom", "foldersUnknown": "A mappáid nem tölthetők be",
"keep": "Megtartás",
"unkeep": "Megtartás visszavonása", "unkeep": "Megtartás visszavonása",
"details": "Részletek…", "details": "Részletek…",
"upload": "Feltöltés", "upload": "Feltöltés",
"view": "Előnézet" "view": "Előnézet",
"delete": "Törlés…",
"removeShared": "Eltávolítás a listámból…",
"redownload": "Újraletöltés (fájl újraépítése)…"
}, },
"folder": { "folder": {
"menu": "Mappa műveletei", "menu": "Mappa műveletei",
@@ -101,6 +111,8 @@
"none": "Ehhez a fájlhoz nincs előnézet", "none": "Ehhez a fájlhoz nincs előnézet",
"asText": "Egyszerű szövegként", "asText": "Egyszerű szövegként",
"truncated": "A {{size}} méretű fájl eleje látszik", "truncated": "A {{size}} méretű fájl eleje látszik",
"wrap": "Hosszú sorok tördelése" "wrap": "Hosszú sorok tördelése",
"firstLines": "Az első {{lines}} sor látszik.",
"downloadWhole": "A teljes fájl letöltése"
} }
} }
+1 -1
View File
@@ -28,7 +28,7 @@
"goPlaylists": "Ugrás: Lejátszási listák", "goPlaylists": "Ugrás: Lejátszási listák",
"goPlex": "Ugrás: Plex", "goPlex": "Ugrás: Plex",
"goNotifications": "Ugrás: Értesítések", "goNotifications": "Ugrás: Értesítések",
"goFiles": "Ugrás a Fájlokhoz", "goFiles": "Ugrás: Fájlok",
"goStats": "Ugrás: Statisztika", "goStats": "Ugrás: Statisztika",
"goSettings": "Ugrás: Beállítások", "goSettings": "Ugrás: Beállítások",
"search": "Ugrás a keresőbe", "search": "Ugrás a keresőbe",
+1
View File
@@ -62,6 +62,7 @@ import { downloadsApi } from "./api/downloads";
export type * from "./api/downloads"; export type * from "./api/downloads";
import { filesApi } from "./api/files"; import { filesApi } from "./api/files";
export type * from "./api/files"; export type * from "./api/files";
export { FILES_LIST_CAP, FILE_SORTS } from "./api/files";
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics"; import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
export type * from "./api/diagnostics"; export type * from "./api/diagnostics";
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics"; export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
+2 -2
View File
@@ -121,8 +121,8 @@ export interface DownloadJob {
// Whose it is — only the admin Storage tab's listing of every user's downloads carries them. // Whose it is — only the admin Storage tab's listing of every user's downloads carries them.
user_id?: number; user_id?: number;
user_email?: string; user_email?: string;
// Where the viewer stopped in it (whole seconds; 0 = from the start). Only the two library listings // Where the viewer stopped in it (whole seconds; 0 = from the start). Only the listings carry it (the
// (`downloads`, `downloadsShared`) carry it — a job a mutation returns does not. // Files library's and the queue's) — a job a mutation returns does not.
position_seconds?: number; position_seconds?: number;
} }
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { FILE_SORTS, FILES_LIST_CAP, listQuery } from "./files";
// The Files list's request, held to the route that answers it (backend routes/files.py `list_files`).
// FastAPI ignores a query parameter it does not know, so a name renamed on one side only is no error
// anywhere: a folder's view lists every file, "Shared with me" lists the reader's own files, a search
// matches everything. The route's parameter names are read from its source, as text — the backend
// cannot be imported here.
const routes = readFileSync(resolve(__dirname, "../../../../backend/app/routes/files.py"), "utf-8");
function routeParams(): Map<string, string> {
const sig = routes.match(/^def list_files\(([\s\S]*?)\) -> /m);
expect(sig, "routes/files.py no longer defines list_files").toBeTruthy();
const params = new Map<string, string>();
for (const m of sig![1]!.matchAll(/^\s*(\w+): ([^\n]*?),?$/gm)) params.set(m[1]!, m[2]!);
return params;
}
const sent = (p: Parameters<typeof listQuery>[0]) => [
...new URLSearchParams(listQuery(p).replace(/^\?/, "")).keys(),
];
describe("the Files list's request", () => {
it("names only parameters the route reads", () => {
const known = routeParams();
const names = new Set([
...sent({ folderId: 3, q: "x", sort: "name", order: "asc" }),
...sent({ shared: true }),
]);
expect([...names].sort()).toEqual(["folder_id", "order", "q", "scope", "sort"]);
for (const n of names) expect(known.has(n), `list_files has no parameter "${n}"`).toBe(true);
});
it("asks for the shared list and the sorts the route knows by its own words", () => {
const known = routeParams();
expect(listQuery({ shared: true })).toBe("?scope=shared");
expect(known.get("scope")).toContain('"shared"');
for (const s of FILE_SORTS) expect(known.get("sort")).toContain(`"${s}"`);
});
it("knows the cap the route lists up to", () => {
// A list as long as the cap may be missing older files; the page then searches on the server.
const cap = routes.match(/^MAX_LIST = (\d+)$/m);
expect(cap, "routes/files.py no longer defines MAX_LIST").toBeTruthy();
expect(FILES_LIST_CAP).toBe(Number(cap![1]));
});
});
+8 -1
View File
@@ -19,7 +19,13 @@ export interface FileFolder {
created_at: string | null; created_at: string | null;
} }
// The most files one list answers with (backend `routes/files.py` MAX_LIST, held equal by
// files.contract.test.ts): a list this long may be missing older files, so the page asks the
// server to search and sort it rather than doing either on the rows it got.
export const FILES_LIST_CAP = 500;
export type FileSort = "added" | "name" | "size"; export type FileSort = "added" | "name" | "size";
export const FILE_SORTS: readonly FileSort[] = ["added", "name", "size"];
export type FileOrder = "asc" | "desc"; export type FileOrder = "asc" | "desc";
export interface FileListParams { export interface FileListParams {
@@ -69,7 +75,8 @@ export interface StorageUsage {
upload_max_bytes: number; upload_max_bytes: number;
} }
function listQuery(p: FileListParams): string { /** The list's query string — exported for the contract test that holds its names to the route's. */
export function listQuery(p: FileListParams): string {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (p.shared) q.set("scope", "shared"); if (p.shared) q.set("scope", "shared");
else if (p.folderId != null) q.set("folder_id", String(p.folderId)); else if (p.folderId != null) q.set("folder_id", String(p.folderId));
+6 -3
View File
@@ -1,4 +1,5 @@
import { ensureNavId, peekNavId } from "./history"; import { ensureNavId, peekNavId } from "./history";
import { anyBlockingLayer } from "./layerStack";
import { createStore } from "./store"; import { createStore } from "./store";
// Downloading a link is an action you start anywhere: the Files page's link box, a link pasted onto // Downloading a link is an action you start anywhere: the Files page's link box, a link pasted onto
@@ -13,13 +14,15 @@ export function asDownloadLink(text: string | null | undefined): string | null {
return /^https?:\/\/\S+$/i.test(s) ? s : null; return /^https?:\/\/\S+$/i.test(s) ? s : null;
} }
/** Whether a paste here is the page's to take: not typed into a field, and no dialog on top (the /** Whether a paste here is the page's to take: not typed into a field, and nothing on top that owns
* reader is busy with that, and a link pasted at it is not a request to download). */ * the keyboard — a dialog, a player, an open menu (the reader is busy with that, and a link pasted
* at it is not a request to download). The layer stack knows them all; a player marks no
* `aria-modal`, so the markup alone would miss it. */
export function pasteIsForThePage(target: EventTarget | null): boolean { export function pasteIsForThePage(target: EventTarget | null): boolean {
const el = target instanceof Element ? target : null; const el = target instanceof Element ? target : null;
if (el?.closest("input, textarea, select, [contenteditable]:not([contenteditable='false'])")) if (el?.closest("input, textarea, select, [contenteditable]:not([contenteditable='false'])"))
return false; return false;
return !document.querySelector("[aria-modal='true']"); return !anyBlockingLayer() && !document.querySelector("[aria-modal='true']");
} }
/** Ask to download `url`: the app's download dialog opens on it. */ /** Ask to download `url`: the app's download dialog opens on it. */
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import type { QueueJob } from "./api"; import type { QueueJob } from "./api";
import { import {
clearFinished, clearFinished,
forgetFinished,
noteQueue, noteQueue,
resetQueueWatch, resetQueueWatch,
takeUnannounced, takeUnannounced,
@@ -47,4 +48,11 @@ describe("downloadQueue · a finish", () => {
clearFinished(); clearFinished();
expect(finishedIds()).toEqual([]); expect(finishedIds()).toEqual([]);
}); });
it("a finished file deleted since leaves the tray: its Open and Copy link would reach nothing", () => {
noteQueue([job(1, "running"), job(2, "running")]);
noteQueue([job(1, "done"), job(2, "done")]);
forgetFinished(1);
expect(finishedIds()).toEqual([2]);
});
}); });
+25 -3
View File
@@ -1,4 +1,4 @@
import { api, type QueueJob } from "./api"; import { api, type DownloadStatus, type QueueJob } from "./api";
import { createStore } from "./store"; import { createStore } from "./store";
// The download queue as this tab has watched it: which jobs it has seen finish. The tray shows them // The download queue as this tab has watched it: which jobs it has seen finish. The tray shows them
@@ -8,8 +8,26 @@ import { createStore } from "./store";
// through `fetchQueue`, so a finish is seen once whichever of them polled, and nothing has to diff the // through `fetchQueue`, so a finish is seen once whichever of them polled, and nothing has to diff the
// previous render's data. // previous render's data.
/** The statuses a job leaves on its own: it is waiting, or working. */ /** The statuses the tray lists as under way: waiting, working, or paused by its user. */
export const IN_FLIGHT: ReadonlySet<string> = new Set(["queued", "running", "paused"]); export const IN_FLIGHT: ReadonlySet<DownloadStatus> = new Set(["queued", "running", "paused"]);
/** The statuses a job leaves on its own, so the queue is worth asking about often. A paused job is
* not one: it waits for its user, however long that takes. */
export const MOVING: ReadonlySet<DownloadStatus> = new Set(["queued", "running"]);
/** The steps a running job reports that have a name of their own (`downloads.phase.*`). */
export const DOWNLOAD_PHASES: readonly string[] = [
"video",
"audio",
"editing",
"converting",
"merging",
"audio_extract",
"thumbnail",
"sponsorblock",
"metadata",
"processing",
];
let lastSeen: Map<number, string> | null = null; let lastSeen: Map<number, string> | null = null;
const finished = createStore<QueueJob[]>([]); const finished = createStore<QueueJob[]>([]);
@@ -48,6 +66,10 @@ export const useFinished = (): QueueJob[] => finished.useValue();
/** The tray was closed: what finished is no longer shown. */ /** The tray was closed: what finished is no longer shown. */
export const clearFinished = (): void => finished.set([]); export const clearFinished = (): void => finished.set([]);
/** A file was deleted: a "Done · Open" row for it would open and link nothing. */
export const forgetFinished = (id: number): void =>
finished.set((list) => (list.some((j) => j.id === id) ? list.filter((j) => j.id !== id) : list));
/** The finishes not yet announced — the render subscription that wakes the announcer. */ /** The finishes not yet announced — the render subscription that wakes the announcer. */
export const useUnannounced = (): QueueJob[] => unannounced.useValue(); export const useUnannounced = (): QueueJob[] => unannounced.useValue();
@@ -108,3 +108,16 @@ describe("useCardPager — following a moved row", () => {
expect(result.current.pagerProps.page).toBe(1); expect(result.current.pagerProps.page).toBe(1);
}); });
}); });
describe("useCardPager — another list", () => {
it("starts on page one when the caller's scope changes", () => {
const { result, rerender } = renderHook(
({ scope }) => useCardPager(rows(100), KEY, true, null, undefined, scope),
{ initialProps: { scope: "folder 1" } },
);
act(() => result.current.pagerProps.onPageChange(3));
expect(result.current.pagerProps.page).toBe(3);
rerender({ scope: "folder 2" });
expect(result.current.pagerProps.page).toBe(0);
});
});
+7
View File
@@ -19,10 +19,17 @@ export function useCardPager<T>(
reveal?: { seq: number; match: (row: T) => boolean } | null, reveal?: { seq: number; match: (row: T) => boolean } | null,
/** The size picker's name — what the cards ARE (the channel manager's are "cards"). */ /** The size picker's name — what the cards ARE (the channel manager's are "cards"). */
sizeLabel?: string, sizeLabel?: string,
/** Which list `rows` are: another one (a folder, a search, a sort) starts at page one. */
scope?: string,
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
const [size, setSize] = useState(() => parseCardSize(readAccount(sizeKey, null))); const [size, setSize] = useState(() => parseCardSize(readAccount(sizeKey, null)));
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [scopeSeen, setScopeSeen] = useState(scope);
if (scope !== scopeSeen) {
setScopeSeen(scope);
setPage(0);
}
// During render against the last seq seen, as in DataTable (seeded so a mount never jumps). // During render against the last seq seen, as in DataTable (seeded so a mount never jumps).
const [revealSeen, setRevealSeen] = useState(reveal?.seq); const [revealSeen, setRevealSeen] = useState(reveal?.seq);