Files
pi-multifx-pedal-ui/docs/pipedal-components-reference.txt
T
shawn 62c373b989 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/
2026-06-12 19:29:50 -04:00

594 lines
24 KiB
Plaintext

=== FXScreen (block library) ===
function FXScreen({ state, connected }) {
const [fxList, setFxList] = useState(null);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [paramSchemas, setParamSchemas] = useState({});
// Load block param schemas
const loadParams = useCallback(async (fxType) => {
if (paramSchemas[fxType]) return paramSchemas[fxType];
try {
const data = await apiGet(`/api/block-params/${fxType}`);
setParamSchemas(prev => ({ ...prev, [fxType]: data.params || [] }));
return data.params || [];
} catch (e) { return []; }
}, [paramSchemas]);
useEffect(() => {
if (!connected || !state?.current_preset) {
// Offline: show a simple empty state
setFxList([]);
setLoading(false);
return;
}
// Load active preset's chain from presets API
apiGet('/api/presets').then(async data => {
const banks = data.banks || [];
const cp = state.current_preset;
const preset = banks[cp.bank]?.presets?.[cp.program];
if (preset?.chain) {
const blocks = await Promise.all(preset.chain.map(async (b, i) => {
const params = await loadParams(b.fx_type);
return {
id: i + 1,
title: FX_TYPE_LABELS[b.fx_type] || b.fx_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
type: b.fx_type,
bypassed: b.bypass || !b.enabled,
params: params,
};
}));
setFxList(blocks);
}
setLoading(false);
}).catch(() => {
setFxList([]);
setLoading(false);
});
}, [connected, state, loadParams]);
const toggle = (id) => setFxList(prev => prev ? prev.map(f => f.id === id ? { ...f, bypassed: !f.bypassed } : f) : prev);
const addBlock = async (fxType) => {
setShowAdd(false);
const params = await loadParams(fxType);
setFxList(prev => [...(prev || []), {
id: Date.now(),
title: FX_TYPE_LABELS[fxType] || fxType,
type: fxType,
bypassed: false,
params: params,
}]);
};
if (loading) return <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}><div className="loader" /></div>;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="screen-header">
<div className="screen-title">FX Chain {(fxList?.length || 0) > 0 ? `(${fxList.length})` : ''}</div>
<button className="btn btn-ghost btn-sm" onClick={() => setShowAdd(true)}>+ Add Block</button>
</div>
<div className="screen-body">
{/* Signal path */}
{fxList && fxList.length > 0 && (
<div style={{ display: "flex", alignItems: "center", gap: 4, padding: "4px 2px", overflowX: "auto" }}>
{["IN", ...fxList.map(f => f.type.toUpperCase()), "OUT"].map((s, i, arr) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
<div style={{ padding: "3px 8px", borderRadius: 4, fontSize: 10, fontWeight: 700,
background: s === "IN" || s === "OUT" ? T.blueDim : T.amberDim,
color: s === "IN" || s === "OUT" ? T.blue : T.amber,
border: `1px solid ${(s === "IN" || s === "OUT" ? T.blue : T.amber)}60` }}>{s}</div>
{i < arr.length - 1 && <div style={{ width: 12, height: 2, background: T.border, borderRadius: 1 }} />}
</div>
))}
</div>
)}
{(!fxList || fxList.length === 0) ? (
<div style={{ textAlign: "center", padding: 30, color: T.textDim, fontSize: 14 }}>
No FX blocks in the chain.<br />
<button className="btn btn-ghost btn-sm" style={{ marginTop: 10 }} onClick={() => setShowAdd(true)}>+ Add your first block</button>
</div>
) : (
fxList.map(f => (
<FXBlock key={f.id} {...f} active={!f.bypassed} onToggle={() => toggle(f.id)} />
))
)}
</div>
{/* Add Block slide panel */}
{showAdd && (
<SlidePanel onClose={() => setShowAdd(false)}>
<div className="screen-header">
<div className="screen-title">Add FX Block</div>
</div>
<div className="screen-body" style={{ gap: 4 }}>
{Object.entries(FX_TYPE_LABELS).map(([key, label]) => (
<div key={key} className="preset-row" onClick={() => addBlock(key)} style={{ borderRadius: 6 }}>
<span className="badge badge-blue" style={{ width: 60, flexShrink: 0 }}>{key.split('_')[0]}</span>
<span style={{ flex: 1, fontSize: 13 }}>{label}</span>
<span style={{ color: T.textDim }}>+</span>
</div>
))}
</div>
</SlidePanel>
)}
</div>
);
}
// 3. MODELS SCREEN ─────────────────────────────────────────────
=== ModelsScreen ===
function ModelsScreen({ state, connected, refresh }) {
const [models, setModels] = useState([]);
const [current, setCurrent] = useState(null);
const [loading, setLoading] = useState(true);
const [searching, setSearching] = useState(false);
const [searchQ, setSearchQ] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [showSearch, setShowSearch] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef(null);
const loadModels = useCallback(async () => {
if (!connected) { setLoading(false); return; }
try {
const data = await apiGet('/api/models');
setModels(data.models || []);
setCurrent(data.current);
} catch (e) { /* ignore */ }
setLoading(false);
}, [connected]);
useEffect(() => { loadModels(); }, [loadModels]);
const handleLoad = async (path) => {
try { await apiPost('/api/models/load', { path }); await loadModels(); refresh(); } catch (e) { alert('Failed to load model'); }
};
const handleUnload = async () => {
try { await apiPost('/api/models/unload'); setCurrent(null); await loadModels(); refresh(); } catch (e) { alert('Failed to unload'); }
};
const handleUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
await apiUpload('/api/models/upload', file);
await loadModels();
} catch (err) { alert('Upload failed'); }
setUploading(false);
};
const handleSearch = useCallback(async () => {
if (!searchQ.trim()) return;
setSearching(true);
try {
const data = await apiGet(`/api/models/tonedownload/search?q=${encodeURIComponent(searchQ)}`);
setSearchResults(data.results || []);
} catch (e) { alert('Search failed'); }
setSearching(false);
}, [searchQ]);
const handleInstall = async (result) => {
try {
await apiPost('/api/models/tonedownload/install', {
download_url: result.download_url,
name: result.name || result.filename,
id: result.id,
size: result.size,
pack_name: result.pack_name,
display_name: result.name,
});
await loadModels();
refresh();
} catch (e) { alert('Install failed'); }
};
if (loading) return <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}><div className="loader" /></div>;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="screen-header">
<div className="screen-title">NAM Models ({models.length})</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-ghost btn-sm" onClick={() => setShowSearch(!showSearch)}>
{showSearch ? "Local" : "Search"}
</button>
<button className="btn btn-primary btn-sm" onClick={() => fileRef.current?.click()}>
{uploading ? "..." : "+ Upload"}
</button>
<input ref={fileRef} type="file" accept=".nam" style={{ display: "none" }} onChange={handleUpload} />
</div>
</div>
{showSearch && (
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.border}` }}>
<div className="search-bar">
<span style={{ color: T.textDim }}>🔍</span>
<input type="text" placeholder="Search Tone3000…" value={searchQ}
onChange={e => setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
<button className="btn btn-primary btn-sm" onClick={handleSearch} disabled={searching}>
{searching ? "..." : "Go"}
</button>
</div>
{searchResults.length > 0 && (
<div style={{ marginTop: 8, maxHeight: 240, overflow: "auto" }}>
{searchResults.map(r => (
<div key={r.id} className="preset-row" onClick={() => handleInstall(r)} style={{ borderBottom: `1px solid ${T.border}`, borderRadius: 0 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13 }}>{r.name}</div>
<div style={{ fontSize: 10, color: T.textDim }}>{r.author} · {r.size_display} · {r.architecture}</div>
</div>
<button className="btn btn-primary btn-sm">Get</button>
</div>
))}
</div>
)}
</div>
)}
<div className="screen-body">
{current && (
<div className="card-sm">
<div className="section-label" style={{ marginBottom: 6 }}>Loaded Model</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div className="nam-chip">{current}</div>
<button className="btn btn-danger btn-sm" onClick={handleUnload}>Unload</button>
</div>
</div>
)}
{models.length === 0 ? (
<div style={{ textAlign: "center", padding: 30, color: T.textDim, fontSize: 14 }}>
No NAM models found.<br />Upload a .nam file or search Tone3000.
</div>
) : (
models.map(m => (
<div key={m.name} className={`preset-row ${m.loaded ? "active" : ""}`}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{m.name}</div>
<div style={{ fontSize: 10, color: T.textDim }}>{m.architecture} · {m.size_mb} MB</div>
</div>
{m.loaded ? (
<span className="badge badge-green">Loaded</span>
) : (
<button className="btn btn-ghost btn-sm" onClick={() => handleLoad(m.path)}>Load</button>
)}
</div>
))
)}
</div>
</div>
);
}
// 4. IRS SCREEN ────────────────────────────────────────────────
=== IRsScreen ===
function IRsScreen({ state, connected, refresh }) {
const [irs, setIrs] = useState([]);
const [current, setCurrent] = useState(null);
const [loading, setLoading] = useState(true);
const [searching, setSearching] = useState(false);
const [searchQ, setSearchQ] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [showSearch, setShowSearch] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef(null);
const loadIrs = useCallback(async () => {
if (!connected) { setLoading(false); return; }
try {
const data = await apiGet('/api/irs');
setIrs(data.irs || []);
setCurrent(data.current);
} catch (e) { /* ignore */ }
setLoading(false);
}, [connected]);
useEffect(() => { loadIrs(); }, [loadIrs]);
const handleLoad = async (path) => {
try { await apiPost('/api/irs/load', { path }); await loadIrs(); refresh(); } catch (e) { alert('Failed to load IR'); }
};
const handleUnload = async () => {
try { await apiPost('/api/irs/unload'); setCurrent(null); await loadIrs(); refresh(); } catch (e) { alert('Failed to unload'); }
};
const handleUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
await apiUpload('/api/irs/upload', file);
await loadIrs();
} catch (err) { alert('Upload failed'); }
setUploading(false);
};
const handleSearch = useCallback(async () => {
if (!searchQ.trim()) return;
setSearching(true);
try {
const data = await apiGet(`/api/irs/tonedownload/search?q=${encodeURIComponent(searchQ)}`);
setSearchResults(data.results || []);
} catch (e) { alert('Search failed'); }
setSearching(false);
}, [searchQ]);
const handleInstall = async (result) => {
try {
await apiPost('/api/irs/tonedownload/install', {
download_url: result.download_url,
name: result.name || result.filename,
id: result.id,
size: result.size,
display_name: result.name,
});
await loadIrs();
refresh();
} catch (e) { alert('Install failed'); }
};
if (loading) return <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}><div className="loader" /></div>;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="screen-header">
<div className="screen-title">IR Files ({irs.length})</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-ghost btn-sm" onClick={() => setShowSearch(!showSearch)}>
{showSearch ? "Local" : "Search"}
</button>
<button className="btn btn-primary btn-sm" onClick={() => fileRef.current?.click()}>
{uploading ? "..." : "+ Upload"}
</button>
<input ref={fileRef} type="file" accept=".wav" style={{ display: "none" }} onChange={handleUpload} />
</div>
</div>
{showSearch && (
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.border}` }}>
<div className="search-bar">
<span style={{ color: T.textDim }}>🔍</span>
<input type="text" placeholder="Search Tone3000 IRs…" value={searchQ}
onChange={e => setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
<button className="btn btn-primary btn-sm" onClick={handleSearch} disabled={searching}>
{searching ? "..." : "Go"}
</button>
</div>
{searchResults.length > 0 && (
<div style={{ marginTop: 8, maxHeight: 240, overflow: "auto" }}>
{searchResults.map(r => (
<div key={r.id} className="preset-row" onClick={() => handleInstall(r)} style={{ borderBottom: `1px solid ${T.border}`, borderRadius: 0 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13 }}>{r.name}</div>
<div style={{ fontSize: 10, color: T.textDim }}>{r.author} · {r.size_display}</div>
</div>
<button className="btn btn-primary btn-sm">Get</button>
</div>
))}
</div>
)}
</div>
)}
<div className="screen-body">
{current && (
<div className="card-sm">
<div className="section-label" style={{ marginBottom: 6 }}>Loaded IR</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div className="nam-chip" style={{ color: T.green, borderColor: `${T.green}55`, background: `${T.green}15` }}>{current}</div>
<button className="btn btn-danger btn-sm" onClick={handleUnload}>Unload</button>
</div>
</div>
)}
{irs.length === 0 ? (
<div style={{ textAlign: "center", padding: 30, color: T.textDim, fontSize: 14 }}>
No IR files found.<br />Upload a .wav file or search Tone3000.
</div>
) : (
irs.map(ir => (
<div key={ir.name} className={`preset-row ${ir.loaded ? "active" : ""}`}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{ir.name}</div>
<div style={{ fontSize: 10, color: T.textDim }}>{ir.sample_rate} Hz · {ir.length_ms} ms · {ir.num_taps} taps</div>
</div>
{ir.loaded ? (
<span className="badge badge-green">Loaded</span>
) : (
<button className="btn btn-ghost btn-sm" onClick={() => handleLoad(ir.path)}>Load</button>
)}
</div>
))
)}
</div>
</div>
);
}
// 5. PRESETS SCREEN ────────────────────────────────────────────
=== PresetsScreen ===
function PresetsScreen({ state, connected, refresh }) {
const [banks, setBanks] = useState([]);
const [selectedBank, setSelectedBank] = useState(0);
const [loading, setLoading] = useState(true);
const [saveName, setSaveName] = useState('');
const [showSave, setShowSave] = useState(false);
const [deleting, setDeleting] = useState(null);
const loadPresets = useCallback(async () => {
if (!connected) {
setBanks([{ name: "Default", number: 0, presets: [
{ name: "Edge of Breakup", bank: 0, program: 0 },
{ name: "Modern High Gain", bank: 0, program: 1 },
null, null,
]}]);
setLoading(false);
return;
}
try {
const data = await apiGet('/api/presets');
setBanks(data.banks || []);
} catch (e) { /* ignore */ }
setLoading(false);
}, [connected]);
useEffect(() => { loadPresets(); }, [loadPresets]);
const handleActivate = async (bank, program) => {
try {
await apiPost(`/api/presets/${bank}/${program}/activate`);
await loadPresets();
refresh();
} catch (e) { alert('Failed to load preset'); }
};
const handleSave = async () => {
if (!saveName.trim()) return;
const currentBank = state?.current_preset?.bank ?? 0;
const currentProgram = state?.current_preset?.program ?? 0;
try {
await apiPut(`/api/presets/${currentBank}/${currentProgram}`, { name: saveName });
setShowSave(false);
await loadPresets();
} catch (e) { alert('Failed to save'); }
};
const handleNewPreset = async () => {
// Find the first empty slot, or create at next program number
const bank = selectedBank;
const bankData = banks[bank];
const presets = bankData?.presets || [];
let nextProg = presets.findIndex(p => p == null);
if (nextProg === -1) nextProg = presets.length;
const name = `New Preset ${nextProg + 1}`;
try {
await apiPut(`/api/presets/${bank}/${nextProg}`, { name });
await loadPresets();
await apiPost(`/api/presets/${bank}/${nextProg}/activate`);
refresh();
} catch (e) { alert('Failed to create preset'); }
};
const handleDelete = async (bank, program) => {
try {
await apiDelete(`/api/presets/${bank}/${program}`);
setDeleting(null);
await loadPresets();
} catch (e) { alert('Failed to delete'); }
};
if (loading) return <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}><div className="loader" /></div>;
const currentPreset = state?.current_preset;
const bankData = banks[selectedBank];
const presets = bankData?.presets || [];
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="screen-header">
<div className="screen-title">Presets</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-ghost btn-sm" onClick={() => setShowSave(true)}>Save</button>
<button className="btn btn-primary btn-sm" onClick={handleNewPreset}>+ New</button>
</div>
</div>
<div className="screen-body">
{/* Bank selector */}
{banks.length > 1 && (
<div className="toggle">
{banks.map((b, i) => (
<button key={b.number} className={`toggle-btn ${selectedBank === i ? "active" : ""}`}
onClick={() => setSelectedBank(i)}>{b.name}</button>
))}
</div>
)}
{/* Current preset indicator */}
{currentPreset && (
<div className="card-sm" style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 11, color: T.textDim }}>Current</div>
<div style={{ fontSize: 14, fontWeight: 600, color: T.amber }}>
{currentPreset.name}
<span className="mono" style={{ fontSize: 10, color: T.textDim, marginLeft: 8 }}>
Bank {currentPreset.bank} · Prog {currentPreset.program}
</span>
</div>
</div>
</div>
)}
{/* Preset list */}
{presets.length === 0 ? (
<div style={{ textAlign: "center", padding: 30, color: T.textDim, fontSize: 14 }}>No presets in this bank.</div>
) : (
presets.map((p, i) => {
if (!p) return null;
const isActive = currentPreset?.bank === p.bank && currentPreset?.program === p.program;
const isDeleting = deleting === `${p.bank}-${p.program}`;
return (
<div key={i} className={`preset-row ${isActive ? "active" : ""}`}
onClick={() => !isDeleting && handleActivate(p.bank, p.program)}>
<div className="mono" style={{ width: 28, height: 28, borderRadius: 6, display: "flex",
alignItems: "center", justifyContent: "center", fontSize: 12, fontWeight: 700,
background: isActive ? `${T.amber}22` : T.surface,
color: isActive ? T.amber : T.textDim,
border: `1px solid ${isActive ? T.amber+"60" : T.border}` }}>{p.program + 1}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{p.name}</div>
<div style={{ fontSize: 10, color: T.textDim }}>Bank {p.bank} · Program {p.program}</div>
</div>
{isActive && <span style={{ color: T.amber, fontSize: 16 }}>●</span>}
{isDeleting ? (
<div style={{ display: "flex", gap: 4 }}>
<button className="btn btn-danger btn-sm" onClick={e => { e.stopPropagation(); handleDelete(p.bank, p.program); }}>Confirm</button>
<button className="btn btn-ghost btn-sm" onClick={e => { e.stopPropagation(); setDeleting(null); }}>Cancel</button>
</div>
) : (
<button className="btn btn-ghost btn-sm" style={{ color: T.red }}
onClick={e => { e.stopPropagation(); setDeleting(`${p.bank}-${p.program}`); }}>Delete</button>
)}
</div>
);
})
)}
</div>
{/* Save dialog */}
{showSave && (
<SlidePanel onClose={() => setShowSave(false)}>
<div className="screen-header">
<div className="screen-title">Save Preset</div>
</div>
<div className="screen-body">
<div className="search-bar">
<input type="text" placeholder="Preset name…" value={saveName}
onChange={e => setSaveName(e.target.value)} autoFocus
onKeyDown={e => e.key === 'Enter' && handleSave()} />
</div>
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={handleSave}>Save</button>
<button className="btn btn-ghost" onClick={() => setShowSave(false)}>Cancel</button>
</div>
</div>
</SlidePanel>
)}
</div>
);
}
// 6. SETTINGS SCREEN ───────────────────────────────────────────