From 376249172fcdbe52476d75ffd588e7ff23e7c1c7 Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 12 Jun 2026 19:51:19 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Model=20Browser=20=E2=80=94=20block=20l?= =?UTF-8?q?ibrary=20with=20category=20filter=20tabs,=20search,=20favorites?= =?UTF-8?q?,=20NAM/IR=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 64 +++- src/BlockChain.jsx | 74 ++++- src/ModelBrowser.jsx | 680 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 807 insertions(+), 11 deletions(-) create mode 100644 src/ModelBrowser.jsx diff --git a/src/App.jsx b/src/App.jsx index 298c900..36df4c6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,6 +3,7 @@ import BlockChain from "./BlockChain.jsx"; import FootswitchBar from "./FootswitchBar.jsx"; import TunerScreen from "./Tuner.jsx"; import FocusView from "./FocusView.jsx"; +import ModelBrowser from "./ModelBrowser.jsx"; // ── API Layer ────────────────────────────────────────────────────── const API_BASE = ""; @@ -343,13 +344,17 @@ function SelectParam({ def, value, color, onChange }) { } // ── Parameter Panel ────────────────────────────────────────── -function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,onToggleView,onFocus }) { +function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,onToggleView,onFocus,onModelBrowser }) { const defs=getBlockParams(block.type);const color=getBlockColor(block.type); const visibleDefs = defs.filter(d => !d.conditional || d.conditional(params)); return (
- {block.name||block.type||''} + onModelBrowser?.(block.id)}> + {block.name||block.type||''} + {onModelBrowser&&} +
{onFocus&&} @@ -450,6 +455,7 @@ export default function App(){ const[globalBypass,setGlobalBypass]=useState(false); const[loadingPreset,setLoadingPreset]=useState(false); const[focusBlockId,setFocusBlockId]=useState(null); + const[modelBrowserBlockId,setModelBrowserBlockId]=useState(null); const[footswitchMode,setFootswitchMode]=useState("stomp"); // ── Poll pedal state from API ── @@ -604,10 +610,37 @@ export default function App(){ setTimeout(()=>{userChangedBlock.current=false;},3000); },[focusBlockId]); - const handleOpenFocusModelBrowser=useCallback(()=>{ - setView("captures"); - setFocusBlockId(null); - },[]); + const handleOpenModelBrowser=useCallback((blockId)=>{ + setModelBrowserBlockId(blockId); + if(focusBlockId) setFocusBlockId(null); + },[focusBlockId]); + + const handleSelectModel=useCallback(({type, name, modelPath, isBuiltin})=>{ + const id=modelBrowserBlockId; + if(!id)return; + userChangedBlock.current=true; + setBlocks(prev=>prev.map(b=>{ + if(b.id!==id)return b; + const defs=getBlockParams(type); + const init={}; + defs.forEach(p=>{init[p.key]=p.def;}); + return { + ...b, + type, + name: name || getBlockDisplayName({type}), + params: init, + nam_model_path: (!isBuiltin && modelPath && type==='nam') ? modelPath : undefined, + ir_file_path: (!isBuiltin && modelPath && type==='ir') ? modelPath : undefined, + }; + })); + setModelBrowserBlockId(null); + setBlockParams(prev=>{ + const next={...prev}; + delete next[id]; + return next; + }); + setTimeout(()=>{userChangedBlock.current=false;},3000); + },[modelBrowserBlockId]); // ── Render ── return( @@ -720,6 +753,7 @@ export default function App(){ onReorder={handleReorder} onAddBlock={handleAddBlock} onRemoveBlock={handleRemoveBlock} + onModelBrowser={handleOpenModelBrowser} />
@@ -733,6 +767,7 @@ export default function App(){ onParamChange={(key,value)=>handleParamChange(selectedBlockId,key,value)} onParamChangeEnd={(key,value)=>handleParamChangeEnd(selectedBlockId,key,value)} onFocus={()=>setFocusBlockId(selectedBlockId)} + onModelBrowser={handleOpenModelBrowser} /> ):(
@@ -774,7 +809,7 @@ export default function App(){ onParamChangeEnd={(key,value)=>handleParamChangeEnd(focusBlockId,key,value)} onToggleView={()=>setParamView(p=>p==='slider'?'knob':'slider')} onToggleBypass={handleFocusViewBypass} - onOpenModelBrowser={handleOpenFocusModelBrowser} + onOpenModelBrowser={()=>handleOpenModelBrowser(focusBlockId)} /> )} {/* ── Overlays ── */} @@ -809,6 +844,21 @@ export default function App(){ /> )} + {/* ── Model Browser ── */} + {modelBrowserBlockId&&(()=>{ + const block=blocks.find(b=>b.id===modelBrowserBlockId); + if(!block)return null; + return ( + setModelBrowserBlockId(null)} + /> + ); + })()} +
); } diff --git a/src/BlockChain.jsx b/src/BlockChain.jsx index 887e241..6974f03 100644 --- a/src/BlockChain.jsx +++ b/src/BlockChain.jsx @@ -163,17 +163,37 @@ function VertBracket({ height, direction }) { } // ── Split Block Tile ───────────────────────────── -function SplitBlockTile({ block, selected, onSelect, onToggle }) { +function SplitBlockTile({ block, selected, onSelect, onToggle, onModelBrowser }) { const splitParams = block.params || {}; const splitType = splitParams.splitType || "y"; const st = SPLIT_TYPES[splitType] || SPLIT_TYPES.y; const enabled = !block.bypassed; const color = getBlockColor("split"); + const longPressRef = useRef(null); + + const handleTouchStart = useCallback(() => { + longPressRef.current = setTimeout(() => { + longPressRef.current = null; + onModelBrowser?.(block.id); + }, 500); + }, [block.id, onModelBrowser]); + + const handleTouchEnd = useCallback(() => { + if (longPressRef.current) { + clearTimeout(longPressRef.current); + longPressRef.current = null; + } + }, []); return (
onSelect(block.id)} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchEnd} + onMouseDown={handleTouchEnd} + onMouseUp={handleTouchEnd} style={{ minWidth: 130, width: 130, height: 76, borderRadius: 8, background: `linear-gradient(135deg, ${T.panel}, #1A1520)`, @@ -237,14 +257,34 @@ function SplitBlockTile({ block, selected, onSelect, onToggle }) { } // ── Merge Block Tile ────────────────────────────── -function MergeBlockTile({ block, selected, onSelect, onToggle }) { +function MergeBlockTile({ block, selected, onSelect, onToggle, onModelBrowser }) { const mergeParams = block.params || {}; const enabled = !block.bypassed; + const longPressRef = useRef(null); + + const handleTouchStart = useCallback(() => { + longPressRef.current = setTimeout(() => { + longPressRef.current = null; + onModelBrowser?.(block.id); + }, 500); + }, [block.id, onModelBrowser]); + + const handleTouchEnd = useCallback(() => { + if (longPressRef.current) { + clearTimeout(longPressRef.current); + longPressRef.current = null; + } + }, []); return (
onSelect(block.id)} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchEnd} + onMouseDown={handleTouchEnd} + onMouseUp={handleTouchEnd} style={{ minWidth: 130, width: 130, height: 76, borderRadius: 8, background: `linear-gradient(135deg, #15201A, ${T.panel})`, @@ -304,14 +344,34 @@ function MergeBlockTile({ block, selected, onSelect, onToggle }) { } // ── Regular Block Tile ──────────────────────────── -function BlockTile({ block, selected, onSelect, onToggle, pathLabel }) { +function BlockTile({ block, selected, onSelect, onToggle, pathLabel, onModelBrowser }) { const color = getBlockColor(block.type); const enabled = !block.bypassed; + const longPressRef = useRef(null); + + const handleTouchStart = useCallback(() => { + longPressRef.current = setTimeout(() => { + longPressRef.current = null; + onModelBrowser?.(block.id); + }, 500); + }, [block.id, onModelBrowser]); + + const handleTouchEnd = useCallback(() => { + if (longPressRef.current) { + clearTimeout(longPressRef.current); + longPressRef.current = null; + } + }, []); return (
onSelect(block.id)} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchEnd} + onMouseDown={handleTouchEnd} + onMouseUp={handleTouchEnd} style={{ minWidth: 120, width: 120, height: 76, borderRadius: 8, background: pathLabel === "B" ? "#1A1820" : T.panel, @@ -435,7 +495,7 @@ function buildChainModel(blocks) { // ── Block Chain (exported) ───────────────────────── export default function BlockChain({ blocks, selectedBlockId, onSelectBlock, onToggleBlock, - onReorder, onAddBlock, onRemoveBlock, + onReorder, onAddBlock, onRemoveBlock, onModelBrowser, }) { const scrollRef = useRef(null); const scrollBRef = useRef(null); @@ -528,6 +588,7 @@ export default function BlockChain({ index={idx} onSelect={onSelectBlock} onToggle={onToggleBlock} + onModelBrowser={onModelBrowser} />
@@ -585,6 +646,7 @@ export default function BlockChain({ index={idx} onSelect={onSelectBlock} onToggle={onToggleBlock} + onModelBrowser={onModelBrowser} />
@@ -597,6 +659,7 @@ export default function BlockChain({ selected={splitBlock.id === selectedBlockId} onSelect={onSelectBlock} onToggle={onToggleBlock} + onModelBrowser={onModelBrowser} />
@@ -611,6 +674,7 @@ export default function BlockChain({ onSelect={onSelectBlock} onToggle={onToggleBlock} pathLabel="A" + onModelBrowser={onModelBrowser} />
@@ -626,6 +690,7 @@ export default function BlockChain({ selected={mergeBlock.id === selectedBlockId} onSelect={onSelectBlock} onToggle={onToggleBlock} + onModelBrowser={onModelBrowser} />
@@ -639,6 +704,7 @@ export default function BlockChain({ index={idx} onSelect={onSelectBlock} onToggle={onToggleBlock} + onModelBrowser={onModelBrowser} /> diff --git a/src/ModelBrowser.jsx b/src/ModelBrowser.jsx new file mode 100644 index 0000000..cb23726 --- /dev/null +++ b/src/ModelBrowser.jsx @@ -0,0 +1,680 @@ +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; + +// ── Design tokens ── +const T = { + bg: "#0A0A0C", + panel: "#141418", + surface: "#1C1C22", + border: "#2A2A32", + amber: "#E8A030", + amberDim: "#7A5218", + blue: "#3A7BA8", + blueDim: "#1E4060", + green: "#3AB87A", + red: "#C84040", + textPrimary: "#F0EDE6", + textSec: "#8888A0", + textDim: "#444458", +}; + +const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/'; + +// ── Category definitions ── +const CATEGORY_DEFS = { + "Distortions": { icon: "fx_distortion", color: T.red }, + "Dynamics": { icon: "fx_compressor", color: T.green }, + "Modulations": { icon: "fx_modulator", color: T.blue }, + "Delays": { icon: "fx_delay", color: "#60A0E0" }, + "Reverbs": { icon: "fx_reverb", color: "#60C0D0" }, + "Filters": { icon: "fx_filter", color: "#D0A060" }, + "Amps": { icon: "fx_amplifier", color: "#E0A040" }, + "Preamps": { icon: "fx_simulator", color: "#D08040" }, + "Cabs": { icon: "fx_spatial", color: "#D07090" }, + "IRs": { icon: "fx_spatial", color: "#70B090" }, + "Pitch/Synth": { icon: "fx_pitch", color: "#A060E0" }, + "Volume/Pan": { icon: "fx_constant", color: T.textSec }, + "Looper": { icon: "fx_oscillator", color: "#80D0A0" }, + "Send/Return": { icon: "fx_lr", color: "#9090C0" }, + "Special": { icon: "fx_utility", color: "#B080C0" }, +}; + +const CATEGORY_ORDER = [ + "Distortions", "Dynamics", "Modulations", "Delays", "Reverbs", + "Filters", "Amps", "Preamps", "Cabs", "IRs", + "Pitch/Synth", "Volume/Pan", "Looper", "Send/Return", "Special", +]; + +const CATEGORY_HIDE = ["Preamps", "Looper", "Pitch/Synth"]; // hide empty categories + +// ── Built-in block types mapped to categories ── +const TYPE_TO_CATEGORY = { + overdrive: "Distortions", disturbance: "Distortions", distortion: "Distortions", + fuzz: "Distortions", boost: "Distortions", wah: "Filters", + delay: "Delays", echo: "Delays", + reverb: "Reverbs", + chorus: "Modulations", flanger: "Modulations", phaser: "Modulations", + tremolo: "Modulations", vibrato: "Modulations", rotary: "Modulations", + compressor: "Dynamics", gate: "Dynamics", limiter: "Dynamics", + eq: "Filters", filter: "Filters", + pitch: "Pitch/Synth", harmonizer: "Pitch/Synth", shifter: "Pitch/Synth", + nam: "Amps", amplifier: "Amps", + cabinet: "Cabs", cab: "Cabs", + ir: "IRs", + volume: "Volume/Pan", level: "Volume/Pan", + looper: "Looper", + loop: "Send/Return", + split: "Special", merge: "Special", tuner: "Special", + instrument: "Special", plugin: "Special", utility: "Special", + mixer: "Special", converter: "Special", reamp: "Special", +}; + +// ── Type-to-color (same palette as BlockChain) ── +const TYPE_COLORS = { + od: T.amber, overdrive: T.amber, drive: T.amber, + dist: T.red, distortion: T.red, + fuzz: "#D060E0", + mod: T.blue, chorus: T.blue, flange: T.blue, phaser: T.blue, + tremolo: T.blue, vibrato: T.blue, rotary: T.blue, + delay: "#60A0E0", echo: "#60A0E0", reverb: "#60C0D0", + pitch: "#A060E0", comp: T.green, compressor: T.green, + gate: T.green, filter: "#D0A060", wah: "#D0A060", + eq: "#80A0B0", ir: T.green, cab: T.green, nam: "#E0A040", + volume: T.textSec, boost: T.amber, + split: "#B080C0", merge: "#80B0C0", +}; + +// ── Type-to-icon map ── +const TYPE_ICON_MAP = { + od: "fx_distortion", overdrive: "fx_distortion", drive: "fx_distortion", + dist: "fx_distortion", distortion: "fx_distortion", gain: "fx_distortion", + fuzz: "fx_distortion", + delay: "fx_delay", echo: "fx_delay", + reverb: "fx_reverb", + chorus: "fx_chorus", flange: "fx_flanger", flanger: "fx_flanger", + phaser: "fx_phaser", tremolo: "fx_modulator", vibrato: "fx_modulator", + rotary: "fx_modulator", modulator: "fx_modulator", + pitch: "fx_pitch", harmonizer: "fx_pitch", shifter: "fx_pitch", + comp: "fx_compressor", compressor: "fx_compressor", + limiter: "fx_limiter", + gate: "fx_gate", noise_gate: "fx_gate", + filter: "fx_filter", wah: "fx_filter", + eq: "fx_eq", graphic_eq: "fx_eq", + parametric_eq: "fx_parametric_eq", multiband_eq: "fx_multiband_eq", + ir: "fx_spatial", cab: "fx_spatial", cabinet: "fx_spatial", + nam: "fx_simulator", capture: "fx_simulator", amp: "fx_amplifier", + amplifier: "fx_amplifier", + volume: "fx_constant", level: "fx_constant", boost: "fx_constant", + split: "fx_split_a", merge: "fx_mixer", + tuner: "fx_analyzer", + mixer: "fx_mixer", utility: "fx_utility", + plugin: "fx_plugin", + instrument: "fx_instrument", + looper: "fx_oscillator", + converter: "fx_converter", + reamp: "fx_converter", + lr: "fx_lr", +}; + +// ── Built-in model library ── +const BUILTIN_MODELS = [ + // Distortions + { type: "overdrive", name: "OD-808", category: "Distortions", desc: "Classic overdrive" }, + { type: "distortion", name: "RAT", category: "Distortions", desc: "Aggressive distortion" }, + { type: "fuzz", name: "Big Muff", category: "Distortions", desc: "Thick fuzz sustain" }, + { type: "boost", name: "Clean Boost", category: "Distortions", desc: "Clean level boost" }, + // Dynamics + { type: "compressor", name: "Compressor", category: "Dynamics", desc: "Dynamic range control" }, + { type: "gate", name: "Noise Gate", category: "Dynamics", desc: "Noise suppression" }, + // Modulations + { type: "chorus", name: "Chorus", category: "Modulations", desc: "Rich chorus effect" }, + { type: "flanger", name: "Flanger", category: "Modulations", desc: "Jet plane sweep" }, + { type: "phaser", name: "Phase 90", category: "Modulations", desc: "Iconic phase shift" }, + { type: "tremolo", name: "Tremolo", category: "Modulations", desc: "Volume modulation" }, + // Delays + { type: "delay", name: "Digital Delay", category: "Delays", desc: "Clean digital repeats" }, + { type: "echo", name: "Tape Echo", category: "Delays", desc: "Warm tape repeats" }, + // Reverbs + { type: "reverb", name: "Hall Reverb", category: "Reverbs", desc: "Lush hall reverb" }, + // Filters + { type: "eq", name: "Equalizer", category: "Filters", desc: "3-band tone control" }, + { type: "wah", name: "Wah", category: "Filters", desc: "Crybaby wah" }, + // Volume/Pan + { type: "volume", name: "Volume Pedal", category: "Volume/Pan", desc: "Volume control" }, + // Special + { type: "split", name: "Split", category: "Special", desc: "Parallel routing" }, + { type: "merge", name: "Merge", category: "Special", desc: "Sum parallel paths" }, + { type: "tuner", name: "Tuner", category: "Special", desc: "Instrument tuner" }, + { type: "loop", name: "FX Loop", category: "Send/Return", desc: "External FX send/return" }, + // Cabs & IRs + { type: "cabinet", name: "Cabinet", category: "Cabs", desc: "Speaker cabinet sim" }, + { type: "ir", name: "IR Loader", category: "IRs", desc: "Impulse response loader" }, + // Amps + { type: "nam", name: "NAM Model", category: "Amps", desc: "Neural amp model" }, +]; + +// ── Helper: resolve icon for a type ── +function getIconFile(type) { + const key = (type || "").toLowerCase().trim(); + for (const [k, v] of Object.entries(TYPE_ICON_MAP)) { + if (key === k || key.startsWith(k) || key.includes(k)) return v; + } + return "fx_plugin"; +} + +function getTypeColor(type) { + const key = (type || "").toLowerCase().trim(); + for (const [k, v] of Object.entries(TYPE_COLORS)) { + if (key === k || key.startsWith(k) || key.includes(k)) return v; + } + return T.amber; +} + +// ── Block Icon (inline from BlockChain) ── +function BlockIcon({ type, size = 16 }) { + const icon = getIconFile(type); + return ( + {type { e.target.style.display = "none"; }} + /> + ); +} + +// ── Category Icon ── +function CategoryIcon({ category, size = 14 }) { + const def = CATEGORY_DEFS[category]; + if (!def) return null; + return ( + {category} { e.target.style.display = "none"; }} + /> + ); +} + +// ── Model Browser ── +export default function ModelBrowser({ + currentBlockType, + currentBlockName, + currentModelPath, + onSelectModel, + onClose, +}) { + const [activeTab, setActiveTab] = useState("All"); + const [searchQuery, setSearchQuery] = useState(""); + const [loadedModels, setLoadedModels] = useState([]); + const [favorites, setFavorites] = useState(() => { + try { + const saved = localStorage.getItem("helix_model_favorites"); + return saved ? JSON.parse(saved) : []; + } catch { return []; } + }); + const searchRef = useRef(null); + + // Load installed NAM / IR models from API + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const data = await fetch("/api/models").then(r => r.json()); + if (!cancelled) setLoadedModels(data.models || []); + } catch {} + }; + load(); + return () => { cancelled = true; }; + }, []); + + // Save favorites + const toggleFav = useCallback((id) => { + setFavorites(prev => { + const next = prev.includes(id) ? prev.filter(f => f !== id) : [...prev, id]; + localStorage.setItem("helix_model_favorites", JSON.stringify(next)); + return next; + }); + }, []); + + // ── Determine current model ID for highlight ── + const currentModelId = currentModelPath + ? currentModelPath + : currentBlockType + ? `builtin:${currentBlockType}` + : null; + + // ── Build model list ── + const allModels = useMemo(() => { + const list = []; + + // Built-in block types + for (const m of BUILTIN_MODELS) { + list.push({ + id: `builtin:${m.type}`, + type: m.type, + name: m.name, + category: m.category, + desc: m.desc, + isBuiltin: true, + iconType: m.type, + }); + } + + // Installed NAM models — map to Amps category + for (const m of loadedModels) { + const label = m.name || m.filename || "Unnamed Model"; + list.push({ + id: `nam:${label}`, + type: "nam", + name: label, + category: "Amps", + desc: m.architecture ? `${m.architecture} · ${m.size_mb}MB` : "Installed NAM model", + isBuiltin: false, + loaded: m.loaded, + modelPath: m.path || m.filename, + iconType: "nam", + }); + } + + return list; + }, [loadedModels]); + + // ── Filter by tab & search ── + const visibleCategories = useMemo(() => { + if (activeTab === "All") { + const cats = [...CATEGORY_ORDER]; + // Only show non-empty + visible categories + return cats.filter(c => { + if (CATEGORY_HIDE.includes(c)) { + // Only show hidden categories if they have installed models + return allModels.some(m => m.category === c && !m.isBuiltin); + } + return allModels.some(m => m.category === c); + }); + } + if (activeTab === "★Favorites") return []; + return [activeTab]; + }, [activeTab, allModels]); + + const filteredModels = useMemo(() => { + let list = allModels; + + // Tab filter + if (activeTab === "★Favorites") { + list = list.filter(m => favorites.includes(m.id)); + } else if (activeTab !== "All") { + list = list.filter(m => m.category === activeTab); + } + + // Search filter + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + list = list.filter(m => + m.name.toLowerCase().includes(q) || + m.type.toLowerCase().includes(q) || + (m.desc || "").toLowerCase().includes(q) || + (m.category || "").toLowerCase().includes(q) + ); + } + + return list; + }, [allModels, activeTab, searchQuery, favorites]); + + const groupedByCategory = useMemo(() => { + if (activeTab !== "All" && activeTab !== "★Favorites") return null; + const groups = {}; + for (const m of filteredModels) { + if (!groups[m.category]) groups[m.category] = []; + groups[m.category].push(m); + } + return groups; + }, [filteredModels, activeTab]); + + // ── Handle model selection ── + const handleSelect = useCallback((model) => { + onSelectModel?.({ + type: model.type, + name: model.name, + modelPath: model.isBuiltin ? null : model.modelPath || model.id, + isBuiltin: model.isBuiltin, + }); + onClose?.(); + }, [onSelectModel, onClose]); + + // ── Handle tab selection ── + const tabs = ["All", "★Favorites", ...CATEGORY_ORDER.filter(c => { + // Only show categories that have models + if (CATEGORY_HIDE.includes(c)) { + return allModels.some(m => m.category === c && !m.isBuiltin); + } + return allModels.some(m => m.category === c); + })]; + + return ( +
+ {/* ── Header ── */} +
+
+ +
+
Model Browser
+ {currentBlockName && ( +
+ Current: {currentBlockName} +
+ )} +
+
+
+ + {/* ── Search bar ── */} +
+
+ 🔍 + setSearchQuery(e.target.value)} + style={{ + flex: 1, background: "none", border: "none", + color: T.textPrimary, fontSize: 13, outline: "none", + }} + /> + {searchQuery && ( + + )} +
+
+ + {/* ── Category tabs ── */} +
+ {tabs.map(tab => { + const def = CATEGORY_DEFS[tab]; + const isActive = activeTab === tab; + return ( + + ); + })} +
+ + {/* ── Model grid area ── */} +
+ {/* Favorites empty state */} + {activeTab === "★Favorites" && filteredModels.length === 0 && ( +
+ +
No favorites yet
+
+ Star a model to add it to your favorites +
+
+ )} + + {/* Search empty state */} + {searchQuery && filteredModels.length === 0 && ( +
+
No models matching "{searchQuery}"
+
+ )} + + {/* Categories with models (All tab) or just the models */} + {groupedByCategory ? ( + // All tab — grouped by category + visibleCategories.map(cat => { + const models = groupedByCategory[cat] || []; + if (models.length === 0) return null; + const def = CATEGORY_DEFS[cat]; + return ( +
+
+ {def && } + {cat} +
+
+
+ {models.map(model => ( + handleSelect(model)} + onToggleFav={() => toggleFav(model.id)} + /> + ))} +
+
+ ); + }) + ) : ( + // Specific tab — flat grid + <> + {filteredModels.length > 0 && ( +
+ {filteredModels.map(model => ( + handleSelect(model)} + onToggleFav={() => toggleFav(model.id)} + /> + ))} +
+ )} + + )} +
+
+ ); +} + +// ── Individual Model Tile ── +function ModelTile({ model, isCurrent, isFavorite, onSelect, onToggleFav }) { + const color = getTypeColor(model.type); + const [imgError, setImgError] = useState(false); + + return ( +
+ {/* Favorite star */} +
{ e.stopPropagation(); onToggleFav(); }} + style={{ + position: "absolute", top: 4, right: 4, + fontSize: 13, cursor: "pointer", zIndex: 2, + color: isFavorite ? T.amber : T.textDim, + textShadow: isFavorite ? `0 0 4px ${T.amber}` : "none", + transition: "all .15s", + lineHeight: 1, + }}> + {isFavorite ? "★" : "☆"} +
+ + {/* Model icon */} +
+ {!imgError ? ( + {model.type} setImgError(true)} + /> + ) : ( + + {model.name.charAt(0)} + + )} +
+ + {/* Model name */} +
+ {model.name} +
+ + {/* Type badge */} +
+ + {model.type.replace(/_/g, " ")} + +
+ + {/* Description */} + {model.desc && ( +
+ {model.desc} +
+ )} + + {/* Current indicator */} + {isCurrent && ( +
+ )} +
+ ); +}