This commit is contained in:
oliver committed 2026-09-27 11:18:26 -03:00
commit 14555c6e37
5 files changed
+744

No files matched your search

+309
View File
@@ -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 <code>node scripts/generate-manifest.mjs</code> 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 <code>img/</code>, then run <code>node scripts/generate-manifest.mjs</code>.`
);
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 <mark>.
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"), "<mark>$1</mark>");
}
/* ------------------------------------------------------------------ *
* 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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();
})();
+299
View File
@@ -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; }
}