427 lines
19 KiB
TypeScript
427 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|