216 lines
9.1 KiB
TypeScript
216 lines
9.1 KiB
TypeScript
|
|
import { useState } from "react";
|
||
|
|
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";
|
||
|
|
|
||
|
|
const inputCls =
|
||
|
|
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
|
||
|
|
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
||
|
|
|
||
|
|
const BLANK: DownloadSpec = {
|
||
|
|
mode: "av",
|
||
|
|
max_height: 1080,
|
||
|
|
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: ["download-profiles"], 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: ["download-profiles"] });
|
||
|
|
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 bg-surface/60">
|
||
|
|
<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 bg-surface/60">
|
||
|
|
<span className="flex-1 truncate">{p.name}</span>
|
||
|
|
<button onClick={() => startEdit(p)} title={t("downloads.actions.rename")} className="p-1 rounded 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 hover:bg-surface text-muted hover:text-red-400">
|
||
|
|
<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 bg-accent text-accent-fg hover:opacity-90 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 bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
|
||
|
|
>
|
||
|
|
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</Modal>
|
||
|
|
);
|
||
|
|
}
|