Files
siftlode/frontend/src/components/useFolderActions.tsx
T
peter d22c64f4dd 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.
2026-10-11 15:59:12 +02:00

131 lines
5.1 KiB
TypeScript

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 };
}