import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Ban, Download, Link2, Pause, Pencil, Play, PlayCircle, RefreshCw, RotateCcw, Scissors, Share2, SkipBack, Trash2, } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { api, type DownloadJob, type DownloadJobKind } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { invalidateDownloads, quickShareLink } from "../lib/downloads"; import { libraryRowActions, sharedRowActions } from "../lib/downloadActions"; import { useConfirm } from "./confirmContext"; import { useCopyItems } from "./copyItems"; import { jobTitle } from "./downloadTitle"; import { type ActionMenuItem } from "./ui/ActionMenu"; import { menuGroups } from "./ui/menuGroups"; // A download's ONE action list: the row's "…", its right-click, long-press and Shift+F10, // on the queue, the library and the shared tab. The row keeps its primary action (play; pause / // resume / retry in the queue), the save link and its state (the error and its details) in sight; // a library row also keeps Share (the link copy) in sight, ahead of save. The rest — the editors, // managing the shares, re-download, the copies and the destructive rows — lives here. export type DownloadTab = "queue" | "done" | "shared"; export type JobOp = "pause" | "resume" | "cancel" | "delete" | "redownload"; // The Cancel confirmation of a queued job; an upload's names what it deletes. const CANCEL_DOWNLOAD = { title: "downloads.confirm.cancelTitle", body: "downloads.confirm.cancelBody", action: "downloads.confirm.cancelAction", }; const CANCEL_UPLOAD = { title: "downloads.confirm.cancelUploadTitle", body: "downloads.confirm.cancelUploadBody", action: "downloads.confirm.cancelUploadAction", }; const CANCEL_EDIT = { title: "downloads.confirm.cancelEditTitle", body: "downloads.confirm.cancelEditBody", action: "downloads.confirm.cancelEditAction", }; // Cancelling an upload deletes the file it uploaded (there is nothing to fetch again), so its // confirmation says so — "stop the download" would read as safe to undo. A clip is being cut, not // downloaded. const CANCEL_COPY: Record = { download: CANCEL_DOWNLOAD, upload: CANCEL_UPLOAD, edit: CANCEL_EDIT, }; // Deleting a download may leave its shared cache file for other holders. An upload and a clip are // their owner's alone (a clip's asset is keyed by its user), so deleting one removes the file for good. const DELETE_COPY: Record = { download: { title: "downloads.confirm.deleteTitle", body: "downloads.confirm.deleteBody" }, upload: { title: "downloads.confirm.deleteUploadTitle", body: "downloads.confirm.deleteUploadBody", }, edit: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" }, }; export function useDownloadActions({ onPlay, onEdit, onEditDetails, onManageShares, }: { onPlay: (job: DownloadJob, fromStart?: boolean) => void; onEdit: (job: DownloadJob) => void; onEditDetails: (job: DownloadJob) => void; onManageShares: (job: DownloadJob) => void; }) { const qc = useQueryClient(); const { t } = useTranslation(); const confirm = useConfirm(); const copy = useCopyItems(); const act = useMutation({ mutationFn: ({ id, op }: { id: number; op: JobOp }) => op === "pause" ? api.pauseDownload(id) : op === "resume" ? api.resumeDownload(id) : op === "cancel" ? api.cancelDownload(id) : op === "redownload" ? api.redownloadDownload(id) : api.deleteDownload(id), onSuccess: () => invalidateDownloads(qc), // A 400/409/422/500 opens the global error dialog with the backend's own message (and is then // skipped here); a job that is gone (404) or a lost connection is said here, naming the action. onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)), }); // Remove a shared-with-me item from your list (only your grant; the owner's file is untouched). const removeShared = useMutation({ mutationFn: (id: number) => api.removeSharedDownload(id), onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }), meta: { failTitle: "errors.writeFailed.removeShared" }, }); // The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title // it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel". const confirmThen = async (title: string, body: string, action: string, fn: () => void) => { if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn(); }; // One-click share: ensure a downloadable link, copy it, toast with "open in new tab" // (see quickShareLink). Re-choosing reuses the existing link; a per-job in-flight guard also stops a // double pick from racing two POSTs into two links before the cache is primed. Advanced controls // (password, expiry, revoke, share-with-a-user) live in the Share dialog, "Manage sharing". const [sharingId, setSharingId] = useState(null); const quickShare = async (job: DownloadJob) => { if (sharingId !== null) return; setSharingId(job.id); await quickShareLink(qc, job, t).finally(() => setSharingId(null)); }; const icon = (I: typeof Play) => ; // A started item continues, and can also start over — the card menus' words and glyph (C5). const play = (job: DownloadJob): ActionMenuItem[] => { const started = (job.position_seconds ?? 0) > 0; return [ { id: "play", label: started ? t("card.continue") : t("downloads.actions.play"), icon: icon(PlayCircle), onSelect: () => onPlay(job), }, ...(started ? [ { id: "restart", label: t("card.restartTitle"), icon: icon(SkipBack), onSelect: () => onPlay(job, true), }, ] : []), ]; }; // The same link the row's save button is, followed inside the click: the server answers it as an // attachment, so the page stays where it is. const save = (job: DownloadJob): ActionMenuItem => ({ id: "save", label: t("downloads.actions.download"), icon: icon(Download), inGesture: true, onSelect: () => { const a = document.createElement("a"); a.href = api.downloadFileUrl(job.id); a.click(); }, }); const edit = (job: DownloadJob): ActionMenuItem => ({ id: "edit", label: t("downloads.actions.edit"), icon: icon(Scissors), onSelect: () => onEdit(job), }); const copies = (job: DownloadJob) => copy(jobTitle(job), job.source_url); function queueItems(job: DownloadJob): ActionMenuItem[] { const running = job.status === "queued" || job.status === "running"; return menuGroups( [ running ? { id: "pause", label: t("downloads.actions.pause"), icon: icon(Pause), onSelect: () => act.mutate({ id: job.id, op: "pause" }), } : { id: "resume", label: t( job.status === "error" ? "downloads.actions.retry" : "downloads.actions.resume", ), icon: icon(job.status === "error" ? RefreshCw : Play), onSelect: () => act.mutate({ id: job.id, op: "resume" }), }, ], copies(job), [ { id: "cancel", label: t("downloads.actions.cancel"), icon: icon(Ban), danger: true, onSelect: () => { const copy = CANCEL_COPY[job.job_kind ?? "download"]; void confirmThen(t(copy.title), t(copy.body), t(copy.action), () => act.mutate({ id: job.id, op: "cancel" }), ); }, }, ], ); } function libraryItems(job: DownloadJob): ActionMenuItem[] { // Row actions gated on the stored media_kind + job_kind (see downloadActions). const a = libraryRowActions(job); return menuGroups( // The row's own buttons first, in the row's order: sharing the link is the library's main // errand, ahead of saving the file. [ ...(a.play ? play(job) : []), { id: "share", label: t("downloads.actions.share"), icon: icon(Share2), disabled: sharingId !== null, onSelect: () => void quickShare(job), }, save(job), ], [ ...(a.edit ? [edit(job)] : []), ...(a.editDetails ? [ { id: "edit-details", label: t("downloads.actions.editDetails"), icon: icon(Pencil), onSelect: () => onEditDetails(job), }, ] : []), ...(a.redownload ? [ { id: "redownload", label: t("downloads.actions.redownload"), icon: icon(RotateCcw), onSelect: () => void confirmThen( t("downloads.confirm.redownloadTitle"), t("downloads.confirm.redownloadBody"), t("downloads.confirm.redownloadAction"), () => act.mutate({ id: job.id, op: "redownload" }), ), }, ] : []), ], [ { id: "share-manage", label: t("downloads.actions.shareManage"), icon: icon(Link2), onSelect: () => onManageShares(job), }, ...copies(job), ], [ { id: "delete", label: t("downloads.actions.delete"), icon: icon(Trash2), danger: true, onSelect: () => { const copy = DELETE_COPY[job.job_kind ?? "download"]; void confirmThen(t(copy.title), t(copy.body), t("downloads.actions.delete"), () => act.mutate({ id: job.id, op: "delete" }), ); }, }, ], ); } function sharedItems(job: DownloadJob): ActionMenuItem[] { // Same media_kind + can_download gate as the library, via the shared helper. const a = sharedRowActions(job); return menuGroups( [...(a.play ? play(job) : []), ...(a.save ? [save(job)] : [])], a.edit ? [edit(job)] : [], copies(job), [ { id: "remove-shared", label: t("downloads.actions.removeShared"), icon: icon(Trash2), danger: true, onSelect: () => void confirmThen( t("downloads.confirm.removeSharedTitle"), t("downloads.confirm.removeSharedBody"), t("downloads.actions.removeShared"), () => removeShared.mutate(job.id), ), }, ], ); } /** The job's menu rows on the tab it is listed on. */ const items = (job: DownloadJob, tab: DownloadTab): ActionMenuItem[] => tab === "queue" ? queueItems(job) : tab === "done" ? libraryItems(job) : sharedItems(job); /** The queue's visible primary action and the error dialog's Retry. */ const run = (job: DownloadJob, op: JobOp) => act.mutate({ id: job.id, op }); /** The library row's visible Share: the same one-click link copy as its menu row. */ const share = (job: DownloadJob) => void quickShare(job); return { items, run, share }; }