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:
@@ -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" })]);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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 } });
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user