aboutsummaryrefslogtreecommitdiff
path: root/public
diff options
context:
space:
mode:
Diffstat (limited to 'public')
-rw-r--r--public/app.css150
-rw-r--r--public/app.js281
-rw-r--r--public/index.html40
3 files changed, 471 insertions, 0 deletions
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>