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:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
@@ -0,0 +1,403 @@
import { useEffect, useId, useRef, useState } from "react";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CopyPlus, Lock, Pencil, Plus, Trash2 } from "lucide-react";
import Modal from "../../components/ui/Modal";
import { useConfirm } from "../../components/ui/confirmContext";
import { api, type DownloadProfile, type DownloadSpec } from "../../lib/api";
import { profileName } from "../../lib/vocab";
import { Button } from "../../components/ui/Button";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import FilterSelect from "../../components/ui/FilterSelect";
import { CheckRow } from "../../components/ui/NativeControls";
import { Input } from "../../components/ui/Input";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { ContextMenu } from "../../components/ui/ContextMenu";
import DataTable, { type Column } from "../../components/ui/DataTable";
import { menuGroups } from "../../components/ui/menuGroups";
import { MoreButton } from "../../components/ui/MoreButton";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useContextMenu } from "../../components/ui/useContextMenu";
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
const BLANK: DownloadSpec = {
mode: "av",
max_height: 720,
container: "mp4",
audio_format: "m4a",
vcodec: null,
embed_subs: false,
embed_chapters: true,
embed_thumbnail: true,
sponsorblock: false,
};
function Check({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<CheckRow checked={checked} onChange={onChange} className="text-sm">
{label}
</CheckRow>
);
}
// The download formats: the built-in ones (read-only, a base to start from) and the user's own, in one
// aligned list whose rows each have ONE menu (its "…", a right-click, a long-press, Shift+F10) —
// Edit · Duplicate · Delete, a built-in's only Duplicate. The new/edit form opens under the list.
// Settings → Downloads shows it in place; the download dialog's "Manage formats" in a modal.
const MODE_KEY = {
av: "downloads.profiles.modeAv",
v: "downloads.profiles.modeV",
a: "downloads.profiles.modeA",
} as const;
/** What a format saves, in a line: the content, the quality (or the audio format), the extras. */
function specSummary(t: TFunction, spec: Partial<DownloadSpec>): string {
const mode = spec.mode ?? "av";
const parts = [
t(MODE_KEY[mode]),
mode === "a"
? (spec.audio_format ?? "m4a").toUpperCase()
: spec.max_height
? `≤${spec.max_height}p`
: t("downloads.profiles.best"),
];
if (mode !== "a" && spec.embed_subs) parts.push(t("downloads.profiles.short.subs"));
if (spec.embed_chapters) parts.push(t("downloads.profiles.short.chapters"));
if (spec.embed_thumbnail) parts.push(t("downloads.profiles.short.thumbnail"));
if (spec.sponsorblock) parts.push(t("downloads.profiles.short.sponsorblock"));
return parts.join(" · ");
}
export function DownloadFormats() {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles });
// Built-ins first, then the user's own — the order the dialog's menu lists them in.
const all = profilesQ.data ?? [];
const profiles = [...all.filter((p) => p.is_builtin), ...all.filter((p) => !p.is_builtin)];
const [editing, setEditing] = useState<DownloadProfile | null>(null);
const [name, setName] = useState("");
const [spec, setSpec] = useState<DownloadSpec>(BLANK);
const [formOpen, setFormOpen] = useState(false);
// Opening the form (New, Edit, Duplicate) puts the keyboard in its name: the form opens away from
// the control that asked for it, and "New format" is replaced by it.
const [opened, setOpened] = useState(0);
const nameField = useRef<HTMLInputElement>(null);
const nameId = useId();
useEffect(() => {
if (opened) nameField.current?.focus();
}, [opened]);
const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
const startNew = () => {
setEditing(null);
setName("");
setSpec(BLANK);
setFormOpen(true);
setOpened((n) => n + 1);
};
const startEdit = (p: DownloadProfile) => {
setEditing(p);
setName(p.name);
setSpec({ ...BLANK, ...p.spec });
setFormOpen(true);
setOpened((n) => n + 1);
};
// A new format from an existing one: its settings, and a name of its own to give.
const startCopy = (p: DownloadProfile) => {
setEditing(null);
setName("");
setSpec({ ...BLANK, ...p.spec });
setFormOpen(true);
setOpened((n) => n + 1);
};
const save = useMutation({
mutationFn: () =>
editing
? api.updateDownloadProfile(editing.id, { name, spec })
: api.createDownloadProfile({ name, spec }),
onSuccess: () => {
invalidate();
setFormOpen(false);
},
meta: { failTitle: "errors.writeFailed.downloadProfileSave" },
});
const del = useMutation({
mutationFn: (id: number) => api.deleteDownloadProfile(id),
onSuccess: invalidate,
meta: { failTitle: "errors.writeFailed.downloadProfileDelete" },
});
const onDelete = async (p: DownloadProfile) => {
if (
await confirm({
title: t("downloads.profiles.delete"),
message: t("downloads.profiles.deleteConfirm", { name: p.name }),
confirmLabel: t("downloads.profiles.delete"),
danger: true,
})
) {
del.mutate(p.id);
}
};
const patch = (p: Partial<DownloadSpec>) => setSpec((s) => ({ ...s, ...p }));
const menu = useContextMenu<DownloadProfile>({ keyOf: (p) => p.id });
const items = (p: DownloadProfile): ActionMenuItem[] =>
menuGroups(
p.is_builtin
? []
: [
{
id: "edit",
label: t("downloads.profiles.edit"),
icon: <Pencil className="w-4 h-4" />,
onSelect: () => startEdit(p),
},
],
[
{
id: "copy",
label: t("downloads.profiles.duplicate"),
icon: <CopyPlus className="w-4 h-4" />,
onSelect: () => startCopy(p),
},
],
p.is_builtin
? []
: [
{
id: "delete",
label: t("downloads.profiles.delete"),
icon: <Trash2 className="w-4 h-4" />,
danger: true,
onSelect: () => void onDelete(p),
},
],
);
const columns: Column<DownloadProfile>[] = [
{
key: "name",
header: t("downloads.profiles.name"),
cardPrimary: true,
render: (p) => (
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{profileName(t, p)}</span>
{p.is_builtin && (
<Lock
className="w-3.5 h-3.5 shrink-0 text-muted"
aria-label={t("downloads.profiles.builtin")}
/>
)}
</span>
),
},
{
key: "spec",
header: t("downloads.profiles.saves"),
render: (p) => {
const summary = specSummary(t, p.spec);
return (
<span className="block truncate text-muted" title={summary}>
{summary}
</span>
);
},
},
{
key: "menu",
header: "",
width: "3rem",
align: "right",
cardLabel: false,
cardRow: true,
render: (p) => (
<MoreButton
label={t("downloads.profiles.rowMenu")}
size="md"
expanded={menu.open && menu.target?.id === p.id}
menuProps={menu.buttonProps(p)}
/>
),
},
];
return (
<div>
{profilesQ.isError && !profilesQ.data ? (
<StateMessage tone="error" onRetry={() => void profilesQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : !profilesQ.data ? (
<LoadingState shape="list" />
) : (
<div className="mb-4">
<DataTable
rows={profiles}
columns={columns}
rowKey={(p) => String(p.id)}
pager={false}
keyboardNav
fixedLayout
rowProps={(p) => menu.triggerProps(p)}
/>
</div>
)}
<ContextMenu
menu={menu}
ariaLabel={t("downloads.profiles.rowMenu")}
items={menu.target ? items(menu.target) : []}
/>
{!formOpen ? (
<Button variant="primary" size="sm" icon={<Plus className="w-4 h-4" />} onClick={startNew}>
{t("downloads.profiles.new")}
</Button>
) : (
<div className="rounded-xl border border-border p-4 space-y-3">
<div>
<label htmlFor={nameId} className="block text-sm font-medium mb-1">
{t("downloads.profiles.name")}
</label>
<Input
id={nameId}
ref={nameField}
className="w-full"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("downloads.profiles.namePlaceholder")}
/>
</div>
{/* Mode on its own row: three worded choices need the width a half column lacks on a
phone. Segmented for the short fixed sets, a menu for the seven heights (owner's pick). */}
<div>
<span className="block text-sm font-medium mb-1" aria-hidden="true">
{t("downloads.profiles.mode")}
</span>
<SegmentedToggle
ariaLabel={t("downloads.profiles.mode")}
size="md"
fullWidth
value={spec.mode}
onChange={(mode) => patch({ mode })}
options={[
{ value: "av", label: t("downloads.profiles.modeAv") },
{ value: "v", label: t("downloads.profiles.modeV") },
{ value: "a", label: t("downloads.profiles.modeA") },
]}
/>
</div>
<div>
{spec.mode !== "a" ? (
<div>
<span className="block text-sm font-medium mb-1" aria-hidden="true">
{t("downloads.profiles.quality")}
</span>
<FilterSelect
label={t("downloads.profiles.quality")}
hideLabel
selected={[String(spec.max_height ?? "")]}
onChange={([v]) => v !== undefined && patch({ max_height: v ? Number(v) : null })}
options={HEIGHTS.map((h) => ({
value: String(h ?? ""),
label: h ? `${h}p` : t("downloads.profiles.best"),
}))}
/>
</div>
) : (
<div>
<span className="block text-sm font-medium mb-1" aria-hidden="true">
{t("downloads.profiles.audioFormat")}
</span>
{/* Only browser-playable audio: AAC (m4a) + MP3. Opus is dropped — Safari plays it
unreliably as a bare file, and the module's purpose is in-browser playback. */}
<SegmentedToggle
ariaLabel={t("downloads.profiles.audioFormat")}
size="md"
value={spec.audio_format ?? "m4a"}
onChange={(audio_format) => patch({ audio_format })}
options={[
{ value: "m4a", label: "M4A" },
{ value: "mp3", label: "MP3" },
]}
/>
</div>
)}
{/* Container/codec are no longer user-selectable: every download is clamped to the
mp4/H.264/AAC compat profile (the only universally browser-playable combo) so a
shared link plays everywhere, iOS/Safari included. Enforced in backend `normalize`. */}
</div>
{/* One option a line: a CheckRow is inline, and in the wide Settings column four of them
ran together on one line. */}
<div className="flex flex-col items-start gap-1.5 pt-1">
{spec.mode !== "a" && (
<Check
label={t("downloads.profiles.embedSubs")}
checked={spec.embed_subs}
onChange={(v) => patch({ embed_subs: v })}
/>
)}
<Check
label={t("downloads.profiles.embedChapters")}
checked={spec.embed_chapters}
onChange={(v) => patch({ embed_chapters: v })}
/>
<Check
label={t("downloads.profiles.embedThumbnail")}
checked={spec.embed_thumbnail}
onChange={(v) => patch({ embed_thumbnail: v })}
/>
<Check
label={t("downloads.profiles.sponsorblock")}
checked={spec.sponsorblock}
onChange={(v) => patch({ sponsorblock: v })}
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button variant="ghost" size="sm" onClick={() => setFormOpen(false)}>
{t("downloads.actions.cancel")}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
>
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
</Button>
</div>
</div>
)}
</div>
);
}
export default function ProfileEditor({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
return (
<Modal
initialFocus="close"
title={t("downloads.profiles.title")}
onClose={onClose}
maxWidth="max-w-xl"
>
<DownloadFormats />
</Modal>
);
}