aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordax <me@dax.ist>2026-08-04 05:21:18 +0000
committerdax <me@dax.ist>2026-08-04 05:21:18 +0000
commit90042156c8538cf02efe77a09d0fe671c50e4ab2 (patch)
tree0d3e090f33c2ff2fbe65235a2155251dfa844a1d
pathways: photo viewer over the PhotoPrism library
Groups photos into paths by shared label, keyword, camera, lens and dominant colour. Node server.mjs (no build) reads the MariaDB photoprism database read-only; public/ is a zero-dependency front end with progressive reveal, a lightbox with connection chips, and a dominant-colour swatch under every thumbnail.
-rw-r--r--.gitignore36
-rw-r--r--README.md52
-rw-r--r--package-lock.json157
-rw-r--r--package.json13
-rw-r--r--public/app.css150
-rw-r--r--public/app.js281
-rw-r--r--public/index.html40
-rw-r--r--server.mjs224
8 files changed, 953 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fd3dbb5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,36 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+.yarn/install-state.gz
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..346e910
--- /dev/null
+++ b/README.md
@@ -0,0 +1,52 @@
+# pathways
+
+A photo viewer that organises a PhotoPrism library into paths — shared labels,
+keywords, cameras, lenses, and dominant colours — instead of a flat grid or
+albums.
+
+The live instance lives at <https://pathways.dax.ist> and reads the PhotoPrism
+database directly (read-only) on the same machine. PhotoPrism's admin UI
+stays behind basic auth at <https://prism.dax.ist>; pathways is public.
+
+## How it works
+
+- `server.mjs` — a single Node process (`nodejs_22`, no build step). It pools
+ the MariaDB `photoprism` database, groups the primary files into *paths*
+ (facets with ≥3 photos) and exposes three endpoints:
+ - `GET /api/paths` — every photo (id, thumb, title, date) plus the paths
+ - `GET /api/photo/:id` — one photo plus its connections across labels,
+ keywords, camera, and lens
+ - `GET /api/health` — liveness probe
+- `public/` — the front end. Progressive reveal on scroll, a lightbox with
+ connection chips that jump you to the linked path, and a dominant-colour
+ swatch under every thumbnail. In colour mode the paths themselves are
+ computed client-side from each photo's dominant hue.
+
+Thumbnails are served as static files by Caddy from
+`/srv/photoprism/storage/cache/thumbnails/{a}/{b}/{c}/{hash}_{size}.jpg`
+(`720x720_fit` for the grid, `1920x1200_fit` in the lightbox).
+
+## Run it
+
+```bash
+npm install
+DB_HOST=127.0.0.1 DB_USER=photoprism DB_PASSWORD=… DB_NAME=photoprism \
+ PORT=3100 THUMBS_ROOT=/srv/photoprism/storage/cache/thumbnails \
+ node server.mjs
+```
+
+On NixOS the box runs it as a systemd unit (`systemd.services.pathways`),
+started after `mysql.service`, as user `dax`.
+
+## Layout
+
+```
+server.mjs API + static file serving
+public/index.html viewer page
+public/app.css styling
+public/app.js viewer logic (paths, colours, lightbox)
+```
+
+The thumbnail path sharding matches PhotoPrism: directory = first three
+characters of the file hash. Note `file_hash` comes back from MariaDB as a
+binary Buffer, so it is decoded to a string before building URLs.
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..502601e
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,157 @@
+{
+ "name": "pathways",
+ "version": "0.2.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "pathways",
+ "version": "0.2.0",
+ "dependencies": {
+ "mysql2": "^3.10.2"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "26.1.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
+ "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/aws-ssl-profiles": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
+ "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/generate-function": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
+ "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-property": "^1.0.2"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/is-property": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
+ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
+ "license": "MIT"
+ },
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/lru.min": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
+ "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
+ "license": "MIT",
+ "engines": {
+ "bun": ">=1.0.0",
+ "deno": ">=1.30.0",
+ "node": ">=8.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wellwelwel"
+ }
+ },
+ "node_modules/mysql2": {
+ "version": "3.23.2",
+ "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz",
+ "integrity": "sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "aws-ssl-profiles": "^1.1.2",
+ "denque": "^2.1.0",
+ "generate-function": "^2.3.1",
+ "iconv-lite": "^0.7.2",
+ "long": "^5.3.2",
+ "lru.min": "^1.1.4",
+ "named-placeholders": "^1.1.6",
+ "sql-escaper": "^1.5.1"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">= 8"
+ }
+ },
+ "node_modules/named-placeholders": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
+ "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
+ "license": "MIT",
+ "dependencies": {
+ "lru.min": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/sql-escaper": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
+ "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==",
+ "license": "MIT",
+ "engines": {
+ "bun": ">=1.0.0",
+ "deno": ">=2.0.0",
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "license": "MIT",
+ "peer": true
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2530250
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "pathways",
+ "version": "0.2.0",
+ "private": true,
+ "type": "module",
+ "description": "pathways - a novel photo viewer over the photoprism database",
+ "scripts": {
+ "start": "node server.mjs"
+ },
+ "dependencies": {
+ "mysql2": "^3.10.2"
+ }
+}
diff --git a/public/app.css b/public/app.css
new file mode 100644
index 0000000..b72761c
--- /dev/null
+++ b/public/app.css
@@ -0,0 +1,150 @@
+:root {
+ --ink: #111;
+ --paper: #faf8f5;
+ --line: #ddd6cc;
+ --accent: #b3000a;
+ --mono: "SF Mono", ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
+ --serif: Georgia, "Times New Roman", serif;
+}
+* { box-sizing: border-box; }
+html { background: var(--paper); }
+body {
+ margin: 0;
+ color: var(--ink);
+ font-family: var(--mono);
+ font-size: 14px;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+.mast {
+ max-width: 980px;
+ margin: 0 auto;
+ padding: 48px 24px 8px;
+}
+.mast h1 {
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: 44px;
+ letter-spacing: -0.02em;
+ margin: 0 0 4px;
+}
+.mast h1 .dot { color: var(--accent); }
+.tag { margin: 0 0 22px; color: #555; font-size: 13px; }
+.modes { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
+.modes button {
+ font: inherit;
+ background: none;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 5px 14px;
+ color: var(--ink);
+ cursor: pointer;
+}
+.modes button.on { background: var(--ink); color: var(--paper); border-color: var(--ink); }
+.status { margin: 0; color: #888; font-size: 12px; min-height: 18px; }
+
+.stream { max-width: 980px; margin: 0 auto; padding: 12px 24px 40px; }
+
+.thread { margin: 34px 0 0; }
+.thread-head {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ margin-bottom: 10px;
+}
+.thread-head h2 {
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: 22px;
+ margin: 0;
+}
+.thread-head .count { color: var(--accent); font-size: 12px; }
+.strip {
+ display: flex;
+ gap: 6px;
+ overflow-x: auto;
+ padding-bottom: 8px;
+ scrollbar-width: thin;
+}
+.strip a { flex: 0 0 auto; display: block; }
+.strip img {
+ display: block;
+ width: 140px;
+ height: 140px;
+ object-fit: cover;
+ border-radius: 3px;
+ background: #eee;
+ transition: transform 120ms ease;
+}
+.strip a:hover img { transform: scale(1.03); }
+.swatch {
+ display: block;
+ height: 3px;
+ background: #ccc;
+ border-radius: 0 0 3px 3px;
+ margin-top: 2px;
+}
+
+.lightbox {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.82);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 50;
+}
+.lightbox[hidden] { display: none; }
+.lightbox-inner {
+ display: flex;
+ gap: 24px;
+ max-width: 1180px;
+ width: calc(100% - 40px);
+ background: var(--paper);
+ border-radius: 4px;
+ padding: 18px;
+ position: relative;
+ align-items: flex-start;
+}
+.lightbox-inner img {
+ max-width: min(760px, 72%);
+ max-height: 82vh;
+ object-fit: contain;
+ border-radius: 2px;
+ background: #000;
+}
+.lightbox-side { flex: 1; min-width: 220px; padding-right: 30px; }
+.lightbox-side h2 { font-family: var(--serif); font-weight: 400; font-size: 24px; margin: 0 0 4px; }
+.dim { color: #666; }
+.conns { margin-top: 16px; }
+.conns h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #888; margin: 0 0 8px; }
+.conns .chip {
+ display: inline-block;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 4px 12px;
+ margin: 0 6px 6px 0;
+ font-size: 12px;
+ cursor: pointer;
+}
+.conns .chip:hover { border-color: var(--accent); color: var(--accent); }
+.conns .chip b { color: var(--accent); font-weight: 600; }
+.close {
+ position: absolute;
+ top: 8px;
+ right: 12px;
+ border: none;
+ background: none;
+ font-size: 26px;
+ cursor: pointer;
+ color: #555;
+}
+.close:hover { color: var(--ink); }
+
+.foot {
+ max-width: 980px;
+ margin: 0 auto;
+ padding: 10px 24px 40px;
+ color: #aaa;
+ font-size: 11px;
+}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..406b6ce
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,281 @@
+"use strict"
+
+const $ = (sel) => document.querySelector(sel)
+const stream = $("#stream")
+const status = $("#status")
+const modes = $("#modes")
+
+const PHOTOS = new Map() // id -> {thumb, title, date}
+const FACETS = new Map() // type -> facet[]
+const COLOURS = new Map() // photo id -> [r,g,b]
+
+let currentMode = "label"
+let revealed = 0
+const PER_BATCH = 4
+
+/* ---- tiny observable-style stream ---- */
+
+const observable = (subscribe) => {
+ const listeners = []
+ subscribe({
+ next: (v) => listeners.slice().forEach((fn) => fn(v)),
+ error: (e) => listeners.slice().forEach((fn) => fn && fn(e)),
+ })
+ return {
+ subscribe: (fn) => {
+ listeners.push(fn)
+ return () => listeners.splice(listeners.indexOf(fn), 1)
+ },
+ }
+}
+
+const fromEvent = (target, event) =>
+ observable(({ next }) => target.addEventListener(event, next))
+
+const debounce = (ms) => (source) =>
+ observable(({ next }) => {
+ let t
+ return source.subscribe((v) => {
+ clearTimeout(t)
+ t = setTimeout(() => next(v), ms)
+ })
+ })
+
+const map = (fn) => (source) =>
+ observable(({ next }) => source.subscribe((v) => next(fn(v))))
+
+/* ---- dominant colour from a thumbnail ---- */
+
+function colourFrom(img) {
+ const size = 24
+ const canvas = document.createElement("canvas")
+ canvas.width = size
+ canvas.height = size
+ const ctx = canvas.getContext("2d")
+ try {
+ ctx.drawImage(img, 0, 0, size, size)
+ const { data } = ctx.getImageData(0, 0, size, size)
+ let r = 0, g = 0, b = 0, n = 0
+ for (let i = 0; i < data.length; i += 16) {
+ r += data[i]; g += data[i + 1]; b += data[i + 2]; n++
+ }
+ return [Math.round(r / n), Math.round(g / n), Math.round(b / n)]
+ } catch {
+ return [128, 128, 128]
+ }
+}
+
+function rgbToHue([r, g, b]) {
+ r /= 255; g /= 255; b /= 255
+ const max = Math.max(r, g, b), min = Math.min(r, g, b)
+ const d = max - min
+ if (d === 0) return -1
+ let h
+ if (max === r) h = ((g - b) / d) % 6
+ else if (max === g) h = (b - r) / d + 2
+ else h = (r - g) / d + 4
+ h = Math.round(h * 60)
+ return h < 0 ? h + 360 : h
+}
+
+const HUE_NAMES = [
+ "red", "orange", "yellow", "yellow", "green", "green",
+ "green", "cyan", "blue", "blue", "violet", "magenta", "red",
+]
+
+async function colourFor(id, src) {
+ if (COLOURS.has(id)) return COLOURS.get(id)
+ return new Promise((resolve) => {
+ const img = new Image()
+ img.crossOrigin = "anonymous"
+ img.onload = () => { const c = colourFrom(img); COLOURS.set(id, c); resolve(c) }
+ img.onerror = () => { const c = [128, 128, 128]; COLOURS.set(id, c); resolve(c) }
+ img.src = src
+ })
+}
+
+/* ---- rendering ---- */
+
+function thumbEl(photo, { full = false } = {}) {
+ const a = document.createElement("a")
+ a.href = `#photo-${photo.id}`
+ a.dataset.id = photo.id
+ const img = document.createElement("img")
+ img.loading = "lazy"
+ img.alt = photo.title || "photo"
+ img.src = photo.thumb
+ a.appendChild(img)
+ const sw = document.createElement("span")
+ sw.className = "swatch"
+ a.appendChild(sw)
+ colourFor(photo.id, photo.thumb).then(([r, g, b]) => {
+ sw.style.background = `rgb(${r},${g},${b})`
+ })
+ a.addEventListener("click", (e) => { e.preventDefault(); openLightbox(photo.id) })
+ return a
+}
+
+function threadEl(facet) {
+ const sec = document.createElement("section")
+ sec.className = "thread"
+ sec.id = `thread-${facet.type}-${facet.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`
+ const head = document.createElement("div")
+ head.className = "thread-head"
+ const h = document.createElement("h2")
+ h.textContent = facet.name
+ const c = document.createElement("span")
+ c.className = "count"
+ c.textContent = `${facet.count}`
+ head.append(h, c)
+ const strip = document.createElement("div")
+ strip.className = "strip"
+ for (const id of facet.ids) {
+ const p = PHOTOS.get(id)
+ if (p) strip.appendChild(thumbEl(p))
+ }
+ sec.append(head, strip)
+ return sec
+}
+
+function renderChunk() {
+ const facets = FACETS.get(currentMode) || []
+ const next = facets.slice(revealed, revealed + PER_BATCH)
+ const frag = document.createDocumentFragment()
+ for (const f of next) frag.appendChild(threadEl(f))
+ stream.appendChild(frag)
+ revealed += next.length
+ if (revealed >= facets.length) {
+ status.textContent = `${facets.length} paths shown`
+ sentinel.unobserve()
+ } else {
+ status.textContent = `${revealed} of ${facets.length} paths`
+ }
+}
+
+const sentinel = document.createElement("div")
+sentinel.id = "sentinel"
+
+const reveal$ = map(() => renderChunk())(debounce(60)(fromEvent(window, "scroll")))
+reveal$.subscribe(() => {})
+new IntersectionObserver((entries) => {
+ if (entries[0].isIntersecting) renderChunk()
+}).observe(sentinel)
+
+/* ---- colour paths ---- */
+
+const SWATCH_NAMES = [
+ "red", "orange", "gold", "yellow", "lime", "green",
+ "teal", "cyan", "azure", "blue", "violet", "magenta",
+]
+
+function colourPaths(ids) {
+ const buckets = new Map()
+ for (const id of ids) {
+ const c = COLOURS.get(id)
+ if (!c) continue
+ const hue = rgbToHue(c)
+ const bucket = hue < 0 ? 0 : Math.round(hue / 30) % 12
+ if (!buckets.has(bucket)) buckets.set(bucket, [])
+ buckets.get(bucket).push(id)
+ }
+ const out = []
+ for (const [bucket, bucketIds] of buckets) {
+ if (bucketIds.length < 3) continue
+ out.push({ type: "colour", label: "Colours", name: SWATCH_NAMES[bucket], count: bucketIds.length, ids: bucketIds })
+ }
+ return out.sort((a, b) => b.count - a.count)
+}
+
+/* ---- lightbox ---- */
+
+async function openLightbox(id) {
+ const box = $("#lightbox")
+ const resp = await fetch(`/api/photo/${id}`)
+ if (!resp.ok) return
+ const data = await resp.json()
+ $("#lightbox-img").src = data.photo.full
+ $("#lightbox-title").textContent = data.photo.title || "untitled"
+ $("#lightbox-date").textContent = data.photo.date
+ const conns = $("#lightbox-conns")
+ conns.innerHTML = ""
+ const h = document.createElement("h3")
+ h.textContent = "connections"
+ conns.appendChild(h)
+ for (const c of data.connections) {
+ const chip = document.createElement("span")
+ chip.className = "chip"
+ chip.textContent = `${c.name} · `
+ const b = document.createElement("b")
+ b.textContent = `${c.n}`
+ chip.appendChild(b)
+ chip.addEventListener("click", () => { closeLightbox(); jumpTo(c.type, c.name) })
+ conns.appendChild(chip)
+ }
+ box.hidden = false
+}
+
+function closeLightbox() {
+ $("#lightbox").hidden = true
+}
+
+function jumpTo(type, name) {
+ setMode(type)
+ const t = FACETS.get(type).find((f) => f.name === name)
+ if (!t) return
+ const el = document.getElementById(`thread-${type}-${t.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`)
+ if (el) {
+ el.scrollIntoView({ behavior: "smooth", block: "start" })
+ el.firstElementChild.animate([{ opacity: 0.4 }, { opacity: 1 }], { duration: 700 })
+ }
+}
+
+/* ---- mode switching ---- */
+
+function setMode(mode) {
+ if (mode === currentMode && revealed > 0) return
+ currentMode = mode
+ revealed = 0
+ stream.innerHTML = ""
+ stream.appendChild(sentinel)
+ for (const b of modes.children) b.classList.toggle("on", b.dataset.mode === mode)
+ renderChunk()
+}
+
+function buildColourMode() {
+ if (FACETS.has("colour")) return
+ const ids = Array.from(PHOTOS.keys())
+ Promise.all(ids.map((id) => colourFor(id, PHOTOS.get(id).thumb))).then(() => {
+ FACETS.set("colour", colourPaths(ids))
+ if (currentMode === "colour") renderChunk()
+ })
+}
+
+modes.addEventListener("click", (e) => {
+ const b = e.target.closest("button")
+ if (!b) return
+ const mode = b.dataset.mode
+ setMode(mode)
+ if (mode === "colour") buildColourMode()
+})
+
+$("#lightbox-close").addEventListener("click", closeLightbox)
+$("#lightbox").addEventListener("click", (e) => { if (e.target === $("#lightbox")) closeLightbox() })
+document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeLightbox() })
+
+/* ---- boot ---- */
+
+async function boot() {
+ status.textContent = "finding paths…"
+ const resp = await fetch("/api/paths")
+ if (!resp.ok) { status.textContent = "could not reach the library"; return }
+ const data = await resp.json()
+ for (const [id, p] of Object.entries(data.photos)) PHOTOS.set(Number(id), p)
+ for (const f of data.facets) {
+ if (!FACETS.has(f.type)) FACETS.set(f.type, [])
+ FACETS.get(f.type).push(f)
+ }
+ stream.appendChild(sentinel)
+ renderChunk()
+}
+
+boot()
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..e59101c
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,40 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>pathways — photo viewer</title>
+ <link rel="stylesheet" href="/app.css">
+</head>
+<body>
+ <header class="mast">
+ <h1>pathways<span class="dot">.</span></h1>
+ <p class="tag">a way through the library, along the things photos share.</p>
+ <nav id="modes" class="modes" aria-label="path type">
+ <button data-mode="label" class="on">labels</button>
+ <button data-mode="keyword">keywords</button>
+ <button data-mode="camera">cameras</button>
+ <button data-mode="lens">lenses</button>
+ <button data-mode="colour">colours</button>
+ </nav>
+ <p id="status" class="status"></p>
+ </header>
+
+ <main id="stream" class="stream"></main>
+
+ <div id="lightbox" class="lightbox" hidden>
+ <div class="lightbox-inner">
+ <img id="lightbox-img" alt="">
+ <div class="lightbox-side">
+ <h2 id="lightbox-title"></h2>
+ <p id="lightbox-date" class="dim"></p>
+ <div id="lightbox-conns" class="conns"></div>
+ </div>
+ <button id="lightbox-close" class="close" aria-label="close">×</button>
+ </div>
+ </div>
+
+ <footer class="foot">pathways · built over the photoprism database · no tracking</footer>
+ <script src="/app.js" defer></script>
+</body>
+</html>
diff --git a/server.mjs b/server.mjs
new file mode 100644
index 0000000..0c1dac6
--- /dev/null
+++ b/server.mjs
@@ -0,0 +1,224 @@
+import http from "node:http"
+import { readFile } from "node:fs/promises"
+import { createReadStream, existsSync } from "node:fs"
+import { extname, join, normalize } from "node:path"
+import mysql from "mysql2/promise"
+
+const DB = {
+ host: process.env.DB_HOST || "127.0.0.1",
+ port: Number(process.env.DB_PORT || 3306),
+ user: process.env.DB_USER || "photoprism",
+ password: process.env.DB_PASSWORD || "",
+ database: process.env.DB_NAME || "photoprism",
+ connectionLimit: 4,
+}
+
+const PORT = Number(process.env.PORT || 3100)
+const THUMBS = process.env.THUMBS_ROOT || "/srv/photoprism/storage/cache/thumbnails"
+const PUBLIC = join(process.cwd(), "public")
+const MIN_PATH = 3
+
+const pool = mysql.createPool(DB)
+
+const THUMB_SIZE = "720x720_fit"
+const BIG_SIZE = "1920x1200_fit"
+const thumb = (hash, size = THUMB_SIZE) => {
+ if (!hash) return null
+ const s = hash.toString()
+ return `/thumbs/${s[0]}/${s[1]}/${s[2]}/${s}_${size}.jpg`
+}
+
+let state = null
+
+async function load() {
+ const [photos] = await pool.query(
+ `SELECT p.id, p.photo_title, p.photo_year, p.photo_month, p.photo_day,
+ f.file_hash
+ FROM photos p
+ JOIN files f ON f.photo_id = p.id AND f.file_primary = 1 AND f.file_missing = 0
+ WHERE p.deleted_at IS NULL
+ ORDER BY p.taken_at_local`
+ )
+
+ const byId = new Map()
+ for (const p of photos) {
+ byId.set(p.id, {
+ id: p.id,
+ thumb: thumb(p.file_hash),
+ title: p.photo_title || "",
+ date: p.photo_year
+ ? `${p.photo_year}-${String(p.photo_month || 1).padStart(2, "0")}-${String(p.photo_day || 1).padStart(2, "0")}`
+ : "",
+ })
+ }
+
+ const facetQueries = [
+ {
+ type: "label",
+ label: "Labels",
+ q: `SELECT pl.label_id AS k, l.label_name AS name, COUNT(*) AS n
+ FROM photos_labels pl JOIN labels l ON l.id = pl.label_id
+ GROUP BY pl.label_id HAVING n >= ? ORDER BY n DESC`,
+ member: `SELECT label_id AS k, photo_id FROM photos_labels`,
+ },
+ {
+ type: "keyword",
+ label: "Keywords",
+ q: `SELECT pk.keyword_id AS k, kw.keyword AS name, COUNT(*) AS n
+ FROM photos_keywords pk JOIN keywords kw ON kw.id = pk.keyword_id
+ GROUP BY pk.keyword_id HAVING n >= ? ORDER BY n DESC`,
+ member: `SELECT keyword_id AS k, photo_id FROM photos_keywords`,
+ },
+ {
+ type: "camera",
+ label: "Cameras",
+ q: `SELECT p.camera_id AS k, CONCAT(c.camera_make, ' ', c.camera_model) AS name, COUNT(*) AS n
+ FROM photos p JOIN cameras c ON c.id = p.camera_id
+ WHERE p.camera_id <> 1
+ GROUP BY p.camera_id HAVING n >= ? ORDER BY n DESC`,
+ member: `SELECT camera_id AS k, id AS photo_id FROM photos WHERE camera_id <> 1`,
+ },
+ {
+ type: "lens",
+ label: "Lenses",
+ q: `SELECT p.lens_id AS k, l.lens_model AS name, COUNT(*) AS n
+ FROM photos p JOIN lenses l ON l.id = p.lens_id
+ WHERE p.lens_id <> 1
+ GROUP BY p.lens_id HAVING n >= ? ORDER BY n DESC`,
+ member: `SELECT lens_id AS k, id AS photo_id FROM photos WHERE lens_id <> 1`,
+ },
+ ]
+
+ const facets = []
+ for (const f of facetQueries) {
+ const [rows] = await pool.query(f.q, [MIN_PATH])
+ const [members] = await pool.query(f.member)
+ const groups = new Map()
+ for (const m of members) {
+ if (!groups.has(m.k)) groups.set(m.k, [])
+ groups.get(m.k).push(m.photo_id)
+ }
+ for (const r of rows) {
+ const ids = (groups.get(r.k) || []).filter((id) => byId.has(id))
+ if (ids.length >= MIN_PATH) {
+ facets.push({ type: f.type, label: f.label, name: r.name, count: ids.length, ids })
+ }
+ }
+ }
+
+ return { builtAt: new Date().toISOString(), photos: Object.fromEntries(byId), facets }
+}
+
+async function refresh() {
+ try {
+ state = await load()
+ } catch (err) {
+ console.error("pathways: refresh failed:", err.message)
+ }
+}
+
+const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "text/javascript", ".svg": "image/svg+xml", ".ico": "image/x-icon" }
+
+function serveFile(res, path) {
+ if (!existsSync(path)) {
+ res.writeHead(404).end("not found")
+ return
+ }
+ res.writeHead(200, { "Content-Type": MIME[extname(path)] || "application/octet-stream", "Cache-Control": "max-age=300" })
+ createReadStream(path).pipe(res)
+}
+
+function json(res, code, data) {
+ res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" })
+ res.end(JSON.stringify(data))
+}
+
+async function photoConnections(id) {
+ const [ph] = await pool.query(
+ `SELECT p.id, p.photo_title, p.photo_year, p.photo_month, p.photo_day,
+ f.file_hash
+ FROM photos p
+ JOIN files f ON f.photo_id = p.id AND f.file_primary = 1 AND f.file_missing = 0
+ WHERE p.id = ?`,
+ [id]
+ )
+ if (ph.length === 0) return null
+ const p = ph[0]
+ const conns = []
+
+ const [labels] = await pool.query(
+ `SELECT l.label_name AS name, COUNT(*) AS n
+ FROM photos_labels pl JOIN labels l ON l.id = pl.label_id
+ WHERE pl.photo_id = ? GROUP BY pl.label_id`,
+ [id]
+ )
+ const [keywords] = await pool.query(
+ `SELECT kw.keyword AS name, COUNT(*) AS n
+ FROM photos_keywords pk JOIN keywords kw ON kw.id = pk.keyword_id
+ WHERE pk.photo_id = ? GROUP BY pk.keyword_id`,
+ [id]
+ )
+ const [camera] = await pool.query(
+ `SELECT CONCAT(c.camera_make, ' ', c.camera_model) AS name, COUNT(*) AS n
+ FROM photos p JOIN cameras c ON c.id = p.camera_id WHERE p.id = ?`,
+ [id]
+ )
+ const [lens] = await pool.query(
+ `SELECT l.lens_model AS name, COUNT(*) AS n
+ FROM photos p JOIN lenses l ON l.id = p.lens_id WHERE p.id = ?`,
+ [id]
+ )
+
+ for (const r of labels) conns.push({ type: "label", name: r.name, n: r.n })
+ for (const r of keywords) conns.push({ type: "keyword", name: r.name, n: r.n })
+ for (const r of camera) conns.push({ type: "camera", name: r.name, n: r.n })
+ for (const r of lens) conns.push({ type: "lens", name: r.name, n: r.n })
+
+ return {
+ photo: {
+ id: p.id,
+ thumb: thumb(p.file_hash),
+ full: thumb(p.file_hash, BIG_SIZE),
+ title: p.photo_title || "",
+ date: p.photo_year ? `${p.photo_year}-${String(p.photo_month || 1).padStart(2, "0")}-${String(p.photo_day || 1).padStart(2, "0")}` : "",
+ },
+ connections: conns,
+ }
+}
+
+const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
+ const path = url.pathname
+
+ try {
+ if (path === "/api/paths") {
+ if (!state) await refresh()
+ return json(res, 200, state)
+ }
+ const photoMatch = path.match(/^\/api\/photo\/(\d+)$/)
+ if (photoMatch) {
+ const data = await photoConnections(Number(photoMatch[1]))
+ if (!data) return json(res, 404, { error: "not found" })
+ return json(res, 200, data)
+ }
+ if (path === "/api/health") return json(res, 200, { ok: true })
+
+ if (path === "/" || path === "") {
+ return serveFile(res, join(PUBLIC, "index.html"))
+ }
+ const safe = normalize(path).replace(/^(\.\.[/\\])+/, "")
+ const file = join(PUBLIC, safe)
+ if (file.startsWith(PUBLIC)) return serveFile(res, file)
+
+ res.writeHead(404).end("not found")
+ } catch (err) {
+ console.error("pathways:", err)
+ json(res, 500, { error: "internal error" })
+ }
+})
+
+server.listen(PORT, "127.0.0.1", () => {
+ console.log(`pathways listening on 127.0.0.1:${PORT}`)
+ refresh()
+})
+setInterval(refresh, 30 * 60 * 1000).unref()