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