feat(files): folders managed in place, a grid per folder, drop to upload, open on one file
- Own folders are made, renamed and deleted from the rail (right click, long press, Shift+F10) or the "…" beside an open folder's name; any folder's retention is a submenu (7/30/90 days, until deleted). Deleting confirms where its files go. - List or grid per folder, sharing one sort; the grid is the one media card. - A file dropped anywhere on the page opens Upload with it, bound for the folder on screen. - A link (?item=) or the upload notice opens Files on the file: its page turned to, the keyboard on it, a ring round it. - An in-place name field left by Enter or Escape hands the focus back to what replaced it (the row, the entry, the "…") instead of dropping it on <body>. - The table's saved page size is per account.
This commit is contained in:
@@ -0,0 +1,113 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Scissors } from "lucide-react";
|
||||||
|
import type { LibraryFile } from "../lib/api";
|
||||||
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
|
import { JobThumb } from "./downloadRow";
|
||||||
|
import { jobTitle } from "./downloadTitle";
|
||||||
|
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
|
||||||
|
import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard";
|
||||||
|
import {
|
||||||
|
MEDIA_BADGE,
|
||||||
|
MEDIA_BAR,
|
||||||
|
MEDIA_HOVER_LAYER,
|
||||||
|
MEDIA_PICTURE,
|
||||||
|
mediaPictureBox,
|
||||||
|
mediaTitleClass,
|
||||||
|
} from "./ui/mediaCardStyles";
|
||||||
|
import { MoreButton } from "./ui/MoreButton";
|
||||||
|
import { Pressable } from "./ui/Pressable";
|
||||||
|
|
||||||
|
// A library file in the Files grid: the one media card (ui/MediaCard) — its picture plays it, the
|
||||||
|
// title under it, then where it came from and what needs saying about it (kept, leaving soon). The
|
||||||
|
// right click, the long press and the "…" open the same menu as its table row (DESIGN.md "Files page":
|
||||||
|
// the grid is the list's other paint, a toggle remembered per folder).
|
||||||
|
|
||||||
|
export function FileCard({
|
||||||
|
file,
|
||||||
|
index,
|
||||||
|
onPlay,
|
||||||
|
menuProps,
|
||||||
|
moreProps,
|
||||||
|
menuOpen,
|
||||||
|
arrived,
|
||||||
|
}: {
|
||||||
|
file: LibraryFile;
|
||||||
|
/** Its place on the page, for the keyboard walk. */
|
||||||
|
index: number;
|
||||||
|
/** Null: nothing to play (a plain file, a shared one its owner keeps from me). */
|
||||||
|
onPlay: (() => void) | null;
|
||||||
|
menuProps: Record<string, unknown>;
|
||||||
|
moreProps: Record<string, unknown>;
|
||||||
|
menuOpen: boolean;
|
||||||
|
/** The file a link or a notice opened the page on. */
|
||||||
|
arrived: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const title = jobTitle(file);
|
||||||
|
const channel = file.display_uploader || file.asset?.uploader || null;
|
||||||
|
const clip = file.job_kind === "edit" || file.job_kind === "clip";
|
||||||
|
const thumb = <JobThumb job={file} box="h-full w-full" />;
|
||||||
|
return (
|
||||||
|
<MediaCard
|
||||||
|
dense
|
||||||
|
rootProps={{ ...menuProps, ...navItemAttrs(index, title), "data-file-id": file.id }}
|
||||||
|
className={clsx(arrived && "ring-2 ring-accent/60")}
|
||||||
|
picture={
|
||||||
|
<div className={mediaPictureBox("wide")}>
|
||||||
|
{onPlay ? (
|
||||||
|
<Pressable
|
||||||
|
onClick={onPlay}
|
||||||
|
aria-label={t("downloads.actions.play")}
|
||||||
|
data-nav-open=""
|
||||||
|
className={MEDIA_PICTURE}
|
||||||
|
>
|
||||||
|
{thumb}
|
||||||
|
<div className={MEDIA_HOVER_LAYER}>
|
||||||
|
<MediaPlayGlyph small />
|
||||||
|
</div>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<div className={clsx(MEDIA_PICTURE, "cursor-default")}>{thumb}</div>
|
||||||
|
)}
|
||||||
|
{clip && (
|
||||||
|
<span
|
||||||
|
className={clsx(MEDIA_BADGE, "left-1.5 top-1.5 inline-flex items-center gap-0.5")}
|
||||||
|
>
|
||||||
|
<Scissors className="size-3" aria-hidden />
|
||||||
|
{t("downloads.clipBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={MEDIA_BAR}>
|
||||||
|
{/* `media`: legible on any picture, the Plex cards' hover row's look. */}
|
||||||
|
<MoreButton
|
||||||
|
variant="media"
|
||||||
|
label={t("files.rowMenu")}
|
||||||
|
expanded={menuOpen}
|
||||||
|
menuProps={moreProps}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<span className={mediaTitleClass(true)} title={title}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
meta={
|
||||||
|
<span className="flex flex-col gap-1">
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
|
||||||
|
<SourceBadge file={file} />
|
||||||
|
{channel && <span className="truncate">{channel}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-wrap items-center gap-1 empty:hidden">
|
||||||
|
<PhoneBadge file={file} />
|
||||||
|
<RetentionBadge file={file} quiet />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
more={{ props: moreProps, open: menuOpen }}
|
||||||
|
moreLabel={t("files.rowMenu")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,17 +5,25 @@
|
|||||||
// opened by the "…" or a right-click. Renaming happens in the row; moving goes to an own folder or
|
// 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
|
// 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.
|
// list. i18n is key-passthrough, so labels read as keys.
|
||||||
|
import { useState } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { api, type FileFolder, type LibraryFile } from "../lib/api";
|
import { api, type FileFolder, type LibraryFile } from "../lib/api";
|
||||||
import { ALL_FILES, FilesActionsContext, FilesStateContext, type FilesView } from "./filesContext";
|
import {
|
||||||
|
ALL_FILES,
|
||||||
|
FilesActionsContext,
|
||||||
|
FilesStateContext,
|
||||||
|
type FilesFocus,
|
||||||
|
type FilesView,
|
||||||
|
} from "./filesContext";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
files: [] as unknown[],
|
files: [] as unknown[],
|
||||||
folders: [] as unknown[],
|
folders: [] as unknown[],
|
||||||
confirm: vi.fn(async (_o?: unknown) => true),
|
confirm: vi.fn(async (_o?: unknown) => true),
|
||||||
keys: [] as unknown[],
|
keys: [] as unknown[],
|
||||||
|
fetching: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
@@ -38,6 +46,7 @@ vi.mock("../lib/useLiveQuery", () => ({
|
|||||||
key[0] === "files" ? mocks.files : key[0] === "file-folders" ? mocks.folders : undefined,
|
key[0] === "files" ? mocks.files : key[0] === "file-folders" ? mocks.folders : undefined,
|
||||||
isError: false,
|
isError: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isFetching: mocks.fetching,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -92,13 +101,24 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
mocks.confirm.mockClear();
|
mocks.confirm.mockClear();
|
||||||
mocks.keys = [];
|
mocks.keys = [];
|
||||||
|
mocks.fetching = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function mount(view: FilesView = ALL_FILES, setView = vi.fn(), search = "") {
|
function mount(
|
||||||
|
view: FilesView = ALL_FILES,
|
||||||
|
setView = vi.fn(),
|
||||||
|
search = "",
|
||||||
|
{
|
||||||
|
focus = null,
|
||||||
|
consumeFocus = vi.fn(),
|
||||||
|
}: { focus?: FilesFocus | null; consumeFocus?: () => void } = {},
|
||||||
|
) {
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
<FilesActionsContext.Provider value={{ setSearch: vi.fn(), setView }}>
|
<FilesActionsContext.Provider
|
||||||
<FilesStateContext.Provider value={{ search, view }}>
|
value={{ setSearch: vi.fn(), setView, openFile: vi.fn(), consumeFocus }}
|
||||||
|
>
|
||||||
|
<FilesStateContext.Provider value={{ search, view, focus, arrived: null }}>
|
||||||
<Files />
|
<Files />
|
||||||
</FilesStateContext.Provider>
|
</FilesStateContext.Provider>
|
||||||
</FilesActionsContext.Provider>
|
</FilesActionsContext.Provider>
|
||||||
@@ -218,6 +238,8 @@ describe("Files · my file's row", () => {
|
|||||||
fireEvent.keyDown(field, { key: "Enter" });
|
fireEvent.keyDown(field, { key: "Enter" });
|
||||||
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Új név" }));
|
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Új név" }));
|
||||||
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull();
|
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull();
|
||||||
|
// The keyboard is handed back to the row, not dropped on <body>.
|
||||||
|
await waitFor(() => expect(rowOf("Clip 2").contains(document.activeElement)).toBe(true));
|
||||||
|
|
||||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||||
fireEvent.click(menuItem("files.actions.rename"));
|
fireEvent.click(menuItem("files.actions.rename"));
|
||||||
@@ -239,6 +261,7 @@ describe("Files · my file's row", () => {
|
|||||||
fireEvent.keyDown(field, { key: "Escape" });
|
fireEvent.keyDown(field, { key: "Escape" });
|
||||||
expect(screen.queryByRole("textbox")).toBeNull();
|
expect(screen.queryByRole("textbox")).toBeNull();
|
||||||
expect(update).not.toHaveBeenCalled();
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
await waitFor(() => expect(rowOf("Clip 2").contains(document.activeElement)).toBe(true));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves to an own folder, and from there back to its source folder only", async () => {
|
it("moves to an own folder, and from there back to its source folder only", async () => {
|
||||||
@@ -395,3 +418,276 @@ describe("Files · the view", () => {
|
|||||||
expect(screen.queryByText("Harmadik")).toBeNull();
|
expect(screen.queryByText("Harmadik")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Files · the grid", () => {
|
||||||
|
// The layout is a pref, saved to the server; nothing here is about that.
|
||||||
|
const quiet = () => vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
|
||||||
|
|
||||||
|
async function pick(layout: "grid" | "list") {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /files\.layout\.label/ }));
|
||||||
|
const options = await screen.findAllByRole("menuitemradio", { hidden: true });
|
||||||
|
fireEvent.click(options.find((o) => o.textContent === `files.layout.${layout}`)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("paints the same files as cards, each with the same menu as its row", async () => {
|
||||||
|
quiet();
|
||||||
|
mocks.files = [file(2, { kept: true }), file(3)];
|
||||||
|
mocks.folders = [YT, OWN];
|
||||||
|
mount();
|
||||||
|
expect(rowOf("Clip 2").tagName).toBe("TR");
|
||||||
|
await pick("grid");
|
||||||
|
await waitFor(() => expect(rowOf("Clip 2").tagName).toBe("DIV"));
|
||||||
|
// Kept says so on its card.
|
||||||
|
expect(rowOf("Clip 2").textContent).toContain("files.retention.kept");
|
||||||
|
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||||
|
expect(menuRows()).toContain("files.actions.move");
|
||||||
|
expect(menuRows()).toContain("downloads.actions.delete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is remembered per folder", async () => {
|
||||||
|
quiet();
|
||||||
|
mocks.files = [file(2, { folder_id: 2 })];
|
||||||
|
mocks.folders = [YT, OWN];
|
||||||
|
// One mount whose view the test turns, as the rail would.
|
||||||
|
let turn: (v: FilesView) => void = () => {};
|
||||||
|
function Harness() {
|
||||||
|
const [view, setView] = useState<FilesView>(ALL_FILES);
|
||||||
|
turn = setView;
|
||||||
|
return (
|
||||||
|
<FilesActionsContext.Provider
|
||||||
|
value={{ setSearch: vi.fn(), setView, openFile: vi.fn(), consumeFocus: vi.fn() }}
|
||||||
|
>
|
||||||
|
<FilesStateContext.Provider value={{ search: "", view, focus: null, arrived: null }}>
|
||||||
|
<Files />
|
||||||
|
</FilesStateContext.Provider>
|
||||||
|
</FilesActionsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<Harness />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await pick("grid");
|
||||||
|
await waitFor(() => expect(rowOf("Clip 2").tagName).toBe("DIV"));
|
||||||
|
act(() => turn({ kind: "folder", id: 2 }));
|
||||||
|
expect(rowOf("Clip 2").tagName).toBe("TR");
|
||||||
|
act(() => turn(ALL_FILES));
|
||||||
|
expect(rowOf("Clip 2").tagName).toBe("DIV");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Files · the open folder's menu", () => {
|
||||||
|
const folderMenu = () => screen.getByRole("button", { name: "files.folder.menu" });
|
||||||
|
|
||||||
|
it("an own folder is renamed, kept for a while, or deleted", async () => {
|
||||||
|
mocks.files = [];
|
||||||
|
mocks.folders = [YT, OWN];
|
||||||
|
mount({ kind: "folder", id: 2 });
|
||||||
|
await openMenu(() => fireEvent.click(folderMenu()));
|
||||||
|
expect(menuRows()).toEqual([
|
||||||
|
"files.folder.rename",
|
||||||
|
"files.folder.retention",
|
||||||
|
"files.folder.delete",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a source folder only keeps its files for a while", async () => {
|
||||||
|
mocks.files = [];
|
||||||
|
mocks.folders = [YT, OWN];
|
||||||
|
mount({ kind: "folder", id: 1 });
|
||||||
|
await openMenu(() => fireEvent.click(folderMenu()));
|
||||||
|
expect(menuRows()).toEqual(["files.folder.retention"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets the retention, its current value ticked — an odd one in its place", async () => {
|
||||||
|
mocks.files = [];
|
||||||
|
mocks.folders = [folder(1, { source_key: "youtube", retention_days: 14 })];
|
||||||
|
const update = vi.spyOn(api, "updateFolder").mockResolvedValue({} as never);
|
||||||
|
mount({ kind: "folder", id: 1 });
|
||||||
|
await openMenu(() => fireEvent.click(folderMenu()));
|
||||||
|
fireEvent.keyDown(menuItem("files.folder.retention"), { key: "ArrowRight" });
|
||||||
|
await waitFor(() => expect(menuRows()).toContain("files.folder.forever"));
|
||||||
|
const choices = screen.queryAllByRole("menuitemradio", { hidden: true });
|
||||||
|
// 7, 14 (its own, ticked), 30, 90, forever.
|
||||||
|
expect(choices.map((c) => c.getAttribute("aria-checked"))).toEqual([
|
||||||
|
"false",
|
||||||
|
"true",
|
||||||
|
"false",
|
||||||
|
"false",
|
||||||
|
"false",
|
||||||
|
]);
|
||||||
|
fireEvent.click(menuItem("files.folder.forever"));
|
||||||
|
await waitFor(() => expect(update).toHaveBeenCalledWith(1, { retention_days: null }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames in the title: Enter saves, an unchanged name sends nothing", async () => {
|
||||||
|
mocks.files = [];
|
||||||
|
mocks.folders = [YT, OWN];
|
||||||
|
const update = vi.spyOn(api, "updateFolder").mockResolvedValue({} as never);
|
||||||
|
mount({ kind: "folder", id: 2 });
|
||||||
|
await openMenu(() => fireEvent.click(folderMenu()));
|
||||||
|
fireEvent.click(menuItem("files.folder.rename"));
|
||||||
|
const field = await screen.findByRole("textbox", { name: "files.folder.rename" });
|
||||||
|
fireEvent.keyDown(field, { key: "Enter" });
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
// The keyboard goes back to the "…" the rename was opened from.
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(folderMenu()));
|
||||||
|
await openMenu(() => fireEvent.click(folderMenu()));
|
||||||
|
fireEvent.click(menuItem("files.folder.rename"));
|
||||||
|
const again = await screen.findByRole("textbox", { name: "files.folder.rename" });
|
||||||
|
fireEvent.change(again, { target: { value: " Receptek " } });
|
||||||
|
fireEvent.keyDown(again, { key: "Enter" });
|
||||||
|
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Receptek" }));
|
||||||
|
// Only the changed name was sent: the first, unchanged Enter sent nothing.
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[3, "files.folder.deleteBody"],
|
||||||
|
[0, "files.folder.deleteBodyEmpty"],
|
||||||
|
])("deletes only through a confirm that says where its %i files go", async (count, body) => {
|
||||||
|
mocks.files = [];
|
||||||
|
mocks.folders = [YT, { ...OWN, count }];
|
||||||
|
const del = vi.spyOn(api, "deleteFolder").mockResolvedValue({} as never);
|
||||||
|
mocks.confirm.mockResolvedValueOnce(false);
|
||||||
|
mount({ kind: "folder", id: 2 });
|
||||||
|
await openMenu(() => fireEvent.click(folderMenu()));
|
||||||
|
fireEvent.click(menuItem("files.folder.delete"));
|
||||||
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||||
|
expect(del).not.toHaveBeenCalled();
|
||||||
|
const opts = mocks.confirm.mock.calls[0]![0] as Record<string, unknown>;
|
||||||
|
expect([opts.title, opts.message, opts.confirmLabel, opts.danger]).toEqual([
|
||||||
|
"files.folder.deleteTitle",
|
||||||
|
body,
|
||||||
|
"files.folder.deleteAction",
|
||||||
|
true,
|
||||||
|
]);
|
||||||
|
await openMenu(() => fireEvent.click(folderMenu()));
|
||||||
|
fireEvent.click(menuItem("files.folder.delete"));
|
||||||
|
await waitFor(() => expect(del).toHaveBeenCalledWith(2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Files · arriving at one file", () => {
|
||||||
|
const many = () => Array.from({ length: 60 }, (_, i) => file(i + 1));
|
||||||
|
|
||||||
|
// FilesProvider's arrival half: a pending focus, spent by the page into what it found.
|
||||||
|
function arriveAt(id: number, startView: FilesView = ALL_FILES) {
|
||||||
|
const seen = { setView: vi.fn(), consumed: [] as (string | null)[] };
|
||||||
|
function Harness() {
|
||||||
|
const [view, setViewState] = useState<FilesView>(startView);
|
||||||
|
const [focus, setFocus] = useState<FilesFocus | null>({ id, token: 1 });
|
||||||
|
const [arrived, setArrived] = useState<(FilesFocus & { view: string }) | null>(null);
|
||||||
|
const actions = {
|
||||||
|
setSearch: vi.fn(),
|
||||||
|
setView: (v: FilesView) => {
|
||||||
|
seen.setView(v);
|
||||||
|
setViewState(v);
|
||||||
|
},
|
||||||
|
openFile: vi.fn(),
|
||||||
|
consumeFocus: (at: string | null) => {
|
||||||
|
seen.consumed.push(at);
|
||||||
|
setArrived(focus && at !== null ? { ...focus, view: at } : null);
|
||||||
|
setFocus(null);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<FilesActionsContext.Provider value={actions}>
|
||||||
|
<FilesStateContext.Provider value={{ search: "", view, focus, arrived }}>
|
||||||
|
<Files />
|
||||||
|
</FilesStateContext.Provider>
|
||||||
|
</FilesActionsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<Harness />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("turns to the page that holds it, rings it and puts the keyboard on it", async () => {
|
||||||
|
mocks.files = many();
|
||||||
|
mocks.folders = [YT];
|
||||||
|
const seen = arriveAt(55);
|
||||||
|
// Page two of fifty: without the arrival it would not be on screen at all.
|
||||||
|
await waitFor(() => expect(screen.queryByText("Clip 55")).not.toBeNull());
|
||||||
|
expect(seen.consumed).toEqual(["all"]);
|
||||||
|
expect(rowOf("Clip 55").className).toContain("ring-accent");
|
||||||
|
expect(rowOf("Clip 54").className).not.toContain("ring-accent");
|
||||||
|
await waitFor(() => expect(rowOf("Clip 55").contains(document.activeElement)).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds it among every file when the folder on screen does not hold it", async () => {
|
||||||
|
mocks.files = [file(2)];
|
||||||
|
mocks.folders = [YT, OWN];
|
||||||
|
const seen = arriveAt(2, { kind: "folder", id: 2 });
|
||||||
|
// The mock answers every view with the same rows, so "not in this folder" is staged by the
|
||||||
|
// folder's read: it is the all-files read that holds the file.
|
||||||
|
expect(seen.consumed).toEqual(["2"]);
|
||||||
|
expect(seen.setView).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spends an arrival at a file that is nowhere, ringing nothing", async () => {
|
||||||
|
mocks.files = [file(2)];
|
||||||
|
mocks.folders = [YT];
|
||||||
|
const seen = arriveAt(9);
|
||||||
|
await waitFor(() => expect(seen.consumed).toEqual([null]));
|
||||||
|
expect(seen.setView).not.toHaveBeenCalled();
|
||||||
|
expect(rowOf("Clip 2").className).not.toContain("ring-accent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits while the list re-reads: a file just finished is not in the cached rows yet", async () => {
|
||||||
|
mocks.files = [file(2)];
|
||||||
|
mocks.folders = [YT];
|
||||||
|
mocks.fetching = true;
|
||||||
|
const seen = arriveAt(9);
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(seen.consumed).toEqual([]);
|
||||||
|
expect(seen.setView).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("looks in every file when this folder has no such file", async () => {
|
||||||
|
mocks.files = [file(2)];
|
||||||
|
mocks.folders = [YT, OWN];
|
||||||
|
const seen = arriveAt(9, { kind: "folder", id: 2 });
|
||||||
|
await waitFor(() => expect(seen.setView).toHaveBeenCalledWith(ALL_FILES));
|
||||||
|
// Then, among every file, it is nowhere either: spent.
|
||||||
|
await waitFor(() => expect(seen.consumed).toEqual([null]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Files · dropping a file on the page", () => {
|
||||||
|
const drag = (type: string, files: File[] = []) => {
|
||||||
|
const e = new Event(type, { bubbles: true, cancelable: true });
|
||||||
|
const list = Object.assign([...files], { item: (i: number) => files[i] ?? null });
|
||||||
|
Object.defineProperty(e, "dataTransfer", { value: { types: ["Files"], files: list } });
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(e);
|
||||||
|
});
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("names the folder it lands in, and opens Upload with the file", async () => {
|
||||||
|
mocks.files = [];
|
||||||
|
mocks.folders = [YT, OWN];
|
||||||
|
vi.spyOn(api, "filesUsage").mockResolvedValue({ upload_max_bytes: 1_000_000 } as never);
|
||||||
|
mount({ kind: "folder", id: 2 });
|
||||||
|
// preventDefault is what makes the page a drop target at all.
|
||||||
|
expect(drag("dragenter").defaultPrevented).toBe(true);
|
||||||
|
expect(screen.getByText("files.drop.here:Cimboráknak")).toBeTruthy();
|
||||||
|
drag("drop", [new File(["x"], "recept.pdf")]);
|
||||||
|
expect(screen.queryByText("files.drop.here:Cimboráknak")).toBeNull();
|
||||||
|
expect(await screen.findByText("recept.pdf")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is no target where nothing can be uploaded", () => {
|
||||||
|
mocks.files = [];
|
||||||
|
mocks.folders = [YT];
|
||||||
|
mount({ kind: "shared" });
|
||||||
|
expect(drag("dragenter").defaultPrevented).toBe(false);
|
||||||
|
expect(screen.queryByText(/files\.drop\.here/)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+313
-138
@@ -1,36 +1,40 @@
|
|||||||
import { lazy, Suspense, useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useEffectEvent, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Download, LayoutGrid, Link, List, Scissors, Share2, UploadCloud } from "lucide-react";
|
||||||
Clock,
|
|
||||||
Download,
|
|
||||||
Link,
|
|
||||||
Pin,
|
|
||||||
Scissors,
|
|
||||||
Share2,
|
|
||||||
Smartphone,
|
|
||||||
UploadCloud,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { api, type FileFolder, type FileListParams, type LibraryFile } from "../lib/api";
|
import { api, type FileFolder, type FileListParams, type LibraryFile } from "../lib/api";
|
||||||
|
import { type SortState, sortRows } from "../lib/columnSort";
|
||||||
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
||||||
import { formatBytes, formatDateTime, relativeTime } from "../lib/format";
|
import { formatBytes, formatDateTime, relativeTime } from "../lib/format";
|
||||||
|
import { focusNavItem } from "../lib/listNav";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { LS } from "../lib/storage";
|
import { LS, accountKey } from "../lib/storage";
|
||||||
|
import { useCardPager } from "../lib/useCardPager";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
|
import { usePref } from "../lib/usePref";
|
||||||
import { JobThumb } from "./downloadRow";
|
import { JobThumb } from "./downloadRow";
|
||||||
import { MetaEditModal } from "./downloadModals";
|
import { MetaEditModal } from "./downloadModals";
|
||||||
import { jobTitle } from "./downloadTitle";
|
import { jobTitle } from "./downloadTitle";
|
||||||
import { folderLabel, sourceIcon, sourceLabel } from "./fileFolders";
|
import { FileCard } from "./FileCard";
|
||||||
import { ALL_FILES, useFiles, useFilesActions, type FilesView } from "./filesContext";
|
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
|
||||||
|
import { folderLabel } from "./fileFolders";
|
||||||
|
import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
|
||||||
import { useFileActions } from "./useFileActions";
|
import { useFileActions } from "./useFileActions";
|
||||||
|
import { useFolderActions } from "./useFolderActions";
|
||||||
|
import { RenameField } from "./RenameField";
|
||||||
|
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||||
import { Badge } from "./ui/Badge";
|
import { Badge } from "./ui/Badge";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
|
import ColumnSortControl from "./ui/ColumnSortControl";
|
||||||
import { ContextMenu } from "./ui/ContextMenu";
|
import { ContextMenu } from "./ui/ContextMenu";
|
||||||
import DataTable, { type Column } from "./ui/DataTable";
|
import DataTable, { type Column } from "./ui/DataTable";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { Input } from "./ui/Input";
|
|
||||||
import { IconLink } from "./ui/LinkButton";
|
import { IconLink } from "./ui/LinkButton";
|
||||||
import { MoreButton } from "./ui/MoreButton";
|
import { MoreButton } from "./ui/MoreButton";
|
||||||
|
import NavGrid from "./ui/NavGrid";
|
||||||
|
import Overlay from "./ui/Overlay";
|
||||||
import { PageToolbar } from "./ui/PageShell";
|
import { PageToolbar } from "./ui/PageShell";
|
||||||
|
import Pager from "./ui/Pager";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
@@ -44,12 +48,41 @@ const VideoEditor = lazy(() => import("./VideoEditor"));
|
|||||||
// The Files library: every done file of mine, the ones shared with me, or one folder's (the rail
|
// 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
|
// 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
|
// 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").
|
// right click and on the "…" (DESIGN.md "Files page", "Library list columns"). The grid is the same
|
||||||
|
// rows' other paint — a toggle remembered per folder, sharing the table's sort.
|
||||||
|
//
|
||||||
|
// A link or a notice can open the page on ONE file (`openFile`, `?item=`): the page turns to it, puts
|
||||||
|
// the keyboard on it and rings it until the view changes. A file dropped anywhere on the page goes
|
||||||
|
// into the Upload dialog, bound for the folder on screen.
|
||||||
|
|
||||||
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;
|
const fileKey = (f: LibraryFile) => f.id;
|
||||||
|
const folderKey = (f: FileFolder) => f.id;
|
||||||
|
|
||||||
|
type FilesLayout = "list" | "grid";
|
||||||
|
const LAYOUTS: readonly FilesLayout[] = ["list", "grid"];
|
||||||
|
const LAYOUT_ICON = { list: List, grid: LayoutGrid } as const;
|
||||||
|
|
||||||
|
/** A stored per-view layout map: the pref round-trips the server, so it is checked, not trusted. */
|
||||||
|
function parseLayouts(raw: unknown): Record<string, FilesLayout> {
|
||||||
|
if (!raw || typeof raw !== "object") return {};
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(raw).filter((e): e is [string, FilesLayout] => LAYOUTS.includes(e[1])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put the keyboard on a file's row or card (lib/listNav), whichever paint is on screen. */
|
||||||
|
function focusFile(within: HTMLElement | null, id: number): void {
|
||||||
|
const item = within && shownFileElement(within, id)?.closest<HTMLElement>("[data-nav-index]");
|
||||||
|
if (within && item) focusNavItem(within, Number(item.getAttribute("data-nav-index")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The element a file is painted as on screen — its table row, the row's narrow card twin, or its
|
||||||
|
* grid card, whichever is shown (DataTable keeps both row shapes mounted). */
|
||||||
|
function shownFileElement(within: HTMLElement, id: number): HTMLElement | null {
|
||||||
|
const all = [...within.querySelectorAll<HTMLElement>(`[data-file-id="${id}"]`)];
|
||||||
|
// Nothing laid out at all (no layout engine, as under test): the first is the only one there is.
|
||||||
|
return all.find((el) => el.getClientRects().length > 0) ?? all[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** The list a view reads. The search is applied here, on the rows (the list is the whole library,
|
/** The list a view reads. 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. */
|
* capped server-side): a request per keystroke would buy nothing the rows do not already hold. */
|
||||||
@@ -73,39 +106,6 @@ function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number |
|
|||||||
return f && (f.kind === "own" || f.source_key === "upload") ? f.id : null;
|
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({
|
function NameCell({
|
||||||
file,
|
file,
|
||||||
renaming,
|
renaming,
|
||||||
@@ -115,36 +115,14 @@ function NameCell({
|
|||||||
file: LibraryFile;
|
file: LibraryFile;
|
||||||
renaming: boolean;
|
renaming: boolean;
|
||||||
onPlay: (() => void) | null;
|
onPlay: (() => void) | null;
|
||||||
onRenamed: (name: string | null) => void;
|
onRenamed: (name: string | null, byKey: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [draft, setDraft] = useState(() => jobTitle(file));
|
|
||||||
const channel = file.display_uploader || file.asset?.uploader || null;
|
const channel = file.display_uploader || file.asset?.uploader || null;
|
||||||
const clip = file.job_kind === "edit" || file.job_kind === "clip";
|
const clip = file.job_kind === "edit" || file.job_kind === "clip";
|
||||||
if (renaming)
|
if (renaming)
|
||||||
return (
|
return (
|
||||||
<Input
|
<RenameField initial={jobTitle(file)} label={t("files.actions.rename")} onDone={onRenamed} />
|
||||||
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>;
|
const title = <span className="block truncate text-sm font-medium">{jobTitle(file)}</span>;
|
||||||
return (
|
return (
|
||||||
@@ -178,8 +156,8 @@ function NameCell({
|
|||||||
export default function Files() {
|
export default function Files() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const { search, view } = useFiles();
|
const { search, view, focus, arrived } = useFiles();
|
||||||
const { setView } = useFilesActions();
|
const { setView, consumeFocus } = useFilesActions();
|
||||||
|
|
||||||
const foldersQ = useLiveQuery(qk.fileFolders(), api.fileFolders, { intervalMs: 15000 });
|
const foldersQ = useLiveQuery(qk.fileFolders(), api.fileFolders, { intervalMs: 15000 });
|
||||||
const folders = foldersQ.data ?? [];
|
const folders = foldersQ.data ?? [];
|
||||||
@@ -198,8 +176,18 @@ export default function Files() {
|
|||||||
const q = search.trim();
|
const q = search.trim();
|
||||||
const rows = all.filter((f) => matches(f, q, lang));
|
const rows = all.filter((f) => matches(f, q, lang));
|
||||||
|
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const [renamingId, setRenamingId] = useState<number | null>(null);
|
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||||
const [upload, setUpload] = useState(false);
|
// A rename left by Enter or Escape hands the keyboard back to its row (RenameField).
|
||||||
|
const refocusFile = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = refocusFile.current;
|
||||||
|
if (renamingId !== null || id === null) return;
|
||||||
|
refocusFile.current = null;
|
||||||
|
focusFile(listRef.current, id);
|
||||||
|
}, [renamingId]);
|
||||||
|
// The Upload dialog while open, with the file a drop on the page brought (null: pick one in it).
|
||||||
|
const [upload, setUpload] = useState<{ file: File | null } | null>(null);
|
||||||
const [metaFile, setMetaFile] = useState<LibraryFile | null>(null);
|
const [metaFile, setMetaFile] = useState<LibraryFile | null>(null);
|
||||||
const [shareFile, setShareFile] = useState<LibraryFile | null>(null);
|
const [shareFile, setShareFile] = useState<LibraryFile | null>(null);
|
||||||
const [editFile, setEditFile] = useState<LibraryFile | null>(null);
|
const [editFile, setEditFile] = useState<LibraryFile | null>(null);
|
||||||
@@ -220,6 +208,19 @@ export default function Files() {
|
|||||||
const menuItems = rowMenu.target ? actions.items(rowMenu.target) : [];
|
const menuItems = rowMenu.target ? actions.items(rowMenu.target) : [];
|
||||||
|
|
||||||
const open = shown.kind === "folder" ? folders.find((f) => f.id === shown.id) : undefined;
|
const open = shown.kind === "folder" ? folders.find((f) => f.id === shown.id) : undefined;
|
||||||
|
// The open folder's menu, on the "…" beside its name — the one its rail entry's right click opens.
|
||||||
|
const [renamingFolder, setRenamingFolder] = useState(false);
|
||||||
|
const folderActions = useFolderActions({ onRename: () => setRenamingFolder(true) });
|
||||||
|
const folderMenu = useContextMenu<FileFolder>({ keyOf: folderKey });
|
||||||
|
// Its rename left by a key hands the keyboard back to the "…" it was opened from.
|
||||||
|
const folderMore = useRef<HTMLButtonElement>(null);
|
||||||
|
const refocusFolderMore = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (renamingFolder || !refocusFolderMore.current) return;
|
||||||
|
refocusFolderMore.current = false;
|
||||||
|
folderMore.current?.focus();
|
||||||
|
}, [renamingFolder]);
|
||||||
|
|
||||||
// A folder view names its folder once the folder list is in (blank until then, never a guess).
|
// A folder view names its folder once the folder list is in (blank until then, never a guess).
|
||||||
const title =
|
const title =
|
||||||
shown.kind === "shared"
|
shown.kind === "shared"
|
||||||
@@ -230,8 +231,85 @@ export default function Files() {
|
|||||||
: ""
|
: ""
|
||||||
: t("files.rail.all");
|
: t("files.rail.all");
|
||||||
const canUpload = shown.kind !== "shared";
|
const canUpload = shown.kind !== "shared";
|
||||||
|
const uploadTarget = uploadFolder(shown, folders);
|
||||||
|
const uploadTargetName =
|
||||||
|
uploadTarget !== null && open ? folderLabel(open, t) : t("files.source.upload");
|
||||||
|
|
||||||
|
// List or grid, per view (DESIGN.md "Files page": the grid a toggle remembered per folder).
|
||||||
|
const [layouts, setLayouts] = usePref<Record<string, FilesLayout>>(
|
||||||
|
"filesLayouts",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
localKey: LS.filesLayouts,
|
||||||
|
parse: parseLayouts,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const viewKey = viewParam(shown);
|
||||||
|
const layout: FilesLayout = layouts[viewKey] ?? "list";
|
||||||
|
const isGrid = layout === "grid";
|
||||||
|
// One sort for both paints: the table's headers and the grid's dropdown move the same state.
|
||||||
|
const [sort, setSort] = usePref<SortState>("filesSort", null, { localKey: LS.filesSort });
|
||||||
|
|
||||||
|
// ── Arrival at one file ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// A pending arrival (`focus`) is looked for once the list is in: found here, it becomes `arrived`
|
||||||
|
// (FilesProvider), which turns the table's or the grid's page to the file (`reveal`), puts the
|
||||||
|
// keyboard on it and rings it while this view is open. Not in this view, it is looked for among
|
||||||
|
// every file; nowhere (gone since the link was made), it leaves nothing to arrive at, and is spent.
|
||||||
|
const here = !!focus && !!filesQ.data?.some((f) => f.id === focus.id);
|
||||||
|
// "Not here" only once the list is fresh: a just-finished upload is not in the cached rows yet.
|
||||||
|
const missing = !!focus && !!filesQ.data && !here && !filesQ.isFetching;
|
||||||
|
const lookEverywhere = missing && shown.kind !== "all";
|
||||||
|
useEffect(() => {
|
||||||
|
if (lookEverywhere) setView(ALL_FILES);
|
||||||
|
else if (here) consumeFocus(viewKey);
|
||||||
|
else if (missing) consumeFocus(null);
|
||||||
|
}, [here, missing, lookEverywhere, viewKey, setView, consumeFocus]);
|
||||||
|
const arrival = arrived && arrived.view === viewKey ? arrived : null;
|
||||||
|
const ringed = arrival?.id ?? null;
|
||||||
|
const reveal = arrival && { key: String(arrival.id), seq: arrival.token };
|
||||||
|
// The keyboard half, once the page that holds the file has painted.
|
||||||
|
const focusArrived = useEffectEvent(() => {
|
||||||
|
if (ringed !== null) focusFile(listRef.current, ringed);
|
||||||
|
});
|
||||||
|
// Once per arrival (its token), not on every list re-read or layout switch.
|
||||||
|
const arrivalToken = arrival?.token;
|
||||||
|
useEffect(() => {
|
||||||
|
if (arrivalToken !== undefined) focusArrived();
|
||||||
|
}, [arrivalToken]);
|
||||||
|
|
||||||
|
// ── A file dropped anywhere on the page ─────────────────────────────────────────────────────────
|
||||||
|
// The page is the target, not a box on it: a short folder leaves most of the screen empty. Window
|
||||||
|
// listeners, since the drag starts outside the app. While the dialog is open its own zone takes the
|
||||||
|
// drop; a drag that carries no files (a link, selected text) is not ours.
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const dragDepth = useRef(0);
|
||||||
|
const dropReady = canUpload && upload === null;
|
||||||
|
const onDrag = useEffectEvent((e: DragEvent) => {
|
||||||
|
if (!dropReady || !e.dataTransfer?.types.includes("Files")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.type === "dragenter") dragDepth.current += 1;
|
||||||
|
if (e.type === "dragleave") dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||||
|
if (e.type === "drop") {
|
||||||
|
dragDepth.current = 0;
|
||||||
|
const dropped = e.dataTransfer.files;
|
||||||
|
const first = dropped.item(0);
|
||||||
|
if (first) setUpload({ file: first });
|
||||||
|
if (dropped.length > 1) notify({ level: "info", message: t("files.drop.oneAtATime") });
|
||||||
|
}
|
||||||
|
setDragging(dragDepth.current > 0);
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
const on = (e: DragEvent) => onDrag(e);
|
||||||
|
const kinds = ["dragenter", "dragover", "dragleave", "drop"] as const;
|
||||||
|
for (const k of kinds) window.addEventListener(k, on);
|
||||||
|
return () => {
|
||||||
|
for (const k of kinds) window.removeEventListener(k, on);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const iconSlot = <span className="size-9 shrink-0" aria-hidden />;
|
const iconSlot = <span className="size-9 shrink-0" aria-hidden />;
|
||||||
|
const playOf = (f: LibraryFile) =>
|
||||||
|
(f.shared ? sharedRowActions(f) : libraryRowActions(f)).play ? () => play(f) : null;
|
||||||
const columns: Column<LibraryFile>[] = [
|
const columns: Column<LibraryFile>[] = [
|
||||||
{
|
{
|
||||||
key: "thumb",
|
key: "thumb",
|
||||||
@@ -254,52 +332,37 @@ export default function Files() {
|
|||||||
cardPrimary: true,
|
cardPrimary: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (f) => jobTitle(f),
|
sortValue: (f) => jobTitle(f),
|
||||||
render: (f) => {
|
render: (f) => (
|
||||||
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
|
<NameCell
|
||||||
return (
|
key={renamingId === f.id ? "renaming" : "shown"}
|
||||||
<NameCell
|
file={f}
|
||||||
key={renamingId === f.id ? "renaming" : "shown"}
|
renaming={renamingId === f.id}
|
||||||
file={f}
|
onPlay={playOf(f)}
|
||||||
renaming={renamingId === f.id}
|
onRenamed={(name, byKey) => {
|
||||||
onPlay={a.play ? () => play(f) : null}
|
if (byKey) refocusFile.current = f.id;
|
||||||
onRenamed={(name) => {
|
setRenamingId(null);
|
||||||
setRenamingId(null);
|
if (name !== null) actions.rename(f, name);
|
||||||
if (name !== null) actions.rename(f, name);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "source",
|
key: "source",
|
||||||
header: t("files.columns.source"),
|
header: t("files.columns.source"),
|
||||||
width: "8rem",
|
width: "8rem",
|
||||||
tabletHidden: true,
|
tabletHidden: true,
|
||||||
render: (f) => {
|
render: (f) => <SourceBadge file={f} />,
|
||||||
const Icon = sourceIcon(f.source_key);
|
|
||||||
return (
|
|
||||||
<Badge>
|
|
||||||
<Icon />
|
|
||||||
{sourceLabel(f.source_key, t)}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "phone",
|
key: "phone",
|
||||||
header: t("files.columns.phone"),
|
header: t("files.columns.phone"),
|
||||||
width: "5.5rem",
|
width: "5.5rem",
|
||||||
narrowHidden: true,
|
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) =>
|
render: (f) =>
|
||||||
f.asset?.media_kind === "file" ? (
|
f.asset?.media_kind === "file" ? (
|
||||||
<span className="text-xs text-muted">—</span>
|
<span className="text-xs text-muted">—</span>
|
||||||
) : (
|
) : (
|
||||||
<Badge title={t("files.phoneOk")}>
|
<PhoneBadge file={f} />
|
||||||
<Smartphone />
|
|
||||||
<span className="text-success">✓</span>
|
|
||||||
</Badge>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -307,7 +370,7 @@ export default function Files() {
|
|||||||
header: t("files.columns.retention"),
|
header: t("files.columns.retention"),
|
||||||
width: "7.5rem",
|
width: "7.5rem",
|
||||||
narrowHidden: true,
|
narrowHidden: true,
|
||||||
render: (f) => <RetentionCell file={f} />,
|
render: (f) => <RetentionBadge file={f} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "size",
|
key: "size",
|
||||||
@@ -396,69 +459,166 @@ export default function Files() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// The grid sorts and pages here; the table does both itself (from the same sort).
|
||||||
|
const cards = isGrid ? sortRows(rows, columns, sort, lang) : rows;
|
||||||
|
const { paged, pagerProps } = useCardPager(
|
||||||
|
cards,
|
||||||
|
LS.filesCardSize,
|
||||||
|
isGrid,
|
||||||
|
reveal && { seq: reveal.seq, match: (f) => String(f.id) === reveal.key },
|
||||||
|
t("files.perPage"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const emptyText = q
|
||||||
|
? t("files.empty.search", { q })
|
||||||
|
: t(
|
||||||
|
shown.kind === "shared"
|
||||||
|
? "files.empty.shared"
|
||||||
|
: shown.kind === "folder"
|
||||||
|
? "files.empty.folder"
|
||||||
|
: "files.empty.all",
|
||||||
|
);
|
||||||
|
|
||||||
|
const grid =
|
||||||
|
cards.length === 0 ? (
|
||||||
|
<StateMessage>{emptyText}</StateMessage>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
|
||||||
|
{paged.map((f, i) => (
|
||||||
|
<FileCard
|
||||||
|
key={f.id}
|
||||||
|
file={f}
|
||||||
|
index={i}
|
||||||
|
onPlay={playOf(f)}
|
||||||
|
menuProps={rowMenu.triggerProps(f)}
|
||||||
|
moreProps={rowMenu.buttonProps(f)}
|
||||||
|
menuOpen={rowMenu.open && rowMenu.target?.id === f.id}
|
||||||
|
arrived={ringed === f.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</NavGrid>
|
||||||
|
<div className="flex justify-end pt-3">
|
||||||
|
<Pager {...pagerProps} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const body =
|
const body =
|
||||||
filesQ.isError && !filesQ.data ? (
|
filesQ.isError && !filesQ.data ? (
|
||||||
<StateMessage tone="error" onRetry={() => filesQ.refetch()}>
|
<StateMessage tone="error" onRetry={() => filesQ.refetch()}>
|
||||||
{t("common.loadError")}
|
{t("common.loadError")}
|
||||||
</StateMessage>
|
</StateMessage>
|
||||||
) : filesQ.isLoading ? (
|
) : filesQ.isLoading ? (
|
||||||
<LoadingState shape="list" />
|
<LoadingState shape={isGrid ? "grid" : "list"} />
|
||||||
|
) : isGrid ? (
|
||||||
|
grid
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(f) => String(f.id)}
|
rowKey={(f) => String(f.id)}
|
||||||
persistKey={LS.filesTable}
|
persistKey={accountKey(LS.filesTable) ?? undefined}
|
||||||
pageSize={50}
|
pageSize={50}
|
||||||
keyboardNav
|
keyboardNav
|
||||||
fixedLayout
|
fixedLayout
|
||||||
rowProps={(f) => rowMenu.triggerProps(f)}
|
sort={sort}
|
||||||
emptyText={
|
onSortChange={setSort}
|
||||||
q
|
reveal={reveal}
|
||||||
? t("files.empty.search", { q })
|
narrowedBy={q}
|
||||||
: t(
|
rowProps={(f) => ({ ...rowMenu.triggerProps(f), "data-file-id": f.id })}
|
||||||
shown.kind === "shared"
|
rowClassName={(f) =>
|
||||||
? "files.empty.shared"
|
ringed === f.id ? "bg-accent/8 ring-2 ring-inset ring-accent/60" : ""
|
||||||
: shown.kind === "folder"
|
|
||||||
? "files.empty.folder"
|
|
||||||
: "files.empty.all",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
emptyText={emptyText}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const layoutOptions: ViewOption<FilesLayout>[] = LAYOUTS.map((id) => ({
|
||||||
|
id,
|
||||||
|
label: t(`files.layout.${id}`),
|
||||||
|
icon: LAYOUT_ICON[id],
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageToolbar>
|
<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">
|
<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>
|
{renamingFolder && open?.kind === "own" ? (
|
||||||
|
<RenameField
|
||||||
|
initial={open.name ?? ""}
|
||||||
|
label={t("files.folder.rename")}
|
||||||
|
className="w-64 max-w-full"
|
||||||
|
onDone={(name, byKey) => {
|
||||||
|
refocusFolderMore.current = byKey;
|
||||||
|
setRenamingFolder(false);
|
||||||
|
if (name !== null) folderActions.rename(open, name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h2 className="min-w-0 truncate text-lg font-bold">{title}</h2>
|
||||||
|
)}
|
||||||
|
{open && !renamingFolder && (
|
||||||
|
<MoreButton
|
||||||
|
ref={folderMore}
|
||||||
|
label={t("files.folder.menu")}
|
||||||
|
size="sm"
|
||||||
|
expanded={folderMenu.open}
|
||||||
|
menuProps={folderMenu.buttonProps(open)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{filesQ.data && (
|
{filesQ.data && (
|
||||||
<span className="text-xs tabular-nums text-muted">
|
<span className="text-xs tabular-nums text-muted">
|
||||||
{t("files.count", { count: rows.length })}
|
{t("files.count", { count: rows.length })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{canUpload && (
|
{/* Wraps on a phone: sort, view and Upload together are wider than the column. */}
|
||||||
<Button
|
<span className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2">
|
||||||
variant="outline"
|
{isGrid && (
|
||||||
size="sm"
|
<ColumnSortControl
|
||||||
icon={<UploadCloud className="w-4 h-4" />}
|
columns={columns}
|
||||||
onClick={() => setUpload(true)}
|
sort={sort}
|
||||||
className="ml-auto shrink-0"
|
onChange={setSort}
|
||||||
>
|
label={t("files.sort.label")}
|
||||||
{t("files.actions.upload")}
|
defaultLabel={t("files.sort.default")}
|
||||||
</Button>
|
ascLabel={t("files.sort.asc")}
|
||||||
)}
|
descLabel={t("files.sort.desc")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ViewSwitcher
|
||||||
|
value={layout}
|
||||||
|
options={layoutOptions}
|
||||||
|
onChange={(next) => setLayouts({ ...layouts, [viewKey]: next })}
|
||||||
|
label={t("files.layout.label")}
|
||||||
|
/>
|
||||||
|
{canUpload && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon={<UploadCloud className="w-4 h-4" />}
|
||||||
|
onClick={() => setUpload({ file: null })}
|
||||||
|
>
|
||||||
|
{t("files.actions.upload")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
<div className="mx-auto w-full max-w-7xl px-4 pt-3 pb-4">
|
<div ref={listRef} className="mx-auto w-full max-w-7xl px-4 pt-3 pb-4">
|
||||||
{body}
|
{body}
|
||||||
<ContextMenu menu={rowMenu} ariaLabel={t("files.rowMenu")} items={menuItems} />
|
<ContextMenu menu={rowMenu} ariaLabel={t("files.rowMenu")} items={menuItems} />
|
||||||
|
<ContextMenu
|
||||||
|
menu={folderMenu}
|
||||||
|
ariaLabel={t("files.folder.menu")}
|
||||||
|
items={folderMenu.target ? folderActions.items(folderMenu.target) : []}
|
||||||
|
/>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{upload && (
|
{upload && (
|
||||||
<UploadDialog
|
<UploadDialog
|
||||||
folderId={uploadFolder(shown, folders)}
|
folderId={uploadTarget}
|
||||||
onClose={() => setUpload(false)}
|
initialFile={upload.file}
|
||||||
|
onClose={() => setUpload(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{metaFile && <MetaEditModal job={metaFile} onClose={() => setMetaFile(null)} />}
|
{metaFile && <MetaEditModal job={metaFile} onClose={() => setMetaFile(null)} />}
|
||||||
@@ -473,6 +633,21 @@ export default function Files() {
|
|||||||
{editFile && <VideoEditor job={editFile} onClose={() => setEditFile(null)} />}
|
{editFile && <VideoEditor job={editFile} onClose={() => setEditFile(null)} />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
{dragging && (
|
||||||
|
// Through <Overlay>: declared in the page, a `fixed` layer is trapped in its stacking
|
||||||
|
// context and the rail paints over it.
|
||||||
|
<Overlay>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none fixed inset-3 z-overlay grid place-items-center rounded-2xl border-2 border-dashed border-accent bg-accent/10 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 rounded-xl bg-card px-4 py-3 text-base font-semibold shadow-float">
|
||||||
|
<UploadCloud className="size-5 text-accent" />
|
||||||
|
{t("files.drop.here", { folder: uploadTargetName })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Overlay>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// FilesProvider's arrival at one file: `openFile` (a notice's "Open") and a `?item=` link both make a
|
||||||
|
// pending focus that the Files page spends once it has looked; what it found is held until the page
|
||||||
|
// is left. Real storage with the tab's account pinned; only the navigation is mocked, its page
|
||||||
|
// switchable.
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
const nav = vi.hoisted(() => ({ page: "files", setPage: vi.fn() }));
|
||||||
|
vi.mock("./navigationContext", () => ({
|
||||||
|
useNavigation: () => ({ page: nav.page }),
|
||||||
|
useNavigationActions: () => ({ setPage: nav.setPage }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { FilesProvider } from "./FilesProvider";
|
||||||
|
import { ALL_FILES, useFiles, useFilesActions } from "./filesContext";
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<FilesProvider>{children}</FilesProvider>
|
||||||
|
);
|
||||||
|
const render = () => renderHook(() => ({ ...useFiles(), ...useFilesActions() }), { wrapper });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.setItem("siftlode.activeAccount", "1");
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
nav.page = "files";
|
||||||
|
nav.setPage.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FilesProvider · arriving at one file", () => {
|
||||||
|
it("openFile opens its folder on the Files page, with no search hiding it", () => {
|
||||||
|
nav.page = "feed";
|
||||||
|
const { result, rerender } = render();
|
||||||
|
act(() => result.current.setSearch("valami"));
|
||||||
|
act(() => result.current.openFile(7, 3));
|
||||||
|
expect(nav.setPage).toHaveBeenCalledWith("files");
|
||||||
|
expect(result.current.view).toEqual({ kind: "folder", id: 3 });
|
||||||
|
expect(result.current.search).toBe("");
|
||||||
|
nav.page = "files";
|
||||||
|
rerender();
|
||||||
|
expect(result.current.focus).toMatchObject({ id: 7 });
|
||||||
|
// The address carries it until it is spent, so a reload mid-arrival still lands on the file.
|
||||||
|
expect(new URLSearchParams(window.location.search).get("item")).toBe("7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a file in no folder opens every file", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.openFile(7, null));
|
||||||
|
expect(result.current.view).toEqual(ALL_FILES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a ?item= link is an arrival from the start", () => {
|
||||||
|
window.history.replaceState(null, "", "/?item=12");
|
||||||
|
const { result } = render();
|
||||||
|
expect(result.current.focus).toMatchObject({ id: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spent, it is what the page found — until the page is left", () => {
|
||||||
|
const { result, rerender } = render();
|
||||||
|
act(() => result.current.openFile(7, null));
|
||||||
|
act(() => result.current.consumeFocus("all"));
|
||||||
|
expect(result.current.focus).toBeNull();
|
||||||
|
expect(result.current.arrived).toMatchObject({ id: 7, view: "all" });
|
||||||
|
expect(new URLSearchParams(window.location.search).get("item")).toBeNull();
|
||||||
|
nav.page = "feed";
|
||||||
|
rerender();
|
||||||
|
expect(result.current.arrived).toBeNull();
|
||||||
|
nav.page = "files";
|
||||||
|
rerender();
|
||||||
|
// Back on the page later: nothing is rung or turned to again.
|
||||||
|
expect(result.current.arrived).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an arrival found nowhere leaves nothing found", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.openFile(7, null));
|
||||||
|
act(() => result.current.consumeFocus(null));
|
||||||
|
expect(result.current.focus).toBeNull();
|
||||||
|
expect(result.current.arrived).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the same file opened again is a new arrival", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.openFile(7, null));
|
||||||
|
act(() => result.current.consumeFocus("all"));
|
||||||
|
act(() => result.current.openFile(7, null));
|
||||||
|
expect(result.current.focus).toMatchObject({ id: 7 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||||
import { LS, getAccountRaw, setAccountRaw } from "../lib/storage";
|
import { LS, getAccountRaw, setAccountRaw } from "../lib/storage";
|
||||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
import { useNavigation } from "./navigationContext";
|
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||||
import {
|
import {
|
||||||
ALL_FILES,
|
ALL_FILES,
|
||||||
FilesActionsContext,
|
FilesActionsContext,
|
||||||
FilesStateContext,
|
FilesStateContext,
|
||||||
|
parseItem,
|
||||||
parseView,
|
parseView,
|
||||||
viewParam,
|
viewParam,
|
||||||
type FilesActions,
|
type FilesActions,
|
||||||
|
type FilesArrival,
|
||||||
|
type FilesFocus,
|
||||||
type FilesState,
|
type FilesState,
|
||||||
type FilesView,
|
type FilesView,
|
||||||
} from "./filesContext";
|
} from "./filesContext";
|
||||||
@@ -22,6 +25,11 @@ import {
|
|||||||
// account and mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder.
|
// 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
|
// 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).
|
// page's to fall back from (it knows the folder list).
|
||||||
|
//
|
||||||
|
// An arrival at one file (`?item=`, or `openFile` from a notice) is an INTENT the page consumes once it
|
||||||
|
// has the file in view, the way the channel manager's focusChannel is: the consumed marker lives here,
|
||||||
|
// because the page chunk remounts on every navigation and would otherwise re-apply it. Until then the
|
||||||
|
// address carries it, so a reload mid-arrival still lands on the file.
|
||||||
|
|
||||||
function initialView(): FilesView {
|
function initialView(): FilesView {
|
||||||
return (
|
return (
|
||||||
@@ -31,9 +39,16 @@ function initialView(): FilesView {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initialFocus(): FilesFocus | null {
|
||||||
|
const id = parseItem(new URLSearchParams(window.location.search).get("item"));
|
||||||
|
return id === null ? null : { id, token: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
export function FilesProvider({ children }: { children: ReactNode }) {
|
export function FilesProvider({ children }: { children: ReactNode }) {
|
||||||
const [search, setSearchState] = useState("");
|
const [search, setSearchState] = useState("");
|
||||||
const [view, setViewState] = useState<FilesView>(initialView);
|
const [view, setViewState] = useState<FilesView>(initialView);
|
||||||
|
const [focusRaw, setFocusRaw] = useState<FilesFocus | null>(initialFocus);
|
||||||
|
const [focusApplied, setFocusApplied] = useState(0);
|
||||||
|
|
||||||
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||||
const setView = useCallback((next: FilesView) => {
|
const setView = useCallback((next: FilesView) => {
|
||||||
@@ -42,10 +57,48 @@ export function FilesProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { page } = useNavigation();
|
const { page } = useNavigation();
|
||||||
useUrlMirror({ folder: page === "files" ? viewParam(view) : null });
|
const { setPage } = useNavigationActions();
|
||||||
|
const openFile = useCallback(
|
||||||
|
(id: number, folderId: number | null) => {
|
||||||
|
setView(folderId === null ? ALL_FILES : { kind: "folder", id: folderId });
|
||||||
|
// A search that hides the file would leave nothing to arrive at.
|
||||||
|
setSearchState("");
|
||||||
|
setFocusRaw((prev) => ({ id, token: (prev?.token ?? 0) + 1 }));
|
||||||
|
setPage("files");
|
||||||
|
},
|
||||||
|
[setView, setPage],
|
||||||
|
);
|
||||||
|
// What the page found. Held HERE, and set only once the page has mounted and looked: the table and
|
||||||
|
// the grid follow a reveal that CHANGES under them, never one they mount with (a remount must not
|
||||||
|
// move a page nobody asked to move), so an arrival decided during the page's first render would be
|
||||||
|
// one they never turn to.
|
||||||
|
const [arrivedRaw, setArrivedRaw] = useState<FilesArrival | null>(null);
|
||||||
|
const consumeFocus = useCallback(
|
||||||
|
(at: string | null) => {
|
||||||
|
setFocusApplied(focusRaw?.token ?? 0);
|
||||||
|
setArrivedRaw(focusRaw && at !== null ? { ...focusRaw, view: at } : null);
|
||||||
|
},
|
||||||
|
[focusRaw],
|
||||||
|
);
|
||||||
|
// Only ON the Files page, and only until the page has used it.
|
||||||
|
const focus = page === "files" && focusRaw && focusRaw.token !== focusApplied ? focusRaw : null;
|
||||||
|
// Leaving the page ends the arrival: back on it later, nothing is rung or turned to.
|
||||||
|
if (page !== "files" && arrivedRaw) setArrivedRaw(null);
|
||||||
|
const arrived = page === "files" ? arrivedRaw : null;
|
||||||
|
|
||||||
const state = useMemo<FilesState>(() => ({ search, view }), [search, view]);
|
useUrlMirror({
|
||||||
const actions = useMemo<FilesActions>(() => ({ setSearch, setView }), [setSearch, setView]);
|
folder: page === "files" ? viewParam(view) : null,
|
||||||
|
item: focus ? String(focus.id) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useMemo<FilesState>(
|
||||||
|
() => ({ search, view, focus, arrived }),
|
||||||
|
[search, view, focus, arrived],
|
||||||
|
);
|
||||||
|
const actions = useMemo<FilesActions>(
|
||||||
|
() => ({ setSearch, setView, openFile, consumeFocus }),
|
||||||
|
[setSearch, setView, openFile, consumeFocus],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilesActionsContext.Provider value={actions}>
|
<FilesActionsContext.Provider value={actions}>
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The Files rail's folder management: a new folder is named in place, a folder's right click opens its
|
||||||
|
// ONE menu (the page's "…" opens the same — Files.menu.dom.test), and a rename happens in the entry.
|
||||||
|
// A field left by Enter or Escape hands the keyboard back to what stands there now, never to <body>.
|
||||||
|
// The SidePanel chrome around the groups is stubbed; the writes are watched at the API.
|
||||||
|
import { afterEach, beforeAll, 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 type { ReactNode } from "react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
|
||||||
|
});
|
||||||
|
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => false }));
|
||||||
|
vi.mock("./FilterRail", () => ({
|
||||||
|
default: ({
|
||||||
|
items,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
items: { id: string; body: ReactNode }[];
|
||||||
|
children: ReactNode;
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
{items.map((i) => (
|
||||||
|
<div key={i.id}>{i.body}</div>
|
||||||
|
))}
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import FilesRail from "./FilesRail";
|
||||||
|
import { api, type FileFolder } from "../lib/api";
|
||||||
|
import { defaultLayout } from "../lib/panelLayout";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
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: "Receptek", retention_days: null });
|
||||||
|
|
||||||
|
async function mount(folders: FileFolder[] = [YT, OWN]) {
|
||||||
|
vi.spyOn(api, "fileFolders").mockResolvedValue(folders);
|
||||||
|
vi.spyOn(api, "filesUsage").mockResolvedValue({ unlimited: true, footprint_bytes: 0 } as never);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<FilesRail
|
||||||
|
layout={defaultLayout("files")}
|
||||||
|
setLayout={vi.fn()}
|
||||||
|
collapsed={false}
|
||||||
|
onToggleCollapse={vi.fn()}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByText("files.folder.new");
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = (name: string) => screen.getByText(name).closest<HTMLElement>("button")!;
|
||||||
|
const menuRows = () =>
|
||||||
|
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
|
||||||
|
|
||||||
|
describe("FilesRail · folders", () => {
|
||||||
|
it("names a new folder in place; Enter makes it and gives the keyboard back", async () => {
|
||||||
|
await mount();
|
||||||
|
const create = vi.spyOn(api, "createFolder").mockResolvedValue(folder(3, { kind: "own" }));
|
||||||
|
fireEvent.click(screen.getByText("files.folder.new"));
|
||||||
|
const field = screen.getByRole("textbox", { name: "files.folder.newName" });
|
||||||
|
fireEvent.change(field, { target: { value: " Cimboráknak " } });
|
||||||
|
fireEvent.keyDown(field, { key: "Enter" });
|
||||||
|
await waitFor(() => expect(create).toHaveBeenCalledWith("Cimboráknak"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.activeElement).toBe(screen.getByText("files.folder.new").closest("button")),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape makes nothing; an empty name makes nothing", async () => {
|
||||||
|
await mount();
|
||||||
|
const create = vi.spyOn(api, "createFolder");
|
||||||
|
fireEvent.click(screen.getByText("files.folder.new"));
|
||||||
|
const field = screen.getByRole("textbox", { name: "files.folder.newName" });
|
||||||
|
fireEvent.change(field, { target: { value: "Más" } });
|
||||||
|
fireEvent.keyDown(field, { key: "Escape" });
|
||||||
|
expect(screen.queryByRole("textbox")).toBeNull();
|
||||||
|
fireEvent.click(screen.getByText("files.folder.new"));
|
||||||
|
fireEvent.keyDown(screen.getByRole("textbox", { name: "files.folder.newName" }), {
|
||||||
|
key: "Enter",
|
||||||
|
});
|
||||||
|
await act(async () => {});
|
||||||
|
expect(create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a folder's right click opens its menu; a source folder's holds only its retention", async () => {
|
||||||
|
await mount();
|
||||||
|
act(() => {
|
||||||
|
fireEvent.contextMenu(entry("Receptek"));
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(menuRows()).toEqual([
|
||||||
|
"files.folder.rename",
|
||||||
|
"files.folder.retention",
|
||||||
|
"files.folder.delete",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||||
|
act(() => {
|
||||||
|
fireEvent.contextMenu(entry("files.source.youtube"));
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(menuRows()).toEqual(["files.folder.retention"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames in the entry, and Enter hands the keyboard back to it", async () => {
|
||||||
|
await mount();
|
||||||
|
const update = vi.spyOn(api, "updateFolder").mockResolvedValue(OWN);
|
||||||
|
act(() => {
|
||||||
|
fireEvent.contextMenu(entry("Receptek"));
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(menuRows()).toContain("files.folder.rename"));
|
||||||
|
fireEvent.click(
|
||||||
|
screen
|
||||||
|
.getAllByRole("menuitem", { hidden: true })
|
||||||
|
.find((b) => b.textContent === "files.folder.rename")!,
|
||||||
|
);
|
||||||
|
const field = await screen.findByRole("textbox", { name: "files.folder.rename" });
|
||||||
|
fireEvent.change(field, { target: { value: "Ételek" } });
|
||||||
|
fireEvent.keyDown(field, { key: "Enter" });
|
||||||
|
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Ételek" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(entry("Receptek")));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { FolderOpen, HardDrive, Users } from "lucide-react";
|
import { FolderOpen, FolderPlus, HardDrive, Users } from "lucide-react";
|
||||||
import { api, type FileFolder } from "../lib/api";
|
import { api, type FileFolder } from "../lib/api";
|
||||||
import { compareText } from "../lib/collate";
|
import { compareText } from "../lib/collate";
|
||||||
import { formatBytes } from "../lib/format";
|
import { formatBytes } from "../lib/format";
|
||||||
@@ -11,30 +12,46 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
|||||||
import FilterRail from "./FilterRail";
|
import FilterRail from "./FilterRail";
|
||||||
import { folderIcon, folderLabel } from "./fileFolders";
|
import { folderIcon, folderLabel } from "./fileFolders";
|
||||||
import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext";
|
import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext";
|
||||||
|
import { RenameField } from "./RenameField";
|
||||||
|
import { useFolderActions } from "./useFolderActions";
|
||||||
|
import { ContextMenu } from "./ui/ContextMenu";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
import { type PanelItem } from "./ui/PanelGroups";
|
import { type PanelItem } from "./ui/PanelGroups";
|
||||||
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
|
|
||||||
// The Files module's left rail: the library's places — every file, the ones shared with me, the
|
// 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
|
// 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
|
// 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.
|
// picks lives in FilesProvider, which the Files page reads. A folder's right click (or long press, or
|
||||||
|
// Shift+F10) opens its menu (`useFolderActions`); the same menu is the "…" beside an open folder's
|
||||||
|
// name on the page, so nothing here is reachable only by a gesture.
|
||||||
|
|
||||||
|
const folderKey = (f: FileFolder) => f.id;
|
||||||
|
|
||||||
function Entry({
|
function Entry({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
count,
|
count,
|
||||||
view,
|
view,
|
||||||
|
trigger,
|
||||||
|
folderId,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
label: string;
|
label: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
view: FilesView;
|
view: FilesView;
|
||||||
|
/** The folder's menu triggers (right click, long press, Shift+F10). */
|
||||||
|
trigger?: Record<string, unknown>;
|
||||||
|
/** A folder's entry is found by it, to hand the keyboard back after a rename. */
|
||||||
|
folderId?: number;
|
||||||
}) {
|
}) {
|
||||||
const { view: current } = useFiles();
|
const { view: current } = useFiles();
|
||||||
const { setView } = useFilesActions();
|
const { setView } = useFilesActions();
|
||||||
const active = sameView(view, current);
|
const active = sameView(view, current);
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
{...trigger}
|
||||||
|
data-folder-id={folderId}
|
||||||
selectable
|
selectable
|
||||||
layer={!active}
|
layer={!active}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
@@ -108,6 +125,28 @@ export default function FilesRail({
|
|||||||
const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName);
|
const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName);
|
||||||
const own = folders.filter((f) => f.kind === "own").sort(byName);
|
const own = folders.filter((f) => f.kind === "own").sort(byName);
|
||||||
|
|
||||||
|
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
// A name field left by Enter or Escape hands the keyboard back to what stands there now: the
|
||||||
|
// folder's entry, or the New-folder command (RenameField).
|
||||||
|
const railRef = useRef<HTMLDivElement>(null);
|
||||||
|
const refocusFolder = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = refocusFolder.current;
|
||||||
|
if (renamingId !== null || id === null) return;
|
||||||
|
refocusFolder.current = null;
|
||||||
|
railRef.current?.querySelector<HTMLElement>(`[data-folder-id="${id}"]`)?.focus();
|
||||||
|
}, [renamingId]);
|
||||||
|
const newFolder = useRef<HTMLButtonElement>(null);
|
||||||
|
const refocusNew = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (creating || !refocusNew.current) return;
|
||||||
|
refocusNew.current = false;
|
||||||
|
newFolder.current?.focus();
|
||||||
|
}, [creating]);
|
||||||
|
const folderActions = useFolderActions({ onRename: (f) => setRenamingId(f.id) });
|
||||||
|
const menu = useContextMenu<FileFolder>({ keyOf: folderKey });
|
||||||
|
|
||||||
const failed = foldersQ.isError && !foldersQ.data && (
|
const failed = foldersQ.isError && !foldersQ.data && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onClick={() => foldersQ.refetch()}
|
onClick={() => foldersQ.refetch()}
|
||||||
@@ -116,15 +155,29 @@ export default function FilesRail({
|
|||||||
{t("common.loadError")} — {t("common.retry")}
|
{t("common.loadError")} — {t("common.retry")}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
const entry = (f: FileFolder) => (
|
const entry = (f: FileFolder) =>
|
||||||
<Entry
|
renamingId === f.id ? (
|
||||||
key={f.id}
|
<RenameField
|
||||||
icon={folderIcon(f)}
|
key={f.id}
|
||||||
label={folderLabel(f, t)}
|
initial={f.name ?? ""}
|
||||||
count={f.count}
|
label={t("files.folder.rename")}
|
||||||
view={{ kind: "folder", id: f.id }}
|
onDone={(name, byKey) => {
|
||||||
/>
|
if (byKey) refocusFolder.current = f.id;
|
||||||
);
|
setRenamingId(null);
|
||||||
|
if (name !== null) folderActions.rename(f, name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Entry
|
||||||
|
key={f.id}
|
||||||
|
icon={folderIcon(f)}
|
||||||
|
label={folderLabel(f, t)}
|
||||||
|
count={f.count}
|
||||||
|
view={{ kind: "folder", id: f.id }}
|
||||||
|
trigger={menu.triggerProps(f)}
|
||||||
|
folderId={f.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const items: PanelItem[] = [
|
const items: PanelItem[] = [
|
||||||
{
|
{
|
||||||
@@ -159,12 +212,34 @@ export default function FilesRail({
|
|||||||
id: "own",
|
id: "own",
|
||||||
title: t("files.rail.own"),
|
title: t("files.rail.own"),
|
||||||
body: (
|
body: (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div ref={railRef} className="flex flex-col gap-0.5">
|
||||||
{failed}
|
{failed}
|
||||||
{own.map(entry)}
|
{own.map(entry)}
|
||||||
{foldersQ.data && own.length === 0 && (
|
{foldersQ.data && own.length === 0 && !creating && (
|
||||||
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noOwn")}</span>
|
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noOwn")}</span>
|
||||||
)}
|
)}
|
||||||
|
{creating ? (
|
||||||
|
<RenameField
|
||||||
|
initial=""
|
||||||
|
label={t("files.folder.newName")}
|
||||||
|
onDone={(name, byKey) => {
|
||||||
|
refocusNew.current = byKey;
|
||||||
|
setCreating(false);
|
||||||
|
if (name !== null) void folderActions.add(name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
foldersQ.data && (
|
||||||
|
<Pressable
|
||||||
|
ref={newFolder}
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
className="flex min-h-8 w-full items-center gap-2 rounded-lg px-2 text-sm text-muted hover:text-fg"
|
||||||
|
>
|
||||||
|
<FolderPlus className="size-4 shrink-0" />
|
||||||
|
{t("files.folder.new")}
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -182,6 +257,11 @@ export default function FilesRail({
|
|||||||
items={items}
|
items={items}
|
||||||
>
|
>
|
||||||
<StorageMeter />
|
<StorageMeter />
|
||||||
|
<ContextMenu
|
||||||
|
menu={menu}
|
||||||
|
ariaLabel={t("files.folder.menu")}
|
||||||
|
items={menu.target ? folderActions.items(menu.target) : []}
|
||||||
|
/>
|
||||||
</FilterRail>
|
</FilterRail>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Input } from "./ui/Input";
|
||||||
|
|
||||||
|
// A name being typed in place of the name itself — a library file's in its row, a folder's in the rail
|
||||||
|
// or the page title, a new folder's. Enter or leaving the field saves, Escape cancels: unmounting the
|
||||||
|
// input fires no blur in React, so Escape really does cancel. The caller decides what an unchanged or
|
||||||
|
// empty name means.
|
||||||
|
//
|
||||||
|
// Ended by a KEY, the field leaves with the keyboard's focus and nothing to give it to: the caller
|
||||||
|
// puts it back on what stands there now (the row, the entry, the button that opened it) — or the
|
||||||
|
// keyboard user is dropped on <body> and loses their place (measured in Chrome, the playlist rail's
|
||||||
|
// rename). Ended by a blur, the user has put the focus somewhere on purpose, and it stays there.
|
||||||
|
|
||||||
|
export function RenameField({
|
||||||
|
initial,
|
||||||
|
label,
|
||||||
|
className = "w-full",
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
initial: string;
|
||||||
|
/** The field's accessible name (it has no visible label: it stands where the name stood). */
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
/** The typed name, or null when cancelled; `byKey`: Enter or Escape, so the focus is the caller's
|
||||||
|
* to put back. */
|
||||||
|
onDone: (name: string | null, byKey: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(initial);
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
size="sm"
|
||||||
|
className={className}
|
||||||
|
autoFocus
|
||||||
|
value={draft}
|
||||||
|
aria-label={label}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={() => onDone(draft, false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// preventDefault: a real Enter's own activation would otherwise press what focus lands on.
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
onDone(draft, true);
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
// Only the edit is cancelled: a drawer (the rail on a phone) or a dialog round the field
|
||||||
|
// must not close with it.
|
||||||
|
e.stopPropagation();
|
||||||
|
onDone(null, true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ const h = vi.hoisted(() => ({
|
|||||||
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
|
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
|
||||||
notify: vi.fn(),
|
notify: vi.fn(),
|
||||||
navigateTo: vi.fn(),
|
navigateTo: vi.fn(),
|
||||||
|
openFile: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
@@ -37,12 +38,25 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
|||||||
vi.mock("../lib/nav", () => ({ navigateTo: h.navigateTo }));
|
vi.mock("../lib/nav", () => ({ navigateTo: h.navigateTo }));
|
||||||
|
|
||||||
import UploadDialog from "./UploadDialog";
|
import UploadDialog from "./UploadDialog";
|
||||||
|
import { FilesActionsContext } from "./filesContext";
|
||||||
|
|
||||||
const wrap = (onClose = vi.fn(), folderId: number | null = null) => {
|
const wrap = (
|
||||||
|
onClose = vi.fn(),
|
||||||
|
folderId: number | null = null,
|
||||||
|
initialFile: File | null = null,
|
||||||
|
) => {
|
||||||
const qc = new QueryClient();
|
const qc = new QueryClient();
|
||||||
|
const files = {
|
||||||
|
setSearch: vi.fn(),
|
||||||
|
setView: vi.fn(),
|
||||||
|
openFile: h.openFile,
|
||||||
|
consumeFocus: vi.fn(),
|
||||||
|
};
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<UploadDialog folderId={folderId} onClose={onClose} />
|
<FilesActionsContext.Provider value={files}>
|
||||||
|
<UploadDialog folderId={folderId} initialFile={initialFile} onClose={onClose} />
|
||||||
|
</FilesActionsContext.Provider>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -52,6 +66,8 @@ const fileInput = () => document.querySelector('input[type="file"]') as HTMLInpu
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
h.uploadFile.mockClear();
|
h.uploadFile.mockClear();
|
||||||
|
h.openFile.mockClear();
|
||||||
|
h.navigateTo.mockClear();
|
||||||
h.notify.mockClear();
|
h.notify.mockClear();
|
||||||
h.filesUsage.mockClear();
|
h.filesUsage.mockClear();
|
||||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
|
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
|
||||||
@@ -99,14 +115,17 @@ describe("UploadDialog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
|
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.
|
// Done: Files opens on the file itself, in its folder. Still converting: it is in the download
|
||||||
|
// queue until it is done.
|
||||||
|
h.uploadFile.mockResolvedValueOnce({ id: 42, status: "done", folder_id: 5 } as never);
|
||||||
wrap(vi.fn(), 5);
|
wrap(vi.fn(), 5);
|
||||||
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.mp4")] } });
|
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.mp4")] } });
|
||||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||||
expect((h.uploadFile.mock.calls[0]![1] as { folderId?: number }).folderId).toBe(5);
|
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();
|
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
|
||||||
expect(h.navigateTo).toHaveBeenLastCalledWith("files");
|
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
|
||||||
|
expect(h.navigateTo).not.toHaveBeenCalled();
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
h.notify.mockClear();
|
h.notify.mockClear();
|
||||||
@@ -119,6 +138,17 @@ describe("UploadDialog", () => {
|
|||||||
expect(h.navigateTo).toHaveBeenLastCalledWith("downloads");
|
expect(h.navigateTo).toHaveBeenLastCalledWith("downloads");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens ready to send a file dropped on the page", async () => {
|
||||||
|
const dropped = new File(["dropped"], "recept.pdf");
|
||||||
|
wrap(vi.fn(), null, dropped);
|
||||||
|
expect(screen.getByText("recept.pdf")).toBeTruthy();
|
||||||
|
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||||
|
expect(btn.disabled).toBe(false);
|
||||||
|
fireEvent.click(btn);
|
||||||
|
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
|
||||||
|
expect(h.uploadFile.mock.calls[0]![0]).toBe(dropped);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps Upload disabled until a file is chosen", () => {
|
it("keeps Upload disabled until a file is chosen", () => {
|
||||||
wrap();
|
wrap();
|
||||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { notifyActionError } from "../lib/actionErrors";
|
|||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads } from "../lib/downloads";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { navigateTo } from "../lib/nav";
|
import { navigateTo } from "../lib/nav";
|
||||||
|
import type { DownloadJob } from "../lib/api";
|
||||||
|
import { useFilesActions } from "./filesContext";
|
||||||
import { formatBytes } from "../lib/format";
|
import { formatBytes } from "../lib/format";
|
||||||
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./ui/Pressable";
|
||||||
@@ -26,21 +28,21 @@ import { Input } from "./ui/Input";
|
|||||||
// skip the whole dialog.
|
// skip the whole dialog.
|
||||||
const uploadedKey = (status: string) =>
|
const uploadedKey = (status: string) =>
|
||||||
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
|
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({
|
export default function UploadDialog({
|
||||||
folderId = null,
|
folderId = null,
|
||||||
|
initialFile = null,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
/** The library folder the file goes into (an own folder, or the Uploads folder); null = Uploads. */
|
/** The library folder the file goes into (an own folder, or the Uploads folder); null = Uploads. */
|
||||||
folderId?: number | null;
|
folderId?: number | null;
|
||||||
|
/** A file already chosen — dropped on the Files page — so the dialog opens ready to send it. */
|
||||||
|
initialFile?: File | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(initialFile);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
|
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
|
||||||
@@ -65,6 +67,13 @@ export default function UploadDialog({
|
|||||||
// silent to a screen reader. `SetupWizard` and `Welcome` both key their alerts for the
|
// silent to a screen reader. `SetupWizard` and `Welcome` both key their alerts for the
|
||||||
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
|
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
|
||||||
const [pickSeq, setPickSeq] = useState(0);
|
const [pickSeq, setPickSeq] = useState(0);
|
||||||
|
const { openFile } = useFilesActions();
|
||||||
|
// A done file is in Files, opened on it; one the worker still converts is in the download queue
|
||||||
|
// until it is.
|
||||||
|
const openUploaded = (job: DownloadJob) => {
|
||||||
|
if (job.status === "done") openFile(job.id, job.folder_id ?? null);
|
||||||
|
else navigateTo("downloads");
|
||||||
|
};
|
||||||
const pick_ = (f: File) => {
|
const pick_ = (f: File) => {
|
||||||
setFile(f);
|
setFile(f);
|
||||||
setName("");
|
setName("");
|
||||||
@@ -101,7 +110,7 @@ export default function UploadDialog({
|
|||||||
message: t(uploadedKey(job.status)),
|
message: t(uploadedKey(job.status)),
|
||||||
action: {
|
action: {
|
||||||
label: t("downloads.dialog.view"),
|
label: t("downloads.dialog.view"),
|
||||||
onClick: () => navigateTo(uploadedPage(job.status)),
|
onClick: () => openUploaded(job),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { createElement } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Clock, Pin, Smartphone } from "lucide-react";
|
||||||
|
import type { LibraryFile } from "../lib/api";
|
||||||
|
import { formatDateTime } from "../lib/format";
|
||||||
|
import { sourceIcon, sourceLabel } from "./fileFolders";
|
||||||
|
import { Badge } from "./ui/Badge";
|
||||||
|
|
||||||
|
// What a library file says about itself — where it came from, whether a phone plays it, how long it
|
||||||
|
// stays — drawn once for both paints of the list: the table's columns and the grid's cards.
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
// A file this close to leaving says so in the warning colour (and, on a card, says it at all).
|
||||||
|
const SOON_DAYS = 7;
|
||||||
|
|
||||||
|
function daysLeft(iso: string): number {
|
||||||
|
return Math.max(0, Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceBadge({ file }: { file: LibraryFile }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Badge>
|
||||||
|
{/* A looked-up icon drawn as an element: a component picked during render would remount. */}
|
||||||
|
{createElement(sourceIcon(file.source_key))}
|
||||||
|
{sourceLabel(file.source_key, t)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every library video and sound is phone-safe by construction (backend downloads/playable.py); a
|
||||||
|
* plain file is not a thing a phone plays, so it has no badge. */
|
||||||
|
export function PhoneBadge({ file }: { file: LibraryFile }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (file.asset?.media_kind === "file") return null;
|
||||||
|
return (
|
||||||
|
<Badge title={t("files.phoneOk")}>
|
||||||
|
<Smartphone />
|
||||||
|
<span className="text-success">✓</span>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long the file stays. `quiet`: only what needs saying — kept, or leaving within a week (a
|
||||||
|
* card has no column to hold the rest); otherwise every state, as the table's column. */
|
||||||
|
export function RetentionBadge({ file, quiet = false }: { file: LibraryFile; quiet?: boolean }) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
if (file.kept)
|
||||||
|
return (
|
||||||
|
<Badge>
|
||||||
|
<Pin />
|
||||||
|
{t("files.retention.kept")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
if (!file.expires_at)
|
||||||
|
return quiet ? null : (
|
||||||
|
<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);
|
||||||
|
if (days <= SOON_DAYS)
|
||||||
|
return (
|
||||||
|
<Badge tone="warn" title={t("files.retention.until", { when })}>
|
||||||
|
<Clock />
|
||||||
|
{t("files.retention.days", { count: days })}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
return quiet ? null : (
|
||||||
|
<span className="text-xs tabular-nums text-muted" title={t("files.retention.until", { when })}>
|
||||||
|
{t("files.retention.days", { count: days })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,20 +23,49 @@ export function parseView(raw: string | null | undefined): FilesView | null {
|
|||||||
|
|
||||||
export const sameView = (a: FilesView, b: FilesView): boolean => viewParam(a) === viewParam(b);
|
export const sameView = (a: FilesView, b: FilesView): boolean => viewParam(a) === viewParam(b);
|
||||||
|
|
||||||
|
/** The file to bring into view on arrival (`?item=`, a notice's "Open"); `token` makes a second
|
||||||
|
* arrival at the same file a new one. */
|
||||||
|
export type FilesFocus = { id: number; token: number };
|
||||||
|
|
||||||
|
/** The `?item=` a link carries: a positive id, else nothing. */
|
||||||
|
export function parseItem(raw: string | null | undefined): number | null {
|
||||||
|
const id = raw && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
|
||||||
|
return id !== null && Number.isSafeInteger(id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An arrival the page has found: the file, in the view it was found in (`viewParam`). */
|
||||||
|
export type FilesArrival = FilesFocus & { view: string };
|
||||||
|
|
||||||
export type FilesState = {
|
export type FilesState = {
|
||||||
search: string;
|
search: string;
|
||||||
view: FilesView;
|
view: FilesView;
|
||||||
|
/** Pending until the page has looked for it (`consumeFocus`). */
|
||||||
|
focus: FilesFocus | null;
|
||||||
|
/** The last arrival the page found, while the Files page is open: turned to and ringed. */
|
||||||
|
arrived: FilesArrival | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FilesActions {
|
export interface FilesActions {
|
||||||
setSearch: (q: string) => void;
|
setSearch: (q: string) => void;
|
||||||
setView: (view: FilesView) => void;
|
setView: (view: FilesView) => void;
|
||||||
|
/** Open the Files page on one file: its folder (null = every file) with the file in view. */
|
||||||
|
openFile: (id: number, folderId: number | null) => void;
|
||||||
|
/** The page has looked for the focused file: found in the view `at` (a `viewParam`), or nowhere
|
||||||
|
* (null). Either way the arrival is spent. */
|
||||||
|
consumeFocus: (at: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FilesStateContext = createContext<FilesState>({ search: "", view: ALL_FILES });
|
export const FilesStateContext = createContext<FilesState>({
|
||||||
|
search: "",
|
||||||
|
view: ALL_FILES,
|
||||||
|
focus: null,
|
||||||
|
arrived: null,
|
||||||
|
});
|
||||||
export const FilesActionsContext = createContext<FilesActions>({
|
export const FilesActionsContext = createContext<FilesActions>({
|
||||||
setSearch: () => {},
|
setSearch: () => {},
|
||||||
setView: () => {},
|
setView: () => {},
|
||||||
|
openFile: () => {},
|
||||||
|
consumeFocus: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useFiles(): FilesState {
|
export function useFiles(): FilesState {
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ describe("loadInitialPage precedence", () => {
|
|||||||
setUrl("?pl=3");
|
setUrl("?pl=3");
|
||||||
expect(loadInitialPage()).toBe("playlists");
|
expect(loadInitialPage()).toBe("playlists");
|
||||||
});
|
});
|
||||||
|
it.each(["?folder=4", "?item=12", "?folder=all&item=12"])(
|
||||||
|
"%s implies the Files page (a folder, or a file to arrive at)",
|
||||||
|
(search) => {
|
||||||
|
setUrl(search);
|
||||||
|
expect(loadInitialPage()).toBe("files");
|
||||||
|
},
|
||||||
|
);
|
||||||
it("a filter param implies the feed", () => {
|
it("a filter param implies the feed", () => {
|
||||||
setUrl("?tags=1,2");
|
setUrl("?tags=1,2");
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage()).toBe("feed");
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function loadInitialPage(): Page {
|
|||||||
// without an explicit ?page. Feed filters (or a channel overlay) imply the feed.
|
// without an explicit ?page. Feed filters (or a channel overlay) imply the feed.
|
||||||
if (params.has("v")) return "plex";
|
if (params.has("v")) return "plex";
|
||||||
if (params.has("pl")) return "playlists";
|
if (params.has("pl")) return "playlists";
|
||||||
if (params.has("folder")) return "files";
|
if (params.has("folder") || params.has("item")) return "files";
|
||||||
if (hasFilterParams(params) || params.has("chan")) return "feed";
|
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
|
// 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
|
// the user has to decide anything. It deliberately OVERRIDES the remembered last module: landing
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ const files = readSources(__dirname, [
|
|||||||
|
|
||||||
/** file → [how many plain Pressables, why they are not titles]. */
|
/** file → [how many plain Pressables, why they are not titles]. */
|
||||||
const PLAIN: Record<string, [number, string]> = {
|
const PLAIN: Record<string, [number, string]> = {
|
||||||
"FilesRail.tsx": [1, "the load-error retry"],
|
"FileCard.tsx": [1, "a card's picture (named by aria-label; its title is the caption)"],
|
||||||
|
"FilesRail.tsx": [2, "the load-error retry; the New-folder command"],
|
||||||
"Home.tsx": [
|
"Home.tsx": [
|
||||||
2,
|
2,
|
||||||
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
|
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Clock, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { api, type FileFolder, type FolderPatch } from "../lib/api";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
|
import { invalidateDownloads } from "../lib/downloads";
|
||||||
|
import { useConfirm } from "./confirmContext";
|
||||||
|
import { folderLabel } from "./fileFolders";
|
||||||
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||||
|
import { menuGroups } from "./ui/menuGroups";
|
||||||
|
|
||||||
|
// A library folder's ONE menu — the rail entry's right click and the Files page's "…" beside an open
|
||||||
|
// folder's name. An own folder is renamed (inline, where it is named), kept for a while and deleted; a
|
||||||
|
// source folder is named by its origin and cannot go, so only how long it keeps its files is its
|
||||||
|
// user's to set (the server holds the same rules: `routes/files.py`).
|
||||||
|
|
||||||
|
/** The choices the Retention submenu offers (days; null = until the user deletes the file). */
|
||||||
|
const RETENTION_CHOICES: readonly (number | null)[] = [7, 30, 90, null];
|
||||||
|
|
||||||
|
// Which failure title a PATCH speaks under: what it was asked to change.
|
||||||
|
const patchFail = (p: FolderPatch) =>
|
||||||
|
"name" in p ? "errors.writeFailed.folder.rename" : "errors.writeFailed.folder.retention";
|
||||||
|
|
||||||
|
export function useFolderActions({ onRename }: { onRename: (folder: FileFolder) => void }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
// Every write lands in the listings too: a retention change moves its files' expiry, a delete moves
|
||||||
|
// the files themselves.
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: (name: string) => api.createFolder(name),
|
||||||
|
onSuccess: () => invalidateDownloads(qc),
|
||||||
|
meta: { failTitle: "errors.writeFailed.folder.create" },
|
||||||
|
});
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: ({ id, patch }: { id: number; patch: FolderPatch }) => api.updateFolder(id, patch),
|
||||||
|
onSuccess: () => invalidateDownloads(qc),
|
||||||
|
onError: (e, v) => notifyActionError(e, t(patchFail(v.patch))),
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: number) => api.deleteFolder(id),
|
||||||
|
onSuccess: () => invalidateDownloads(qc),
|
||||||
|
meta: { failTitle: "errors.writeFailed.folder.delete" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const icon = (I: typeof Clock) => <I className="w-4 h-4" />;
|
||||||
|
const daysLabel = (days: number | null) =>
|
||||||
|
days === null ? t("files.folder.forever") : t("files.retention.days", { count: days });
|
||||||
|
|
||||||
|
// The folder's current retention is always one of the rows, ticked — an odd value set elsewhere (the
|
||||||
|
// admin's default for source folders) included, so the menu never claims a value it does not hold.
|
||||||
|
const retention = (folder: FileFolder): ActionMenuItem => {
|
||||||
|
const current = folder.retention_days;
|
||||||
|
const days = RETENTION_CHOICES.filter((d): d is number => d !== null);
|
||||||
|
if (current !== null && !days.includes(current)) days.push(current);
|
||||||
|
const choices = [...days.sort((a, b) => a - b), null];
|
||||||
|
return {
|
||||||
|
id: "retention",
|
||||||
|
label: t("files.folder.retention"),
|
||||||
|
icon: icon(Clock),
|
||||||
|
items: choices.map((d) => ({
|
||||||
|
id: `retention-${d ?? "forever"}`,
|
||||||
|
label: daysLabel(d),
|
||||||
|
check: { kind: "one", checked: d === current },
|
||||||
|
onSelect: () => {
|
||||||
|
if (d !== current) update.mutate({ id: folder.id, patch: { retention_days: d } });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async (folder: FileFolder) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("files.folder.deleteTitle", { name: folderLabel(folder, t) }),
|
||||||
|
message:
|
||||||
|
folder.count === 0
|
||||||
|
? t("files.folder.deleteBodyEmpty")
|
||||||
|
: t("files.folder.deleteBody", { count: folder.count }),
|
||||||
|
confirmLabel: t("files.folder.deleteAction"),
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (ok) remove.mutate(folder.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The folder's menu rows. */
|
||||||
|
const items = (folder: FileFolder): ActionMenuItem[] =>
|
||||||
|
folder.kind === "own"
|
||||||
|
? menuGroups(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "rename",
|
||||||
|
label: t("files.folder.rename"),
|
||||||
|
icon: icon(Pencil),
|
||||||
|
onSelect: () => onRename(folder),
|
||||||
|
},
|
||||||
|
retention(folder),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
label: t("files.folder.delete"),
|
||||||
|
icon: icon(Trash2),
|
||||||
|
danger: true,
|
||||||
|
onSelect: () => void confirmDelete(folder),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: [retention(folder)];
|
||||||
|
|
||||||
|
/** Save the inline rename: an unchanged or empty name changes nothing (a folder needs a name). */
|
||||||
|
const rename = (folder: FileFolder, name: string) => {
|
||||||
|
const next = name.trim();
|
||||||
|
if (!next || next === folder.name) return;
|
||||||
|
update.mutate({ id: folder.id, patch: { name: next } });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Make an own folder; resolves to it, or null when the server refused (already told). */
|
||||||
|
const add = async (name: string): Promise<FileFolder | null> => {
|
||||||
|
const next = name.trim();
|
||||||
|
if (!next) return null;
|
||||||
|
try {
|
||||||
|
return await create.mutateAsync(next);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { items, rename, add };
|
||||||
|
}
|
||||||
@@ -103,6 +103,12 @@
|
|||||||
"move": "Couldn't move the file",
|
"move": "Couldn't move the file",
|
||||||
"keep": "Couldn't change whether the file is kept",
|
"keep": "Couldn't change whether the file is kept",
|
||||||
"delete": "Couldn't delete the file"
|
"delete": "Couldn't delete the file"
|
||||||
|
},
|
||||||
|
"folder": {
|
||||||
|
"create": "Couldn't create the folder",
|
||||||
|
"rename": "Couldn't rename the folder",
|
||||||
|
"retention": "Couldn't change how long the folder keeps its files",
|
||||||
|
"delete": "Couldn't delete the folder"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
|
|||||||
@@ -56,5 +56,35 @@
|
|||||||
"unkeep": "Stop keeping",
|
"unkeep": "Stop keeping",
|
||||||
"details": "Details…",
|
"details": "Details…",
|
||||||
"upload": "Upload"
|
"upload": "Upload"
|
||||||
}
|
},
|
||||||
|
"folder": {
|
||||||
|
"menu": "Folder actions",
|
||||||
|
"new": "New folder",
|
||||||
|
"newName": "Folder name",
|
||||||
|
"rename": "Rename",
|
||||||
|
"retention": "Keeps files",
|
||||||
|
"forever": "Until I delete them",
|
||||||
|
"delete": "Delete folder…",
|
||||||
|
"deleteTitle": "Delete “{{name}}”?",
|
||||||
|
"deleteBodyEmpty": "The folder is empty. Nothing else changes.",
|
||||||
|
"deleteBody_one": "Its file goes back to the folder it came from, and its retention starts again from today.",
|
||||||
|
"deleteBody_other": "Its {{count}} files go back to the folders they came from, and their retention starts again from today.",
|
||||||
|
"deleteAction": "Delete folder"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"label": "View",
|
||||||
|
"list": "List",
|
||||||
|
"grid": "Grid"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"label": "Sort",
|
||||||
|
"default": "Newest first",
|
||||||
|
"asc": "Ascending",
|
||||||
|
"desc": "Descending"
|
||||||
|
},
|
||||||
|
"drop": {
|
||||||
|
"here": "Drop to upload into {{folder}}",
|
||||||
|
"oneAtATime": "Upload one file at a time — the first one is ready to go."
|
||||||
|
},
|
||||||
|
"perPage": "Files per page"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,12 @@
|
|||||||
"move": "Nem sikerült áthelyezni a fájlt",
|
"move": "Nem sikerült áthelyezni a fájlt",
|
||||||
"keep": "Nem sikerült módosítani a fájl megtartását",
|
"keep": "Nem sikerült módosítani a fájl megtartását",
|
||||||
"delete": "Nem sikerült törölni a fájlt"
|
"delete": "Nem sikerült törölni a fájlt"
|
||||||
|
},
|
||||||
|
"folder": {
|
||||||
|
"create": "Nem sikerült létrehozni a mappát",
|
||||||
|
"rename": "Nem sikerült átnevezni a mappát",
|
||||||
|
"retention": "Nem sikerült módosítani a mappa megőrzési idejét",
|
||||||
|
"delete": "Nem sikerült törölni a mappát"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
|
|||||||
@@ -56,5 +56,35 @@
|
|||||||
"unkeep": "Megtartás visszavonása",
|
"unkeep": "Megtartás visszavonása",
|
||||||
"details": "Részletek…",
|
"details": "Részletek…",
|
||||||
"upload": "Feltöltés"
|
"upload": "Feltöltés"
|
||||||
}
|
},
|
||||||
|
"folder": {
|
||||||
|
"menu": "Mappa műveletei",
|
||||||
|
"new": "Új mappa",
|
||||||
|
"newName": "Mappa neve",
|
||||||
|
"rename": "Átnevezés",
|
||||||
|
"retention": "Fájlok megőrzése",
|
||||||
|
"forever": "Amíg ki nem törlöm",
|
||||||
|
"delete": "Mappa törlése…",
|
||||||
|
"deleteTitle": "Törlöd: „{{name}}”?",
|
||||||
|
"deleteBodyEmpty": "A mappa üres. Más nem változik.",
|
||||||
|
"deleteBody_one": "A benne lévő fájl visszakerül abba a mappába, ahonnan jött, és a megőrzési ideje mától újraindul.",
|
||||||
|
"deleteBody_other": "A benne lévő {{count}} fájl visszakerül abba a mappába, ahonnan jött, és a megőrzési idejük mától újraindul.",
|
||||||
|
"deleteAction": "Mappa törlése"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"label": "Nézet",
|
||||||
|
"list": "Lista",
|
||||||
|
"grid": "Rács"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"label": "Rendezés",
|
||||||
|
"default": "Legújabb elöl",
|
||||||
|
"asc": "Növekvő",
|
||||||
|
"desc": "Csökkenő"
|
||||||
|
},
|
||||||
|
"drop": {
|
||||||
|
"here": "Engedd el, és feltöltöm ide: {{folder}}",
|
||||||
|
"oneAtATime": "Egyszerre egy fájlt tölthetsz fel — az első készen áll."
|
||||||
|
},
|
||||||
|
"perPage": "Fájl oldalanként"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,8 +72,21 @@ function listQuery(p: FileListParams): string {
|
|||||||
return s ? `?${s}` : "";
|
return s ? `?${s}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a folder PATCH may change. Presence decides: `retention_days: null` = keep its files forever.
|
||||||
|
export interface FolderPatch {
|
||||||
|
name?: string; // an own folder's only
|
||||||
|
retention_days?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const filesApi = {
|
export const filesApi = {
|
||||||
fileFolders: (): Promise<FileFolder[]> => req("/api/files/folders"),
|
fileFolders: (): Promise<FileFolder[]> => req("/api/files/folders"),
|
||||||
|
createFolder: (name: string): Promise<FileFolder> =>
|
||||||
|
req("/api/files/folders", { method: "POST", body: JSON.stringify({ name }) }),
|
||||||
|
updateFolder: (id: number, patch: FolderPatch): Promise<FileFolder> =>
|
||||||
|
req(`/api/files/folders/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
// An own folder only; its files go back to the folders they came from.
|
||||||
|
deleteFolder: (id: number): Promise<{ ok: true; deleted: number; files_moved: number }> =>
|
||||||
|
req(`/api/files/folders/${id}`, { method: "DELETE" }),
|
||||||
files: (p: FileListParams = {}): Promise<LibraryFile[]> => req(`/api/files${listQuery(p)}`),
|
files: (p: FileListParams = {}): Promise<LibraryFile[]> => req(`/api/files${listQuery(p)}`),
|
||||||
filesUsage: (): Promise<StorageUsage> => req("/api/files/usage"),
|
filesUsage: (): Promise<StorageUsage> => req("/api/files/usage"),
|
||||||
updateFile: (id: number, patch: FilePatch): Promise<DownloadJob> =>
|
updateFile: (id: number, patch: FilePatch): Promise<DownloadJob> =>
|
||||||
|
|||||||
@@ -14,8 +14,13 @@ export const LS = {
|
|||||||
filesCollapsed: "siftlode.filesCollapsed",
|
filesCollapsed: "siftlode.filesCollapsed",
|
||||||
// The Files view last open (every file, shared with me, or a folder id), per-account.
|
// The Files view last open (every file, shared with me, or a folder id), per-account.
|
||||||
filesView: "siftlode.filesView",
|
filesView: "siftlode.filesView",
|
||||||
// The Files table's sort and rows-per-page (DataTable's persist blob).
|
// The Files table's rows-per-page and page (DataTable's persist blob), per-account.
|
||||||
filesTable: "siftlode.filesTable",
|
filesTable: "siftlode.filesTable",
|
||||||
|
// List or grid per Files view (a `{ viewParam: layout }` map), the sort both paints share, and the
|
||||||
|
// grid's cards per page — per-account mirrors of the server prefs (the last is local only).
|
||||||
|
filesLayouts: "siftlode.filesLayouts",
|
||||||
|
filesSort: "siftlode.filesSort",
|
||||||
|
filesCardSize: "siftlode.filesCardSize",
|
||||||
hints: "siftlode.hints",
|
hints: "siftlode.hints",
|
||||||
lang: "siftlode.lang",
|
lang: "siftlode.lang",
|
||||||
filters: "siftlode.filters",
|
filters: "siftlode.filters",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export function useCardPager<T>(
|
|||||||
/** Follow a row the caller just moved: each new `seq` turns to the page holding the row `match`
|
/** Follow a row the caller just moved: each new `seq` turns to the page holding the row `match`
|
||||||
* finds — the same contract as `DataTable`'s `reveal`, for the card paint of the same list. */
|
* finds — the same contract as `DataTable`'s `reveal`, for the card paint of the same list. */
|
||||||
reveal?: { seq: number; match: (row: T) => boolean } | null,
|
reveal?: { seq: number; match: (row: T) => boolean } | null,
|
||||||
|
/** The size picker's name — what the cards ARE (the channel manager's are "cards"). */
|
||||||
|
sizeLabel?: string,
|
||||||
) {
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [size, setSize] = useState(() => parseCardSize(readAccount(sizeKey, null)));
|
const [size, setSize] = useState(() => parseCardSize(readAccount(sizeKey, null)));
|
||||||
@@ -58,7 +60,7 @@ export function useCardPager<T>(
|
|||||||
writeAccount(sizeKey, s);
|
writeAccount(sizeKey, s);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
},
|
},
|
||||||
sizeLabel: t("channels.cardsPerPage"),
|
sizeLabel: sizeLabel ?? t("channels.cardsPerPage"),
|
||||||
},
|
},
|
||||||
/** Call when the caller's own filters change — a different list starts at page one. */
|
/** Call when the caller's own filters change — a different list starts at page one. */
|
||||||
resetPage: useCallback(() => setPage(0), []),
|
resetPage: useCallback(() => setPage(0), []),
|
||||||
|
|||||||
Reference in New Issue
Block a user