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
@@ -24,6 +24,7 @@ import DownloadIntake from "./DownloadIntake";
import { LinkBox } from "./LinkBox";
import { clearPendingDownload, requestLinkBox } from "../lib/downloadIntake";
import { ensureNavId } from "../lib/history";
import { pushLayer, removeLayer } from "../lib/layerStack";
afterEach(() => {
cleanup();
@@ -79,6 +80,18 @@ describe("DownloadIntake · a pasted link", () => {
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 () => {
render(<DownloadIntake />);
paste(document.body, "https://a.example/1");
@@ -102,6 +115,20 @@ describe("LinkBox", () => {
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 () => {
render(
<>
@@ -17,6 +17,7 @@ const h = vi.hoisted(() => ({
share: vi.fn(async () => "https://x/watch/t"),
notify: vi.fn(),
prefs: new Map<string, unknown>(),
intervalMs: ((_d: unknown) => 0) as (d: unknown) => number,
}));
vi.mock("react-i18next", async (importOriginal) => ({
@@ -28,13 +29,16 @@ vi.mock("react-i18next", async (importOriginal) => ({
}),
}));
vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: () => ({
data: h.jobs,
dataUpdatedAt: h.updatedAt,
isError: false,
isLoading: false,
refetch: vi.fn(),
}),
useLiveQuery: (_key: unknown, _fn: unknown, opts: { intervalMs: (d: unknown) => number }) => (
(h.intervalMs = opts.intervalMs),
{
data: h.jobs,
dataUpdatedAt: h.updatedAt,
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
// 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();
});
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", () => {
const resume = vi.spyOn(api, "resumeDownload").mockResolvedValue({} as never);
poll([job(3, "error", { error_code: "login_required" })]);
+49 -16
View File
@@ -9,6 +9,7 @@ import {
FolderOpen,
Link,
Loader2,
PauseCircle,
RotateCw,
X,
} from "lucide-react";
@@ -17,6 +18,8 @@ import { quickShareLink } from "../lib/downloads";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import {
IN_FLIGHT,
DOWNLOAD_PHASES,
MOVING,
clearFinished,
doneRevealing,
fetchQueue,
@@ -66,22 +69,39 @@ const BYTE_PHASES = ["video", "audio", "editing", "converting"];
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 }) {
if (job.phase && !BYTE_PHASES.includes(job.phase))
return (
<span className="block h-1 overflow-hidden rounded-full bg-fg/10">
<span className="block h-full w-full animate-pulse bg-accent/60" />
</span>
);
const { t } = useTranslation();
const bytes = !job.phase || BYTE_PHASES.includes(job.phase);
const step =
job.phase && DOWNLOAD_PHASES.includes(job.phase) ? t(`downloads.phase.${job.phase}`) : null;
return (
<span className="flex items-center gap-2 text-xs text-muted">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-fg/10">
<span
className="block h-full rounded-full bg-accent transition-all"
style={{ width: `${job.progress}%` }}
/>
<span className="flex min-w-0 items-center gap-2 text-xs text-muted">
{/* One column for the step, so every row's bar starts on the same line. */}
{step && (
<span className="w-36 shrink-0 truncate" title={step}>
{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 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>
);
}
@@ -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
// (invalidateDownloads), and one started on another device shows within a minute.
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 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
? Math.round(running.reduce((sum, j) => sum + j.progress, 0) / running.length)
: 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 = (
<StateIcon
aria-hidden
className={clsx(
"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 { pushLayer, removeLayer } from "../lib/layerStack";
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 () => {
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a", truncated: true });
mount([file(4, "text")], 4);
@@ -141,6 +157,30 @@ describe("FilePreview · stepping", () => {
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", () => {
const { onFileChange } = mount(list, 1);
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 { formatBytes } from "../lib/format";
import { dispatch, useKeyScope } from "../lib/keymap";
import { useLayer } from "../lib/layerStack";
import { qk } from "../lib/queryKeys";
import { jobTitle } from "./downloadTitle";
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.
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). */
function useTextPreview(id: number, enabled: boolean) {
@@ -74,7 +77,17 @@ export default function FilePreview({
if (next) onFileChange(next.id);
};
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) => {
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", {
prevFile: (ev) => {
ev.preventDefault();
@@ -217,8 +230,11 @@ function TextStage({ file }: { file: LibraryFile }) {
</StateMessage>
);
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.
const lines = q.data.text.replace(/\r?\n$/, "").split(/\r?\n/);
// A file's last line ends in a newline; that is no line of its own. Only the first TEXT_LINES are
// 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 (
<div className={`${STAGE} flex flex-col gap-2`}>
<div className="flex items-center gap-2 text-xs text-muted">
@@ -239,8 +255,10 @@ function TextStage({ file }: { file: LibraryFile }) {
</IconButton>
</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}
data-scrolls-sideways
role="region"
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"
@@ -262,6 +280,12 @@ function TextStage({ file }: { file: LibraryFile }) {
</li>
))}
</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>
);
+193 -33
View File
@@ -26,6 +26,14 @@ const mocks = vi.hoisted(() => ({
confirm: vi.fn(async (_o?: unknown) => true),
keys: [] as unknown[],
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) => {
@@ -34,19 +42,39 @@ vi.mock("react-i18next", async (importOriginal) => {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
o && "folder" in o ? `${key}:${String(o.folder)}` : key,
t: (key: string, o?: Record<string, unknown>) => {
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("../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);
const asked = key[1] as { q?: string; sort?: string } | undefined;
return {
data:
key[0] === "files" ? mocks.files : key[0] === "file-folders" ? mocks.folders : undefined,
isError: false,
key[0] === "files"
? 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,
isFetching: mocks.fetching,
refetch: vi.fn(),
@@ -59,6 +87,7 @@ vi.mock("./ui/PageShell", async (importOriginal) => ({
}));
import Files from "./Files";
import { FILES_LIST_CAP } from "../lib/api";
const file = (id: number, extra: Partial<LibraryFile> = {}): LibraryFile =>
({
@@ -172,10 +201,10 @@ describe("Files · my file's row", () => {
"files.actions.move",
"files.actions.keep",
"files.actions.details",
"downloads.actions.redownload",
"files.actions.redownload",
"common.copy.title",
"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 }));
});
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 () => {
mocks.files = [file(2)];
mocks.folders = [YT];
@@ -329,15 +375,39 @@ describe("Files · my file's row", () => {
mocks.confirm.mockResolvedValueOnce(false);
mount();
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));
expect(del).not.toHaveBeenCalled();
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));
});
});
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", () => {
it("label the confirm button with the action, not the dialog's question", async () => {
mocks.files = [file(2)];
@@ -345,10 +415,10 @@ describe("Files · the confirms", () => {
mocks.confirm.mockResolvedValue(false);
mount();
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 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));
expect(
mocks.confirm.mock.calls.map(([o]) => {
@@ -363,20 +433,21 @@ describe("Files · the confirms", () => {
});
it.each([
["upload", "downloads.confirm.deleteUploadTitle", "downloads.confirm.deleteUploadBody"],
["edit", "downloads.confirm.deleteEditTitle", "downloads.confirm.deleteEditBody"],
["download", "downloads.confirm.deleteTitle", "downloads.confirm.deleteBody"],
] as const)("deleting a %s says what happens to its file", async (kind, title, body) => {
// Only a download's file may stay cached for other users; an upload's or a clip's is theirs alone.
["upload", "downloads.confirm.deleteUploadBody"],
["edit", "downloads.confirm.deleteEditBody"],
["download", "downloads.confirm.deleteBody"],
] as const)("deleting a %s names it and says what happens to its file", async (kind, body) => {
// 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.folders = [YT];
mocks.confirm.mockResolvedValueOnce(false);
mount();
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));
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.edit",
"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));
// The shared list is its own read, and nothing can be uploaded into it.
expect(mocks.keys).toContainEqual(["files", { shared: true }]);
@@ -547,7 +618,7 @@ describe("Files · the grid", () => {
expect(rowOf("Clip 2").textContent).toContain("files.retention.kept");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toContain("files.actions.move");
expect(menuRows()).toContain("downloads.actions.delete");
expect(menuRows()).toContain("files.actions.delete");
});
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 e = new Event(type, { bubbles: true, cancelable: true });
const list = Object.assign([...files], { item: (i: number) => files[i] ?? null });
Object.defineProperty(e, "dataTransfer", { value: { types: ["Files"], files: list } });
act(() => {
window.dispatchEvent(e);
});
return e;
};
const drag = (type: string, files: File[] = []) => {
const e = new Event(type, { bubbles: true, cancelable: true });
const list = Object.assign([...files], { item: (i: number) => files[i] ?? null });
Object.defineProperty(e, "dataTransfer", { value: { types: ["Files"], files: list } });
act(() => {
window.dispatchEvent(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 () => {
mocks.files = [];
mocks.folders = [YT, OWN];
@@ -789,11 +860,100 @@ describe("Files · dropping a file on the page", () => {
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.folders = [YT];
mount({ kind: "shared" });
expect(drag("dragenter").defaultPrevented).toBe(false);
drag("dragenter");
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 { useTranslation } from "react-i18next";
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 { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { formatBytes, formatDateTime, relativeTime } from "../lib/format";
@@ -10,6 +18,7 @@ import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { LS, accountKey } from "../lib/storage";
import { useCardPager } from "../lib/useCardPager";
import { useDebounced } from "../lib/useDebounced";
import { useLiveQuery } from "../lib/useLiveQuery";
import { usePref } from "../lib/usePref";
import { JobThumb } from "./downloadRow";
@@ -21,7 +30,7 @@ import { folderLabel } from "./fileFolders";
import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
import { useFileActions } from "./useFileActions";
import { useFolderActions } from "./useFolderActions";
import { RenameField } from "./RenameField";
import { RenameField } from "./ui/RenameField";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import { Badge } from "./ui/Badge";
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;
}
/** The list a view reads. The search is applied here, on the rows (the list is the whole library,
* capped server-side): a request per keystroke would buy nothing the rows do not already hold. */
/** The list a view reads. Below the server's cap it holds every file of the view, so the search and
* 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 {
if (view.kind === "shared") return { shared: true };
if (view.kind === "folder") return { folderId: view.id };
@@ -165,18 +174,49 @@ export default function Files() {
const folders = foldersQ.data ?? [];
// 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.
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;
// 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(() => {
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]);
// 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);
// 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 all = filesQ.data ?? [];
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 [renamingId, setRenamingId] = useState<number | null>(null);
@@ -199,7 +239,7 @@ export default function Files() {
const [viewing, setViewing] = useState<number | null>(null);
const actions = useFileActions({
folders,
folders: foldersQ.data ?? null,
onPlay: play,
onView: (f) => setViewing(f.id),
onEdit: setEditFile,
@@ -233,9 +273,13 @@ export default function Files() {
: shown.kind === "folder"
? open
? folderLabel(open, t)
: ""
: foldersQ.isError && !foldersQ.data
? t("files.folderUnknown")
: ""
: 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 uploadTargetName =
uploadTarget !== null && open ? folderLabel(open, t) : t("files.source.upload");
@@ -252,8 +296,6 @@ export default function Files() {
const viewKey = viewParam(shown);
const layout: FilesLayout = layouts[viewKey] ?? "list";
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 ─────────────────────────────────────────────────────────────────────────
// 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(() => {
if (lookEverywhere) setView(ALL_FILES);
else if (here) consumeFocus(viewKey);
else if (missing) consumeFocus(null);
}, [here, missing, lookEverywhere, viewKey, setView, consumeFocus]);
else if (missing) {
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 ringed = arrival?.id ?? null;
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 ─────────────────────────────────────────────────────────
// 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
// 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 dragDepth = useRef(0);
const dropReady = canUpload && upload === null;
const onDrag = useEffectEvent((e: DragEvent) => {
if (!dropReady || !e.dataTransfer?.types.includes("Files")) return;
if (!e.dataTransfer?.types.includes("Files")) return;
e.preventDefault();
if (!dropReady) {
e.dataTransfer.dropEffect = "none";
return;
}
if (e.type === "dragenter") dragDepth.current += 1;
if (e.type === "dragleave") dragDepth.current = Math.max(0, dragDepth.current - 1);
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).
const cards = isGrid ? sortRows(rows, columns, sort, lang) : rows;
const { paged, pagerProps } = useCardPager(
@@ -488,6 +542,7 @@ export default function Files() {
isGrid,
reveal && { seq: reveal.seq, match: (f) => String(f.id) === reveal.key },
t("files.perPage"),
`${pageScope}|${JSON.stringify(sort)}`,
);
const emptyText = q
@@ -526,11 +581,11 @@ export default function Files() {
);
const body =
filesQ.isError && !filesQ.data ? (
<StateMessage tone="error" onRetry={() => filesQ.refetch()}>
listQ.isError && !listQ.data ? (
<StateMessage tone="error" onRetry={() => listQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : filesQ.isLoading ? (
) : listQ.isLoading ? (
<LoadingState shape={isGrid ? "grid" : "list"} />
) : isGrid ? (
grid
@@ -547,6 +602,7 @@ export default function Files() {
onSortChange={setSort}
reveal={reveal}
narrowedBy={q}
pageScope={pageScope}
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.
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 && (
<MoreButton
@@ -589,9 +651,10 @@ export default function Files() {
menuProps={folderMenu.buttonProps(open)}
/>
)}
{filesQ.data && (
{listQ.data && (
<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>
)}
{isGrid && (
@@ -155,3 +155,31 @@ describe("FilesRail · folders", () => {
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 { Meter } from "./ui/Meter";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
@@ -12,7 +13,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import FilterRail from "./FilterRail";
import { folderIcon, folderLabel } from "./fileFolders";
import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext";
import { RenameField } from "./RenameField";
import { RenameField } from "./ui/RenameField";
import { useFolderActions } from "./useFolderActions";
import { ContextMenu } from "./ui/ContextMenu";
import { Pressable } from "./ui/Pressable";
@@ -77,9 +78,13 @@ function StorageMeter() {
intervalMs: (u) => (u?.running_jobs ? 2000 : 15000),
});
const u = usage.data;
if (!u) return null;
const pct =
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
// A failed read says so where the figures would be, rather than the meter quietly not being there.
if (!u)
return usage.isError ? (
<p className="mt-3 px-2 text-xs text-muted" data-testid="storage-meter">
{t("files.usage.failed")}
</p>
) : null;
return (
<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">
@@ -90,12 +95,7 @@ function StorageMeter() {
</span>
</span>
{!u.unlimited && (
<span className="h-1 overflow-hidden rounded-full bg-fg/10">
<span
className={clsx("block h-full rounded-full", pct > 90 ? "bg-danger" : "bg-accent")}
style={{ width: `${pct}%` }}
/>
</span>
<Meter value={u.footprint_bytes} max={u.max_bytes} label={t("files.usage.title")} />
)}
</div>
);
@@ -202,7 +202,8 @@ export default function FilesRail({
<div className="flex flex-col gap-0.5">
{failed}
{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>
)}
</div>
+3 -1
View File
@@ -41,7 +41,9 @@ export function LinkBox({ className }: { className?: string }) {
const paste = async (force: boolean) => {
if (!force && url.trim()) return;
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
@@ -111,7 +111,9 @@ describe("DownloadFormats", () => {
await mount();
await openMenu(rowOf("Phone 480"));
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("");
fireEvent.change(name, { target: { value: "Phone copy" } });
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 { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
@@ -93,6 +93,14 @@ export function DownloadFormats() {
const [name, setName] = useState("");
const [spec, setSpec] = useState<DownloadSpec>(BLANK);
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 startNew = () => {
@@ -100,12 +108,14 @@ export function DownloadFormats() {
setName("");
setSpec(BLANK);
setFormOpen(true);
setOpened((n) => n + 1);
};
const startEdit = (p: DownloadProfile) => {
setEditing(p);
setName(p.name);
setSpec({ ...BLANK, ...p.spec });
setFormOpen(true);
setOpened((n) => n + 1);
};
// A new format from an existing one: its settings, and a name of its own to give.
const startCopy = (p: DownloadProfile) => {
@@ -113,6 +123,7 @@ export function DownloadFormats() {
setName("");
setSpec({ ...BLANK, ...p.spec });
setFormOpen(true);
setOpened((n) => n + 1);
};
const save = useMutation({
@@ -261,8 +272,12 @@ export function DownloadFormats() {
) : (
<div className="rounded-xl border border-border p-4 space-y-3">
<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
id={nameId}
ref={nameField}
className="w-full"
value={name}
onChange={(e) => setName(e.target.value)}
+4 -4
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { invalidateDownloads } from "../lib/downloads";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
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 another try adds only the rest.
if (done.length) {
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.storageUsage() });
invalidateDownloads(qc);
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
// 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 };
},
onSuccess: ({ files }) => {
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.storageUsage() });
// The whole library moves with it: an edit the user already made is done at once, and lists.
invalidateDownloads(qc);
notify({ level: "success", message: t("editor.created", { count: files }) });
onClose();
},
+7 -15
View File
@@ -1,8 +1,8 @@
import { useState } from "react";
import { Meter } from "./ui/Meter";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import clsx from "clsx";
import { ExternalLink, RotateCcw, Settings2 } from "lucide-react";
import Modal, { ModalFooter } from "./ui/Modal";
import { useLiveQuery } from "../lib/useLiveQuery";
@@ -441,22 +441,14 @@ function QuotaFill({ user, lang }: { user: AdminStorageUser; lang: string }) {
const { t } = useTranslation();
if (user.unlimited || !user.max_bytes)
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 (
<span className="flex flex-col gap-1">
<span
className="h-1.5 overflow-hidden rounded-full bg-fg/10"
role="meter"
aria-valuemin={0}
aria-valuemax={100}
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>
<Meter
value={user.footprint_bytes}
max={user.max_bytes}
label={t("downloads.admin.columns.quota")}
className="h-1.5"
/>
<span className="text-2xs text-muted tabular-nums">
{t("downloads.admin.ofQuota", { size: formatBytes(user.max_bytes, lang) })}
</span>
+4 -20
View File
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { File, Film, Music } from "lucide-react";
import clsx from "clsx";
import { DOWNLOAD_PHASES } from "../lib/downloadQueue";
import { api, type DownloadJob } from "../lib/api";
import { IconButton } from "./ui/IconButton";
import { MediaProgress } from "./ui/MediaCard";
@@ -19,24 +20,11 @@ const STATUS_CLS: Record<string, string> = {
canceled: "text-muted",
};
const PHASE_KEYS = [
"video",
"audio",
"editing",
"converting",
"merging",
"audio_extract",
"thumbnail",
"sponsorblock",
"metadata",
"processing",
];
export function StatusPill({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
// 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.
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
if (job.status === "running" && job.phase && DOWNLOAD_PHASES.includes(job.phase)) {
return (
<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
// 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.
// A picture's box when the caller gives none: a thumbnail-sized 16:9 frame.
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
* 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 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
// (`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.
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. */
export function sourceLabel(key: string, t: T): string {
if (key.startsWith(SITE_PREFIX)) return key.slice(SITE_PREFIX.length);
if (key === "youtube" || key === "plex" || key === "upload" || key === "hub")
return t(`files.source.${key}`);
if (key === "youtube" || key === "plex" || key === "upload") return t(`files.source.${key}`);
return key;
}
@@ -22,7 +21,6 @@ export function sourceIcon(key: string): LucideIcon | typeof YouTubeIcon {
if (key === "youtube") return YouTubeIcon;
if (key === "plex") return Film;
if (key === "upload") return Upload;
if (key === "hub") return Server;
return Globe;
}
@@ -36,4 +36,27 @@ describe("DataTable pager", () => {
expect(screen.getByText("row 12")).toBeTruthy();
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;
page: 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
@@ -38,12 +40,13 @@ interface Persisted {
// render). Mirrors controlled sort's stable `null` fallback.
const EMPTY_FILTERS: FilterMap = {};
function loadPersist(key?: string): Persisted {
function loadPersist(key?: string, pageScope?: string): Persisted {
const empty: Persisted = { sort: null, filters: {}, page: 0 };
if (!key) return empty;
try {
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 {
return empty;
}
@@ -114,6 +117,7 @@ export default function DataTable<T>({
reveal,
surfacedFilters,
narrowedBy = "",
pageScope,
}: {
rows: 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
// sort and filter changes do (progressiveRows). Without it every key re-rendered the whole page.
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 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
// the table keeps its own, seeded from and saved to persistKey.
const controlledSort = onSortChange !== undefined;
@@ -196,6 +203,12 @@ export default function DataTable<T>({
const [internalFilters, setInternalFilters] = useState<FilterMap>(initial.filters);
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
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.
const [size, setSize] = useState(pager ? (initial.size ?? pageSize) : 0);
const wide = useMediaQuery("(min-width: 48rem)");
@@ -236,9 +249,10 @@ export default function DataTable<T>({
filters: controlledFilters ? {} : filters,
page,
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
// 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
| 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` |
| 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` |
| 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` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
| 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` |
| 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) |
| 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` |
## The size contract (E10 S1c)
+1 -1
View File
@@ -1,5 +1,5 @@
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
// 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,
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() {
const qc = useQueryClient();
const { t } = useTranslation();
+44 -24
View File
@@ -20,6 +20,7 @@ import {
import { api, type DownloadJobKind, type FileFolder, type LibraryFile } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { forgetFinished } from "../lib/downloadQueue";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { useConfirm } from "./confirmContext";
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
// dropped from my list, never renamed, moved or deleted.
const DELETE_COPY: Record<DownloadJobKind, { title: string; body: string }> = {
download: { title: "downloads.confirm.deleteTitle", body: "downloads.confirm.deleteBody" },
upload: {
title: "downloads.confirm.deleteUploadTitle",
body: "downloads.confirm.deleteUploadBody",
},
edit: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
clip: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
// A destructive confirm names the file (DESIGN.md "Copy"); what the delete takes depends on the kind:
// a download's file may stay for others who have it, an upload or a clip is its owner's alone.
const DELETE_BODY: Record<DownloadJobKind, string> = {
download: "downloads.confirm.deleteBody",
upload: "downloads.confirm.deleteUploadBody",
edit: "downloads.confirm.deleteEditBody",
clip: "downloads.confirm.deleteEditBody",
};
type Patch = { name?: string | null; folder_id?: number; kept?: boolean };
@@ -61,8 +61,8 @@ export function useFileActions({
onEditDetails,
onManageShares,
}: {
/** The caller's folders — the Move submenu's places. */
folders: readonly FileFolder[];
/** The caller's folders — the Move submenu's places; null while they are not loaded. */
folders: readonly FileFolder[] | null;
onPlay: (file: LibraryFile, fromStart?: boolean) => void;
/** Open a file that is not media in the Files viewer. */
onView: (file: LibraryFile) => void;
@@ -83,7 +83,10 @@ export function useFileActions({
});
const remove = useMutation({
mutationFn: (id: number) => api.deleteFile(id),
onSuccess: () => invalidateDownloads(qc),
onSuccess: (_r, id) => {
forgetFinished(id);
void invalidateDownloads(qc);
},
meta: { failTitle: "errors.writeFailed.file.delete" },
});
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
// has left it. The server refuses any other place (`folders.move`), so none is offered.
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 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 } });
@@ -246,11 +264,11 @@ export function useFileActions({
? [
{
id: "redownload",
label: t("downloads.actions.redownload"),
label: t("files.actions.redownload"),
icon: icon(RotateCcw),
onSelect: () =>
void confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadTitle", { name: jobTitle(file) }),
t("downloads.confirm.redownloadBody"),
t("downloads.confirm.redownloadAction"),
() => redownload.mutate(file.id),
@@ -263,15 +281,16 @@ export function useFileActions({
[
{
id: "delete",
label: t("downloads.actions.delete"),
label: t("files.actions.delete"),
icon: icon(Trash2),
danger: true,
onSelect: () => {
const c = DELETE_COPY[file.job_kind ?? "download"];
void confirmThen(t(c.title), t(c.body), t("downloads.actions.delete"), () =>
remove.mutate(file.id),
);
},
onSelect: () =>
void confirmThen(
t("downloads.confirm.deleteTitle", { name: jobTitle(file) }),
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",
label: t("downloads.actions.removeShared"),
label: t("files.actions.removeShared"),
icon: icon(Trash2),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedTitle", { name: jobTitle(file) }),
t("downloads.confirm.removeSharedBody"),
t("downloads.actions.removeShared"),
() => removeShared.mutate(file.id),
@@ -313,10 +332,11 @@ export function useFileActions({
/** The row's Copy-link icon: the menu's row, one click. */
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 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 } });
};