aboutsummaryrefslogtreecommitdiff
path: root/public
diff options
context:
space:
mode:
Diffstat (limited to 'public')
-rw-r--r--public/admin.css42
-rw-r--r--public/admin.html23
-rw-r--r--public/admin.js87
-rw-r--r--public/app.css104
-rw-r--r--public/app.js138
-rw-r--r--public/index.html22
6 files changed, 157 insertions, 259 deletions
diff --git a/public/admin.css b/public/admin.css
index d698076..98dbd09 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -115,6 +115,48 @@ button.danger:hover { border-color: var(--danger); background: rgba(252, 165, 16
#tab-graph { display: grid; gap: 14px; }
#gq { padding: 12px 14px; }
+#tab-import { display: grid; gap: 14px; }
+.dropzone {
+ border: 2px dashed rgba(255, 255, 255, .22);
+ border-radius: 12px;
+ padding: 34px 20px;
+ text-align: center;
+ cursor: pointer;
+ outline: none;
+ transition: border-color .15s, background .15s;
+}
+.dropzone:hover, .dropzone:focus-visible, .dropzone.drag {
+ border-color: var(--ring);
+ background: rgba(125, 211, 252, .06);
+}
+.dropzone.busy { pointer-events: none; opacity: .6; }
+.dropzone .dz-icon { font-size: 30px; line-height: 1; color: var(--ring); }
+.dropzone p { margin: 8px 0 0; font-size: 14px; }
+.dropzone .dz-note { font-size: 12px; color: var(--muted); }
+
+.progress {
+ background: var(--panel);
+ border: 1px solid rgba(255, 255, 255, .08);
+ border-radius: 10px;
+ padding: 12px;
+ display: grid;
+ gap: 8px;
+}
+.bar { height: 6px; background: var(--panel-2); border-radius: 999px; overflow: hidden; }
+.bar .fill { height: 100%; width: 0; background: var(--ring); transition: width .2s; }
+.bar .fill.indeterminate { width: 30%; animation: slide 1s infinite ease-in-out; }
+@keyframes slide { 0% { margin-left: -30%; } 100% { margin-left: 100%; } }
+.im-status { margin: 0; font-size: 13px; color: var(--muted); }
+
+.recent h2 {
+ font-size: 14px;
+ font-weight: 500;
+ letter-spacing: .08em;
+ text-transform: uppercase;
+ color: var(--muted);
+ margin: 0 0 8px;
+}
+
.results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
diff --git a/public/admin.html b/public/admin.html
index c722af6..61fd6e1 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -5,7 +5,7 @@
<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">
+ <link rel="stylesheet" href="/admin.css?v=3">
</head>
<body>
<main id="app">
@@ -35,6 +35,7 @@
<h1>pathways<span class="dot">.</span> admin</h1>
<nav>
<button class="tab active" data-tab="status">status</button>
+ <button class="tab" data-tab="import">import</button>
<button class="tab" data-tab="graph">walk graph</button>
<button id="logout">sign out</button>
</nav>
@@ -57,6 +58,24 @@
<p class="err" id="dash-err"></p>
</section>
+ <section id="tab-import" hidden>
+ <div class="dropzone" id="dz" tabindex="0">
+ <input type="file" id="im-files" accept="image/jpeg,image/png,image/webp" multiple hidden>
+ <div class="dz-icon">+</div>
+ <p>drop photos here, or click to choose</p>
+ <p class="dz-note">jpg &middot; png &middot; webp &mdash; copied into the library and indexed</p>
+ </div>
+ <div class="progress" id="im-progress" hidden>
+ <div class="bar"><div class="fill" id="im-fill"></div></div>
+ <p class="im-status" id="im-status"></p>
+ </div>
+ <p class="err" id="im-err"></p>
+ <div class="recent" id="im-recent" hidden>
+ <h2>recently added</h2>
+ <div class="results" id="im-new"></div>
+ </div>
+ </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>
@@ -65,6 +84,6 @@
</section>
</main>
<div id="toast"></div>
- <script src="/admin.js?v=2"></script>
+ <script src="/admin.js?v=3"></script>
</body>
</html>
diff --git a/public/admin.js b/public/admin.js
index fa74635..0540236 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -293,9 +293,82 @@ 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", () => {
@@ -303,7 +376,9 @@ async function init() {
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))
})
})
@@ -365,6 +440,18 @@ async function init() {
$("#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 {
diff --git a/public/app.css b/public/app.css
index 5f4aaab..079139b 100644
--- a/public/app.css
+++ b/public/app.css
@@ -192,21 +192,9 @@ html, body {
}
.direct-hint a:hover { border-bottom-color: var(--ring); }
-#file {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0 0 0 0);
- border: 0;
- opacity: 0;
-}
#import-state h1 { margin: 0; font-size: 44px; font-weight: 300; letter-spacing: .18em; }
#import-state h1 .dot { color: var(--ring); }
#import-state p { max-width: 34ch; color: var(--muted); margin: 0; font-size: 15px; line-height: 1.6; }
-/* app.css?v=38 */
#start {
margin-top: 10px;
padding: 10px 34px;
@@ -231,56 +219,15 @@ html, body {
padding: 10px 22px;
font-size: 14px;
letter-spacing: .06em;
+ text-decoration: none;
border: 1px solid rgba(255, 255, 255, .3);
border-radius: 999px;
background: transparent;
color: var(--ink);
cursor: pointer;
- opacity: 0;
- transform: translateY(6px);
- transition: opacity .4s, transform .4s, border-color .15s, background .15s;
-}
-#import:not([hidden]) { opacity: 1; transform: translateY(0); }
-#import svg {
- width: 17px;
- height: 17px;
- fill: none;
- stroke: currentColor;
- stroke-width: 1.7;
- stroke-linecap: round;
- stroke-linejoin: round;
+ transition: border-color .15s, background .15s;
}
#import:hover { border-color: var(--ring); background: rgba(125, 211, 252, .08); }
-#import.busy {
- border-color: var(--ring);
- color: var(--ring);
- pointer-events: none;
- animation: pulse 1.1s ease-in-out infinite;
-}
-#import[hidden] { display: none; }
-
-@keyframes pulse {
- 0%, 100% { opacity: .55; }
- 50% { opacity: 1; }
-}
-
-#drop {
- position: fixed;
- inset: 0;
- z-index: 9;
- display: grid;
- place-content: center;
- background: rgba(14, 15, 19, .82);
- border: 2px dashed rgba(125, 211, 252, .55);
- color: var(--ink);
- font-size: 16px;
- letter-spacing: .1em;
- opacity: 0;
- visibility: hidden;
- pointer-events: none;
- transition: opacity .15s, visibility .15s;
-}
-#drop.show { opacity: 1; visibility: visible; pointer-events: auto; }
#toast {
position: fixed;
@@ -304,54 +251,7 @@ html, body {
#toast.show { opacity: 1; visibility: visible; pointer-events: auto; }
#toast.err { border-color: rgba(248, 113, 113, .6); color: #fca5a5; }
-#progress {
- position: fixed;
- left: 50%;
- bottom: 26px;
- transform: translateX(-50%);
- z-index: 40;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 9px;
- min-width: 280px;
- max-width: 70vw;
- padding: 13px 18px;
- border: 1px solid rgba(255, 255, 255, .18);
- border-radius: 10px;
- background: rgba(14, 15, 19, .95);
-}
-#progress[hidden] { display: none; }
-#progress-track {
- width: 100%;
- height: 6px;
- border-radius: 3px;
- background: rgba(255, 255, 255, .12);
- overflow: hidden;
-}
-#progress-fill {
- height: 100%;
- width: 0%;
- border-radius: 3px;
- background: var(--ring);
- transition: width .15s ease;
-}
-#progress.indeterminate #progress-fill {
- width: 40%;
- animation: progress-slide 1.1s linear infinite;
-}
-@keyframes progress-slide {
- 0% { transform: translateX(-110%); }
- 100% { transform: translateX(360%); }
-}
-#progress-text {
- font-size: 12px;
- letter-spacing: .04em;
- color: var(--muted);
-}
-
@media (prefers-reduced-motion: reduce) {
#world { transition: none; }
.tile.lock { animation: none; }
- #upload.busy { animation: none; }
}
diff --git a/public/app.js b/public/app.js
index 311faa9..746a9fa 100644
--- a/public/app.js
+++ b/public/app.js
@@ -4,26 +4,10 @@ const $ = (s) => document.querySelector(s)
const stage = $("#stage")
const world = $("#world")
const importState = $("#import-state")
-const importEl = $("#import")
-const fileInput = $("#file")
-const dropOverlay = $("#drop")
const toastEl = $("#toast")
-const progressEl = $("#progress")
-const progressFill = $("#progress-fill")
-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)
})
@@ -734,21 +718,6 @@ function hideImportState() {
stage.classList.remove("blocked")
}
-async function initDirectHint() {
- try {
- const r = await fetch("/api/health")
- const data = await r.json()
- const d = data.direct || {}
- const urls = [d.magic, d.lan, d.tailscale].filter(Boolean)
- if (!urls.length) return
- if (urls.some((u) => { try { return new URL(u).host === location.host } catch { return false } })) return
- const link = $("#direct-link")
- link.textContent = d.magic
- link.href = d.magic
- $("#direct-hint").hidden = false
- } catch { /* ignore */ }
-}
-
async function beginWalk() {
if (started) return
started = true
@@ -786,7 +755,6 @@ let hasPhotos = false
splash.hidden = true
showImportState()
}
- initDirectHint()
})()
const MIN_READ = 4000
@@ -827,109 +795,3 @@ function toast(msg, isError = false) {
clearTimeout(toastTimer)
toastTimer = setTimeout(() => toastEl.classList.remove("show"), isError ? 7000 : 3500)
}
-
-function plural(n) {
- return n === 1 ? "photo" : "photos"
-}
-
-function setProgress(mode, pct, text) {
- progressEl.hidden = false
- progressText.textContent = text
- if (mode === "indeterminate") {
- progressEl.classList.add("indeterminate")
- } else {
- progressEl.classList.remove("indeterminate")
- progressFill.style.width = pct + "%"
- }
-}
-
-function hideProgress() {
- progressEl.hidden = true
- progressEl.classList.remove("indeterminate")
- progressFill.style.width = "0%"
-}
-
-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)))
- })
- xhr.upload.addEventListener("load", () => onUploaded())
- 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)
- importEl.classList.add("busy")
- setProgress("determinate", 0, `uploading ${files.length} ${plural(files.length)}… 0%`)
- try {
- const data = await uploadWithProgress(
- form,
- (pct) => setProgress("determinate", pct, `uploading ${files.length} ${plural(files.length)}… ${pct}%`),
- () => setProgress("indeterminate", 100, "indexing…")
- )
- if (!data.ok) throw new Error(data.error || "import failed")
- exitsCache.clear()
- exitPending.clear()
- hideProgress()
- hideImportState()
- await beginWalk()
- toast(`imported ${data.imported} ${plural(data.imported)} - keep walking to find them`)
- } catch (err) {
- hideProgress()
- toast(err.message || "import failed", true)
- } finally {
- importEl.classList.remove("busy")
- fileInput.value = ""
- }
-}
-
-function hasFiles(e) {
- return Array.from(e.dataTransfer?.types || []).includes("Files")
-}
-
-
-fileInput.addEventListener("change", () => importFiles(fileInput.files))
-
-let dragDepth = 0
-
-addEventListener("dragenter", (e) => {
- if (!hasFiles(e)) return
- e.preventDefault()
- dragDepth++
- dropOverlay.classList.add("show")
-})
-
-addEventListener("dragover", (e) => {
- if (!hasFiles(e)) return
- e.preventDefault()
- e.dataTransfer.dropEffect = "copy"
-})
-
-addEventListener("dragleave", () => {
- dragDepth = Math.max(0, dragDepth - 1)
- if (dragDepth === 0) dropOverlay.classList.remove("show")
-})
-
-addEventListener("drop", (e) => {
- if (!e.dataTransfer) return
- e.preventDefault()
- dragDepth = 0
- dropOverlay.classList.remove("show")
- importFiles(e.dataTransfer.files)
-})
diff --git a/public/index.html b/public/index.html
index 8b1f7d4..106b69b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,22 +5,16 @@
<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=67">
+ <link rel="stylesheet" href="/app.css?v=68">
</head>
<body>
<main id="stage">
<div id="world"></div>
<div id="import-state" hidden>
<h1>pathways<span class="dot">.</span></h1>
- <p>your library is empty &mdash; add some photos to begin.</p>
- <label id="import" for="file">
- <svg viewBox="0 0 24 24" aria-hidden="true">
- <path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5M4 16v2.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V16"/>
- </svg>
- <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>
+ <p>your library is empty &mdash; add photos from the admin panel.</p>
+ <a id="import" href="/admin">admin</a>
+ <p class="direct-hint">upload &amp; manage the walk graph there</p>
</div>
</main>
<div id="splash">
@@ -28,13 +22,7 @@
<p>four paths, one per direction. walk them.</p>
<button id="start">start</button>
</div>
- <input id="file" type="file" accept="image/jpeg,image/png,image/webp" multiple>
- <div id="drop"><span>drop photos to import</span></div>
- <div id="progress" hidden>
- <div id="progress-track"><div id="progress-fill"></div></div>
- <span id="progress-text"></span>
- </div>
<div id="toast" role="status" aria-live="polite"></div>
- <script src="/app.js?v=67"></script>
+ <script src="/app.js?v=68"></script>
</body>
</html>