aboutsummaryrefslogtreecommitdiff
path: root/public
diff options
context:
space:
mode:
authordax <me@dax.ist>2026-08-05 20:14:45 +0100
committerdax <me@dax.ist>2026-08-05 20:14:45 +0100
commit994cdbec9dd6ae6b965b56f4f6816a1dfef8bcc9 (patch)
tree0d7b0d1c5bec8d34b5ebe731af34ce3e8b1706ca /public
parentd2a8b2abc908311fc249454086d0d0eacfe861e1 (diff)
staging: local drop-folder (watch/) auto-import with startup recovery + failed batches; admin local-files panel; upload 413 pre-checkHEADmain
Diffstat (limited to 'public')
-rw-r--r--public/admin.css60
-rw-r--r--public/admin.html17
-rw-r--r--public/admin.js53
3 files changed, 127 insertions, 3 deletions
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 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>pathways · admin</title>
- <link rel="stylesheet" href="/admin.css?v=4">
+ <link rel="stylesheet" href="/admin.css?v=5">
</head>
<body>
<main id="app">
@@ -73,6 +73,19 @@
<p class="im-status" id="im-status"></p>
</div>
<p class="err" id="im-err"></p>
+ <div class="local">
+ <div class="local-head">
+ <h2>local drop folder</h2>
+ <button id="st-scan">import now</button>
+ </div>
+ <p class="local-path" id="st-path"></p>
+ <p class="local-hint">copy photos into this folder on the server &mdash; they import automatically (polled every ~15s).<br>
+ <code>scp photo.jpg dax@&lt;host&gt;:/srv/pathways/staging/watch/</code></p>
+ <div id="st-files"></div>
+ <p class="facets" id="st-none" hidden>no files staged yet</p>
+ <h2>recent imports</h2>
+ <div id="st-log"></div>
+ </div>
<div class="recent" id="im-recent" hidden>
<h2>recently added</h2>
<div class="results" id="im-new"></div>
@@ -87,6 +100,6 @@
</section>
</main>
<div id="toast"></div>
- <script src="/admin.js?v=4"></script>
+ <script src="/admin.js?v=5"></script>
</body>
</html>
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")