350 lines
12 KiB
TypeScript
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 };
|
|
}
|