Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell, the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate register, with its three imports. Placement: Plex's chip builder moves into lib beside the feed's, the folder names into features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed" itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic. The floating header re-measures when one page's pills replace another's in the same commit (the claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a page with a rail adds in lib.
349 lines
12 KiB
TypeScript
349 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,
|
|
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<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, 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<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(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 };
|
|
}
|