Private
Public Access
230 lines
11 KiB
HTML
230 lines
11 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, input, a.download { border: 1px solid #5c6c67; border-radius: 7px; background: #293938; color: #f4f5ed; font: inherit; padding: .65rem .8rem; }
|
||
button, select, a.download { cursor: pointer; }
|
||
button:hover, a.download:hover { background: #36504a; }
|
||
button:focus-visible, select:focus-visible, input: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; }
|
||
.events { grid-column: 1 / -1; }
|
||
#events-list { margin: .5rem 0 0; padding-left: 1.4rem; }
|
||
#events-list li { margin: .35rem 0; }
|
||
#profiles-list li { margin: .35rem 0; }
|
||
.event-tools { margin: .3rem 0 .8rem; }
|
||
@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>
|
||
<section class="events">
|
||
<h2>Gespeicherte Testereignisse</h2>
|
||
<p class="muted">Erfolgreiche Testauslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
|
||
<ul id="events-list"><li>Lädt …</li></ul>
|
||
</section>
|
||
<section class="events">
|
||
<h2>Personenprofile</h2>
|
||
<p class="notice">Die automatische Wiedererkennung per Kamera ist noch nicht aktiv. Diese Profile sind derzeit nur manuell angelegte Kennungen; Bilder oder Gesichtsmerkmale werden nicht gespeichert.</p>
|
||
<div class="row">
|
||
<input id="profile-label" type="text" maxlength="80" aria-label="Profilkennung" placeholder="Profilkennung oder Spitzname">
|
||
<button id="create-profile">Profil anlegen</button>
|
||
</div>
|
||
<ul id="profiles-list"><li>Lädt …</li></ul>
|
||
</section>
|
||
</main>
|
||
<script>
|
||
const $ = id => document.getElementById(id);
|
||
const names = { voll: 'Voller Schreck', sanft: 'Sanft', atmosphaere: 'Atmosphäre', aus: 'Aus' };
|
||
let profiles = [];
|
||
async function api(path, body = {}, method = 'POST') {
|
||
const response = await fetch(path, {
|
||
method, 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();
|
||
await refreshDb();
|
||
} catch (error) { $('message').textContent = error.message; }
|
||
}
|
||
async function refreshProfiles() {
|
||
const response = await fetch('/api/profiles', { cache: 'no-store' });
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.detail || 'Profile nicht verfügbar');
|
||
profiles = data.profiles;
|
||
const list = $('profiles-list');
|
||
list.replaceChildren();
|
||
if (!profiles.length) {
|
||
const item = document.createElement('li');
|
||
item.textContent = 'Noch keine Profile';
|
||
list.append(item);
|
||
}
|
||
for (const profile of profiles) {
|
||
const item = document.createElement('li');
|
||
const label = document.createElement('span');
|
||
label.textContent = '#' + profile.id + ' · ' + profile.label + ' · ' + profile.event_count + ' Ereignisse ';
|
||
const remove = document.createElement('button');
|
||
remove.textContent = 'Löschen';
|
||
remove.onclick = async () => {
|
||
if (!confirm('Profil und seine Zuordnungen entfernen? Die Ereignisse bleiben erhalten.')) return;
|
||
try {
|
||
await api('/api/profiles/' + profile.id, {}, 'DELETE');
|
||
await refreshDb();
|
||
} catch (error) { $('message').textContent = error.message; }
|
||
};
|
||
item.append(label, remove);
|
||
list.append(item);
|
||
}
|
||
}
|
||
async function refreshEvents() {
|
||
try {
|
||
const response = await fetch('/api/events', { cache: 'no-store' });
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.detail || 'Ereignisse nicht verfügbar');
|
||
const list = $('events-list');
|
||
list.replaceChildren();
|
||
if (!data.events.length) {
|
||
const item = document.createElement('li');
|
||
item.textContent = 'Noch keine Testauslösung gespeichert';
|
||
list.append(item);
|
||
}
|
||
for (const event of data.events) {
|
||
const item = document.createElement('li');
|
||
const summary = document.createElement('span');
|
||
summary.textContent = '#' + event.id + ' · ' + new Date(event.occurred_at).toLocaleString('de-DE') + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : '');
|
||
const tools = document.createElement('div');
|
||
tools.className = 'row event-tools';
|
||
const select = document.createElement('select');
|
||
select.setAttribute('aria-label', 'Profil für Ereignis ' + event.id);
|
||
select.add(new Option('Ohne Profil', ''));
|
||
for (const profile of profiles) select.add(new Option('#' + profile.id + ' · ' + profile.label, String(profile.id)));
|
||
select.value = event.profile_id === null ? '' : String(event.profile_id);
|
||
const assign = document.createElement('button');
|
||
assign.textContent = 'Zuordnen';
|
||
assign.onclick = async () => {
|
||
try {
|
||
await api('/api/events/' + event.id + '/profile', { profile_id: select.value ? Number(select.value) : null }, 'PATCH');
|
||
await refreshDb();
|
||
} catch (error) { $('message').textContent = error.message; }
|
||
};
|
||
tools.append(select, assign);
|
||
item.append(summary, tools);
|
||
list.append(item);
|
||
}
|
||
} catch (error) { $('events-list').textContent = error.message; }
|
||
}
|
||
async function refreshDb() {
|
||
try {
|
||
await refreshProfiles();
|
||
await refreshEvents();
|
||
} catch (error) {
|
||
$('profiles-list').textContent = error.message;
|
||
$('events-list').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');
|
||
$('create-profile').onclick = async () => {
|
||
try {
|
||
await api('/api/profiles', { label: $('profile-label').value });
|
||
$('profile-label').value = '';
|
||
await refreshDb();
|
||
} catch (error) { $('message').textContent = error.message; }
|
||
};
|
||
refresh();
|
||
refreshDb();
|
||
setInterval(refresh, 1000);
|
||
setInterval(refreshDb, 15000);
|
||
</script>
|
||
</body>
|
||
</html>
|