INIT
This commit is contained in:
commit
14555c6e37
5 files changed
+744
No files matched your search
+309
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||||
})();
|
||||
Reference in new issue
Block a user