refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
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 "./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 };
|
||||
}
|
||||
Reference in New Issue
Block a user