feat: organizer Android app and token-gated upload API
CI / lint-and-test (push) Failing after 1m18s
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:
@@ -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();
|
||||
})();
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user