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:
@@ -311,21 +311,26 @@ main {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.gallery-video-badge {
|
||||
.gallery-media-icon {
|
||||
position: absolute;
|
||||
left: 0.35rem;
|
||||
bottom: 0.35rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(20, 16, 12, 0.8);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gallery-media-icon svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.gallery-item:hover img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
@@ -524,54 +529,30 @@ main {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.modal-video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-figure img,
|
||||
.modal-figure video {
|
||||
.modal-figure > img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 75vh;
|
||||
min-height: 12rem;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.modal-figure video.video-broken {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video-unavailable {
|
||||
margin: 0;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
min-height: 12rem;
|
||||
.modal-video-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-unavailable[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.video-unavailable a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.modal-figure figcaption {
|
||||
margin-top: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85rem;
|
||||
.modal-video-stack video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 75vh;
|
||||
min-height: 3.5rem;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
@@ -742,14 +723,25 @@ main {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.modal-figure img,
|
||||
.modal-figure video {
|
||||
.modal-figure > img {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.modal-video-stack {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-video-stack video {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
|
||||
275
app/static/video-test.html
Normal file
275
app/static/video-test.html
Normal 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>
|
||||
Reference in New Issue
Block a user