diff options
Diffstat (limited to 'public')
| -rw-r--r-- | public/admin.css | 42 | ||||
| -rw-r--r-- | public/admin.html | 23 | ||||
| -rw-r--r-- | public/admin.js | 87 | ||||
| -rw-r--r-- | public/app.css | 104 | ||||
| -rw-r--r-- | public/app.js | 138 | ||||
| -rw-r--r-- | public/index.html | 22 |
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 · png · webp — 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 — 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 & uploads: <a href="/admin">admin</a></p> + <p>your library is empty — add photos from the admin panel.</p> + <a id="import" href="/admin">admin</a> + <p class="direct-hint">upload & 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> |
