525 lines
22 KiB
React
525 lines
22 KiB
React
import { useState, useRef, useCallback, useMemo } 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",
|
||
};
|
||
|
||
// ── Block param definitions (mirrored from App.jsx) ──
|
||
const BLOCK_PARAMS = {
|
||
overdrive: [{ key:'drive', label:'Drive', min:0, max:100, def:30 }, { key:'tone', label:'Tone', min:0, max:100, def:50 }, { key:'level', label:'Level', min:0, max:100, def:70 }],
|
||
distortion: [{ key:'drive', label:'Drive', min:0, max:100, def:50 }, { key:'tone', label:'Tone', min:0, max:100, def:40 }, { key:'level', label:'Level', min:0, max:100, def:60 }],
|
||
fuzz: [{ key:'fuzz', label:'Fuzz', min:0, max:100, def:60 }, { key:'tone', label:'Tone', min:0, max:100, def:30 }, { key:'level', label:'Level', min:0, max:100, def:60 }],
|
||
delay: [{ key:'time', label:'Time', min:0, max:100, def:40 }, { key:'feedback', label:'Feedback', min:0, max:100, def:30 }, { key:'mix', label:'Mix', min:0, max:100, def:35 }],
|
||
echo: [{ key:'time', label:'Time', min:0, max:100, def:40 }, { key:'feedback', label:'Feedback', min:0, max:100, def:30 }, { key:'mix', label:'Mix', min:0, max:100, def:35 }],
|
||
reverb: [{ key:'decay', label:'Decay', min:0, max:100, def:50 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }, { key:'tone', label:'Tone', min:0, max:100, def:50 }],
|
||
chorus: [{ key:'rate', label:'Rate', min:0, max:100, def:40 }, { key:'depth', label:'Depth', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }],
|
||
flanger: [{ key:'rate', label:'Rate', min:0, max:100, def:30 }, { key:'depth', label:'Depth', min:0, max:100, def:50 }, { key:'feedback', label:'Feedback', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }],
|
||
phaser: [{ key:'rate', label:'Rate', min:0, max:100, def:30 }, { key:'depth', label:'Depth', min:0, max:100, def:50 }, { key:'feedback', label:'Feedback', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }],
|
||
compressor: [{ key:'threshold', label:'Thresh', min:0, max:100, def:50 }, { key:'ratio', label:'Ratio', min:1, max:20, def:4 }, { key:'gain', label:'Gain', min:0, max:100, def:50 }],
|
||
gate: [{ key:'threshold', label:'Thresh', min:0, max:100, def:30 }, { key:'release', label:'Release', min:0, max:100, def:50 }],
|
||
eq: [{ key:'low', label:'Low', min:-12, max:12, def:0 }, { key:'mid', label:'Mid', min:-12, max:12, def:0 }, { key:'high', label:'High', min:-12, max:12, def:0 }, { key:'level', label:'Level', min:0, max:100, def:70 }],
|
||
cabinet: [{ key:'level', label:'Level', min:0, max:100, def:75 }],
|
||
ir: [{ key:'level', label:'Level', min:0, max:100, def:75 }],
|
||
};
|
||
|
||
const BLOCK_COLORS = {
|
||
overdrive: T.amber, distortion: T.red, fuzz: '#C07030',
|
||
delay: T.blue, echo: T.blue, reverb: '#60B0D0',
|
||
chorus: T.green, flanger: '#50C080', phaser: '#80C050',
|
||
tremolo: T.green, compressor: '#D09040', gate: T.textSec, eq: '#7080D0',
|
||
cabinet: '#D07090', ir: '#D07090', nam: '#E0A040', boost: T.amber,
|
||
wah: '#D0A060', volume: T.textSec, tuner: '#80D0A0',
|
||
};
|
||
|
||
const BLOCK_DISPLAY_NAMES = {
|
||
overdrive: "OD-808", distortion: "RAT", fuzz: "Big Muff",
|
||
delay: "Digital Delay", echo: "Tape Echo", reverb: "Hall Reverb",
|
||
chorus: "Chorus", flanger: "Flanger", phaser: "Phase 90",
|
||
tremolo: "Tremolo", compressor: "Compressor", gate: "Noise Gate",
|
||
eq: "EQ", cabinet: "Cabinet", ir: "IR Loader", nam: "NAM Model",
|
||
boost: "Clean Boost", wah: "Wah", volume: "Volume", tuner: "Tuner",
|
||
loop: "FX Loop",
|
||
};
|
||
|
||
function getBlockParams(type) {
|
||
return BLOCK_PARAMS[(type || '').toLowerCase()] || [{ key:'level', label:'Level', min:0, max:100, def:50 }];
|
||
}
|
||
function getBlockColor(type) {
|
||
return BLOCK_COLORS[(type || '').toLowerCase()] || T.amber;
|
||
}
|
||
function getBlockDisplayName(block) {
|
||
const key = (block.type || '').toLowerCase();
|
||
if (block.nam_model_path) {
|
||
const parts = block.nam_model_path.split('/').pop()?.replace(/\.(nam|wav|aiff?)$/i, '') || '';
|
||
return parts || 'NAM Model';
|
||
}
|
||
if (block.ir_file_path) {
|
||
const parts = block.ir_file_path.split('/').pop()?.replace(/\.(wav|aiff?|ir)$/i, '') || '';
|
||
return parts || 'IR Loader';
|
||
}
|
||
return BLOCK_DISPLAY_NAMES[key] || key.charAt(0).toUpperCase() + key.slice(1);
|
||
}
|
||
|
||
// ── Large touch knob (bigger version for Focus View) ──
|
||
function FocusKnob({ label, value = 50, onChange, size = 72, color = T.amber, min = 0, max = 100, compact = false }) {
|
||
const startRef = useRef(null);
|
||
const norm = (value - min) / (max - min);
|
||
const angle = -140 + norm * 280;
|
||
const dotH = size * 0.38;
|
||
const dotTop = size * 0.5 - dotH;
|
||
|
||
const handleStart = (e) => {
|
||
e.preventDefault();
|
||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||
startRef.current = { y: clientY, val: value };
|
||
const move = (ev) => {
|
||
const cy = ev.touches ? ev.touches[0].clientY : ev.clientY;
|
||
const delta = (startRef.current.y - cy) / 120;
|
||
const next = Math.max(min, Math.min(max, startRef.current.val + delta * (max - min)));
|
||
onChange?.(Math.round(next));
|
||
};
|
||
const up = () => { window.removeEventListener("mousemove",move);window.removeEventListener("mouseup",up);
|
||
window.removeEventListener("touchmove",move);window.removeEventListener("touchend",up); };
|
||
window.addEventListener("mousemove",move);window.addEventListener("mouseup",up);
|
||
window.addEventListener("touchmove",move,{passive:false});window.addEventListener("touchend",up);
|
||
};
|
||
|
||
const svgSize = size + 16, cx = svgSize / 2, cy = svgSize / 2, r = size / 2 + 4;
|
||
const lx = cx + r * Math.cos((-140 - 90) * (Math.PI / 180)), ly = cy + r * Math.sin((-140 - 90) * (Math.PI / 180));
|
||
const ex = cx + r * Math.cos((angle - 90) * (Math.PI / 180)), ey = cy + r * Math.sin((angle - 90) * (Math.PI / 180));
|
||
const large = norm * 280 > 180 ? 1 : 0;
|
||
|
||
return (
|
||
<div style={{
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: compact ? 2 : 6,
|
||
cursor: 'pointer',
|
||
}}>
|
||
<div style={{ width: size, height: size, position: 'relative', touchAction: 'none' }}
|
||
onMouseDown={handleStart} onTouchStart={handleStart}>
|
||
<svg style={{ position: 'absolute', inset: -8, width: svgSize, height: svgSize, overflow: 'visible' }}>
|
||
<circle cx={cx} cy={cy} r={r} fill="none" stroke={T.border} strokeWidth="3" strokeLinecap="round" opacity=".5" />
|
||
{norm > 0 && <path d={`M ${lx} ${ly} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`} fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" style={{ filter: `drop-shadow(0 0 4px ${color})` }} />}
|
||
</svg>
|
||
<div style={{
|
||
borderRadius: '50%', width: '100%', height: '100%', position: 'relative',
|
||
background: 'radial-gradient(circle at 35% 30%, #3A3A48, #1A1A22)',
|
||
boxShadow: '0 3px 12px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.07)',
|
||
}}>
|
||
<div style={{
|
||
position: 'absolute', width: 4, borderRadius: 2, background: color,
|
||
left: '50%', transformOrigin: 'bottom center',
|
||
height: dotH, top: dotTop,
|
||
transform: `translateX(-50%) rotate(${angle}deg)`,
|
||
transformOrigin: `50% ${dotH}px`,
|
||
boxShadow: `0 0 6px ${color}`,
|
||
}} />
|
||
</div>
|
||
</div>
|
||
<div style={{
|
||
fontFamily: "'JetBrains Mono', monospace", fontSize: compact ? 11 : 14,
|
||
fontWeight: 700, color,
|
||
}}>{value}</div>
|
||
<div style={{
|
||
fontSize: compact ? 7 : 9, color: T.textSec,
|
||
letterSpacing: '.06em', textTransform: 'uppercase', textAlign: 'center',
|
||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: size + 16,
|
||
}}>{label}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Large Param Slider (bigger for Focus View) ──
|
||
function FocusSlider({ label, value = 50, onChange, onChangeEnd, min = 0, max = 100, color = T.amber }) {
|
||
const trackRef = useRef(null);
|
||
const dragRef = useRef(false);
|
||
const lastValRef = useRef(value);
|
||
const onChangeRef = useRef(onChange);
|
||
const onChangeEndRef = useRef(onChangeEnd);
|
||
const pct = ((value - min) / (max - min)) * 100;
|
||
|
||
const [touchVal, setTouchVal] = useState(null);
|
||
|
||
useMemo(() => { onChangeRef.current = onChange; }, [onChange]);
|
||
useMemo(() => { onChangeEndRef.current = onChangeEnd; }, [onChangeEnd]);
|
||
|
||
const valFromEvent = useCallback((e) => {
|
||
const t = trackRef.current; if (!t) return value;
|
||
const r = t.getBoundingClientRect();
|
||
const x = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
|
||
return Math.round(min + Math.max(0, Math.min(1, x / r.width)) * (max - min));
|
||
}, [min, max, value]);
|
||
|
||
useMemo(() => {
|
||
const move = (e) => { if (!dragRef.current) return; e.preventDefault(); const v = valFromEvent(e); lastValRef.current = v; setTouchVal(v); onChangeRef.current?.(v); };
|
||
const up = () => { if (dragRef.current) { onChangeEndRef.current?.(lastValRef.current); dragRef.current = false; setTouchVal(null); } };
|
||
window.addEventListener('mousemove', move); window.addEventListener('mouseup', up);
|
||
window.addEventListener('touchmove', move, { passive: false }); window.addEventListener('touchend', up);
|
||
return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up);
|
||
window.removeEventListener('touchmove', move); window.removeEventListener('touchend', up); };
|
||
}, [valFromEvent]);
|
||
|
||
const handleStart = (e) => { e.preventDefault(); const v = valFromEvent(e); lastValRef.current = v; dragRef.current = true; setTouchVal(v); onChangeRef.current?.(v); };
|
||
const quick = (delta) => (e) => { e.preventDefault(); e.stopPropagation(); const next = Math.max(min, Math.min(max, value + delta)); onChangeRef.current?.(next); onChangeEndRef.current?.(next); };
|
||
|
||
return (
|
||
<div style={{ marginBottom: 4 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
|
||
<span style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '.06em', color }}>{label}</span>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<button style={{
|
||
width: 34, height: 34, borderRadius: '50%', border: `1px solid ${T.border}`,
|
||
background: T.panel, color: T.textPrimary, fontSize: 16, fontWeight: 700,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
||
lineHeight: 1,
|
||
}} onMouseDown={quick(-5)} onTouchStart={quick(-5)}>−</button>
|
||
<span style={{
|
||
fontFamily: "'JetBrains Mono', monospace", fontSize: 16, fontWeight: 700, color,
|
||
minWidth: 40, textAlign: 'center',
|
||
}}>{touchVal ?? value}</span>
|
||
<button style={{
|
||
width: 34, height: 34, borderRadius: '50%', border: `1px solid ${T.border}`,
|
||
background: T.panel, color: T.textPrimary, fontSize: 16, fontWeight: 700,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
||
lineHeight: 1,
|
||
}} onMouseDown={quick(5)} onTouchStart={quick(5)}>+</button>
|
||
</div>
|
||
</div>
|
||
<div ref={trackRef}
|
||
onMouseDown={handleStart} onTouchStart={handleStart}
|
||
style={{
|
||
position: 'relative', height: 44, display: 'flex', alignItems: 'center',
|
||
cursor: 'pointer', touchAction: 'none',
|
||
}}>
|
||
<div style={{
|
||
width: '100%', height: 10, background: T.border, borderRadius: 5,
|
||
position: 'relative', overflow: 'hidden',
|
||
}}>
|
||
<div style={{
|
||
height: '100%', borderRadius: 5, position: 'absolute', top: 0, left: 0,
|
||
width: `${pct}%`, background: color,
|
||
boxShadow: `0 0 8px ${color}44`,
|
||
transition: 'width .03s',
|
||
}} />
|
||
</div>
|
||
<div style={{
|
||
width: 30, height: 30, borderRadius: '50%', position: 'absolute',
|
||
top: '50%', left: `${pct}%`,
|
||
transform: 'translate(-50%,-50%)',
|
||
background: color,
|
||
boxShadow: `0 2px 10px rgba(0,0,0,.6), 0 0 0 3px ${T.bg}`,
|
||
pointerEvents: 'none',
|
||
transition: 'left .03s',
|
||
}} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Parameter page (single page of up to 8 params) ──
|
||
function ParamPage({ defs, params, viewMode, color, onParamChange, onParamChangeEnd, compact }) {
|
||
return (
|
||
<div style={{
|
||
display: 'flex', flexDirection: 'column', gap: viewMode === 'knob' ? 8 : 0,
|
||
width: '100%', minWidth: 0, flexShrink: 0,
|
||
}}>
|
||
{viewMode === 'slider' ? (
|
||
defs.map(p => (
|
||
<FocusSlider key={p.key} label={p.label}
|
||
value={params[p.key] != null ? params[p.key] : p.def}
|
||
min={p.min} max={p.max} color={color}
|
||
onChange={v => onParamChange(p.key, v)}
|
||
onChangeEnd={v => onParamChangeEnd(p.key, v)} />
|
||
))
|
||
) : (
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: `repeat(${compact ? 4 : Math.min(4, defs.length)}, 1fr)`,
|
||
gap: 12,
|
||
padding: '8px 0',
|
||
justifyItems: 'center',
|
||
width: '100%',
|
||
maxWidth: 480,
|
||
margin: '0 auto',
|
||
}}>
|
||
{defs.map(p => (
|
||
<FocusKnob key={p.key} label={p.label} size={compact ? 60 : 72}
|
||
value={params[p.key] != null ? params[p.key] : p.def}
|
||
min={p.min} max={p.max} color={color}
|
||
onChange={v => { onParamChange(p.key, v); onParamChangeEnd(p.key, v); }}
|
||
compact={compact} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Focus View (full-screen overlay) ──
|
||
export default function FocusView({
|
||
block,
|
||
params,
|
||
viewMode,
|
||
onClose,
|
||
onParamChange,
|
||
onParamChangeEnd,
|
||
onToggleView,
|
||
onToggleBypass,
|
||
onOpenModelBrowser,
|
||
}) {
|
||
const defs = getBlockParams(block.type);
|
||
const color = getBlockColor(block.type);
|
||
const displayName = getBlockDisplayName(block);
|
||
|
||
// Pagination — max 8 params per page
|
||
const PER_PAGE = 8;
|
||
const pages = useMemo(() => {
|
||
const p = [];
|
||
for (let i = 0; i < defs.length; i += PER_PAGE) {
|
||
p.push(defs.slice(i, i + PER_PAGE));
|
||
}
|
||
return p;
|
||
}, [defs]);
|
||
|
||
const [currentPage, setCurrentPage] = useState(0);
|
||
const scrollRef = useRef(null);
|
||
const touchStartX = useRef(null);
|
||
const [isCompact, setIsCompact] = useState(window.innerWidth < 500);
|
||
|
||
// Track resize for compact layout
|
||
useMemo(() => {
|
||
const onResize = () => setIsCompact(window.innerWidth < 500);
|
||
window.addEventListener('resize', onResize);
|
||
return () => window.removeEventListener('resize', onResize);
|
||
}, []);
|
||
|
||
// ── Swipe handling ──
|
||
const handleTouchStart = useCallback((e) => {
|
||
touchStartX.current = e.touches[0].clientX;
|
||
}, []);
|
||
|
||
const handleTouchEnd = useCallback((e) => {
|
||
if (touchStartX.current == null) return;
|
||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||
touchStartX.current = null;
|
||
if (Math.abs(dx) < 50) return; // threshold
|
||
if (dx < 0 && currentPage < pages.length - 1) {
|
||
setCurrentPage(p => Math.min(p + 1, pages.length - 1));
|
||
} else if (dx > 0 && currentPage > 0) {
|
||
setCurrentPage(p => Math.max(p - 1, 0));
|
||
}
|
||
}, [currentPage, pages.length]);
|
||
|
||
// ── Keyboard nav ──
|
||
useMemo(() => {
|
||
const handler = (e) => {
|
||
if (e.key === 'ArrowLeft' && currentPage > 0) setCurrentPage(p => p - 1);
|
||
if (e.key === 'ArrowRight' && currentPage < pages.length - 1) setCurrentPage(p => p + 1);
|
||
if (e.key === 'Escape') onClose?.();
|
||
};
|
||
window.addEventListener('keydown', handler);
|
||
return () => window.removeEventListener('keydown', handler);
|
||
}, [currentPage, pages.length, onClose]);
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'fixed', inset: 0, zIndex: 1000,
|
||
display: 'flex', flexDirection: 'column',
|
||
background: T.bg,
|
||
fontFamily: "'Inter', sans-serif",
|
||
color: T.textPrimary,
|
||
}}>
|
||
{/* ── 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, minWidth: 0 }}>
|
||
<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',
|
||
flexShrink: 0,
|
||
}}>
|
||
✕
|
||
</button>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{
|
||
fontSize: 15, fontWeight: 600, whiteSpace: 'nowrap',
|
||
overflow: 'hidden', textOverflow: 'ellipsis',
|
||
}}>
|
||
{displayName}
|
||
</div>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 6, marginTop: 2,
|
||
}}>
|
||
<span style={{
|
||
display: 'inline-flex', padding: '1px 6px', borderRadius: 3,
|
||
fontSize: 9, fontWeight: 700, letterSpacing: '.06em',
|
||
textTransform: 'uppercase',
|
||
background: `${color}22`, color,
|
||
}}>
|
||
{(block.type || 'fx').replace(/_/g, ' ').toUpperCase()}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
|
||
{/* Slider/Knob toggle */}
|
||
<button onClick={onToggleView}
|
||
style={{
|
||
padding: '6px 10px', borderRadius: 6, fontSize: 10, fontWeight: 600,
|
||
background: T.surface, border: `1px solid ${T.border}`,
|
||
color: T.textPrimary, cursor: 'pointer', letterSpacing: '.04em',
|
||
}}>
|
||
{viewMode === 'slider' ? '◉ Knobs' : '▦ Sliders'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Bypass toggle (prominent) ── */}
|
||
<div style={{
|
||
padding: '6px 14px',
|
||
borderBottom: `1px solid ${T.border}`,
|
||
background: T.surface,
|
||
flexShrink: 0,
|
||
}}>
|
||
<button onClick={onToggleBypass}
|
||
style={{
|
||
width: '100%',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||
padding: '10px 14px',
|
||
borderRadius: 8,
|
||
border: `2px solid ${block.bypassed ? T.red + '66' : T.green + '66'}`,
|
||
background: block.bypassed
|
||
? `linear-gradient(180deg, ${T.red + '18'}, ${T.panel})`
|
||
: `linear-gradient(180deg, ${T.green + '18'}, ${T.panel})`,
|
||
cursor: 'pointer',
|
||
transition: 'all .12s',
|
||
}}>
|
||
<div style={{
|
||
width: 12, height: 12, borderRadius: '50%',
|
||
background: block.bypassed ? T.textDim : T.green,
|
||
boxShadow: block.bypassed ? 'none' : `0 0 8px ${T.green}`,
|
||
}} />
|
||
<span style={{
|
||
fontSize: 12, fontWeight: 700, letterSpacing: '.08em', textTransform: 'uppercase',
|
||
color: block.bypassed ? T.red : T.green,
|
||
}}>
|
||
{block.bypassed ? 'Bypassed — Tap to Enable' : 'Active — Tap to Bypass'}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Parameter area (scrollable with pages) ── */}
|
||
<div
|
||
ref={scrollRef}
|
||
onTouchStart={handleTouchStart}
|
||
onTouchEnd={handleTouchEnd}
|
||
style={{
|
||
flex: 1, overflowY: 'auto', overflowX: 'hidden',
|
||
padding: '12px 16px',
|
||
display: 'flex', flexDirection: 'column',
|
||
gap: 6,
|
||
}}>
|
||
|
||
{pages.length === 0 ? (
|
||
<div style={{
|
||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
color: T.textDim, fontSize: 12,
|
||
}}>
|
||
No parameters for this block type
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Page content */}
|
||
<ParamPage
|
||
defs={pages[currentPage]}
|
||
params={params}
|
||
viewMode={viewMode}
|
||
color={color}
|
||
onParamChange={onParamChange}
|
||
onParamChangeEnd={onParamChangeEnd}
|
||
compact={isCompact}
|
||
/>
|
||
|
||
{/* Spacer so page dots don't crowd content */}
|
||
<div style={{ flex: 1, minHeight: 8 }} />
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Bottom bar: Model browser + page indicators ── */}
|
||
<div style={{
|
||
padding: '8px 14px 12px',
|
||
borderTop: `1px solid ${T.border}`,
|
||
background: T.panel,
|
||
flexShrink: 0,
|
||
display: 'flex', flexDirection: 'column', gap: 6,
|
||
}}>
|
||
{/* Page dots */}
|
||
{pages.length > 1 && (
|
||
<div style={{ display: 'flex', justifyContent: 'center', gap: 5 }}>
|
||
{pages.map((_, i) => (
|
||
<button key={i} onClick={() => setCurrentPage(i)}
|
||
style={{
|
||
width: 8, height: 8, borderRadius: '50%', border: 'none',
|
||
padding: 0, cursor: 'pointer',
|
||
background: i === currentPage ? color : T.border,
|
||
opacity: i === currentPage ? 1 : 0.4,
|
||
transition: 'all .12s',
|
||
}} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Page indicator text */}
|
||
{pages.length > 1 && (
|
||
<div style={{
|
||
textAlign: 'center', fontSize: 9, color: T.textDim,
|
||
fontFamily: "'JetBrains Mono', monospace", letterSpacing: '.04em',
|
||
}}>
|
||
Page {currentPage + 1} / {pages.length}
|
||
<span style={{ marginLeft: 8, opacity: 0.6 }}>
|
||
← swipe to navigate →
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Model browser button / swap block */}
|
||
{onOpenModelBrowser && (
|
||
<button onClick={onOpenModelBrowser}
|
||
style={{
|
||
width: '100%',
|
||
padding: '8px 14px', borderRadius: 7,
|
||
background: T.surface, border: `1px solid ${T.border}`,
|
||
color: T.textPrimary, fontSize: 11, fontWeight: 600,
|
||
cursor: 'pointer', letterSpacing: '.04em',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||
}}>
|
||
<span>🎛</span> Swap Model / Block Type
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|