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();
|
||||||
|
})();
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"generatedAt": "2026-09-27T14:18:20.806Z",
|
||||||
|
"count": 0,
|
||||||
|
"images": []
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Image Library</title>
|
||||||
|
<meta name="description" content="Browse and search the image library. Click an image to copy its URL." />
|
||||||
|
<link rel="stylesheet" href="assets/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap">
|
||||||
|
<h1 class="site-title">Image Library</h1>
|
||||||
|
<p class="site-sub">Click any image to copy its URL to your clipboard.</p>
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<svg class="search-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M10 2a8 8 0 1 0 4.9 14.32l5.39 5.39 1.42-1.42-5.39-5.39A8 8 0 0 0 10 2Zm0 2a6 6 0 1 1 0 12 6 6 0 0 1 0-12Z"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
id="search"
|
||||||
|
type="search"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search image names…"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
aria-label="Search image names"
|
||||||
|
/>
|
||||||
|
<button id="clear" class="search-clear" type="button" aria-label="Clear search" hidden>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<span id="count" class="count" aria-live="polite"></span>
|
||||||
|
<span id="manifest-info" class="manifest-info"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="wrap">
|
||||||
|
<div id="grid" class="grid"></div>
|
||||||
|
|
||||||
|
<div id="empty" class="empty" hidden>
|
||||||
|
<p class="empty-title">No images found</p>
|
||||||
|
<p class="empty-hint" id="empty-hint"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loading" class="loading">Loading…</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
|
<script src="assets/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user