feat: Model Browser — block library with category filter tabs, search, favorites, NAM/IR models

This commit is contained in:
2026-06-12 19:51:19 -04:00
parent f67e6bd8c7
commit 376249172f
3 changed files with 807 additions and 11 deletions
+57 -7
View File
@@ -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 (
<div className="param-panel">
<div className="param-panel-header">
<span className="param-panel-title"><span style={{color}}></span> {block.name||block.type||''}</span>
<span className="param-panel-title" style={{cursor:onModelBrowser?'pointer':'default'}}
onClick={()=>onModelBrowser?.(block.id)}>
<span style={{color}}></span> {block.name||block.type||''}
{onModelBrowser&&<span style={{marginLeft:4,fontSize:8,color:T.textDim}}></span>}
</span>
<div style={{display:'flex',gap:4,alignItems:'center'}}>
{onFocus&&<button className="btn" style={{padding:'4px 8px',fontSize:9,fontWeight:600,background:`${T.amber}22`,border:`1px solid ${T.amber}44`,color:T.amber,borderRadius:5,letterSpacing:'.04em',cursor:'pointer'}}
onClick={onFocus}>🔍 Focus</button>}
@@ -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}
/>
</div>
@@ -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}
/>
):(
<div style={{flex:1,display:"flex",alignItems:"center",justifyContent:"center",background:T.bg,color:T.textDim,fontSize:11}}>
@@ -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 (
<ModelBrowser
currentBlockType={block.type}
currentBlockName={block.name}
currentModelPath={block.nam_model_path||block.ir_file_path}
onSelectModel={handleSelectModel}
onClose={()=>setModelBrowserBlockId(null)}
/>
);
})()}
</div></>
);
}
+70 -4
View File
@@ -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 (
<div
draggable
onClick={() => 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 (
<div
draggable
onClick={() => 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 (
<div
draggable
onClick={() => 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}
/>
<ChainArrow />
</div>
@@ -585,6 +646,7 @@ export default function BlockChain({
index={idx}
onSelect={onSelectBlock}
onToggle={onToggleBlock}
onModelBrowser={onModelBrowser}
/>
<ChainArrow />
</div>
@@ -597,6 +659,7 @@ export default function BlockChain({
selected={splitBlock.id === selectedBlockId}
onSelect={onSelectBlock}
onToggle={onToggleBlock}
onModelBrowser={onModelBrowser}
/>
<ChainArrow />
</div>
@@ -611,6 +674,7 @@ export default function BlockChain({
onSelect={onSelectBlock}
onToggle={onToggleBlock}
pathLabel="A"
onModelBrowser={onModelBrowser}
/>
<ChainArrow />
</div>
@@ -626,6 +690,7 @@ export default function BlockChain({
selected={mergeBlock.id === selectedBlockId}
onSelect={onSelectBlock}
onToggle={onToggleBlock}
onModelBrowser={onModelBrowser}
/>
<ChainArrow />
</div>
@@ -639,6 +704,7 @@ export default function BlockChain({
index={idx}
onSelect={onSelectBlock}
onToggle={onToggleBlock}
onModelBrowser={onModelBrowser}
/>
<ChainArrow />
</div>
+680
View File
@@ -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 (
<img
src={`${IMG_PATH}${icon}.svg`}
alt={type || "fx"}
style={{
width: size, height: size, flexShrink: 0,
filter: "invert(0.95) brightness(2)",
objectFit: "contain",
}}
onError={(e) => { e.target.style.display = "none"; }}
/>
);
}
// ── Category Icon ──
function CategoryIcon({ category, size = 14 }) {
const def = CATEGORY_DEFS[category];
if (!def) return null;
return (
<img
src={`${IMG_PATH}${def.icon}.svg`}
alt={category}
style={{
width: size, height: size, flexShrink: 0,
filter: "invert(0.95) brightness(2)",
objectFit: "contain",
}}
onError={(e) => { 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 (
<div style={{
position: "fixed", inset: 0, zIndex: 1000,
display: "flex", flexDirection: "column",
background: T.bg,
color: T.textPrimary,
fontFamily: "'Inter', sans-serif",
}}>
{/* ── Header ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "10px 14px",
borderBottom: `1px solid ${T.border}`,
background: T.panel,
flexShrink: 0,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button onClick={onClose}
style={{
width: 34, height: 34, borderRadius: 8,
display: "flex", alignItems: "center", justifyContent: "center",
background: T.surface, border: `1px solid ${T.border}`,
color: T.textPrimary, fontSize: 16, cursor: "pointer",
}}>
</button>
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Model Browser</div>
{currentBlockName && (
<div style={{ fontSize: 10, color: T.textSec, marginTop: 1 }}>
Current: {currentBlockName}
</div>
)}
</div>
</div>
</div>
{/* ── Search bar ── */}
<div style={{
padding: "8px 14px",
borderBottom: `1px solid ${T.border}`,
background: T.surface,
flexShrink: 0,
}}>
<div style={{
display: "flex", alignItems: "center", gap: 8,
background: T.panel, borderRadius: 7,
border: `1px solid ${T.border}`,
padding: "7px 10px",
}}>
<span style={{ color: T.textDim, fontSize: 13 }}>🔍</span>
<input
ref={searchRef}
placeholder="Search models..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
flex: 1, background: "none", border: "none",
color: T.textPrimary, fontSize: 13, outline: "none",
}}
/>
{searchQuery && (
<button onClick={() => setSearchQuery("")}
style={{
background: "none", border: "none", color: T.textDim,
fontSize: 14, cursor: "pointer", padding: 2,
}}>
</button>
)}
</div>
</div>
{/* ── Category tabs ── */}
<div style={{
display: "flex", gap: 0,
overflowX: "auto", overflowY: "hidden",
borderBottom: `1px solid ${T.border}`,
background: T.panel,
flexShrink: 0,
WebkitOverflowScrolling: "touch",
}}>
{tabs.map(tab => {
const def = CATEGORY_DEFS[tab];
const isActive = activeTab === tab;
return (
<button key={tab} onClick={() => setActiveTab(tab)}
style={{
padding: "8px 12px",
fontSize: 10, fontWeight: 600,
letterSpacing: ".04em",
whiteSpace: "nowrap",
background: "none",
border: "none",
borderBottom: `2px solid ${isActive ? T.amber : "transparent"}`,
color: isActive ? T.textPrimary : T.textSec,
cursor: "pointer",
display: "flex", alignItems: "center", gap: 5,
flexShrink: 0,
transition: "all .12s",
}}>
{def && <CategoryIcon category={tab} size={12} />}
{tab === "★Favorites" && <span></span>}
{tab === "★Favorites" ? " Favorites" : tab}
</button>
);
})}
</div>
{/* ── Model grid area ── */}
<div style={{
flex: 1, overflowY: "auto", overflowX: "hidden",
padding: "10px 14px",
WebkitOverflowScrolling: "touch",
}}>
{/* Favorites empty state */}
{activeTab === "★Favorites" && filteredModels.length === 0 && (
<div style={{
display: "flex", flexDirection: "column", alignItems: "center",
justifyContent: "center", padding: "40px 20px", gap: 8,
color: T.textDim,
}}>
<span style={{ fontSize: 28 }}></span>
<div style={{ fontSize: 13, fontWeight: 500 }}>No favorites yet</div>
<div style={{ fontSize: 11, textAlign: "center" }}>
Star a model to add it to your favorites
</div>
</div>
)}
{/* Search empty state */}
{searchQuery && filteredModels.length === 0 && (
<div style={{
display: "flex", flexDirection: "column", alignItems: "center",
justifyContent: "center", padding: "40px 20px", gap: 8,
color: T.textDim,
}}>
<div style={{ fontSize: 13 }}>No models matching "{searchQuery}"</div>
</div>
)}
{/* 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 (
<div key={cat} style={{ marginBottom: 20 }}>
<div style={{
display: "flex", alignItems: "center", gap: 8,
marginBottom: 8,
}}>
{def && <CategoryIcon category={cat} size={16} />}
<span style={{
fontSize: 11, fontWeight: 700, letterSpacing: ".08em",
textTransform: "uppercase", color: T.textSec,
}}>{cat}</span>
<div style={{
flex: 1, height: 1, background: T.border, opacity: 0.4,
}} />
</div>
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(130px, 1fr))",
gap: 6,
}}>
{models.map(model => (
<ModelTile
key={model.id}
model={model}
isCurrent={model.id === currentModelId}
isFavorite={favorites.includes(model.id)}
onSelect={() => handleSelect(model)}
onToggleFav={() => toggleFav(model.id)}
/>
))}
</div>
</div>
);
})
) : (
// Specific tab — flat grid
<>
{filteredModels.length > 0 && (
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(130px, 1fr))",
gap: 6,
}}>
{filteredModels.map(model => (
<ModelTile
key={model.id}
model={model}
isCurrent={model.id === currentModelId}
isFavorite={favorites.includes(model.id)}
onSelect={() => handleSelect(model)}
onToggleFav={() => toggleFav(model.id)}
/>
))}
</div>
)}
</>
)}
</div>
</div>
);
}
// ── Individual Model Tile ──
function ModelTile({ model, isCurrent, isFavorite, onSelect, onToggleFav }) {
const color = getTypeColor(model.type);
const [imgError, setImgError] = useState(false);
return (
<div onClick={onSelect}
style={{
display: "flex", flexDirection: "column", gap: 0,
padding: "10px 8px 8px",
borderRadius: 8,
background: isCurrent
? `linear-gradient(135deg, ${color}18, ${T.panel})`
: T.surface,
border: `1.5px solid ${isCurrent ? color + "66" : T.border}`,
cursor: "pointer",
position: "relative",
transition: "all .12s",
boxShadow: isCurrent ? `0 0 12px ${color}33` : "none",
minHeight: 72,
}}
>
{/* Favorite star */}
<div onClick={(e) => { 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 ? "★" : "☆"}
</div>
{/* Model icon */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
height: 34, marginBottom: 6,
}}>
{!imgError ? (
<img
src={`${IMG_PATH}${getIconFile(model.iconType || model.type)}.svg`}
alt={model.type}
style={{
width: 28, height: 28,
filter: `invert(0.95) brightness(2) drop-shadow(0 0 4px ${color}44)`,
objectFit: "contain",
}}
onError={() => setImgError(true)}
/>
) : (
<span style={{
fontSize: 18, color, opacity: 0.7,
fontFamily: "'JetBrains Mono', monospace", fontWeight: 700,
}}>
{model.name.charAt(0)}
</span>
)}
</div>
{/* Model name */}
<div style={{
fontSize: 11, fontWeight: 600,
color: isCurrent ? color : T.textPrimary,
textAlign: "center",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
lineHeight: 1.3,
}}>
{model.name}
</div>
{/* Type badge */}
<div style={{
display: "flex", justifyContent: "center", marginTop: 3,
}}>
<span style={{
display: "inline-flex", padding: "1px 5px", borderRadius: 3,
fontSize: 8, fontWeight: 700, letterSpacing: ".05em",
textTransform: "uppercase",
background: `${color}18`, color,
}}>
{model.type.replace(/_/g, " ")}
</span>
</div>
{/* Description */}
{model.desc && (
<div style={{
fontSize: 8, color: T.textDim,
textAlign: "center", marginTop: 3,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
}}>
{model.desc}
</div>
)}
{/* Current indicator */}
{isCurrent && (
<div style={{
position: "absolute", top: 4, left: 4,
width: 6, height: 6, borderRadius: "50%",
background: T.green,
boxShadow: `0 0 6px ${T.green}`,
}} />
)}
</div>
);
}