aboutsummaryrefslogtreecommitdiff
path: root/public/admin.js
diff options
context:
space:
mode:
authordax <me@dax.ist>2026-08-05 15:24:33 +0100
committerdax <me@dax.ist>2026-08-05 15:24:33 +0100
commit03dcc91c96ac45ff005a1c3e14a71a5733037447 (patch)
tree48cbb120853132e299dd79559321265a5a8fdd42 /public/admin.js
parentea71584d1fc54d4d6f5799914632328f1327dc08 (diff)
admin panel for walk-graph management; /api/import gated behind admin login
Diffstat (limited to 'public/admin.js')
-rw-r--r--public/admin.js385
1 files changed, 385 insertions, 0 deletions
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()