eslint now also enforces: - the React Compiler diagnostics that `recommended` leaves out (todo, memo-dependencies and ten zero-hit tripwires); - a description on every suppression, and no blanket disables; - no focused or skipped tests (vitest and playwright); - the typed pair no-floating-promises and no-deprecated. Lint also covers e2e/ and the TypeScript build configs, which `eslint .` had been skipping in silence. Every hit is fixed or suppressed inline with a named reason: - The 31 try statements the compiler cannot lower yet carry a suppression until S3 rewrites them. Five other compiler skips were plain fixes: a module counter bumped inside a hook, a ref read in a default parameter, and a local named like the <section> it renders. - The real defects behind floating promises are fixed. ChatDock threw at nobody when the device key store could not be opened. VideoEditor's play() rejection went unhandled. loadYouTubeApi never rejected, so a blocked IFrame API left the player on an empty stage forever; it now fails on a script error or after 15 s, shows its own error with the way out, and lets the next open try again. - MutableRefObject, FormEvent and lucide's deprecated brand icon are replaced. The YouTube glyph now lives in lib/brandIcons. - The lint-probe harness lets its virtual file names into TypeScript's default project, so the contract tests still run the real config.
263 lines
9.3 KiB
TypeScript
263 lines
9.3 KiB
TypeScript
import { useState } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
|
|
import Modal from "./ui/Modal";
|
|
import { useConfirm } from "./confirmContext";
|
|
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
|
|
import { profileName } from "../lib/vocab";
|
|
import { Button } from "./ui/Button";
|
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
|
import FilterSelect from "./ui/FilterSelect";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { CheckRow } from "./ui/NativeControls";
|
|
import { Input } from "./ui/Input";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
import { SectionLabel } from "./ui/Headings";
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles });
|
|
const profiles = profilesQ.data ?? [];
|
|
const builtins = profiles.filter((p) => p.is_builtin);
|
|
const mine = profiles.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);
|
|
|
|
const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
|
|
const startNew = () => {
|
|
setEditing(null);
|
|
setName("");
|
|
setSpec(BLANK);
|
|
setFormOpen(true);
|
|
};
|
|
const startEdit = (p: DownloadProfile) => {
|
|
setEditing(p);
|
|
setName(p.name);
|
|
setSpec({ ...BLANK, ...p.spec });
|
|
setFormOpen(true);
|
|
};
|
|
|
|
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 }));
|
|
|
|
return (
|
|
<Modal
|
|
initialFocus="close"
|
|
title={t("downloads.profiles.title")}
|
|
onClose={onClose}
|
|
maxWidth="max-w-xl"
|
|
>
|
|
{/* Existing formats */}
|
|
<div className="space-y-1.5 mb-4">
|
|
<SectionLabel level={3}>{t("downloads.profiles.builtin")}</SectionLabel>
|
|
{builtins.map((p) => (
|
|
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
|
|
<Lock className="w-3.5 h-3.5 text-muted shrink-0" />
|
|
<span className="flex-1 truncate">{profileName(t, p)}</span>
|
|
</div>
|
|
))}
|
|
{mine.length > 0 && (
|
|
<SectionLabel level={3} className="pt-2">
|
|
{t("downloads.profiles.yours")}
|
|
</SectionLabel>
|
|
)}
|
|
{mine.map((p) => (
|
|
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
|
|
<span className="flex-1 truncate">{p.name}</span>
|
|
<IconButton onClick={() => startEdit(p)} label={t("downloads.actions.rename")}>
|
|
<Pencil />
|
|
</IconButton>
|
|
<IconButton
|
|
variant="danger"
|
|
onClick={() => onDelete(p)}
|
|
label={t("downloads.profiles.delete")}
|
|
>
|
|
<Trash2 />
|
|
</IconButton>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{!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 className="block text-sm font-medium mb-1">{t("downloads.profiles.name")}</label>
|
|
<Input
|
|
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>
|
|
<div className="space-y-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>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|