aboutsummaryrefslogtreecommitdiff
path: root/public
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
parentea71584d1fc54d4d6f5799914632328f1327dc08 (diff)
admin panel for walk-graph management; /api/import gated behind admin login
Diffstat (limited to 'public')
-rw-r--r--public/admin.css212
-rw-r--r--public/admin.html70
-rw-r--r--public/admin.js385
-rw-r--r--public/app.js14
-rw-r--r--public/index.html5
5 files changed, 683 insertions, 3 deletions
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 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <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=2">
+</head>
+<body>
+ <main id="app">
+ <section id="view-setup" hidden>
+ <h1>pathways<span class="dot">.</span> admin</h1>
+ <p class="sub">no admin password is set yet. create one to manage the walk graph and uploads.</p>
+ <form id="setup-form" autocomplete="off">
+ <input type="password" id="setup-pw" minlength="8" required placeholder="password (min 8 chars)">
+ <input type="password" id="setup-pw2" required placeholder="repeat password">
+ <button type="submit">set password</button>
+ </form>
+ <p class="err" id="setup-err"></p>
+ </section>
+
+ <section id="view-login" hidden>
+ <h1>pathways<span class="dot">.</span> admin</h1>
+ <form id="login-form">
+ <input type="password" id="login-pw" required placeholder="password" autocomplete="current-password">
+ <label class="remember"><input type="checkbox" id="login-remember" checked> remember this device</label>
+ <button type="submit">sign in</button>
+ </form>
+ <p class="err" id="login-err"></p>
+ </section>
+
+ <section id="view-dash" hidden>
+ <header class="top">
+ <h1>pathways<span class="dot">.</span> admin</h1>
+ <nav>
+ <button class="tab active" data-tab="status">status</button>
+ <button class="tab" data-tab="graph">walk graph</button>
+ <button id="logout">sign out</button>
+ </nav>
+ </header>
+
+ <section id="tab-status">
+ <div class="cards">
+ <div class="card"><span class="k">photos</span><span class="v" id="st-total">–</span></div>
+ <div class="card"><span class="k">graph built</span><span class="v" id="st-built">–</span></div>
+ <div class="card"><span class="k">built age</span><span class="v" id="st-age">–</span></div>
+ <div class="card"><span class="k">overrides</span><span class="v" id="st-overrides">–</span></div>
+ <div class="card"><span class="k">database</span><span class="v" id="st-db">–</span></div>
+ <div class="card"><span class="k">uptime</span><span class="v" id="st-up">–</span></div>
+ </div>
+ <p class="facets" id="st-facets"></p>
+ <div class="row">
+ <button id="rebuild">rebuild walk graph</button>
+ <button id="reset-graph" class="danger">clear all overrides</button>
+ </div>
+ <p class="err" id="dash-err"></p>
+ </section>
+
+ <section id="tab-graph" hidden>
+ <input type="search" id="gq" placeholder="search photos by title or id…">
+ <div class="results" id="g-results" hidden></div>
+ <div class="graph" id="g-graph" hidden></div>
+ </section>
+ </section>
+ </main>
+ <div id="toast"></div>
+ <script src="/admin.js?v=2"></script>
+</body>
+</html>
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 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>pathways</title>
- <link rel="stylesheet" href="/app.css?v=66">
+ <link rel="stylesheet" href="/app.css?v=67">
</head>
<body>
<main id="stage">
@@ -20,6 +20,7 @@
<span>import photos</span>
</label>
<p id="direct-hint" class="direct-hint" hidden>upload slow? use the direct link <a id="direct-link" href="#"></a></p>
+ <p class="direct-hint">sign in to manage the walk graph &amp; uploads: <a href="/admin">admin</a></p>
</div>
</main>
<div id="splash">
@@ -34,6 +35,6 @@
<span id="progress-text"></span>
</div>
<div id="toast" role="status" aria-live="polite"></div>
- <script src="/app.js?v=66"></script>
+ <script src="/app.js?v=67"></script>
</body>
</html>