- 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.
131 lines
5.1 KiB
TypeScript
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 };
|
|
}
|