Neues Web-Frontend mit Statusanzeige, Vorschau-Leiste und relativen Links
This commit is contained in:
1 parent
cb21f948ee
commit
aded536568
3 files changed
+202
-23
No files matched your search
+166
@@ -0,0 +1,166 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Webcams</title>
|
||||
<style>
|
||||
:root{--bg:#f4f5f7;--card:#fff;--fg:#1b1f24;--muted:#6a737d;--line:#dfe2e6;--accent:#2563eb;--ok:#16a34a;--bad:#dc2626}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#0f1114;--card:#181b20;--fg:#e8eaed;--muted:#8b949e;--line:#2a2f36;--accent:#6ea8ff;--ok:#3fb950;--bad:#f85149}}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
header{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--line);background:var(--card);position:sticky;top:0;z-index:5}
|
||||
header h1{font-size:17px;margin:0;font-weight:600}
|
||||
header .sp{flex:1}
|
||||
#clock{color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
#banner{display:none;background:var(--bad);color:#fff;padding:8px 16px;text-align:center}
|
||||
main{padding:16px;max-width:1800px;margin:0 auto}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px}
|
||||
.tile{margin:0;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;position:relative}
|
||||
.view{position:relative;background:#000;aspect-ratio:16/9}
|
||||
.view img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:block}
|
||||
.overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.65);color:#fff;font-weight:500}
|
||||
.tile.off .overlay{display:flex}
|
||||
.cap{display:flex;align-items:center;gap:8px;padding:8px 12px}
|
||||
.cap .name{font-weight:600}
|
||||
.dot{width:9px;height:9px;border-radius:50%;background:var(--muted)}
|
||||
.tile.on .dot{background:var(--ok)}
|
||||
.tile.off .dot{background:var(--bad)}
|
||||
.state{color:var(--muted);font-size:13px}
|
||||
.cap .sp{flex:1}
|
||||
.btn{border:1px solid var(--line);background:transparent;color:var(--fg);border-radius:6px;padding:4px 9px;font:inherit;font-size:13px;cursor:pointer}
|
||||
.btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.focus .tile.big .view{aspect-ratio:auto;height:min(75vh,56.25vw)}
|
||||
.strip{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;margin-top:16px}
|
||||
.strip .cap{padding:6px 10px;font-size:13px}
|
||||
.tile:fullscreen{border:0;border-radius:0;display:flex;flex-direction:column;background:#000}
|
||||
.tile:fullscreen .view{flex:1;aspect-ratio:auto}
|
||||
.tile:fullscreen .cap{background:var(--card)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="__ROOT__" style="color:inherit">Webcams</a></h1>
|
||||
<span class="sp"></span>
|
||||
<span id="clock"></span>
|
||||
</header>
|
||||
<div id="banner">Relay nicht erreichbar – neuer Versuch …</div>
|
||||
<main id="app"></main>
|
||||
<script>
|
||||
const ROOT = __ROOT__, CURRENT = __CURRENT__;
|
||||
const app = document.getElementById("app");
|
||||
const banner = document.getElementById("banner");
|
||||
const tiles = {};
|
||||
let order = [], visible = true;
|
||||
|
||||
const el = (tag, cls, text) => {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text) e.textContent = text;
|
||||
return e;
|
||||
};
|
||||
const btn = (label, fn) => { const b = el("button", "btn", label); b.onclick = fn; return b; };
|
||||
|
||||
// mode: "live" = MJPEG-Stream, "thumb" = Einzelbild im Intervall
|
||||
function makeTile(name, mode) {
|
||||
const t = el("figure", "tile");
|
||||
const view = el("div", "view");
|
||||
const img = el("img");
|
||||
img.alt = name;
|
||||
const ov = el("div", "overlay", "Kamera offline");
|
||||
view.append(img, ov);
|
||||
|
||||
const cap = el("figcaption", "cap");
|
||||
const link = el("a", "name", name);
|
||||
link.href = ROOT + name + "/";
|
||||
const state = el("span", "state", "verbinde …");
|
||||
const actions = el("span", "actions");
|
||||
const snap = btn("Foto", () => {
|
||||
const a = el("a");
|
||||
a.href = ROOT + name + "/snapshot.jpg?t=" + Date.now();
|
||||
a.download = name + "-" + new Date().toISOString().replace(/[:.]/g, "-") + ".jpg";
|
||||
a.click();
|
||||
});
|
||||
const full = btn("Vollbild", () => document.fullscreenElement ? document.exitFullscreen() : t.requestFullscreen());
|
||||
actions.append(snap, " ", full);
|
||||
cap.append(el("span", "dot"), link, state, el("span", "sp"), actions);
|
||||
t.append(view, cap);
|
||||
|
||||
const o = { name, mode, t, img, state, online: null, timer: null, retry: null };
|
||||
img.onerror = () => {
|
||||
if (!visible || o.online === false) return;
|
||||
clearTimeout(o.retry);
|
||||
o.retry = setTimeout(() => connect(o), 3000);
|
||||
};
|
||||
tiles[name] = o;
|
||||
return t;
|
||||
}
|
||||
|
||||
function connect(o) {
|
||||
clearTimeout(o.retry);
|
||||
clearInterval(o.timer);
|
||||
if (!visible) { o.img.removeAttribute("src"); return; }
|
||||
const base = ROOT + o.name + "/";
|
||||
if (o.mode === "live") {
|
||||
o.img.src = base + "stream.mjpg?t=" + Date.now();
|
||||
} else {
|
||||
const load = () => { o.img.src = base + "snapshot.jpg?t=" + Date.now(); };
|
||||
load();
|
||||
o.timer = setInterval(load, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function setOnline(o, on) {
|
||||
o.t.classList.toggle("on", on);
|
||||
o.t.classList.toggle("off", !on);
|
||||
o.state.textContent = on ? "live" : "offline";
|
||||
if (on && o.online !== true) connect(o);
|
||||
o.online = on;
|
||||
}
|
||||
|
||||
function build(names) {
|
||||
order = names;
|
||||
app.replaceChildren();
|
||||
if (CURRENT && names.includes(CURRENT)) {
|
||||
app.className = "focus";
|
||||
const big = makeTile(CURRENT, "live");
|
||||
big.classList.add("big");
|
||||
app.append(big);
|
||||
const strip = el("div", "strip");
|
||||
names.filter(n => n !== CURRENT).forEach(n => strip.append(makeTile(n, "thumb")));
|
||||
if (strip.children.length) app.append(strip);
|
||||
} else {
|
||||
const grid = el("div", "grid");
|
||||
names.forEach(n => grid.append(makeTile(n, "live")));
|
||||
app.append(grid);
|
||||
}
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const r = await fetch(ROOT + "status.json", { cache: "no-store" });
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
const data = await r.json();
|
||||
banner.style.display = "none";
|
||||
const names = data.cameras.map(c => c.name);
|
||||
if (names.join() !== order.join()) build(names);
|
||||
data.cameras.forEach(c => setOnline(tiles[c.name], c.online));
|
||||
} catch (e) {
|
||||
banner.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
// Verbindungen im Hintergrund-Tab trennen, spart Bandbreite
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
visible = !document.hidden;
|
||||
Object.values(tiles).forEach(o => { if (!visible || o.online) connect(o); });
|
||||
});
|
||||
|
||||
const clock = document.getElementById("clock");
|
||||
const tick = () => { clock.textContent = new Date().toLocaleString("de-DE"); };
|
||||
tick(); setInterval(tick, 1000);
|
||||
poll(); setInterval(poll, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user