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

427 lines
19 KiB
TypeScript
Raw Normal View History

import { useState, useMemo, useEffect, useCallback } from "react";
import { Search } from "lucide-react";
import { toast } from "sonner";
import {
fetchLuxsinAudioBrands,
fetchLuxsinAudioModelList,
fetchLuxsinAudioModels,
type LuxsinAudioBrand,
type LuxsinAudioModelListItem,
type LuxsinAudioModel,
} from "@/lib/luxsinApi";
import { CATALOG_TARGETS } from "../eqConstants";
import type { PeqEqUi } from "../types";
import { eqInterp } from "../eqFormatters";
type BrandDrawerTab = "brands" | "models" | "target";
export function BrandDrawer({
open,
onClose,
eqUi,
onConfirm,
}: {
open: boolean;
onClose: () => void;
eqUi: PeqEqUi;
onConfirm: (brand: string, name: string, target: string, form?: string) => Promise<boolean>;
}) {
const [brandDrawerTab, setBrandDrawerTab] = useState<BrandDrawerTab>("brands");
const [brandSearchQuery, setBrandSearchQuery] = useState("");
const [catalogBrands, setCatalogBrands] = useState<LuxsinAudioBrand[]>([]);
const [catalogBrandsLoading, setCatalogBrandsLoading] = useState(false);
const [catalogBrandsError, setCatalogBrandsError] = useState<string | null>(null);
const [catalogSearchResults, setCatalogSearchResults] = useState<LuxsinAudioModelListItem[]>([]);
const [catalogSearchLoading, setCatalogSearchLoading] = useState(false);
const [catalogSearchError, setCatalogSearchError] = useState<string | null>(null);
const [selectedCatalogBrand, setSelectedCatalogBrand] = useState<string>("");
const [catalogModels, setCatalogModels] = useState<LuxsinAudioModel[]>([]);
const [catalogModelsLoading, setCatalogModelsLoading] = useState(false);
const [catalogModelsError, setCatalogModelsError] = useState<string | null>(null);
const [selectedCatalogModelFromSearch, setSelectedCatalogModelFromSearch] = useState<string>("");
const [selectedCatalogModelName, setSelectedCatalogModelName] = useState("");
const [selectedCatalogModelForm, setSelectedCatalogModelForm] = useState<string | undefined>(undefined);
const [selectedCatalogTarget, setSelectedCatalogTarget] = useState<string>("");
const [isConfirmingTarget, setIsConfirmingTarget] = useState(false);
/* ── Derived data ── */
const filteredCatalogBrands = useMemo(() => {
const q = brandSearchQuery.trim().toLowerCase();
if (!q) return catalogBrands;
return catalogBrands.filter((b) => b.name.toLowerCase().includes(q));
}, [catalogBrands, brandSearchQuery]);
const availableCatalogTargets = useMemo(() => {
if (selectedCatalogModelForm === "in-ear") {
return CATALOG_TARGETS.filter((item) => item.ear === "in" || item.ear === "all");
}
if (selectedCatalogModelForm === "over-ear") {
return CATALOG_TARGETS.filter((item) => item.ear === "over" || item.ear === "all");
}
return CATALOG_TARGETS;
}, [selectedCatalogModelForm]);
/* ── API calls ── */
const loadCatalogBrands = useCallback(async () => {
setCatalogBrandsLoading(true);
setCatalogBrandsError(null);
try {
const list = await fetchLuxsinAudioBrands();
const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
setCatalogBrands(sorted);
} catch (e) {
const msg = e instanceof Error ? e.message : "加载失败";
setCatalogBrandsError(msg);
toast.error(eqUi.toastBrandsFail);
} finally {
setCatalogBrandsLoading(false);
}
}, [eqUi]);
const searchCatalogByKeyword = useCallback(async (keyword: string) => {
const q = keyword.trim();
if (!q) {
setCatalogSearchResults([]);
setCatalogSearchError(null);
setCatalogSearchLoading(false);
return;
}
setCatalogSearchLoading(true);
setCatalogSearchError(null);
try {
const list = await fetchLuxsinAudioModelList(q, 1000);
setCatalogSearchResults(list);
} catch (e) {
const msg = e instanceof Error ? e.message : "搜索失败";
setCatalogSearchError(msg);
setCatalogSearchResults([]);
} finally {
setCatalogSearchLoading(false);
}
}, []);
const loadCatalogModels = useCallback(async (brandName: string) => {
setSelectedCatalogModelFromSearch("");
setCatalogModelsLoading(true);
setCatalogModelsError(null);
try {
const list = await fetchLuxsinAudioModels(brandName);
const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
setCatalogModels(sorted);
} catch (e) {
const msg = e instanceof Error ? e.message : "加载失败";
setCatalogModelsError(msg);
toast.error(eqUi.toastModelsFail);
} finally {
setCatalogModelsLoading(false);
}
}, [eqUi]);
/* ── Effects ── */
// Auto-load brands when drawer opens
useEffect(() => {
if (!open) return;
void loadCatalogBrands();
}, [open, loadCatalogBrands]);
// Search debounce
useEffect(() => {
if (!open || brandDrawerTab !== "brands") return;
const timer = window.setTimeout(() => {
void searchCatalogByKeyword(brandSearchQuery);
}, 260);
return () => window.clearTimeout(timer);
}, [open, brandDrawerTab, brandSearchQuery, searchCatalogByKeyword]);
// Escape key
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Body overflow lock
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[110] flex flex-col justify-end">
<button
type="button"
className="absolute inset-0 bg-black/55"
aria-label={eqUi.closeDrawer}
onClick={onClose}
/>
<div
className="relative z-10 flex h-[58dvh] flex-shrink-0 flex-col overflow-hidden rounded-t-[18px] bg-white shadow-[0_-8px_32px_rgba(0,0,0,0.35)]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex shrink-0 items-center justify-center pt-2 pb-1">
<div className="h-1 w-10 rounded-full bg-black/15" />
</div>
<div className="flex shrink-0 items-stretch rounded-t-[12px] bg-[#2c2c2e] px-1 pt-1">
{(["brands", "models", "target"] as const).map((tab) => {
const active = brandDrawerTab === tab;
const label =
tab === "brands" ? eqUi.tabBrands : tab === "models" ? eqUi.tabModels : eqUi.tabTarget;
return (
<button
key={tab}
type="button"
className={`flex-1 py-2.5 text-[14px] font-medium transition-colors rounded-t-[10px] ${
active ? "bg-[#1c1c1e] text-white" : "text-white/55 hover:text-white/80"
}`}
onClick={() => setBrandDrawerTab(tab)}
>
{label}
</button>
);
})}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white">
{brandDrawerTab === "brands" && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="shrink-0 border-b border-black/8 px-3 py-2.5">
<div className="flex items-center gap-2 rounded-[10px] border border-black/12 bg-[#f5f5f7] px-3 py-2">
<Search size={18} className="shrink-0 text-black/35" />
<input
type="search"
value={brandSearchQuery}
onChange={(e) => setBrandSearchQuery(e.target.value)}
placeholder={eqUi.searchPlaceholder}
className="min-w-0 flex-1 bg-transparent text-[15px] text-black/80 outline-none placeholder:text-black/35"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
{brandSearchQuery.trim() && catalogSearchLoading && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.searching}</div>
)}
{!!brandSearchQuery.trim() && !catalogSearchLoading && catalogSearchError && (
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogSearchError}</div>
)}
{!!brandSearchQuery.trim() &&
!catalogSearchLoading &&
!catalogSearchError &&
catalogSearchResults.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noSearchResults}</div>
)}
{!!brandSearchQuery.trim() &&
!catalogSearchLoading &&
!catalogSearchError &&
catalogSearchResults.map((item, idx) => (
<button
key={`${item.brandName}-${item.modelName}-${idx}`}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3.5 text-left active:bg-black/[0.04] transition-colors"
onClick={() => {
setSelectedCatalogBrand(item.brandName);
setSelectedCatalogModelFromSearch(item.modelName);
setSelectedCatalogModelName(item.modelName);
setSelectedCatalogModelForm(item.form);
setSelectedCatalogTarget("");
setCatalogModels([{ id: 0, name: item.modelName, ...(item.form ? { form: item.form } : {}) }]);
setCatalogModelsLoading(false);
setCatalogModelsError(null);
setBrandDrawerTab("target");
}}
>
<div className="text-[15px] text-black/85 font-medium">{item.modelName}</div>
<div className="mt-0.5 text-[12px] text-black/50">{item.brandName}</div>
</button>
))}
{!brandSearchQuery.trim() && catalogBrandsLoading && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.loading}</div>
)}
{!brandSearchQuery.trim() && !catalogBrandsLoading && catalogBrandsError && (
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogBrandsError}</div>
)}
{!brandSearchQuery.trim() && !catalogBrandsLoading && !catalogBrandsError && filteredCatalogBrands.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noBrandMatch}</div>
)}
{!brandSearchQuery.trim() &&
!catalogBrandsLoading &&
!catalogBrandsError &&
filteredCatalogBrands.map((b) => (
<button
key={b.id}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3.5 text-left text-[16px] text-black/85 active:bg-black/[0.04] transition-colors"
onClick={() => {
setSelectedCatalogBrand(b.name);
setSelectedCatalogModelFromSearch("");
setSelectedCatalogModelName("");
setSelectedCatalogModelForm(undefined);
setSelectedCatalogTarget("");
setBrandDrawerTab("models");
void loadCatalogModels(b.name);
}}
>
{b.name}
</button>
))}
</div>
</div>
)}
{brandDrawerTab === "models" && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
<div className="sticky top-0 z-10 border-b border-black/8 bg-white px-4 py-2.5 text-[13px] text-black/55">
{eqUi.brandLabel}<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
{selectedCatalogModelFromSearch && (
<span className="ml-2 text-black/45">
{eqUi.modelFromSearchLabel}
{selectedCatalogModelFromSearch}
</span>
)}
</div>
{!selectedCatalogBrand && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.selectBrandFirst}</div>
)}
{!!selectedCatalogBrand && catalogModelsLoading && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.loading}</div>
)}
{!!selectedCatalogBrand && !catalogModelsLoading && catalogModelsError && (
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogModelsError}</div>
)}
{!!selectedCatalogBrand && !catalogModelsLoading && !catalogModelsError && catalogModels.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noModelsForBrand}</div>
)}
{!!selectedCatalogBrand &&
!catalogModelsLoading &&
!catalogModelsError &&
catalogModels.map((m) => (
<button
key={`${m.id}-${m.name}`}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3.5 text-left text-[16px] text-black/85 active:bg-black/[0.04] transition-colors"
onClick={() => {
setSelectedCatalogModelName(m.name);
setSelectedCatalogModelForm(m.form);
setSelectedCatalogTarget("");
setBrandDrawerTab("target");
}}
>
<div>{m.name}</div>
{m.form && <div className="mt-0.5 text-[12px] text-black/50">{m.form}</div>}
</button>
))}
</div>
</div>
)}
{brandDrawerTab === "target" && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
<div className="sticky top-0 z-10 border-b border-black/8 bg-white px-4 py-2.5 text-[13px] text-black/55 space-y-0.5">
<div>
{eqUi.targetBrand}
<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
</div>
<div>
{eqUi.targetModel}
<span className="font-semibold text-black/80">{selectedCatalogModelName || "—"}</span>
</div>
<div>
{eqUi.targetForm}
<span className="font-semibold text-black/80">{selectedCatalogModelForm || eqUi.formAll}</span>
</div>
</div>
<div className="px-5 pt-4 pb-3">
<button
type="button"
disabled={!selectedCatalogTarget || isConfirmingTarget}
className="w-full h-10 rounded-full text-[24px] leading-none transition-all disabled:cursor-not-allowed flex items-center justify-center gap-3"
style={{
background: selectedCatalogTarget && !isConfirmingTarget
? "linear-gradient(180deg, #6a6a6d 0%, #565659 100%)"
: "linear-gradient(180deg, #8b8b8f 0%, #78787c 100%)",
color: selectedCatalogTarget && !isConfirmingTarget ? "#00FFF6" : "rgba(255,255,255,0.55)",
boxShadow: selectedCatalogTarget && !isConfirmingTarget
? "inset 0 1px 0 rgba(255,255,255,0.15)"
: "inset 0 1px 0 rgba(255,255,255,0.08)",
opacity: selectedCatalogTarget && !isConfirmingTarget ? 1 : 0.9,
}}
onClick={async () => {
if (isConfirmingTarget) return;
setIsConfirmingTarget(true);
try {
const completed = await onConfirm(
selectedCatalogBrand,
selectedCatalogModelName,
selectedCatalogTarget,
selectedCatalogModelForm,
);
if (completed) {
onClose();
toast.success(eqUi.toastNewEqOk);
} else {
toast.info(eqUi.overwritePresetCancelled);
}
} catch {
toast.error(eqUi.toastCurveFail);
} finally {
setIsConfirmingTarget(false);
}
}}
>
{isConfirmingTarget && (
<span
className="inline-block w-4 h-4 rounded-full border-2 border-white/40 border-t-[#00FFF6] animate-spin"
aria-hidden="true"
/>
)}
{isConfirmingTarget ? eqUi.confirmLoading : eqUi.confirmButton}
</button>
</div>
{!selectedCatalogModelName && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.selectModelFirst}</div>
)}
{!!selectedCatalogModelName && availableCatalogTargets.length === 0 && (
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noTargets}</div>
)}
{!!selectedCatalogModelName &&
availableCatalogTargets.map((target) => {
const active = selectedCatalogTarget === target.name;
return (
<button
key={target.name}
type="button"
className="w-full border-b border-black/[0.06] px-4 py-3 text-left active:bg-black/[0.04] transition-colors"
style={active ? { background: "rgba(0,255,246,0.14)" } : undefined}
onClick={() => {
setSelectedCatalogTarget(target.name);
toast.success(eqInterp(eqUi.toastTargetSelected, { name: target.name }));
}}
>
<div className="text-[15px] text-black/85 font-medium">{target.name}</div>
<div className="mt-1 text-[12px] text-black/55">
{eqInterp(eqUi.bassBoost, {
fc: String(target.bassBoost.fc),
q: String(target.bassBoost.q),
gain: String(target.bassBoost.gain),
})}
</div>
</button>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
}