v_2
This commit is contained in:
+443
@@ -0,0 +1,443 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user