Files
siftlode/frontend/src/components/ProfileEditor.tsx
T
peter 76f8de258c refactor(ui): R10 — .btn-primary recipe for the primary/action buttons
Add a paint-only .btn-primary recipe (solid accent bg + accent-fg text +
hover:opacity dim) in index.css @layer components, alongside the selection-paint
recipes (.pill/.chip/.is-current). Migrate ~38 primary/action buttons across 24
files off the hand-rolled 'bg-accent text-accent-fg hover:opacity-90', keeping
each site's geometry (padding/radius/font/gap/disabled/shadow).

Deliberately NOT migrated: accent BADGES/labels (spans), the chat message bubble,
selection toggles (tableHeader — R8 popover territory), and the round play FAB
(hover:scale, not the opacity-dim primary idiom). Three previously hover-less
primary buttons (SettingsPanel/SetupWizard/DraftSaveBar) gain the standard hover
dim for consistency.

Gate green (tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed a migrated
button computes to accent/accent-fg and both recipe rules load.
2026-08-11 04:04:06 +02:00

285 lines
10 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 "./Modal";
import { useConfirm } from "./ConfirmProvider";
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
import { inputCls } from "./ui/form";
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 (
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
);
}
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);
},
});
const del = useMutation({
mutationFn: (id: number) => api.deleteDownloadProfile(id),
onSuccess: invalidate,
});
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 set = (k: keyof DownloadSpec) => (e: React.ChangeEvent<HTMLSelectElement>) =>
patch({ [k]: e.target.value === "" ? null : e.target.value } as Partial<DownloadSpec>);
return (
<Modal title={t("downloads.profiles.title")} onClose={onClose} maxWidth="max-w-xl">
{/* Existing formats */}
<div className="space-y-1.5 mb-4">
<div className="text-xs uppercase tracking-wide text-muted">
{t("downloads.profiles.builtin")}
</div>
{builtins.map((p) => (
<div
key={p.id}
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card"
>
<Lock className="w-3.5 h-3.5 text-muted shrink-0" />
<span className="flex-1 truncate">{p.name}</span>
</div>
))}
{mine.length > 0 && (
<div className="text-xs uppercase tracking-wide text-muted pt-2">
{t("downloads.profiles.yours")}
</div>
)}
{mine.map((p) => (
<div
key={p.id}
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card"
>
<span className="flex-1 truncate">{p.name}</span>
<button
onClick={() => startEdit(p)}
title={t("downloads.actions.rename")}
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onDelete(p)}
title={t("downloads.profiles.delete")}
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-danger"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
{!formOpen ? (
<button
onClick={startNew}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
>
<Plus className="w-4 h-4" /> {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
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("downloads.profiles.namePlaceholder")}
className={inputCls}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.mode")}
</label>
<select value={spec.mode} onChange={set("mode")} className={inputCls}>
<option value="av">{t("downloads.profiles.modeAv")}</option>
<option value="v">{t("downloads.profiles.modeV")}</option>
<option value="a">{t("downloads.profiles.modeA")}</option>
</select>
</div>
{spec.mode !== "a" ? (
<div>
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.quality")}
</label>
<select
value={spec.max_height ?? ""}
onChange={(e) =>
patch({ max_height: e.target.value === "" ? null : Number(e.target.value) })
}
className={inputCls}
>
{HEIGHTS.map((h) => (
<option key={h ?? "best"} value={h ?? ""}>
{h ? `${h}p` : t("downloads.profiles.best")}
</option>
))}
</select>
</div>
) : (
<div>
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.audioFormat")}
</label>
<select
value={spec.audio_format ?? "m4a"}
onChange={set("audio_format")}
className={inputCls}
>
<option value="m4a">M4A</option>
<option value="mp3">MP3</option>
<option value="opus">Opus</option>
</select>
</div>
)}
{spec.mode !== "a" && (
<>
<div>
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.container")}
</label>
<select
value={spec.container ?? "mp4"}
onChange={set("container")}
className={inputCls}
>
<option value="mp4">MP4</option>
<option value="mkv">MKV</option>
<option value="webm">WebM</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t("downloads.profiles.vcodec")}
</label>
<select value={spec.vcodec ?? ""} onChange={set("vcodec")} className={inputCls}>
<option value="">{t("downloads.profiles.any")}</option>
<option value="h264">H.264</option>
<option value="vp9">VP9</option>
<option value="av1">AV1</option>
</select>
</div>
</>
)}
</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
onClick={() => setFormOpen(false)}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("downloads.actions.cancel")}
</button>
<button
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
>
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
</button>
</div>
</div>
)}
</Modal>
);
}