workable POC

This commit is contained in:
2026-08-09 16:32:42 +02:00
commit f0c527f355
10 changed files with 1293 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
:root { color-scheme: dark; background: #080a0f; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; }
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body { margin: 0; background: #080a0f; color: #f7f8fc; }
.dashboard { min-height: 100vh; min-height: 100dvh; padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); }
.button-grid { height: calc(100dvh - max(24px, env(safe-area-inset-top)) - max(24px, env(safe-area-inset-bottom))); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(4, minmax(0, 1fr)); gap: 12px; }
.group-button { appearance: none; border: 1px solid #252a36; border-radius: 20px; background: linear-gradient(145deg, #1a1e28, #10131b); color: inherit; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; font: inherit; font-size: clamp(1.05rem, 5vw, 1.5rem); font-weight: 650; letter-spacing: -.02em; touch-action: manipulation; transition: transform .12s ease, background .2s ease, border-color .2s ease, box-shadow .2s ease; }
.group-button small { color: #8c94a6; font-size: .76rem; font-weight: 600; line-height: 1.2; text-align: center; }
.group-button.is-on { border-color: #3196ff; box-shadow: 0 0 0 2px #1677d8, 0 0 20px #1264ba55; }
.group-button:active { transform: scale(.975); background: #222938; }
.group-button:focus-visible { outline: 3px solid #f3f7ff; outline-offset: 3px; }
.group-button[disabled] { opacity: .5; }
.status { position: fixed; z-index: 1; bottom: calc(env(safe-area-inset-bottom) + 10px); left: 50%; margin: 0; padding: 6px 10px; border-radius: 999px; background: #252a36dd; color: #cbd2df; font-size: .75rem; transform: translateX(-50%); opacity: 0; transition: opacity .2s; pointer-events: none; white-space: nowrap; }
.status.visible { opacity: 1; }
@media (orientation: landscape) { .button-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); } }
+102
View File
@@ -0,0 +1,102 @@
const status = document.querySelector('#status');
const buttons = [...document.querySelectorAll('.group-button')];
const apiPrefix = document.body.dataset.apiPrefix;
const url = new URL(window.location.href);
const suppliedKey = url.searchParams.get('key');
if (suppliedKey) {
sessionStorage.setItem('fasthue-api-key', suppliedKey);
url.searchParams.delete('key');
history.replaceState({}, '', url);
}
const apiKey = sessionStorage.getItem('fasthue-api-key');
function apiFetch(path, options = {}) {
return fetch(path, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${apiKey || ''}` },
});
}
function showStatus(message, persistent = false) {
status.textContent = message;
status.classList.add('visible');
if (!persistent) window.setTimeout(() => status.classList.remove('visible'), 1800);
}
async function refresh() {
try {
const response = await apiFetch(`${apiPrefix}/groups`, { cache: 'no-store' });
const body = await response.json();
if (!response.ok) throw new Error(body.error || 'Could not check the bridge');
const states = new Map(body.groups.map(group => [group.id, group]));
buttons.forEach(button => {
const group = states.get(button.dataset.id);
button.classList.toggle('is-on', group?.on === true);
button.querySelector('small').textContent = group?.scene || '';
});
status.classList.remove('visible');
} catch (error) {
showStatus(error.message, true);
}
}
async function activateNextScene(button) {
button.disabled = true;
try {
const response = await apiFetch(`${apiPrefix}/groups/${button.dataset.id}/next-scene`, { method: 'POST' });
const body = await response.json();
if (!response.ok) throw new Error(body.error || 'Could not control the bridge');
showStatus(`${button.querySelector('span').textContent}: ${body.scene}`);
button.querySelector('small').textContent = body.scene;
window.setTimeout(refresh, 350);
} catch (error) {
showStatus(error.message, true);
} finally {
button.disabled = false;
}
}
async function turnOff(button) {
button.disabled = true;
try {
const response = await apiFetch(`${apiPrefix}/groups/${button.dataset.id}/off`, { method: 'POST' });
const body = await response.json();
if (!response.ok) throw new Error(body.error || 'Could not turn off the lights');
button.classList.remove('is-on');
button.querySelector('small').textContent = 'Lights off';
showStatus(`${button.querySelector('span').textContent}: off`);
window.setTimeout(refresh, 350);
} catch (error) {
showStatus(error.message, true);
} finally {
button.disabled = false;
}
}
buttons.forEach(button => {
let pressTimer;
let longPressHandled = false;
const clearPressTimer = () => window.clearTimeout(pressTimer);
button.addEventListener('pointerdown', () => {
longPressHandled = false;
pressTimer = window.setTimeout(() => {
longPressHandled = true;
turnOff(button);
}, 650);
});
button.addEventListener('pointerup', clearPressTimer);
button.addEventListener('pointercancel', clearPressTimer);
button.addEventListener('pointerleave', clearPressTimer);
button.addEventListener('click', event => {
if (longPressHandled) {
event.preventDefault();
longPressHandled = false;
return;
}
activateNextScene(button);
});
});
refresh();
window.setInterval(refresh, 15000);