Files
siftlode/frontend/src/components/ProfileEditor.tsx
T
peter 3ef2c38eb4 feat(design): dialogs open by purpose and keep their commit row pinned (E28 S3f1)
Modal takes a required initialFocus (field / close / action), a field that
loads late takes the focus while it still rests on the close button, and
ModalFooter portals a dialog's commit row under the scrolling body from any
depth. The header and footer fades follow the body's scroll timeline, and the
viewport's interactive-widget=resizes-content keeps the footer above an
on-screen keyboard.
2026-09-27 01:33:37 +02:00

262 lines
9.2 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 { 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 = () => 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">{p.name}</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>
);
}