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
+2
-1
@@ -37,7 +37,8 @@ pkg/
|
||||
# User config (not tracked — see contrib/config.example.json)
|
||||
config.json
|
||||
|
||||
# IDE / editor
|
||||
# Node / Vite
|
||||
ui/node_modules/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
@@ -11,6 +11,8 @@ depends=(
|
||||
'python'
|
||||
'python-pyserial'
|
||||
'python-pulsectl'
|
||||
'python-fastapi'
|
||||
'python-uvicorn'
|
||||
'pipewire-pulse'
|
||||
'playerctl'
|
||||
)
|
||||
@@ -37,9 +39,11 @@ package() {
|
||||
|
||||
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||
|
||||
# Systemd user service
|
||||
# Systemd user services
|
||||
install -Dm644 contrib/turnupd.service \
|
||||
"$pkgdir/usr/lib/systemd/user/turnupd.service"
|
||||
install -Dm644 contrib/turnup-ui.service \
|
||||
"$pkgdir/usr/lib/systemd/user/turnup-ui.service"
|
||||
|
||||
# License
|
||||
install -Dm644 LICENSE \
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Turn Up web UI
|
||||
Documentation=https://github.com/sean351/turn-up-arch
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/turnup-ui
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=turnup-ui
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
+3
-1
@@ -17,12 +17,14 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0"]
|
||||
ui = ["fastapi>=0.110", "uvicorn>=0.29"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[project.scripts]
|
||||
turnupd = "turnup.turnupd:main"
|
||||
turnupd = "turnup.turnupd:main"
|
||||
turnup-ui = "turnup.ui.server:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/sean351/turn-up-arch"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# turnup.ui — web UI package
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ui/server.py — TurnUp web UI (FastAPI + uvicorn)
|
||||
|
||||
Serves a PWA at http://127.0.0.1:5173 that lets you edit
|
||||
~/.config/turnup/config.toml and manage named TOML presets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from ..config import DEFAULT_CONFIG_PATH, _XDG_CONFIG_DIR, load_config
|
||||
|
||||
log = logging.getLogger("turnup-ui")
|
||||
|
||||
PRESETS_DIR = Path(_XDG_CONFIG_DIR) / "presets"
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
# ── TOML serializer ────────────────────────────────────────────────────────────
|
||||
# tomllib (stdlib) is read-only; we write our own minimal serialiser so we
|
||||
# don't need an extra runtime dep (tomli-w).
|
||||
|
||||
_SAFE_NAME = re.compile(r"^[A-Za-z0-9 _\-\.]+$")
|
||||
|
||||
|
||||
def _s(v: str) -> str:
|
||||
"""Quote a string value for TOML."""
|
||||
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def _color(c: list[int]) -> str:
|
||||
return f"[{int(c[0])}, {int(c[1])}, {int(c[2])}]"
|
||||
|
||||
|
||||
def config_to_toml(cfg: dict) -> str:
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append(f'port = {_s(cfg.get("port", "/dev/ttyACM0"))}')
|
||||
lines.append(f'baud = {int(cfg.get("baud", 115200))}')
|
||||
lines.append("")
|
||||
|
||||
leds = cfg.get("leds") or {}
|
||||
lines.append("[leds]")
|
||||
lines.append(f'mode = {_s(leds.get("mode", "volume"))}')
|
||||
lines.append(f'low_color = {_color(leds.get("low_color", [255, 0, 0]))}')
|
||||
lines.append(f'high_color = {_color(leds.get("high_color", [0, 255, 0]))}')
|
||||
lines.append("")
|
||||
|
||||
knobs = cfg.get("knobs") or {}
|
||||
for i in range(5):
|
||||
knob = knobs.get(str(i))
|
||||
if not knob:
|
||||
continue
|
||||
action = knob.get("action", "sink_volume")
|
||||
lines.append(f"[knobs.{i}]")
|
||||
lines.append(f'action = {_s(action)}')
|
||||
if action == "group_volume":
|
||||
tgts = knob.get("targets") or []
|
||||
lines.append(f'targets = [{", ".join(_s(t) for t in tgts)}]')
|
||||
else:
|
||||
lines.append(f'target = {_s(knob.get("target", "default"))}')
|
||||
# Optional per-knob LED override (written as an inline table)
|
||||
knob_led = knob.get("led")
|
||||
if knob_led and isinstance(knob_led, dict):
|
||||
parts: list[str] = []
|
||||
if "mode" in knob_led:
|
||||
parts.append(f'mode = {_s(knob_led["mode"])}')
|
||||
if "low_color" in knob_led:
|
||||
parts.append(f'low_color = {_color(knob_led["low_color"])}')
|
||||
if "high_color" in knob_led:
|
||||
parts.append(f'high_color = {_color(knob_led["high_color"])}')
|
||||
if parts:
|
||||
lines.append(f'led = {{{", ".join(parts)}}}')
|
||||
lines.append("")
|
||||
|
||||
buttons = cfg.get("buttons") or {}
|
||||
for i in range(5):
|
||||
btn = buttons.get(str(i))
|
||||
if not btn:
|
||||
continue
|
||||
lines.append(f"[buttons.{i}]")
|
||||
lines.append(f'action = {_s(btn.get("action", "mute_sink"))}')
|
||||
lines.append(f'target = {_s(btn.get("target", "default"))}')
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── FastAPI app ────────────────────────────────────────────────────────────────
|
||||
|
||||
app = FastAPI(title="TurnUp UI", docs_url=None, redoc_url=None)
|
||||
|
||||
|
||||
# ── Config API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/config")
|
||||
def get_config() -> dict[str, Any]:
|
||||
return load_config()
|
||||
|
||||
|
||||
@app.post("/api/config")
|
||||
async def save_config(request: Request) -> dict[str, bool]:
|
||||
cfg = await request.json()
|
||||
Path(DEFAULT_CONFIG_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(DEFAULT_CONFIG_PATH).write_text(config_to_toml(cfg))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Presets API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _preset_path(name: str) -> Path:
|
||||
if not name or not _SAFE_NAME.match(name):
|
||||
raise HTTPException(status_code=400, detail="Invalid preset name — use letters, numbers, spaces, hyphens, underscores, dots only")
|
||||
return PRESETS_DIR / f"{name}.toml"
|
||||
|
||||
|
||||
@app.get("/api/presets")
|
||||
def list_presets() -> list[str]:
|
||||
PRESETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return sorted(p.stem for p in PRESETS_DIR.glob("*.toml"))
|
||||
|
||||
|
||||
@app.get("/api/presets/{name}")
|
||||
def get_preset(name: str) -> dict[str, Any]:
|
||||
path = _preset_path(name)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
return load_config(str(path))
|
||||
|
||||
|
||||
@app.post("/api/presets/{name}/save")
|
||||
async def save_preset(name: str, request: Request) -> dict[str, bool]:
|
||||
path = _preset_path(name)
|
||||
PRESETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cfg = await request.json()
|
||||
path.write_text(config_to_toml(cfg))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/presets/{name}/apply")
|
||||
def apply_preset(name: str) -> dict[str, bool]:
|
||||
path = _preset_path(name)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
cfg = load_config(str(path))
|
||||
Path(DEFAULT_CONFIG_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(DEFAULT_CONFIG_PATH).write_text(config_to_toml(cfg))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.delete("/api/presets/{name}")
|
||||
def delete_preset(name: str) -> dict[str, bool]:
|
||||
path = _preset_path(name)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
path.unlink()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Static files ───────────────────────────────────────────────────────────────
|
||||
# Must come AFTER all /api/* routes so the catch-all doesn't shadow them.
|
||||
# Vite outputs hashed assets under assets/ and references them as /assets/…
|
||||
# so we serve everything straight off STATIC_DIR at the URL root.
|
||||
|
||||
@app.get("/")
|
||||
def root() -> FileResponse:
|
||||
return FileResponse(str(STATIC_DIR / "index.html"))
|
||||
|
||||
|
||||
@app.get("/{filepath:path}")
|
||||
def static_file(filepath: str) -> FileResponse:
|
||||
path = (STATIC_DIR / filepath).resolve()
|
||||
# Safety: disallow path traversal outside STATIC_DIR
|
||||
try:
|
||||
path.relative_to(STATIC_DIR.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403)
|
||||
if not path.exists() or not path.is_file():
|
||||
# SPA fallback — return index.html for unknown paths
|
||||
return FileResponse(str(STATIC_DIR / "index.html"))
|
||||
return FileResponse(str(path))
|
||||
|
||||
|
||||
# ── Entry point ────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
log.info("TurnUp UI → http://127.0.0.1:5173")
|
||||
uvicorn.run(app, host="127.0.0.1", port=5173, log_level="warning")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
: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}*,*: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}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}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}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}#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}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px}#connection .fields{display:flex;gap:var(--gap);flex-wrap:wrap}#connection .field{flex:1;min-width:160px}#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}.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{font-size:11px;color:var(--text-muted);cursor:pointer;display:flex;align-items:center;gap:6px;-webkit-user-select:none;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 .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-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,toaLine truncated
|
||||
@@ -0,0 +1,9 @@
|
||||
(function(){const p=document.createElement("link").relList;if(p&&p.supports&&p.supports("modulepreload"))return;for(const U of document.querySelectorAll('link[rel="modulepreload"]'))v(U);new MutationObserver(U=>{for(const G of U)if(G.type==="childList")for(const J of G.addedNodes)J.tagName==="LINK"&&J.rel==="modulepreload"&&v(J)}).observe(document,{childList:!0,subtree:!0});function H(U){const G={};return U.integrity&&(G.integrity=U.integrity),U.referrerPolicy&&(G.referrerPolicy=U.referrerPolicy),U.crossOrigin==="use-credentials"?G.credentials="include":U.crossOrigin==="anonymous"?G.credentials="omit":G.credentials="same-origin",G}function v(U){if(U.ep)return;U.ep=!0;const G=H(U);fetch(U.href,G)}})();var fi={exports:{}},ze={};var Sv;function Im(){if(Sv)return ze;Sv=1;var g=Symbol.for("react.transitional.element"),p=Symbol.for("react.fragment");function H(v,U,G){var J=null;if(G!==void 0&&(J=""+G),U.key!==void 0&&(J=""+U.key),"key"in U){G={};for(var ml in U)ml!=="key"&&(G[ml]=U[ml])}else G=U;return U=G.ref,{$$typeof:g,type:v,key:J,ref:U!==void 0?U:null,props:G}}return ze.Fragment=p,ze.jsx=H,ze.jsxs=H,ze}var bv;function Pm(){return bv||(bv=1,fi.exports=Im()),fi.exports}var _=Pm(),ii={exports:{}},X={};var zv;function ly(){if(zv)return X;zv=1;var g=Symbol.for("react.transitional.element"),p=Symbol.for("react.portal"),H=Symbol.for("react.fragment"),v=Symbol.for("react.strict_mode"),U=Symbol.for("react.profiler"),G=Symbol.for("react.consumer"),J=Symbol.for("react.context"),ml=Symbol.for("react.forward_ref"),R=Symbol.for("react.suspense"),S=Symbol.for("react.memo"),Q=Symbol.for("react.lazy"),O=Symbol.for("react.activity"),Z=Symbol.iterator;function Gl(d){return d===null||typeof d!="object"?null:(d=Z&&d[Z]||d["@@iterator"],typeof d=="function"?d:null)}var Dl={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Ol=Object.assign,zt={};function Xl(d,A,D){this.props=d,this.context=A,this.refs=zt,this.updater=D||Dl}Xl.prototype.isReactComponent={},Xl.prototype.setState=function(d,A){if(typeof d!="object"&&typeof d!="function"&&d!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,d,A,"setState")},Xl.prototype.forceUpdate=function(d){this.updater.enqueueForceUpdate(this,d,"forceUpdate")};function st(){}st.prototype=Xl.prototype;function Y(d,A,D){this.props=d,this.context=A,this.refs=zt,this.updater=D||Dl}var vl=Y.prototype=new st;vl.constructor=Y,Ol(vl,Xl.prototype),vl.isPureReactComponent=!0;var Ul=Array.isArray;function xl(){}var I={H:null,A:null,T:null,S:null},Vl=Object.prototype.hasOwnProperty;function pt(d,A,D){var j=D.ref;return{$$typeof:g,type:d,key:A,ref:j!==void 0?j:null,props:D}}function Qa(d,A){return pt(d.type,A,d.props)}function Ot(d){return typeof d=="object"&&d!==null&&d.$$typeof===g}function Kl(d){var A={"=":"=0",":":"=2"};return"$"+d.replace(/[=:]/g,function(D){return A[D]})}var Ta=/\/+/g;function jt(d,A){return typeof d=="object"&&d!==null&&d.key!=null?Kl(""+d.key):A.toString(36)}function Tt(d){switch(d.status){case"fulfilled":return d.value;case"rejected":throw d.reason;default:switch(typeof d.status=="string"?d.then(xl,xl):(d.status="pending",d.then(function(A){d.status==="pending"&&(d.status="fulfilled",d.value=A)},function(A){d.status==="pending"&&(d.status="rejected",d.reason=A)})),d.status){case"fulfilled":return d.value;case"rejected":throw d.reason}}throw d}function z(d,A,D,j,L){var w=typeof d;(w==="undefined"||w==="boolean")&&(d=null);var ul=!1;if(d===null)ul=!0;else switch(w){case"bigint":case"string":case"number":ul=!0;break;case"object":switch(d.$$typeof){case g:case p:ul=!0;break;case Q:return ul=d._init,z(ul(d._payload),A,D,j,L)}}if(ul)return L=L(d),ul=j===""?"."+jt(d,0):j,Ul(L)?(D="",ul!=null&&(D=ul.replace(Ta,"$&/")+"/"),z(L,A,D,"",function(Mu){return Mu})):L!=null&&(Ot(L)&&(L=Qa(L,D+(L.key==null||d&&d.key===L.key?"":(""+L.key).replace(Ta,"$&/")+"/")+ul)),A.push(L)),1;ul=0;var Ql=j===""?".":j+":";if(Ul(d))for(var bl=0;bl<d.length;bl++)j=d[bl],w=Ql+jt(j,bl),ul+=z(j,A,D,w,L);else if(bl=Gl(d),typeof bl=="function")for(d=bl.call(d),bl=0;!(j=d.next()).done;)j=j.value,w=Ql+jt(j,bl++),ul+=z(j,A,D,w,L);else if(w==="object"){if(typeof d.then=="function")return z(Tt(d),A,D,j,L);throw A=String(d),Error("Objects are not valid as a React child (found: "+(A==="[object Object]"?"object with keys {"+Object.keys(d).join(", ")+"}":A)+"). If you meant to render a collection of children, use an array instead.")}return ul}function M(d,A,D){if(d==null)return d;var j=[],L=0;return z(d,j,"","",function(w){return A.call(D,w,L++)}),j}function x(d){if(d._status===-1){var A=d._result;A=A(),A.then(function(D){(d._status===0||d._status===-1)&&(d._status=1,d._result=D)},function(D){(d._status===0||d._status===-1)&&(d._status=2,d._result=D)}),d._status===-1&&(d._status=0,d._result=A)}if(d._status===1)return d._result.default;throw d._result}vLine truncated
|
||||
at`)?" (<anonymous>)":-1<a.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+Zn+l+gi}var Ln=!1;function Vn(l,t){if(!l||Ln)return"";Ln=!0;var a=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var u={DetermineComponentFrameRoot:function(){try{if(t){var E=function(){throw Error()};if(Object.defineProperty(E.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(E,[])}catch(r){var h=r}Reflect.construct(l,[],E)}else{try{E.call()}catch(r){h=r}l.call(E.prototype)}}else{try{throw Error()}catch(r){h=r}(E=l())&&typeof E.catch=="function"&&E.catch(function(){})}}catch(r){if(r&&h&&typeof r.stack=="string")return[r.stack,h.stack]}return[null,null]}};u.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var e=Object.getOwnPropertyDescriptor(u.DetermineComponentFrameRoot,"name");e&&e.configurable&&Object.defineProperty(u.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var n=u.DetermineComponentFrameRoot(),c=n[0],f=n[1];if(c&&f){var i=c.split(`
|
||||
`),y=f.split(`
|
||||
`);for(e=u=0;u<i.length&&!i[u].includes("DetermineComponentFrameRoot");)u++;for(;e<y.length&&!y[e].includes("DetermineComponentFrameRoot");)e++;if(u===i.length||e===y.length)for(u=i.length-1,e=y.length-1;1<=u&&0<=e&&i[u]!==y[e];)e--;for(;1<=u&&0<=e;u--,e--)if(i[u]!==y[e]){if(u!==1||e!==1)do if(u--,e--,0>e||i[u]!==y[e]){var b=`
|
||||
`+i[u].replace(" at new "," at ");return l.displayName&&b.includes("<anonymous>")&&(b=b.replace("<anonymous>",l.displayName)),b}while(1<=u&&0<=e);break}}}finally{Ln=!1,Error.prepareStackTrace=a}return(a=l?l.displayName||l.name:"")?Ea(a):""}function Uv(l,t){switch(l.tag){case 26:case 27:case 5:return Ea(l.type);case 16:return Ea("Lazy");case 13:return l.child!==t&&t!==null?Ea("Suspense Fallback"):Ea("Suspense");case 19:return Ea("SuspenseList");case 0:case 15:return Vn(l.type,!1);case 11:return Vn(l.type.render,!1);case 1:return Vn(l.type,!0);case 31:return Ea("Activity");default:return""}}function ri(l){try{var t="",a=null;do t+=Uv(l,a),a=l,l=l.return;while(l);return t}catch(u){return`
|
||||
Error generating stack: `+u.message+`
|
||||
`+u.stack}}var Kn=Object.prototype.hasOwnProperty,Jn=g.unstable_scheduleCallback,wn=g.unstable_cancelCallback,Nv=g.unstable_shouldYield,jv=g.unstable_requestPaint,Pl=g.unstable_now,Cv=g.unstable_getCurrentPriorityLevel,Si=g.unstable_ImmediatePriority,bi=g.unstable_UserBlockingPriority,Ae=g.unstable_NormalPriority,Hv=g.unstable_LowPriority,zi=g.unstable_IdlePriority,Rv=g.log,qv=g.unstable_setDisableYieldValue,Du=null,lt=null;function Ft(l){if(typeof Rv=="function"&&qv(l),lt&&typeof lt.setStrictMode=="function")try{lt.setStrictMode(Du,l)}catch{}}var tt=Math.clz32?Math.clz32:Yv,Bv=Math.log,xv=Math.LN2;function Yv(l){return l>>>=0,l===0?32:31-(Bv(l)/xv|0)|0}var _e=256,pe=262144,Oe=4194304;function Aa(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&261888;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function Me(l,t,a){var u=l.pendingLanes;if(u===0)return 0;var e=0,n=l.suspendedLanes,c=l.pingedLanes;l=l.warmLanes;var f=u&134217727;return f!==0?(u=f&~n,u!==0?e=Aa(u):(c&=f,c!==0?e=Aa(c):a||(a=f&~l,a!==0&&(e=Aa(a))))):(f=u&~n,f!==0?e=Aa(f):c!==0?e=Aa(c):a||(a=u&~l,a!==0&&(e=Aa(a)))),e===0?0:t!==0&&t!==e&&(t&n)===0&&(n=e&-e,a=t&-t,n>=a||n===32&&(a&4194048)!==0)?t:e}function Uu(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function Gv(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Ti(){var l=Oe;return Oe<<=1,(Oe&62914560)===0&&(Oe=4194304),l}function Wn(l){for(var t=[],a=0;31>a;a++)t.push(l);return t}function Nu(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function Xv(l,t,a,u,e,n){var c=l.pendingLanes;l.pendingLanes=a,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=a,l.entangledLanes&=a,l.errorRecoveryDisabledLanes&=a,l.shellSuspendCounter=0;var f=l.entanglements,i=l.expirationTimes,y=l.hiddenUpdates;for(a=c&~a;0<a;){var b=31-tt(a),E=1<<b;f[b]=0,i[b]=-1;var h=y[b];if(h!==null)for(y[b]=null,b=0;b<h.length;b++){var r=h[b];r!==null&&(r.lane&=-536870913)}a&=~E}u!==0&&Ei(l,u,0),n!==0&&e===0&&l.tag!==0&&(l.suspendedLanes|=n&~(c&~t))}function Ei(l,t,a){l.pendingLanes|=t,l.suspendedLanes&=~t;var u=31-tt(t);l.entangledLanes|=t,l.entanglements[u]=l.entanglements[u]|1073741824|a&261930}function Ai(l,t){var a=l.entangledLanes|=t;for(l=l.entanglements;a;){var u=31-tt(a),e=1<<u;e&t|l[u]&t&&(l[u]|=t),a&=~e}}function _i(l,t){var a=t&-t;return a=(a&42)!==0?1:$n(a),(a&(l.suspendedLanes|t))!==0?0:a}function $n(l){switch(l){case 2:l=1;break;case 8:l=4;break;case 32:l=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:l=128;break;case 268435456:l=134217728;break;default:l=0}return l}function Fn(l){return l&=-l,2<l?8<l?(l&134217727)!==0?32:268435456:8:2}function pi(){var l=M.p;return l!==0?l:(l=window.event,l===void 0?32:ov(l.type))}function Oi(l,t){var a=M.p;try{return M.p=l,t()}finally{M.p=a}}var kt=Math.random().toString(36).slice(2),Cl="__reactFiber$"+kt,Jl="__reactProps$"+kt,Za="__reactContainer$"+kt,kn="__reactEvents$"+kt,Qv="__reactListeners$"+kt,Zv="__reactHandles$"+kt,Mi="__reactResources$"+kt,ju="__reactMarker$"+kt;function In(l){delete l[Cl],delete l[Jl],delete l[kn],delete l[Qv],delete l[Zv]}function La(l){var t=l[Cl];if(t)return t;for(var a=l.parentNode;a;){if(t=a[Za]||a[Cl]){if(a=t.alternate,t.child!==null||a!==null&&a.child!==null)for(l=$o(l);l!==null;){if(a=l[Cl])return a;l=$o(l)}return t}l=a,a=l.parentNode}return null}function Va(l){if(l=l[Cl]||l[Za]){var t=l.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return l}return null}function Cu(l){var t=l.tag;if(t===5||t===26||t===27||t===6)return l.stateNode;throw Error(v(33))}function Ka(l){var t=l[Mi];return t||(t=l[Mi]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function Nl(l){l[ju]=!0}var Di=new Set,Ui={};function _a(l,t){Ja(l,t),Ja(l+"Capture",t)}function Ja(l,t){for(Ui[l]=t,l=0;l<t.length;l++)Di.add(t[l])}var Lv=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u21
Line truncated
|
||||
`).replace(zm,"")}function Go(l,t){return t=Yo(t),Yo(l)===t}function il(l,t,a,u,e,n){switch(a){case"children":typeof u=="string"?t==="body"||t==="textarea"&&u===""||Wa(l,u):(typeof u=="number"||typeof u=="bigint")&&t!=="body"&&Wa(l,""+u);break;case"className":Ue(l,"class",u);break;case"tabIndex":Ue(l,"tabindex",u);break;case"dir":case"role":case"viewBox":case"width":case"height":Ue(l,a,u);break;case"style":Yi(l,u,n);break;case"data":if(t!=="object"){Ue(l,"data",u);break}case"src":case"href":if(u===""&&(t!=="a"||a!=="href")){l.removeAttribute(a);break}if(u==null||typeof u=="function"||typeof u=="symbol"||typeof u=="boolean"){l.removeAttribute(a);break}u=je(""+u),l.setAttribute(a,u);break;case"action":case"formAction":if(typeof u=="function"){l.setAttribute(a,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof n=="function"&&(a==="formAction"?(t!=="input"&&il(l,t,"name",e.name,e,null),il(l,t,"formEncType",e.formEncType,e,null),il(l,t,"formMethod",e.formMethod,e,null),il(l,t,"formTarget",e.formTarget,e,null)):(il(l,t,"encType",e.encType,e,null),il(l,t,"method",e.method,e,null),il(l,t,"target",e.target,e,null)));if(u==null||typeof u=="symbol"||typeof u=="boolean"){l.removeAttribute(a);break}u=je(""+u),l.setAttribute(a,u);break;case"onClick":u!=null&&(l.onclick=Ht);break;case"onScroll":u!=null&&$("scroll",l);break;case"onScrollEnd":u!=null&&$("scrollend",l);break;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(v(61));if(a=u.__html,a!=null){if(e.children!=null)throw Error(v(60));l.innerHTML=a}}break;case"multiple":l.multiple=u&&typeof u!="function"&&typeof u!="symbol";break;case"muted":l.muted=u&&typeof u!="function"&&typeof u!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(u==null||typeof u=="function"||typeof u=="boolean"||typeof u=="symbol"){l.removeAttribute("xlink:href");break}a=je(""+u),l.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",a);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":u!=null&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(a,""+u):l.removeAttribute(a);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":u&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(a,""):l.removeAttribute(a);break;case"capture":case"download":u===!0?l.setAttribute(a,""):u!==!1&&u!=null&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(a,u):l.removeAttribute(a);break;case"cols":case"rows":case"size":case"span":u!=null&&typeof u!="function"&&typeof u!="symbol"&&!isNaN(u)&&1<=u?l.setAttribute(a,u):l.removeAttribute(a);break;case"rowSpan":case"start":u==null||typeof u=="function"||typeof u=="symbol"||isNaN(u)?l.removeAttribute(a):l.setAttribute(a,u);break;case"popover":$("beforetoggle",l),$("toggle",l),De(l,"popover",u);break;case"xlinkActuate":Ct(l,"http://www.w3.org/1999/xlink","xlink:actuate",u);break;case"xlinkArcrole":Ct(l,"http://www.w3.org/1999/xlink","xlink:arcrole",u);break;case"xlinkRole":Ct(l,"http://www.w3.org/1999/xlink","xlink:role",u);break;case"xlinkShow":Ct(l,"http://www.w3.org/1999/xlink","xlink:show",u);break;case"xlinkTitle":Ct(l,"http://www.w3.org/1999/xlink","xlink:title",u);break;case"xlinkType":Ct(l,"http://www.w3.org/1999/xlink","xlink:type",u);break;case"xmlBase":Ct(l,"http://www.w3.org/XML/1998/namespace","xml:base",u);break;case"xmlLang":Ct(l,"http://www.w3.org/XML/1998/namespace","xml:lang",u);break;case"xmlSpace":Ct(l,"http://www.w3.org/XML/1998/namespace","xml:space",u);break;case"is":De(l,"is",u);break;case"innerText":case"textContent":break;default:(!(2<a.length)||a[0]!=="o"&&a[0]!=="O"||a[1]!=="n"&&a[1]!=="N")&&(a=Wv.get(a)||a,De(l,a,u))}}function Lf(l,t,a,u,e,n){switch(a){case"style":Yi(l,u,n);break;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(v(61));if(a=u.__html,a!=null){if(e.children!=null)throw Error(v(60));l.innerHTML=a}}break;case"children":typeof u=="string"?Wa(l,u):(typeof u=="number"||typeof u=="bigint")&&Wa(l,""+u);break;case"onScroll":u!=null&&$("scroll",l);break;case"onScrollEnd":u!=null&&$("scrollend",l);break;case"onClick":u!=null&&(l.onclick=Ht);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"innerHTML"
Line truncated
|
||||
@@ -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 |
@@ -0,0 +1,16 @@
|
||||
<!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>
|
||||
<script type="module" crossorigin src="/assets/index-Dcy9vL-q.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-35WG7lRs.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
});
|
||||
@@ -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 |
+15
-8
@@ -1,25 +1,32 @@
|
||||
post_install() {
|
||||
systemctl --global enable turnupd.service
|
||||
echo "turnupd: service enabled for all users — it will start on next login."
|
||||
echo "To start it now: systemctl --user start turnupd.service"
|
||||
systemctl --global enable turnup-ui.service
|
||||
echo "turnupd + turnup-ui: services enabled for all users — they will start on next login."
|
||||
echo "To start them now:"
|
||||
echo " systemctl --user start turnupd.service"
|
||||
echo " systemctl --user start turnup-ui.service"
|
||||
echo "UI will be available at http://127.0.0.1:5173"
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
systemctl --global reenable turnupd.service
|
||||
systemctl --global reenable turnup-ui.service
|
||||
# Restart for all currently logged-in users
|
||||
while read -r uid _; do
|
||||
systemctl -M "${uid}@.host" --user restart turnupd.service 2>/dev/null || true
|
||||
systemctl -M "${uid}@.host" --user restart turnupd.service 2>/dev/null || true
|
||||
systemctl -M "${uid}@.host" --user restart turnup-ui.service 2>/dev/null || true
|
||||
done < <(loginctl list-users --no-legend 2>/dev/null)
|
||||
}
|
||||
|
||||
pre_remove() {
|
||||
# Stop and disable for all currently logged-in users
|
||||
while read -r uid _; do
|
||||
systemctl -M "${uid}@.host" --user stop turnupd.service 2>/dev/null || true
|
||||
systemctl -M "${uid}@.host" --user disable turnupd.service 2>/dev/null || true
|
||||
systemctl -M "${uid}@.host" --user stop turnupd.service 2>/dev/null || true
|
||||
systemctl -M "${uid}@.host" --user disable turnupd.service 2>/dev/null || true
|
||||
systemctl -M "${uid}@.host" --user stop turnup-ui.service 2>/dev/null || true
|
||||
systemctl -M "${uid}@.host" --user disable turnup-ui.service 2>/dev/null || true
|
||||
done < <(loginctl list-users --no-legend 2>/dev/null)
|
||||
|
||||
# Remove the global enable symlink
|
||||
# (/etc/systemd/user/default.target.wants/turnupd.service)
|
||||
systemctl --global disable turnupd.service 2>/dev/null || true
|
||||
systemctl --global disable turnupd.service 2>/dev/null || true
|
||||
systemctl --global disable turnup-ui.service 2>/dev/null || true
|
||||
}
|
||||
@@ -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?
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
+3286
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user