Private
Public Access
124 lines
6.0 KiB
HTML
124 lines
6.0 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Halloween-Grab · Steuerung</title>
|
||
<style>
|
||
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #111718; color: #eef0e9; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; }
|
||
header { padding: 1.1rem max(1rem, calc((100vw - 1120px)/2)); background: #182122; border-bottom: 1px solid #3a4644; }
|
||
h1 { margin: 0; font-size: 1.5rem; }
|
||
header p { margin: .35rem 0 0; color: #c1cbc2; }
|
||
main { max-width: 1120px; margin: auto; padding: 1rem; display: grid; gap: 1rem; grid-template-columns: minmax(0, 2fr) minmax(290px, 1fr); }
|
||
section { background: #1a2424; border: 1px solid #394846; border-radius: 12px; padding: 1rem; }
|
||
h2 { margin: 0 0 .7rem; font-size: 1.1rem; }
|
||
.camera { width: 100%; aspect-ratio: 4/3; object-fit: contain; background: #050808; border-radius: 8px; }
|
||
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||
button, select, a.download { border: 1px solid #5c6c67; border-radius: 7px; background: #293938; color: #f4f5ed; font: inherit; padding: .65rem .8rem; cursor: pointer; }
|
||
button:hover, a.download:hover { background: #36504a; }
|
||
button:focus-visible, select:focus-visible, a:focus-visible { outline: 3px solid #ffb44f; outline-offset: 2px; }
|
||
button.danger { background: #782c26; border-color: #c76652; font-weight: 700; }
|
||
button.danger:hover { background: #9c352c; }
|
||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||
a.download { display: inline-block; text-decoration: none; }
|
||
.value { font-weight: 650; color: #ffca78; }
|
||
.notice { border-left: 4px solid #ffb44f; padding: .55rem .8rem; background: #302b21; margin: .8rem 0; }
|
||
.muted { color: #bfcbc6; }
|
||
#message { min-height: 1.5rem; color: #ffca78; }
|
||
dl { display: grid; grid-template-columns: auto 1fr; gap: .4rem .8rem; margin: .5rem 0 1rem; }
|
||
dt { color: #adbfba; } dd { margin: 0; }
|
||
@media (max-width: 760px) { main { grid-template-columns: 1fr; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Halloween-Grab</h1>
|
||
<p>Lokale Kameraansicht und Steuerung</p>
|
||
</header>
|
||
<main>
|
||
<section>
|
||
<h2>Livebild</h2>
|
||
<img class="camera" src="/stream.mjpg" alt="Livebild der Kamera">
|
||
<p class="muted" id="camera-status">Kamera wird gestartet …</p>
|
||
<a class="download" href="/snapshot.jpg" download="halloween-snapshot.jpg">Aktuelles Bild herunterladen</a>
|
||
<p class="notice">Aufnahmen und Personenerkennung sind noch nicht aktiviert. Der Aufnahmebereich muss vor dem Außeneinsatz festgelegt werden.</p>
|
||
</section>
|
||
<section>
|
||
<h2>Betrieb</h2>
|
||
<dl>
|
||
<dt>Modus</dt><dd class="value" id="mode-label">Lädt …</dd>
|
||
<dt>Zustand</dt><dd id="phase">Lädt …</dd>
|
||
<dt>Sperrzeit</dt><dd id="cooldown">–</dd>
|
||
<dt>Letzter Test</dt><dd id="last-event">–</dd>
|
||
</dl>
|
||
<label for="mode">Modus wählen</label>
|
||
<div class="row" style="margin: .5rem 0 1rem">
|
||
<select id="mode">
|
||
<option value="voll">Voller Schreck</option>
|
||
<option value="sanft">Sanft</option>
|
||
<option value="atmosphaere">Atmosphäre</option>
|
||
<option value="aus">Aus</option>
|
||
</select>
|
||
<button id="save-mode">Übernehmen</button>
|
||
</div>
|
||
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Es gibt noch keine automatische Personenauslösung.</p>
|
||
<div class="row">
|
||
<button id="zone-clear">Bereich frei (Test)</button>
|
||
<button id="test-trigger">Testauslösung</button>
|
||
</div>
|
||
<div class="row" style="margin-top: 1rem">
|
||
<button class="danger" id="stop">Stopp</button>
|
||
<button id="reset">Nach Stopp zurücksetzen</button>
|
||
</div>
|
||
<p id="message" role="status" aria-live="polite"></p>
|
||
</section>
|
||
</main>
|
||
<script>
|
||
const $ = id => document.getElementById(id);
|
||
const names = { voll: 'Voller Schreck', sanft: 'Sanft', atmosphaere: 'Atmosphäre', aus: 'Aus' };
|
||
async function api(path, body = {}) {
|
||
const response = await fetch(path, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Halloween-UI': '1' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.detail || 'Anfrage fehlgeschlagen');
|
||
return data;
|
||
}
|
||
async function refresh() {
|
||
try {
|
||
const response = await fetch('/api/status', { cache: 'no-store' });
|
||
const data = await response.json();
|
||
const c = data.controller, cam = data.camera;
|
||
$('mode-label').textContent = names[c.mode] || c.mode;
|
||
if (document.activeElement !== $('mode')) $('mode').value = c.mode;
|
||
$('phase').textContent = c.phase;
|
||
$('cooldown').textContent = c.cooldown_seconds ? c.cooldown_seconds + ' s' : '–';
|
||
$('last-event').textContent = c.last_event ? c.last_event.time + ' · ' + names[c.last_event.mode] : '–';
|
||
$('camera-status').textContent = cam.error || (cam.ready ? cam.source + ' · Bild aktiv' : cam.source + ' · wartet auf Bild');
|
||
if (c.config_error) $('message').textContent = c.config_error;
|
||
} catch (error) {
|
||
$('camera-status').textContent = 'Server nicht erreichbar';
|
||
$('message').textContent = error.message;
|
||
}
|
||
}
|
||
async function action(path, body) {
|
||
try {
|
||
const data = await api(path, body);
|
||
$('message').textContent = data.reason || 'Einstellung übernommen';
|
||
await refresh();
|
||
} catch (error) { $('message').textContent = error.message; }
|
||
}
|
||
$('save-mode').onclick = () => action('/api/mode', { mode: $('mode').value });
|
||
$('zone-clear').onclick = () => action('/api/zone-clear');
|
||
$('test-trigger').onclick = () => action('/api/test-trigger');
|
||
$('stop').onclick = () => action('/api/stop');
|
||
$('reset').onclick = () => action('/api/reset');
|
||
refresh();
|
||
setInterval(refresh, 1000);
|
||
</script>
|
||
</body>
|
||
</html>
|