Merge pull request #4 from sean351/feat/vite-ui

feat: PWA web UI + LED flicker fix
This commit is contained in:
Sean authored and GitHub committed 2026-02-27 21:26:54 -05:00
commit bf034fdefe
42 files changed
+5036 -7

No files matched your search

+2 -1
View File
@@ -37,7 +37,8 @@ pkg/
# User config (not tracked — see contrib/config.example.json) # User config (not tracked — see contrib/config.example.json)
config.json config.json
# IDE / editor # Node / Vite
ui/node_modules/
.vscode/ .vscode/
.idea/ .idea/
*.swp *.swp
+5 -1
View File
@@ -11,6 +11,8 @@ depends=(
'python' 'python'
'python-pyserial' 'python-pyserial'
'python-pulsectl' 'python-pulsectl'
'python-fastapi'
'python-uvicorn'
'pipewire-pulse' 'pipewire-pulse'
'playerctl' 'playerctl'
) )
@@ -37,9 +39,11 @@ package() {
python -m installer --destdir="$pkgdir" dist/*.whl python -m installer --destdir="$pkgdir" dist/*.whl
# Systemd user service # Systemd user services
install -Dm644 contrib/turnupd.service \ install -Dm644 contrib/turnupd.service \
"$pkgdir/usr/lib/systemd/user/turnupd.service" "$pkgdir/usr/lib/systemd/user/turnupd.service"
install -Dm644 contrib/turnup-ui.service \
"$pkgdir/usr/lib/systemd/user/turnup-ui.service"
# License # License
install -Dm644 LICENSE \ install -Dm644 LICENSE \
+18
View File
@@ -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
+2
View File
@@ -17,12 +17,14 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = ["pytest>=8.0"] dev = ["pytest>=8.0"]
ui = ["fastapi>=0.110", "uvicorn>=0.29"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
[project.scripts] [project.scripts]
turnupd = "turnup.turnupd:main" turnupd = "turnup.turnupd:main"
turnup-ui = "turnup.ui.server:main"
[project.urls] [project.urls]
Homepage = "https://github.com/sean351/turn-up-arch" Homepage = "https://github.com/sean351/turn-up-arch"
+3 -1
View File
@@ -397,7 +397,9 @@ def main() -> None:
msg["id"], msg["action"], config, pulse msg["id"], msg["action"], config, pulse
) )
elif msg["type"] == "heartbeat": elif msg["type"] == "heartbeat":
send_leds(ser, all_led_colors(config, knob_norms)) new_colors = all_led_colors(config, knob_norms)
send_leds(ser, new_colors)
last_led_colors[:] = new_colors
# Check for config changes every 2 s (serial read timeout = 0.1 s). # Check for config changes every 2 s (serial read timeout = 0.1 s).
now = time.monotonic() now = time.monotonic()
+1
View File
@@ -0,0 +1 @@
# turnup.ui — web UI package
+204
View File
@@ -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
+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

+16
View File
@@ -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>
+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

+11 -4
View File
@@ -1,14 +1,20 @@
post_install() { post_install() {
systemctl --global enable turnupd.service systemctl --global enable turnupd.service
echo "turnupd: service enabled for all users — it will start on next login." systemctl --global enable turnup-ui.service
echo "To start it now: systemctl --user start turnupd.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() { post_upgrade() {
systemctl --global reenable turnupd.service systemctl --global reenable turnupd.service
systemctl --global reenable turnup-ui.service
# Restart for all currently logged-in users # Restart for all currently logged-in users
while read -r uid _; do 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) done < <(loginctl list-users --no-legend 2>/dev/null)
} }
@@ -17,9 +23,10 @@ pre_remove() {
while read -r uid _; do while read -r uid _; do
systemctl -M "${uid}@.host" --user stop 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 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) 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
} }
+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',
},
},
})