aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--public/app.css117
-rw-r--r--public/app.js161
-rw-r--r--public/index.html22
-rw-r--r--server.mjs178
5 files changed, 423 insertions, 56 deletions
diff --git a/.gitignore b/.gitignore
index 6659201..5e3e4a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ node_modules/
.DS_Store
*.log
.env*
+staging/
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>
diff --git a/server.mjs b/server.mjs
index 5ae51a0..e783acc 100644
--- a/server.mjs
+++ b/server.mjs
@@ -1,9 +1,14 @@
import http from "node:http"
-import { readFile } from "node:fs/promises"
+import { readFile, mkdir, writeFile, rm } from "node:fs/promises"
import { createReadStream, existsSync } from "node:fs"
import { extname, join, normalize } from "node:path"
+import { execFile } from "node:child_process"
+import { randomUUID } from "node:crypto"
+import { promisify } from "node:util"
import mysql from "mysql2/promise"
+const execFileAsync = promisify(execFile)
+
const DB = {
host: process.env.DB_HOST || "127.0.0.1",
port: Number(process.env.DB_PORT || 3306),
@@ -17,6 +22,16 @@ const PORT = Number(process.env.PORT || 3100)
const PUBLIC = join(process.cwd(), "public")
const MIN_PATH = 3
+const STAGING_ROOT = process.env.STAGING_ROOT || join(process.cwd(), "staging")
+const PHOTOPRISM_BIN = process.env.PHOTOPRISM_BIN || "photoprism"
+const PHOTOPRISM_USER = process.env.PHOTOPRISM_USER || "photoprism"
+const PHOTOPRISM_HOME = process.env.PHOTOPRISM_HOME || "/srv/photoprism"
+const ORIGINALS_PATH = process.env.PHOTOPRISM_ORIGINALS_PATH || "/srv/photoprism/originals"
+const STORAGE_PATH = process.env.PHOTOPRISM_STORAGE_PATH || "/srv/photoprism/storage"
+const MAX_UPLOAD_BYTES = Number(process.env.MAX_UPLOAD_BYTES || 200 * 1024 * 1024)
+const MAX_UPLOAD_FILES = Number(process.env.MAX_UPLOAD_FILES || 20)
+const IMPORT_TIMEOUT_MS = Number(process.env.IMPORT_TIMEOUT_MS || 10 * 60 * 1000)
+
const pool = mysql.createPool(DB)
const THUMB_SIZE = "1280x1024_fit"
@@ -152,7 +167,7 @@ async function load() {
exits.set(pid, list)
}
- return { builtAt: new Date().toISOString(), photos: Object.fromEntries(byId), facets, photoOrder, byId, exits }
+ return { builtAt: new Date().toISOString(), total: photoOrder.length, photos: Object.fromEntries(byId), facets, photoOrder, byId, exits }
}
async function refresh() {
@@ -181,6 +196,163 @@ function json(res, code, data) {
res.end(JSON.stringify(data))
}
+const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+
+const SIGNATURES = [
+ { ext: "jpg", accepts: /\.jpe?g$/i, test: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
+ { ext: "png", accepts: /\.png$/i, test: (b) => b.length > 8 && b.subarray(0, 8).equals(PNG_MAGIC) },
+ {
+ ext: "webp",
+ accepts: /\.webp$/i,
+ test: (b) => b.length > 12 && b.subarray(0, 4).toString("latin1") === "RIFF" && b.subarray(8, 12).toString("latin1") === "WEBP",
+ },
+]
+
+function sniff(buf) {
+ return SIGNATURES.find((s) => s.test(buf)) || null
+}
+
+function readBody(req, limit) {
+ return new Promise((resolve, reject) => {
+ const chunks = []
+ let size = 0
+ req.on("data", (c) => {
+ size += c.length
+ if (size > limit) {
+ const err = new Error("payload too large")
+ err.code = "TOO_LARGE"
+ reject(err)
+ req.destroy()
+ return
+ }
+ chunks.push(c)
+ })
+ req.on("end", () => resolve(Buffer.concat(chunks)))
+ req.on("error", reject)
+ })
+}
+
+function parseMultipart(buf, boundary) {
+ const parts = []
+ const delim = Buffer.from(`--${boundary}`)
+ const sep = Buffer.from("\r\n\r\n")
+ let pos = buf.indexOf(delim)
+ if (pos < 0) return parts
+ pos += delim.length
+ while (pos < buf.length) {
+ if (buf[pos] === 0x2d && buf[pos + 1] === 0x2d) break
+ if (buf[pos] === 0x0d && buf[pos + 1] === 0x0a) pos += 2
+ const headEnd = buf.indexOf(sep, pos)
+ if (headEnd < 0) break
+ const headers = buf.subarray(pos, headEnd).toString("utf8")
+ const bodyStart = headEnd + sep.length
+ const next = buf.indexOf(delim, bodyStart)
+ if (next < 0) break
+ let bodyEnd = next
+ if (buf[bodyEnd - 2] === 0x0d && buf[bodyEnd - 1] === 0x0a) bodyEnd -= 2
+ parts.push({ headers, body: buf.subarray(bodyStart, bodyEnd) })
+ pos = next + delim.length
+ }
+ return parts
+}
+
+function fileNameOf(headers) {
+ const m = /filename\*?=(?:UTF-8''([^\r\n;]+)|"([^"]*)"|([^\r\n;]+))/i.exec(headers)
+ if (!m) return null
+ const raw = m[1] ? decodeURIComponent(m[1]) : (m[2] ?? m[3] ?? "")
+ return raw.trim()
+}
+
+function safeName(raw, sig, taken) {
+ const base = (raw.split(/[\\/]/).pop() || "").replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "").slice(0, 120)
+ let name = base || "upload"
+ if (!sig.accepts.test(name)) name += `.${sig.ext}`
+ let out = name
+ let n = 1
+ while (taken.has(out)) {
+ const dot = name.lastIndexOf(".")
+ out = `${name.slice(0, dot)}-${n}${name.slice(dot)}`
+ n++
+ }
+ taken.add(out)
+ return out
+}
+
+function photoprismCopy(dir) {
+ const env = {
+ PHOTOPRISM_DATABASE_DRIVER: "mysql",
+ PHOTOPRISM_DATABASE_SERVER: `${DB.host}:${DB.port}`,
+ PHOTOPRISM_DATABASE_NAME: DB.database,
+ PHOTOPRISM_DATABASE_USER: DB.user,
+ PHOTOPRISM_DATABASE_PASSWORD: DB.password,
+ PHOTOPRISM_ORIGINALS_PATH: ORIGINALS_PATH,
+ PHOTOPRISM_STORAGE_PATH: STORAGE_PATH,
+ PHOTOPRISM_READONLY: "false",
+ PHOTOPRISM_DISABLE_TENSORFLOW: "true",
+ PHOTOPRISM_DISABLE_CLASSIFICATION: "true",
+ PHOTOPRISM_DISABLE_FACES: "true",
+ PHOTOPRISM_LOG_LEVEL: "info",
+ }
+ const assignments = Object.entries(env).map(([k, v]) => `${k}=${v}`)
+ const args = ["-n", "-u", PHOTOPRISM_USER, "env", ...assignments, PHOTOPRISM_BIN, "cp", dir]
+ return execFileAsync("sudo", args, { cwd: PHOTOPRISM_HOME, timeout: IMPORT_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 })
+}
+
+async function handleImport(req, res) {
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" })
+
+ const ctype = req.headers["content-type"] || ""
+ const bm = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(ctype)
+ if (!/^multipart\/form-data/i.test(ctype) || !bm) {
+ return json(res, 400, { error: "expected multipart/form-data" })
+ }
+
+ let body
+ try {
+ body = await readBody(req, MAX_UPLOAD_BYTES)
+ } catch (err) {
+ if (err.code === "TOO_LARGE") return json(res, 413, { error: `upload exceeds ${MAX_UPLOAD_BYTES} bytes` })
+ return json(res, 400, { error: "could not read upload" })
+ }
+
+ const taken = new Set()
+ const files = []
+ for (const part of parseMultipart(body, (bm[1] || bm[2]).trim())) {
+ const raw = fileNameOf(part.headers)
+ if (!raw || part.body.length === 0) continue
+ const sig = sniff(part.body)
+ if (!sig) return json(res, 415, { error: `unsupported file type: ${raw}` })
+ if (files.length >= MAX_UPLOAD_FILES) return json(res, 413, { error: `too many files (max ${MAX_UPLOAD_FILES})` })
+ files.push({ name: safeName(raw, sig, taken), data: part.body })
+ }
+ if (files.length === 0) return json(res, 400, { error: "no image files in request" })
+
+ const batch = randomUUID()
+ const dir = join(STAGING_ROOT, batch)
+ try {
+ await mkdir(dir, { recursive: true })
+ for (const f of files) await writeFile(join(dir, f.name), f.data)
+ } catch (err) {
+ console.error("pathways: staging failed:", err.message)
+ return json(res, 500, { error: "could not stage upload" })
+ }
+
+ try {
+ const { stdout, stderr } = await photoprismCopy(dir)
+ console.log(`pathways: imported ${files.length} file(s) via batch ${batch}`)
+ if (stderr) console.error(`pathways: photoprism cp stderr: ${stderr.slice(-2000)}`)
+ else if (stdout) console.log(`pathways: photoprism cp: ${stdout.slice(-2000)}`)
+ } catch (err) {
+ const detail = (err.stderr || err.stdout || err.message || "").slice(-2000)
+ console.error(`pathways: import failed (batch ${batch} kept at ${dir}):`, detail)
+ return json(res, 500, { error: "photoprism import failed", batch, detail })
+ }
+
+ await refresh()
+ await rm(dir, { recursive: true, force: true }).catch(() => {})
+ return json(res, 200, { ok: true, imported: files.length, batch })
+}
+
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
const path = url.pathname
@@ -212,6 +384,7 @@ const server = http.createServer(async (req, res) => {
photos,
})
}
+ if (path === "/api/import") return await handleImport(req, res)
if (path === "/api/health") return json(res, 200, { ok: true })
if (path === "/" || path === "") {
@@ -230,6 +403,7 @@ const server = http.createServer(async (req, res) => {
server.listen(PORT, "127.0.0.1", () => {
console.log(`pathways listening on 127.0.0.1:${PORT}`)
+ mkdir(STAGING_ROOT, { recursive: true }).catch((err) => console.error("pathways: staging dir:", err.message))
refresh()
})
setInterval(refresh, 30 * 60 * 1000).unref()