initial commit

This commit is contained in:
2026-08-01 12:07:22 +02:00
commit 40260afeeb
8 changed files with 562 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0b0f14;
color: #f4f7fb;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(70, 120, 255, .14), transparent 32rem),
#0b0f14;
}
button { font: inherit; }
.shell {
width: min(980px, calc(100% - 32px));
margin: 0 auto;
padding: 48px 0 64px;
}
.header, .section-title, .control-card, .footer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.header { margin-bottom: 24px; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 6px; font-size: clamp(2rem, 5vw, 3.8rem); }
h2 { margin-bottom: 8px; }
p { color: #a9b4c2; line-height: 1.55; }
.eyebrow {
margin-bottom: 8px;
color: #7ea2ff;
font-size: .76rem;
font-weight: 800;
letter-spacing: .14em;
text-transform: uppercase;
}
.subtitle { margin-bottom: 0; }
code { color: #d7e2ff; }
.panel {
border: 1px solid #26313d;
border-radius: 24px;
background: rgba(20, 27, 35, .88);
box-shadow: 0 24px 80px rgba(0, 0, 0, .28);
padding: 28px;
}
.badge {
flex: 0 0 auto;
border-radius: 999px;
padding: 9px 14px;
font-size: .86rem;
font-weight: 800;
}
.badge--checking { background: #40391e; color: #f8d56b; }
.badge--online { background: #163b2a; color: #72e2a8; }
.badge--offline { background: #4a2025; color: #ff9da7; }
.section-title strong {
border-radius: 999px;
background: #1a222d;
padding: 9px 14px;
text-transform: capitalize;
}
.traffic-light {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-top: 28px;
}
.lamp {
border: 1px solid #303b47;
border-radius: 20px;
background: #111820;
color: #dfe6ef;
cursor: pointer;
padding: 24px 12px 18px;
transition: transform .16s ease, border-color .16s ease, background .16s ease;
}
.lamp:hover { transform: translateY(-2px); border-color: #627086; }
.lamp:focus-visible, .toggle:focus-visible, .danger-button:focus-visible {
outline: 3px solid #7ea2ff;
outline-offset: 3px;
}
.lamp span {
display: block;
width: clamp(76px, 13vw, 130px);
aspect-ratio: 1;
margin: 0 auto 18px;
border-radius: 50%;
background: #242c35;
box-shadow: inset 0 0 30px rgba(0,0,0,.8);
opacity: .24;
}
.lamp.is-active { background: #17202a; border-color: #738197; }
.lamp.is-active span { opacity: 1; }
.lamp--red.is-active span { background: #ff3b4f; box-shadow: 0 0 42px rgba(255,59,79,.75); }
.lamp--yellow.is-active span { background: #ffd23f; box-shadow: 0 0 42px rgba(255,210,63,.72); }
.lamp--green.is-active span { background: #31df78; box-shadow: 0 0 42px rgba(49,223,120,.72); }
.lamp:disabled, .toggle:disabled, .danger-button:disabled { cursor: wait; opacity: .62; }
.controls-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
margin-top: 20px;
}
.control-card p { margin-bottom: 0; }
.toggle {
min-width: 94px;
border: 0;
background: transparent;
color: #dbe4ee;
cursor: pointer;
}
.toggle__track {
display: block;
width: 66px;
height: 36px;
margin: 0 auto 8px;
border-radius: 999px;
background: #3a4552;
padding: 4px;
transition: background .18s ease;
}
.toggle__thumb {
display: block;
width: 28px;
height: 28px;
border-radius: 50%;
background: white;
transition: transform .18s ease;
}
.toggle[aria-checked="true"] .toggle__track { background: #2dca70; }
.toggle[aria-checked="true"] .toggle__thumb { transform: translateX(30px); }
.footer-actions { margin-top: 20px; }
.danger-button {
border: 1px solid #a8404c;
border-radius: 14px;
background: #511e25;
color: #ffdfe2;
cursor: pointer;
font-weight: 800;
padding: 13px 18px;
}
.message { margin: 0; text-align: right; }
.message.is-error { color: #ff9da7; }
@media (max-width: 700px) {
.shell { padding-top: 24px; }
.header, .section-title, .control-card, .footer-actions { align-items: flex-start; }
.header, .control-card, .footer-actions { flex-direction: column; }
.controls-grid { grid-template-columns: 1fr; }
.traffic-light { gap: 10px; }
.panel { padding: 20px; border-radius: 18px; }
.lamp { padding-inline: 6px; }
.message { text-align: left; }
}
+137
View File
@@ -0,0 +1,137 @@
const state = {
activeColor: null,
alertActive: false,
demoActive: false,
busy: false,
};
const lamps = [...document.querySelectorAll(".lamp")];
const alertToggle = document.getElementById("alertToggle");
const demoToggle = document.getElementById("demoToggle");
const allOffButton = document.getElementById("allOffButton");
const activeColorText = document.getElementById("activeColorText");
const alertToggleLabel = document.getElementById("alertToggleLabel");
const demoToggleLabel = document.getElementById("demoToggleLabel");
const connectionBadge = document.getElementById("connectionBadge");
const message = document.getElementById("message");
function setMessage(text, isError = false) {
message.textContent = text;
message.classList.toggle("is-error", isError);
}
function setConnection(online) {
connectionBadge.textContent = online ? "API online" : "API offline";
connectionBadge.className = `badge ${online ? "badge--online" : "badge--offline"}`;
}
function render() {
lamps.forEach((lamp) => {
const active = !state.demoActive && lamp.dataset.color === state.activeColor;
lamp.classList.toggle("is-active", active);
lamp.setAttribute("aria-pressed", String(active));
lamp.disabled = state.busy;
});
activeColorText.textContent = state.demoActive
? "Demo running"
: (state.activeColor || "Off");
alertToggle.setAttribute("aria-checked", String(state.alertActive));
alertToggleLabel.textContent = state.alertActive ? "On" : "Off";
alertToggle.disabled = state.busy;
demoToggle.setAttribute("aria-checked", String(state.demoActive));
demoToggleLabel.textContent = state.demoActive ? "On" : "Off";
demoToggle.disabled = state.busy;
allOffButton.disabled = state.busy;
}
function applyApiState(payload) {
if (Object.hasOwn(payload, "active_color")) state.activeColor = payload.active_color;
if (Object.hasOwn(payload, "alert_active")) state.alertActive = Boolean(payload.alert_active);
if (Object.hasOwn(payload, "demo_active")) state.demoActive = Boolean(payload.demo_active);
}
async function apiCall(path, options = {}) {
const result = await fetch(path, {
method: options.method || "GET",
headers: { "Accept": "application/json" },
});
const payload = await result.json().catch(() => ({}));
if (!result.ok) {
throw new Error(payload.error || payload.detail || `Request failed (${result.status})`);
}
return payload;
}
async function runAction(action, successMessage) {
if (state.busy) return;
state.busy = true;
render();
setMessage("Sending command…");
try {
const payload = await action();
applyApiState(payload);
setConnection(true);
setMessage(successMessage);
} catch (error) {
setConnection(false);
setMessage(error.message, true);
} finally {
state.busy = false;
render();
}
}
lamps.forEach((lamp) => {
lamp.addEventListener("click", () => {
const color = lamp.dataset.color;
runAction(
() => apiCall(`/ui-api/color/${color}`, { method: "POST" }),
`${color[0].toUpperCase()}${color.slice(1)} light activated.`,
);
});
});
alertToggle.addEventListener("click", () => {
const nextState = state.alertActive ? "off" : "on";
runAction(
() => apiCall(`/ui-api/alert/${nextState}`, { method: "POST" }),
`Alert turned ${nextState}.`,
);
});
demoToggle.addEventListener("click", () => {
const nextState = state.demoActive ? "off" : "on";
runAction(
() => apiCall(`/ui-api/demo/${nextState}`, { method: "POST" }),
`Demo mode turned ${nextState}.`,
);
});
allOffButton.addEventListener("click", () => {
runAction(
() => apiCall("/ui-api/off", { method: "POST" }),
"All outputs turned off.",
);
});
async function refreshStatus({ quiet = false } = {}) {
if (state.busy) return;
try {
const payload = await apiCall("/ui-api/status");
applyApiState(payload);
setConnection(true);
if (!quiet) setMessage("Status synchronized.");
render();
} catch (error) {
setConnection(false);
if (!quiet) setMessage(error.message, true);
}
}
refreshStatus();
setInterval(() => refreshStatus({ quiet: true }), 1500);
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB