From 03dcc91c96ac45ff005a1c3e14a71a5733037447 Mon Sep 17 00:00:00 2001 From: dax Date: Wed, 5 Aug 2026 15:24:33 +0100 Subject: admin panel for walk-graph management; /api/import gated behind admin login --- public/admin.css | 212 ++++++++++++++++++++++++++++++ public/admin.html | 70 ++++++++++ public/admin.js | 385 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ public/app.js | 14 +- public/index.html | 5 +- 5 files changed, 683 insertions(+), 3 deletions(-) create mode 100644 public/admin.css create mode 100644 public/admin.html create mode 100644 public/admin.js (limited to 'public') diff --git a/public/admin.css b/public/admin.css new file mode 100644 index 0000000..d698076 --- /dev/null +++ b/public/admin.css @@ -0,0 +1,212 @@ +:root { + --bg: #0e0f13; + --panel: #16181f; + --panel-2: #1d2028; + --ring: #7dd3fc; + --ink: #e6e8ee; + --muted: #8b90a0; + --danger: #fca5a5; + --ok: #86efac; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + min-height: 100%; + background: var(--bg); + color: var(--ink); + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +body { + display: grid; + place-items: start center; + padding: 32px 16px 64px; +} + +#app { width: 100%; max-width: 640px; } + +h1 { + margin: 0 0 6px; + font-size: 30px; + font-weight: 300; + letter-spacing: .12em; +} +h1 .dot { color: var(--ring); } +.sub { color: var(--muted); margin: 0 0 20px; line-height: 1.5; } + +form { + display: grid; + gap: 10px; + margin-bottom: 8px; +} + +input[type="password"], input[type="search"], input[type="text"] { + width: 100%; + padding: 12px 14px; + font-size: 15px; + color: var(--ink); + background: var(--panel); + border: 1px solid rgba(255, 255, 255, .14); + border-radius: 8px; + outline: none; +} +input:focus { border-color: var(--ring); } + +.remember { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--muted); +} + +button { + padding: 11px 18px; + font-size: 14px; + letter-spacing: .08em; + color: var(--ink); + background: transparent; + border: 1px solid rgba(255, 255, 255, .28); + border-radius: 999px; + cursor: pointer; + transition: border-color .15s, background .15s, opacity .15s; +} +button:hover { border-color: var(--ring); background: rgba(125, 211, 252, .08); } +button:disabled { opacity: .45; cursor: default; } +button.danger { border-color: rgba(252, 165, 165, .5); color: var(--danger); } +button.danger:hover { border-color: var(--danger); background: rgba(252, 165, 165, .08); } + +.err { color: var(--danger); min-height: 20px; font-size: 14px; } +.facets { color: var(--muted); font-size: 13px; margin: 16px 0 0; line-height: 1.7; } + +.top { margin-bottom: 24px; } +.top nav { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + margin-top: 12px; +} +.top nav #logout { margin-left: auto; } +.tab.active { border-color: var(--ring); background: rgba(125, 211, 252, .12); } + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} +.card { + background: var(--panel); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 10px; + padding: 14px; + display: grid; + gap: 4px; +} +.card .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; } +.card .v { font-size: 20px; } +.card .v.ok { color: var(--ok); } +.card .v.bad { color: var(--danger); } + +.row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; } + +#tab-graph { display: grid; gap: 14px; } +#gq { padding: 12px 14px; } + +.results { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 8px; + max-height: 320px; + overflow-y: auto; +} +.result { + background: var(--panel-2); + border: 1px solid rgba(255, 255, 255, .1); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + padding: 0; + text-align: left; + transition: border-color .15s; +} +.result:hover { border-color: var(--ring); } +.result img { width: 100%; height: 74px; object-fit: cover; display: block; } +.result .t { padding: 6px 8px; font-size: 12px; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.result .d { padding: 0 8px 6px; font-size: 11px; color: var(--muted); } + +.graph { display: grid; gap: 10px; } +.grow { + background: var(--panel); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 10px; + padding: 12px; + display: grid; + gap: 10px; +} +.grow-head { display: flex; gap: 12px; align-items: center; } +.grow-head img { width: 52px; height: 52px; object-fit: cover; border-radius: 6px; } +.grow-head .t { font-weight: 500; } +.grow-head .id { color: var(--muted); font-size: 12px; } + +.dir-row { + display: grid; + grid-template-columns: 34px 1fr auto; + gap: 10px; + align-items: center; + padding: 8px; + background: var(--panel-2); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, .06); +} +.dir-badge { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + background: rgba(125, 211, 252, .14); + color: var(--ring); +} +.dir-dest { display: flex; align-items: center; gap: 10px; min-width: 0; } +.dir-dest img { width: 40px; height: 40px; object-fit: cover; border-radius: 5px; flex: none; } +.dir-dest .t { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.dir-dest .meta { font-size: 12px; color: var(--muted); } +.dir-dest .override-badge { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--ok); border: 1px solid rgba(134, 239, 172, .4); border-radius: 999px; padding: 1px 6px; } +.dir-actions { display: flex; gap: 6px; } +.dir-actions button { padding: 6px 12px; font-size: 12px; } + +.picker { + grid-column: 1 / -1; + display: grid; + gap: 8px; +} +.picker .presults { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 6px; + max-height: 220px; + overflow-y: auto; +} + +#toast { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + background: var(--panel-2); + border: 1px solid rgba(255, 255, 255, .15); + color: var(--ink); + padding: 10px 18px; + border-radius: 999px; + font-size: 14px; + opacity: 0; + pointer-events: none; + transition: opacity .2s; + max-width: 90vw; +} +#toast.show { opacity: 1; } diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..c722af6 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,70 @@ + + + + + + + pathways · admin + + + +
+ + + + + +
+
+ + + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..fa74635 --- /dev/null +++ b/public/admin.js @@ -0,0 +1,385 @@ +"use strict" + +const $ = (s) => document.querySelector(s) +const KEY = "pathways_admin" + +const views = { setup: $("#view-setup"), login: $("#view-login"), dash: $("#view-dash") } +const toastEl = $("#toast") +let toastTimer = 0 +let currentGraphId = null + +function toast(msg) { + toastEl.textContent = msg + toastEl.classList.add("show") + clearTimeout(toastTimer) + toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2600) +} + +function show(view) { + for (const [k, el] of Object.entries(views)) el.hidden = k !== view +} + +function setToken(token, remember) { + const entry = JSON.stringify({ token }) + if (remember) { + localStorage.setItem(KEY, entry) + sessionStorage.removeItem(KEY) + } else { + sessionStorage.setItem(KEY, entry) + localStorage.removeItem(KEY) + } +} + +function getToken() { + for (const store of [sessionStorage, localStorage]) { + try { + const raw = store.getItem(KEY) + if (raw) { const t = JSON.parse(raw).token; if (t) return t } + } catch { /* ignore */ } + } + return null +} + +function clearToken() { + try { localStorage.removeItem(KEY) } catch { /* ignore */ } + try { sessionStorage.removeItem(KEY) } catch { /* ignore */ } +} + +async function api(path, opts = {}) { + const headers = { ...(opts.headers || {}) } + const token = getToken() + if (token) headers.Authorization = "Bearer " + token + const res = await fetch(path, { ...opts, headers }) + let data = {} + try { data = await res.json() } catch { /* ignore */ } + if (res.status === 401) { + clearToken() + show("login") + if (data.error) toast(data.error) + throw new Error(data.error || "unauthorized") + } + if (!res.ok) throw new Error(data.error || `request failed (${res.status})`) + return data +} + +function errInto(el, err) { + el.textContent = err && err.message ? err.message : "" +} + +async function loadStatus() { + const s = await api("/api/admin/status") + $("#st-total").textContent = s.stateLoaded ? String(s.total) : "unavailable" + $("#st-built").textContent = s.builtAt ? new Date(s.builtAt).toLocaleString() : "–" + $("#st-age").textContent = s.builtAgeMs != null ? fmtAge(s.builtAgeMs) : "–" + $("#st-overrides").textContent = String(s.overrides) + const dbEl = $("#st-db") + dbEl.textContent = s.db === "ok" ? "ok" : "error" + dbEl.className = "v " + (s.db === "ok" ? "ok" : "bad") + $("#st-up").textContent = fmtUptime(s.uptimeS) + const f = s.facetCounts || {} + const parts = [] + for (const type of ["label", "keyword", "camera", "lens"]) { + if (f[type]) parts.push(`${type}s: ${f[type]}`) + } + $("#st-facets").textContent = parts.length ? "facet paths: " + parts.join(" · ") : "no facet paths yet" +} + +function fmtAge(ms) { + const m = Math.floor(ms / 60000) + if (m < 1) return "just now" + if (m < 60) return `${m} min ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h} h ${m % 60} m ago` + return `${Math.floor(h / 24)} d ${h % 24} h ago` +} + +function fmtUptime(s) { + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + return h ? `${h} h ${m} m` : `${m} m` +} + +let debounceTimer = 0 +function debounce(fn, ms) { + clearTimeout(debounceTimer) + debounceTimer = setTimeout(fn, ms) +} + +async function searchPhotos(q, limit = 24) { + const data = await api(`/api/admin/photos?q=${encodeURIComponent(q)}&limit=${limit}`) + return data.photos +} + +function photoBox(p) { + const el = document.createElement("button") + el.type = "button" + el.className = "result" + const img = document.createElement("img") + img.src = p.thumb + img.alt = "" + img.loading = "lazy" + const t = document.createElement("div") + t.className = "t" + t.textContent = p.title || `photo ${p.id}` + const d = document.createElement("div") + d.className = "d" + d.textContent = p.date || `#${p.id}` + el.append(img, t, d) + return el +} + +async function runGraphSearch() { + const q = $("#gq").value.trim() + const results = $("#g-results") + if (!q) { results.hidden = true; return } + try { + const photos = await searchPhotos(q) + results.hidden = false + results.replaceChildren() + for (const p of photos) { + const box = photoBox(p) + box.addEventListener("click", () => { loadGraph(p.id); currentGraphId = p.id }) + results.appendChild(box) + } + if (!photos.length) { + const none = document.createElement("div") + none.className = "t" + none.textContent = "no matches" + results.appendChild(none) + } + } catch (err) { + results.hidden = true + toast(err.message) + } +} + +function pickerRow(row, dir, id) { + const box = document.createElement("div") + box.className = "picker" + box.hidden = true + const input = document.createElement("input") + input.type = "search" + input.placeholder = "search destination…" + const presults = document.createElement("div") + presults.className = "presults" + box.append(input, presults) + let timer = 0 + input.addEventListener("input", () => { + clearTimeout(timer) + timer = setTimeout(async () => { + const q = input.value.trim() + if (!q) { presults.replaceChildren(); return } + try { + const photos = await searchPhotos(q, 12) + presults.replaceChildren() + for (const p of photos) { + const opt = photoBox(p) + opt.addEventListener("click", async () => { + try { + await api("/api/admin/graph", { method: "PUT", body: JSON.stringify({ id, dir, target: p.id }), headers: { "Content-Type": "application/json" } }) + toast(`set ${dir} → ${p.title || p.id}`) + box.hidden = true + loadGraph(id) + } catch (e2) { toast(e2.message) } + }) + presults.appendChild(opt) + } + } catch (e2) { toast(e2.message) } + }, 220) + }) + row.appendChild(box) +} + +function renderGraph(d) { + const wrap = $("#g-graph") + wrap.hidden = false + wrap.replaceChildren() + const head = document.createElement("div") + head.className = "grow-head" + const info = d.dirs[0]?.current || d.dirs[0]?.default + if (info && info.thumb) { + const img = document.createElement("img") + img.src = info.thumb + img.alt = "" + head.appendChild(img) + } + const t = document.createElement("div") + const title = document.createElement("div") + title.className = "t" + title.textContent = info?.title || `photo ${d.id}` + const idLine = document.createElement("div") + idLine.className = "id" + idLine.textContent = `#${d.id}` + t.append(title, idLine) + head.appendChild(t) + wrap.appendChild(head) + + for (const dir of d.dirs) { + const row = document.createElement("div") + row.className = "dir-row" + + const badge = document.createElement("div") + badge.className = "dir-badge" + badge.textContent = dir.dir + + const dest = document.createElement("div") + dest.className = "dir-dest" + if (dir.current) { + const img = document.createElement("img") + img.src = dir.current.thumb + img.alt = "" + const meta = document.createElement("div") + const t2 = document.createElement("div") + t2.className = "t" + t2.textContent = dir.current.title || `photo ${dir.current.id}` + const m2 = document.createElement("div") + m2.className = "meta" + m2.textContent = `${dir.current.date || "#" + dir.current.id} · ${dir.path.label}: ${dir.path.name}` + meta.append(t2, m2) + dest.append(img, meta) + if (dir.overridden) { + const b = document.createElement("span") + b.className = "override-badge" + b.textContent = "override" + meta.appendChild(b) + } + } else { + dest.textContent = "no destination" + dest.style.color = "var(--muted)" + } + + const actions = document.createElement("div") + actions.className = "dir-actions" + const change = document.createElement("button") + change.textContent = dir.overridden ? "change" : "set" + change.addEventListener("click", () => { + const picker = row.querySelector(".picker") + if (picker) { picker.hidden = !picker.hidden; return } + const p = document.createElement("div") + p.className = "picker" + pickerRow(p, dir.dir, d.id) + row.appendChild(p) + }) + const reset = document.createElement("button") + reset.textContent = "default" + reset.disabled = !dir.overridden + reset.addEventListener("click", async () => { + try { + await api("/api/admin/graph", { method: "PUT", body: JSON.stringify({ id: d.id, dir: dir.dir, target: null }), headers: { "Content-Type": "application/json" } }) + toast(`reset ${dir.dir} to default`) + loadGraph(d.id) + } catch (err) { toast(err.message) } + }) + actions.append(change, reset) + + row.append(badge, dest, actions) + pickerRow(row, dir.dir, d.id) + wrap.appendChild(row) + } +} + +async function loadGraph(id) { + try { + const d = await api(`/api/admin/graph/${id}`) + currentGraphId = id + renderGraph(d) + $("#g-graph").scrollIntoView({ behavior: "smooth", block: "nearest" }) + } catch (err) { + toast(err.message) + } +} + +async function enterDash() { + show("dash") + try { + await loadStatus() + } catch (err) { if (views.dash.hidden) return; } +} + +async function init() { + document.querySelectorAll(".tab").forEach((b) => { + b.addEventListener("click", () => { + document.querySelectorAll(".tab").forEach((x) => x.classList.remove("active")) + b.classList.add("active") + const tab = b.dataset.tab + $("#tab-status").hidden = tab !== "status" + $("#tab-graph").hidden = tab !== "graph" + }) + }) + + $("#setup-form").addEventListener("submit", async (e) => { + e.preventDefault() + const err = $("#setup-err") + const pw = $("#setup-pw").value + const pw2 = $("#setup-pw2").value + err.textContent = "" + if (pw.length < 8) { err.textContent = "password must be at least 8 characters"; return } + if (pw !== pw2) { err.textContent = "passwords don't match"; return } + try { + await api("/api/admin/setup", { method: "POST", body: JSON.stringify({ password: pw }), headers: { "Content-Type": "application/json" } }) + toast("password set — sign in") + $("#setup-pw").value = "" + $("#setup-pw2").value = "" + show("login") + } catch (err2) { errInto(err, err2) } + }) + + $("#login-form").addEventListener("submit", async (e) => { + e.preventDefault() + const err = $("#login-err") + err.textContent = "" + try { + const data = await api("/api/admin/login", { method: "POST", body: JSON.stringify({ password: $("#login-pw").value, remember: $("#login-remember").checked }), headers: { "Content-Type": "application/json" } }) + setToken(data.token, !!data.remember) + $("#login-pw").value = "" + await enterDash() + } catch (err2) { errInto(err, err2) } + }) + + $("#logout").addEventListener("click", async () => { + try { await api("/api/admin/logout", { method: "POST" }) } catch { /* ignore */ } + clearToken() + show("login") + }) + + $("#rebuild").addEventListener("click", async () => { + const btn = $("#rebuild") + btn.disabled = true + try { + const r = await api("/api/admin/refresh", { method: "POST" }) + toast(`rebuilt — ${r.total} photos`) + await loadStatus() + } catch (err) { toast(err.message) } + btn.disabled = false + }) + + $("#reset-graph").addEventListener("click", async () => { + if (!confirm("Clear all walk-graph overrides?")) return + try { + await api("/api/admin/graph/reset", { method: "POST" }) + toast("all overrides cleared") + await loadStatus() + if (currentGraphId != null) loadGraph(currentGraphId) + } catch (err) { toast(err.message) } + }) + + $("#gq").addEventListener("input", () => debounce(runGraphSearch, 220)) + + const token = getToken() + if (token) { + try { + await loadStatus() + show("dash") + return + } catch { /* 401 handled → login view */ } + } + try { + const s = await api("/api/admin/setup-state") + show(s.configured ? "login" : "setup") + } catch (err) { + show("login") + toast(err.message) + } +} + +init() diff --git a/public/app.js b/public/app.js index 0959509..311faa9 100644 --- a/public/app.js +++ b/public/app.js @@ -14,6 +14,16 @@ const progressText = $("#progress-text") const splash = $("#splash") const startBtn = $("#start") +function adminToken() { + for (const store of [localStorage, sessionStorage]) { + try { + const raw = store.getItem("pathways_admin") + if (raw) { const t = JSON.parse(raw).token; if (t) return t } + } catch { /* ignore */ } + } + return null +} + addEventListener("error", (e) => { toast(`error: ${e.message || "script failed"}`, true) }) @@ -306,7 +316,7 @@ function choosePlacement(id, dir) { scored.sort((a, b) => a.cost - b.cost) const best = scored[0] const next = ex.next && !rectOf.has(ex.next.id) ? scored.find((s) => s.id === ex.next.id) : null - const chosen = next && next.cost - best.cost <= FIT_TOLERANCE ? next : best + const chosen = (ex.forced && next) || (next && next.cost - best.cost <= FIT_TOLERANCE) ? next : best const out = { id: chosen.id, info: chosen.info, rect: chosen.rect } placementCache.set(key, out) return out @@ -843,6 +853,8 @@ function uploadWithProgress(form, onPct, onUploaded) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.open("POST", "/api/import") + const tok = adminToken() + if (tok) xhr.setRequestHeader("Authorization", "Bearer " + tok) xhr.upload.addEventListener("progress", (e) => { if (e.lengthComputable) onPct(Math.min(100, Math.round((e.loaded / e.total) * 100))) }) diff --git a/public/index.html b/public/index.html index 24503ba..8b1f7d4 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ pathways - +
@@ -20,6 +20,7 @@ import photos +

sign in to manage the walk graph & uploads: admin

@@ -34,6 +35,6 @@
- + -- cgit v1.3.1