From 14555c6e37dcc50dc748e72ef9464d19a7586415 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 27 Sep 2026 11:18:26 -0300 Subject: [PATCH] INIT --- assets/app.js | 309 ++++++++++++++++++++++++++++++++++ assets/styles.css | 299 ++++++++++++++++++++++++++++++++ images.json | 5 + index.html | 54 ++++++ scripts/generate-manifest.mjs | 77 +++++++++ 5 files changed, 744 insertions(+) create mode 100644 assets/app.js create mode 100644 assets/styles.css create mode 100644 images.json create mode 100644 index.html create mode 100644 scripts/generate-manifest.mjs diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..363be9b --- /dev/null +++ b/assets/app.js @@ -0,0 +1,309 @@ +/* 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(); +})(); diff --git a/assets/styles.css b/assets/styles.css new file mode 100644 index 0000000..809b3de --- /dev/null +++ b/assets/styles.css @@ -0,0 +1,299 @@ +:root { + --bg: #0f1115; + --bg-elev: #171a21; + --bg-elev-2: #1e222b; + --border: #2a2f3a; + --text: #e7e9ee; + --text-dim: #9aa2b1; + --accent: #4c8dff; + --accent-soft: rgba(76, 141, 255, 0.16); + --ok: #3ddc84; + --radius: 12px; + --shadow: 0 6px 24px rgba(0, 0, 0, 0.35); + color-scheme: dark; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f6f7f9; + --bg-elev: #ffffff; + --bg-elev-2: #f0f2f5; + --border: #e2e5ea; + --text: #1a1d23; + --text-dim: #626b7a; + --accent: #2563eb; + --accent-soft: rgba(37, 99, 235, 0.12); + --ok: #16a34a; + --shadow: 0 6px 24px rgba(15, 23, 42, 0.10); + color-scheme: light; + } +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; +} + +body { + background: var(--bg); + color: var(--text); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + min-height: 100vh; +} + +.wrap { + width: min(1200px, 100% - 2rem); + margin-inline: auto; +} + +/* ---------- Header ---------- */ + +.site-header { + position: sticky; + top: 0; + z-index: 20; + padding: 1.5rem 0 1rem; + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border); +} + +.site-title { + margin: 0; + font-size: 1.5rem; + letter-spacing: -0.02em; +} + +.site-sub { + margin: 0.25rem 0 1rem; + color: var(--text-dim); + font-size: 0.9rem; +} + +.search-bar { + position: relative; + display: flex; + align-items: center; +} + +.search-icon { + position: absolute; + left: 0.85rem; + width: 18px; + height: 18px; + fill: var(--text-dim); + pointer-events: none; +} + +.search-input { + width: 100%; + padding: 0.75rem 2.75rem 0.75rem 2.6rem; + font-size: 1rem; + color: var(--text); + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.search-input::placeholder { color: var(--text-dim); } + +.search-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.search-clear { + position: absolute; + right: 0.5rem; + width: 2rem; + height: 2rem; + display: grid; + place-items: center; + font-size: 1.25rem; + line-height: 1; + color: var(--text-dim); + background: transparent; + border: 0; + border-radius: 8px; + cursor: pointer; +} + +.search-clear:hover { color: var(--text); background: var(--bg-elev-2); } + +.meta { + display: flex; + justify-content: space-between; + gap: 1rem; + margin-top: 0.65rem; + font-size: 0.82rem; + color: var(--text-dim); +} + +/* ---------- Grid ---------- */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 1rem; + padding: 1.5rem 0 3rem; +} + +.card { + position: relative; + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; + text-align: left; + color: inherit; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: transform 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease; +} + +.card:hover, +.card:focus-visible { + transform: translateY(-2px); + border-color: var(--accent); + box-shadow: var(--shadow); + outline: none; +} + +.card:active { transform: translateY(0); } + +.thumb { + position: relative; + aspect-ratio: 4 / 3; + background: + linear-gradient(45deg, var(--bg-elev-2) 25%, transparent 25%) 0 0 / 16px 16px, + linear-gradient(-45deg, var(--bg-elev-2) 25%, transparent 25%) 0 8px / 16px 16px, + linear-gradient(45deg, transparent 75%, var(--bg-elev-2) 75%) 8px -8px / 16px 16px, + linear-gradient(-45deg, transparent 75%, var(--bg-elev-2) 75%) -8px 0 / 16px 16px; + overflow: hidden; +} + +.thumb img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +.card-name { + padding: 0.6rem 0.7rem; + font-size: 0.82rem; + line-height: 1.35; + word-break: break-all; + border-top: 1px solid var(--border); +} + +.card-name mark { + padding: 0 1px; + color: inherit; + background: var(--accent-soft); + border-radius: 3px; +} + +.copy-badge { + position: absolute; + top: 0.5rem; + right: 0.5rem; + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.25rem 0.5rem; + font-size: 0.72rem; + color: var(--text); + background: color-mix(in srgb, var(--bg) 78%, transparent); + border: 1px solid var(--border); + border-radius: 999px; + opacity: 0; + transform: translateY(-4px); + transition: opacity 0.12s, transform 0.12s; + pointer-events: none; +} + +.card:hover .copy-badge, +.card:focus-visible .copy-badge { + opacity: 1; + transform: translateY(0); +} + +.card.copied .copy-badge { + opacity: 1; + transform: translateY(0); + color: var(--ok); + border-color: var(--ok); +} + +/* ---------- States ---------- */ + +.loading, +.empty { + padding: 3rem 1rem; + text-align: center; + color: var(--text-dim); +} + +.empty-title { + margin: 0 0 0.35rem; + font-size: 1.05rem; + color: var(--text); +} + +.empty-hint { margin: 0; font-size: 0.88rem; } + +.empty-hint code { + padding: 0.1rem 0.35rem; + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: 5px; + font-size: 0.85em; +} + +/* ---------- Toast ---------- */ + +.toast { + position: fixed; + left: 50%; + bottom: 1.5rem; + z-index: 50; + max-width: min(560px, calc(100vw - 2rem)); + padding: 0.7rem 1rem; + color: var(--text); + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + transform: translate(-50%, 1rem); + opacity: 0; + transition: opacity 0.18s, transform 0.18s; + pointer-events: none; +} + +.toast.show { + opacity: 1; + transform: translate(-50%, 0); +} + +.toast.error { border-color: #e5484d; } + +.toast-url { + display: block; + margin-top: 0.2rem; + font-size: 0.78rem; + color: var(--text-dim); + word-break: break-all; +} + +@media (max-width: 480px) { + .grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } + .site-title { font-size: 1.25rem; } +} diff --git a/images.json b/images.json new file mode 100644 index 0000000..e01ff65 --- /dev/null +++ b/images.json @@ -0,0 +1,5 @@ +{ + "generatedAt": "2026-09-27T14:18:20.806Z", + "count": 0, + "images": [] +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..97b3143 --- /dev/null +++ b/index.html @@ -0,0 +1,54 @@ + + + + + + Image Library + + + + + + +
+
+ + + +
Loading…
+
+ + + + + + diff --git a/scripts/generate-manifest.mjs b/scripts/generate-manifest.mjs new file mode 100644 index 0000000..0c88074 --- /dev/null +++ b/scripts/generate-manifest.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * Scans ./img recursively and writes ./images.json — the manifest the + * single-page app fetches at runtime. No backend required. + * + * Usage: + * node scripts/generate-manifest.mjs + * + * Re-run this whenever you add, rename, or remove images. + */ +import { readdir, stat, writeFile } from "node:fs/promises"; +import { join, relative, sep, extname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const IMG_DIR = join(ROOT, "img"); +const OUT_FILE = join(ROOT, "images.json"); + +const IMAGE_EXTS = new Set([ + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", + ".svg", ".bmp", ".ico", ".tif", ".tiff", +]); + +const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + if (entry.name.startsWith(".")) continue; // skip dotfiles/dirs + const full = join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...(await walk(full))); + } else if (entry.isFile() && IMAGE_EXTS.has(extname(entry.name).toLowerCase())) { + const info = await stat(full); + files.push({ + name: entry.name, + path: relative(ROOT, full).split(sep).join("/"), // POSIX-style for URLs + size: info.size, + mtime: info.mtime.toISOString(), + }); + } + } + return files; +} + +async function main() { + let files; + try { + files = await walk(IMG_DIR); + } catch (err) { + if (err.code === "ENOENT") { + console.error(`✗ Image folder not found: ${IMG_DIR}`); + console.error(" Create it and add some images, then re-run."); + process.exit(1); + } + throw err; + } + + files.sort((a, b) => collator.compare(a.path, b.path)); + + const manifest = { + generatedAt: new Date().toISOString(), + count: files.length, + images: files, + }; + + await writeFile(OUT_FILE, JSON.stringify(manifest, null, 2) + "\n", "utf8"); + console.log(`✓ Wrote ${files.length} image${files.length === 1 ? "" : "s"} to images.json`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});