From dadf0909dd235a7764a59a63d2b51861fa81e385 Mon Sep 17 00:00:00 2001 From: dax Date: Tue, 4 Aug 2026 16:54:07 +0100 Subject: 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 --- public/app.js | 163 ++++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 123 insertions(+), 40 deletions(-) (limited to 'public/app.js') 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() { - 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 */ } - })() +function showImportState() { + importState.hidden = false + stage.classList.add("blocked") +} + +function hideImportState() { + importState.hidden = true + stage.classList.remove("blocked") } -function noteActivity() { +async function beginWalk() { if (started) return - const left = MIN_READ - (Date.now() - splashShownAt) - if (left <= 0) start() - else { - clearTimeout(splashTimer) - splashTimer = setTimeout(start, left) + started = true + 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 */ } +} + +;(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 = "" + } +} + +function hasFiles(e) { + return Array.from(e.dataTransfer?.types || []).includes("Files") } -startBtn.addEventListener("click", start) -addEventListener("pointermove", noteActivity) -addEventListener("pointerdown", noteActivity) -addEventListener("keydown", noteActivity) -addEventListener("wheel", noteActivity) -addEventListener("touchstart", noteActivity) + +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) +}) -- cgit v1.3.1