"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() await loadRecent() } catch (err) { if (views.dash.hidden) return; } } async function loadRecent() { const data = await api("/api/admin/photos?recent=1&limit=12") const grid = $("#im-new") grid.replaceChildren() for (const p of data.photos) { const box = photoBox(p) box.addEventListener("click", () => { document.querySelectorAll(".tab").forEach((x) => x.classList.remove("active")) document.querySelector('.tab[data-tab="graph"]').classList.add("active") $("#tab-status").hidden = true $("#tab-import").hidden = true $("#tab-graph").hidden = false loadGraph(p.id) }) grid.appendChild(box) } $("#im-recent").hidden = data.photos.length === 0 } function setImProgress(pct, text) { $("#im-progress").hidden = false $("#im-status").textContent = text const fill = $("#im-fill") if (pct === "indeterminate") fill.classList.add("indeterminate") else { fill.classList.remove("indeterminate") fill.style.width = pct + "%" } } function uploadImport(form) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.open("POST", "/api/import") xhr.setRequestHeader("Authorization", "Bearer " + getToken()) xhr.upload.addEventListener("progress", (e) => { if (e.lengthComputable) setImProgress(Math.min(100, Math.round((e.loaded / e.total) * 100))) }) xhr.upload.addEventListener("load", () => setImProgress("indeterminate", "indexing…")) xhr.addEventListener("load", () => { let data = {} try { data = JSON.parse(xhr.responseText) } catch { /* ignore */ } if (xhr.status >= 200 && xhr.status < 300) resolve(data) else reject(new Error(data.error || `import failed (${xhr.status})`)) }) xhr.addEventListener("error", () => reject(new Error("upload failed (network error)"))) xhr.send(form) }) } async function importFiles(list) { const files = Array.from(list || []).filter((f) => f.size > 0) if (!files.length) return const form = new FormData() for (const f of files) form.append("photos", f, f.name) const dz = $("#dz") dz.classList.add("busy") $("#im-err").textContent = "" try { const data = await uploadImport(form) toast(`imported ${data.imported} ${data.imported === 1 ? "photo" : "photos"}`) await loadStatus() await loadRecent() } catch (err) { $("#im-err").textContent = err.message } finally { dz.classList.remove("busy") $("#im-progress").hidden = true $("#im-files").value = "" } } 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-import").hidden = tab !== "import" $("#tab-graph").hidden = tab !== "graph" if (tab === "import") loadRecent().catch((e2) => toast(e2.message)) }) }) $("#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 dz = $("#dz") dz.addEventListener("click", () => $("#im-files").click()) dz.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("#im-files").click() } }) $("#im-files").addEventListener("change", () => importFiles($("#im-files").files)) dz.addEventListener("dragover", (e) => { e.preventDefault(); dz.classList.add("drag") }) dz.addEventListener("dragleave", () => dz.classList.remove("drag")) dz.addEventListener("drop", (e) => { e.preventDefault() dz.classList.remove("drag") importFiles(e.dataTransfer.files) }) 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()