Improve gallery video UX and add upload-to-publish media workflow.

Stage raw files in upload/, publish with make sync-media/publish, and polish the lightbox: autoplay, remembered volume, Escape to close, and image/video icons without poster or caption clutter.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-04 23:55:43 +12:00
parent 6c215d40e6
commit 3f5235daaf
22 changed files with 644 additions and 119 deletions

275
app/static/video-test.html Normal file
View File

@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Video playback test — Technical Kiwi</title>
<style>
:root {
--bg: #14100c;
--surface: #2a2219;
--text: #f5efe6;
--muted: #b5a090;
--accent: #c47b3a;
--border: rgba(245, 239, 230, 0.12);
--mono: ui-monospace, "Cascadia Code", monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
main {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
}
h1 { font-size: 1.35rem; margin: 0 0 0.5rem; }
p.lead { color: var(--muted); margin: 0 0 1.5rem; }
label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.35rem; }
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
input[type="text"] {
flex: 1 1 16rem;
min-width: 0;
padding: 0.55rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font: inherit;
}
button, .btn {
padding: 0.55rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font: inherit;
cursor: pointer;
}
button:hover, .btn:hover { border-color: var(--accent); }
.panel {
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
background: rgba(42, 34, 25, 0.5);
}
.panel h2 { font-size: 1rem; margin: 0 0 0.75rem; }
video {
display: block;
width: 100%;
max-height: 60vh;
background: #000;
border-radius: 8px;
}
.meta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: 0.5rem 1rem;
font-family: var(--mono);
font-size: 0.8rem;
margin-top: 0.75rem;
}
.meta dt { color: var(--muted); }
.meta dd { margin: 0; word-break: break-all; }
#log {
font-family: var(--mono);
font-size: 0.75rem;
max-height: 14rem;
overflow: auto;
margin: 0;
padding: 0.75rem;
background: #0a0806;
border-radius: 8px;
list-style: none;
}
#log li { margin: 0.15rem 0; }
#log .err { color: #f87171; }
#log .ok { color: #86efac; }
table.codec {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
table.codec th, table.codec td {
text-align: left;
padding: 0.35rem 0.5rem;
border-bottom: 1px solid var(--border);
}
table.codec th { color: var(--muted); font-weight: 500; }
a { color: var(--accent); }
</style>
</head>
<body>
<main>
<h1>Video playback test</h1>
<p class="lead">
Served from <code>/static/video-test.html</code>.
Use with <code>make dev</code> at
<a href="http://localhost:7331/static/video-test.html">localhost:7331</a>
or <a href="http://localhost:8080/static/video-test.html">8080</a>.
Path is under <code>/images/…</code> (copy from gallery Network tab).
</p>
<div class="panel">
<label for="path">Video path (site-relative)</label>
<div class="row">
<input id="path" type="text" placeholder="/images/album/clip.mp4" spellcheck="false">
<button type="button" id="load">Load</button>
<a class="btn" id="open-tab" href="#" target="_blank" rel="noopener">Open URL</a>
</div>
<p style="margin:0;font-size:0.85rem;color:var(--muted)">
Query link: <code>?path=/images/…</code> (encode slashes as <code>%2F</code> if needed)
</p>
</div>
<div class="panel">
<h2>Player</h2>
<video id="player" controls playsinline preload="auto"></video>
<dl class="meta" id="stats">
<div><dt>src</dt><dd id="stat-src"></dd></div>
<div><dt>readyState</dt><dd id="stat-ready"></dd></div>
<div><dt>dimensions</dt><dd id="stat-dim"></dd></div>
<div><dt>duration</dt><dd id="stat-dur"></dd></div>
<div><dt>networkState</dt><dd id="stat-net"></dd></div>
<div><dt>error</dt><dd id="stat-err"></dd></div>
</dl>
</div>
<div class="panel">
<h2>Browser codec hints (canPlayType)</h2>
<table class="codec" id="codec-table">
<thead><tr><th>MIME</th><th>Result</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="panel">
<h2>Events</h2>
<ul id="log"></ul>
</div>
</main>
<script>
(function () {
var pathInput = document.getElementById("path");
var player = document.getElementById("player");
var logEl = document.getElementById("log");
var openTab = document.getElementById("open-tab");
var codecs = [
'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',
'video/mp4; codecs="hvc1.1.6.L93.B0, mp4a.40.2"',
'video/mp4; codecs="hev1.1.6.L93.B0, mp4a.40.2"',
"video/mp4",
"video/quicktime",
"video/webm; codecs=\"vp9, opus\"",
"video/webm",
];
function log(msg, cls) {
var li = document.createElement("li");
if (cls) li.className = cls;
li.textContent = new Date().toISOString().slice(11, 23) + " " + msg;
logEl.prepend(li);
}
function updateStats() {
document.getElementById("stat-src").textContent = player.currentSrc || player.src || "—";
document.getElementById("stat-ready").textContent = String(player.readyState);
document.getElementById("stat-dim").textContent =
player.videoWidth + "×" + player.videoHeight;
document.getElementById("stat-dur").textContent = isFinite(player.duration)
? player.duration.toFixed(2) + "s"
: "—";
document.getElementById("stat-net").textContent = String(player.networkState);
var err = player.error;
document.getElementById("stat-err").textContent = err
? err.code + " " + (err.message || mediaErrorLabel(err.code))
: "—";
}
function mediaErrorLabel(code) {
return (
{ 1: "MEDIA_ERR_ABORTED", 2: "MEDIA_ERR_NETWORK", 3: "MEDIA_ERR_DECODE", 4: "MEDIA_ERR_SRC_NOT_SUPPORTED" }[code] || "?"
);
}
function normalizePath(raw) {
raw = (raw || "").trim();
if (!raw) return "";
if (raw.startsWith("http://") || raw.startsWith("https://")) {
try {
var u = new URL(raw);
return u.pathname;
} catch (e) {
return raw;
}
}
return raw.startsWith("/") ? raw : "/" + raw;
}
function loadFromInput() {
var path = normalizePath(pathInput.value);
if (!path) {
log("No path entered", "err");
return;
}
pathInput.value = path;
logEl.innerHTML = "";
log("Loading " + path);
player.removeAttribute("poster");
player.src = path;
openTab.href = path;
player.load();
history.replaceState(null, "", "?path=" + encodeURIComponent(path));
updateStats();
}
[
"loadstart", "loadedmetadata", "loadeddata", "canplay", "canplaythrough",
"playing", "pause", "ended", "error", "stalled", "waiting", "suspend",
].forEach(function (ev) {
player.addEventListener(ev, function () {
var cls = ev === "error" ? "err" : ev === "canplay" || ev === "playing" ? "ok" : "";
log(ev + (ev === "error" && player.error ? " " + mediaErrorLabel(player.error.code) : ""));
if (cls && ev !== "error") logEl.querySelector("li").className = cls;
updateStats();
});
});
document.getElementById("load").addEventListener("click", loadFromInput);
pathInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") loadFromInput();
});
var tbody = document.querySelector("#codec-table tbody");
var probe = document.createElement("video");
codecs.forEach(function (mime) {
var tr = document.createElement("tr");
var tdM = document.createElement("td");
var tdR = document.createElement("td");
tdM.textContent = mime;
tdR.textContent = probe.canPlayType(mime) || '""';
tr.appendChild(tdM);
tr.appendChild(tdR);
tbody.appendChild(tr);
});
var q = new URLSearchParams(location.search).get("path");
if (q) {
pathInput.value = q;
loadFromInput();
}
})();
</script>
</body>
</html>