feat(files): the Files page — the library in one aligned list, its folders in a rail

Files is the one library: every done file, the ones shared with me, or one
folder's, picked in a side rail (sources, own folders, the storage meter) and
mirrored to ?folder=. One table with every kind of value in its own column;
Copy link, Share and Save in sight, every action on a right click: play,
cut, rename in the row, move to an own folder or back home, keep, details,
re-download, delete.

The Downloads page keeps only the queue until the tray replaces it. The old
/api/downloads library routes are gone: list shared, upload, the details
PATCH, delete, the file and the usage now live under /api/files, and a
listed file says which source it came from. The job's "expired" flag is gone
with the state it described.
This commit is contained in:
2026-10-11 05:34:25 +02:00
parent 1a1665b8ec
commit 85aa917b38
65 changed files with 2295 additions and 1044 deletions
@@ -1,8 +1,9 @@
// @vitest-environment jsdom
//
// The Download Center's confirm dialogs name their ACTION on the confirm button: the shared
// `confirmThen` once labelled it with the dialog's question — "Re-download this file?" on the button,
// and the cancel dialog's own "Cancel" beside "Cancel". i18n is key-passthrough, so labels read as keys.
// `confirmThen` once labelled it with the dialog's question — the cancel dialog's own "Cancel" beside
// "Cancel". A finished file's confirms are the Files page's (Files.menu.dom.test.tsx). i18n is
// key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -73,7 +74,6 @@ const job = (id: number, status: DownloadJob["status"]): DownloadJob => ({
display_uploader_url: null,
extra_links: [],
can_download: status === "done",
expired: false,
folder_id: null,
kept: false,
expires_at: null,
@@ -105,7 +105,7 @@ async function pick(row: string, label: string) {
describe("DownloadCenter confirm dialogs", () => {
it("label the confirm button with the action, not the dialog's question", async () => {
mocks.jobs = [job(1, "running"), job(2, "done")];
mocks.jobs = [job(1, "running")];
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
@@ -115,18 +115,9 @@ describe("DownloadCenter confirm dialogs", () => {
// The destructive and rare row buttons live in the row's "…".
await pick("Clip 1", "downloads.actions.cancel");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
// The tab's label shares its button with a count badge.
fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ }));
await screen.findByText("Clip 2");
await pick("Clip 2", "downloads.actions.redownload");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
await pick("Clip 2", "downloads.actions.delete");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3));
expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([
["downloads.confirm.cancelTitle", "downloads.confirm.cancelAction"],
["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"],
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
]);
});
@@ -163,24 +154,4 @@ describe("DownloadCenter confirm dialogs", () => {
"downloads.confirm.cancelEditAction",
]);
});
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 finished %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.
mocks.jobs = [{ ...job(5, "done"), job_kind: kind }];
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ }));
await screen.findByText("Clip 5");
await pick("Clip 5", "downloads.actions.delete");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
const [opts] = mocks.confirm.mock.calls[0]!;
expect([opts.title, opts.message]).toEqual([title, body]);
});
});
@@ -1,10 +1,9 @@
// @vitest-environment jsdom
//
// The Download Center: a download row keeps its primary action (play; pause / resume /
// retry in the queue) and the save link in sight, and everything else — the editors, sharing,
// re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it
// shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own
// menu. i18n is key-passthrough, so labels read as keys.
// The Download Center's queue: a row keeps its primary action (pause / resume / retry) in sight, and
// the copies and cancel are its ONE menu, opened by the row's "…" (it shows on hover or focus) or a
// right-click on the row. A finished download's row is the Files page's (Files.menu.dom.test.tsx).
// i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -68,7 +67,6 @@ const job = (
display_uploader_url: null,
extra_links: [],
can_download: status === "done",
expired: false,
job_kind: "download",
asset: { media_kind: "video", duration_s: 60 },
...extra,
@@ -209,160 +207,6 @@ describe("DownloadCenter · the queue row", () => {
});
});
describe("DownloadCenter · the library row", () => {
it("shows play, share, save and its '…'; the editors, managing shares and delete are in the menu", async () => {
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
mount("done");
await screen.findByText("Clip 2");
expect(rowButtons("Clip 2")).toEqual([
"downloads.actions.play",
"downloads.actions.share",
"downloads.actions.download",
"downloads.rowMenu",
]);
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.share",
"downloads.actions.download",
"downloads.actions.edit",
"downloads.actions.editDetails",
"downloads.actions.redownload",
"downloads.actions.shareManage",
"common.copy.title",
"common.copy.link",
"downloads.actions.delete",
]);
});
it("a started item continues — its bar shows how far — and its menu can start it over", async () => {
mocks.jobs = [job(2, "done", { position_seconds: 30 })];
mount("done");
await screen.findByText("Clip 2");
expect(rowButtons("Clip 2")[0]).toBe("card.continueTitle");
// The media cards' bar along the picture's bottom edge: 30 s of the 60 s item.
expect(rowOf("Clip 2").querySelector<HTMLElement>(".bg-accent")!.style.width).toBe("50%");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
// Starting over opens the player at 0:00, not at the kept point.
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "card.restartTitle")!,
);
const video = await waitFor(() => {
const v = document.querySelector("video");
expect(v).not.toBeNull();
return v!;
});
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => 60, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
expect(video.currentTime).toBe(0);
});
it("an item never played offers Play only, with no bar", async () => {
mocks.jobs = [job(2, "done", { position_seconds: 0 })];
mount("done");
await screen.findByText("Clip 2");
expect(rowOf("Clip 2").querySelector(".bg-accent")).toBeNull();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).not.toContain("card.restartTitle");
});
it("copies the share link from the row's own Share button", async () => {
mocks.jobs = [job(2, "done")];
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
const create = vi
.spyOn(api, "createDownloadLink")
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
mount("done");
await screen.findByText("Clip 2");
fireEvent.click(
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="downloads.actions.share"]')!,
);
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
});
it("saves the file through the same link as the row's save button", async () => {
mocks.jobs = [job(2, "done")];
const followed: string[] = [];
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement,
) {
followed.push(this.href);
});
mount("done");
await screen.findByText("Clip 2");
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.download")!,
);
const button = rowOf("Clip 2").querySelector<HTMLAnchorElement>(
'a[aria-label="downloads.actions.download"]',
)!;
expect(followed).toEqual([button.href]);
});
it("leaves out what a file or a clip cannot do, and no rule is left behind", async () => {
mocks.jobs = [job(3, "done", { job_kind: "upload", asset: { media_kind: "file" } } as never)];
mount("done");
await screen.findByText("Clip 3");
await openMenu(() => fireEvent.click(moreOf("Clip 3")));
expect(menuRows()).toEqual([
"downloads.actions.share",
"downloads.actions.download",
"downloads.actions.shareManage",
"common.copy.title",
"downloads.actions.delete",
]);
});
it("gives a link inside the row the browser's own menu", async () => {
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
mount("done");
const link = await screen.findByText("youtu.be/x");
const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
act(() => void link.dispatchEvent(ev));
expect(ev.defaultPrevented).toBe(false);
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
});
});
describe("DownloadCenter · the shared row", () => {
it("holds play, save, edit, the copies and Remove from my list", async () => {
mocks.jobs = [];
vi.spyOn(api, "downloadsShared").mockResolvedValue([
job(4, "done", { can_download: true }),
] as never);
const remove = vi.spyOn(api, "removeSharedDownload").mockResolvedValue({} as never);
mount("shared");
await screen.findByText("Clip 4");
await openMenu(() => fireEvent.click(moreOf("Clip 4")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.download",
"downloads.actions.edit",
"common.copy.title",
"downloads.actions.removeShared",
]);
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.removeShared")!,
);
await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
});
});
describe("menuGroups", () => {
const row = (id: string) => ({ id, label: id, onSelect: () => {} });
it("rules between non-empty groups only", () => {
+11 -141
View File
@@ -1,47 +1,29 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import {
ClipboardPaste,
Download,
Pause,
Play,
PlayCircle,
Plus,
RefreshCw,
Settings2,
Share2,
UploadCloud,
} from "lucide-react";
import { ClipboardPaste, Pause, Play, Plus, RefreshCw, Settings2, UploadCloud } from "lucide-react";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { PageToolbar } from "./ui/PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState";
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
// used (the video editor in particular is heavy — filmstrip + scrubber).
// Lazy: these dialogs open on demand, so they stay out of the Downloads page chunk until used.
const DownloadDialog = lazy(() => import("./DownloadDialog"));
const UploadDialog = lazy(() => import("./UploadDialog"));
const ProfileEditor = lazy(() => import("./ProfileEditor"));
const VideoEditor = lazy(() => import("./VideoEditor"));
const ShareDialog = lazy(() => import("./ShareDialog"));
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
import { MetaEditModal, ErrorModal } from "./downloadModals";
import { DownloadRow, IconBtn } from "./downloadRow";
import { ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin";
import NavGrid from "./ui/NavGrid";
import { Input } from "./ui/Input";
import { IconLink } from "./ui/LinkButton";
import { useContextMenu } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { useDownloadActions, type DownloadTab } from "./useDownloadActions";
import { useDownloadActions } from "./useDownloadActions";
const jobKey = (job: DownloadJob) => job.id;
@@ -55,6 +37,9 @@ async function clipboardText(): Promise<string | null> {
}
}
// The download queue: what is downloading, waiting or failed, the link box and the upload, the
// download profiles, and (admin) the instance's downloads. A finished download is a library file and
// lives in Files.
export default function DownloadCenter() {
const me = useMe();
const { t } = useTranslation();
@@ -66,12 +51,6 @@ export default function DownloadCenter() {
const [addSource, setAddSource] = useState<string | null>(null);
const [upload, setUpload] = useState(false);
const [manage, setManage] = useState(false);
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
// What the player opens, and whether over the kept point ("Play from the beginning").
const [playing, setPlaying] = useState<{ job: DownloadJob; fromStart: boolean } | null>(null);
const setPlayJob = (job: DownloadJob, fromStart = false) => setPlaying({ job, fromStart });
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
// Poll fast (2s) while work is actually in flight; back off to 15s once the queue is settled
@@ -81,29 +60,15 @@ export default function DownloadCenter() {
intervalMs: (data) =>
data?.some((j) => j.status === "queued" || j.status === "running") ? 2000 : 15000,
});
const sharedQ = useQuery({
queryKey: qk.downloadsShared(),
queryFn: api.downloadsShared,
enabled: tab === "shared",
});
const jobs = jobsQ.data ?? [];
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
const library = jobs.filter((j) => j.status === "done");
// --- The rows' ONE menu -------------------------------------------------------------
// Keyed by the job's id: the queue polls every 2 s while a job runs, replacing the object under an
// open menu, and its "…" must still close it. The tab is the one on screen.
// open menu, and its "…" must still close it.
const rowMenu = useContextMenu<DownloadJob>({ keyOf: jobKey });
const actions = useDownloadActions({
onPlay: setPlayJob,
onEdit: setEditJob,
onEditDetails: setMetaJob,
onManageShares: setShareJob,
});
const menuTarget = rowMenu.target;
const listTab: DownloadTab | null =
tab === "queue" || tab === "done" || tab === "shared" ? tab : null;
const menuItems = menuTarget && listTab ? actions.items(menuTarget, listTab) : [];
const actions = useDownloadActions();
const menuItems = rowMenu.target && tab === "queue" ? actions.items(rowMenu.target) : [];
// The row's menu triggers and its "…".
const rowMenuProps = (job: DownloadJob) => {
const mine = rowMenu.open && rowMenu.target?.id === job.id;
@@ -133,8 +98,6 @@ export default function DownloadCenter() {
const tabs = [
{ id: "queue", label: t("downloads.tabs.queue"), badge: queue.length },
{ id: "done", label: t("downloads.tabs.done"), badge: library.length },
{ id: "shared", label: t("downloads.tabs.shared") },
...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []),
];
// Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session)
@@ -144,12 +107,6 @@ export default function DownloadCenter() {
// account), so a render-phase write is allowed, and it converges — "queue" is always in the list.
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
const saveBtn = (job: DownloadJob) => (
<IconLink href={api.downloadFileUrl(job.id)} label={t("downloads.actions.download")} size="md">
<Download />
</IconLink>
);
return (
<>
{/* The tabs are the page's band (B4); the tab content scrolls under them. No visible title: the
@@ -268,83 +225,6 @@ export default function DownloadCenter() {
</>
)}
{tab === "done" && (
<>
<UsageBar />
<NavGrid className="space-y-2">
{library.map((job, i) => (
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
{libraryRowActions(job).play && (
<IconBtn
open
onClick={() => setPlayJob(job)}
title={
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{/* Sharing the link is the library's main errand: in sight, ahead of save. */}
<IconBtn onClick={() => actions.share(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
{saveBtn(job)}
</DownloadRow>
))}
{library.length === 0 &&
(jobsQ.isError && !jobsQ.data ? (
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : jobsQ.isLoading ? (
<LoadingState shape="list" />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.done")}
</div>
))}
</NavGrid>
</>
)}
{tab === "shared" && (
<NavGrid className="space-y-2">
{(sharedQ.data ?? []).map((job, i) => {
// Same media_kind + can_download gate as the library, via the shared helper.
const a = sharedRowActions(job);
return (
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
{a.play && (
<IconBtn
open
onClick={() => setPlayJob(job)}
title={
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{a.save && saveBtn(job)}
</DownloadRow>
);
})}
{(sharedQ.data ?? []).length === 0 &&
(sharedQ.isError && !sharedQ.data ? (
<StateMessage tone="error" onRetry={() => sharedQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : sharedQ.isLoading ? (
<LoadingState shape="list" />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.shared")}
</div>
))}
</NavGrid>
)}
{tab === "system" && me.role === "admin" && <AdminSystem />}
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
@@ -361,16 +241,6 @@ export default function DownloadCenter() {
)}
{upload && <UploadDialog onClose={() => setUpload(false)} />}
{manage && <ProfileEditor onClose={() => setManage(false)} />}
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{playing && (
<LibraryPlayer
job={playing.job}
fromStart={playing.fromStart}
onClose={() => setPlaying(null)}
/>
)}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
{errorJob && (
<ErrorModal
job={errorJob}
@@ -0,0 +1,397 @@
// @vitest-environment jsdom
//
// The Files page: a row keeps Copy link, Share, Save and its "…" in sight — a slot an action does not
// apply to stays empty, so the icons line up down the list — and its every action is its ONE menu,
// opened by the "…" or a right-click. Renaming happens in the row; moving goes to an own folder or
// back home; a shared file is the owner's, so its menu only plays, saves, cuts or drops it from my
// list. i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type FileFolder, type LibraryFile } from "../lib/api";
import { ALL_FILES, FilesActionsContext, FilesStateContext, type FilesView } from "./filesContext";
const mocks = vi.hoisted(() => ({
files: [] as unknown[],
folders: [] as unknown[],
confirm: vi.fn(async (_o?: unknown) => true),
keys: [] as unknown[],
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
o && "folder" in o ? `${key}:${String(o.folder)}` : key,
}),
};
});
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: (key: readonly unknown[]) => {
mocks.keys.push(key);
return {
data:
key[0] === "files" ? mocks.files : key[0] === "file-folders" ? mocks.folders : undefined,
isError: false,
isLoading: false,
refetch: vi.fn(),
};
},
}));
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import Files from "./Files";
const file = (id: number, extra: Partial<LibraryFile> = {}): LibraryFile =>
({
id,
status: "done",
progress: 100,
created_at: "2026-10-10T10:00:00+00:00",
source_kind: "youtube",
source_ref: `v${id}`,
source_url: null,
poster_url: null,
display_name: `Clip ${id}`,
display_uploader: null,
extra_links: [],
can_download: true,
job_kind: "download",
folder_id: 1,
kept: false,
expires_at: null,
shared: false,
source_key: "youtube",
asset: { media_kind: "video", duration_s: 60, size_bytes: 2048 },
...extra,
}) as unknown as LibraryFile;
const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
id,
kind: "source",
source_key: null,
name: null,
retention_days: 30,
count: 1,
size_bytes: 0,
created_at: null,
...extra,
});
const YT = folder(1, { source_key: "youtube" });
const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null });
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mocks.confirm.mockClear();
mocks.keys = [];
});
function mount(view: FilesView = ALL_FILES, setView = vi.fn(), search = "") {
render(
<QueryClientProvider client={new QueryClient()}>
<FilesActionsContext.Provider value={{ setSearch: vi.fn(), setView }}>
<FilesStateContext.Provider value={{ search, view }}>
<Files />
</FilesStateContext.Provider>
</FilesActionsContext.Provider>
</QueryClientProvider>,
);
return setView;
}
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="files.rowMenu"]')!;
// A folder in the Move submenu is a choice of one (menuitemradio), the rest plain rows.
const menuEls = () => [
...screen.getAllByRole("menuitem", { hidden: true }),
...screen.queryAllByRole("menuitemradio", { hidden: true }),
];
const menuRows = () => menuEls().map((b) => b.textContent ?? "");
const menuItem = (label: string) => menuEls().find((b) => b.textContent === label)!;
async function openMenu(open: () => void) {
act(open);
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
const rowButtons = (text: string) =>
[...rowOf(text).querySelectorAll<HTMLElement>("button[aria-label], a[aria-label]")].map((b) =>
b.getAttribute("aria-label"),
);
describe("Files · my file's row", () => {
it("shows Copy link, Share, Save and the '…'; the menu holds every action", async () => {
mocks.files = [file(2, { source_url: "https://youtu.be/x" })];
mocks.folders = [YT, OWN];
mount();
expect(rowButtons("Clip 2")).toEqual([
"files.actions.copyLink",
"files.actions.share",
"downloads.actions.download",
"files.rowMenu",
]);
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"files.actions.copyLink",
"files.actions.share",
"downloads.actions.download",
"downloads.actions.edit",
"files.actions.rename",
"files.actions.move",
"files.actions.keep",
"files.actions.details",
"downloads.actions.redownload",
"common.copy.title",
"files.actions.copySourceLink",
"downloads.actions.delete",
]);
});
it("keeps the slot of an action a plain file lacks, so the icons line up", () => {
mocks.files = [file(3, { job_kind: "upload", asset: { media_kind: "file" } } as never)];
mocks.folders = [YT];
mount();
expect(rowButtons("Clip 3")).toEqual([
"files.actions.share",
"downloads.actions.download",
"files.rowMenu",
]);
// The empty slot holds the Copy link's place, the same width as an icon.
const actions = rowOf("Clip 3").querySelector(
'button[aria-label="files.actions.share"]',
)!.parentElement!;
expect(actions.firstElementChild!.getAttribute("aria-hidden")).toBe("true");
});
it("copies the link from the row's own icon", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
const create = vi
.spyOn(api, "createDownloadLink")
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
mount();
fireEvent.click(
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="files.actions.copyLink"]')!,
);
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
});
it("saves through the file's own address", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
const followed: string[] = [];
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement,
) {
followed.push(new URL(this.href).pathname);
});
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.download"));
expect(followed).toEqual(["/api/files/2/file"]);
const icon = rowOf("Clip 2").querySelector<HTMLAnchorElement>(
'a[aria-label="downloads.actions.download"]',
)!;
expect(new URL(icon.href).pathname).toBe("/api/files/2/file");
});
it("renames in the row: Enter saves, an emptied name gives the title back", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.rename"));
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
fireEvent.change(field, { target: { value: " Új név " } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Új név" }));
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.rename"));
const again = await screen.findByRole("textbox", { name: "files.actions.rename" });
fireEvent.change(again, { target: { value: "" } });
fireEvent.keyDown(again, { key: "Enter" });
await waitFor(() => expect(update).toHaveBeenLastCalledWith(2, { name: null }));
});
it("Escape leaves the name as it was", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.rename"));
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
fireEvent.change(field, { target: { value: "Más" } });
fireEvent.keyDown(field, { key: "Escape" });
expect(screen.queryByRole("textbox")).toBeNull();
expect(update).not.toHaveBeenCalled();
});
it("moves to an own folder, and from there back to its source folder only", async () => {
mocks.files = [file(2, { folder_id: 2 })];
// Another source folder first, so "its own" is chosen by its key, not by its place in the list.
mocks.folders = [folder(3, { source_key: "site:tiktok.com" }), YT, OWN];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" });
await waitFor(() => expect(menuRows()).toContain("Cimboráknak"));
// The folder it is in, ticked; home is its own source folder, never another site's.
expect(menuRows()).toContain("files.actions.moveHome:files.source.youtube");
expect(menuRows().some((r) => r.includes("tiktok"))).toBe(false);
expect(menuItem("Cimboráknak").getAttribute("aria-checked")).toBe("true");
fireEvent.click(menuItem("files.actions.moveHome:files.source.youtube"));
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 1 }));
});
it("offers no move home while it is home, and says so when there is nowhere to go", async () => {
mocks.files = [file(2, { folder_id: 1 })];
mocks.folders = [YT];
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" });
await waitFor(() => expect(menuRows()).toContain("files.actions.noOwnFolders"));
expect(menuItem("files.actions.noOwnFolders").hasAttribute("disabled")).toBe(true);
});
it("keeps a file, and a kept one is handed back to its folder", async () => {
mocks.files = [file(2), file(3, { kept: true })];
mocks.folders = [YT];
const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never);
mount();
expect(rowOf("Clip 3").textContent).toContain("files.retention.kept");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("files.actions.keep"));
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { kept: true }));
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 3")));
fireEvent.click(menuItem("files.actions.unkeep"));
await waitFor(() => expect(update).toHaveBeenLastCalledWith(3, { kept: false }));
});
it("deletes only through the confirm", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
const del = vi.spyOn(api, "deleteFile").mockResolvedValue({} as never);
mocks.confirm.mockResolvedValueOnce(false);
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
fireEvent.click(menuItem("downloads.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"));
await waitFor(() => expect(del).toHaveBeenCalledWith(2));
});
});
describe("Files · the confirms", () => {
it("label the confirm button with the action, not the dialog's question", async () => {
mocks.files = [file(2)];
mocks.folders = [YT];
mocks.confirm.mockResolvedValue(false);
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.redownload"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(menuItem("downloads.actions.delete"));
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
expect(
mocks.confirm.mock.calls.map(([o]) => {
const opts = o as { title: string; confirmLabel?: string };
return [opts.title, opts.confirmLabel];
}),
).toEqual([
["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"],
["downloads.confirm.deleteTitle", "downloads.actions.delete"],
]);
mocks.confirm.mockResolvedValue(true);
});
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.
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"));
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]);
});
});
describe("Files · a shared file's row", () => {
it("plays, saves, cuts, copies and drops it from my list — nothing of the owner's", async () => {
mocks.files = [file(4, { shared: true, shared_at: "2026-10-11T08:00:00+00:00" })];
mocks.folders = [YT];
const remove = vi.spyOn(api, "removeSharedFile").mockResolvedValue({} as never);
mount({ kind: "shared" });
expect(rowButtons("Clip 4")).toEqual(["downloads.actions.download", "files.rowMenu"]);
await openMenu(() => fireEvent.click(moreOf("Clip 4")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.download",
"downloads.actions.edit",
"common.copy.title",
"downloads.actions.removeShared",
]);
fireEvent.click(menuItem("downloads.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 }]);
expect(screen.queryByText("files.actions.upload")).toBeNull();
});
});
describe("Files · the view", () => {
it("reads one folder's files and names the folder", () => {
mocks.files = [file(2, { folder_id: 2 })];
mocks.folders = [YT, OWN];
mount({ kind: "folder", id: 2 });
expect(mocks.keys).toContainEqual(["files", { folderId: 2 }]);
expect(screen.getByRole("heading", { name: "Cimboráknak" })).toBeTruthy();
});
it("opens the whole library when the folder it was given is gone", async () => {
mocks.files = [];
mocks.folders = [YT];
const setView = mount({ kind: "folder", id: 99 });
expect(mocks.keys).toContainEqual(["files", {}]);
expect(mocks.keys).not.toContainEqual(["files", { folderId: 99 }]);
await waitFor(() => expect(setView).toHaveBeenCalledWith(ALL_FILES));
});
it("searches the rows it holds by name and channel", () => {
mocks.files = [
file(2, { display_name: "Szméagol szompolyog" }),
file(3, { display_name: "Más", display_uploader: "Szméagol rajongó" }),
file(4, { display_name: "Harmadik" }),
];
mocks.folders = [YT];
mount(ALL_FILES, vi.fn(), "szméagol");
expect(screen.queryByText("Szméagol szompolyog")).not.toBeNull();
expect(screen.queryByText("Más")).not.toBeNull();
expect(screen.queryByText("Harmadik")).toBeNull();
});
});
+478
View File
@@ -0,0 +1,478 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Clock,
Download,
Link,
Pin,
Scissors,
Share2,
Smartphone,
UploadCloud,
} from "lucide-react";
import { api, type FileFolder, type FileListParams, type LibraryFile } from "../lib/api";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { formatBytes, formatDateTime, relativeTime } from "../lib/format";
import { qk } from "../lib/queryKeys";
import { LS } from "../lib/storage";
import { useLiveQuery } from "../lib/useLiveQuery";
import { JobThumb } from "./downloadRow";
import { MetaEditModal } from "./downloadModals";
import { jobTitle } from "./downloadTitle";
import { folderLabel, sourceIcon, sourceLabel } from "./fileFolders";
import { ALL_FILES, useFiles, useFilesActions, type FilesView } from "./filesContext";
import { useFileActions } from "./useFileActions";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
import { ContextMenu } from "./ui/ContextMenu";
import DataTable, { type Column } from "./ui/DataTable";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { IconLink } from "./ui/LinkButton";
import { MoreButton } from "./ui/MoreButton";
import { PageToolbar } from "./ui/PageShell";
import { Pressable } from "./ui/Pressable";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useContextMenu } from "./ui/useContextMenu";
// Lazy: these open on demand, so they stay out of the Files chunk until used (the video editor in
// particular is heavy — filmstrip + scrubber).
const UploadDialog = lazy(() => import("./UploadDialog"));
const ShareDialog = lazy(() => import("./ShareDialog"));
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
const VideoEditor = lazy(() => import("./VideoEditor"));
// The Files library: every done file of mine, the ones shared with me, or one folder's (the rail
// picks — FilesProvider holds it), searched by the header's box. One aligned table: every kind of
// value in its own column, an action that does not apply leaving its slot empty, the same menu on a
// right click and on the "…" (DESIGN.md "Files page", "Library list columns").
const DAY_MS = 24 * 60 * 60 * 1000;
// A file this close to leaving says so in the warning colour.
const SOON_DAYS = 7;
const fileKey = (f: LibraryFile) => f.id;
/** 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. */
function listParams(view: FilesView): FileListParams {
if (view.kind === "shared") return { shared: true };
if (view.kind === "folder") return { folderId: view.id };
return {};
}
function matches(file: LibraryFile, q: string, lang: string): boolean {
if (!q) return true;
const hay = `${jobTitle(file)} ${file.display_uploader ?? file.asset?.uploader ?? ""}`;
return hay.toLocaleLowerCase(lang).includes(q.toLocaleLowerCase(lang));
}
/** Where an upload from this view lands: the folder itself when it may take one (an own folder, the
* Uploads folder — `folders.upload_target`), else the Uploads folder. */
function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number | null {
if (view.kind !== "folder") return null;
const f = folders.find((x) => x.id === view.id);
return f && (f.kind === "own" || f.source_key === "upload") ? f.id : null;
}
function daysLeft(iso: string): number {
return Math.max(0, Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS));
}
function RetentionCell({ file }: { file: LibraryFile }) {
const { t, i18n } = useTranslation();
if (file.kept)
return (
<Badge>
<Pin />
{t("files.retention.kept")}
</Badge>
);
if (!file.expires_at)
return (
<span className="text-xs text-muted" title={t("files.retention.never")}>
—
</span>
);
const days = daysLeft(file.expires_at);
const when = formatDateTime(file.expires_at, i18n.language);
return days <= SOON_DAYS ? (
<Badge tone="warn" title={t("files.retention.until", { when })}>
<Clock />
{t("files.retention.days", { count: days })}
</Badge>
) : (
<span className="text-xs tabular-nums text-muted" title={t("files.retention.until", { when })}>
{t("files.retention.days", { count: days })}
</span>
);
}
function NameCell({
file,
renaming,
onPlay,
onRenamed,
}: {
file: LibraryFile;
renaming: boolean;
onPlay: (() => void) | null;
onRenamed: (name: string | null) => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState(() => jobTitle(file));
const channel = file.display_uploader || file.asset?.uploader || null;
const clip = file.job_kind === "edit" || file.job_kind === "clip";
if (renaming)
return (
<Input
size="sm"
className="w-full"
autoFocus
value={draft}
aria-label={t("files.actions.rename")}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => onRenamed(draft)}
onKeyDown={(e) => {
// preventDefault: a real Enter's own activation would otherwise press what focus lands on.
if (e.key === "Enter") {
e.preventDefault();
onRenamed(draft);
}
// Unmounting the input fires no blur in React, so Escape really does cancel.
if (e.key === "Escape") {
e.preventDefault();
onRenamed(null);
}
}}
/>
);
const title = <span className="block truncate text-sm font-medium">{jobTitle(file)}</span>;
return (
<div className="flex min-w-0 flex-col">
{onPlay ? (
<Pressable
selectable
data-nav-open=""
onClick={onPlay}
title={jobTitle(file)}
className="min-w-0 rounded-sm hover:text-accent"
>
{title}
</Pressable>
) : (
<span title={jobTitle(file)}>{title}</span>
)}
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
<span className="truncate">{channel ?? "—"}</span>
{clip && (
<Badge>
<Scissors />
{t("downloads.clipBadge")}
</Badge>
)}
</span>
</div>
);
}
export default function Files() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { search, view } = useFiles();
const { setView } = useFilesActions();
const foldersQ = useLiveQuery(qk.fileFolders(), api.fileFolders, { intervalMs: 15000 });
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);
const shown = gone ? ALL_FILES : view;
useEffect(() => {
if (gone) setView(ALL_FILES);
}, [gone, setView]);
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));
const [renamingId, setRenamingId] = useState<number | null>(null);
const [upload, setUpload] = useState(false);
const [metaFile, setMetaFile] = useState<LibraryFile | null>(null);
const [shareFile, setShareFile] = useState<LibraryFile | null>(null);
const [editFile, setEditFile] = useState<LibraryFile | null>(null);
const [playing, setPlaying] = useState<{ file: LibraryFile; fromStart: boolean } | null>(null);
const play = (file: LibraryFile, fromStart = false) => setPlaying({ file, fromStart });
const actions = useFileActions({
folders,
onPlay: play,
onEdit: setEditFile,
onRename: (f) => setRenamingId(f.id),
onEditDetails: setMetaFile,
onManageShares: setShareFile,
});
// Keyed by id: the list re-reads every 15 s, replacing the file under an open menu, and its "…"
// must still close it.
const rowMenu = useContextMenu<LibraryFile>({ keyOf: fileKey });
const menuItems = rowMenu.target ? actions.items(rowMenu.target) : [];
const open = shown.kind === "folder" ? folders.find((f) => f.id === shown.id) : undefined;
// A folder view names its folder once the folder list is in (blank until then, never a guess).
const title =
shown.kind === "shared"
? t("files.rail.shared")
: shown.kind === "folder"
? open
? folderLabel(open, t)
: ""
: t("files.rail.all");
const canUpload = shown.kind !== "shared";
const iconSlot = <span className="size-9 shrink-0" aria-hidden />;
const columns: Column<LibraryFile>[] = [
{
key: "thumb",
header: "",
width: "5.5rem",
hideInCard: true,
// A narrow table (a pinned rail beside it) gives its fixed columns' room to the name: with the
// picture and the date in, the name had none left.
narrowHidden: true,
render: (f) => (
<JobThumb
job={f}
box="w-20 aspect-video shrink-0 rounded-md overflow-hidden bg-surface border border-border"
/>
),
},
{
key: "name",
header: t("files.columns.name"),
cardPrimary: true,
sortable: true,
sortValue: (f) => jobTitle(f),
render: (f) => {
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
return (
<NameCell
key={renamingId === f.id ? "renaming" : "shown"}
file={f}
renaming={renamingId === f.id}
onPlay={a.play ? () => play(f) : null}
onRenamed={(name) => {
setRenamingId(null);
if (name !== null) actions.rename(f, name);
}}
/>
);
},
},
{
key: "source",
header: t("files.columns.source"),
width: "8rem",
tabletHidden: true,
render: (f) => {
const Icon = sourceIcon(f.source_key);
return (
<Badge>
<Icon />
{sourceLabel(f.source_key, t)}
</Badge>
);
},
},
{
key: "phone",
header: t("files.columns.phone"),
width: "5.5rem",
narrowHidden: true,
// Every library video and sound is phone-safe by construction (backend downloads/playable.py);
// a plain file is not a thing a phone plays.
render: (f) =>
f.asset?.media_kind === "file" ? (
<span className="text-xs text-muted">—</span>
) : (
<Badge title={t("files.phoneOk")}>
<Smartphone />
<span className="text-success">✓</span>
</Badge>
),
},
{
key: "retention",
header: t("files.columns.retention"),
width: "7.5rem",
narrowHidden: true,
render: (f) => <RetentionCell file={f} />,
},
{
key: "size",
header: t("files.columns.size"),
width: "5.5rem",
align: "right",
nowrap: true,
sortable: true,
sortValue: (f) => f.asset?.size_bytes ?? 0,
render: (f) => (
<span className="text-xs tabular-nums text-muted">
{formatBytes(f.asset?.size_bytes, lang)}
</span>
),
},
{
key: "added",
header: t("files.columns.added"),
width: "6.5rem",
narrowHidden: true,
align: "right",
nowrap: true,
sortable: true,
sortValue: (f) => f.shared_at ?? f.created_at ?? "",
render: (f) => {
const at = f.shared_at ?? f.created_at;
return (
<span className="text-xs text-muted" title={formatDateTime(at, lang)}>
{relativeTime(at, lang, t)}
</span>
);
},
},
{
key: "actions",
header: "",
width: "10rem",
align: "right",
cardLabel: false,
render: (f) => {
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
const mine = rowMenu.open && rowMenu.target?.id === f.id;
return (
<span className="flex items-center justify-end gap-0.5">
{!f.shared && a.play ? (
<IconButton
variant="ghost"
size="md"
label={t("files.actions.copyLink")}
disabled={actions.sharing !== null}
onClick={() => actions.share(f)}
>
<Link />
</IconButton>
) : (
iconSlot
)}
{!f.shared ? (
<IconButton
variant="ghost"
size="md"
label={t("files.actions.share")}
onClick={() => setShareFile(f)}
>
<Share2 />
</IconButton>
) : (
iconSlot
)}
{f.can_download ? (
<IconLink href={api.fileUrl(f.id)} label={t("downloads.actions.download")} size="md">
<Download />
</IconLink>
) : (
iconSlot
)}
<MoreButton
label={t("files.rowMenu")}
size="md"
expanded={mine}
menuProps={rowMenu.buttonProps(f)}
/>
</span>
);
},
},
];
const body =
filesQ.isError && !filesQ.data ? (
<StateMessage tone="error" onRetry={() => filesQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : filesQ.isLoading ? (
<LoadingState shape="list" />
) : (
<DataTable
rows={rows}
columns={columns}
rowKey={(f) => String(f.id)}
persistKey={LS.filesTable}
pageSize={50}
keyboardNav
fixedLayout
rowProps={(f) => rowMenu.triggerProps(f)}
emptyText={
q
? t("files.empty.search", { q })
: t(
shown.kind === "shared"
? "files.empty.shared"
: shown.kind === "folder"
? "files.empty.folder"
: "files.empty.all",
)
}
/>
);
return (
<>
<PageToolbar>
<div className="mx-auto flex w-full max-w-7xl flex-wrap items-center gap-x-3 gap-y-2 px-4 pt-2 pb-2">
<h2 className="min-w-0 truncate text-lg font-bold">{title}</h2>
{filesQ.data && (
<span className="text-xs tabular-nums text-muted">
{t("files.count", { count: rows.length })}
</span>
)}
{canUpload && (
<Button
variant="outline"
size="sm"
icon={<UploadCloud className="w-4 h-4" />}
onClick={() => setUpload(true)}
className="ml-auto shrink-0"
>
{t("files.actions.upload")}
</Button>
)}
</div>
</PageToolbar>
<div className="mx-auto w-full max-w-7xl px-4 pt-3 pb-4">
{body}
<ContextMenu menu={rowMenu} ariaLabel={t("files.rowMenu")} items={menuItems} />
<Suspense fallback={null}>
{upload && (
<UploadDialog
folderId={uploadFolder(shown, folders)}
onClose={() => setUpload(false)}
/>
)}
{metaFile && <MetaEditModal job={metaFile} onClose={() => setMetaFile(null)} />}
{shareFile && <ShareDialog job={shareFile} onClose={() => setShareFile(null)} />}
{playing && (
<LibraryPlayer
job={playing.file}
fromStart={playing.fromStart}
onClose={() => setPlaying(null)}
/>
)}
{editFile && <VideoEditor job={editFile} onClose={() => setEditFile(null)} />}
</Suspense>
</div>
</>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { LS, getAccountRaw, setAccountRaw } from "../lib/storage";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./navigationContext";
import {
ALL_FILES,
FilesActionsContext,
FilesStateContext,
parseView,
viewParam,
type FilesActions,
type FilesState,
type FilesView,
} from "./filesContext";
// Owns the Files module's shared state — the header search term and the view the rail picked (every
// file, shared with me, or a folder) — so the header's search box, the FilesRail and the Files page
// read it via hooks. Shaped like PlaylistsProvider: split State + Actions contexts, composed in
// main.tsx around <App/>.
//
// The search is ephemeral (resets on reload, like the other list filters); the view is remembered per
// account and mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder.
// A `?folder=` in the address wins over the remembered one. A folder that no longer exists is the
// page's to fall back from (it knows the folder list).
function initialView(): FilesView {
return (
parseView(new URLSearchParams(window.location.search).get("folder")) ??
parseView(getAccountRaw(LS.filesView)) ??
ALL_FILES
);
}
export function FilesProvider({ children }: { children: ReactNode }) {
const [search, setSearchState] = useState("");
const [view, setViewState] = useState<FilesView>(initialView);
const setSearch = useCallback((q: string) => setSearchState(q), []);
const setView = useCallback((next: FilesView) => {
setViewState(next);
setAccountRaw(LS.filesView, viewParam(next));
}, []);
const { page } = useNavigation();
useUrlMirror({ folder: page === "files" ? viewParam(view) : null });
const state = useMemo<FilesState>(() => ({ search, view }), [search, view]);
const actions = useMemo<FilesActions>(() => ({ setSearch, setView }), [setSearch, setView]);
return (
<FilesActionsContext.Provider value={actions}>
<FilesStateContext.Provider value={state}>{children}</FilesStateContext.Provider>
</FilesActionsContext.Provider>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { FolderOpen, HardDrive, Users } from "lucide-react";
import { api, type FileFolder } from "../lib/api";
import { compareText } from "../lib/collate";
import { formatBytes } from "../lib/format";
import { type PanelLayout } from "../lib/panelLayout";
import { qk } from "../lib/queryKeys";
import { useLiveQuery } from "../lib/useLiveQuery";
import FilterRail from "./FilterRail";
import { folderIcon, folderLabel } from "./fileFolders";
import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext";
import { Pressable } from "./ui/Pressable";
import { type PanelItem } from "./ui/PanelGroups";
// The Files module's left rail: the library's places — every file, the ones shared with me, the
// source folders the library fills by itself, the user's own folders — and the storage meter. Lifted
// to App level like the other rails (the SidePanel's collapse and customise come with it); the view it
// picks lives in FilesProvider, which the Files page reads.
function Entry({
icon: Icon,
label,
count,
view,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
count?: number;
view: FilesView;
}) {
const { view: current } = useFiles();
const { setView } = useFilesActions();
const active = sameView(view, current);
return (
<Pressable
selectable
layer={!active}
aria-current={active ? "page" : undefined}
onClick={() => setView(view)}
className={clsx(
"flex w-full min-h-8 items-center gap-2 rounded-lg px-2 text-sm",
active ? "bg-accent/12 font-medium text-accent" : "text-fg",
)}
>
<Icon className={clsx("size-4 shrink-0", !active && "text-muted")} />
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
{count !== undefined && <span className="text-xs tabular-nums text-muted">{count}</span>}
</Pressable>
);
}
function StorageMeter() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Live while something is in flight (a download's bytes land when the worker finishes it), at rest
// otherwise — the old Download Center's meter, moved to where the files are.
const usage = useLiveQuery(qk.storageUsage(), api.filesUsage, {
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);
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">
<span>{t("files.usage.title")}</span>
<span className="tabular-nums">
{formatBytes(u.footprint_bytes, lang)}
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`}
</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>
)}
</div>
);
}
export default function FilesRail({
layout,
setLayout,
collapsed,
onToggleCollapse,
}: {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const { t, i18n } = useTranslation();
const { view } = useFiles();
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
const folders = foldersQ.data ?? [];
const total = folders.reduce((n, f) => n + f.count, 0);
// A source folder its files have all left stays on the server; here it shows only while it holds
// something, or while it is the one open (so the page never points at a folder the rail hides).
const shown = (f: FileFolder) => f.count > 0 || sameView(view, { kind: "folder", id: f.id });
const byName = (a: FileFolder, b: FileFolder) =>
compareText(folderLabel(a, t), folderLabel(b, t), i18n.language);
const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName);
const own = folders.filter((f) => f.kind === "own").sort(byName);
const failed = foldersQ.isError && !foldersQ.data && (
<Pressable
onClick={() => foldersQ.refetch()}
className="px-2 py-1 text-xs text-danger hover:underline"
>
{t("common.loadError")} — {t("common.retry")}
</Pressable>
);
const entry = (f: FileFolder) => (
<Entry
key={f.id}
icon={folderIcon(f)}
label={folderLabel(f, t)}
count={f.count}
view={{ kind: "folder", id: f.id }}
/>
);
const items: PanelItem[] = [
{
id: "library",
title: t("files.rail.library"),
body: (
<div className="flex flex-col gap-0.5">
<Entry
icon={HardDrive}
label={t("files.rail.all")}
count={foldersQ.data ? total : undefined}
view={{ kind: "all" }}
/>
<Entry icon={Users} label={t("files.rail.shared")} view={{ kind: "shared" }} />
</div>
),
},
{
id: "sources",
title: t("files.rail.sources"),
body: (
<div className="flex flex-col gap-0.5">
{failed}
{sources.map(entry)}
{foldersQ.data && sources.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noSources")}</span>
)}
</div>
),
},
{
id: "own",
title: t("files.rail.own"),
body: (
<div className="flex flex-col gap-0.5">
{failed}
{own.map(entry)}
{foldersQ.data && own.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noOwn")}</span>
)}
</div>
),
},
];
return (
<FilterRail
kind="files"
title={t("files.navLabel")}
icon={<FolderOpen className="w-4 h-4" />}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
layout={layout}
setLayout={setLayout}
items={items}
>
<StorageMeter />
</FilterRail>
);
}
+12
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { YouTubeIcon } from "../lib/brandIcons";
import type { Me } from "../lib/api";
import SearchBar from "./SearchBar";
import { useFiles, useFilesActions } from "./filesContext";
import PlexSearchBox from "./PlexSearchBox";
import PlexScopeToggle from "./PlexScopeToggle";
import PlexViewToggle from "./PlexViewToggle";
@@ -37,6 +38,8 @@ const Header = forwardRef<
const { search: playlistsSearch } = usePlaylists();
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
const { search: channelsSearch } = useChannels();
const { search: filesSearch } = useFiles();
const { setSearch: setFilesSearch } = useFilesActions();
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
@@ -77,6 +80,15 @@ const Header = forwardRef<
onGo={blur}
/>
);
} else if (page === "files") {
search = (
<SearchBar
value={filesSearch}
placeholder={t("files.searchPlaceholder")}
onChange={setFilesSearch}
onGo={blur}
/>
);
} else if (page === "playlists") {
search = (
<SearchBar
@@ -53,7 +53,6 @@ const baseJob: DownloadJob = {
display_uploader_url: null,
extra_links: [],
can_download: true,
expired: false,
folder_id: null,
kept: false,
expires_at: null,
@@ -112,7 +111,7 @@ describe("LibraryPlayer", () => {
);
const video = document.body.querySelector("video");
expect(video).not.toBeNull();
expect(video!.getAttribute("src")).toBe("/api/downloads/7/file");
expect(video!.getAttribute("src")).toBe("/api/files/7/file");
// No <audio> element for a video asset.
expect(document.body.querySelector("audio")).toBeNull();
});
@@ -131,7 +130,7 @@ describe("LibraryPlayer", () => {
);
const audio = document.body.querySelector("audio");
expect(audio).not.toBeNull();
expect(audio!.getAttribute("src")).toBe("/api/downloads/7/file");
expect(audio!.getAttribute("src")).toBe("/api/files/7/file");
expect(document.body.querySelector("video")).toBeNull();
});
@@ -179,7 +178,7 @@ describe("LibraryPlayer", () => {
expect(loaded().currentTime).toBe(0);
});
it("keeps where it stopped: on the server, and on the library row at once", () => {
it("keeps where it stopped: on the server, and on its row in every library list at once", () => {
const save = vi.spyOn(api, "saveDownloadProgress").mockResolvedValue({
ok: true,
job_id: 7,
@@ -188,7 +187,9 @@ describe("LibraryPlayer", () => {
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
const client = new QueryClient();
const job = withAsset({ width: 1280, height: 720 });
client.setQueryData(qk.downloads(), [job, { ...job, id: 8 }]);
// The file sits in two lists at once: its folder's and the whole library's.
client.setQueryData(qk.files({ folderId: 1 }), [job, { ...job, id: 8 }]);
client.setQueryData(qk.files({}), [job]);
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
const video = loaded();
video.dispatchEvent(new Event("playing"));
@@ -196,8 +197,9 @@ describe("LibraryPlayer", () => {
video.dispatchEvent(new Event("pause"));
expect(save).toHaveBeenCalledWith(7, 125.4, 600);
const rows = client.getQueryData<DownloadJob[]>(qk.downloads())!;
const rows = client.getQueryData<DownloadJob[]>(qk.files({ folderId: 1 }))!;
expect(rows.map((j) => j.position_seconds)).toEqual([125, undefined]);
expect(client.getQueryData<DownloadJob[]>(qk.files({}))![0]!.position_seconds).toBe(125);
// Closing the player sends the point the way that survives the page going away.
unmount();
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600, expect.any(Function));
@@ -213,10 +215,10 @@ describe("LibraryPlayer", () => {
});
const client = new QueryClient();
const job = withAsset({ width: 1280, height: 720 });
client.setQueryData(qk.downloads(), [{ ...job, position_seconds: 10 }]);
client.setQueryData(qk.files({}), [{ ...job, position_seconds: 10 }]);
let answer: (jobs: DownloadJob[]) => void = () => {};
const poll = client.fetchQuery({
queryKey: qk.downloads(),
queryKey: qk.files({}),
queryFn: () => new Promise<DownloadJob[]>((r) => (answer = r)),
});
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
@@ -226,10 +228,10 @@ describe("LibraryPlayer", () => {
unmount();
answer([{ ...job, position_seconds: 10 }]);
await poll;
expect(client.getQueryData<DownloadJob[]>(qk.downloads())![0]!.position_seconds).toBe(300);
expect(client.getQueryData<DownloadJob[]>(qk.files({}))![0]!.position_seconds).toBe(300);
// Once the point has landed, the listing asks the server again.
const invalidate = vi.spyOn(client, "invalidateQueries");
settled?.();
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.downloads(), exact: true });
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.files() });
});
});
+13 -14
View File
@@ -34,7 +34,7 @@ export default function LibraryPlayer({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
// an uploaded audio file whose container is outside a hardcoded set (uploads accept any container).
@@ -68,23 +68,22 @@ export default function LibraryPlayer({
artwork: poster ?? null,
});
useMediaKeys(media);
// The library row's bar follows the point at once — both listings this job may sit in — rather
// than after the next refetch. On the way out a listing poll may already be out, having read the
// server before the closing point lands: it is cancelled, or its answer replaced the row's point
// with the older one, Continue opened there, and its first checkpoint wrote it back over the right
// one. Once the point is on the server, the listings are read again.
const lists = [qk.downloads(), qk.downloadsShared()];
// The library row's bar follows the point at once — every library listing this file may sit in
// (each folder, search and the shared view is its own key under `files`) — rather than after the
// next refetch. On the way out a listing read may already be out, having read the server before
// the closing point lands: it is cancelled, or its answer replaced the row's point with the older
// one, Continue opened there, and its first checkpoint wrote it back over the right one. Once the
// point is on the server, the listings are read again.
const lists = { queryKey: qk.files() };
const keepPoint = (position: number, duration: number, leaving: boolean) => {
const at = keptPosition(position, duration);
for (const key of lists) {
if (leaving) void qc.cancelQueries({ queryKey: key, exact: true });
qc.setQueryData<DownloadJob[]>(key, (jobs) =>
jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)),
);
}
if (leaving) void qc.cancelQueries(lists);
qc.setQueriesData<DownloadJob[]>(lists, (jobs) =>
jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)),
);
if (leaving) {
api.saveDownloadProgressBeacon(job.id, position, duration, () => {
for (const key of lists) void qc.invalidateQueries({ queryKey: key, exact: true });
void qc.invalidateQueries(lists);
});
} else void api.saveDownloadProgress(job.id, position, duration).catch(() => {});
};
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
//
// The local-upload dialog. It's a general file upload (any type, no format list): a picked
// file surfaces its name+size, and Upload streams it through api.uploadDownload (the XHR-progress
// file surfaces its name+size, and Upload streams it through api.uploadFile (the XHR-progress
// path). This drives the real dialog against a mocked api so we can assert the file + optional name
// actually reach the ingest call, and that the success toast points at the library. i18n is
// key-passthrough.
@@ -10,11 +10,12 @@ import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/re
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({
uploadDownload: vi.fn(async (_f: File, _o: unknown) => ({ id: 42, status: "done" })),
uploadFile: vi.fn(async (_f: File, _o: unknown) => ({ id: 42, status: "done" })),
// The dialog reads the per-file upload cap so it can refuse an over-cap file before spending the
// user's bandwidth on it. Generous by default; the cap test below lowers it.
downloadUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
notify: vi.fn(),
navigateTo: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => {
@@ -25,7 +26,7 @@ vi.mock("react-i18next", async (importOriginal) => {
};
});
vi.mock("../lib/api", () => ({
api: { uploadDownload: h.uploadDownload, downloadUsage: h.downloadUsage },
api: { uploadFile: h.uploadFile, filesUsage: h.filesUsage },
}));
vi.mock("../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
// The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts.
@@ -33,15 +34,15 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: h.notify,
}));
vi.mock("../lib/nav", () => ({ navigateTo: vi.fn() }));
vi.mock("../lib/nav", () => ({ navigateTo: h.navigateTo }));
import UploadDialog from "./UploadDialog";
const wrap = (onClose = vi.fn()) => {
const wrap = (onClose = vi.fn(), folderId: number | null = null) => {
const qc = new QueryClient();
return render(
<QueryClientProvider client={qc}>
<UploadDialog onClose={onClose} />
<UploadDialog folderId={folderId} onClose={onClose} />
</QueryClientProvider>,
);
};
@@ -50,10 +51,10 @@ const wrap = (onClose = vi.fn()) => {
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
beforeEach(() => {
h.uploadDownload.mockClear();
h.uploadFile.mockClear();
h.notify.mockClear();
h.downloadUsage.mockClear();
h.downloadUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
h.filesUsage.mockClear();
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
});
afterEach(cleanup);
@@ -73,8 +74,8 @@ describe("UploadDialog", () => {
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
const [passedFile, opts] = h.uploadDownload.mock.calls[0]!;
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
const [passedFile, opts] = h.uploadFile.mock.calls[0]!;
expect(passedFile).toBe(f);
expect((opts as { displayName?: string }).displayName).toBe("My notes");
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
@@ -85,7 +86,7 @@ describe("UploadDialog", () => {
it("says so when the server queued the upload for its phone-safe conversion", async () => {
// A video no phone could play as it is (an iPhone's HEVC .mov) comes back `queued`, not `done`:
// "File uploaded" alone would send the user to a Library row that is still converting.
h.uploadDownload.mockResolvedValueOnce({ id: 43, status: "queued" });
h.uploadFile.mockResolvedValueOnce({ id: 43, status: "queued" });
wrap();
fireEvent.change(fileInput(), {
target: { files: [new File(["x"], "clip.mov", { type: "video/quicktime" })] },
@@ -97,6 +98,27 @@ describe("UploadDialog", () => {
});
});
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
// Done: the file is in Files. Still converting: it is in the download queue until it is done.
wrap(vi.fn(), 5);
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.mp4")] } });
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
expect((h.uploadFile.mock.calls[0]![1] as { folderId?: number }).folderId).toBe(5);
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
expect(h.navigateTo).toHaveBeenLastCalledWith("files");
cleanup();
h.notify.mockClear();
h.uploadFile.mockResolvedValueOnce({ id: 43, status: "queued" });
wrap();
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "b.mov")] } });
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
expect(h.navigateTo).toHaveBeenLastCalledWith("downloads");
});
it("keeps Upload disabled until a file is chosen", () => {
wrap();
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
@@ -118,9 +140,9 @@ describe("UploadDialog", () => {
// answers is the edge ceiling, whose refusal carries no number at all and may not even reach
// the browser. So the dialog checks the size itself: the user learns the limit before a byte
// moves.
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
pickFile(CAP + 1, "big.bin");
await waitFor(() => expect(screen.getByText("downloads.errors.upload.tooLarge")).toBeTruthy());
@@ -139,9 +161,9 @@ describe("UploadDialog", () => {
// the only feedback that a drag is over a live target — and a danger border that outranks the
// drag state reads as "drag-and-drop is off now", sending the user hunting for a control that
// does not exist. The drop would have worked.
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
pickFile(CAP + 1, "big.bin");
const zone = screen.getByText("big.bin").closest("button")!;
expect(zone.className).toContain("border-danger");
@@ -159,9 +181,9 @@ describe("UploadDialog", () => {
// `submit()` changes nothing here, which is why that term is documented as belt and braces
// rather than as the mechanism. What this pins is the user-visible contract: pressing Upload
// after dropping a bigger file does not start a transfer the server will refuse.
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
pickFile(CAP, "fits.bin");
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
btn.focus();
@@ -170,17 +192,17 @@ describe("UploadDialog", () => {
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
expect(btn.disabled).toBe(false); // soft-disabled, still focusable — and still clickable
fireEvent.click(btn);
expect(h.uploadDownload).not.toHaveBeenCalled();
expect(h.uploadFile).not.toHaveBeenCalled();
});
it("sends a file of EXACTLY the cap, which the server accepts", async () => {
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
pickFile(CAP, "exact.bin");
expect(screen.queryByRole("alert")).toBeNull();
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
});
it("re-announces the refusal when a SECOND over-cap file is picked", async () => {
@@ -188,9 +210,9 @@ describe("UploadDialog", () => {
// file, so two over-cap picks leave the node and its text identical — and a live region that
// is never touched says nothing. See formAlerts.dom.test.tsx, which holds the same rule for
// the three other alerts in this app that can repeat.
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
h.filesUsage.mockResolvedValue({ upload_max_bytes: CAP });
wrap();
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
await waitFor(() => expect(h.filesUsage).toHaveBeenCalled());
pickFile(CAP + 1, "a.bin");
const first = await screen.findByRole("alert");
pickFile(CAP + 2, "b.bin");
+18 -5
View File
@@ -19,15 +19,24 @@ import { Input } from "./ui/Input";
// Local-file ingest. A general upload — ANY file type, no format list: media
// (video/audio) joins the library with the full function set, a generic file is download-only. The
// file is streamed to the server via XHR so this dialog can show real upload progress. On success it
// lands in the Library as an already-`done` job — or, for media no phone could play as it is (an
// lands in the library (Files) as an already-`done` job — or, for media no phone could play as it is (an
// iPhone's HEVC, a webm, an mkv), as a `queued` job the worker converts first — so we invalidate,
// say which, and point the user there.
// Out here, not inline in the upload handler: a `?:` inside its `try` makes the React Compiler
// skip the whole dialog.
const uploadedKey = (status: string) =>
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
// A done file is in Files; one the worker still converts is in the download queue until it is.
const uploadedPage = (status: string) => (status === "done" ? "files" : "downloads");
export default function UploadDialog({ onClose }: { onClose: () => void }) {
export default function UploadDialog({
folderId = null,
onClose,
}: {
/** The library folder the file goes into (an own folder, or the Uploads folder); null = Uploads. */
folderId?: number | null;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
@@ -46,7 +55,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
// On a default install the one that answers is the edge ceiling (`bodylimit`), whose refusal
// carries no number, so without this the user would be told "too large" and not what the limit is.
// A cap that has not loaded yet blocks nothing — the server still refuses, honestly.
const cap = useQuery({ queryKey: qk.downloadUsage(), queryFn: api.downloadUsage }).data
const cap = useQuery({ queryKey: qk.storageUsage(), queryFn: api.filesUsage }).data
?.upload_max_bytes;
const tooBig = !!file && !!cap && file.size > cap;
const refusalId = useId();
@@ -80,8 +89,9 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
setPct(0);
const displayName = name.trim() || undefined;
try {
const job = await api.uploadDownload(file, {
const job = await api.uploadFile(file, {
displayName,
folderId,
onProgress: setPct,
signal: ac.signal,
});
@@ -89,7 +99,10 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
notify({
level: "success",
message: t(uploadedKey(job.status)),
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
action: {
label: t("downloads.dialog.view"),
onClick: () => navigateTo(uploadedPage(job.status)),
},
});
onClose();
} catch (e) {
+3 -3
View File
@@ -73,7 +73,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const [accurate, setAccurate] = useState(true);
const [name, setName] = useState("");
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
const tc = (sec: number) => formatTimecode(sec, duration >= 3600);
const aspect =
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
@@ -281,7 +281,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// so another try adds only the rest.
if (done.length) {
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
qc.invalidateQueries({ queryKey: qk.storageUsage() });
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
@@ -299,7 +299,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
},
onSuccess: ({ files }) => {
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
qc.invalidateQueries({ queryKey: qk.storageUsage() });
notify({ level: "success", message: t("editor.created", { count: files }) });
onClose();
},
@@ -132,15 +132,15 @@ describe("the upload dialog", () => {
// toast that outlives the test that spawned it and lands as an unexpected error in whichever
// test is running by then. The file is fast enough today that it always lands after the suite,
// which is a coincidence, not a guarantee.
vi.spyOn(api, "downloadUsage").mockResolvedValue({
vi.spyOn(api, "filesUsage").mockResolvedValue({
upload_max_bytes: 1_000_000,
} as Awaited<ReturnType<typeof api.downloadUsage>>);
} as Awaited<ReturnType<typeof api.filesUsage>>);
});
it("names an upload the server refused", async () => {
// A 502 from the proxy raises only the self-clearing connection notice; the next successful poll
// wipes it, and the dialog snapped back with no error and no job in the list.
vi.spyOn(api, "uploadDownload").mockRejectedValue(new HttpError(502, "Bad Gateway"));
vi.spyOn(api, "uploadFile").mockRejectedValue(new HttpError(502, "Bad Gateway"));
mount();
pick();
fireEvent.click(screen.getByText("downloads.upload.submit"));
@@ -150,7 +150,7 @@ describe("the upload dialog", () => {
it("says nothing when the user cancelled it themselves", async () => {
// A cancel is not a failure — and it rejects with the same status 0 a genuine network drop does,
// which is why the difference is a named sentinel (`isAborted`) and not a status check.
const upload = vi.spyOn(api, "uploadDownload").mockRejectedValue(new HttpError(0, "aborted"));
const upload = vi.spyOn(api, "uploadFile").mockRejectedValue(new HttpError(0, "aborted"));
mount();
pick();
fireEvent.click(screen.getByText("downloads.upload.submit"));
+5 -2
View File
@@ -26,11 +26,14 @@ export function useCopyItems() {
);
};
/** The rows for one object. `text` is its title (a video, a show) or its name (a channel, a
* playlist); a row whose value is missing is left out rather than copying an empty string. */
* playlist); a row whose value is missing is left out rather than copying an empty string.
* `linkLabel` names the link row where "Copy link" would be ambiguous (a library file's menu also
* copies its share link). */
return (
text: string | null | undefined,
link: string | null | undefined,
named: Named = "title",
linkLabel?: string,
): ActionMenuItem[] => [
...(text
? [
@@ -47,7 +50,7 @@ export function useCopyItems() {
? [
{
id: "copy-link",
label: t("common.copy.link"),
label: linkLabel ?? t("common.copy.link"),
icon: <Link2 className="w-4 h-4" />,
inGesture: true,
onSelect: () => copy(link, t("common.copy.linkDone")),
+8 -14
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { invalidateDownloads } from "../lib/downloads";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react";
@@ -13,12 +13,11 @@ import { Input } from "./ui/Input";
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
// editing and the failure-detail window.
// Edit a download's display metadata: title, channel (name + link), and extra reference links.
// All are display-only overrides — the on-disk file is never renamed.
// Edit a library file's details: its channel (name + link) and extra reference links. Display-only
// overrides — the on-disk file is never touched. Its name is renamed in place in the Files list.
export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [name, setName] = useState(job.display_name || job.asset?.title || "");
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
const [channelUrl, setChannelUrl] = useState(
job.display_uploader_url || job.asset?.uploader_url || "",
@@ -32,14 +31,13 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
const save = useMutation({
mutationFn: () =>
api.updateDownloadMeta(job.id, {
display_name: name.trim(),
display_uploader: channel.trim(),
display_uploader_url: channelUrl.trim(),
api.updateFile(job.id, {
uploader: channel.trim(),
uploader_url: channelUrl.trim(),
extra_links: links.map((l) => l.trim()).filter(Boolean),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.downloads() });
invalidateDownloads(qc);
onClose();
},
meta: { failTitle: "errors.writeFailed.downloadDetails" },
@@ -48,10 +46,6 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
return (
<Modal initialFocus="field" title={t("downloads.edit.title")} onClose={onClose}>
<div className="space-y-3">
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
<Input className="w-full" value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
<Input className="w-full" value={channel} onChange={(e) => setChannel(e.target.value)} />
@@ -96,7 +90,7 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
variant="primary"
size="sm"
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
disabled={save.isPending}
>
{t("downloads.edit.save")}
</Button>
@@ -51,7 +51,6 @@ const baseJob: DownloadJob = {
display_uploader_url: null,
extra_links: [],
can_download: true,
expired: false,
folder_id: null,
kept: false,
expires_at: null,
+10 -50
View File
@@ -2,8 +2,6 @@ import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { CalendarClock, File, Film, Link2, Music, Scissors } from "lucide-react";
import clsx from "clsx";
import { qk } from "../lib/queryKeys";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob } from "../lib/api";
import {
formatBytes,
@@ -21,9 +19,9 @@ import { MediaProgress } from "./ui/MediaCard";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
// library, shared, and admin tabs.
// Download-row presentation + the small shared primitives (status pill, icon button, the job's
// picture), split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the
// queue and the admin tab; the Files list borrows the picture.
const STATUS_CLS: Record<string, string> = {
queued: "text-muted",
@@ -60,10 +58,9 @@ export function StatusPill({ job }: { job: DownloadJob }) {
<span className="text-sm font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
);
}
const key = job.expired ? "expired" : job.status;
return (
<span className={clsx("text-sm font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
{t(`downloads.status.${key}`)}
{t(`downloads.status.${job.status}`)}
</span>
);
}
@@ -76,7 +73,9 @@ export function StatusPill({ job }: { job: DownloadJob }) {
const THUMB_BOX =
"w-28 @max-[40rem]/dlrow:w-24 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border";
function Thumb({ job }: { job: DownloadJob }) {
/** 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). */
export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: string }) {
// Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so
// a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields
// rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for
@@ -107,7 +106,7 @@ function Thumb({ job }: { job: DownloadJob }) {
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail.
if (job.asset?.media_kind === "file") {
return (
<div className={`${THUMB_BOX} flex items-center justify-center text-muted`}>
<div className={`${box} flex items-center justify-center text-muted`}>
<File className="w-8 h-8" aria-hidden="true" />
</div>
);
@@ -116,7 +115,7 @@ function Thumb({ job }: { job: DownloadJob }) {
const duration = job.asset?.duration_s ?? 0;
const pct = duration > 0 ? ((job.position_seconds ?? 0) / duration) * 100 : 0;
return (
<div className={`${THUMB_BOX} relative`}>
<div className={`${box} relative`}>
<MediaProgress pct={pct} />
{src ? (
<img
@@ -262,7 +261,7 @@ export function DownloadRow({
className={`grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-3 gap-y-1 [grid-template-areas:'thumb_head_act''thumb_meta_act''thumb_src_act''thumb_prog_act'] @max-[40rem]/dlrow:grid-cols-[auto_minmax(0,1fr)] @max-[40rem]/dlrow:[grid-template-areas:'thumb_head''meta_meta''foot_foot''prog_prog']`}
>
<div className="[grid-area:thumb]">
<Thumb job={job} />
<JobThumb job={job} />
</div>
<div className="[grid-area:head] min-w-0">
<div
@@ -379,42 +378,3 @@ export function DownloadRow({
</div>
);
}
export function UsageBar() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. The footprint
// only moves while a job is in flight, so poll fast (2s) then and idle-back-off to 15s otherwise
// (running_jobs counts queued+running — it flips back to 2s on the first poll after a new enqueue).
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, {
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);
return (
<div className={`${SURFACE.card} p-3 mb-4`}>
<div className="flex justify-between text-sm mb-1.5">
<span className="font-medium">{t("downloads.usage.title")}</span>
<span className="text-muted">
{formatBytes(u.footprint_bytes, lang)}
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`} ·{" "}
{u.unlimited
? t("downloads.usage.unlimited")
: t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
</span>
</div>
{!u.unlimited && (
<div className="h-2 rounded-full bg-surface overflow-hidden">
<div
className={clsx("h-full transition-all", pct > 90 ? "bg-danger" : "bg-accent")}
style={{ width: `${pct}%` }}
/>
</div>
)}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { Film, Folder, Globe, Server, 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
// its name to the client, in the reader's language; an own folder carries the name its user gave it.
const SITE_PREFIX = "site:";
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}`);
return key;
}
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;
}
export function folderLabel(folder: FileFolder, t: T): string {
return folder.kind === "own" ? (folder.name ?? "") : sourceLabel(folder.source_key ?? "", t);
}
export function folderIcon(folder: FileFolder): LucideIcon | typeof YouTubeIcon {
return folder.kind === "own" ? Folder : sourceIcon(folder.source_key ?? "");
}
+47
View File
@@ -0,0 +1,47 @@
import { createContext, useContext } from "react";
// The Files module's contexts + hooks, split out of FilesProvider so that file exports only
// components (Fast Refresh).
/** What the Files page lists: every file of mine, the ones shared with me, or one folder's. */
export type FilesView = { kind: "all" } | { kind: "shared" } | { kind: "folder"; id: number };
export const ALL_FILES: FilesView = { kind: "all" };
/** The view as one string — the `?folder=` bookmark and the remembered view share it. */
export function viewParam(view: FilesView): string {
return view.kind === "folder" ? String(view.id) : view.kind;
}
/** The view a stored or bookmarked string names; anything else is the whole library. */
export function parseView(raw: string | null | undefined): FilesView | null {
if (raw === "all") return ALL_FILES;
if (raw === "shared") return { kind: "shared" };
const id = raw && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
return id !== null && Number.isSafeInteger(id) ? { kind: "folder", id } : null;
}
export const sameView = (a: FilesView, b: FilesView): boolean => viewParam(a) === viewParam(b);
export type FilesState = {
search: string;
view: FilesView;
};
export interface FilesActions {
setSearch: (q: string) => void;
setView: (view: FilesView) => void;
}
export const FilesStateContext = createContext<FilesState>({ search: "", view: ALL_FILES });
export const FilesActionsContext = createContext<FilesActions>({
setSearch: () => {},
setView: () => {},
});
export function useFiles(): FilesState {
return useContext(FilesStateContext);
}
export function useFilesActions(): FilesActions {
return useContext(FilesActionsContext);
}
+8 -3
View File
@@ -26,6 +26,8 @@ const Playlists = lazyWithPreload(() => import("./Playlists"));
const PlexBrowse = lazyWithPreload(() => import("./PlexBrowse"));
const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar"));
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
const Files = lazyWithPreload(() => import("./Files"));
const FilesRail = lazyWithPreload(() => import("./FilesRail"));
const DownloadCenter = lazyWithPreload(() => import("./DownloadCenter"));
const Stats = lazyWithPreload(() => import("./Stats"));
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
@@ -54,6 +56,7 @@ export const PAGE_CONTENT: Record<Page, PageDef> = {
playlists: { Component: Playlists, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "100rem" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
files: { Component: Files, maxWidth: "80rem" },
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
stats: { Component: Stats },
scheduler: { Component: Scheduler },
@@ -73,8 +76,9 @@ type RailProps = {
onToggleCollapse: () => void;
};
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists has its own).
type RailCollapseKey = "filter" | "playlists";
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists and files
// have their own).
type RailCollapseKey = "filter" | "playlists" | "files";
export type RailDef = {
Rail: ComponentType<RailProps>;
@@ -83,11 +87,12 @@ export type RailDef = {
collapse: RailCollapseKey;
};
// page id → its side rail (only the three pages that have one). App renders it in the rail slot.
// page id → its side rail (only the pages that have one). App renders it in the rail slot.
export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
files: { Rail: FilesRail, panelId: "files", collapse: "files" },
};
/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are
@@ -28,6 +28,7 @@ export function loadInitialPage(): Page {
// without an explicit ?page. Feed filters (or a channel overlay) imply the feed.
if (params.has("v")) return "plex";
if (params.has("pl")) return "playlists";
if (params.has("folder")) return "files";
if (hasFilterParams(params) || params.has("chan")) return "feed";
// A bare URL → Home, the surface whose whole job is to answer "what should I watch" before
// the user has to decide anything. It deliberately OVERRIDES the remembered last module: landing
@@ -17,6 +17,7 @@ const files = readSources(__dirname, [
/** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record<string, [number, string]> = {
"FilesRail.tsx": [1, "the load-error retry"],
"Home.tsx": [
2,
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
+12 -219
View File
@@ -1,40 +1,21 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
Ban,
Download,
Link2,
Pause,
Pencil,
Play,
PlayCircle,
RefreshCw,
RotateCcw,
Scissors,
Share2,
SkipBack,
Trash2,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import { Ban, Pause, Play, RefreshCw } from "lucide-react";
import { api, type DownloadJob, type DownloadJobKind } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { invalidateDownloads } from "../lib/downloads";
import { useConfirm } from "./confirmContext";
import { useCopyItems } from "./copyItems";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
// A download's ONE action list: the row's "…", its right-click, long-press and Shift+F10,
// on the queue, the library and the shared tab. The row keeps its primary action (play; pause /
// resume / retry in the queue), the save link and its state (the error and its details) in sight;
// a library row also keeps Share (the link copy) in sight, ahead of save. The rest — the editors,
// managing the shares, re-download, the copies and the destructive rows — lives here.
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
// sight; the copies and Cancel live here. A finished download is a library file: its actions are
// the Files page's (useFileActions).
export type DownloadTab = "queue" | "done" | "shared";
export type JobOp = "pause" | "resume" | "cancel" | "delete" | "redownload";
export type JobOp = "pause" | "resume" | "cancel";
// The Cancel confirmation of a queued job; an upload's names what it deletes.
const CANCEL_DOWNLOAD = {
@@ -63,27 +44,7 @@ const CANCEL_COPY: Record<DownloadJobKind, typeof CANCEL_DOWNLOAD> = {
};
// 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.
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" },
};
export function useDownloadActions({
onPlay,
onEdit,
onEditDetails,
onManageShares,
}: {
onPlay: (job: DownloadJob, fromStart?: boolean) => void;
onEdit: (job: DownloadJob) => void;
onEditDetails: (job: DownloadJob) => void;
onManageShares: (job: DownloadJob) => void;
}) {
export function useDownloadActions() {
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
@@ -95,83 +56,16 @@ export function useDownloadActions({
? api.pauseDownload(id)
: op === "resume"
? api.resumeDownload(id)
: op === "cancel"
? api.cancelDownload(id)
: op === "redownload"
? api.redownloadDownload(id)
: api.deleteDownload(id),
: api.cancelDownload(id),
onSuccess: () => invalidateDownloads(qc),
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
});
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
// The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title
// it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel".
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
};
// One-click share: ensure a downloadable link, copy it, toast with "open in new tab"
// (see quickShareLink). Re-choosing reuses the existing link; a per-job in-flight guard also stops a
// double pick from racing two POSTs into two links before the cache is primed. Advanced controls
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, "Manage sharing".
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
await quickShareLink(qc, job, t).finally(() => setSharingId(null));
};
const icon = (I: typeof Play) => <I className="w-4 h-4" />;
// A started item continues, and can also start over — the card menus' words and glyph (C5).
const play = (job: DownloadJob): ActionMenuItem[] => {
const started = (job.position_seconds ?? 0) > 0;
return [
{
id: "play",
label: started ? t("card.continue") : t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(job),
},
...(started
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: icon(SkipBack),
onSelect: () => onPlay(job, true),
},
]
: []),
];
};
// The same link the row's save button is, followed inside the click: the server answers it as an
// attachment, so the page stays where it is.
const save = (job: DownloadJob): ActionMenuItem => ({
id: "save",
label: t("downloads.actions.download"),
icon: icon(Download),
inGesture: true,
onSelect: () => {
const a = document.createElement("a");
a.href = api.downloadFileUrl(job.id);
a.click();
},
});
const edit = (job: DownloadJob): ActionMenuItem => ({
id: "edit",
label: t("downloads.actions.edit"),
icon: icon(Scissors),
onSelect: () => onEdit(job),
});
const copies = (job: DownloadJob) => copy(jobTitle(job), job.source_url);
function queueItems(job: DownloadJob): ActionMenuItem[] {
@@ -212,112 +106,11 @@ export function useDownloadActions({
);
}
function libraryItems(job: DownloadJob): ActionMenuItem[] {
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
const a = libraryRowActions(job);
return menuGroups(
// The row's own buttons first, in the row's order: sharing the link is the library's main
// errand, ahead of saving the file.
[
...(a.play ? play(job) : []),
{
id: "share",
label: t("downloads.actions.share"),
icon: icon(Share2),
disabled: sharingId !== null,
onSelect: () => void quickShare(job),
},
save(job),
],
[
...(a.edit ? [edit(job)] : []),
...(a.editDetails
? [
{
id: "edit-details",
label: t("downloads.actions.editDetails"),
icon: icon(Pencil),
onSelect: () => onEditDetails(job),
},
]
: []),
...(a.redownload
? [
{
id: "redownload",
label: t("downloads.actions.redownload"),
icon: icon(RotateCcw),
onSelect: () =>
void confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
t("downloads.confirm.redownloadAction"),
() => act.mutate({ id: job.id, op: "redownload" }),
),
},
]
: []),
],
[
{
id: "share-manage",
label: t("downloads.actions.shareManage"),
icon: icon(Link2),
onSelect: () => onManageShares(job),
},
...copies(job),
],
[
{
id: "delete",
label: t("downloads.actions.delete"),
icon: icon(Trash2),
danger: true,
onSelect: () => {
const copy = DELETE_COPY[job.job_kind ?? "download"];
void confirmThen(t(copy.title), t(copy.body), t("downloads.actions.delete"), () =>
act.mutate({ id: job.id, op: "delete" }),
);
},
},
],
);
}
function sharedItems(job: DownloadJob): ActionMenuItem[] {
// Same media_kind + can_download gate as the library, via the shared helper.
const a = sharedRowActions(job);
return menuGroups(
[...(a.play ? play(job) : []), ...(a.save ? [save(job)] : [])],
a.edit ? [edit(job)] : [],
copies(job),
[
{
id: "remove-shared",
label: t("downloads.actions.removeShared"),
icon: icon(Trash2),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
t("downloads.actions.removeShared"),
() => removeShared.mutate(job.id),
),
},
],
);
}
/** The job's menu rows on the tab it is listed on. */
const items = (job: DownloadJob, tab: DownloadTab): ActionMenuItem[] =>
tab === "queue" ? queueItems(job) : tab === "done" ? libraryItems(job) : sharedItems(job);
/** The job's menu rows. */
const items = (job: DownloadJob): ActionMenuItem[] => queueItems(job);
/** The queue's visible primary action and the error dialog's Retry. */
const run = (job: DownloadJob, op: JobOp) => act.mutate({ id: job.id, op });
/** The library row's visible Share: the same one-click link copy as its menu row. */
const share = (job: DownloadJob) => void quickShare(job);
return { items, run, share };
return { items, run };
}
+303
View File
@@ -0,0 +1,303 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
Download,
FolderInput,
Info,
Link,
Pencil,
Pin,
PinOff,
PlayCircle,
RotateCcw,
Scissors,
Share2,
SkipBack,
Trash2,
} from "lucide-react";
import { api, type DownloadJobKind, type FileFolder, type LibraryFile } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { useConfirm } from "./confirmContext";
import { useCopyItems } from "./copyItems";
import { jobTitle } from "./downloadTitle";
import { folderLabel } from "./fileFolders";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
// The Files rows' ONE menu, and the actions the rows show as icons (the same ones, so a row's every
// action is reachable by right click — DESIGN.md "Library list columns"). My own files and the ones
// 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" },
};
type Patch = { name?: string | null; folder_id?: number; kept?: boolean };
// Which failure title a PATCH speaks under: what it was asked to change.
const patchFail = (p: Patch) =>
"folder_id" in p
? "errors.writeFailed.file.move"
: "kept" in p
? "errors.writeFailed.file.keep"
: "errors.writeFailed.file.rename";
export function useFileActions({
folders,
onPlay,
onEdit,
onRename,
onEditDetails,
onManageShares,
}: {
/** The caller's folders — the Move submenu's places. */
folders: readonly FileFolder[];
onPlay: (file: LibraryFile, fromStart?: boolean) => void;
onEdit: (file: LibraryFile) => void;
onRename: (file: LibraryFile) => void;
onEditDetails: (file: LibraryFile) => void;
onManageShares: (file: LibraryFile) => void;
}) {
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
const copy = useCopyItems();
const update = useMutation({
mutationFn: ({ id, patch }: { id: number; patch: Patch }) => api.updateFile(id, patch),
onSuccess: () => invalidateDownloads(qc),
onError: (e, v) => notifyActionError(e, t(patchFail(v.patch))),
});
const remove = useMutation({
mutationFn: (id: number) => api.deleteFile(id),
onSuccess: () => invalidateDownloads(qc),
meta: { failTitle: "errors.writeFailed.file.delete" },
});
const redownload = useMutation({
mutationFn: (id: number) => api.redownloadDownload(id),
onSuccess: () => invalidateDownloads(qc),
meta: { failTitle: "errors.writeFailed.job.redownload" },
});
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedFile(id),
onSuccess: () => invalidateDownloads(qc),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
};
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (file: LibraryFile) => {
if (sharingId !== null) return;
setSharingId(file.id);
await quickShareLink(qc, file, t).finally(() => setSharingId(null));
};
const icon = (I: typeof PlayCircle) => <I className="w-4 h-4" />;
const play = (file: LibraryFile): ActionMenuItem[] => {
const started = (file.position_seconds ?? 0) > 0;
return [
{
id: "play",
label: started ? t("card.continue") : t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(file),
},
...(started
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: icon(SkipBack),
onSelect: () => onPlay(file, true),
},
]
: []),
];
};
const save = (file: LibraryFile): ActionMenuItem => ({
id: "save",
label: t("downloads.actions.download"),
icon: icon(Download),
inGesture: true,
onSelect: () => {
const a = document.createElement("a");
a.href = api.fileUrl(file.id);
a.click();
},
});
const copyLink = (file: LibraryFile): ActionMenuItem => ({
id: "share-link",
label: t("files.actions.copyLink"),
icon: icon(Link),
disabled: sharingId !== null,
onSelect: () => void quickShare(file),
});
const edit = (file: LibraryFile): ActionMenuItem => ({
id: "edit",
label: t("downloads.actions.edit"),
icon: icon(Scissors),
onSelect: () => onEdit(file),
});
// "Copy link" is the share link here (the library's daily errand); the page it came from is the
// source link, named so.
const copies = (file: LibraryFile) =>
copy(jobTitle(file), file.source_url, "title", t("files.actions.copySourceLink"));
// 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 => {
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 } });
const items: ActionMenuItem[] = own.map((f) => ({
id: `move-${f.id}`,
label: folderLabel(f, t),
check: { kind: "one", checked: f.id === file.folder_id },
onSelect: to(f),
}));
if (home && home.id !== file.folder_id)
items.push({
id: "move-home",
label: t("files.actions.moveHome", { folder: folderLabel(home, t) }),
separatorBefore: items.length > 0,
onSelect: to(home),
});
if (items.length === 0)
items.push({
id: "move-none",
label: t("files.actions.noOwnFolders"),
disabled: true,
onSelect: () => {},
});
return { id: "move", label: t("files.actions.move"), icon: icon(FolderInput), items };
};
function mineItems(file: LibraryFile): ActionMenuItem[] {
const a = libraryRowActions(file);
return menuGroups(
// The row's icons first, in the row's order.
[
...(a.play ? play(file) : []),
...(a.play ? [copyLink(file)] : []),
{
id: "share",
label: t("files.actions.share"),
icon: icon(Share2),
onSelect: () => onManageShares(file),
},
save(file),
],
[
...(a.edit ? [edit(file)] : []),
{
id: "rename",
label: t("files.actions.rename"),
icon: icon(Pencil),
onSelect: () => onRename(file),
},
moveTo(file),
{
id: "keep",
label: t(file.kept ? "files.actions.unkeep" : "files.actions.keep"),
icon: icon(file.kept ? PinOff : Pin),
onSelect: () => update.mutate({ id: file.id, patch: { kept: !file.kept } }),
},
],
[
...(a.editDetails
? [
{
id: "details",
label: t("files.actions.details"),
icon: icon(Info),
onSelect: () => onEditDetails(file),
},
]
: []),
...(a.redownload
? [
{
id: "redownload",
label: t("downloads.actions.redownload"),
icon: icon(RotateCcw),
onSelect: () =>
void confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
t("downloads.confirm.redownloadAction"),
() => redownload.mutate(file.id),
),
},
]
: []),
...copies(file),
],
[
{
id: "delete",
label: t("downloads.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),
);
},
},
],
);
}
function sharedItems(file: LibraryFile): ActionMenuItem[] {
const a = sharedRowActions(file);
return menuGroups(
[...(a.play ? play(file) : []), ...(a.save ? [save(file)] : [])],
a.edit ? [edit(file)] : [],
copies(file),
[
{
id: "remove-shared",
label: t("downloads.actions.removeShared"),
icon: icon(Trash2),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
t("downloads.actions.removeShared"),
() => removeShared.mutate(file.id),
),
},
],
);
}
/** The file's menu rows. */
const items = (file: LibraryFile): ActionMenuItem[] =>
file.shared ? sharedItems(file) : mineItems(file);
/** 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. */
const rename = (file: LibraryFile, name: string) => {
const next = name.trim();
if (next === (file.display_name ?? "")) return;
update.mutate({ id: file.id, patch: { name: next || null } });
};
return { items, share, rename, sharing: sharingId };
}