feat: add React/Vite PWA web UI with FastAPI backend
- FastAPI + uvicorn server (turnup-ui) serving config API and static files - React + TypeScript frontend: connection, global LEDs, per-knob/button cards, preset save/load/apply, toast notifications, dark theme - Vite builds into src/turnup/ui/static/ so no Node.js needed at install time - PWA assets (manifest, service worker, icon) in ui/public/ - turnup-ui.service systemd user unit; enabled globally via turnupd.install - PKGBUILD: add python-fastapi + python-uvicorn deps, install UI service file - pyproject.toml: ui extras group, turnup-ui entry point
This commit is contained in:
41 files changed
+5038
-11
No files matched your search
+174
@@ -0,0 +1,174 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Config, ToastItem } from './types';
|
||||
import * as api from './api';
|
||||
import { ToastContainer } from './components/Toast';
|
||||
import { Connection } from './components/Connection';
|
||||
import { GlobalLeds } from './components/GlobalLeds';
|
||||
import { KnobCard } from './components/KnobCard';
|
||||
import { ButtonCard } from './components/ButtonCard';
|
||||
import { Presets } from './components/Presets';
|
||||
|
||||
const KNOB_INDICES = ['0', '1', '2', '3', '4'] as const;
|
||||
const BTN_INDICES = ['0', '1', '2', '3', '4'] as const;
|
||||
|
||||
let toastSeq = 0;
|
||||
|
||||
export default function App() {
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [saved, setSaved] = useState<Config | null>(null);
|
||||
const [presets, setPresets] = useState<string[]>([]);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const addToast = useCallback((message: string, type: ToastItem['type'] = 'success') => {
|
||||
const id = ++toastSeq;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const loadPresets = useCallback(async () => {
|
||||
try {
|
||||
const names = await api.listPresets();
|
||||
setPresets(names);
|
||||
} catch {
|
||||
// Non-fatal — presets dir may not exist yet
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const cfg = await api.fetchConfig();
|
||||
setConfig(cfg);
|
||||
setSaved(cfg);
|
||||
} catch (err) {
|
||||
addToast(`Failed to load config: ${(err as Error).message}`, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
await loadPresets();
|
||||
})();
|
||||
}, [addToast, loadPresets]);
|
||||
|
||||
const isDirty = config !== null && JSON.stringify(config) !== JSON.stringify(saved);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.saveConfig(config);
|
||||
setSaved(config);
|
||||
addToast('Config saved — daemon will reload automatically');
|
||||
} catch (err) {
|
||||
addToast(`Save failed: ${(err as Error).message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevert = () => {
|
||||
if (saved) setConfig(saved);
|
||||
};
|
||||
|
||||
const patchConfig = useCallback(<K extends keyof Config>(key: K, value: Config[K]) => {
|
||||
setConfig((prev) => prev ? { ...prev, [key]: value } : prev);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div id="loading">
|
||||
<p>Connecting to daemon…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div id="loading">
|
||||
<p>Could not reach the API server. Is <code>turnup-ui</code> running?</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header id="app-header">
|
||||
<h1>TurnUp</h1>
|
||||
{isDirty && <span id="dirty-badge" className="visible">unsaved changes</span>}
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={handleRevert}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!isDirty || saving}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Config'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<Connection
|
||||
config={config}
|
||||
onChange={(patch) => setConfig((prev) => prev ? { ...prev, ...patch } : prev)}
|
||||
/>
|
||||
|
||||
<GlobalLeds
|
||||
leds={config.leds}
|
||||
onChange={(leds) => patchConfig('leds', leds)}
|
||||
/>
|
||||
|
||||
<section className="card" id="knobs">
|
||||
<h2>Knobs</h2>
|
||||
<div className="cards-grid">
|
||||
{KNOB_INDICES.map((i) => (
|
||||
<KnobCard
|
||||
key={i}
|
||||
index={Number(i)}
|
||||
knob={config.knobs[i] ?? { action: 'sink_volume', target: 'default' }}
|
||||
onChange={(knob) =>
|
||||
patchConfig('knobs', { ...config.knobs, [i]: knob })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card" id="buttons">
|
||||
<h2>Buttons</h2>
|
||||
<div className="cards-grid">
|
||||
{BTN_INDICES.map((i) => (
|
||||
<ButtonCard
|
||||
key={i}
|
||||
index={Number(i)}
|
||||
btn={config.buttons[i] ?? { action: 'mute_sink', target: 'default' }}
|
||||
onChange={(btn) =>
|
||||
patchConfig('buttons', { ...config.buttons, [i]: btn })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Presets
|
||||
presets={presets}
|
||||
config={config}
|
||||
onLoad={(cfg) => setConfig(cfg)}
|
||||
onRefresh={loadPresets}
|
||||
onToast={addToast}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Config } from './types';
|
||||
|
||||
async function checkResponse(r: Response): Promise<void> {
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => null);
|
||||
throw new Error(body?.detail ?? `${r.status} ${r.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchConfig(): Promise<Config> {
|
||||
const r = await fetch('/api/config');
|
||||
await checkResponse(r);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function saveConfig(cfg: Config): Promise<void> {
|
||||
const r = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg),
|
||||
});
|
||||
await checkResponse(r);
|
||||
}
|
||||
|
||||
export async function listPresets(): Promise<string[]> {
|
||||
const r = await fetch('/api/presets');
|
||||
if (!r.ok) return [];
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function fetchPreset(name: string): Promise<Config> {
|
||||
const r = await fetch(`/api/presets/${encodeURIComponent(name)}`);
|
||||
await checkResponse(r);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function savePreset(name: string, cfg: Config): Promise<void> {
|
||||
const r = await fetch(`/api/presets/${encodeURIComponent(name)}/save`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg),
|
||||
});
|
||||
await checkResponse(r);
|
||||
}
|
||||
|
||||
export async function applyPreset(name: string): Promise<void> {
|
||||
const r = await fetch(`/api/presets/${encodeURIComponent(name)}/apply`, {
|
||||
method: 'POST',
|
||||
});
|
||||
await checkResponse(r);
|
||||
}
|
||||
|
||||
export async function deletePreset(name: string): Promise<void> {
|
||||
const r = await fetch(`/api/presets/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
await checkResponse(r);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ButtonConfig, ButtonAction } from '../types';
|
||||
|
||||
const ACTIONS: { value: ButtonAction; label: string }[] = [
|
||||
{ value: 'mute_sink', label: 'mute_sink — toggle output mute' },
|
||||
{ value: 'mute_source', label: 'mute_source — toggle mic mute' },
|
||||
{ value: 'command', label: 'command — run shell command' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
btn: ButtonConfig;
|
||||
onChange: (btn: ButtonConfig) => void;
|
||||
}
|
||||
|
||||
export function ButtonCard({ index, btn, onChange }: Props) {
|
||||
const isCmd = btn.action === 'command';
|
||||
|
||||
return (
|
||||
<div className="button-card">
|
||||
<div className="card-title">
|
||||
<span className="card-index">{index}</span>
|
||||
Button {index}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={`btn-${index}-action`}>Action</label>
|
||||
<select
|
||||
id={`btn-${index}-action`}
|
||||
value={btn.action}
|
||||
onChange={(e) =>
|
||||
onChange({ ...btn, action: e.target.value as ButtonAction })
|
||||
}
|
||||
>
|
||||
{ACTIONS.map((a) => (
|
||||
<option key={a.value} value={a.value}>{a.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={`btn-${index}-target`}>{isCmd ? 'Command' : 'Target'}</label>
|
||||
<input
|
||||
type="text"
|
||||
id={`btn-${index}-target`}
|
||||
value={btn.target}
|
||||
placeholder={isCmd ? 'playerctl play-pause' : 'default'}
|
||||
onChange={(e) => onChange({ ...btn, target: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { hexToRgb, rgbToHex } from '../utils';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
value: [number, number, number];
|
||||
onChange: (rgb: [number, number, number]) => void;
|
||||
}
|
||||
|
||||
export function ColorPicker({ id, label, value, onChange }: Props) {
|
||||
const hex = rgbToHex(value);
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id}>{label}</label>
|
||||
<div className="color-row">
|
||||
<input
|
||||
type="color"
|
||||
id={id}
|
||||
value={hex}
|
||||
onChange={(e) => onChange(hexToRgb(e.target.value))}
|
||||
/>
|
||||
<span className="color-hex">{hex}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Config } from '../types';
|
||||
|
||||
interface Props {
|
||||
config: Config;
|
||||
onChange: (patch: Partial<Pick<Config, 'port' | 'baud'>>) => void;
|
||||
}
|
||||
|
||||
export function Connection({ config, onChange }: Props) {
|
||||
return (
|
||||
<section className="card" id="connection">
|
||||
<h2>Connection</h2>
|
||||
<div className="fields">
|
||||
<div className="field">
|
||||
<label htmlFor="port">Serial port</label>
|
||||
<input
|
||||
type="text"
|
||||
id="port"
|
||||
value={config.port}
|
||||
placeholder="/dev/ttyACM0"
|
||||
onChange={(e) => onChange({ port: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="baud">Baud rate</label>
|
||||
<input
|
||||
type="number"
|
||||
id="baud"
|
||||
value={config.baud}
|
||||
onChange={(e) =>
|
||||
onChange({ baud: parseInt(e.target.value, 10) || 115200 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { LedConfig, LedMode } from '../types';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
import { rgbToHex } from '../utils';
|
||||
|
||||
interface Props {
|
||||
leds: LedConfig;
|
||||
onChange: (leds: LedConfig) => void;
|
||||
}
|
||||
|
||||
export function GlobalLeds({ leds, onChange }: Props) {
|
||||
const update = (patch: Partial<LedConfig>) => onChange({ ...leds, ...patch });
|
||||
const lowHex = rgbToHex(leds.low_color);
|
||||
const highHex = rgbToHex(leds.high_color);
|
||||
|
||||
const previewBg =
|
||||
leds.mode === 'off' ? '#000' :
|
||||
leds.mode === 'static' ? highHex :
|
||||
`linear-gradient(to right, ${lowHex}, ${highHex})`;
|
||||
|
||||
return (
|
||||
<section className="card" id="global-leds">
|
||||
<h2>Global LEDs</h2>
|
||||
<div className="fields">
|
||||
<div className="field">
|
||||
<label htmlFor="led-mode">Mode</label>
|
||||
<select
|
||||
id="led-mode"
|
||||
value={leds.mode}
|
||||
onChange={(e) => update({ mode: e.target.value as LedMode })}
|
||||
>
|
||||
<option value="volume">volume — fade low → high</option>
|
||||
<option value="static">static — always high color</option>
|
||||
<option value="off">off — LEDs disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ opacity: leds.mode === 'volume' ? 1 : 0.4 }}>
|
||||
<ColorPicker
|
||||
id="led-low-color"
|
||||
label="Low color (0 %)"
|
||||
value={leds.low_color}
|
||||
onChange={(low_color) => update({ low_color })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<ColorPicker
|
||||
id="led-high-color"
|
||||
label="High color (100 %)"
|
||||
value={leds.high_color}
|
||||
onChange={(high_color) => update({ high_color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="led-preview" style={{ background: previewBg }} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import type { KnobConfig, KnobAction, LedConfig, LedMode } from '../types';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
|
||||
const ACTIONS: { value: KnobAction; label: string }[] = [
|
||||
{ value: 'sink_volume', label: 'sink_volume — output device' },
|
||||
{ value: 'source_volume', label: 'source_volume — microphone' },
|
||||
{ value: 'app_volume', label: 'app_volume — single app' },
|
||||
{ value: 'group_volume', label: 'group_volume — multiple apps' },
|
||||
];
|
||||
|
||||
const DEFAULT_LED: LedConfig = {
|
||||
mode: 'volume',
|
||||
low_color: [255, 0, 0],
|
||||
high_color: [0, 255, 0],
|
||||
};
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
knob: KnobConfig;
|
||||
onChange: (knob: KnobConfig) => void;
|
||||
}
|
||||
|
||||
export function KnobCard({ index, knob, onChange }: Props) {
|
||||
const [ledOpen, setLedOpen] = useState(!!knob.led);
|
||||
|
||||
const update = (patch: Partial<KnobConfig>) => onChange({ ...knob, ...patch });
|
||||
|
||||
const ledCfg: LedConfig = {
|
||||
mode: knob.led?.mode ?? DEFAULT_LED.mode,
|
||||
low_color: knob.led?.low_color ?? DEFAULT_LED.low_color,
|
||||
high_color: knob.led?.high_color ?? DEFAULT_LED.high_color,
|
||||
};
|
||||
|
||||
const updateLed = (patch: Partial<LedConfig>) =>
|
||||
update({ led: { ...ledCfg, ...patch } });
|
||||
|
||||
const toggleLed = () => {
|
||||
const next = !ledOpen;
|
||||
setLedOpen(next);
|
||||
if (!next) {
|
||||
// Drop the led key entirely when panel is closed
|
||||
const { led: _dropped, ...rest } = knob;
|
||||
onChange(rest as KnobConfig);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="knob-card">
|
||||
<div className="card-title">
|
||||
<span className="card-index">{index}</span>
|
||||
Knob {index}
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div>
|
||||
<label htmlFor={`knob-${index}-action`}>Action</label>
|
||||
<select
|
||||
id={`knob-${index}-action`}
|
||||
value={knob.action}
|
||||
onChange={(e) => {
|
||||
const action = e.target.value as KnobAction;
|
||||
if (action === 'group_volume') {
|
||||
update({ action, targets: knob.targets ?? [], target: undefined });
|
||||
} else {
|
||||
update({ action, target: knob.target ?? 'default', targets: undefined });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ACTIONS.map((a) => (
|
||||
<option key={a.value} value={a.value}>{a.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Target / Targets */}
|
||||
{knob.action === 'group_volume' ? (
|
||||
<div>
|
||||
<label htmlFor={`knob-${index}-targets`}>Targets (comma-separated)</label>
|
||||
<textarea
|
||||
id={`knob-${index}-targets`}
|
||||
value={(knob.targets ?? []).join(', ')}
|
||||
placeholder="spotify, vlc, brave"
|
||||
onChange={(e) =>
|
||||
update({
|
||||
targets: e.target.value.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label htmlFor={`knob-${index}-target`}>Target</label>
|
||||
<input
|
||||
type="text"
|
||||
id={`knob-${index}-target`}
|
||||
value={knob.target ?? 'default'}
|
||||
placeholder="default"
|
||||
onChange={(e) => update({ target: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LED override toggle */}
|
||||
<button className="led-override-toggle" onClick={toggleLed}>
|
||||
<span>{ledOpen ? '▾' : '▸'}</span>
|
||||
LED override
|
||||
</button>
|
||||
|
||||
{/* LED override panel */}
|
||||
{ledOpen && (
|
||||
<div className="led-override-panel open">
|
||||
<div>
|
||||
<label htmlFor={`knob-${index}-led-mode`}>Mode</label>
|
||||
<select
|
||||
id={`knob-${index}-led-mode`}
|
||||
value={ledCfg.mode}
|
||||
onChange={(e) => updateLed({ mode: e.target.value as LedMode })}
|
||||
>
|
||||
<option value="volume">volume</option>
|
||||
<option value="static">static</option>
|
||||
<option value="off">off</option>
|
||||
</select>
|
||||
</div>
|
||||
<ColorPicker
|
||||
id={`knob-${index}-led-low`}
|
||||
label="Low color"
|
||||
value={ledCfg.low_color}
|
||||
onChange={(low_color) => updateLed({ low_color })}
|
||||
/>
|
||||
<ColorPicker
|
||||
id={`knob-${index}-led-high`}
|
||||
label="High color"
|
||||
value={ledCfg.high_color}
|
||||
onChange={(high_color) => updateLed({ high_color })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import type { Config, ToastItem } from '../types';
|
||||
import * as api from '../api';
|
||||
|
||||
interface Props {
|
||||
presets: string[];
|
||||
config: Config;
|
||||
onLoad: (cfg: Config) => void;
|
||||
onRefresh: () => void;
|
||||
onToast: (msg: string, type?: ToastItem['type']) => void;
|
||||
}
|
||||
|
||||
export function Presets({ presets, config, onLoad, onRefresh, onToast }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) { onToast('Enter a preset name first', 'info'); return; }
|
||||
try {
|
||||
await api.savePreset(trimmed, config);
|
||||
onToast(`Preset "${trimmed}" saved`);
|
||||
setName('');
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
onToast(`Save preset failed: ${(err as Error).message}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoad = async (presetName: string) => {
|
||||
try {
|
||||
const cfg = await api.fetchPreset(presetName);
|
||||
onLoad(cfg);
|
||||
onToast(`Preset "${presetName}" loaded — click Save Config to write to disk`, 'info');
|
||||
} catch (err) {
|
||||
onToast(`Load failed: ${(err as Error).message}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async (presetName: string) => {
|
||||
try {
|
||||
await api.applyPreset(presetName);
|
||||
const cfg = await api.fetchConfig();
|
||||
onLoad(cfg);
|
||||
onToast(`Preset "${presetName}" applied — daemon will reload automatically`);
|
||||
} catch (err) {
|
||||
onToast(`Apply failed: ${(err as Error).message}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (presetName: string) => {
|
||||
if (!confirm(`Delete preset "${presetName}"?`)) return;
|
||||
try {
|
||||
await api.deletePreset(presetName);
|
||||
onToast(`Preset "${presetName}" deleted`);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
onToast(`Delete failed: ${(err as Error).message}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card" id="presets">
|
||||
<h2>Presets</h2>
|
||||
|
||||
<div className="save-row">
|
||||
<div className="field">
|
||||
<label htmlFor="preset-name">Preset name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="preset-name"
|
||||
value={name}
|
||||
placeholder="my-setup"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && void handleSave()}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn-secondary" onClick={() => void handleSave()}>
|
||||
Save current as preset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="preset-list">
|
||||
{presets.length === 0 ? (
|
||||
<p className="empty">No presets saved yet.</p>
|
||||
) : (
|
||||
presets.map((p) => (
|
||||
<div key={p} className="preset-item">
|
||||
<span className="preset-name">{p}</span>
|
||||
<div className="preset-actions">
|
||||
<button className="btn-secondary btn-sm" onClick={() => void handleLoad(p)}>
|
||||
Load
|
||||
</button>
|
||||
<button className="btn-primary btn-sm" onClick={() => void handleApply(p)}>
|
||||
Apply
|
||||
</button>
|
||||
<button className="btn-danger btn-sm" onClick={() => void handleDelete(p)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { ToastItem } from '../types';
|
||||
|
||||
interface Props {
|
||||
toasts: ToastItem[];
|
||||
onRemove: (id: number) => void;
|
||||
}
|
||||
|
||||
export function ToastContainer({ toasts, onRemove }: Props) {
|
||||
return (
|
||||
<div id="toast-container">
|
||||
{toasts.map((t) => (
|
||||
<ToastEl key={t.id} toast={t} onRemove={onRemove} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastEl({
|
||||
toast,
|
||||
onRemove,
|
||||
}: {
|
||||
toast: ToastItem;
|
||||
onRemove: (id: number) => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => onRemove(toast.id), 3100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.id, onRemove]);
|
||||
|
||||
return <div className={`toast ${toast.type}`}>{toast.message}</div>;
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/* ── Design tokens ─────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b22;
|
||||
--surface-2: #21262d;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-muted: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--accent-dim: #1f3a5f;
|
||||
--success: #3fb950;
|
||||
--danger: #f85149;
|
||||
--warning: #d29922;
|
||||
--radius: 8px;
|
||||
--gap: 16px;
|
||||
}
|
||||
|
||||
/* ── Reset ─────────────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* ── Typography ────────────────────────────────────────────────────────────── */
|
||||
h2 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── Form controls ─────────────────────────────────────────────────────────── */
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
input[type="text"]:focus,
|
||||
input[type="number"]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
select { cursor: pointer; }
|
||||
textarea { resize: vertical; min-height: 60px; }
|
||||
|
||||
input[type="color"] {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 2px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Buttons ───────────────────────────────────────────────────────────────── */
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s, opacity .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button:active { opacity: .75; }
|
||||
button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: #79b8ff; border-color: #79b8ff; }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { background: var(--danger); color: #fff; }
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Layout ────────────────────────────────────────────────────────────────── */
|
||||
#app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
padding: 12px 24px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
#app-header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#dirty-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
background: var(--warning);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
#loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100dvh;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Cards / sections ──────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ── Connection row ────────────────────────────────────────────────────────── */
|
||||
#connection .fields {
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#connection .field { flex: 1; min-width: 160px; }
|
||||
|
||||
/* ── Global LEDs ───────────────────────────────────────────────────────────── */
|
||||
#global-leds .fields {
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#global-leds .field { flex: 1; min-width: 120px; }
|
||||
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.color-hex {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.led-preview {
|
||||
margin-top: 12px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Knobs / Buttons grids ─────────────────────────────────────────────────── */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--gap);
|
||||
}
|
||||
|
||||
.knob-card,
|
||||
.button-card {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.knob-card .card-title,
|
||||
.button-card .card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── LED override toggle ───────────────────────────────────────────────────── */
|
||||
.led-override-toggle {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
user-select: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.led-override-toggle:hover { color: var(--accent); }
|
||||
|
||||
.led-override-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.led-override-panel.open { display: flex; }
|
||||
|
||||
.led-override-panel .color-row { flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
/* ── Presets ───────────────────────────────────────────────────────────────── */
|
||||
#presets .save-row {
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
#presets .save-row .field { flex: 1; min-width: 180px; }
|
||||
|
||||
.preset-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.preset-item .preset-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
.preset-item .preset-actions { display: flex; gap: 6px; }
|
||||
|
||||
/* ── Toast notifications ───────────────────────────────────────────────────── */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
animation: toast-in .2s ease, toast-out .3s ease 2.7s forwards;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.toast.success { background: var(--success); color: #000; }
|
||||
.toast.error { background: var(--danger); }
|
||||
.toast.info { background: var(--accent); color: #000; }
|
||||
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes toast-out { from { opacity: 1; } to { opacity: 0; } }
|
||||
|
||||
/* ── Empty state ───────────────────────────────────────────────────────────── */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 540px) {
|
||||
main { padding: 16px; }
|
||||
#app-header { padding: 10px 16px; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// SW registration failure is non-fatal
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
export type LedMode = 'volume' | 'static' | 'off';
|
||||
export type KnobAction = 'sink_volume' | 'source_volume' | 'app_volume' | 'group_volume';
|
||||
export type ButtonAction = 'mute_sink' | 'mute_source' | 'command';
|
||||
|
||||
export interface LedConfig {
|
||||
mode: LedMode;
|
||||
low_color: [number, number, number];
|
||||
high_color: [number, number, number];
|
||||
}
|
||||
|
||||
export interface KnobConfig {
|
||||
action: KnobAction;
|
||||
target?: string;
|
||||
targets?: string[];
|
||||
led?: Partial<LedConfig>;
|
||||
}
|
||||
|
||||
export interface ButtonConfig {
|
||||
action: ButtonAction;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
port: string;
|
||||
baud: number;
|
||||
leds: LedConfig;
|
||||
knobs: Record<string, KnobConfig>;
|
||||
buttons: Record<string, ButtonConfig>;
|
||||
}
|
||||
|
||||
export interface ToastItem {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function rgbToHex([r, g, b]: [number, number, number]): string {
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b]
|
||||
.map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
);
|
||||
}
|
||||
|
||||
export function hexToRgb(hex: string): [number, number, number] {
|
||||
const n = parseInt(hex.replace('#', ''), 16);
|
||||
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
||||
}
|
||||
Reference in new issue
Block a user