/* Image Library — single-page app, no backend. * * Reads `images.json` (generated by scripts/generate-manifest.mjs), renders a * searchable grid, and copies an image's absolute URL to the clipboard on click. */ (() => { "use strict"; const MANIFEST_URL = "images.json"; const els = { grid: document.getElementById("grid"), search: document.getElementById("search"), clear: document.getElementById("clear"), count: document.getElementById("count"), manifestInfo: document.getElementById("manifest-info"), empty: document.getElementById("empty"), emptyHint: document.getElementById("empty-hint"), loading: document.getElementById("loading"), toast: document.getElementById("toast"), }; /** @type {Array<{name:string, path:string, size:number, mtime:string}>} */ let images = []; let query = ""; /* ------------------------------------------------------------------ * * Manifest loading * ------------------------------------------------------------------ */ async function loadManifest() { try { const res = await fetch(MANIFEST_URL, { cache: "no-cache" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); images = Array.isArray(data) ? data : data.images || []; if (data.generatedAt) { els.manifestInfo.textContent = `Manifest updated ${formatDate(data.generatedAt)}`; } } catch (err) { images = []; els.manifestInfo.textContent = ""; showEmpty( "Could not load images.json", `Run node scripts/generate-manifest.mjs to build the manifest, then reload.` ); els.loading.hidden = true; return; } els.loading.hidden = true; render(); } /* ------------------------------------------------------------------ * * Search * ------------------------------------------------------------------ */ function tokenize(q) { return q.toLowerCase().split(/\s+/).filter(Boolean); } // Score a filename against the query tokens. Every token must appear // (AND semantics); better matches rank higher. function score(name, tokens) { const lower = name.toLowerCase(); let total = 0; for (const token of tokens) { const idx = lower.indexOf(token); if (idx === -1) return -1; // token missing -> no match if (idx === 0) total += 3; // prefix of the whole name else if (/[\s._\-/]/.test(lower[idx - 1])) total += 2; // start of a word else total += 1; // substring anywhere } return total; } function getResults() { const tokens = tokenize(query); if (tokens.length === 0) { return images.map((img) => ({ img, score: 0 })); } return images .map((img) => ({ img, score: score(img.name, tokens) })) .filter((r) => r.score >= 0) .sort((a, b) => b.score - a.score || a.img.name.localeCompare(b.img.name, undefined, { numeric: true })); } /* ------------------------------------------------------------------ * * Rendering * ------------------------------------------------------------------ */ function render() { const results = getResults(); els.grid.textContent = ""; if (images.length === 0) { showEmpty( "No images yet", `Drop files into img/, then run node scripts/generate-manifest.mjs.` ); updateCount(0, 0); return; } if (results.length === 0) { showEmpty("No matches", `Nothing matches “${query}”. Try a different search.`); updateCount(0, images.length); return; } els.empty.hidden = true; const frag = document.createDocumentFragment(); for (const { img } of results) { frag.appendChild(renderCard(img)); } els.grid.appendChild(frag); updateCount(results.length, images.length); } function renderCard(img) { const card = document.createElement("button"); card.type = "button"; card.className = "card"; card.title = `Copy URL: ${img.name}`; card.setAttribute("aria-label", `Copy URL of ${img.name}`); const thumb = document.createElement("div"); thumb.className = "thumb"; const image = document.createElement("img"); image.src = img.path; image.alt = img.name; image.loading = "lazy"; image.decoding = "async"; thumb.appendChild(image); const badge = document.createElement("span"); badge.className = "copy-badge"; badge.textContent = "Copy URL"; thumb.appendChild(badge); const label = document.createElement("span"); label.className = "card-name"; label.innerHTML = highlight(img.name, tokenize(query)); card.append(thumb, label); card.addEventListener("click", () => copyImage(img, card)); return card; } function updateCount(shown, total) { if (total === 0) { els.count.textContent = ""; return; } els.count.textContent = shown === total ? `${total} image${total === 1 ? "" : "s"}` : `${shown} of ${total} images`; } function showEmpty(title, hintHtml) { els.grid.textContent = ""; els.empty.hidden = false; els.empty.querySelector(".empty-title").textContent = title; els.emptyHint.innerHTML = hintHtml; } // Escape HTML, then wrap query-token matches in . function highlight(name, tokens) { const escaped = escapeHtml(name); if (tokens.length === 0) return escaped; const pattern = tokens .map((t) => escapeRegExp(escapeHtml(t))) .sort((a, b) => b.length - a.length) .join("|"); if (!pattern) return escaped; return escaped.replace(new RegExp(`(${pattern})`, "gi"), "$1"); } /* ------------------------------------------------------------------ * * Clipboard * ------------------------------------------------------------------ */ async function copyImage(img, card) { const url = new URL(img.path, document.baseURI).href; const ok = await copyText(url); if (ok) { flashCopied(card); showToast("URL copied to clipboard", url); } else { showToast("Copy failed — select the URL manually", url, true); } } async function copyText(text) { // Preferred: async Clipboard API (requires a secure context: HTTPS or localhost). if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text); return true; } catch { /* fall through to legacy path */ } } // Fallback for plain-HTTP origins (e.g. a LAN IP without TLS). try { const ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, text.length); const ok = document.execCommand("copy"); document.body.removeChild(ta); return ok; } catch { return false; } } function flashCopied(card) { card.classList.add("copied"); const badge = card.querySelector(".copy-badge"); if (badge) badge.textContent = "Copied!"; setTimeout(() => { card.classList.remove("copied"); if (badge) badge.textContent = "Copy URL"; }, 1200); } let toastTimer; function showToast(message, url, isError = false) { els.toast.textContent = ""; els.toast.classList.toggle("error", isError); const line = document.createElement("span"); line.textContent = message; els.toast.appendChild(line); if (url) { const u = document.createElement("span"); u.className = "toast-url"; u.textContent = url; els.toast.appendChild(u); } els.toast.hidden = false; requestAnimationFrame(() => els.toast.classList.add("show")); clearTimeout(toastTimer); toastTimer = setTimeout(() => { els.toast.classList.remove("show"); setTimeout(() => (els.toast.hidden = true), 200); }, 2600); } /* ------------------------------------------------------------------ * * Helpers * ------------------------------------------------------------------ */ function escapeHtml(s) { return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function formatDate(iso) { const d = new Date(iso); return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); } /* ------------------------------------------------------------------ * * Wiring * ------------------------------------------------------------------ */ let debounce; els.search.addEventListener("input", (e) => { query = e.target.value.trim(); els.clear.hidden = query.length === 0; clearTimeout(debounce); debounce = setTimeout(render, 80); }); els.clear.addEventListener("click", () => { els.search.value = ""; query = ""; els.clear.hidden = true; els.search.focus(); render(); }); // "/" focuses search, Escape clears it. document.addEventListener("keydown", (e) => { if (e.key === "/" && document.activeElement !== els.search) { e.preventDefault(); els.search.focus(); } else if (e.key === "Escape" && document.activeElement === els.search) { els.clear.click(); } }); loadManifest(); })();