Files
controller-v2/client/src/pages/eq/components/PeqPresetManageDialog.tsx
T

634 lines
22 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { X, Trash2, Pencil, Check, Loader2, ArrowBigDown, ArrowBigUp, ArrowBigUpDash, ArrowBigDownDash } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import type { PeqEqUi } from "../constants";
import { PEQ_PRESET_NAME_MAX_LENGTH } from "../constants";
import { clampPeqPresetName, isPeqPresetNameTooLong } from "../peqPresetName";
import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint";
import { PeqOverwriteConfirmDialog } from "./PeqOverwriteConfirmDialog";
import { eqInterp } from "../utils";
/* ── 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.
* Firmware >= threshold: peqRename. Below threshold: peqChange then peqRemove.
* - Reorder (when reorderEnabled): up/down buttons + apply order → peqSoft.
*/
export function PeqPresetManageDialog({
open,
items,
selectedIdx,
reorderEnabled,
onClose,
eqUi,
onDeletePresets,
onRenamePreset,
onConfirmOrder,
}: {
open: boolean;
items: PeqItem[];
selectedIdx: number;
/** When true: show reorder controls and apply-order button (firmware-gated in parent). */
reorderEnabled: boolean;
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 is version-dependent:
* - firmware >= 29: api.renamePeq(oldName, newName) then sync
* - firmware < 29: peqChange + peqRemove then sync
* Return true on success, false on failure.
*/
onRenamePreset: (oldName: string, newName: string, item: PeqItem) => Promise<boolean>;
/**
* Called to persist a new preset order (firmware >= 29).
* `names` is the full ordered list; `peqSelect` is the index of the currently selected preset.
*/
onConfirmOrder: (names: string[], peqSelect: number) => Promise<boolean>;
}) {
const [checked, setChecked] = useState<Record<number, boolean>>({});
const [editingIdx, setEditingIdx] = useState<number | null>(null);
const [editingName, setEditingName] = useState("");
const [draftItems, setDraftItems] = useState<PeqItem[]>(items);
const [draftSelectedIdx, setDraftSelectedIdx] = useState(selectedIdx);
const [deleting, setDeleting] = useState(false);
const [renaming, setRenaming] = useState(false);
const [confirmingOrder, setConfirmingOrder] = useState(false);
const [deleteConfirmIdxs, setDeleteConfirmIdxs] = useState<number[] | null>(
null,
);
const prevOpenRef = useRef(false);
const displayItems = reorderEnabled ? draftItems : items;
const displaySelectedIdx = reorderEnabled ? draftSelectedIdx : selectedIdx;
const checkedIdxs = useMemo(
() =>
Object.entries(checked)
.filter(([, v]) => v)
.map(([k]) => Number(k)),
[checked]
);
const singleCheckedIdx = checkedIdxs.length === 1 ? checkedIdxs[0] : null;
const allChecked = displayItems.length > 0 && checkedIdxs.length === displayItems.length;
const orderChanged = useMemo(() => {
if (!reorderEnabled) return false;
if (draftItems.length !== items.length) return true;
return draftItems.some((it, i) => it.name !== items[i]?.name);
}, [reorderEnabled, draftItems, items]);
useEffect(() => {
if (!open) {
prevOpenRef.current = false;
setDeleteConfirmIdxs(null);
return;
}
if (!prevOpenRef.current) {
setDraftItems(items);
setDraftSelectedIdx(selectedIdx);
setChecked({});
setEditingIdx(null);
setEditingName("");
}
prevOpenRef.current = true;
}, [open, items, selectedIdx]);
if (!open) return null;
const toggleAll = () => {
if (allChecked) {
setChecked({});
return;
}
const next: Record<number, boolean> = {};
for (let i = 0; i < displayItems.length; i++) next[i] = true;
setChecked(next);
};
const toggleOne = (idx: number) => {
setChecked(prev => ({ ...prev, [idx]: !prev[idx] }));
};
const beginRename = (idx: number) => {
const name = displayItems[idx]?.name ?? "";
setEditingIdx(idx);
setEditingName(name);
};
const movePreset = (idx: number, direction: "up" | "down") => {
const targetIdx = direction === "up" ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= draftItems.length) return;
setDraftItems(prev => {
const next = [...prev];
[next[idx], next[targetIdx]] = [next[targetIdx], next[idx]];
return next;
});
setDraftSelectedIdx(prev => {
if (prev === idx) return targetIdx;
if (prev === targetIdx) return idx;
return prev;
});
setChecked({});
setEditingIdx(null);
setEditingName("");
};
const movePresetToEdge = (idx: number, edge: "first" | "last") => {
if (idx < 0 || idx >= draftItems.length) return;
const selectedName = draftItems[draftSelectedIdx]?.name ?? null;
const next = [...draftItems];
const [item] = next.splice(idx, 1);
if (edge === "first") next.unshift(item);
else next.push(item);
const newSelectedIdx = selectedName
? next.findIndex(it => it.name === selectedName)
: draftSelectedIdx;
setDraftItems(next);
setDraftSelectedIdx(newSelectedIdx >= 0 ? newSelectedIdx : 0);
setChecked({});
setEditingIdx(null);
setEditingName("");
};
const commitRename = async () => {
if (editingIdx === null) return;
const nextName = editingName.trim();
if (!nextName) {
toast.error(eqUi.renameEmptyName);
return;
}
if (isPeqPresetNameTooLong(nextName)) {
toast.error(eqUi.addPresetNameTooLong);
return;
}
const oldItem = displayItems[editingIdx];
if (!oldItem) return;
if (nextName === oldItem.name) {
setEditingIdx(null);
setEditingName("");
return;
}
if (displayItems.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) {
if (reorderEnabled) {
setDraftItems(prev =>
prev.map(it => (it.name === oldItem.name ? { ...it, name: nextName } : it))
);
}
setEditingIdx(null);
setEditingName("");
}
} finally {
setRenaming(false);
}
};
const requestDelete = (idxs: number[]) => {
const uniq = Array.from(new Set(idxs)).filter(
i => i >= 0 && i < displayItems.length
);
if (uniq.length === 0) return;
setDeleteConfirmIdxs(uniq);
};
const executeDelete = async () => {
const idxs = deleteConfirmIdxs;
if (!idxs) return;
setDeleteConfirmIdxs(null);
const names = idxs.map(i => displayItems[i].name);
setDeleting(true);
try {
const ok = await onDeletePresets(names);
if (ok) {
setChecked({});
if (reorderEnabled) {
setDraftItems(prev => {
const next = prev.filter(it => !names.includes(it.name));
setDraftSelectedIdx(sel => {
let removedBefore = 0;
for (const i of idxs) {
if (i < sel) removedBefore++;
}
const adjusted = sel - removedBefore;
return Math.max(0, Math.min(adjusted, next.length - 1));
});
return next;
});
}
if (editingIdx !== null && idxs.includes(editingIdx)) {
setEditingIdx(null);
setEditingName("");
}
}
} finally {
setDeleting(false);
}
};
const executeConfirmOrder = async () => {
if (!orderChanged || confirmingOrder) return;
const names = draftItems.map(it => it.name);
setConfirmingOrder(true);
try {
const ok = await onConfirmOrder(names, draftSelectedIdx);
if (ok) {
setChecked({});
setEditingIdx(null);
setEditingName("");
}
} finally {
setConfirmingOrder(false);
}
};
const deleteConfirmDescription =
deleteConfirmIdxs === null
? ""
: deleteConfirmIdxs.length === 1
? eqInterp(eqUi.deletePresetConfirm, {
name: displayItems[deleteConfirmIdxs[0]]?.name ?? "",
})
: eqInterp(eqUi.batchDeleteConfirm, {
count: deleteConfirmIdxs.length,
});
const orderBusy = confirmingOrder || renaming || deleting;
const canMoveToFirst =
reorderEnabled &&
singleCheckedIdx !== null &&
singleCheckedIdx > 0 &&
!orderBusy;
const canMoveToLast =
reorderEnabled &&
singleCheckedIdx !== null &&
singleCheckedIdx < displayItems.length - 1 &&
!orderBusy;
const edgeMoveButtonClass = (enabled: boolean) =>
cn(
"w-8 h-8 rounded-[8px] flex items-center justify-center active:scale-95 transition-transform",
enabled
? "text-white/75 hover:bg-white/[0.08]"
: "opacity-30 cursor-not-allowed",
);
const edgeMoveButtonStyle = {
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.1)",
} as const;
return (
<>
<div className="fixed inset-0 z-[140] flex items-center justify-center bg-black/65 px-4">
<div
className="w-full max-w-[600px] 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">
{eqUi.managePresetTitle}
</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={eqUi.closeDrawer}
>
<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} />
{eqUi.selectAll}
</label>
<div className="flex items-center gap-2">
{reorderEnabled && (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-disabled={!canMoveToFirst}
className={edgeMoveButtonClass(canMoveToFirst)}
style={edgeMoveButtonStyle}
onClick={() => {
if (!canMoveToFirst || singleCheckedIdx === null) return;
movePresetToEdge(singleCheckedIdx, "first");
}}
>
<ArrowBigUpDash size={14} />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6} className="z-[200]">
{eqUi.moveToFirst}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-disabled={!canMoveToLast}
className={edgeMoveButtonClass(canMoveToLast)}
style={edgeMoveButtonStyle}
onClick={() => {
if (!canMoveToLast || singleCheckedIdx === null) return;
movePresetToEdge(singleCheckedIdx, "last");
}}
>
<ArrowBigDownDash size={14} />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6} className="z-[200]">
{eqUi.moveToLast}
</TooltipContent>
</Tooltip>
<button
type="button"
disabled={!orderChanged || orderBusy}
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",
!orderChanged || orderBusy
? "bg-[#00FFF6]/15 text-[#00FFF6]/35 cursor-not-allowed"
: "bg-[#00FFF6]/15 text-[#00FFF6] hover:bg-[#00FFF6]/25"
)}
onClick={() => void executeConfirmOrder()}
>
{confirmingOrder && <Loader2 size={13} className="animate-spin" />}
{eqUi.confirmOrder}
</button>
</>
)}
<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={() => requestDelete(checkedIdxs)}
>
{deleting && <Loader2 size={13} className="animate-spin" />}
{eqUi.batchDelete}
</button>
</div>
</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)",
}}
>
{displayItems.length === 0 ? (
<div className="py-10 text-center text-[13px] text-white/30">
{eqUi.noPresets}
</div>
) : (
displayItems.map((item, idx) => {
const isEditing = idx === editingIdx;
const isSelected = idx === displaySelectedIdx;
return (
<div
key={`${item.name}-${idx}`}
className="flex items-center gap-2 pl-3 pr-2 py-2.5 border-b border-white/[0.06] last:border-b-0"
>
<Checkbox
checked={!!checked[idx]}
onChange={() => toggleOne(idx)}
/>
<div className="min-w-0 flex-1">
{isEditing ? (
<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"
/>
<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" />}
{eqUi.save}
</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("");
}}
>
{eqUi.cancel}
</button>
</div>
<PeqPresetNameLengthHint value={editingName} />
</div>
) : (
<div
className={cn(
"truncate text-[13px]",
isSelected ? "text-[#00FFF6]" : "text-white/85",
)}
title={item.name}
>
{item.name}
</div>
)}
</div>
{!isEditing && (
<div className="ml-2 flex shrink-0 items-center gap-1.5">
{reorderEnabled && displayItems.length > 1 && (
<>
<button
type="button"
disabled={idx === 0 || orderBusy}
className={cn(
"w-8 h-8 rounded-[8px] flex items-center justify-center active:scale-95 transition-transform",
idx === 0 || orderBusy
? "opacity-30 cursor-not-allowed"
: "text-white/75 hover:bg-white/[0.08]",
)}
style={{
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.1)",
}}
title={eqUi.movePresetUp}
aria-label={eqUi.movePresetUp}
onClick={() => movePreset(idx, "up")}
>
<ArrowBigUp size={14} />
</button>
<button
type="button"
disabled={idx === displayItems.length - 1 || orderBusy}
className={cn(
"w-8 h-8 rounded-[8px] flex items-center justify-center active:scale-95 transition-transform",
idx === displayItems.length - 1 || orderBusy
? "opacity-30 cursor-not-allowed"
: "text-white/75 hover:bg-white/[0.08]",
)}
style={{
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.1)",
}}
title={eqUi.movePresetDown}
aria-label={eqUi.movePresetDown}
onClick={() => movePreset(idx, "down")}
>
<ArrowBigDown size={14} />
</button>
</>
)}
<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={eqUi.renamePreset}
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={eqUi.deletePreset}
onClick={() => requestDelete([idx])}
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
);
})
)}
</div>
</div>
</div>
<PeqOverwriteConfirmDialog
open={deleteConfirmIdxs !== null}
zClassName="z-[150]"
title={eqUi.deletePreset}
description={deleteConfirmDescription}
cancelLabel={eqUi.cancel}
confirmLabel={eqUi.deletePreset}
variant="destructive"
onConfirm={() => {
void executeDelete();
}}
onCancel={() => setDeleteConfirmIdxs(null)}
/>
</>
);
}