aboutsummaryrefslogtreecommitdiff
path: root/public
diff options
context:
space:
mode:
authordax <me@dax.ist>2026-08-04 16:54:07 +0100
committerdax <me@dax.ist>2026-08-04 16:54:07 +0100
commitdadf0909dd235a7764a59a63d2b51861fa81e385 (patch)
treefad8563b7c2d9f7f9216b001a1f89ba5db5f5479 /public
parent6a0dc37b2e5580987df8fd11becdff53b32c9836 (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.css117
-rw-r--r--public/app.js161
-rw-r--r--public/index.html22
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 &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>
+ </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>