From 994cdbec9dd6ae6b965b56f4f6816a1dfef8bcc9 Mon Sep 17 00:00:00 2001 From: dax Date: Wed, 5 Aug 2026 20:14:45 +0100 Subject: staging: local drop-folder (watch/) auto-import with startup recovery + failed batches; admin local-files panel; upload 413 pre-check --- public/admin.css | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ public/admin.html | 17 ++++++++++++++-- public/admin.js | 53 +++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 127 insertions(+), 3 deletions(-) (limited to 'public') diff --git a/public/admin.css b/public/admin.css index 8f95d63..8756359 100644 --- a/public/admin.css +++ b/public/admin.css @@ -159,6 +159,66 @@ button.danger:hover { border-color: var(--danger); background: rgba(252, 165, 16 margin: 0 0 8px; } +.local { + background: var(--panel); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 10px; + padding: 14px; + display: grid; + gap: 10px; +} +.local h2 { + font-size: 14px; + font-weight: 500; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--muted); + margin: 0; +} +.local-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.local-head h2 { margin: 0; } +.local-path { + margin: 0; + font-size: 12px; + color: var(--ring); + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + overflow-wrap: anywhere; +} +.local-hint { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.5; } +.local-hint code { + display: inline-block; + margin-top: 4px; + font-size: 12px; + color: var(--ink); + background: var(--panel-2); + border: 1px solid rgba(255, 255, 255, .1); + border-radius: 6px; + padding: 3px 8px; + overflow-wrap: anywhere; +} +.st-file { + display: flex; + justify-content: space-between; + gap: 10px; + padding: 6px 8px; + background: var(--panel-2); + border: 1px solid rgba(255, 255, 255, .06); + border-radius: 8px; + font-size: 13px; +} +.st-file .n { color: var(--ink); overflow-wrap: anywhere; } +.st-file .s { color: var(--muted); white-space: nowrap; } +.st-log-item { + padding: 6px 8px; + font-size: 12px; + border-radius: 8px; + background: var(--panel-2); + border: 1px solid rgba(255, 255, 255, .06); +} +.st-log-item.ok { color: var(--ok); } +.st-log-item.bad { color: var(--danger); } +#st-log { display: grid; gap: 6px; } + .results { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); diff --git a/public/admin.html b/public/admin.html index 9fe1b5a..c296bea 100644 --- a/public/admin.html +++ b/public/admin.html @@ -5,7 +5,7 @@ pathways · admin - +
@@ -73,6 +73,19 @@

+
+
+

local drop folder

+ +
+

+

copy photos into this folder on the server — they import automatically (polled every ~15s).
+ scp photo.jpg dax@<host>:/srv/pathways/staging/watch/

+
+ +

recent imports

+
+
- + diff --git a/public/admin.js b/public/admin.js index 90dd6b0..5291620 100644 --- a/public/admin.js +++ b/public/admin.js @@ -99,6 +99,13 @@ function fmtUptime(s) { return h ? `${h} h ${m} m` : `${m} m` } +function fmtSize(n) { + if (n >= 1e9) return (n / 1e9).toFixed(1) + " GB" + if (n >= 1e6) return (n / 1e6).toFixed(1) + " MB" + if (n >= 1e3) return Math.round(n / 1e3) + " KB" + return n + " B" +} + let debounceTimer = 0 function debounce(fn, ms) { clearTimeout(debounceTimer) @@ -294,6 +301,7 @@ async function enterDash() { try { await loadStatus() await loadRecent() + await loadStaging().catch(() => {}) } catch (err) { if (views.dash.hidden) return; } } @@ -316,6 +324,34 @@ async function loadRecent() { $("#im-recent").hidden = data.photos.length === 0 } +async function loadStaging() { + const d = await api("/api/admin/staging") + $("#st-path").textContent = d.watch + const filesEl = $("#st-files") + filesEl.replaceChildren() + for (const f of d.files) { + const row = document.createElement("div") + row.className = "st-file" + const n = document.createElement("span") + n.className = "n" + n.textContent = f.name + const s = document.createElement("span") + s.className = "s" + s.textContent = fmtSize(f.size) + " · " + fmtAge(Date.now() - f.mtime) + row.append(n, s) + filesEl.appendChild(row) + } + $("#st-none").hidden = d.files.length > 0 + const logEl = $("#st-log") + logEl.replaceChildren() + for (const r of d.imports) { + const row = document.createElement("div") + row.className = "st-log-item " + (r.ok ? "ok" : "bad") + row.textContent = `${new Date(r.at).toLocaleString()} — ${r.ok ? `imported ${r.count} ${r.count === 1 ? "photo" : "photos"}` : "failed: " + (r.error || "unknown error")}` + logEl.appendChild(row) + } +} + function setImProgress(pct, text) { $("#im-progress").hidden = false $("#im-status").textContent = text @@ -360,6 +396,7 @@ async function importFiles(list) { toast(`imported ${data.imported} ${data.imported === 1 ? "photo" : "photos"}`) await loadStatus() await loadRecent() + await loadStaging().catch(() => {}) } catch (err) { $("#im-err").textContent = err.message } finally { @@ -378,10 +415,24 @@ async function init() { $("#tab-status").hidden = tab !== "status" $("#tab-import").hidden = tab !== "import" $("#tab-graph").hidden = tab !== "graph" - if (tab === "import") loadRecent().catch((e2) => toast(e2.message)) + if (tab === "import") { + loadRecent().catch((e2) => toast(e2.message)) + loadStaging().catch((e2) => toast(e2.message)) + } }) }) + $("#st-scan").addEventListener("click", async () => { + const btn = $("#st-scan") + btn.disabled = true + try { + await api("/api/admin/staging", { method: "POST" }) + await loadStaging() + toast("scan complete") + } catch (err) { toast(err.message) } + btn.disabled = false + }) + $("#setup-form").addEventListener("submit", async (e) => { e.preventDefault() const err = $("#setup-err") -- cgit v1.3.1