/* ============================================================ selfies.js — renders the /selfies gallery from a manifest Manifest: /assets/selfies/selfies.json - array of filename strings, or { "src", "alt", "caption" } objects - shown in list order (newest first = top of the list) "alt" is for screen readers; "caption" (optional) is shown on the page. Click any thumbnail to open it full-size in a lightbox. ============================================================ */ (function selfies() { "use strict"; const MANIFEST = "/assets/selfies/selfies.json"; const FOLDER = "/assets/selfies/"; const root = document.getElementById("selfies-root"); if (!root) return; const reduceMotion = window.matchMedia( "(prefers-reduced-motion: reduce)" ).matches; /* ---------- helpers ---------- */ // Resolve a manifest src to a usable URL. function resolveSrc(s) { if (typeof s !== "string") return ""; s = s.trim(); if (/^https?:\/\//i.test(s) || s.startsWith("/")) return s; return FOLDER + s.replace(/^\.?\//, ""); } // Normalise a manifest entry into { src, alt, caption } or null if unusable. function normalize(entry, i) { let raw = ""; let alt = ""; let caption = ""; if (typeof entry === "string") { raw = entry; } else if (entry && typeof entry === "object" && entry.src) { raw = entry.src; alt = typeof entry.alt === "string" ? entry.alt : ""; caption = typeof entry.caption === "string" ? entry.caption.trim() : ""; } if (typeof raw !== "string" || !raw.trim()) return null; const src = resolveSrc(raw); if (!src) return null; if (!alt) alt = caption || "Selfie " + (i + 1) + " of Clove Twilight"; return { src, alt, caption }; } function showMessage(text) { root.innerHTML = ""; const p = document.createElement("p"); p.className = "selfie-empty"; p.textContent = text; root.appendChild(p); } /* ---------- lightbox ---------- */ let items = []; let current = 0; let lastFocus = null; const lb = document.createElement("div"); lb.className = "lightbox"; lb.hidden = true; lb.setAttribute("role", "dialog"); lb.setAttribute("aria-modal", "true"); lb.setAttribute("aria-label", "Selfie viewer"); lb.innerHTML = '' + '' + '" + ''; document.body.appendChild(lb); const lbImg = lb.querySelector(".lightbox-img"); const lbCap = lb.querySelector(".lightbox-caption"); const btnClose = lb.querySelector(".lightbox-close"); const btnPrev = lb.querySelector(".lightbox-prev"); const btnNext = lb.querySelector(".lightbox-next"); function preload(i) { if (i < 0 || i >= items.length) return; const img = new Image(); img.src = items[i].src; } function render(i) { current = (i + items.length) % items.length; // wrap around const it = items[current]; lbImg.src = it.src; lbImg.alt = it.alt; if (it.caption) { lbCap.textContent = it.caption; lbCap.hidden = false; } else { lbCap.textContent = ""; lbCap.hidden = true; } const multiple = items.length > 1; btnPrev.hidden = !multiple; btnNext.hidden = !multiple; if (multiple) { preload(current + 1); preload(current - 1); } } function open(i) { lastFocus = document.activeElement; render(i); lb.hidden = false; if (!reduceMotion) lb.classList.add("is-open"); document.body.classList.add("lightbox-open"); btnClose.focus(); } function close() { lb.hidden = true; lb.classList.remove("is-open"); document.body.classList.remove("lightbox-open"); lbImg.removeAttribute("src"); if (lastFocus && typeof lastFocus.focus === "function") lastFocus.focus(); } const next = () => render(current + 1); const prev = () => render(current - 1); btnClose.addEventListener("click", close); btnNext.addEventListener("click", next); btnPrev.addEventListener("click", prev); // Click on the dim backdrop (but not the image/caption or buttons) closes. lb.addEventListener("click", (e) => { if (e.target === lb) close(); }); document.addEventListener("keydown", (e) => { if (lb.hidden) return; if (e.key === "Escape") close(); else if (e.key === "ArrowRight") next(); else if (e.key === "ArrowLeft") prev(); }); /* ---------- grid ---------- */ function buildGrid(list) { root.innerHTML = ""; const frag = document.createDocumentFragment(); list.forEach((it, i) => { const fig = document.createElement("figure"); fig.className = "selfie-item"; const btn = document.createElement("button"); btn.type = "button"; btn.className = "selfie-thumb"; btn.setAttribute("aria-label", "Open " + (it.caption || it.alt)); const img = document.createElement("img"); img.src = it.src; img.alt = it.alt; img.loading = i < 4 ? "eager" : "lazy"; img.decoding = "async"; // If an image fails to load, drop its tile so the grid stays clean. img.addEventListener("error", () => fig.remove()); btn.appendChild(img); btn.addEventListener("click", () => open(i)); fig.appendChild(btn); if (it.caption) { const cap = document.createElement("figcaption"); cap.className = "selfie-caption"; cap.textContent = it.caption; fig.appendChild(cap); } frag.appendChild(fig); }); root.appendChild(frag); } /* ---------- load ---------- */ root.setAttribute("aria-busy", "true"); fetch(MANIFEST, { cache: "no-cache" }) .then((r) => { if (!r.ok) throw new Error("manifest " + r.status); return r.json(); }) .then((data) => { if (!Array.isArray(data)) throw new Error("manifest is not an array"); items = data.map(normalize).filter(Boolean); if (!items.length) { showMessage("No selfies yet — check back soon! 📸"); return; } buildGrid(items); }) .catch((err) => { console.error("Could not load selfies:", err); showMessage("Couldn't load the selfies right now."); }) .finally(() => root.removeAttribute("aria-busy")); })();