Files
siftlode/frontend/src/modules/files/useFileActions.tsx
T

350 lines
12 KiB
TypeScript

import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
Download,
Eye,
FolderInput,
Info,
Link,
Pencil,
Pin,
PinOff,
PlayCircle,
RefreshCw,
RotateCcw,
Scissors,
Share2,
Trash2,
} from "lucide-react";
import { api, type DownloadJobKind, type FileFolder, type LibraryFile } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../../lib/downloads";
import { forgetFinished } from "../../lib/downloadQueue";
import { libraryRowActions, sharedRowActions } from "../../lib/downloadActions";
import { useConfirm } from "../../components/ui/confirmContext";
import { useCopyItems } from "../../components/ui/copyItems";
import { jobTitle } from "../../features/downloads/downloadTitle";
import { folderLabel } from "../../features/folders/fileFolders";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { menuGroups } from "../../components/ui/menuGroups";
import { msg } from "../../lib/notifications";
// The Files rows' ONE menu, and the actions the rows show as icons (the same ones, so a row's every
// action is reachable by right click — DESIGN.md "Library list columns"). My own files and the ones
// shared with me get different sets: a shared file is the owner's, so it is played, saved, cut or
// dropped from my list, never renamed, moved or deleted.
// A destructive confirm names the file (DESIGN.md "Copy"); what the delete takes depends on the kind:
// a download's file may stay for others who have it, an upload or a clip is its owner's alone.
const DELETE_BODY: Record<DownloadJobKind, string> = {
download: "downloads.confirm.deleteBody",
upload: "downloads.confirm.deleteUploadBody",
edit: "downloads.confirm.deleteEditBody",
clip: "downloads.confirm.deleteEditBody",
};
type Patch = { name?: string | null; folder_id?: number; kept?: boolean };
// Which failure title a PATCH speaks under: what it was asked to change.
const patchFail = (p: Patch) =>
"folder_id" in p
? "errors.writeFailed.file.move"
: "kept" in p
? "errors.writeFailed.file.keep"
: "errors.writeFailed.file.rename";
export function useFileActions({
folders,
onPlay,
onView,
onEdit,
onRename,
onEditDetails,
onManageShares,
}: {
/** The caller's folders — the Move submenu's places; null while they are not loaded. */
folders: readonly FileFolder[] | null;
onPlay: (file: LibraryFile, fromStart?: boolean) => void;
/** Open a file that is not media in the Files viewer. */
onView: (file: LibraryFile) => void;
onEdit: (file: LibraryFile) => void;
onRename: (file: LibraryFile) => void;
onEditDetails: (file: LibraryFile) => void;
onManageShares: (file: LibraryFile) => void;
}) {
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
const copy = useCopyItems();
const update = useMutation({
mutationFn: ({ id, patch }: { id: number; patch: Patch }) => api.updateFile(id, patch),
onSuccess: () => invalidateDownloads(qc),
onError: (e, v) => notifyActionError(e, msg(patchFail(v.patch))),
});
const remove = useMutation({
mutationFn: (id: number) => api.deleteFile(id),
onSuccess: (_r, id) => {
forgetFinished(id);
void invalidateDownloads(qc);
},
meta: { failTitle: "errors.writeFailed.file.delete" },
});
const redownload = useMutation({
mutationFn: (id: number) => api.redownloadDownload(id),
onSuccess: () => invalidateDownloads(qc),
meta: { failTitle: "errors.writeFailed.job.redownload" },
});
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedFile(id),
onSuccess: () => invalidateDownloads(qc),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
};
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (file: LibraryFile) => {
if (sharingId !== null) return;
setSharingId(file.id);
await quickShareLink(qc, { kind: "file", id: file.id }, t).finally(() => setSharingId(null));
};
const icon = (I: typeof PlayCircle) => <I className="w-4 h-4" />;
const play = (file: LibraryFile): ActionMenuItem[] => {
const started = (file.position_seconds ?? 0) > 0;
return [
{
id: "play",
label: started ? t("card.continue") : t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(file),
},
...(started
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: icon(RotateCcw),
onSelect: () => onPlay(file, true),
},
]
: []),
];
};
const view = (file: LibraryFile): ActionMenuItem => ({
id: "view",
label: t("files.actions.view"),
icon: icon(Eye),
onSelect: () => onView(file),
});
const save = (file: LibraryFile): ActionMenuItem => ({
id: "save",
label: t("downloads.actions.download"),
icon: icon(Download),
inGesture: true,
onSelect: () => {
const a = document.createElement("a");
a.href = api.fileUrl(file.id);
a.click();
},
});
const copyLink = (file: LibraryFile): ActionMenuItem => ({
id: "share-link",
label: t("files.actions.copyLink"),
icon: icon(Link),
disabled: sharingId !== null,
onSelect: () => void quickShare(file),
});
const edit = (file: LibraryFile): ActionMenuItem => ({
id: "edit",
label: t("downloads.actions.edit"),
icon: icon(Scissors),
onSelect: () => onEdit(file),
});
// "Copy link" is the share link here (the library's daily errand); the page it came from is the
// source link, named so.
const copies = (file: LibraryFile) =>
copy(jobTitle(file), file.source_url, "title", t("files.actions.copySourceLink"));
// Move: every own folder (the one it is in, ticked), then home — its own source folder — when it
// has left it. The server refuses any other place (`folders.move`), so none is offered.
const moveTo = (file: LibraryFile): ActionMenuItem => {
// Unknown (a failed read) is not "none": the row says the folders are missing, not that there are none.
if (folders === null)
return {
id: "move",
label: t("files.actions.move"),
icon: icon(FolderInput),
items: [
{
id: "move-unknown",
label: t("files.actions.foldersUnknown"),
disabled: true,
onSelect: () => {},
},
],
};
// The user's own folders, then the Hub folder: the ones a file may be moved into from anywhere.
const own = [
...folders.filter((f) => f.kind === "own"),
...folders.filter((f) => f.kind === "hub"),
];
const home = folders.find((f) => f.kind === "source" && f.source_key === file.source_key);
const to = (f: FileFolder) => () => update.mutate({ id: file.id, patch: { folder_id: f.id } });
const items: ActionMenuItem[] = own.map((f) => ({
id: `move-${f.id}`,
label: folderLabel(f, t),
check: { kind: "one", checked: f.id === file.folder_id },
onSelect: to(f),
}));
if (home && home.id !== file.folder_id)
items.push({
id: "move-home",
label: t("files.actions.moveHome", { folder: folderLabel(home, t) }),
separatorBefore: items.length > 0,
onSelect: to(home),
});
if (items.length === 0)
items.push({
id: "move-none",
label: t("files.actions.noOwnFolders"),
disabled: true,
onSelect: () => {},
});
return { id: "move", label: t("files.actions.move"), icon: icon(FolderInput), items };
};
function mineItems(file: LibraryFile, inViewer: boolean): ActionMenuItem[] {
const a = libraryRowActions(file);
return menuGroups(
// The row's icons first, in the row's order.
[
...(a.play ? play(file) : []),
...(a.view && !inViewer ? [view(file)] : []),
...(a.play ? [copyLink(file)] : []),
{
id: "share",
label: t("files.actions.share"),
icon: icon(Share2),
onSelect: () => onManageShares(file),
},
save(file),
],
[
...(a.edit ? [edit(file)] : []),
// Renamed in place, in its row: not from the viewer that covers the row.
...(inViewer
? []
: [
{
id: "rename",
label: t("files.actions.rename"),
icon: icon(Pencil),
onSelect: () => onRename(file),
},
]),
moveTo(file),
{
id: "keep",
label: t(file.kept ? "files.actions.unkeep" : "files.actions.keep"),
icon: icon(file.kept ? PinOff : Pin),
onSelect: () => update.mutate({ id: file.id, patch: { kept: !file.kept } }),
},
],
[
...(a.editDetails
? [
{
id: "details",
label: t("files.actions.details"),
icon: icon(Info),
onSelect: () => onEditDetails(file),
},
]
: []),
...(a.redownload
? [
{
id: "redownload",
label: t("files.actions.redownload"),
icon: icon(RefreshCw),
onSelect: () =>
void confirmThen(
t("downloads.confirm.redownloadTitle", { name: jobTitle(file) }),
t("downloads.confirm.redownloadBody"),
t("downloads.confirm.redownloadAction"),
() => redownload.mutate(file.id),
),
},
]
: []),
...copies(file),
],
[
{
id: "delete",
label: t("files.actions.delete"),
icon: icon(Trash2),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.deleteTitle", { name: jobTitle(file) }),
t(DELETE_BODY[file.job_kind ?? "download"]),
t("downloads.actions.delete"),
() => remove.mutate(file.id),
),
},
],
);
}
function sharedItems(file: LibraryFile, inViewer: boolean): ActionMenuItem[] {
const a = sharedRowActions(file);
return menuGroups(
[
...(a.play ? play(file) : []),
...(a.view && !inViewer ? [view(file)] : []),
...(a.save ? [save(file)] : []),
],
a.edit ? [edit(file)] : [],
copies(file),
[
{
id: "remove-shared",
label: t("files.actions.removeShared"),
icon: icon(Trash2),
danger: true,
onSelect: () =>
void confirmThen(
t("downloads.confirm.removeSharedTitle", { name: jobTitle(file) }),
t("downloads.confirm.removeSharedBody"),
t("downloads.actions.removeShared"),
() => removeShared.mutate(file.id),
),
},
],
);
}
/** The file's menu rows; `inViewer`: the viewer's "…", which leaves out what the viewer is or
* what needs the row it covers. */
const items = (file: LibraryFile, inViewer = false): ActionMenuItem[] =>
file.shared ? sharedItems(file, inViewer) : mineItems(file, inViewer);
/** The row's Copy-link icon: the menu's row, one click. */
const share = (file: LibraryFile) => void quickShare(file);
/** Save the inline rename: an empty name gives the file its own title back. The field starts on
* the name the file shows, so leaving it as it was changes nothing — not even a name never set. */
const rename = (file: LibraryFile, name: string) => {
const next = name.trim();
if (next === jobTitle(file) || next === (file.display_name ?? "")) return;
update.mutate({ id: file.id, patch: { name: next || null } });
};
return { items, share, rename, sharing: sharingId };
}