PEDAL-UI: Helix Stadium-style redesign — block chain, big touch sliders, footswitch bar with scribble strips
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user