Files

444 lines
17 KiB
HTML
Raw Permalink Normal View History

2026-09-13 20:24:20 +01:00
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Camera Station · Files</title>
<style>
:root{
--chassis:#0e1116;
--panel:#161b22;
--panel-hi:#1e252e;
--edge:#2a323d;
--ink:#e6e4df;
--ink-dim:#7d8794;
--phosphor:#ffb347;
--tally:#ff3b30;
--good:#3ddc84;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
}
*{box-sizing:border-box}
body{
margin:0;padding:28px 22px 70px;background:var(--chassis);color:var(--ink);
font-family:var(--mono);font-size:13px;line-height:1.5;
display:flex;flex-direction:column;align-items:center;
}
.wrap{width:100%;max-width:1000px;display:flex;flex-direction:column;gap:16px}
header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap}
h1{font-size:13px;letter-spacing:.34em;text-transform:uppercase;margin:0;font-weight:600}
.sub{color:var(--ink-dim);font-size:11px;letter-spacing:.1em}
nav.links{display:flex;gap:8px}
nav.links a{
color:var(--ink-dim);text-decoration:none;font-size:10px;letter-spacing:.18em;
text-transform:uppercase;border:1px solid var(--edge);border-radius:2px;padding:7px 11px;
}
nav.links a:hover{color:var(--ink);border-color:#3c4654}
.tabs{display:flex;gap:8px;flex-wrap:wrap}
.tabs button{
font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;
color:var(--ink-dim);background:var(--panel);border:1px solid var(--edge);
border-radius:2px;padding:10px 16px;cursor:pointer;
}
.tabs button[aria-pressed="true"]{background:var(--panel-hi);border-color:#3c4654;color:var(--ink)}
.filters{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
select{
font-family:var(--mono);font-size:12px;color:var(--ink);background:var(--chassis);
border:1px solid var(--edge);border-radius:2px;padding:8px 10px;
}
select:focus{outline:none;border-color:#4a5768}
.filters .spacer{flex:1}
button.act{
font-family:var(--mono);font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;
color:var(--ink);background:var(--panel-hi);border:1px solid var(--edge);
border-radius:2px;padding:9px 13px;cursor:pointer;white-space:nowrap;
}
button.act:hover:not(:disabled){background:#28313d}
button.act:disabled{opacity:.35;cursor:default}
button.act.danger:hover:not(:disabled){border-color:var(--tally);color:var(--tally)}
.storage{background:var(--panel);border:1px solid var(--edge);border-radius:2px;padding:13px 15px}
.bar{height:8px;background:var(--chassis);border:1px solid var(--edge);border-radius:2px;overflow:hidden;margin-bottom:9px}
.bar i{display:block;height:100%;background:var(--phosphor);transition:width .4s ease}
.bar[data-full="yes"] i{background:var(--tally)}
.usage{font-size:11px;color:var(--ink-dim)}
.usage b{color:var(--ink);font-weight:600}
/* recordings list */
.list{display:flex;flex-direction:column;gap:2px}
.day{
font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-dim);
padding:16px 2px 6px;border-bottom:1px solid var(--edge);margin-bottom:4px;
}
.item{
display:grid;grid-template-columns:auto 1fr auto auto auto;gap:12px;align-items:center;
background:var(--panel);border:1px solid var(--edge);border-radius:2px;padding:10px 12px;
}
.item:hover{border-color:#3c4654}
.item .who{color:var(--phosphor);font-size:11px;letter-spacing:.1em}
.item .when{color:var(--ink);font-size:12px}
.item .size{color:var(--ink-dim);font-size:11px;white-space:nowrap}
.item a.get{
color:var(--ink-dim);text-decoration:none;font-size:10px;letter-spacing:.16em;
text-transform:uppercase;border:1px solid var(--edge);border-radius:2px;padding:7px 11px;
}
.item a.get:hover{color:var(--ink);border-color:#3c4654;background:var(--panel-hi)}
.item.busy{border-left:2px solid var(--tally)}
.item .rec{color:var(--tally);font-size:9px;letter-spacing:.16em;text-transform:uppercase}
/* photo grid */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(168px,1fr));gap:10px}
.card{
background:var(--panel);border:1px solid var(--edge);border-radius:2px;overflow:hidden;
display:flex;flex-direction:column;
}
.card:hover{border-color:#3c4654}
.card .frame{position:relative;background:#0a0d11;aspect-ratio:16/9;display:block}
.card img{width:100%;height:100%;object-fit:cover;display:block}
.card .meta{padding:8px 10px;display:flex;flex-direction:column;gap:3px}
.card .who{color:var(--phosphor);font-size:10px;letter-spacing:.12em}
.card .when{font-size:11px}
.card .row{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:4px}
.card .size{color:var(--ink-dim);font-size:10px}
.card a.get{
color:var(--ink-dim);text-decoration:none;font-size:9.5px;letter-spacing:.14em;
text-transform:uppercase;border:1px solid var(--edge);border-radius:2px;padding:5px 8px;
}
.card a.get:hover{color:var(--ink);border-color:#3c4654}
.card input{position:absolute;top:7px;left:7px;z-index:2;accent-color:var(--phosphor)}
input[type=checkbox]{width:15px;height:15px;accent-color:var(--phosphor);cursor:pointer}
.empty{
border:1px dashed var(--edge);border-radius:2px;padding:34px;text-align:center;
color:var(--ink-dim);font-size:11.5px;
}
.note{
background:var(--panel);border:1px solid var(--edge);border-left:2px solid var(--phosphor);
padding:11px 14px;color:var(--ink-dim);font-size:11px;line-height:1.6;border-radius:2px;
}
.note b{color:var(--ink);font-weight:600}
.status{font-size:11px;color:var(--ink-dim)}
.status[data-state="ok"]{color:var(--good)}
.status[data-state="bad"]{color:#ff8a80}
.more{display:flex;justify-content:center;padding:6px}
@media (max-width:640px){
.item{grid-template-columns:auto 1fr auto;row-gap:6px}
.item .size{grid-column:2}
.item a.get{grid-column:3}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1>Camera Station · Files</h1>
<div class="sub" id="sub">Loading…</div>
</div>
<nav class="links">
<a href="/">Viewer</a>
<a href="/config">Set up</a>
</nav>
</header>
<div class="tabs">
<button id="tabRec" aria-pressed="true">Recordings</button>
<button id="tabPho" aria-pressed="false">Photos</button>
</div>
<div class="storage">
<div class="bar" id="bar"><i style="width:0"></i></div>
<div class="usage" id="usage">Reading…</div>
</div>
<div class="filters">
<select id="camera"><option value="">All cameras</option></select>
<select id="day"><option value="">All dates</option></select>
<span class="spacer"></span>
<span class="status" id="status"></span>
<button class="act" id="selectAll">Select all</button>
<button class="act danger" id="del" disabled>Delete selected</button>
</div>
<div class="note" id="playNote">
Recordings are Matroska files holding the camera's own stream, so most browsers
cannot play them in a tab. Download and open them in <b>VLC</b>, or convert one
without re-encoding: <b>ffmpeg -i file.mkv -c copy file.mp4</b>
</div>
<div id="body"></div>
<div class="more"><button class="act" id="more" hidden>Load more</button></div>
</div>
<script>
(function () {
"use strict";
var kind = "recordings";
var offset = 0;
var loaded = [];
var selected = {};
var total = 0;
var el = {
sub: document.getElementById("sub"),
body: document.getElementById("body"),
camera: document.getElementById("camera"),
day: document.getElementById("day"),
status: document.getElementById("status"),
del: document.getElementById("del"),
selectAll: document.getElementById("selectAll"),
more: document.getElementById("more"),
bar: document.getElementById("bar"),
usage: document.getElementById("usage"),
tabRec: document.getElementById("tabRec"),
tabPho: document.getElementById("tabPho"),
playNote: document.getElementById("playNote")
};
function human(n) {
if (!n) return "0 B";
if (n < 1024) return n + " B";
if (n < 1048576) return (n / 1024).toFixed(0) + " KB";
if (n < 1073741824) return (n / 1048576).toFixed(1) + " MB";
return (n / 1073741824).toFixed(2) + " GB";
}
function prettyDay(d) {
if (!/^\d{8}$/.test(d)) return d;
return d.slice(0, 4) + "-" + d.slice(4, 6) + "-" + d.slice(6, 8);
}
function setStatus(text, state) {
el.status.textContent = text || "";
if (state) el.status.dataset.state = state; else delete el.status.dataset.state;
}
function refreshDelete() {
var n = Object.keys(selected).length;
el.del.disabled = !n;
el.del.textContent = n ? "Delete " + n + " selected" : "Delete selected";
}
function keep(select, values, current, allLabel) {
select.textContent = "";
var any = document.createElement("option");
any.value = ""; any.textContent = allLabel;
select.appendChild(any);
values.forEach(function (v) {
var o = document.createElement("option");
o.value = v;
o.textContent = select === el.day ? prettyDay(v) : v;
select.appendChild(o);
});
select.value = current || "";
}
function load(reset) {
if (reset) { offset = 0; loaded = []; selected = {}; refreshDelete(); }
var url = "/api/files?kind=" + kind +
"&camera=" + encodeURIComponent(el.camera.value) +
"&day=" + encodeURIComponent(el.day.value) +
"&offset=" + offset;
return fetch(url)
.then(function (r) { return r.json(); })
.then(function (data) {
total = data.total;
loaded = loaded.concat(data.files);
keep(el.camera, data.cameras, el.camera.value, "All cameras");
keep(el.day, data.days, el.day.value, "All dates");
var pct = data.limit ? Math.min(100, (data.used / data.limit) * 100) : 0;
el.bar.firstElementChild.style.width = pct.toFixed(1) + "%";
el.bar.dataset.full = pct > 92 ? "yes" : "no";
el.usage.innerHTML = "<b>" + human(data.used) + " of " + human(data.limit) +
"</b> of footage stored \u00b7 oldest is deleted first once it is full";
el.sub.textContent = total + (total === 1 ? " file" : " files") +
(data.bytes ? " \u00b7 " + human(data.bytes) : "");
el.more.hidden = loaded.length >= total;
el.more.textContent = "Load more (" + (total - loaded.length) + " left)";
render();
})
.catch(function () { setStatus("Could not read the file list.", "bad"); });
}
function render() {
el.body.textContent = "";
if (!loaded.length) {
var none = document.createElement("div");
none.className = "empty";
none.textContent = kind === "photos"
? "No photos yet. Press Take photo in the viewer."
: "No recordings yet. Turn a camera on from the set-up page.";
el.body.appendChild(none);
return;
}
if (kind === "photos") renderPhotos(); else renderRecordings();
refreshDelete();
}
function toggle(name, on) {
if (on) selected[name] = true; else delete selected[name];
refreshDelete();
}
function renderRecordings() {
var list = document.createElement("div");
list.className = "list";
var currentDay = null;
loaded.forEach(function (f) {
if (f.day !== currentDay) {
currentDay = f.day;
var head = document.createElement("div");
head.className = "day";
head.textContent = prettyDay(f.day);
list.appendChild(head);
}
var rel = f.camera + "/" + f.name;
var row = document.createElement("div");
row.className = "item" + (f.live ? " busy" : "");
var box = document.createElement("input");
box.type = "checkbox";
box.checked = !!selected[rel];
box.addEventListener("change", function () { toggle(rel, box.checked); });
var who = document.createElement("span"); who.className = "who"; who.textContent = f.camera;
var when = document.createElement("span"); when.className = "when"; when.textContent = f.when;
var size = document.createElement("span"); size.className = "size";
size.textContent = human(f.size);
if (f.live) {
var live = document.createElement("span");
live.className = "rec";
live.textContent = "recording now";
size.textContent = human(f.size) + " ";
size.appendChild(live);
}
var get = document.createElement("a");
get.className = "get"; get.href = f.url + "?download=1";
get.setAttribute("download", f.name);
get.textContent = f.live ? "Download part" : "Download";
get.title = f.live
? "This file is still being written. You will get it as far as it has got."
: "Download " + f.name;
row.appendChild(box); row.appendChild(when); row.appendChild(who);
row.appendChild(size); row.appendChild(get);
list.appendChild(row);
});
el.body.appendChild(list);
}
function renderPhotos() {
var grid = document.createElement("div");
grid.className = "grid";
loaded.forEach(function (f) {
var card = document.createElement("div");
card.className = "card";
var frame = document.createElement("a");
frame.className = "frame"; frame.href = f.url; frame.target = "_blank";
frame.rel = "noreferrer";
var img = document.createElement("img");
img.src = f.url; img.alt = f.name; img.loading = "lazy";
frame.appendChild(img);
var box = document.createElement("input");
box.type = "checkbox";
box.checked = !!selected[f.name];
box.addEventListener("change", function () { toggle(f.name, box.checked); });
frame.appendChild(box);
var meta = document.createElement("div"); meta.className = "meta";
var who = document.createElement("span"); who.className = "who"; who.textContent = f.camera;
var when = document.createElement("span"); when.className = "when"; when.textContent = f.when;
var row = document.createElement("div"); row.className = "row";
var size = document.createElement("span"); size.className = "size"; size.textContent = human(f.size);
var get = document.createElement("a");
get.className = "get"; get.href = f.url + "?download=1";
get.setAttribute("download", f.name);
get.textContent = "Download";
row.appendChild(size); row.appendChild(get);
meta.appendChild(who); meta.appendChild(when); meta.appendChild(row);
card.appendChild(frame); card.appendChild(meta);
grid.appendChild(card);
});
el.body.appendChild(grid);
}
function switchKind(next) {
if (kind === next) return;
kind = next;
el.tabRec.setAttribute("aria-pressed", String(kind === "recordings"));
el.tabPho.setAttribute("aria-pressed", String(kind === "photos"));
el.playNote.hidden = kind === "photos";
el.camera.value = ""; el.day.value = "";
setStatus("");
load(true);
}
el.tabRec.addEventListener("click", function () { switchKind("recordings"); });
el.tabPho.addEventListener("click", function () { switchKind("photos"); });
el.camera.addEventListener("change", function () { load(true); });
el.day.addEventListener("change", function () { load(true); });
el.more.addEventListener("click", function () { offset = loaded.length; load(false); });
el.selectAll.addEventListener("click", function () {
var all = loaded.length && loaded.every(function (f) {
return selected[kind === "photos" ? f.name : f.camera + "/" + f.name];
});
selected = {};
if (!all) {
loaded.forEach(function (f) {
selected[kind === "photos" ? f.name : f.camera + "/" + f.name] = true;
});
}
render();
});
el.del.addEventListener("click", function () {
var names = Object.keys(selected);
if (!names.length) return;
if (!window.confirm("Delete " + names.length +
(names.length === 1 ? " file" : " files") + " permanently?")) return;
el.del.disabled = true;
setStatus("Deleting\u2026");
fetch("/api/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: kind, names: names })
}).then(function (r) {
return r.text().then(function (body) {
if (!r.ok) throw new Error(body || "The server refused that.");
return JSON.parse(body);
});
}).then(function (res) {
var msg = "Deleted " + res.deleted + ", freeing " + human(res.freed) + ".";
if (res.skipped && res.skipped.length) {
msg += " " + res.skipped.length + " kept (still being recorded).";
}
setStatus(msg, "ok");
selected = {};
load(true);
}).catch(function (err) {
setStatus(String(err.message || err), "bad");
refreshDelete();
});
});
load(true);
setInterval(function () { if (!Object.keys(selected).length) load(true); }, 20000);
})();
</script>
</body>
</html>