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:
2026-10-11 15:59:12 +02:00
parent 85aa917b38
commit d22c64f4dd
23 changed files with 1569 additions and 175 deletions
+113
View File
@@ -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")}
/>
);
}
+300 -4
View File
@@ -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
View File
@@ -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 });
});
});
+57 -4
View File
@@ -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")));
});
});
+93 -13
View File
@@ -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>
);
}
+55
View File
@@ -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")!;
+14 -5
View File
@@ -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();
+75
View File
@@ -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>
);
}
+30 -1
View File
@@ -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");
+1 -1
View File
@@ -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 };
}
+6
View File
@@ -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",
+31 -1
View File
@@ -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"
}
+6
View File
@@ -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",
+31 -1
View File
@@ -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"
}
+13
View File
@@ -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> =>
+6 -1
View File
@@ -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",
+3 -1
View File
@@ -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), []),