diff options
Diffstat (limited to 'public/admin.js')
| -rw-r--r-- | public/admin.js | 385 |
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() |
