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:
Sean Doran committed 2026-02-27 21:25:16 -05:00
1 parent 1c73dc1883
commit 8f8d9ac3fa
41 files changed
+5038 -11

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.json" />
<title>TurnUp</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3286
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "turnup-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="80" fill="#0d1117"/>
<!-- Knob body -->
<circle cx="256" cy="210" r="110" fill="#161b22" stroke="#58a6ff" stroke-width="14"/>
<!-- Knob indicator line -->
<line x1="256" y1="210" x2="256" y2="122" stroke="#58a6ff" stroke-width="12" stroke-linecap="round"/>
<!-- Center dot -->
<circle cx="256" cy="210" r="18" fill="#58a6ff"/>
<!-- 5 dots = 5 knobs/buttons -->
<circle cx="116" cy="390" r="14" fill="#3fb950"/>
<circle cx="176" cy="390" r="14" fill="#3fb950"/>
<circle cx="256" cy="390" r="14" fill="#58a6ff"/>
<circle cx="336" cy="390" r="14" fill="#3fb950"/>
<circle cx="396" cy="390" r="14" fill="#3fb950"/>
</svg>

After

Width:  |  Height:  |  Size: 742 B

+17
View File
@@ -0,0 +1,17 @@
{
"name": "TurnUp",
"short_name": "TurnUp",
"description": "Configure your TurnUp knob/button mixer",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+31
View File
@@ -0,0 +1,31 @@
// Network-first SW: static assets are cached after first fetch;
// API calls always go to the network.
const CACHE = 'turnup-v1';
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
// Always go network-first for API calls so the UI never serves stale data
if (e.request.url.includes('/api/')) return;
e.respondWith(
fetch(e.request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE).then((c) => c.put(e.request, clone));
return response;
})
.catch(() => caches.match(e.request))
);
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+174
View File
@@ -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} />
</>
);
}
+58
View File
@@ -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);
}
+52
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+141
View File
@@ -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>
);
}
+106
View File
@@ -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>
);
}
+32
View File
@@ -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>;
}
+356
View File
@@ -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; }
}
+18
View File
@@ -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>,
);
+35
View File
@@ -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';
}
+13
View File
@@ -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];
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: '../src/turnup/ui/static',
emptyOutDir: true,
},
server: {
port: 5174,
proxy: {
'/api': 'http://localhost:5173',
},
},
})