2026-06-18 12:01:15 +08:00
|
|
|
import { useMemo, useState } from "react";
|
|
|
|
|
import { X, Trash2, Pencil, Check, Loader2 } from "lucide-react";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import type { PeqEqUi } from "../constants";
|
2026-06-26 12:03:31 +08:00
|
|
|
import { PEQ_PRESET_NAME_MAX_LENGTH } from "../constants";
|
|
|
|
|
import { clampPeqPresetName, isPeqPresetNameTooLong } from "../peqPresetName";
|
|
|
|
|
import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint";
|
2026-06-18 12:01:15 +08:00
|
|
|
|
|
|
|
|
/* ── Custom checkbox ── */
|
|
|
|
|
function Checkbox({
|
|
|
|
|
checked,
|
|
|
|
|
onChange,
|
|
|
|
|
className,
|
|
|
|
|
}: {
|
|
|
|
|
checked: boolean;
|
|
|
|
|
onChange: () => void;
|
|
|
|
|
className?: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onChange}
|
|
|
|
|
className={cn(
|
|
|
|
|
"shrink-0 flex items-center justify-center w-[18px] h-[18px] rounded-[5px] border transition-all duration-200 active:scale-90 outline-none focus-visible:ring-2 focus-visible:ring-[#00FFF6]/40",
|
|
|
|
|
checked
|
|
|
|
|
? "border-transparent bg-gradient-to-br from-[#00FFF6] to-[#00C8C0] shadow-[0_0_8px_rgba(0,255,246,0.35)]"
|
|
|
|
|
: "border-white/25 bg-white/[0.04] hover:border-white/40 hover:bg-white/[0.08]",
|
|
|
|
|
className
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
size={12}
|
|
|
|
|
strokeWidth={3}
|
|
|
|
|
className={cn(
|
|
|
|
|
"text-black transition-all duration-200",
|
|
|
|
|
checked ? "opacity-100 scale-100" : "opacity-0 scale-50"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PeqItem = {
|
|
|
|
|
name: string;
|
|
|
|
|
filters?: any[] | string;
|
|
|
|
|
autoPre?: number;
|
|
|
|
|
preamp?: number;
|
|
|
|
|
canDel?: number;
|
|
|
|
|
brand?: string;
|
|
|
|
|
model?: string;
|
|
|
|
|
target?: string;
|
|
|
|
|
form?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Preset management dialog.
|
|
|
|
|
*
|
|
|
|
|
* Behavior:
|
|
|
|
|
* - Delete (single / batch): calls onDeletePresets callback with preset names,
|
|
|
|
|
* which should call info.cgi with { peqRemove: names } then sync.
|
|
|
|
|
* - Rename: calls onRenamePreset(oldName, newName, item) callback which
|
|
|
|
|
* should: 1) peqChange with new name + same EQ data 2) peqRemove old name.
|
|
|
|
|
*/
|
|
|
|
|
export function PeqPresetManageDialog({
|
|
|
|
|
open,
|
|
|
|
|
items,
|
|
|
|
|
selectedIdx,
|
|
|
|
|
onClose,
|
|
|
|
|
eqUi,
|
|
|
|
|
onDeletePresets,
|
|
|
|
|
onRenamePreset,
|
|
|
|
|
}: {
|
|
|
|
|
open: boolean;
|
|
|
|
|
items: PeqItem[];
|
|
|
|
|
selectedIdx: number;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
eqUi: PeqEqUi;
|
|
|
|
|
/**
|
|
|
|
|
* Called with preset names to delete. Should call api.removePeq(names) then
|
|
|
|
|
* fetchEqSyncPeq + applyPeqStateToUI. Return true on success, false on failure.
|
|
|
|
|
*/
|
|
|
|
|
onDeletePresets: (names: string[]) => Promise<boolean>;
|
|
|
|
|
/**
|
|
|
|
|
* Called to rename a preset. Implementation should:
|
|
|
|
|
* 1) upgradePeqChange({ peqChange: { name: newName, filters, ...rest } })
|
|
|
|
|
* 2) api.removePeq([oldName])
|
|
|
|
|
* 3) fetchEqSyncPeq + applyPeqStateToUI
|
|
|
|
|
* Return true on success, false on failure.
|
|
|
|
|
*/
|
|
|
|
|
onRenamePreset: (oldName: string, newName: string, item: PeqItem) => Promise<boolean>;
|
|
|
|
|
}) {
|
|
|
|
|
const [checked, setChecked] = useState<Record<number, boolean>>({});
|
|
|
|
|
const [editingIdx, setEditingIdx] = useState<number | null>(null);
|
|
|
|
|
const [editingName, setEditingName] = useState("");
|
|
|
|
|
|
|
|
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
|
const [renaming, setRenaming] = useState(false);
|
|
|
|
|
|
|
|
|
|
const checkedIdxs = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
Object.entries(checked)
|
|
|
|
|
.filter(([, v]) => v)
|
|
|
|
|
.map(([k]) => Number(k)),
|
|
|
|
|
[checked]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const allChecked = items.length > 0 && checkedIdxs.length === items.length;
|
|
|
|
|
|
|
|
|
|
if (!open) return null;
|
|
|
|
|
|
|
|
|
|
const toggleAll = () => {
|
|
|
|
|
if (allChecked) {
|
|
|
|
|
setChecked({});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const next: Record<number, boolean> = {};
|
|
|
|
|
for (let i = 0; i < items.length; i++) next[i] = true;
|
|
|
|
|
setChecked(next);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleOne = (idx: number) => {
|
|
|
|
|
setChecked(prev => ({ ...prev, [idx]: !prev[idx] }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const beginRename = (idx: number) => {
|
|
|
|
|
const name = items[idx]?.name ?? "";
|
|
|
|
|
setEditingIdx(idx);
|
|
|
|
|
setEditingName(name);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const commitRename = async () => {
|
|
|
|
|
if (editingIdx === null) return;
|
|
|
|
|
const nextName = editingName.trim();
|
|
|
|
|
if (!nextName) {
|
|
|
|
|
toast.error(eqUi.renameEmptyName);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-26 12:03:31 +08:00
|
|
|
if (isPeqPresetNameTooLong(nextName)) {
|
|
|
|
|
toast.error(eqUi.addPresetNameTooLong);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-18 12:01:15 +08:00
|
|
|
const oldItem = items[editingIdx];
|
|
|
|
|
if (!oldItem) return;
|
|
|
|
|
if (nextName === oldItem.name) {
|
|
|
|
|
setEditingIdx(null);
|
|
|
|
|
setEditingName("");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (items.some((it, i) => i !== editingIdx && it.name === nextName)) {
|
|
|
|
|
toast.error(eqUi.renameNameExists);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setRenaming(true);
|
|
|
|
|
try {
|
|
|
|
|
const ok = await onRenamePreset(oldItem.name, nextName, oldItem);
|
|
|
|
|
if (ok) {
|
|
|
|
|
setEditingIdx(null);
|
|
|
|
|
setEditingName("");
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setRenaming(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const confirmAndDelete = async (idxs: number[]) => {
|
|
|
|
|
const uniq = Array.from(new Set(idxs)).filter(
|
|
|
|
|
i => i >= 0 && i < items.length
|
|
|
|
|
);
|
|
|
|
|
if (uniq.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const names = uniq.map(i => items[i].name);
|
|
|
|
|
|
|
|
|
|
// Confirmation dialog
|
|
|
|
|
const confirmMsg =
|
|
|
|
|
names.length === 1
|
|
|
|
|
? eqUi.deletePresetConfirm.replace("{{name}}", names[0])
|
|
|
|
|
: eqUi.batchDeleteConfirm.replace("{{count}}", String(names.length));
|
|
|
|
|
if (!window.confirm(confirmMsg)) return;
|
|
|
|
|
|
|
|
|
|
setDeleting(true);
|
|
|
|
|
try {
|
|
|
|
|
const ok = await onDeletePresets(names);
|
|
|
|
|
if (ok) {
|
|
|
|
|
setChecked({});
|
|
|
|
|
if (editingIdx !== null && uniq.includes(editingIdx)) {
|
|
|
|
|
setEditingIdx(null);
|
|
|
|
|
setEditingName("");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setDeleting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="fixed inset-0 z-[140] flex items-center justify-center bg-black/65 px-4">
|
|
|
|
|
<div
|
|
|
|
|
className="w-full max-w-[520px] rounded-[14px] p-5"
|
|
|
|
|
style={{
|
|
|
|
|
background:
|
|
|
|
|
"linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
|
|
|
|
|
border: "1px solid rgba(255,255,255,0.12)",
|
|
|
|
|
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
|
|
|
|
|
}}
|
|
|
|
|
onClick={e => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<h3 className="text-[18px] font-semibold leading-tight text-white/90">
|
|
|
|
|
管理 EQ 预设
|
|
|
|
|
</h3>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80"
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
aria-label="Close"
|
|
|
|
|
>
|
|
|
|
|
<X size={20} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between gap-3 mb-3">
|
|
|
|
|
<label className="flex items-center gap-2.5 text-[13px] text-white/70 select-none cursor-pointer">
|
|
|
|
|
<Checkbox checked={allChecked} onChange={toggleAll} />
|
|
|
|
|
全选
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={checkedIdxs.length === 0 || deleting}
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded-full px-3 py-1.5 text-[13px] font-semibold transition-all active:scale-[0.98] flex items-center gap-1.5",
|
|
|
|
|
checkedIdxs.length === 0 || deleting
|
|
|
|
|
? "bg-white/10 text-white/30 cursor-not-allowed"
|
|
|
|
|
: "bg-red-500/15 text-red-300 hover:bg-red-500/20"
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => void confirmAndDelete(checkedIdxs)}
|
|
|
|
|
>
|
|
|
|
|
{deleting && <Loader2 size={13} className="animate-spin" />}
|
|
|
|
|
批量删除
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
className="rounded-[10px] overflow-hidden max-h-[46vh] overflow-y-auto"
|
|
|
|
|
style={{
|
|
|
|
|
background: "rgba(10,12,16,0.98)",
|
|
|
|
|
border: "1px solid rgba(0,255,246,0.22)",
|
|
|
|
|
boxShadow: "0 8px 20px rgba(0,0,0,0.35)",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{items.length === 0 ? (
|
|
|
|
|
<div className="py-10 text-center text-[13px] text-white/30">
|
|
|
|
|
暂无预设
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
items.map((item, idx) => {
|
|
|
|
|
const active = idx === selectedIdx;
|
|
|
|
|
const isEditing = idx === editingIdx;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={`${item.name}-${idx}`}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center gap-2 px-3 py-2.5 border-b border-white/[0.06] last:border-b-0",
|
|
|
|
|
active ? "bg-white/[0.06]" : ""
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={!!checked[idx]}
|
|
|
|
|
onChange={() => toggleOne(idx)}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
{isEditing ? (
|
2026-06-26 12:03:31 +08:00
|
|
|
<div className="space-y-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<input
|
|
|
|
|
value={editingName}
|
|
|
|
|
onChange={e => setEditingName(clampPeqPresetName(e.target.value))}
|
|
|
|
|
maxLength={PEQ_PRESET_NAME_MAX_LENGTH}
|
|
|
|
|
className="h-9 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[14px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40"
|
|
|
|
|
/>
|
2026-06-18 12:01:15 +08:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={renaming}
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded-full px-3 py-1.5 text-[13px] font-semibold active:scale-[0.98] flex items-center gap-1.5",
|
|
|
|
|
renaming
|
|
|
|
|
? "bg-[#00FFF6]/40 text-black/50 cursor-not-allowed"
|
|
|
|
|
: "bg-[#00FFF6] text-black"
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => void commitRename()}
|
|
|
|
|
>
|
|
|
|
|
{renaming && <Loader2 size={13} className="animate-spin" />}
|
|
|
|
|
保存
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={renaming}
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded-full px-3 py-1.5 text-[13px] font-medium bg-white/10 text-white/70 active:scale-[0.98]",
|
|
|
|
|
renaming && "cursor-not-allowed opacity-50"
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setEditingIdx(null);
|
|
|
|
|
setEditingName("");
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
取消
|
|
|
|
|
</button>
|
2026-06-26 12:03:31 +08:00
|
|
|
</div>
|
|
|
|
|
<PeqPresetNameLengthHint value={editingName} />
|
2026-06-18 12:01:15 +08:00
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div
|
|
|
|
|
className="truncate text-[13px] text-white/85"
|
|
|
|
|
title={item.name}
|
|
|
|
|
>
|
|
|
|
|
{item.name}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!isEditing && (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-white/70 active:scale-95 transition-transform"
|
|
|
|
|
style={{
|
|
|
|
|
background: "rgba(255,255,255,0.06)",
|
|
|
|
|
border: "1px solid rgba(255,255,255,0.1)",
|
|
|
|
|
}}
|
|
|
|
|
title="重命名"
|
|
|
|
|
onClick={() => beginRename(idx)}
|
|
|
|
|
>
|
|
|
|
|
<Pencil size={14} />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-red-300/80 active:scale-95 transition-transform"
|
|
|
|
|
style={{
|
|
|
|
|
background: "rgba(239,68,68,0.08)",
|
|
|
|
|
border: "1px solid rgba(239,68,68,0.2)",
|
|
|
|
|
}}
|
|
|
|
|
title="删除"
|
|
|
|
|
onClick={() => void confirmAndDelete([idx])}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={14} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|