From d22c64f4dde51ae29f1e5205c19cb03126159e5a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 11 Oct 2026 15:59:12 +0200 Subject: [PATCH] feat(files): folders managed in place, a grid per folder, drop to upload, open on one file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 . - The table's saved page size is per account. --- frontend/src/components/FileCard.tsx | 113 +++++ .../src/components/Files.menu.dom.test.tsx | 304 +++++++++++- frontend/src/components/Files.tsx | 451 ++++++++++++------ .../src/components/FilesProvider.dom.test.tsx | 92 ++++ frontend/src/components/FilesProvider.tsx | 61 ++- .../src/components/FilesRail.dom.test.tsx | 157 ++++++ frontend/src/components/FilesRail.tsx | 106 +++- frontend/src/components/RenameField.tsx | 55 +++ .../src/components/UploadDialog.dom.test.tsx | 38 +- frontend/src/components/UploadDialog.tsx | 19 +- frontend/src/components/fileBadges.tsx | 75 +++ frontend/src/components/filesContext.ts | 31 +- .../src/components/navigation.dom.test.ts | 7 + frontend/src/components/navigationInit.ts | 2 +- .../ui/pressableSelectable.contract.test.ts | 3 +- frontend/src/components/useFolderActions.tsx | 130 +++++ frontend/src/i18n/locales/en/errors.json | 6 + frontend/src/i18n/locales/en/files.json | 32 +- frontend/src/i18n/locales/hu/errors.json | 6 + frontend/src/i18n/locales/hu/files.json | 32 +- frontend/src/lib/api/files.ts | 13 + frontend/src/lib/storage.ts | 7 +- frontend/src/lib/useCardPager.ts | 4 +- 23 files changed, 1569 insertions(+), 175 deletions(-) create mode 100644 frontend/src/components/FileCard.tsx create mode 100644 frontend/src/components/FilesProvider.dom.test.tsx create mode 100644 frontend/src/components/FilesRail.dom.test.tsx create mode 100644 frontend/src/components/RenameField.tsx create mode 100644 frontend/src/components/fileBadges.tsx create mode 100644 frontend/src/components/useFolderActions.tsx diff --git a/frontend/src/components/FileCard.tsx b/frontend/src/components/FileCard.tsx new file mode 100644 index 00000000..230e8688 --- /dev/null +++ b/frontend/src/components/FileCard.tsx @@ -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; + moreProps: Record; + 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 = ; + return ( + + {onPlay ? ( + + {thumb} +
+ +
+
+ ) : ( +
{thumb}
+ )} + {clip && ( + + + {t("downloads.clipBadge")} + + )} + + {/* `media`: legible on any picture, the Plex cards' hover row's look. */} + + + + } + title={ + + {title} + + } + meta={ + + + + {channel && {channel}} + + + + + + + } + more={{ props: moreProps, open: menuOpen }} + moreLabel={t("files.rowMenu")} + /> + ); +} diff --git a/frontend/src/components/Files.menu.dom.test.tsx b/frontend/src/components/Files.menu.dom.test.tsx index e2f1655a..3de77046 100644 --- a/frontend/src/components/Files.menu.dom.test.tsx +++ b/frontend/src/components/Files.menu.dom.test.tsx @@ -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( - - + + @@ -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 . + 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(ALL_FILES); + turn = setView; + return ( + + + + + + ); + } + render( + + + , + ); + 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; + 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(startView); + const [focus, setFocus] = useState({ 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 ( + + + + + + ); + } + render( + + + , + ); + 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(); + }); +}); diff --git a/frontend/src/components/Files.tsx b/frontend/src/components/Files.tsx index b946a737..a794aa79 100644 --- a/frontend/src/components/Files.tsx +++ b/frontend/src/components/Files.tsx @@ -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 { + 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("[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(`[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 ( - - - {t("files.retention.kept")} - - ); - if (!file.expires_at) - return ( - - — - - ); - const days = daysLeft(file.expires_at); - const when = formatDateTime(file.expires_at, i18n.language); - return days <= SOON_DAYS ? ( - - - {t("files.retention.days", { count: days })} - - ) : ( - - {t("files.retention.days", { count: days })} - - ); -} - 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 ( - e.currentTarget.select()} - onChange={(e) => setDraft(e.target.value)} - onBlur={() => onRenamed(draft)} - onKeyDown={(e) => { - // preventDefault: a real Enter's own activation would otherwise press what focus lands on. - if (e.key === "Enter") { - e.preventDefault(); - onRenamed(draft); - } - // Unmounting the input fires no blur in React, so Escape really does cancel. - if (e.key === "Escape") { - e.preventDefault(); - onRenamed(null); - } - }} - /> + ); const title = {jobTitle(file)}; 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(null); const [renamingId, setRenamingId] = useState(null); - const [upload, setUpload] = useState(false); + // A rename left by Enter or Escape hands the keyboard back to its row (RenameField). + const refocusFile = useRef(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(null); const [shareFile, setShareFile] = useState(null); const [editFile, setEditFile] = useState(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({ keyOf: folderKey }); + // Its rename left by a key hands the keyboard back to the "…" it was opened from. + const folderMore = useRef(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>( + "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("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 = ; + const playOf = (f: LibraryFile) => + (f.shared ? sharedRowActions(f) : libraryRowActions(f)).play ? () => play(f) : null; const columns: Column[] = [ { 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 ( - play(f) : null} - onRenamed={(name) => { - setRenamingId(null); - if (name !== null) actions.rename(f, name); - }} - /> - ); - }, + render: (f) => ( + { + 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 ( - - - {sourceLabel(f.source_key, t)} - - ); - }, + render: (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" ? ( — ) : ( - - - ✓ - + ), }, { @@ -307,7 +370,7 @@ export default function Files() { header: t("files.columns.retention"), width: "7.5rem", narrowHidden: true, - render: (f) => , + render: (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 ? ( + {emptyText} + ) : ( + <> + + {paged.map((f, i) => ( + + ))} + +
+ +
+ + ); + const body = filesQ.isError && !filesQ.data ? ( filesQ.refetch()}> {t("common.loadError")} ) : filesQ.isLoading ? ( - + + ) : isGrid ? ( + grid ) : ( 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[] = LAYOUTS.map((id) => ({ + id, + label: t(`files.layout.${id}`), + icon: LAYOUT_ICON[id], + })); + return ( <>
-

{title}

+ {renamingFolder && open?.kind === "own" ? ( + { + refocusFolderMore.current = byKey; + setRenamingFolder(false); + if (name !== null) folderActions.rename(open, name); + }} + /> + ) : ( +

{title}

+ )} + {open && !renamingFolder && ( + + )} {filesQ.data && ( {t("files.count", { count: rows.length })} )} - {canUpload && ( - - )} + {/* Wraps on a phone: sort, view and Upload together are wider than the column. */} + + {isGrid && ( + + )} + setLayouts({ ...layouts, [viewKey]: next })} + label={t("files.layout.label")} + /> + {canUpload && ( + + )} +
-
+
{body} + {upload && ( setUpload(false)} + folderId={uploadTarget} + initialFile={upload.file} + onClose={() => setUpload(null)} /> )} {metaFile && setMetaFile(null)} />} @@ -473,6 +633,21 @@ export default function Files() { {editFile && setEditFile(null)} />}
+ {dragging && ( + // Through : declared in the page, a `fixed` layer is trapped in its stacking + // context and the rail paints over it. + +
+ + + {t("files.drop.here", { folder: uploadTargetName })} + +
+
+ )} ); } diff --git a/frontend/src/components/FilesProvider.dom.test.tsx b/frontend/src/components/FilesProvider.dom.test.tsx new file mode 100644 index 00000000..faf610c6 --- /dev/null +++ b/frontend/src/components/FilesProvider.dom.test.tsx @@ -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 }) => ( + {children} +); +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 }); + }); +}); diff --git a/frontend/src/components/FilesProvider.tsx b/frontend/src/components/FilesProvider.tsx index 3abed005..e2144549 100644 --- a/frontend/src/components/FilesProvider.tsx +++ b/frontend/src/components/FilesProvider.tsx @@ -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(initialView); + const [focusRaw, setFocusRaw] = useState(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(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(() => ({ search, view }), [search, view]); - const actions = useMemo(() => ({ setSearch, setView }), [setSearch, setView]); + useUrlMirror({ + folder: page === "files" ? viewParam(view) : null, + item: focus ? String(focus.id) : null, + }); + + const state = useMemo( + () => ({ search, view, focus, arrived }), + [search, view, focus, arrived], + ); + const actions = useMemo( + () => ({ setSearch, setView, openFile, consumeFocus }), + [setSearch, setView, openFile, consumeFocus], + ); return ( diff --git a/frontend/src/components/FilesRail.dom.test.tsx b/frontend/src/components/FilesRail.dom.test.tsx new file mode 100644 index 00000000..bd6bf770 --- /dev/null +++ b/frontend/src/components/FilesRail.dom.test.tsx @@ -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 . +// 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(); + 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) => ( +
{i.body}
+ ))} + {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 => ({ + 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( + + + , + ); + await screen.findByText("files.folder.new"); +} + +const entry = (name: string) => screen.getByText(name).closest("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"))); + }); +}); diff --git a/frontend/src/components/FilesRail.tsx b/frontend/src/components/FilesRail.tsx index c709c182..1242fc4d 100644 --- a/frontend/src/components/FilesRail.tsx +++ b/frontend/src/components/FilesRail.tsx @@ -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; + /** 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 ( f.kind === "source" && shown(f)).sort(byName); const own = folders.filter((f) => f.kind === "own").sort(byName); + const [renamingId, setRenamingId] = useState(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(null); + const refocusFolder = useRef(null); + useEffect(() => { + const id = refocusFolder.current; + if (renamingId !== null || id === null) return; + refocusFolder.current = null; + railRef.current?.querySelector(`[data-folder-id="${id}"]`)?.focus(); + }, [renamingId]); + const newFolder = useRef(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({ keyOf: folderKey }); + const failed = foldersQ.isError && !foldersQ.data && ( foldersQ.refetch()} @@ -116,15 +155,29 @@ export default function FilesRail({ {t("common.loadError")} — {t("common.retry")} ); - const entry = (f: FileFolder) => ( - - ); + const entry = (f: FileFolder) => + renamingId === f.id ? ( + { + if (byKey) refocusFolder.current = f.id; + setRenamingId(null); + if (name !== null) folderActions.rename(f, name); + }} + /> + ) : ( + + ); const items: PanelItem[] = [ { @@ -159,12 +212,34 @@ export default function FilesRail({ id: "own", title: t("files.rail.own"), body: ( -
+
{failed} {own.map(entry)} - {foldersQ.data && own.length === 0 && ( + {foldersQ.data && own.length === 0 && !creating && ( {t("files.rail.noOwn")} )} + {creating ? ( + { + refocusNew.current = byKey; + setCreating(false); + if (name !== null) void folderActions.add(name); + }} + /> + ) : ( + foldersQ.data && ( + setCreating(true)} + className="flex min-h-8 w-full items-center gap-2 rounded-lg px-2 text-sm text-muted hover:text-fg" + > + + {t("files.folder.new")} + + ) + )}
), }, @@ -182,6 +257,11 @@ export default function FilesRail({ items={items} > + ); } diff --git a/frontend/src/components/RenameField.tsx b/frontend/src/components/RenameField.tsx new file mode 100644 index 00000000..56bd313b --- /dev/null +++ b/frontend/src/components/RenameField.tsx @@ -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 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 ( + 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); + } + }} + /> + ); +} diff --git a/frontend/src/components/UploadDialog.dom.test.tsx b/frontend/src/components/UploadDialog.dom.test.tsx index 595c954b..f0bd42e0 100644 --- a/frontend/src/components/UploadDialog.dom.test.tsx +++ b/frontend/src/components/UploadDialog.dom.test.tsx @@ -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( - + + + , ); }; @@ -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")!; diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index 46e81d3d..8c1f5ae5 100644 --- a/frontend/src/components/UploadDialog.tsx +++ b/frontend/src/components/UploadDialog.tsx @@ -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(null); + const [file, setFile] = useState(initialFile); const [name, setName] = useState(""); const [dragOver, setDragOver] = useState(false); const [pct, setPct] = useState(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(); diff --git a/frontend/src/components/fileBadges.tsx b/frontend/src/components/fileBadges.tsx new file mode 100644 index 00000000..a831e977 --- /dev/null +++ b/frontend/src/components/fileBadges.tsx @@ -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 ( + + {/* 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)} + + ); +} + +/** 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 ( + + + ✓ + + ); +} + +/** 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 ( + + + {t("files.retention.kept")} + + ); + if (!file.expires_at) + return quiet ? null : ( + + — + + ); + const days = daysLeft(file.expires_at); + const when = formatDateTime(file.expires_at, i18n.language); + if (days <= SOON_DAYS) + return ( + + + {t("files.retention.days", { count: days })} + + ); + return quiet ? null : ( + + {t("files.retention.days", { count: days })} + + ); +} diff --git a/frontend/src/components/filesContext.ts b/frontend/src/components/filesContext.ts index 7e2995ef..1d80b76d 100644 --- a/frontend/src/components/filesContext.ts +++ b/frontend/src/components/filesContext.ts @@ -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({ search: "", view: ALL_FILES }); +export const FilesStateContext = createContext({ + search: "", + view: ALL_FILES, + focus: null, + arrived: null, +}); export const FilesActionsContext = createContext({ setSearch: () => {}, setView: () => {}, + openFile: () => {}, + consumeFocus: () => {}, }); export function useFiles(): FilesState { diff --git a/frontend/src/components/navigation.dom.test.ts b/frontend/src/components/navigation.dom.test.ts index 56b96ce2..380693a6 100644 --- a/frontend/src/components/navigation.dom.test.ts +++ b/frontend/src/components/navigation.dom.test.ts @@ -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"); diff --git a/frontend/src/components/navigationInit.ts b/frontend/src/components/navigationInit.ts index dfcd6e0c..abc8a2d9 100644 --- a/frontend/src/components/navigationInit.ts +++ b/frontend/src/components/navigationInit.ts @@ -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 diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts index 06b5bc37..e2bd83b3 100644 --- a/frontend/src/components/ui/pressableSelectable.contract.test.ts +++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts @@ -17,7 +17,8 @@ const files = readSources(__dirname, [ /** file → [how many plain Pressables, why they are not titles]. */ const PLAIN: Record = { - "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)", diff --git a/frontend/src/components/useFolderActions.tsx b/frontend/src/components/useFolderActions.tsx new file mode 100644 index 00000000..2cb29da5 --- /dev/null +++ b/frontend/src/components/useFolderActions.tsx @@ -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) => ; + 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 => { + const next = name.trim(); + if (!next) return null; + try { + return await create.mutateAsync(next); + } catch { + return null; + } + }; + + return { items, rename, add }; +} diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index f19995fd..e9d0a5b7 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -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", diff --git a/frontend/src/i18n/locales/en/files.json b/frontend/src/i18n/locales/en/files.json index 20596505..fbbca5d0 100644 --- a/frontend/src/i18n/locales/en/files.json +++ b/frontend/src/i18n/locales/en/files.json @@ -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" } diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index 6ba23727..3464db2b 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/files.json b/frontend/src/i18n/locales/hu/files.json index d83a6f89..f3e52ea6 100644 --- a/frontend/src/i18n/locales/hu/files.json +++ b/frontend/src/i18n/locales/hu/files.json @@ -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" } diff --git a/frontend/src/lib/api/files.ts b/frontend/src/lib/api/files.ts index 8ec3afb8..0ea1e88b 100644 --- a/frontend/src/lib/api/files.ts +++ b/frontend/src/lib/api/files.ts @@ -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 => req("/api/files/folders"), + createFolder: (name: string): Promise => + req("/api/files/folders", { method: "POST", body: JSON.stringify({ name }) }), + updateFolder: (id: number, patch: FolderPatch): Promise => + 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 => req(`/api/files${listQuery(p)}`), filesUsage: (): Promise => req("/api/files/usage"), updateFile: (id: number, patch: FilePatch): Promise => diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 134abd23..6aaf9cae 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -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", diff --git a/frontend/src/lib/useCardPager.ts b/frontend/src/lib/useCardPager.ts index ea78cf74..6d71a964 100644 --- a/frontend/src/lib/useCardPager.ts +++ b/frontend/src/lib/useCardPager.ts @@ -17,6 +17,8 @@ export function useCardPager( /** 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( 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), []),