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,593 @@
|
||||
=== 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 ───────────────────────────────────────────
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+14
-7
@@ -638,8 +638,12 @@ export default function App(){
|
||||
<span className="mono" style={{fontSize:9,color:T.textDim}}>CPU {state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}</span>
|
||||
</div>
|
||||
<div style={{width:1,height:12,background:T.border}}/>
|
||||
<button className="btn-icon" style={{width:24,height:24,fontSize:10}}onClick={()=>setView("captures")}title="Downloads">📁</button>
|
||||
<button className="btn-icon" style={{width:24,height:24,fontSize:10}}onClick={()=>setView("presets")}title="Presets">⭐</button>
|
||||
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}onClick={()=>setView("captures")}title="Downloads">
|
||||
<img src={`${import.meta.env.BASE_URL}img/ic_bank.svg`} alt="captures" style={{width:14,height:14,filter:'brightness(0.7)'}}/>
|
||||
</button>
|
||||
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}onClick={()=>setView("presets")}title="Presets">
|
||||
<img src={`${import.meta.env.BASE_URL}img/ic_presets.svg`} alt="presets" style={{width:14,height:14,filter:'brightness(0.7)'}}/>
|
||||
</button>
|
||||
<div style={{display:"flex",gap:2,alignItems:"center",background:T.surface,borderRadius:4,border:`1px solid ${T.border}`,padding:2,height:24}}>
|
||||
{["stomp","preset"].map(m=>(<button key={m} onClick={()=>setFootswitchMode(m)} style={{
|
||||
padding:"1px 5px",borderRadius:3,border:"none",height:20,
|
||||
@@ -649,12 +653,15 @@ export default function App(){
|
||||
cursor:"pointer",lineHeight:1.5,minWidth:34,display:"flex",alignItems:"center",justifyContent:"center",
|
||||
}}>{m==="stomp"?"SM":"PR"}</button>))}
|
||||
</div>
|
||||
<button className="btn-icon" style={{width:24,height:24,fontSize:10,
|
||||
<button className="btn-icon" style={{width:24,height:24,position:'relative',
|
||||
background:state.tuner_enabled?'rgba(128,208,160,.15)':T.surface,
|
||||
borderColor:state.tuner_enabled?T.green:T.border,
|
||||
color:state.tuner_enabled?T.green:T.textPrimary}}
|
||||
onClick={handleTunerToggle} title={state.tuner_enabled?"Exit Tuner":"Tuner"}>♪</button>
|
||||
<button className="btn-icon" style={{width:24,height:24,fontSize:10,background:'rgba(232,160,48,.12)',borderColor:T.amber,color:T.amber}}onClick={()=>{if(!document.fullscreenElement){document.documentElement.requestFullscreen()}else{document.exitFullscreen()}}}title="Fullscreen">⛶</button>
|
||||
borderColor:state.tuner_enabled?T.green:T.border}}
|
||||
onClick={handleTunerToggle} title={state.tuner_enabled?"Exit Tuner":"Tuner"}>
|
||||
<img src={`${import.meta.env.BASE_URL}img/fx_analyzer.svg`} alt="tuner" style={{width:14,height:14,filter:state.tuner_enabled?'brightness(1.5) sepia(0.3) hue-rotate(80deg)':'brightness(0.7)'}}/>
|
||||
</button>
|
||||
<button className="btn-icon" style={{width:24,height:24,position:'relative',background:'rgba(232,160,48,.12)',borderColor:T.amber}}onClick={()=>{if(!document.fullscreenElement){document.documentElement.requestFullscreen()}else{document.exitFullscreen()}}}title="Fullscreen">
|
||||
<img src={`${import.meta.env.BASE_URL}img/ic_drawer_2.svg`} alt="fullscreen" style={{width:12,height:12,filter:'brightness(1.2)'}}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+67
-27
@@ -40,6 +40,70 @@ const TYPE_COLORS = {
|
||||
split: "#B080C0", merge: "#80B0C0",
|
||||
};
|
||||
|
||||
// ── Type-to-SVG-icon mapping (from pipedal icon set) ──
|
||||
const TYPE_ICON_MAP = {
|
||||
od: "fx_distortion", overdrive: "fx_distortion", drive: "fx_distortion",
|
||||
dist: "fx_distortion", distortion: "fx_distortion", gain: "fx_distortion",
|
||||
fuzz: "fx_distortion",
|
||||
delay: "fx_delay", echo: "fx_delay",
|
||||
reverb: "fx_reverb",
|
||||
chorus: "fx_chorus", flange: "fx_flanger", flanger: "fx_flanger",
|
||||
phaser: "fx_phaser", tremolo: "fx_modulator", vibrato: "fx_modulator",
|
||||
rotary: "fx_modulator", modulator: "fx_modulator",
|
||||
pitch: "fx_pitch", harmonizer: "fx_pitch", shifter: "fx_pitch",
|
||||
comp: "fx_compressor", compressor: "fx_compressor",
|
||||
limiter: "fx_limiter",
|
||||
gate: "fx_gate", noise_gate: "fx_gate",
|
||||
filter: "fx_filter", wah: "fx_filter",
|
||||
eq: "fx_eq", graphic_eq: "fx_eq",
|
||||
parametric_eq: "fx_parametric_eq", multiband_eq: "fx_multiband_eq",
|
||||
ir: "fx_spatial", cab: "fx_spatial", cabinet: "fx_spatial",
|
||||
nam: "fx_simulator", capture: "fx_simulator", amp: "fx_amplifier",
|
||||
amplifier: "fx_amplifier",
|
||||
volume: "fx_constant", level: "fx_constant", boost: "fx_constant",
|
||||
split: "fx_split_a", merge: "fx_mixer",
|
||||
tuner: "fx_analyzer",
|
||||
mixer: "fx_mixer", utility: "fx_utility",
|
||||
plugin: "fx_plugin",
|
||||
instrument: "fx_instrument",
|
||||
reamp: "fx_converter",
|
||||
looper: "fx_oscillator",
|
||||
};
|
||||
|
||||
const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/';
|
||||
|
||||
// ── SVG Block Icon component ─────────────────────────
|
||||
function BlockIcon({ type, size = 14, style = {} }) {
|
||||
const key = (type || '').toLowerCase().trim();
|
||||
let icon = 'fx_plugin';
|
||||
let found = false;
|
||||
for (const [k, v] of Object.entries(TYPE_ICON_MAP)) {
|
||||
if (key === k || key.startsWith(k) || key.includes(k)) {
|
||||
icon = v;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found && key) {
|
||||
// Try direct match by replacing spaces/special chars
|
||||
const slug = key.replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
||||
if (TYPE_ICON_MAP[slug]) icon = TYPE_ICON_MAP[slug];
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={`${IMG_PATH}${icon}.svg`}
|
||||
alt={type || 'fx'}
|
||||
style={{
|
||||
width: size, height: size, flexShrink: 0,
|
||||
filter: 'brightness(1.2) contrast(1.1)',
|
||||
objectFit: 'contain',
|
||||
...style,
|
||||
}}
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getBlockColor(type) {
|
||||
const key = (type || "").toLowerCase().trim();
|
||||
for (const [k, v] of Object.entries(TYPE_COLORS)) {
|
||||
@@ -48,30 +112,6 @@ function getBlockColor(type) {
|
||||
return T.amber;
|
||||
}
|
||||
|
||||
function getBlockIcon(type) {
|
||||
const key = (type || "").toLowerCase();
|
||||
if (key === "split") return "⇄";
|
||||
if (key === "merge") return "⨁";
|
||||
if (key.includes("dist") || key.includes("gain")) return "💥";
|
||||
if (key.includes("fuzz")) return "⚡";
|
||||
if (key.includes("delay") || key.includes("echo")) return "⏳";
|
||||
if (key.includes("reverb")) return "🌊";
|
||||
if (key.includes("pitch") || key.includes("harm") || key.includes("shift")) return "🎵";
|
||||
if (key.includes("comp") || key.includes("limit")) return "📊";
|
||||
if (key.includes("gate") || key.includes("noise")) return "🔇";
|
||||
if (key.includes("filter") || key.includes("wah")) return "📈";
|
||||
if (key.includes("eq")) return "⚖️";
|
||||
if (odMatch(key)) return "🔥";
|
||||
if (modMatch(key)) return "🌀";
|
||||
if (key.includes("ir") || key.includes("cab")) return "🔊";
|
||||
if (key.includes("nam") || key.includes("capture")) return "🎛";
|
||||
if (key.includes("volume") || key.includes("level") || key.includes("boost")) return "🔈";
|
||||
if (key.includes("tuner")) return "🎹";
|
||||
return "▣";
|
||||
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({ color = T.border, narrow = false }) {
|
||||
const w = narrow ? 14 : 18;
|
||||
@@ -166,7 +206,7 @@ function SplitBlockTile({ block, selected, onSelect, onToggle }) {
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||||
lineHeight: 1.3, display: "flex", alignItems: "center", gap: 6,
|
||||
}}>
|
||||
<span style={{ fontSize: 14 }}>⇄</span>
|
||||
<BlockIcon type="split" size={16} />
|
||||
<span>SPLIT {st.label}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 9, color: T.textSec, marginTop: 2 }}>
|
||||
@@ -234,7 +274,7 @@ function MergeBlockTile({ block, selected, onSelect, onToggle }) {
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||||
lineHeight: 1.3, display: "flex", alignItems: "center", gap: 6,
|
||||
}}>
|
||||
<span style={{ fontSize: 14 }}>⨁</span>
|
||||
<BlockIcon type="merge" size={16} />
|
||||
<span>MERGE</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 9, color: T.textSec, marginTop: 2 }}>
|
||||
@@ -332,7 +372,7 @@ function BlockTile({ block, selected, onSelect, onToggle, pathLabel }) {
|
||||
{block.name || block.type || "FX"}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 5, marginTop: 3 }}>
|
||||
<span style={{ fontSize: 12, lineHeight: 1 }}>{getBlockIcon(block.type)}</span>
|
||||
<BlockIcon type={block.type} size={14} />
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, color, letterSpacing: ".08em",
|
||||
textTransform: "uppercase", background: `${color}18`,
|
||||
|
||||
+121
-4
@@ -1,4 +1,121 @@
|
||||
/**
|
||||
* Styles are embedded in App.jsx via CSS-in-JS template literal.
|
||||
* This file intentionally left empty — remove default Vite styles.
|
||||
*/
|
||||
/* ── Reset & base ── */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; overflow: hidden; }
|
||||
body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
|
||||
/* ── Utility classes (from pipedal) ── */
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
/* Badge system */
|
||||
.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: #E8A030; }
|
||||
.badge-green { background: rgba(58,184,122,.2); color: #3AB87A; }
|
||||
.badge-blue { background: rgba(58,123,168,.2); color: #3A7BA8; }
|
||||
.badge-red { background: rgba(200,64,64,.2); color: #C84040; }
|
||||
|
||||
/* Button system */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 8px 16px; border-radius: 6px; font-size: 12px; font-weight: 600;
|
||||
cursor: pointer; border: 1px solid #2A2A32; background: #1C1C22; color: #F0EDE6;
|
||||
transition: all .12s; touch-action: manipulation; }
|
||||
.btn:active { transform: scale(.96); }
|
||||
.btn-sm { padding: 5px 10px; font-size: 10px; }
|
||||
.btn-ghost { background: transparent; border-color: transparent; }
|
||||
.btn-danger { border-color: #C84040; background: rgba(200,64,64,.15); color: #C84040; }
|
||||
.btn-icon { display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: 5px; border: 1px solid #2A2A32;
|
||||
background: #1C1C22; cursor: pointer; transition: all .12s; touch-action: manipulation; }
|
||||
.btn-icon:active { transform: scale(.92); }
|
||||
|
||||
/* Slide panel (bottom drawer) */
|
||||
.slide-panel { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 100;
|
||||
display: flex; align-items: flex-end; justify-content: center; }
|
||||
.slide-panel-inner { background: #0A0A0C; max-width: 440px; width: 100%; max-height: 85vh;
|
||||
border-radius: 16px 16px 0 0; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.slide-panel-handle { width: 36px; height: 4px; border-radius: 2px; background: #2A2A32;
|
||||
margin: 8px auto; flex-shrink: 0; }
|
||||
|
||||
/* Screen layout */
|
||||
.screen-header { padding: 12px 14px 8px; display: flex; align-items: center;
|
||||
justify-content: space-between; border-bottom: 1px solid #2A2A32; flex-shrink: 0; }
|
||||
.screen-title { font-size: 13px; font-weight: 600; letter-spacing: .06em;
|
||||
text-transform: uppercase; color: #8888A0; }
|
||||
.screen-body { flex: 1; overflow-y: auto; padding: 12px; display: flex;
|
||||
flex-direction: column; gap: 12px; }
|
||||
|
||||
/* Snapshot grid */
|
||||
.snap-grid { display: flex; gap: 6px; overflow-x: auto; padding: 4px 0; }
|
||||
.snap-slot { min-width: 60px; flex-shrink: 0; border-radius: 8px;
|
||||
border: 1.5px solid #2A2A32; background: #1C1C22; cursor: pointer;
|
||||
text-align: center; padding: 8px 4px; transition: all .12s;
|
||||
touch-action: manipulation; position: relative; }
|
||||
.snap-slot:active { transform: scale(.95); }
|
||||
.snap-num { font-size: 16px; font-weight: 700; color: #8888A0; }
|
||||
.snap-name { font-size: 9px; color: #F0EDE6; margin-top: 2px; word-break: break-all; line-height: 1.3; }
|
||||
.snap-empty { font-size: 9px; color: #444458; margin-top: 2px; font-style: italic; }
|
||||
.snap-save-indicator { position: absolute; top: 4px; right: 4px; width: 6px; height: 6px;
|
||||
border-radius: 50%; background: #3AB87A; box-shadow: 0 0 4px #3AB87A; }
|
||||
.snap-name-input { width: 100%; background: #0A0A0C; border: 1px solid #2A2A32;
|
||||
border-radius: 3px; padding: 2px 4px; font-size: 9px; color: #F0EDE6;
|
||||
text-align: center; outline: none; margin-top: 2px; }
|
||||
|
||||
/* Preset rows */
|
||||
.preset-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px;
|
||||
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); }
|
||||
|
||||
/* Transport buttons (looper) */
|
||||
.transport-btn { width: 44px; height: 44px; border-radius: 50%; display: flex;
|
||||
align-items: center; justify-content: center; font-size: 16px; cursor: pointer;
|
||||
border: 2px solid #2A2A32; background: #1C1C22; transition: all .12s; }
|
||||
.transport-btn:active { transform: scale(.92); }
|
||||
.transport-btn.rec { border-color: #C84040; background: rgba(200,64,64,.15); }
|
||||
.transport-btn.play { border-color: #3AB87A; background: rgba(58,184,122,.15); }
|
||||
|
||||
/* Switch toggle */
|
||||
.switch { width: 40px; height: 22px; border-radius: 11px; background: #2A2A32;
|
||||
cursor: pointer; position: relative; transition: background .2s; flex-shrink: 0; }
|
||||
.switch.on { background: #3AB87A; }
|
||||
.switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px;
|
||||
height: 18px; border-radius: 50%; background: white; transition: transform .15s; }
|
||||
.switch.on::after { transform: translateX(18px); }
|
||||
|
||||
/* Search bar */
|
||||
.search-bar { display: flex; background: #1C1C22; border-radius: 7px;
|
||||
border: 1px solid #2A2A32; padding: 8px 12px; gap: 8px; align-items: center; }
|
||||
.search-bar input { background: none; border: none; outline: none; color: #F0EDE6;
|
||||
font-size: 13px; flex: 1; }
|
||||
.search-bar input::placeholder { color: #444458; }
|
||||
|
||||
/* File drop zone */
|
||||
.file-drop { border: 2px dashed #2A2A32; border-radius: 8px; padding: 20px;
|
||||
text-align: center; cursor: pointer; transition: all .15s; font-size: 13px; color: #444458; }
|
||||
.file-drop:hover, .file-drop.dragover { border-color: #E8A030; color: #E8A030; }
|
||||
|
||||
/* Loader */
|
||||
.loader { width: 20px; height: 20px; border: 2px solid #2A2A32;
|
||||
border-top-color: #E8A030; border-radius: 50%; animation: spin .6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Settings list */
|
||||
.setting-row { display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 0; border-bottom: 1px solid #2A2A32; }
|
||||
.setting-label { font-size: 13px; color: #F0EDE6; }
|
||||
.setting-desc { font-size: 11px; color: #444458; margin-top: 2px; }
|
||||
|
||||
/* Section label */
|
||||
.section-label { font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
|
||||
color: #444458; font-weight: 600; margin-bottom: 10px; }
|
||||
|
||||
/* NAM chip */
|
||||
.nam-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
|
||||
background: rgba(58,123,168,.15); border: 1px solid rgba(58,123,168,.35);
|
||||
border-radius: 5px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #3A7BA8; }
|
||||
|
||||
/* Bypass LED */
|
||||
.bypass-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.led-on { background: #3AB87A; box-shadow: 0 0 8px #3AB87A; }
|
||||
.led-off { background: #444458; }
|
||||
|
||||
Reference in New Issue
Block a user