diff options
| author | dax <me@dax.ist> | 2026-08-04 16:54:07 +0100 |
|---|---|---|
| committer | dax <me@dax.ist> | 2026-08-04 16:54:07 +0100 |
| commit | dadf0909dd235a7764a59a63d2b51861fa81e385 (patch) | |
| tree | fad8563b7c2d9f7f9216b001a1f89ba5db5f5479 /public | |
| parent | 6a0dc37b2e5580987df8fd11becdff53b32c9836 (diff) | |
add photo import: upload -> photoprism, fix desktop file dialog
- POST /api/import with native multipart parser (no new deps); stages files
then delegates indexing+thumbnailing to PhotoPrism via `photoprism cp`
- pathways reflects new photos immediately via refresh()
- upload UI shown only in empty-library state
- nixos: PHOTOPRISM_READONLY=false; pathways service gets STAGING_ROOT and
photoprism env; PATH includes /run/wrappers/bin so sudo is found
- fix: stage.setPointerCapture was swallowing the import label's click on
desktop, preventing the native file dialog from opening
Diffstat (limited to 'public')
| -rw-r--r-- | public/app.css | 117 | ||||
| -rw-r--r-- | public/app.js | 161 | ||||
| -rw-r--r-- | public/index.html | 22 |
3 files changed, 246 insertions, 54 deletions
diff --git a/public/app.css b/public/app.css index 8a1b201..24bb927 100644 --- a/public/app.css +++ b/public/app.css @@ -25,6 +25,10 @@ html, body { user-select: none; } +#stage.blocked { + pointer-events: none; +} + #world { position: absolute; left: 0; @@ -128,22 +132,35 @@ html, body { } } -#splash { +#import-state { position: fixed; inset: 0; + z-index: 10; display: grid; place-content: center; justify-items: center; gap: 14px; text-align: center; - background: var(--bg); - z-index: 10; - transition: opacity .4s, visibility .4s; + padding: 24px; + pointer-events: auto; +} +#import-state[hidden] { display: none; } + +#file { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + border: 0; + opacity: 0; } -#splash.hide { opacity: 0; visibility: hidden; pointer-events: none; } -#splash h1 { margin: 0; font-size: 44px; font-weight: 300; letter-spacing: .18em; } -#splash h1 .dot { color: var(--ring); } -#splash p { max-width: 34ch; color: var(--muted); margin: 0; font-size: 15px; line-height: 1.6; } +#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; @@ -158,7 +175,91 @@ html, body { } #start:hover { border-color: var(--ring); background: rgba(125, 211, 252, .08); } +#import { + margin-top: 14px; + position: relative; + z-index: 11; + display: inline-flex; + align-items: center; + gap: 9px; + padding: 10px 22px; + font-size: 14px; + letter-spacing: .06em; + 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; +} +#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; + right: 18px; + bottom: 72px; + z-index: 8; + max-width: 46ch; + padding: 9px 15px; + border: 1px solid rgba(255, 255, 255, .18); + border-radius: 8px; + background: rgba(14, 15, 19, .94); + color: var(--ink); + font-size: 13px; + line-height: 1.5; + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translateY(4px); + transition: opacity .2s, visibility .2s, transform .2s; +} +#toast.show { opacity: 1; visibility: visible; pointer-events: auto; } +#toast.err { border-color: rgba(248, 113, 113, .6); color: #fca5a5; } + @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 11f19b9..dfd6e6b 100644 --- a/public/app.js +++ b/public/app.js @@ -3,8 +3,15 @@ const $ = (s) => document.querySelector(s) const stage = $("#stage") const world = $("#world") -const splash = $("#splash") -const startBtn = $("#start") +const importState = $("#import-state") +const importEl = $("#import") +const fileInput = $("#file") +const dropOverlay = $("#drop") +const toastEl = $("#toast") + +addEventListener("error", (e) => { + toast(`error: ${e.message || "script failed"}`, true) +}) const GAP = 10 const FIT_TOLERANCE = 350 @@ -515,8 +522,9 @@ let pinchDist = 0 stage.addEventListener("pointerdown", (e) => { if (e.target.closest(".nav-zone")) return + if (e.target.closest("#import-state")) return pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) - stage.setPointerCapture(e.pointerId) + try { stage.setPointerCapture(e.pointerId) } catch { /* ignore */ } hideGhost() world.style.transition = "none" if (pointers.size === 2) { @@ -638,48 +646,123 @@ addEventListener("keydown", (e) => { addEventListener("resize", center) -const MIN_READ = 1500 -const splashShownAt = Date.now() let started = false -let splashTimer = 0 -function start() { +function showImportState() { + importState.hidden = false + stage.classList.add("blocked") +} + +function hideImportState() { + importState.hidden = true + stage.classList.remove("blocked") +} + +async function beginWalk() { if (started) return started = true - clearTimeout(splashTimer) - splash.classList.add("hide") - ;(async () => { - try { - const r = await fetch("/api/random") - const data = await r.json() - const photo = data.photo - photoInfo.set(photo.id, photo) - const a = aspect(photo) - const wf = a >= 1 ? a : 1 - const hf = a >= 1 ? 1 : 1 / a - const uW = (window.innerWidth * 0.92) / wf - const uH = (window.innerHeight * 0.92) / hf - U = Math.max(100, Math.min(640, Math.round(Math.min(uW, uH)))) - const rect = { x: 0, y: 0, w: tileW(photo), h: tileH(photo) } - addTile(photo, rect) - setCurrent(photo.id, rect) - } catch { /* ignore */ } - })() + try { + const r = await fetch("/api/random") + const data = await r.json() + if (!data.photo) { + showImportState() + return + } + hideImportState() + const photo = data.photo + photoInfo.set(photo.id, photo) + const a = aspect(photo) + const wf = a >= 1 ? a : 1 + const hf = a >= 1 ? 1 : 1 / a + const uW = (window.innerWidth * 0.92) / wf + const uH = (window.innerHeight * 0.92) / hf + U = Math.max(100, Math.min(640, Math.round(Math.min(uW, uH)))) + const rect = { x: 0, y: 0, w: tileW(photo), h: tileH(photo) } + addTile(photo, rect) + setCurrent(photo.id, rect) + } catch { /* ignore */ } } -function noteActivity() { - if (started) return - const left = MIN_READ - (Date.now() - splashShownAt) - if (left <= 0) start() - else { - clearTimeout(splashTimer) - splashTimer = setTimeout(start, left) +;(async () => { + try { + const r = await fetch("/api/paths") + const data = await r.json() + if (data.total) await beginWalk() + else showImportState() + } catch { + showImportState() + } +})() + +let toastTimer = 0 + +function toast(msg, isError = false) { + toastEl.textContent = msg + toastEl.classList.toggle("err", isError) + toastEl.classList.add("show") + clearTimeout(toastTimer) + toastTimer = setTimeout(() => toastEl.classList.remove("show"), isError ? 7000 : 3500) +} + +function plural(n) { + return n === 1 ? "photo" : "photos" +} + +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") + toast(`importing ${files.length} ${plural(files.length)}...`) + try { + const r = await fetch("/api/import", { method: "POST", body: form }) + const data = await r.json().catch(() => ({})) + if (!r.ok || !data.ok) throw new Error(data.error || `import failed (${r.status})`) + exitsCache.clear() + exitPending.clear() + hideImportState() + await beginWalk() + toast(`imported ${data.imported} ${plural(data.imported)} - keep walking to find them`) + } catch (err) { + toast(err.message || "import failed", true) + } finally { + importEl.classList.remove("busy") + fileInput.value = "" } } -startBtn.addEventListener("click", start) -addEventListener("pointermove", noteActivity) -addEventListener("pointerdown", noteActivity) -addEventListener("keydown", noteActivity) -addEventListener("wheel", noteActivity) -addEventListener("touchstart", noteActivity) +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 549ca5d..0f44c6b 100644 --- a/public/index.html +++ b/public/index.html @@ -5,17 +5,25 @@ <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=26"> + <link rel="stylesheet" href="/app.css?v=43"> </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> + </div> </main> - <div id="splash"> - <h1>pathways<span class="dot">.</span></h1> - <p>four paths, one per direction. walk them.</p> - <button id="start">start</button> - </div> - <script src="/app.js?v=27"></script> + <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="toast" role="status" aria-live="polite"></div> + <script src="/app.js?v=43"></script> </body> </html> |
