Files
controller-v2/client/src/pages/eq/components/ShareDialog.tsx
T
2026-06-26 12:03:31 +08:00

665 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { X, Copy, Loader2, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { acceptShareCode, createShareCode, deleteShareCode, listShareCodes, queryShareCode, resolveShareDeviceModel } from "@/lib/luxsinApi";
import type { PeqEqUi } from "../constants";
import { resolveShareApiMessage } from "../shareMessages";
import { eqInterp } from "../utils";
import { PeqOverwriteConfirmDialog } from "./PeqOverwriteConfirmDialog";
import { PEQ_PRESET_NAME_MAX_LENGTH } from "../constants";
import { clampPeqPresetName, isPeqPresetNameTooLong } from "../peqPresetName";
import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint";
type PeqItem = {
name: string;
brand?: string;
model?: string;
form?: string;
filters?: any[] | string;
autoPre?: number;
preamp?: number;
canDel?: number;
};
type PeqCardLabels = {
share: string;
};
type ImportedEqData = {
name: string;
brand?: string;
model?: string;
target?: string;
form?: string;
filters?: unknown;
preamp?: number;
autoPre?: number;
};
export function ShareDialog({
open,
onClose,
peqItems,
headphoneIdx,
eqUi,
peqCardLabels,
mac,
device,
isDemoMode,
onImportEq,
}: {
open: boolean;
onClose: () => void;
peqItems: PeqItem[];
headphoneIdx: number;
eqUi: PeqEqUi;
peqCardLabels: PeqCardLabels;
mac: string;
device: string;
isDemoMode: boolean;
onImportEq: (data: ImportedEqData, shareCode: string) => void | Promise<void>;
}) {
const [shareDialogTab, setShareDialogTab] = useState<"share" | "import" | "myShares">("share");
const [shareSelectedIdx, setShareSelectedIdx] = useState<number>(headphoneIdx);
const [shareCode, setShareCode] = useState<string | null>(null);
const [shareGenerating, setShareGenerating] = useState(false);
const [importCodeInputs, setImportCodeInputs] = useState<string[]>(["", "", "", "", ""]);
const [importQuerying, setImportQuerying] = useState(false);
const [importSaving, setImportSaving] = useState(false);
const [importPresetName, setImportPresetName] = useState("");
const [queriedShareCode, setQueriedShareCode] = useState("");
const [importedEqData, setImportedEqData] = useState<ImportedEqData | null>(null);
const [importedSourceModel, setImportedSourceModel] = useState<string | null>(null);
const [shareCodeExpireAt, setShareCodeExpireAt] = useState<string | null>(null);
const [mySharesList, setMySharesList] = useState<Array<{ share_code: string; expire_at: string; eq_data: Record<string, unknown> }>>([]);
const [mySharesLoading, setMySharesLoading] = useState(false);
const [deleteConfirmCode, setDeleteConfirmCode] = useState<string | null>(null);
const [deletingShareCode, setDeletingShareCode] = useState<string | null>(null);
const handleDeleteConfirm = () => {
const code = deleteConfirmCode;
if (!code || !mac || deletingShareCode) return;
setDeleteConfirmCode(null);
setDeletingShareCode(code);
void (async () => {
try {
const res = await deleteShareCode(mac, code);
if (res.code !== 200) {
toast.error(resolveShareApiMessage("delete", res, eqUi));
return;
}
setMySharesList((prev) => prev.filter((item) => item.share_code !== code));
toast.success(eqUi.mySharesDeleteSuccess);
} catch {
toast.error(resolveShareApiMessage("delete", { code: 500 }, eqUi));
} finally {
setDeletingShareCode(null);
}
})();
};
const handleImportClick = async () => {
if (importSaving || !importedEqData || !queriedShareCode) return;
const nextName = importPresetName.trim();
if (!nextName) return;
if (isPeqPresetNameTooLong(nextName)) {
toast.error(eqUi.addPresetNameTooLong);
return;
}
setImportSaving(true);
try {
if (!isDemoMode && mac) {
const shareDeviceModel = resolveShareDeviceModel(device);
if (!shareDeviceModel) {
toast.error(eqUi.shareInvalidParams);
return;
}
const acceptRes = await acceptShareCode(mac, shareDeviceModel, queriedShareCode);
if (acceptRes.code !== 200) {
toast.error(resolveShareApiMessage("accept", acceptRes, eqUi));
return;
}
}
await onImportEq({ ...importedEqData, name: nextName }, queriedShareCode);
} finally {
setImportSaving(false);
}
};
if (!open) return null;
/* ── clipboard helper ── */
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(eqUi.shareCopied);
} catch {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
toast.success(eqUi.shareCopied);
}
};
return (
<>
<div
className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto bg-black/65 px-4 pb-8 pt-[22vh]"
onClick={(e) => {
if (e.target !== e.currentTarget) return;
if (importSaving || deleteConfirmCode !== null || deletingShareCode) return;
onClose();
}}
>
<div
className="flex w-full max-w-[420px] flex-col 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()}
>
{/* header */}
<div className="mb-4 flex shrink-0 items-center gap-2">
{/* tab bar */}
<div
className="grid min-w-0 flex-1 grid-cols-3 gap-1 rounded-[10px] p-1"
style={{ background: "rgba(44,44,46,0.8)" }}
>
<button
type="button"
className={cn(
"h-[34px] truncate rounded-[8px] px-2 text-center text-[13px] font-medium transition-colors",
shareDialogTab === "share"
? "bg-[#00FFF6] text-black font-semibold"
: "text-white/60 hover:text-white/80",
)}
onClick={() => setShareDialogTab("share")}
>
{peqCardLabels.share}
</button>
<button
type="button"
className={cn(
"h-[34px] truncate rounded-[8px] px-2 text-center text-[13px] font-medium transition-colors",
shareDialogTab === "import"
? "bg-[#00FFF6] text-black font-semibold"
: "text-white/60 hover:text-white/80",
)}
onClick={() => setShareDialogTab("import")}
>
{eqUi.importTab}
</button>
<button
type="button"
className={cn(
"h-[34px] truncate rounded-[8px] px-2 text-center text-[13px] font-medium transition-colors",
shareDialogTab === "myShares"
? "bg-[#00FFF6] text-black font-semibold"
: "text-white/60 hover:text-white/80",
)}
onClick={() => {
setShareDialogTab("myShares");
if (mySharesList.length === 0 && !mySharesLoading) {
void (async () => {
setMySharesLoading(true);
try {
const res = await listShareCodes(mac);
if (res.code !== 200) {
toast.error(resolveShareApiMessage("list", res, eqUi));
return;
}
setMySharesList(res.share_codes ?? []);
} catch {
toast.error(resolveShareApiMessage("list", { code: 500 }, eqUi));
} finally {
setMySharesLoading(false);
}
})();
}
}}
>
{eqUi.mySharesTab}
</button>
</div>
<button
type="button"
className="shrink-0 rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80 disabled:pointer-events-none disabled:opacity-40"
disabled={importSaving}
onClick={onClose}
>
<X size={20} />
</button>
</div>
{/* ── Tab: Share EQ ── */}
{shareDialogTab === "share" && (
<>
{/* hint */}
<p className="mb-3 text-[13px] leading-relaxed text-white/45">{eqUi.shareSelectHint}</p>
{/* EQ preset list */}
<div
className="mb-4 max-h-48 overflow-hidden overflow-y-auto rounded-[10px]"
style={{
background: "rgba(10,12,16,0.98)",
border: "1px solid rgba(0,255,246,0.35)",
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
}}
>
{peqItems.length === 0 && (
<div className="py-6 text-center text-[13px] text-white/30"></div>
)}
{peqItems.map((item, idx) => {
const active = idx === shareSelectedIdx;
return (
<button
key={`${item.name}-${idx}`}
type="button"
className={`w-full px-3 py-2.5 text-left text-[13px] transition-colors truncate border-b border-white/[0.06] last:border-b-0 ${
active ? "text-black font-semibold" : "text-white/90 hover:bg-white/10"
}`}
style={active ? { background: "#00FFF6" } : undefined}
title={item.name}
onClick={() => setShareSelectedIdx(idx)}
>
{item.name}
</button>
);
})}
</div>
{/* confirm button */}
<button
type="button"
disabled={shareGenerating || peqItems.length === 0}
className={cn(
"w-full rounded-full py-2.5 text-[15px] font-semibold text-black transition-all active:scale-[0.98]",
shareGenerating || peqItems.length === 0
? "bg-[#00FFF6]/40 cursor-not-allowed"
: "bg-[#00FFF6] hover:brightness-95",
)}
onClick={async () => {
if (shareGenerating) return;
setShareGenerating(true);
setShareCode(null);
setShareCodeExpireAt(null);
try {
const selectedPeq = peqItems[shareSelectedIdx];
if (!selectedPeq) {
toast.error(eqUi.shareSelectHint);
return;
}
const eqData: Record<string, unknown> = {
name: selectedPeq.name,
brand: selectedPeq.brand ?? "",
model: selectedPeq.model ?? "",
form: selectedPeq.form ?? "",
filters: selectedPeq.filters ?? [],
autoPre: selectedPeq.autoPre ?? 0,
preamp: selectedPeq.preamp ?? 0,
};
const shareDeviceModel = resolveShareDeviceModel(device);
if (!shareDeviceModel) {
toast.error(eqUi.shareInvalidParams);
return;
}
const res = await createShareCode(mac, shareDeviceModel, eqData);
if (res.code !== 200) {
toast.error(resolveShareApiMessage("create", res, eqUi));
return;
}
setShareCode(res.share_code ?? "");
setShareCodeExpireAt(res.expire_at ?? null);
} catch {
toast.error(resolveShareApiMessage("create", { code: 500 }, eqUi));
} finally {
setShareGenerating(false);
}
}}
>
{shareGenerating ? (
<span className="flex items-center justify-center gap-2">
<Loader2 size={16} className="animate-spin" />
{eqUi.shareGenerating}
</span>
) : (
eqUi.shareConfirm
)}
</button>
{/* share code display */}
{shareCode && (
<div className="mt-5">
<p className="mb-2 text-[12px] text-white/40 font-medium">{eqUi.shareCodeLabel}</p>
<div className="flex items-center gap-2">
<div className="flex flex-1 gap-1.5 justify-center">
{shareCode.split("").map((ch, i) => (
<div
key={i}
className="w-11 h-12 rounded-[8px] flex items-center justify-center text-[18px] font-bold tracking-wider text-[#00FFF6]"
style={{
background: "rgba(0,255,246,0.08)",
border: "1px solid rgba(0,255,246,0.25)",
}}
>
{ch}
</div>
))}
</div>
<button
type="button"
className="shrink-0 w-9 h-9 rounded-[8px] flex items-center justify-center text-[#00FFF6] active:scale-95 transition-transform"
style={{
background: "rgba(0,255,246,0.12)",
border: "1px solid rgba(0,255,246,0.25)",
}}
onClick={() => void copyToClipboard(shareCode)}
>
<Copy size={16} />
</button>
</div>
{/* expiration time */}
{shareCodeExpireAt && (
<p className="mt-2 text-[11px] text-white/30">{eqUi.shareExpireAt}: {shareCodeExpireAt}</p>
)}
</div>
)}
</>
)}
{/* ── Tab: Import EQ ── */}
{shareDialogTab === "import" && (
<>
{/* hint */}
<p className="mb-3 text-[13px] leading-relaxed text-white/45">{eqUi.importCodeHint}</p>
{/* 5 input boxes */}
<div className="flex gap-2 justify-center mb-4">
{importCodeInputs.map((ch, i) => (
<input
key={i}
value={ch}
inputMode="text"
maxLength={1}
autoCapitalize="characters"
autoCorrect="off"
className="w-11 h-12 rounded-[8px] text-center text-[18px] font-bold tracking-wider text-[#00FFF6] bg-transparent outline-none uppercase"
style={{
background: "rgba(0,255,246,0.06)",
border: "1px solid rgba(0,255,246,0.25)",
caretColor: "#00FFF6",
}}
onChange={(e) => {
const val = e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(-1).toUpperCase();
const next = [...importCodeInputs];
next[i] = val;
setImportCodeInputs(next);
setImportedEqData(null);
setImportedSourceModel(null);
setImportPresetName("");
setQueriedShareCode("");
// auto-focus next box
if (val && i < 4) {
const el = e.target.nextElementSibling as HTMLInputElement | null;
el?.focus();
}
}}
onKeyDown={(e) => {
// backspace: clear current & focus previous
if (e.key === "Backspace" && !importCodeInputs[i] && i > 0) {
const next = [...importCodeInputs];
next[i - 1] = "";
setImportCodeInputs(next);
setImportedEqData(null);
setImportedSourceModel(null);
setImportPresetName("");
setQueriedShareCode("");
const prev = (e.target as HTMLElement).previousElementSibling as HTMLInputElement | null;
prev?.focus();
}
}}
onPaste={(e) => {
e.preventDefault();
const text = (e.clipboardData.getData("text") || "").replace(/[^a-zA-Z0-9]/g, "").toUpperCase().slice(0, 5);
if (!text) return;
const next = [...importCodeInputs];
for (let j = 0; j < 5; j++) {
next[j] = text[j] ?? "";
}
setImportCodeInputs(next);
setImportedEqData(null);
setImportedSourceModel(null);
setImportPresetName("");
setQueriedShareCode("");
// focus last filled or last box
const focusIdx = Math.min(text.length, 4);
const inputs = (e.target as HTMLElement).parentElement?.querySelectorAll("input");
(inputs?.[focusIdx] as HTMLInputElement | undefined)?.focus();
}}
ref={() => {
// stash ref for focus management if needed later
}}
/>
))}
</div>
{/* query button */}
<button
type="button"
disabled={importQuerying || importCodeInputs.some((c) => !c)}
className={cn(
"w-full rounded-full py-2.5 text-[15px] font-semibold text-black transition-all active:scale-[0.98]",
importQuerying || importCodeInputs.some((c) => !c)
? "bg-[#00FFF6]/40 cursor-not-allowed"
: "bg-[#00FFF6] hover:brightness-95",
)}
onClick={async () => {
if (importQuerying) return;
const code = importCodeInputs.join("");
setImportQuerying(true);
setImportedEqData(null);
setImportedSourceModel(null);
setImportPresetName("");
setQueriedShareCode("");
try {
const res = await queryShareCode(code);
if (res.code !== 200) {
toast.error(resolveShareApiMessage("query", res, eqUi));
return;
}
const eq = res.eq_data;
if (!eq) {
toast.error(resolveShareApiMessage("query", { code: 0 }, eqUi));
return;
}
const fetchedName = (eq.name as string) || "";
setImportedEqData({
name: fetchedName,
brand: (eq.brand as string) || undefined,
model: (eq.model as string) || undefined,
target: (eq.target as string) || undefined,
form: (eq.form as string) || undefined,
filters: eq.filters,
preamp: eq.preamp as number | undefined,
autoPre: eq.autoPre as number | undefined,
});
setImportPresetName(clampPeqPresetName(fetchedName));
setImportedSourceModel(res.model ?? null);
setQueriedShareCode(code);
} catch {
toast.error(resolveShareApiMessage("query", { code: 500 }, eqUi));
} finally {
setImportQuerying(false);
}
}}
>
{importQuerying ? (
<span className="flex items-center justify-center gap-2">
<Loader2 size={16} className="animate-spin" />
{eqUi.importQuerying}
</span>
) : (
eqUi.importQuery
)}
</button>
{/* imported EQ result */}
{importedEqData && (
<div className="mt-4 rounded-[10px] p-3 space-y-3" style={{ background: "rgba(0,255,246,0.06)", border: "1px solid rgba(0,255,246,0.2)" }}>
{importedSourceModel && (
<p className="text-[12px] text-white/60">
{eqUi.importSourceModel}: <span className="text-white/90">{importedSourceModel}</span>
</p>
)}
<div>
<p className="text-[12px] text-white/40 font-medium mb-1.5">
{eqUi.importEqName}
<span className="font-normal text-white/35">{eqUi.importEqNameHint}</span>
</p>
<input
value={importPresetName}
onChange={(e) => setImportPresetName(clampPeqPresetName(e.target.value))}
disabled={importSaving}
maxLength={PEQ_PRESET_NAME_MAX_LENGTH}
className="h-10 w-full 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 disabled:opacity-60"
placeholder={eqUi.importEqName}
/>
<PeqPresetNameLengthHint value={importPresetName} />
</div>
<button
type="button"
disabled={importSaving || !importPresetName.trim()}
className={cn(
"w-full rounded-full py-2.5 text-[13px] font-semibold text-black transition-all active:scale-[0.98]",
importSaving || !importPresetName.trim()
? "bg-[#00FFF6]/40 cursor-not-allowed"
: "bg-[#00FFF6] hover:brightness-95",
)}
onClick={() => void handleImportClick()}
>
{importSaving ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 size={14} className="animate-spin" />
{eqUi.importSaving}
</span>
) : (
eqUi.importButton
)}
</button>
</div>
)}
</>
)}
{/* ── Tab: My Shares ── */}
{shareDialogTab === "myShares" && (
<>
{mySharesLoading && mySharesList.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-10 text-white/50">
<Loader2 size={16} className="animate-spin" />
<span className="text-[13px]">{eqUi.mySharesLoading}</span>
</div>
) : mySharesList.length === 0 ? (
<div className="py-10 text-center text-[13px] text-white/30">{eqUi.mySharesEmpty}</div>
) : (
<div
className="max-h-64 overflow-hidden overflow-y-auto rounded-[10px]"
style={{
background: "rgba(10,12,16,0.98)",
border: "1px solid rgba(0,255,246,0.35)",
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
}}
>
{mySharesList.map((item, idx) => (
<div
key={`${item.share_code}-${idx}`}
className="flex items-center gap-3 px-3 py-2.5 border-b border-white/[0.06] last:border-b-0"
>
{/* share code */}
<div
className="shrink-0 flex gap-0.5"
title={eqUi.mySharesCode}
>
{item.share_code.split("").map((ch: string, ci: number) => (
<span
key={ci}
className="w-[18px] h-[22px] rounded-[4px] flex items-center justify-center text-[11px] font-bold text-[#00FFF6]"
style={{ background: "rgba(0,255,246,0.1)", border: "1px solid rgba(0,255,246,0.2)" }}
>
{ch}
</span>
))}
</div>
{/* EQ name + expiration */}
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-white/85">
{(item.eq_data?.name as string) ?? "—"}
</div>
{item.expire_at && (
<p className="mt-0.5 truncate text-[11px] text-white/35">
{eqUi.shareExpireAt}: {item.expire_at}
</p>
)}
</div>
{/* copy + delete */}
<button
type="button"
disabled={!!deletingShareCode}
className="shrink-0 w-7 h-7 rounded-[6px] flex items-center justify-center text-[#00FFF6]/70 active:scale-95 transition-transform disabled:opacity-40"
style={{ background: "rgba(0,255,246,0.08)", border: "1px solid rgba(0,255,246,0.15)" }}
onClick={() => void copyToClipboard(item.share_code)}
>
<Copy size={13} />
</button>
<button
type="button"
disabled={!!deletingShareCode || !mac}
className="shrink-0 w-7 h-7 rounded-[6px] flex items-center justify-center text-red-400/80 active:scale-95 transition-transform disabled:opacity-40"
style={{ background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.2)" }}
title={eqUi.mySharesDeleteConfirmTitle}
onClick={() => {
if (deletingShareCode) return;
setDeleteConfirmCode(item.share_code);
}}
>
{deletingShareCode === item.share_code ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Trash2 size={13} />
)}
</button>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
<PeqOverwriteConfirmDialog
open={deleteConfirmCode !== null}
title={eqUi.mySharesDeleteConfirmTitle}
description={
deleteConfirmCode
? eqInterp(eqUi.mySharesDeleteConfirmDesc, { code: deleteConfirmCode })
: ""
}
cancelLabel={eqUi.cancel}
confirmLabel={eqUi.mySharesDeleteConfirmOk}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteConfirmCode(null)}
/>
</>
);
}