feat: pipedal SVG icons in block tiles + nav bar
- Replace emoji getBlockIcon() with <BlockIcon> component rendering SVG icons - 38 fx_*.svg icons from pipedal mapped to block types (distortion, delay, reverb, mod, etc.) - Split/merge tiles now use fx_split_a.svg / fx_mixer.svg - Status bar: ic_bank.svg, ic_presets.svg, fx_analyzer.svg, ic_drawer_2.svg - Extracted reusable CSS design system to index.css (badge, snap-grid, slide-panel, etc.) - SnapshotPanel and component reference extracted to docs/ - 270KB JS build, SVG icons served from /ui/img/
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
function SnapshotPanel({ onClose, state, connected }) {
|
||||
const [snapshots, setSnapshots] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingSlot, setEditingSlot] = useState(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [saveFlash, setSaveFlash] = useState(null);
|
||||
const [discardEdits, setDiscardEdits] = useState(false);
|
||||
const holdTimer = useRef(null);
|
||||
const currentSlot = state?.current_snapshot ?? 0;
|
||||
|
||||
const loadSnapshots = useCallback(async () => {
|
||||
if (!connected) { setLoading(false); return; }
|
||||
try {
|
||||
const data = await apiGet('/api/snapshots');
|
||||
setSnapshots(data.snapshots || {});
|
||||
} catch (e) { /* ignore */ }
|
||||
setLoading(false);
|
||||
}, [connected]);
|
||||
|
||||
useEffect(() => { loadSnapshots(); }, [loadSnapshots]);
|
||||
|
||||
// Handle press & hold vs tap
|
||||
const handlePointerDown = (slot) => {
|
||||
holdTimer.current = setTimeout(() => {
|
||||
// Long press → save
|
||||
holdTimer.current = null;
|
||||
handleSave(slot);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const handlePointerUp = (slot) => {
|
||||
if (holdTimer.current) {
|
||||
clearTimeout(holdTimer.current);
|
||||
holdTimer.current = null;
|
||||
// Short tap → recall (or save if empty)
|
||||
if (snapshots[String(slot)]) {
|
||||
handleRecall(slot);
|
||||
} else {
|
||||
handleSave(slot);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
if (holdTimer.current) {
|
||||
clearTimeout(holdTimer.current);
|
||||
holdTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (slot) => {
|
||||
try {
|
||||
await apiPost(`/api/snapshots/${slot}/save`);
|
||||
setSaveFlash(slot);
|
||||
setTimeout(() => setSaveFlash(null), 800);
|
||||
await loadSnapshots();
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleRecall = async (slot) => {
|
||||
try {
|
||||
await apiPost(`/api/snapshots/${slot}/recall`);
|
||||
await loadSnapshots();
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleRename = async (slot) => {
|
||||
if (!editName.trim()) return;
|
||||
try {
|
||||
await apiPut(`/api/snapshots/${slot}`, { name: editName.trim() });
|
||||
setEditingSlot(null);
|
||||
await loadSnapshots();
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleDelete = async (slot) => {
|
||||
try {
|
||||
await apiDelete(`/api/snapshots/${slot}`);
|
||||
await loadSnapshots();
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<SlidePanel onClose={onClose}>
|
||||
<div className="screen-header" style={{ justifyContent: "space-between" }}>
|
||||
<div className="screen-title">Snapshots</div>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 10, color: T.textDim, cursor: "pointer" }}>
|
||||
<div className={`switch ${discardEdits ? 'on' : ''}`} style={{ width: 28, height: 16 }}
|
||||
onClick={() => setDiscardEdits(d => !d)}>
|
||||
<div style={{ position: "absolute", top: 1, left: discardEdits ? 11 : 1, width: 13, height: 13,
|
||||
borderRadius: "50%", background: "#fff", transition: "left .15s" }} />
|
||||
</div>
|
||||
Discard Edits
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="screen-body" style={{ gap: 8 }}>
|
||||
<div style={{ fontSize: 11, color: T.textDim, marginBottom: 4 }}>
|
||||
Tap to recall · Hold to save
|
||||
{discardEdits && <span style={{ color: T.amber, marginLeft: 8 }}>Edits will be discarded on recall</span>}
|
||||
</div>
|
||||
<div className="snap-grid">
|
||||
{[1,2,3,4,5,6,7,8].map(slot => {
|
||||
const snap = snapshots[String(slot)];
|
||||
const isActive = currentSlot === slot;
|
||||
const isFilled = !!snap;
|
||||
const isEditing = editingSlot === slot;
|
||||
const isFlashing = saveFlash === slot;
|
||||
return (
|
||||
<div key={slot} className={`snap-slot ${isFilled ? "filled" : ""} ${isActive ? "active" : ""}`}
|
||||
onMouseDown={() => handlePointerDown(slot)}
|
||||
onTouchStart={() => handlePointerDown(slot)}
|
||||
onMouseUp={() => handlePointerUp(slot)}
|
||||
onTouchEnd={() => handlePointerUp(slot)}
|
||||
onMouseLeave={handlePointerLeave}
|
||||
onTouchCancel={handlePointerLeave}
|
||||
style={isFlashing ? { background: 'rgba(58,184,122,.15)' } : {}}>
|
||||
<div className="snap-num">{slot}</div>
|
||||
{isEditing ? (
|
||||
<input className="snap-name-input" value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onBlur={() => handleRename(slot)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRename(slot); if (e.key === 'Escape') setEditingSlot(null); }}
|
||||
autoFocus onClick={e => e.stopPropagation()} />
|
||||
) : snap ? (
|
||||
<div className="snap-name"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setEditingSlot(slot);
|
||||
setEditName(snap.name);
|
||||
}}>{snap.name}</div>
|
||||
) : (
|
||||
<div className="snap-empty">Empty</div>
|
||||
)}
|
||||
{isFlashing && <div className="snap-save-indicator" />}
|
||||
{isActive && <div style={{ position: "absolute", bottom: -2, left: "50%", transform: "translateX(-50%)",
|
||||
width: 20, height: 2, borderRadius: 1, background: T.amber }} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Delete button for active snapshot */}
|
||||
{currentSlot > 0 && snapshots[String(currentSlot)] && (
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 8 }}>
|
||||
<button className="btn btn-danger btn-sm" style={{ flex: 1 }}
|
||||
onClick={() => handleDelete(currentSlot)}>
|
||||
Delete Snapshot {currentSlot}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SlidePanel>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user