feat: organizer Android app and token-gated upload API
CI / lint-and-test (push) Failing after 1m18s

Replace Syncthing as the product upload path with a share-target app
and POST /api/jobs. Web UI live-polls job progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-08-27 21:38:50 +02:00
parent 5dcc3717c7
commit c03f17488c
58 changed files with 3134 additions and 186 deletions
+160
View File
@@ -0,0 +1,160 @@
/* live.f12.rocks — live poll + lightbox gallery. No framework. */
(function () {
"use strict";
var POLL_MS = 4000;
function $(id) {
return document.getElementById(id);
}
/* --- live reload while jobs run --- */
function fingerprint(payload) {
if (!payload) return "";
if (payload.job_id) {
return [
payload.status,
payload.stage,
payload.updated_at,
payload.variant_count,
(payload.intermediates || []).length,
payload.error || "",
].join("|");
}
var jobs = payload.jobs || [];
return jobs
.map(function (j) {
return [j.job_id, j.status, j.updated_at, j.variant_count, j.stage].join(":");
})
.join(",");
}
function startLivePoll() {
if (!document.body.hasAttribute("data-live")) return;
var jobId = document.body.getAttribute("data-job-id");
var url = jobId ? "/api/jobs/" + encodeURIComponent(jobId) : "/api/jobs";
var last = "";
var first = true;
function tick() {
fetch(url, { headers: { Accept: "application/json" } })
.then(function (r) {
if (!r.ok) throw new Error("poll " + r.status);
return r.json();
})
.then(function (data) {
var next = fingerprint(data);
if (first) {
last = next;
first = false;
return;
}
if (next !== last) {
window.location.reload();
}
})
.catch(function () {
/* venue wifi: try again next tick */
});
}
tick();
setInterval(tick, POLL_MS);
}
/* --- lightbox with prev/next --- */
function startLightbox() {
var box = $("lightbox");
var img = $("lightbox-img");
var caption = $("lightbox-caption");
if (!box || !img) return;
var items = [];
var index = 0;
function collect() {
items = Array.prototype.slice
.call(document.querySelectorAll("img.lightboxable"))
.map(function (el) {
return {
src: el.getAttribute("data-full-src") || el.currentSrc || el.src,
alt: el.alt || "",
};
});
}
function show(i) {
if (!items.length) return;
index = (i + items.length) % items.length;
img.src = items[index].src;
img.alt = items[index].alt;
if (caption) caption.textContent = items[index].alt;
box.hidden = false;
document.body.classList.add("lightbox-open");
}
function close() {
box.hidden = true;
img.removeAttribute("src");
document.body.classList.remove("lightbox-open");
}
document.addEventListener("click", function (e) {
var target = e.target;
if (!(target instanceof HTMLImageElement)) return;
if (!target.classList.contains("lightboxable")) return;
e.preventDefault();
e.stopPropagation();
collect();
var full = target.getAttribute("data-full-src") || target.currentSrc || target.src;
var found = items.findIndex(function (it) {
return it.src === full;
});
show(found >= 0 ? found : 0);
});
box.addEventListener("click", function (e) {
if (e.target === box || (e.target && e.target.classList.contains("lightbox-close"))) {
close();
return;
}
if (e.target && e.target.classList.contains("lightbox-next")) {
show(index + 1);
}
if (e.target && e.target.classList.contains("lightbox-prev")) {
show(index - 1);
}
});
document.addEventListener("keydown", function (e) {
if (box.hidden) return;
if (e.key === "Escape") close();
if (e.key === "ArrowRight") show(index + 1);
if (e.key === "ArrowLeft") show(index - 1);
});
var startX = 0;
box.addEventListener(
"touchstart",
function (e) {
if (e.changedTouches && e.changedTouches[0]) startX = e.changedTouches[0].screenX;
},
{ passive: true }
);
box.addEventListener(
"touchend",
function (e) {
if (!e.changedTouches || !e.changedTouches[0]) return;
var dx = e.changedTouches[0].screenX - startX;
if (Math.abs(dx) < 40) return;
show(index + (dx < 0 ? 1 : -1));
},
{ passive: true }
);
}
startLivePoll();
startLightbox();
})();
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#14161a"/>
<rect x="10" y="14" width="44" height="36" rx="4" fill="#1d2025" stroke="#ff5a1f" stroke-width="3"/>
<circle cx="24" cy="28" r="5" fill="#ff5a1f"/>
<path d="M14 44l12-12 8 8 8-6 8 10H14z" fill="#ff5a1f"/>
</svg>

After

Width:  |  Height:  |  Size: 335 B

+94
View File
@@ -52,6 +52,17 @@ a:focus-visible {
.site-header {
padding: 1rem var(--gap);
border-bottom: 1px solid var(--border);
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.header-note {
color: var(--text-dim);
font-size: 0.8rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.brand {
@@ -147,6 +158,33 @@ h2 {
}
}
.job-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin: 0.5rem 0 1rem;
}
.job-heading h1 {
margin: 0;
}
.progress {
height: 4px;
background: var(--border);
border-radius: 999px;
overflow: hidden;
margin: -0.5rem 0 1rem;
}
.progress span {
display: block;
height: 100%;
background: var(--accent);
min-width: 0;
}
.job-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
@@ -154,6 +192,27 @@ h2 {
overflow: hidden;
}
.job-card.is-live {
border-color: var(--pending);
animation: live-pulse 1.8s ease-in-out infinite;
}
@keyframes live-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(217, 164, 65, 0);
}
50% {
box-shadow: 0 0 0 3px rgba(217, 164, 65, 0.35);
}
}
@media (prefers-reduced-motion: reduce) {
.job-card.is-live {
animation: none;
}
}
.job-card a {
color: var(--text);
display: block;
@@ -390,4 +449,39 @@ body.lightbox-open {
padding: 0.25rem 0.5rem;
min-height: 2.75rem;
min-width: 2.75rem;
z-index: 1;
}
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
border: 0;
background: transparent;
color: var(--text);
font-size: 2.4rem;
line-height: 1;
cursor: pointer;
padding: 0.5rem 0.75rem;
min-height: 2.75rem;
min-width: 2.75rem;
}
.lightbox-prev {
left: 0.25rem;
}
.lightbox-next {
right: 0.25rem;
}
.lightbox-caption {
position: absolute;
bottom: 0.75rem;
left: 1rem;
right: 1rem;
margin: 0;
text-align: center;
color: var(--text-dim);
font-size: 0.85rem;
}