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, RotateCcw, Scissors, Share2, SkipBack, 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"; // 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 = { 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, t(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(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) => ; 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(SkipBack), 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(RotateCcw), 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 }; }