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
|
||||
// back home; a shared file is the owner's, so its menu only plays, saves, cuts or drops it from my
|
||||
// list. i18n is key-passthrough, so labels read as keys.
|
||||
import { useState } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { api, type FileFolder, type LibraryFile } from "../lib/api";
|
||||
import { ALL_FILES, FilesActionsContext, FilesStateContext, type FilesView } from "./filesContext";
|
||||
import {
|
||||
ALL_FILES,
|
||||
FilesActionsContext,
|
||||
FilesStateContext,
|
||||
type FilesFocus,
|
||||
type FilesView,
|
||||
} from "./filesContext";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
files: [] as unknown[],
|
||||
folders: [] as unknown[],
|
||||
confirm: vi.fn(async (_o?: unknown) => true),
|
||||
keys: [] as unknown[],
|
||||
fetching: false,
|
||||
}));
|
||||
|
||||
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,
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: mocks.fetching,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
},
|
||||
@@ -92,13 +101,24 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mocks.confirm.mockClear();
|
||||
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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<FilesActionsContext.Provider value={{ setSearch: vi.fn(), setView }}>
|
||||
<FilesStateContext.Provider value={{ search, view }}>
|
||||
<FilesActionsContext.Provider
|
||||
value={{ setSearch: vi.fn(), setView, openFile: vi.fn(), consumeFocus }}
|
||||
>
|
||||
<FilesStateContext.Provider value={{ search, view, focus, arrived: null }}>
|
||||
<Files />
|
||||
</FilesStateContext.Provider>
|
||||
</FilesActionsContext.Provider>
|
||||
@@ -218,6 +238,8 @@ describe("Files · my file's row", () => {
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Új név" }));
|
||||
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")));
|
||||
fireEvent.click(menuItem("files.actions.rename"));
|
||||
@@ -239,6 +261,7 @@ describe("Files · my file's row", () => {
|
||||
fireEvent.keyDown(field, { key: "Escape" });
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
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 () => {
|
||||
@@ -395,3 +418,276 @@ describe("Files · the view", () => {
|
||||
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 {
|
||||
Clock,
|
||||
Download,
|
||||
Link,
|
||||
Pin,
|
||||
Scissors,
|
||||
Share2,
|
||||
Smartphone,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import { Download, LayoutGrid, Link, List, Scissors, Share2, UploadCloud } from "lucide-react";
|
||||
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 { formatBytes, formatDateTime, relativeTime } from "../lib/format";
|
||||
import { focusNavItem } from "../lib/listNav";
|
||||
import { notify } from "../lib/notifications";
|
||||
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 { usePref } from "../lib/usePref";
|
||||
import { JobThumb } from "./downloadRow";
|
||||
import { MetaEditModal } from "./downloadModals";
|
||||
import { jobTitle } from "./downloadTitle";
|
||||
import { folderLabel, sourceIcon, sourceLabel } from "./fileFolders";
|
||||
import { ALL_FILES, useFiles, useFilesActions, type FilesView } from "./filesContext";
|
||||
import { FileCard } from "./FileCard";
|
||||
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 { useFolderActions } from "./useFolderActions";
|
||||
import { RenameField } from "./RenameField";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import { Badge } from "./ui/Badge";
|
||||
import { Button } from "./ui/Button";
|
||||
import ColumnSortControl from "./ui/ColumnSortControl";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import DataTable, { type Column } from "./ui/DataTable";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Input } from "./ui/Input";
|
||||
import { IconLink } from "./ui/LinkButton";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import Pager from "./ui/Pager";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
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
|
||||
// picks — FilesProvider holds it), searched by the header's box. One aligned table: every kind of
|
||||
// value in its own column, an action that does not apply leaving its slot empty, the same menu on a
|
||||
// right click and on the "…" (DESIGN.md "Files page", "Library list columns").
|
||||
// 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 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,
|
||||
* 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;
|
||||
}
|
||||
|
||||
function daysLeft(iso: string): number {
|
||||
return Math.max(0, Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS));
|
||||
}
|
||||
|
||||
function RetentionCell({ file }: { file: LibraryFile }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
if (file.kept)
|
||||
return (
|
||||
<Badge>
|
||||
<Pin />
|
||||
{t("files.retention.kept")}
|
||||
</Badge>
|
||||
);
|
||||
if (!file.expires_at)
|
||||
return (
|
||||
<span className="text-xs text-muted" title={t("files.retention.never")}>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
const days = daysLeft(file.expires_at);
|
||||
const when = formatDateTime(file.expires_at, i18n.language);
|
||||
return days <= SOON_DAYS ? (
|
||||
<Badge tone="warn" title={t("files.retention.until", { when })}>
|
||||
<Clock />
|
||||
{t("files.retention.days", { count: days })}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs tabular-nums text-muted" title={t("files.retention.until", { when })}>
|
||||
{t("files.retention.days", { count: days })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NameCell({
|
||||
file,
|
||||
renaming,
|
||||
@@ -115,36 +115,14 @@ function NameCell({
|
||||
file: LibraryFile;
|
||||
renaming: boolean;
|
||||
onPlay: (() => void) | null;
|
||||
onRenamed: (name: string | null) => void;
|
||||
onRenamed: (name: string | null, byKey: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState(() => jobTitle(file));
|
||||
const channel = file.display_uploader || file.asset?.uploader || null;
|
||||
const clip = file.job_kind === "edit" || file.job_kind === "clip";
|
||||
if (renaming)
|
||||
return (
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-full"
|
||||
autoFocus
|
||||
value={draft}
|
||||
aria-label={t("files.actions.rename")}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => onRenamed(draft)}
|
||||
onKeyDown={(e) => {
|
||||
// preventDefault: a real Enter's own activation would otherwise press what focus lands on.
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onRenamed(draft);
|
||||
}
|
||||
// Unmounting the input fires no blur in React, so Escape really does cancel.
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onRenamed(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<RenameField initial={jobTitle(file)} label={t("files.actions.rename")} onDone={onRenamed} />
|
||||
);
|
||||
const title = <span className="block truncate text-sm font-medium">{jobTitle(file)}</span>;
|
||||
return (
|
||||
@@ -178,8 +156,8 @@ function NameCell({
|
||||
export default function Files() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { search, view } = useFiles();
|
||||
const { setView } = useFilesActions();
|
||||
const { search, view, focus, arrived } = useFiles();
|
||||
const { setView, consumeFocus } = useFilesActions();
|
||||
|
||||
const foldersQ = useLiveQuery(qk.fileFolders(), api.fileFolders, { intervalMs: 15000 });
|
||||
const folders = foldersQ.data ?? [];
|
||||
@@ -198,8 +176,18 @@ export default function Files() {
|
||||
const q = search.trim();
|
||||
const rows = all.filter((f) => matches(f, q, lang));
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(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 [shareFile, setShareFile] = 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 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).
|
||||
const title =
|
||||
shown.kind === "shared"
|
||||
@@ -230,8 +231,85 @@ export default function Files() {
|
||||
: ""
|
||||
: t("files.rail.all");
|
||||
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 playOf = (f: LibraryFile) =>
|
||||
(f.shared ? sharedRowActions(f) : libraryRowActions(f)).play ? () => play(f) : null;
|
||||
const columns: Column<LibraryFile>[] = [
|
||||
{
|
||||
key: "thumb",
|
||||
@@ -254,52 +332,37 @@ export default function Files() {
|
||||
cardPrimary: true,
|
||||
sortable: true,
|
||||
sortValue: (f) => jobTitle(f),
|
||||
render: (f) => {
|
||||
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
|
||||
return (
|
||||
<NameCell
|
||||
key={renamingId === f.id ? "renaming" : "shown"}
|
||||
file={f}
|
||||
renaming={renamingId === f.id}
|
||||
onPlay={a.play ? () => play(f) : null}
|
||||
onRenamed={(name) => {
|
||||
setRenamingId(null);
|
||||
if (name !== null) actions.rename(f, name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
render: (f) => (
|
||||
<NameCell
|
||||
key={renamingId === f.id ? "renaming" : "shown"}
|
||||
file={f}
|
||||
renaming={renamingId === f.id}
|
||||
onPlay={playOf(f)}
|
||||
onRenamed={(name, byKey) => {
|
||||
if (byKey) refocusFile.current = f.id;
|
||||
setRenamingId(null);
|
||||
if (name !== null) actions.rename(f, name);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "source",
|
||||
header: t("files.columns.source"),
|
||||
width: "8rem",
|
||||
tabletHidden: true,
|
||||
render: (f) => {
|
||||
const Icon = sourceIcon(f.source_key);
|
||||
return (
|
||||
<Badge>
|
||||
<Icon />
|
||||
{sourceLabel(f.source_key, t)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
render: (f) => <SourceBadge file={f} />,
|
||||
},
|
||||
{
|
||||
key: "phone",
|
||||
header: t("files.columns.phone"),
|
||||
width: "5.5rem",
|
||||
narrowHidden: true,
|
||||
// Every library video and sound is phone-safe by construction (backend downloads/playable.py);
|
||||
// a plain file is not a thing a phone plays.
|
||||
render: (f) =>
|
||||
f.asset?.media_kind === "file" ? (
|
||||
<span className="text-xs text-muted">—</span>
|
||||
) : (
|
||||
<Badge title={t("files.phoneOk")}>
|
||||
<Smartphone />
|
||||
<span className="text-success">✓</span>
|
||||
</Badge>
|
||||
<PhoneBadge file={f} />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -307,7 +370,7 @@ export default function Files() {
|
||||
header: t("files.columns.retention"),
|
||||
width: "7.5rem",
|
||||
narrowHidden: true,
|
||||
render: (f) => <RetentionCell file={f} />,
|
||||
render: (f) => <RetentionBadge file={f} />,
|
||||
},
|
||||
{
|
||||
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 =
|
||||
filesQ.isError && !filesQ.data ? (
|
||||
<StateMessage tone="error" onRetry={() => filesQ.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : filesQ.isLoading ? (
|
||||
<LoadingState shape="list" />
|
||||
<LoadingState shape={isGrid ? "grid" : "list"} />
|
||||
) : isGrid ? (
|
||||
grid
|
||||
) : (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(f) => String(f.id)}
|
||||
persistKey={LS.filesTable}
|
||||
persistKey={accountKey(LS.filesTable) ?? undefined}
|
||||
pageSize={50}
|
||||
keyboardNav
|
||||
fixedLayout
|
||||
rowProps={(f) => rowMenu.triggerProps(f)}
|
||||
emptyText={
|
||||
q
|
||||
? t("files.empty.search", { q })
|
||||
: t(
|
||||
shown.kind === "shared"
|
||||
? "files.empty.shared"
|
||||
: shown.kind === "folder"
|
||||
? "files.empty.folder"
|
||||
: "files.empty.all",
|
||||
)
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
reveal={reveal}
|
||||
narrowedBy={q}
|
||||
rowProps={(f) => ({ ...rowMenu.triggerProps(f), "data-file-id": f.id })}
|
||||
rowClassName={(f) =>
|
||||
ringed === f.id ? "bg-accent/8 ring-2 ring-inset ring-accent/60" : ""
|
||||
}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
);
|
||||
|
||||
const layoutOptions: ViewOption<FilesLayout>[] = LAYOUTS.map((id) => ({
|
||||
id,
|
||||
label: t(`files.layout.${id}`),
|
||||
icon: LAYOUT_ICON[id],
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageToolbar>
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-wrap items-center gap-x-3 gap-y-2 px-4 pt-2 pb-2">
|
||||
<h2 className="min-w-0 truncate text-lg font-bold">{title}</h2>
|
||||
{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 && (
|
||||
<span className="text-xs tabular-nums text-muted">
|
||||
{t("files.count", { count: rows.length })}
|
||||
</span>
|
||||
)}
|
||||
{canUpload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<UploadCloud className="w-4 h-4" />}
|
||||
onClick={() => setUpload(true)}
|
||||
className="ml-auto shrink-0"
|
||||
>
|
||||
{t("files.actions.upload")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Wraps on a phone: sort, view and Upload together are wider than the column. */}
|
||||
<span className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2">
|
||||
{isGrid && (
|
||||
<ColumnSortControl
|
||||
columns={columns}
|
||||
sort={sort}
|
||||
onChange={setSort}
|
||||
label={t("files.sort.label")}
|
||||
defaultLabel={t("files.sort.default")}
|
||||
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>
|
||||
</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}
|
||||
<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}>
|
||||
{upload && (
|
||||
<UploadDialog
|
||||
folderId={uploadFolder(shown, folders)}
|
||||
onClose={() => setUpload(false)}
|
||||
folderId={uploadTarget}
|
||||
initialFile={upload.file}
|
||||
onClose={() => setUpload(null)}
|
||||
/>
|
||||
)}
|
||||
{metaFile && <MetaEditModal job={metaFile} onClose={() => setMetaFile(null)} />}
|
||||
@@ -473,6 +633,21 @@ export default function Files() {
|
||||
{editFile && <VideoEditor job={editFile} onClose={() => setEditFile(null)} />}
|
||||
</Suspense>
|
||||
</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 { LS, getAccountRaw, setAccountRaw } from "../lib/storage";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { useNavigation } from "./navigationContext";
|
||||
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||
import {
|
||||
ALL_FILES,
|
||||
FilesActionsContext,
|
||||
FilesStateContext,
|
||||
parseItem,
|
||||
parseView,
|
||||
viewParam,
|
||||
type FilesActions,
|
||||
type FilesArrival,
|
||||
type FilesFocus,
|
||||
type FilesState,
|
||||
type FilesView,
|
||||
} 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.
|
||||
// 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).
|
||||
//
|
||||
// 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 {
|
||||
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 }) {
|
||||
const [search, setSearchState] = useState("");
|
||||
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 setView = useCallback((next: FilesView) => {
|
||||
@@ -42,10 +57,48 @@ export function FilesProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
const actions = useMemo<FilesActions>(() => ({ setSearch, setView }), [setSearch, setView]);
|
||||
useUrlMirror({
|
||||
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 (
|
||||
<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 { useTranslation } from "react-i18next";
|
||||
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 { compareText } from "../lib/collate";
|
||||
import { formatBytes } from "../lib/format";
|
||||
@@ -11,30 +12,46 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { folderIcon, folderLabel } from "./fileFolders";
|
||||
import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext";
|
||||
import { RenameField } from "./RenameField";
|
||||
import { useFolderActions } from "./useFolderActions";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
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
|
||||
// source folders the library fills by itself, the user's own folders — and the storage meter. Lifted
|
||||
// to App level like the other rails (the SidePanel's collapse and customise come with it); the view it
|
||||
// picks lives in FilesProvider, which the Files page reads.
|
||||
// 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({
|
||||
icon: Icon,
|
||||
label,
|
||||
count,
|
||||
view,
|
||||
trigger,
|
||||
folderId,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
count?: number;
|
||||
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 { setView } = useFilesActions();
|
||||
const active = sameView(view, current);
|
||||
return (
|
||||
<Pressable
|
||||
{...trigger}
|
||||
data-folder-id={folderId}
|
||||
selectable
|
||||
layer={!active}
|
||||
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 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 && (
|
||||
<Pressable
|
||||
onClick={() => foldersQ.refetch()}
|
||||
@@ -116,15 +155,29 @@ export default function FilesRail({
|
||||
{t("common.loadError")} — {t("common.retry")}
|
||||
</Pressable>
|
||||
);
|
||||
const entry = (f: FileFolder) => (
|
||||
<Entry
|
||||
key={f.id}
|
||||
icon={folderIcon(f)}
|
||||
label={folderLabel(f, t)}
|
||||
count={f.count}
|
||||
view={{ kind: "folder", id: f.id }}
|
||||
/>
|
||||
);
|
||||
const entry = (f: FileFolder) =>
|
||||
renamingId === f.id ? (
|
||||
<RenameField
|
||||
key={f.id}
|
||||
initial={f.name ?? ""}
|
||||
label={t("files.folder.rename")}
|
||||
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[] = [
|
||||
{
|
||||
@@ -159,12 +212,34 @@ export default function FilesRail({
|
||||
id: "own",
|
||||
title: t("files.rail.own"),
|
||||
body: (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div ref={railRef} className="flex flex-col gap-0.5">
|
||||
{failed}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
),
|
||||
},
|
||||
@@ -182,6 +257,11 @@ export default function FilesRail({
|
||||
items={items}
|
||||
>
|
||||
<StorageMeter />
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel={t("files.folder.menu")}
|
||||
items={menu.target ? folderActions.items(menu.target) : []}
|
||||
/>
|
||||
</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 })),
|
||||
notify: vi.fn(),
|
||||
navigateTo: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -37,12 +38,25 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
vi.mock("../lib/nav", () => ({ navigateTo: h.navigateTo }));
|
||||
|
||||
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 files = {
|
||||
setSearch: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
openFile: h.openFile,
|
||||
consumeFocus: vi.fn(),
|
||||
};
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<UploadDialog folderId={folderId} onClose={onClose} />
|
||||
<FilesActionsContext.Provider value={files}>
|
||||
<UploadDialog folderId={folderId} initialFile={initialFile} onClose={onClose} />
|
||||
</FilesActionsContext.Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
@@ -52,6 +66,8 @@ const fileInput = () => document.querySelector('input[type="file"]') as HTMLInpu
|
||||
|
||||
beforeEach(() => {
|
||||
h.uploadFile.mockClear();
|
||||
h.openFile.mockClear();
|
||||
h.navigateTo.mockClear();
|
||||
h.notify.mockClear();
|
||||
h.filesUsage.mockClear();
|
||||
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 () => {
|
||||
// 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);
|
||||
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.mp4")] } });
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
expect((h.uploadFile.mock.calls[0]![1] as { folderId?: number }).folderId).toBe(5);
|
||||
(h.notify.mock.calls[0]![0] as { action: { onClick: () => void } }).action.onClick();
|
||||
expect(h.navigateTo).toHaveBeenLastCalledWith("files");
|
||||
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
|
||||
expect(h.navigateTo).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
h.notify.mockClear();
|
||||
@@ -119,6 +138,17 @@ describe("UploadDialog", () => {
|
||||
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", () => {
|
||||
wrap();
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
|
||||
@@ -11,6 +11,8 @@ import { notifyActionError } from "../lib/actionErrors";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { navigateTo } from "../lib/nav";
|
||||
import type { DownloadJob } from "../lib/api";
|
||||
import { useFilesActions } from "./filesContext";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
@@ -26,21 +28,21 @@ import { Input } from "./ui/Input";
|
||||
// skip the whole dialog.
|
||||
const uploadedKey = (status: string) =>
|
||||
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
|
||||
// A done file is in Files; one the worker still converts is in the download queue until it is.
|
||||
const uploadedPage = (status: string) => (status === "done" ? "files" : "downloads");
|
||||
|
||||
export default function UploadDialog({
|
||||
folderId = null,
|
||||
initialFile = null,
|
||||
onClose,
|
||||
}: {
|
||||
/** The library folder the file goes into (an own folder, or the Uploads folder); null = Uploads. */
|
||||
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;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [file, setFile] = useState<File | null>(initialFile);
|
||||
const [name, setName] = useState("");
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
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
|
||||
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
|
||||
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) => {
|
||||
setFile(f);
|
||||
setName("");
|
||||
@@ -101,7 +110,7 @@ export default function UploadDialog({
|
||||
message: t(uploadedKey(job.status)),
|
||||
action: {
|
||||
label: t("downloads.dialog.view"),
|
||||
onClick: () => navigateTo(uploadedPage(job.status)),
|
||||
onClick: () => openUploaded(job),
|
||||
},
|
||||
});
|
||||
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);
|
||||
|
||||
/** 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 = {
|
||||
search: string;
|
||||
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 {
|
||||
setSearch: (q: string) => 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>({
|
||||
setSearch: () => {},
|
||||
setView: () => {},
|
||||
openFile: () => {},
|
||||
consumeFocus: () => {},
|
||||
});
|
||||
|
||||
export function useFiles(): FilesState {
|
||||
|
||||
@@ -42,6 +42,13 @@ describe("loadInitialPage precedence", () => {
|
||||
setUrl("?pl=3");
|
||||
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", () => {
|
||||
setUrl("?tags=1,2");
|
||||
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.
|
||||
if (params.has("v")) return "plex";
|
||||
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";
|
||||
// A bare URL → Home, the surface whose whole job is to answer "what should I watch" before
|
||||
// the user has to decide anything. It deliberately OVERRIDES the remembered last module: landing
|
||||
|
||||
@@ -17,7 +17,8 @@ const files = readSources(__dirname, [
|
||||
|
||||
/** file → [how many plain Pressables, why they are not titles]. */
|
||||
const PLAIN: Record<string, [number, string]> = {
|
||||
"FilesRail.tsx": [1, "the load-error retry"],
|
||||
"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": [
|
||||
2,
|
||||
"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",
|
||||
"keep": "Couldn't change whether the file is kept",
|
||||
"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",
|
||||
|
||||
@@ -56,5 +56,35 @@
|
||||
"unkeep": "Stop keeping",
|
||||
"details": "Details…",
|
||||
"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",
|
||||
"keep": "Nem sikerült módosítani a fájl megtartását",
|
||||
"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",
|
||||
|
||||
@@ -56,5 +56,35 @@
|
||||
"unkeep": "Megtartás visszavonása",
|
||||
"details": "Részletek…",
|
||||
"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}` : "";
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
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)}`),
|
||||
filesUsage: (): Promise<StorageUsage> => req("/api/files/usage"),
|
||||
updateFile: (id: number, patch: FilePatch): Promise<DownloadJob> =>
|
||||
|
||||
@@ -14,8 +14,13 @@ export const LS = {
|
||||
filesCollapsed: "siftlode.filesCollapsed",
|
||||
// The Files view last open (every file, shared with me, or a folder id), per-account.
|
||||
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",
|
||||
// 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",
|
||||
lang: "siftlode.lang",
|
||||
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`
|
||||
* finds — the same contract as `DataTable`'s `reveal`, for the card paint of the same list. */
|
||||
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 [size, setSize] = useState(() => parseCardSize(readAccount(sizeKey, null)));
|
||||
@@ -58,7 +60,7 @@ export function useCardPager<T>(
|
||||
writeAccount(sizeKey, s);
|
||||
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. */
|
||||
resetPage: useCallback(() => setPage(0), []),
|
||||
|
||||
Reference in New Issue
Block a user