PEDAL-UI: Helix Stadium-style redesign — block chain, big touch sliders, footswitch bar with scribble strips

This commit is contained in:
2026-06-12 18:03:27 -04:00
commit b832ae06a3
16 changed files with 5932 additions and 0 deletions
+813
View File
@@ -0,0 +1,813 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import BlockChain from "./BlockChain.jsx";
import FootswitchBar from "./FootswitchBar.jsx";
// ── API Layer ──────────────────────────────────────────────────────
const API_BASE = "";
async function api(method, path, body) {
const opts = { method, headers: { "Content-Type": "application/json" } };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${API_BASE}${path}`, opts);
if (!res.ok) throw new Error(`API ${method} ${path}: ${res.status}`);
return res.json();
}
const API = {
getState: () => api("GET", "/api/state"),
getVu: () => api("GET", "/api/vu"),
updateBlock: (id, params) => api("PATCH", "/api/block-params", { id, ...params }),
toggleBlock: (id, enabled) => api("PATCH", "/api/blocks", { id, enabled }),
bypassToggle: () => api("POST", "/api/bypass/toggle"),
masterVolume: (v) => api("POST", "/api/volume", { volume: v / 100 }),
listModels: () => api("GET", "/api/models"),
loadModel: (path) => api("POST", "/api/models/load", { path }),
listPresets: () => api("GET", "/api/presets"),
loadPreset: (bank, program) => api("GET", "/api/presets", { bank, program }),
savePreset: (bank, program, name, tags) => api("PUT", "/api/presets", { bank, program, name, tags }),
searchModels: (q) => api("GET", `/api/tonehub/search?q=${encodeURIComponent(q)}`),
searchIrs: (q) => api("GET", `/api/tonehub/search/irs?q=${encodeURIComponent(q)}`),
installModel: (url, name) => api("POST", "/api/models/tonedownload/install", { download_url: url, name }),
installIr: (url, name) => api("POST", "/api/irs/tonedownload/install", { download_url: url, name }),
};
// ── 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 parameter definitions ─────────────────────────────────
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',
compressor: '#D09040', gate: T.textSec, eq: '#7080D0',
cabinet: '#D07090', ir: '#D07090',
};
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;
}
// ── CSS (Helix Stadium theme) ─────────────────────────────────
const css = `
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; user-select: none; }
body { background: ${T.bg}; color: ${T.textPrimary}; font-family: 'Inter', sans-serif; }
:root { --amber: ${T.amber}; --blue: ${T.blue}; --green: ${T.green}; }
.mono { font-family: 'JetBrains Mono', monospace; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: ${T.panel}; }
::-webkit-scrollbar-thumb { background: ${T.border}; border-radius: 2px; }
/* ── Knob ── */
.knob-wrap { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; }
.knob { border-radius: 50%; position: relative; touch-action: none; }
.knob-inner { border-radius: 50%; width: 100%; height: 100%; position: relative;
background: radial-gradient(circle at 35% 30%, #3A3A48, #1A1A22);
box-shadow: 0 2px 8px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.07); }
.knob-dot { position: absolute; width: 3px; border-radius: 2px; background: var(--amber);
left: 50%; transform-origin: bottom center; box-shadow: 0 0 6px var(--amber); }
.knob-label { font-size: 10px; color: ${T.textSec}; letter-spacing: .06em; text-transform: uppercase; text-align: center; }
.knob-value { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: ${T.amber}; }
/* ── Toggle ── */
.toggle { display: flex; background: ${T.surface}; border-radius: 6px; border: 1px solid ${T.border}; overflow: hidden; }
.toggle-btn { flex: 1; padding: 7px 12px; font-size: 12px; font-weight: 500; letter-spacing: .04em;
background: none; border: none; color: ${T.textSec}; cursor: pointer; transition: all .15s; }
.toggle-btn.active { background: ${T.amber}; color: #000; }
/* ── Buttons ── */
.btn { padding: 10px 18px; border-radius: 7px; font-size: 13px; font-weight: 600;
letter-spacing: .04em; border: none; cursor: pointer; transition: all .12s; }
.btn-primary { background: ${T.amber}; color: #000; }
.btn-primary:active { filter: brightness(.85); transform: scale(.97); }
.btn-ghost { background: ${T.surface}; color: ${T.textPrimary}; border: 1px solid ${T.border}; }
.btn-ghost:active { background: ${T.border}; }
.btn-danger { background: rgba(200,64,64,.2); color: ${T.red}; border: 1px solid rgba(200,64,64,.3); }
.btn-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center;
justify-content: center; background: ${T.surface}; border: 1px solid ${T.border}; cursor: pointer;
font-size: 16px; transition: all .12s; }
.btn-icon:active { transform: scale(.93); }
.btn-icon.active { background: rgba(232,160,48,.15); border-color: ${T.amber}; color: ${T.amber}; }
/* ── Badge ── */
.badge { display: inline-flex; align-items: center; justify-content: center;
padding: 2px 7px; border-radius: 4px; font-size: 10px; font-weight: 700;
letter-spacing: .06em; text-transform: uppercase; }
.badge-amber { background: rgba(232,160,48,.2); color: ${T.amber}; }
.badge-green { background: rgba(58,184,122,.2); color: ${T.green}; }
.badge-blue { background: rgba(58,123,168,.2); color: ${T.blue}; }
.badge-red { background: rgba(200,64,64,.2); color: ${T.red}; }
/* ── Screen header (for overlay screens) ── */
.screen-header { padding: 14px 16px 10px; display: flex; align-items: center;
justify-content: space-between; border-bottom: 1px solid ${T.border}; }
.screen-title { font-size: 13px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: ${T.textSec}; }
.screen-body { flex: 1; overflow-y: auto; padding: 14px 12px; display: flex;
flex-direction: column; gap: 14px; }
/* ── Param slider ── */
.param-panel { background: ${T.surface}; border-radius: 12px 12px 0 0; padding: 10px 14px 8px;
flex-shrink: 0; overflow-y: auto; border-top: 1px solid ${T.border}; }
.param-panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.param-panel-title { font-size: 11px; font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: ${T.textSec}; }
.param-row { display: flex; flex-direction: column; gap: 2px; padding: 2px 0; }
.param-header { display: flex; justify-content: space-between; align-items: center; }
.param-label { font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: .06em; color: ${T.textSec}; }
.param-val { font-family: 'JetBrains Mono',monospace; font-size: 12px; font-weight: 600; }
.param-track-wrap { position: relative; height: 38px; display: flex; align-items: center; cursor: pointer; touch-action: none; }
.param-track { width: 100%; height: 8px; background: ${T.border}; border-radius: 4px; position: relative; overflow: hidden; }
.param-fill { height: 100%; border-radius: 4px; position: absolute; top: 0; left: 0; transition: width .04s; }
.param-thumb { width: 26px; height: 26px; border-radius: 50%; position: absolute; top: 50%;
transform: translate(-50%,-50%); box-shadow: 0 1px 6px rgba(0,0,0,.6); pointer-events: none;
border: 3px solid ${T.bg}; }
.param-btn { width: 32px; height: 32px; border-radius: 50%; border: 1px solid ${T.border};
background: ${T.panel}; color: ${T.textPrimary}; font-size: 15px; font-weight: 700;
display: flex; align-items: center; justify-content: center; cursor: pointer; line-height: 1; }
.param-btn:active { background: ${T.border}; transform: scale(.92); }
.param-knobs { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; padding: 8px 0; }
/* ── Preset chip ── */
.preset-chip { padding: 5px 8px; border-radius: 6px; background: ${T.surface}; border: 1px solid ${T.border};
display: flex; flex-direction: column; align-items: center; cursor: pointer; transition: all .12s; flex: 1;
min-width: 0; }
.preset-chip.active { border-color: ${T.amber}; background: rgba(232,160,48,.08); }
.preset-chip .pc-name { font-size: 10px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.preset-chip .pc-num { font-family: 'JetBrains Mono', monospace; font-size: 8px; color: ${T.textDim}; margin-top: 1px; }
/* ── Preset row (captures screen) ── */
.preset-row { display: flex; align-items: center; gap: 12px; padding: 11px 14px;
border-radius: 8px; cursor: pointer; transition: background .12s; border: 1px solid transparent; }
.preset-row:active, .preset-row.active { background: rgba(232,160,48,.08); border-color: rgba(232,160,48,.25); }
.section-label { font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
color: ${T.textDim}; font-weight: 600; margin-bottom: 10px; }
/* ── Card ── */
.card { background: ${T.panel}; border: 1px solid ${T.border}; border-radius: 10px; padding: 16px; }
.card-sm { background: ${T.surface}; border: 1px solid ${T.border}; border-radius: 8px; padding: 12px; }
`;
// ── Knob ──────────────────────────────────────────────────────
function Knob({ label, value = 50, onChange, size = 52, color = T.amber, min = 0, max = 100 }) {
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 + 12;
const cx = svgSize / 2, cy = svgSize / 2, r = size / 2 + 3;
const lx = cx + r * Math.cos((-140 - 90) * (Math.PI / 180));
const ly = cy + r * Math.sin((-140 - 90) * (Math.PI / 180));
const ex = cx + r * Math.cos((angle - 90) * (Math.PI / 180));
const ey = cy + r * Math.sin((angle - 90) * (Math.PI / 180));
const large = norm * 280 > 180 ? 1 : 0;
return (
<div className="knob-wrap" style={{ width: size + 12 }}>
<div className="knob" style={{ width: size, height: size }} onMouseDown={handleStart} onTouchStart={handleStart}>
<svg style={{ position: "absolute", inset: -6, width: svgSize, height: svgSize, overflow: "visible" }}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={T.border} strokeWidth="2.5" strokeLinecap="round" opacity=".5" />
{norm > 0 && <path d={`M ${lx} ${ly} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`}
fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 3px ${color})` }} />}
</svg>
<div className="knob-inner" style={{ "--amber": color }}>
<div className="knob-dot" style={{
height: dotH, top: dotTop, bottom: "auto",
transform: `translateX(-50%) rotate(${angle}deg)`,
transformOrigin: `50% ${dotH}px`,
}} />
</div>
</div>
<div className="knob-value mono" style={{ color }}>{value}</div>
<div className="knob-label">{label}</div>
</div>
);
}
// ── Param Slider (big touch-friendly) ─────────────────────────
function ParamSlider({ 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;
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
useEffect(() => { 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]);
useEffect(() => {
const move = (e) => {
if (!dragRef.current) return;
e.preventDefault();
const v = valFromEvent(e);
lastValRef.current = v;
onChangeRef.current?.(v);
};
const up = () => {
if (dragRef.current) {
onChangeEndRef.current?.(lastValRef.current);
dragRef.current = false;
}
};
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;
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 className="param-row">
<div className="param-header">
<span className="param-label" style={{ color }}>{label}</span>
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
<button className="param-btn" onMouseDown={quick(-5)} onTouchStart={quick(-5)}></button>
<span className="param-val" style={{ color }}>{value}</span>
<button className="param-btn" onMouseDown={quick(5)} onTouchStart={quick(5)}>+</button>
</div>
</div>
<div className="param-track-wrap" ref={trackRef}
onMouseDown={handleStart} onTouchStart={handleStart}>
<div className="param-track">
<div className="param-fill" style={{ width: `${pct}%`, background: color }} />
<div className="param-thumb" style={{ left: `${pct}%`, background: color }} />
</div>
</div>
</div>
);
}
// ── Parameter Panel ──────────────────────────────────────────
function ParameterPanel({ block, params, onParamChange, onParamChangeEnd, viewMode, onToggleView }) {
const defs = getBlockParams(block.type);
const color = getBlockColor(block.type);
const blockName = block.name || (block.type || '').toUpperCase();
return (
<div className="param-panel" style={{ maxHeight: '50%', flexShrink: 0 }}>
<div className="param-panel-header">
<span className="param-panel-title" style={{ color: T.textSec }}>
<span style={{ color }}></span> {blockName}
</span>
<button className="btn-icon" style={{ width: 28, height: 28, fontSize: 12, flexShrink: 0 }}
onClick={onToggleView} title={viewMode === 'slider' ? 'Switch to knobs' : 'Switch to sliders'}>
{viewMode === 'slider' ? '◉' : '▦'}
</button>
</div>
{viewMode === 'slider' ? (
defs.map(p => (
<ParamSlider 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 className="param-knobs">
{defs.map(p => (
<Knob key={p.key}
label={p.label}
value={params[p.key] != null ? params[p.key] : p.def}
min={p.min} max={p.max}
size={60}
color={color}
onChange={v => {
onParamChange?.(p.key, v);
onParamChangeEnd?.(p.key, v);
}}
/>
))}
</div>
)}
</div>
);
}
// ── VU Meter ──────────────────────────────────────────────────
function VUMeter({ level = 0, height = 28, vertical = false }) {
const segs = 12;
const active = Math.round((level / 100) * segs);
const segColor = (i) => {
if (i >= 10) return T.red;
if (i >= 8) return "#E8C030";
return T.green;
};
if (vertical) return (
<div style={{ display: "flex", flexDirection: "column-reverse", gap: 1, height, alignItems: "center" }}>
{Array.from({ length: segs }).map((_, i) => (
<div key={i} style={{ width: 4, height: (height - segs) / segs,
borderRadius: 1, background: i < active ? segColor(i) : T.border,
boxShadow: i < active ? `0 0 4px ${segColor(i)}` : "none",
transition: "background .05s, box-shadow .05s" }} />
))}
</div>
);
return (
<div style={{ display: "flex", gap: 2, height: 6, alignItems: "center", width: height }}>
{Array.from({ length: segs }).map((_, i) => (
<div key={i} style={{ flex: 1, height: "100%", borderRadius: 1,
background: i < active ? segColor(i) : T.border,
transition: "background .05s" }} />
))}
</div>
);
}
// ── Scribble Info Bar ──────────────────────────────────────
function ScribbleBar({ state }) {
const vol = state.master_volume != null ? Math.round(state.master_volume * 100) : 0;
return (
<div style={{
display: "flex", gap: 4, padding: "4px 10px",
background: T.surface, borderBottom: `1px solid ${T.border}`,
flexShrink: 0,
}}>
{[
{ label: "Preset", value: state.current_preset?.name || "—" },
{ label: "Routing", value: (state.routing_mode || "MONO").toUpperCase() },
{ label: "Volume", value: `${vol}%` },
{ label: "CPU", value: state.cpu_percent != null ? `${state.cpu_percent}%` : "—" },
].map(s => (
<div key={s.label} style={{ flex: 1, textAlign: "center", padding: "3px 6px",
borderRadius: 4, background: T.panel, border: `1px solid ${T.border}` }}>
<div style={{ fontSize: 8, color: T.textDim, letterSpacing: ".06em", textTransform: "uppercase" }}>{s.label}</div>
<div className="mono" style={{ fontSize: 11, fontWeight: 600, color: T.amber, marginTop: 1 }}>{s.value}</div>
</div>
))}
</div>
);
}
// ── Captures Screen (Model/IR downloads) ──────────────────────
function CapturesScreen({ onClose }) {
const [tab, setTab] = useState("models");
const [query, setQuery] = useState("");
const [results, setResults] = useState(null);
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
API.listModels().then(d => setModels(d.models || [])).catch(()=>{});
}, []);
const doSearch = async () => {
if (!query.trim()) return;
setLoading(true);
try {
const data = tab === "models"
? await API.searchModels(query)
: await API.searchIrs(query);
setResults(data.results || []);
} catch { setResults([]); }
setLoading(false);
};
const install = async (item) => {
try {
if (tab === "models") {
await API.installModel(item.download_url, item.name);
} else {
await API.installIr(item.download_url, item.name);
}
API.listModels().then(d => setModels(d.models || [])).catch(()=>{});
} catch(e) { alert("Install failed: " + e.message); }
};
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: T.bg }}>
<div className="screen-header">
<div className="screen-title">Downloads</div>
<button className="btn-icon" style={{ width: 30, height: 30, fontSize: 14 }}
onClick={onClose}></button>
</div>
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.border}` }}>
<div className="toggle">
<button className={`toggle-btn ${tab === "models" ? "active" : ""}`} onClick={() => setTab("models")}>NAM Models</button>
<button className={`toggle-btn ${tab === "irs" ? "active" : ""}`} onClick={() => setTab("irs")}>IR Cabs</button>
</div>
</div>
<div style={{ padding: "6px 12px", display: "flex", gap: 6, borderBottom: `1px solid ${T.border}` }}>
<div style={{ flex: 1, display: "flex", background: T.surface, borderRadius: 7, border: `1px solid ${T.border}`,
padding: "7px 10px", gap: 8, alignItems: "center" }}>
<span style={{ color: T.textDim, fontSize: 12 }}>🔍</span>
<input placeholder={`Search ${tab === "models" ? "NAM models" : "IR cabs"}...`}
value={query} onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === "Enter" && doSearch()}
style={{ flex: 1, background: "none", border: "none", color: T.textPrimary, fontSize: 12, outline: "none" }} />
</div>
<button className="btn btn-primary" style={{ padding: "7px 12px", fontSize: 11 }} onClick={doSearch}>
{loading ? "..." : "Search"}
</button>
</div>
<div className="screen-body">
{results === null && models.length > 0 && (
<>
<div className="section-label">Installed Models</div>
{models.map((m, i) => (
<div key={i} className="preset-row" style={{ cursor: "default" }}>
<div style={{ width: 28, height: 28, borderRadius: 6, display: "flex",
alignItems: "center", justifyContent: "center", fontSize: 12,
background: m.loaded ? `${T.green}22` : T.surface,
color: m.loaded ? T.green : T.textDim }}>🎛</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 500 }}>{m.name}</div>
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>
{m.architecture} · {m.size_mb}MB {m.loaded ? "· CURRENT" : ""}
</div>
</div>
</div>
))}
</>
)}
{results !== null && results.length === 0 && (
<div className="card-sm" style={{ textAlign: "center", color: T.textDim, padding: 24 }}>No results found</div>
)}
{results !== null && results.map((r, i) => (
<div key={i} className="preset-row" style={{ cursor: "default" }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 500 }}>{r.name}</div>
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>
{r.author} · {r.size_display}
</div>
</div>
<button className="btn btn-ghost" style={{ padding: "5px 10px", fontSize: 11 }}
onClick={() => install(r)}>Get</button>
</div>
))}
</div>
</div>
);
}
// ── Presets Screen ─────────────────────────────────────────
function PresetsScreen({ state, onClose, onLoadPreset }) {
const [activeIdx, setActiveIdx] = useState(0);
const samplePresets = [
{ num: 1, name: "Plexi Crunch", tags: ["Crunch", "Vintage"], color: T.amber },
{ num: 2, name: "Modern High Gain", tags: ["High Gain", "Lead"], color: T.red },
{ num: 3, name: "Clean Chimey", tags: ["Clean", "Sparkle"], color: T.blue },
{ num: 4, name: "Southern Blues", tags: ["Crunch", "Blues"], color: T.green },
{ num: 5, name: "Metal Djent", tags: ["High Gain", "Djent"], color: T.red },
{ num: 6, name: "Studio Direct", tags: ["Clean", "Studio"], color: T.blue },
{ num: 7, name: "Acoustic Sim", tags: ["Acoustic", "Clean"], color: "#A060E0" },
{ num: 8, name: "Nashville Twang", tags: ["Country", "Clean"], color: T.green },
];
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: T.bg }}>
<div className="screen-header">
<div className="screen-title">Presets</div>
<button className="btn-icon" style={{ width: 30, height: 30, fontSize: 14 }}
onClick={onClose}></button>
</div>
<div className="screen-body">
<div className="toggle" style={{ marginBottom: 4 }}>
{["All", "Live", "Studio", "Practice"].map(t => (
<button key={t} className={`toggle-btn ${t === "All" ? "active" : ""}`}>{t}</button>
))}
</div>
{samplePresets.map((p, i) => (
<div key={i} className={`preset-row ${activeIdx === i ? "active" : ""}`}
onClick={() => { setActiveIdx(i); onLoadPreset?.(p.num); }}>
<div className="mono" style={{ width: 28, height: 28, borderRadius: 6, display: "flex",
alignItems: "center", justifyContent: "center", fontSize: 12, fontWeight: 700,
background: activeIdx === i ? `${p.color}22` : T.surface,
color: activeIdx === i ? p.color : T.textDim,
border: `1px solid ${activeIdx === i ? p.color + "60" : T.border}` }}>{p.num}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 3 }}>{p.name}</div>
<div style={{ display: "flex", gap: 4 }}>
{p.tags.map(t => (
<span key={t} className="badge" style={{ background: `${p.color}18`, color: p.color, fontSize: 9 }}>{t}</span>
))}
</div>
</div>
{activeIdx === i && <span style={{ color: T.amber, fontSize: 18 }}></span>}
</div>
))}
</div>
</div>
);
}
// ── App Shell — Helix Stadium Layout ──────────────────────────
const MOCK_BLOCKS = [
{ id: "gate", type: "gate", name: "Noise Gate", bypassed: false, params: { threshold: 40, release: 25, depth: 70, mix: 100 }},
{ id: "drive", type: "overdrive", name: "OD-808", bypassed: false, params: { drive: 65, tone: 55, level: 70 }},
{ id: "dist", type: "distortion", name: "RAT", bypassed: true, params: { drive: 75, filter: 50, level: 65 }},
{ id: "mod", type: "phaser", name: "Phase 90", bypassed: false, params: { rate: 35, depth: 60, feedback: 40, mix: 50 }},
{ id: "delay", type: "delay", name: "Digital Delay", bypassed: false, params: { time: 45, feedback: 25, mix: 40 }},
{ id: "reverb", type: "reverb", name: "Hall Reverb", bypassed: false, params: { decay: 60, mix: 35, tone: 50 }},
];
const MOCK_PRESETS = [
{ num: 1, name: "Plexi Crunch" },
{ num: 2, name: "Modern Hi-Gain" },
{ num: 3, name: "Clean Chime" },
{ num: 4, name: "Blues Drive" },
];
export default function App() {
// ── State ──
const [view, setView] = useState("main"); // "main" | "captures" | "presets"
const [state, setState] = useState({ connected: false, master_volume: 0.72, routing_mode: "mono", cpu_percent: 0 });
const [blocks, setBlocks] = useState(MOCK_BLOCKS);
const [selectedBlockId, setSelectedBlockId] = useState("gate");
const [blockParams, setBlockParams] = useState({});
const [paramView, setParamView] = useState("slider");
const [vuLevel, setVuLevel] = useState(0);
const [currentPreset, setCurrentPreset] = useState(1);
const [currentBank, setCurrentBank] = useState(1);
// Poll pedal state
useEffect(() => {
const poll = async () => {
try {
const s = await API.getState();
setState(s);
if (s.cpu_percent != null) setState(prev => ({ ...prev, cpu_percent: s.cpu_percent }));
if (s.input_level != null) setVuLevel(s.input_level);
} catch {}
};
poll();
const id = setInterval(poll, 3000);
return () => clearInterval(id);
}, []);
// Mock VU animation when no real data
useEffect(() => {
const id = setInterval(() => {
setVuLevel(prev => Math.max(0, Math.min(100, prev + (Math.random() - 0.45) * 8)));
}, 150);
return () => clearInterval(id);
}, []);
// Selected block
const selectedBlock = useMemo(() => blocks.find(b => b.id === selectedBlockId), [blocks, selectedBlockId]);
// Initialize params when block is selected
useEffect(() => {
if (selectedBlock && blockParams[selectedBlockId] == null) {
const defs = getBlockParams(selectedBlock.type);
const init = {};
defs.forEach(p => {
init[p.key] = (selectedBlock.params?.[p.key] != null) ? selectedBlock.params[p.key] : p.def;
});
setBlockParams(prev => ({ ...prev, [selectedBlockId]: init }));
}
}, [selectedBlockId, selectedBlock, blockParams]);
const handleSelectBlock = useCallback((id) => {
setSelectedBlockId(prev => prev === id ? null : id);
}, []);
const handleToggleBlock = useCallback((id) => {
setBlocks(prev => prev.map(b => b.id === id ? { ...b, bypassed: !b.bypassed } : b));
API.toggleBlock(id, false).catch(() => {});
}, []);
const handleParamChange = useCallback((id, key, value) => {
setBlockParams(prev => ({ ...prev, [id]: { ...(prev[id] || {}), [key]: value } }));
}, []);
const handleParamChangeEnd = useCallback((id, key, value) => {
API.updateBlock(id, { [key]: value }).catch(() => {});
}, []);
const handleReorder = useCallback((fromIdx, toIdx) => {
setBlocks(prev => {
const arr = [...prev];
const [moved] = arr.splice(fromIdx, 1);
arr.splice(toIdx, 0, moved);
return arr;
});
}, []);
const handleAddBlock = useCallback(() => {
const id = "block_" + Date.now();
setBlocks(prev => [...prev, { id, type: "overdrive", name: "New Drive", bypassed: false, params: { drive: 30, tone: 50, level: 70 } }]);
}, []);
const handleRemoveBlock = useCallback((id) => {
setBlocks(prev => prev.filter(b => b.id !== id));
if (selectedBlockId === id) setSelectedBlockId(null);
}, [selectedBlockId]);
const handleBankUp = useCallback(() => {
setCurrentBank(prev => Math.min(prev + 1, 8));
}, []);
const handleBankDown = useCallback(() => {
setCurrentBank(prev => Math.max(prev - 1, 1));
}, []);
// Convert bank number to letter (1=A, 2=B, etc.)
const bankLetter = String.fromCharCode(64 + currentBank);
// ── Render ──
return (
<>
<style>{css}</style>
<div style={{
width: "100%", maxWidth: 480, margin: "0 auto", height: "100vh",
display: "flex", flexDirection: "column", background: T.bg, overflow: "hidden",
fontFamily: "'Inter', sans-serif",
}}>
{view === "main" && (
<>
{/* ── Status Bar ── */}
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "6px 12px 4px", background: T.panel, borderBottom: `1px solid ${T.border}`,
flexShrink: 0,
}}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div style={{ width: 6, height: 6, borderRadius: "50%",
background: state.connected ? T.green : T.red,
boxShadow: `0 0 6px ${state.connected ? T.green : T.red}` }} />
<span className="mono" style={{ fontSize: 9, color: T.textSec }}>
{state.connected ? "CONNECTED" : "OFFLINE"}
</span>
<span className="mono" style={{ fontSize: 9, color: T.textDim }}>
{state.current_preset?.name || "Plexi Crunch"}
</span>
</div>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
<VUMeter level={vuLevel} height={28} />
<button className="btn-icon" style={{ width: 26, height: 26, fontSize: 11 }}
onClick={() => setView("captures")} title="Downloads">📁</button>
<button className="btn-icon" style={{ width: 26, height: 26, fontSize: 11 }}
onClick={() => setView("presets")} title="Presets"></button>
</div>
</div>
{/* ── Scribble Info Bar ── */}
<ScribbleBar state={state} />
{/* ── Preset Strip ── */}
<div style={{
display: "flex", gap: 4, padding: "5px 10px",
flexShrink: 0, borderBottom: `1px solid ${T.border}`,
}}>
{MOCK_PRESETS.map(p => (
<div key={p.num}
className={`preset-chip ${p.num === currentPreset ? "active" : ""}`}
onClick={() => setCurrentPreset(p.num)}>
<div className="pc-name">{p.name}</div>
<div className="pc-num">0{p.num}</div>
</div>
))}
</div>
{/* ── Block Chain + Params ── */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ flex: selectedBlock ? "0 0 auto" : 1, overflow: "hidden" }}>
<BlockChain
blocks={blocks}
selectedBlockId={selectedBlockId}
onSelectBlock={handleSelectBlock}
onToggleBlock={handleToggleBlock}
onReorder={handleReorder}
onAddBlock={handleAddBlock}
onRemoveBlock={handleRemoveBlock}
/>
</div>
{/* ── Parameter Panel ── */}
{selectedBlock && blockParams[selectedBlockId] && (
<ParameterPanel
block={selectedBlock}
params={blockParams[selectedBlockId] || {}}
viewMode={paramView}
onToggleView={() => setParamView(p => p === 'slider' ? 'knob' : 'slider')}
onParamChange={(key, value) => handleParamChange(selectedBlockId, key, value)}
onParamChangeEnd={(key, value) => handleParamChangeEnd(selectedBlockId, key, value)}
/>
)}
{!selectedBlock && (
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
background: T.bg, color: T.textDim, fontSize: 12,
}}>
Tap a block to edit its parameters
</div>
)}
</div>
{/* ── Footswitch Bar ── */}
<FootswitchBar
blocks={blocks}
selectedBlockId={selectedBlockId}
currentBank="A"
onSelectBlock={handleSelectBlock}
onToggleBlock={handleToggleBlock}
onBankUp={() => {}}
onBankDown={() => {}}
onGlobalBypass={() => setState(prev => ({ ...prev, bypass: !prev.bypass }))}
globalBypass={state.bypass}
/>
</>
)}
{/* ── Overlay screens ── */}
{view === "captures" && (
<CapturesScreen onClose={() => setView("main")} />
)}
{view === "presets" && (
<PresetsScreen
state={state}
onClose={() => setView("main")}
onLoadPreset={(num) => setCurrentPreset(num)}
/>
)}
</div>
</>
);
}
+446
View File
@@ -0,0 +1,446 @@
import { useState, useRef, useCallback } from "react";
// ── Design tokens (mirrored from App.jsx) ────────────────────
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",
};
// ── Type-to-color mapping for accent strips ─────────────────
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,
};
function getBlockColor(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;
}
function getBlockIcon(type) {
const key = (type || "").toLowerCase();
if (odMatch(key)) return "\uD83D\uDD25";
if (key.includes("dist") || key.includes("gain")) return "\uD83D\uDCA5";
if (key.includes("fuzz")) return "\u26A1";
if (modMatch(key)) return "\uD83C\uDF00";
if (key.includes("delay") || key.includes("echo")) return "\u23F3";
if (key.includes("reverb")) return "\uD83C\uDF0A";
if (key.includes("pitch") || key.includes("harm") || key.includes("shift")) return "\uD83C\uDFB5";
if (key.includes("comp") || key.includes("limit")) return "\uD83D\uDCCA";
if (key.includes("gate") || key.includes("noise")) return "\uD83D\uDD07";
if (key.includes("filter") || key.includes("wah")) return "\uD83D\uDCC8";
if (key.includes("eq")) return "\u2696\uFE0F";
if (key.includes("ir") || key.includes("cab")) return "\uD83D\uDD0A";
if (key.includes("nam") || key.includes("capture")) return "\uD83C\uDF9B";
if (key.includes("volume") || key.includes("level") || key.includes("boost")) return "\uD83D\uDD08";
if (key.includes("tuner")) return "\uD83C\uDFB9";
return "\u25A3";
function odMatch(k) { return k.includes("od") || k.includes("overdrive") || k.includes("drive") || k.includes("boost") && !k.includes("volume"); }
function modMatch(k) { return k.includes("mod") || k.includes("chorus") || k.includes("flange") || k.includes("phaser") || k.includes("trem") || k.includes("vibr") || k.includes("rotary"); }
}
// ── Chain Arrow SVG ─────────────────────────────────────────
function ChainArrow() {
return (
<svg width="18" height="24" viewBox="0 0 18 24" fill="none" style={{ flexShrink: 0 }}>
<line x1="0" y1="12" x2="12" y2="12" stroke={T.border} strokeWidth="2" strokeLinecap="round" />
<polyline points="6,7 14,12 6,17" fill="none" stroke={T.border} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
// ── IN / OUT Terminator ─────────────────────────────────────
function Terminator({ label, isLeft }) {
return (
<div style={{
display: "flex",
alignItems: "center",
gap: 6,
flexShrink: 0,
flexDirection: isLeft ? "row" : "row-reverse",
}}>
{!isLeft && <ChainArrow />}
<div style={{
padding: "7px 12px",
borderRadius: 6,
fontSize: 10,
fontWeight: 700,
letterSpacing: ".12em",
background: T.blueDim,
color: T.blue,
border: `1px solid ${T.blue}60`,
textTransform: "uppercase",
whiteSpace: "nowrap",
}}>
{label}
</div>
{isLeft && <ChainArrow />}
</div>
);
}
// ── Block Tile ──────────────────────────────────────────────
function BlockTile({ block, selected, index, onSelect, onToggle }) {
const color = getBlockColor(block.type);
const enabled = !block.bypassed;
return (
<div
draggable
onClick={() => onSelect(block.id)}
style={{
minWidth: 120,
width: 120,
height: 76,
borderRadius: 8,
background: T.panel,
border: `2px solid ${selected ? T.amber : T.border}`,
opacity: enabled ? 1 : 0.35,
cursor: "pointer",
display: "flex",
flexDirection: "column",
position: "relative",
overflow: "hidden",
transition: "all .15s ease",
flexShrink: 0,
boxShadow: selected
? `0 0 16px ${T.amber}44, inset 0 0 0 1px ${T.amber}22`
: "none",
transform: selected ? "scale(1.05)" : "scale(1)",
}}
>
{/* Color accent strip at top (like Helix footswitch LCD bezel) */}
<div style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 3,
background: color,
boxShadow: enabled ? `0 0 6px ${color}` : "none",
}} />
{/* Drag handle */}
<div style={{
position: "absolute",
top: 5,
left: 5,
fontSize: 9,
color: T.textDim,
letterSpacing: 1.5,
lineHeight: 1,
cursor: "grab",
opacity: 0.5,
}}>
</div>
{/* BYPASS LED - tap to toggle */}
<div
onClick={(e) => { e.stopPropagation(); onToggle(block.id); }}
style={{
position: "absolute",
top: 6,
right: 6,
width: 10,
height: 10,
borderRadius: "50%",
background: enabled ? T.green : T.textDim,
boxShadow: enabled ? `0 0 8px ${T.green}` : "none",
transition: "all .15s",
cursor: "pointer",
zIndex: 2,
border: `1px solid ${enabled ? `${T.green}88` : T.border}`,
}}
title={enabled ? "Active — tap to bypass" : "Bypassed — tap to enable"}
/>
{/* Scribble strip — LCD-like display area */}
<div style={{
flex: 1,
padding: "14px 10px 6px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}>
{/* Block name */}
<div style={{
fontSize: 12,
fontWeight: 600,
color: T.textPrimary,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
lineHeight: 1.3,
paddingRight: 16,
}}>
{block.name || block.type || "FX"}
</div>
{/* Type badge row */}
<div style={{
display: "flex",
alignItems: "center",
gap: 5,
marginTop: 3,
}}>
<span style={{ fontSize: 12, lineHeight: 1 }}>{getBlockIcon(block.type)}</span>
<span style={{
fontSize: 9,
fontWeight: 700,
color: color,
letterSpacing: ".08em",
textTransform: "uppercase",
background: `${color}18`,
padding: "1px 5px",
borderRadius: 3,
}}>
{(block.type || "fx").replace(/_/g, " ").toUpperCase()}
</span>
</div>
</div>
</div>
);
}
// ── Simple parameter row (placeholder for child task) ────────
function ParamRow({ label, value, color }) {
const numVal = typeof value === "number" ? value : 50;
return (
<div style={{
display: "flex",
alignItems: "center",
gap: 10,
}}>
<div style={{
fontSize: 11,
color: T.textSec,
minWidth: 72,
fontWeight: 500,
textTransform: "capitalize",
letterSpacing: ".02em",
}}>
{label}
</div>
<div style={{
flex: 1,
height: 8,
borderRadius: 4,
background: T.border,
position: "relative",
overflow: "hidden",
}}>
<div style={{
height: "100%",
width: `${numVal}%`,
borderRadius: 4,
background: color,
transition: "width .1s ease",
boxShadow: `0 0 4px ${color}44`,
}} />
</div>
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 11,
color: T.textPrimary,
minWidth: 34,
textAlign: "right",
}}>
{numVal}
</div>
</div>
);
}
// ── Block Chain (exported) ──────────────────────────────────
export default function BlockChain({ blocks, selectedBlockId, onSelectBlock, onToggleBlock, onReorder, onAddBlock, onRemoveBlock }) {
const scrollRef = useRef(null);
const [dragIdx, setDragIdx] = useState(null);
const [scrollPos, setScrollPos] = useState(0);
// ── HTML5 drag-to-reorder ──
const handleDragStart = useCallback((e, idx) => {
setDragIdx(idx);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(idx));
}, []);
const handleDragOver = useCallback((e, _idx) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}, []);
const handleDrop = useCallback((e, dropIdx) => {
e.preventDefault();
if (dragIdx !== null && dragIdx !== dropIdx && onReorder) {
onReorder(dragIdx, dropIdx);
}
setDragIdx(null);
}, [dragIdx, onReorder]);
const handleDragEnd = useCallback(() => {
setDragIdx(null);
}, []);
// ── Auto-scroll selected block into view ──
const scrollToSelected = useCallback(() => {
if (!scrollRef.current || !selectedBlockId) return;
const idx = blocks.findIndex(b => b.id === selectedBlockId);
if (idx < 0) return;
const child = scrollRef.current.children[idx + 1]; // +1 for IN terminator
if (child) child.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
}, [selectedBlockId, blocks]);
// Scroll on selection change
const prevSelected = useRef(selectedBlockId);
if (selectedBlockId !== prevSelected.current) {
prevSelected.current = selectedBlockId;
setTimeout(scrollToSelected, 50);
}
const selectedBlock = blocks.find(b => b.id === selectedBlockId);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* ── Horizontal scrollable chain ── */}
<div
ref={scrollRef}
onScroll={(e) => setScrollPos(e.target.scrollLeft)}
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "10px 12px",
overflowX: "auto",
overflowY: "hidden",
minHeight: 96,
background: T.bg,
borderBottom: `1px solid ${T.border}`,
scrollBehavior: "smooth",
WebkitOverflowScrolling: "touch",
}}
>
{/* IN */}
<Terminator label="IN" isLeft />
{/* Blocks */}
{blocks.map((block, idx) => (
<div key={block.id} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
<BlockTile
block={block}
selected={block.id === selectedBlockId}
index={idx}
onSelect={onSelectBlock}
onToggle={onToggleBlock}
/>
<ChainArrow />
</div>
))}
{/* Empty state */}
{blocks.length === 0 && (
<div style={{
flexShrink: 0,
padding: "22px 20px",
color: T.textDim,
fontSize: 12,
textAlign: "center",
border: `1px dashed ${T.border}`,
borderRadius: 8,
minWidth: 140,
}}>
No blocks in chain
</div>
)}
{/* OUT */}
<Terminator label="OUT" isLeft={false} />
</div>
{/* ── Add/Remove bar ── */}
<div style={{
display: "flex",
gap: 8,
padding: "8px 12px",
borderBottom: `1px solid ${T.border}`,
background: T.panel,
}}>
<button
onClick={onAddBlock}
style={{
flex: 1,
padding: "10px",
borderRadius: 7,
background: T.surface,
border: `1px dashed ${T.border}`,
color: T.textSec,
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
letterSpacing: ".04em",
transition: "all .12s",
}}
>
+ Add Block
</button>
{selectedBlockId && (
<button
onClick={() => onRemoveBlock(selectedBlockId)}
style={{
padding: "10px 14px",
borderRadius: 7,
background: "rgba(200,64,64,.15)",
border: `1px solid rgba(200,64,64,.3)`,
color: T.red,
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
transition: "all .12s",
}}
>
Remove
</button>
)}
</div>
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
import { useRef, useEffect } 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 TYPE_COLORS = {
overdrive: T.amber, drive: T.amber, distortion: T.red, fuzz: "#D060E0",
chorus: T.blue, flanger: T.blue, phaser: T.blue, tremolo: T.blue,
delay: "#60A0E0", echo: "#60A0E0", reverb: "#60C0D0",
compressor: T.green, gate: T.green, eq: "#80A0B0",
ir: T.green, cab: T.green, nam: "#E0A040",
};
function getBlockColor(type) {
return TYPE_COLORS[(type || "").toLowerCase()] || T.amber;
}
// ── Single Footswitch with Scribble Strip ──
function Footswitch({ id, label, mainLabel, type, sub, active, enabled, onClick, onDoubleClick, isBank, bankDir }) {
const color = getBlockColor(type);
const tapRef = useRef(null);
useEffect(() => {
const el = tapRef.current;
if (!el || !onDoubleClick) return;
let lastTap = 0;
const handler = () => {
const now = Date.now();
if (now - lastTap < 300) {
onDoubleClick(id);
lastTap = 0;
} else {
lastTap = now;
}
};
el.addEventListener("click", handler);
return () => el.removeEventListener("click", handler);
}, [id, onDoubleClick]);
const isBypassSwitch = type === "bypass";
const isBankSwitch = type === "bank";
const isActive = active || (isBypassSwitch && !enabled);
return (
<div
ref={tapRef}
onClick={() => onClick?.(id)}
style={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
padding: "6px 3px 8px",
borderRadius: 8,
border: `1px solid ${isActive ? T.amber : isBankSwitch ? T.blueDim : T.border}`,
background: isActive && !isBypassSwitch
? `linear-gradient(180deg, ${T.amber + "15"} 0%, ${T.panel} 100%)`
: isBypassSwitch && !enabled
? `linear-gradient(180deg, ${T.red + "15"} 0%, ${T.panel} 100%)`
: T.surface,
cursor: "pointer",
minHeight: 58,
position: "relative",
transition: "all .1s",
WebkitTapHighlightColor: "transparent",
boxShadow: isActive && !isBypassSwitch
? `0 0 8px ${T.amber}33, inset 0 0 0 1px ${T.amber}22`
: isBypassSwitch && !enabled
? `0 0 8px ${T.red}33`
: "none",
}}
>
{/* ── Scribble strip — LCD-like display ── */}
<div style={{
width: "100%",
background: "#0A0A10",
border: `1px solid ${isActive ? T.amber + "66" : T.border}`,
borderRadius: 3,
padding: "2px 2px",
textAlign: "center",
minHeight: 18,
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: isActive ? `inset 0 0 4px ${T.amber}22` : "none",
}}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: 7,
fontWeight: 700,
letterSpacing: ".02em",
color: isActive ? T.amber : (isBypassSwitch && !enabled ? T.red : T.textPrimary),
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
lineHeight: 1.1,
}}>
{label}
</span>
</div>
{/* ── Main label ── */}
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: isBankSwitch ? 10 : 9,
fontWeight: 700,
color: enabled !== false ? T.textPrimary : T.textDim,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
lineHeight: 1.2,
marginTop: 1,
}}>
{mainLabel || label || type || id || "FX"}
</div>
{/* ── Sub label (bypassed state / param value) ── */}
{sub && (
<div style={{
fontSize: 7,
color: T.textDim,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "100%",
lineHeight: 1,
}}>
{sub}
</div>
)}
{/* ── Status LED ── */}
{!isBankSwitch && (
<div style={{
position: "absolute",
top: 3,
right: 3,
width: 4,
height: 4,
borderRadius: "50%",
background: enabled !== false ? T.green : T.textDim,
boxShadow: enabled !== false ? `0 0 5px ${T.green}` : "none",
transition: "all .15s",
}} />
)}
{/* ── Bank direction indicator ── */}
{isBankSwitch && (
<div style={{
position: "absolute",
top: 3,
right: 3,
fontSize: 6,
padding: "1px 3px",
borderRadius: 2,
background: `${T.blueDim}88`,
color: T.blue,
fontWeight: 700,
lineHeight: 1,
}}>
{bankDir === "up" ? "▲" : "▼"}
</div>
)}
</div>
);
}
// ── Footswitch Bar ──
export default function FootswitchBar({
blocks,
selectedBlockId,
currentBank = "A",
onSelectBlock,
onToggleBlock,
onBankUp,
onBankDown,
onGlobalBypass,
globalBypass,
}) {
// Build items: [bank_up, up_to_4_blocks, BYPASS, bank_down]
// Always reserve slots for BYPASS and bank controls by capping block count
const MAX_BLOCK_SWITCHES = 4;
const blockSwitches = (blocks || []).slice(0, MAX_BLOCK_SWITCHES).map(b => ({
id: b.id,
label: (b.name || b.type || "FX").toUpperCase(),
mainLabel: b.name || b.type || "FX",
type: b.type,
sub: b.bypassed ? "BYPASSED" : "",
enabled: !b.bypassed,
}));
const items = [
{
id: "bank_up",
type: "bank",
label: "BANK ▲",
mainLabel: `${currentBank} · 01`,
bankDir: "up",
enabled: true,
},
...blockSwitches,
{
id: "bypass",
type: "bypass",
label: globalBypass ? "MUTED" : "BYPASS",
mainLabel: "GLOBAL",
sub: globalBypass ? "" : "tap to mute",
enabled: !globalBypass,
},
{
id: "bank_down",
type: "bank",
label: "BANK ▼",
mainLabel: `${currentBank} · 01`,
bankDir: "down",
enabled: true,
},
];
return (
<div style={{
display: "flex",
gap: 4,
padding: "6px 8px 10px",
background: T.bg,
borderTop: `1px solid ${T.border}`,
flexShrink: 0,
}}>
{items.map(item => (
<Footswitch
key={item.id}
id={item.id}
label={item.label}
mainLabel={item.mainLabel}
type={item.type}
sub={item.sub}
active={item.id === selectedBlockId || (item.type === "bypass" && globalBypass)}
enabled={item.enabled}
isBank={item.type === "bank"}
bankDir={item.bankDir}
onClick={(id) => {
if (id === "bank_up") onBankUp?.();
else if (id === "bank_down") onBankDown?.();
else if (id === "bypass") onGlobalBypass?.();
else onSelectBlock?.(id);
}}
onDoubleClick={(id) => {
if (id !== "bank_up" && id !== "bank_down" && id !== "bypass") {
onToggleBlock?.(id);
}
}}
/>
))}
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Styles are embedded in App.jsx via CSS-in-JS template literal.
* This file intentionally left empty — remove default Vite styles.
*/
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)