diff options
Diffstat (limited to 'assets/js/site.js')
| -rw-r--r-- | assets/js/site.js | 1341 |
1 files changed, 1341 insertions, 0 deletions
diff --git a/assets/js/site.js b/assets/js/site.js new file mode 100644 index 0000000..63ac6a8 --- /dev/null +++ b/assets/js/site.js @@ -0,0 +1,1341 @@ +(function () { + function byId(id) { + return document.getElementById(id); + } + + /* ======================================================================== + Theme Toggle (localStorage) + ======================================================================== */ + function applyTheme(theme) { + var isLight = theme === "light"; + document.body.classList.add("theme-transitioning"); + window.setTimeout(function () { + document.body.classList.remove("theme-transitioning"); + }, 400); + if (theme === "light") { + document.body.classList.add("light-mode"); + } else { + document.body.classList.remove("light-mode"); + } + document.documentElement.style.colorScheme = isLight ? "light" : "dark"; + var themeColor = document.querySelector('meta[name="theme-color"]'); + if (themeColor) { + themeColor.setAttribute("content", isLight ? "#fff8f1" : "#000000"); + } + var btn = byId("theme-toggle"); + if (btn) { + btn.textContent = theme === "light" ? "\u263E" : "\u2600"; + btn.setAttribute("title", + theme === "light" + ? "Switch to dark theme" + : "Switch to light theme" + ); + btn.setAttribute("aria-label", + theme === "light" + ? "Switch to dark theme" + : "Switch to light theme" + ); + } + // Sync guestbook iframes + ["guestbook-form-iframe", "guestbook-comments-iframe"].forEach(function (id) { + var frame = byId(id); + if (!frame) return; + var src = frame.getAttribute("src") || ""; + var base = src.split("?")[0]; + var next = theme === "light" ? base + "?theme=light" : base; + if (src !== next) frame.setAttribute("src", next); + }); + } + + var stored = localStorage.getItem("siteTheme"); + var prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches; + var savedTheme = stored || (prefersLight ? "light" : "dark"); + applyTheme(savedTheme); + + if (window.matchMedia) { + window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", function (e) { + if (!localStorage.getItem("siteTheme")) { + applyTheme(e.matches ? "light" : "dark"); + } + }); + } + + function syncHeaderOffset() { + var header = document.querySelector("header.site-header"); + if (!header) return; + document.documentElement.style.setProperty( + "--site-header-height", + Math.ceil(header.getBoundingClientRect().height) + "px" + ); + } + + window.addEventListener("load", syncHeaderOffset); + window.addEventListener("resize", syncHeaderOffset); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", syncHeaderOffset); + } else { + syncHeaderOffset(); + } + + var themeBtn = byId("theme-toggle"); + if (themeBtn) { + themeBtn.addEventListener("click", function () { + var next = document.body.classList.contains("light-mode") ? "dark" : "light"; + localStorage.setItem("siteTheme", next); + applyTheme(next); + }); + } + + /* ======================================================================== + Comfort spacing toggle (localStorage, site-wide) + ======================================================================== */ + (function initComfortSpacing() { + if ((location.pathname || "").indexOf("/startpage") !== -1) return; + var KEY = "siteComfortSpacing_v1"; + var btn = byId("comfort-toggle"); + if (!btn) return; + + function applyComfort(on) { + document.body.classList.toggle("comfort-spacing", on); + btn.setAttribute("aria-pressed", on ? "true" : "false"); + btn.setAttribute("title", on ? "Comfort spacing: ON" : "Comfort spacing: OFF"); + btn.setAttribute("aria-label", on ? "Comfort spacing on" : "Comfort spacing off"); + } + + applyComfort(localStorage.getItem(KEY) === "1"); + + btn.addEventListener("click", function () { + var next = !document.body.classList.contains("comfort-spacing"); + localStorage.setItem(KEY, next ? "1" : "0"); + applyComfort(next); + }); + })(); + + /* ======================================================================== + Menu & Language Dropdowns + ======================================================================== */ + const menuBtn = byId("menu-toggle"); + const nav = byId("site-nav"); + + const langToggle = byId("lang-toggle"); + const langMenu = byId("lang-options"); + const currentLang = byId("current-lang"); + + let openDropdown = null; + + function closeMenu() { + if (!menuBtn || !nav) return; + menuBtn.classList.remove("active"); + menuBtn.setAttribute("aria-expanded", "false"); + nav.hidden = true; + if (openDropdown === "menu") openDropdown = null; + } + + function closeLang() { + if (!langToggle || !langMenu) return; + langToggle.setAttribute("aria-expanded", "false"); + langMenu.hidden = true; + if (openDropdown === "lang") openDropdown = null; + } + + function toggleMenu(e) { + if (!menuBtn || !nav) return; + e.stopPropagation(); + + if (openDropdown === "lang") closeLang(); + + const open = menuBtn.getAttribute("aria-expanded") === "true"; + if (open) { + closeMenu(); + return; + } + + menuBtn.classList.add("active"); + menuBtn.setAttribute("aria-expanded", "true"); + nav.hidden = false; + openDropdown = "menu"; + } + + function toggleLang(e) { + if (!langToggle || !langMenu) return; + e.stopPropagation(); + + if (openDropdown === "menu") closeMenu(); + + const open = langToggle.getAttribute("aria-expanded") === "true"; + if (open) { + closeLang(); + return; + } + + langToggle.setAttribute("aria-expanded", "true"); + langMenu.hidden = false; + openDropdown = "lang"; + } + + if (nav) nav.hidden = true; + if (langMenu) langMenu.hidden = true; + + if (menuBtn && nav) { + menuBtn.addEventListener("click", toggleMenu); + nav.addEventListener("click", (e) => e.stopPropagation()); + } + + if (langToggle && langMenu) { + langToggle.addEventListener("click", toggleLang); + langMenu.addEventListener("click", (e) => e.stopPropagation()); + } + + if ((menuBtn && nav) || (langToggle && langMenu)) { + document.addEventListener("click", function (e) { + if ( + menuBtn && nav && + (menuBtn.contains(e.target) || nav.contains(e.target)) + ) { + return; + } + if ( + langToggle && langMenu && + (langToggle.contains(e.target) || langMenu.contains(e.target)) + ) { + return; + } + closeMenu(); + closeLang(); + }); + + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") { + closeMenu(); + closeLang(); + } + }); + } + + if (currentLang) { + const docLang = (document.documentElement.lang || "en").toLowerCase(); + currentLang.textContent = docLang === "zh" ? "ZH" : docLang === "ja" ? "JP" : "EN"; + } + + if (langMenu) { + langMenu.querySelectorAll("a[data-lang]").forEach((a) => { + a.addEventListener("click", function () { + localStorage.setItem("preferredLang", this.getAttribute("data-lang") || "en"); + }); + }); + } + + // Language menu hrefs: convert current page to *_zh.php / *_jp.php. + function updateLangMenuHrefs() { + const links = document.querySelectorAll("a[data-lang-href][data-lang]"); + if (!links.length) return; + + let path = window.location.pathname || "/"; + if (!path.startsWith("/")) path = "/" + path; + + const isDir = path.endsWith("/"); + const file = isDir ? "index.php" : (path.split("/").pop() || "index.php"); + const base = isDir ? path : path.slice(0, -file.length); + + const baseName = file.replace(/_(zh|jp)\.php$/i, ".php"); + const map = { + en: baseName, + zh: baseName.replace(/(_(zh|jp))?\.php$/i, "_zh.php"), + jp: baseName.replace(/(_(zh|jp))?\.php$/i, "_jp.php"), + }; + + links.forEach((a) => { + const lang = (a.getAttribute("data-lang") || "en").toLowerCase(); + const targetFile = map[lang] || map.en; + a.href = base + targetFile; + }); + } + + function updateAriaCurrent() { + const path = window.location.pathname || "/"; + const normalized = path.startsWith("/") ? path : "/" + path; + const isDir = normalized.endsWith("/"); + const file = isDir ? "index.php" : (normalized.split("/").pop() || "index.php"); + const base = isDir ? normalized : normalized.slice(0, -file.length); + const indexPath = base + "index.php"; + const dirPath = base || "/"; + const candidates = new Set([normalized, indexPath, dirPath, dirPath.replace(/\/$/, "")]); + + document.querySelectorAll("#site-nav a[href]").forEach((a) => { + const href = a.getAttribute("href") || ""; + const hrefUrl = new URL(href, window.location.origin); + if (hrefUrl.origin !== window.location.origin) { + a.removeAttribute("aria-current"); + return; + } + const hrefPath = hrefUrl.pathname; + if (candidates.has(hrefPath)) { + a.setAttribute("aria-current", "page"); + } else { + a.removeAttribute("aria-current"); + } + }); + + const docLang = (document.documentElement.lang || "en").toLowerCase(); + // "ja" is valid BCP-47 for Japanese but we label it "jp" in data-lang + const normalizedDocLang = docLang === "ja" ? "jp" : docLang; + document.querySelectorAll("#lang-options a[data-lang]").forEach((a) => { + const lang = (a.getAttribute("data-lang") || "en").toLowerCase(); + if (lang === normalizedDocLang) { + a.setAttribute("aria-current", "page"); + } else { + a.removeAttribute("aria-current"); + } + }); + } + + // Run once now... + updateLangMenuHrefs(); + updateAriaCurrent(); + // ...and again after layout/injection settles. + setTimeout(updateLangMenuHrefs, 0); + setTimeout(updateAriaCurrent, 0); + + /* ======================================================================== + Skeleton Screens — replace static "Loading…" text + ======================================================================== */ + function injectSkeleton(container) { + if (!container) return; + container.innerHTML = + '<div class="skeleton-line"></div>' + + '<div class="skeleton-line"></div>' + + '<div class="skeleton-line"></div>' + + '<div class="skeleton-line"></div>'; + } + + /* ======================================================================== + Home page: Blog list (optional) + ======================================================================== */ + const blogList = byId("blog-list"); + if (blogList) { + injectSkeleton(blogList); + + const blogHost = "https://blog.sillylaird.ca"; + const ctrl = new AbortController(); + const timeout = setTimeout(() => ctrl.abort(), 3500); + + fetch(blogHost + "/api/posts.php", { signal: ctrl.signal }) + .then((r) => r.json()) + .then((posts) => { + blogList.textContent = ""; + + const top = document.createElement("div"); + + const strong = document.createElement("strong"); + strong.textContent = "Posts"; + top.appendChild(strong); + + top.appendChild(document.createTextNode(" (")); + const allLink = document.createElement("a"); + allLink.href = blogHost; + allLink.textContent = "all"; + top.appendChild(allLink); + top.appendChild(document.createTextNode(")")); + + blogList.appendChild(top); + + const list = document.createElement("ol"); + (posts || []).forEach((post) => { + const li = document.createElement("li"); + + const a = document.createElement("a"); + a.href = blogHost + "/" + String(post.path || "").replace(/^\/+/, ""); + a.textContent = String(post.title || "Untitled"); + + const date = document.createElement("div"); + date.className = "muted"; + + const y = String(post.year || ""); + const m = String(post.month || "").padStart(2, "0"); + const d = String(post.day || "").padStart(2, "0"); + const iso = y && m && d ? `${y}-${m}-${d}` : ""; + + const time = document.createElement("time"); + if (iso) time.setAttribute("datetime", iso); + time.textContent = iso || ""; + + li.appendChild(a); + li.appendChild(document.createElement("br")); + date.appendChild(time); + li.appendChild(date); + + list.appendChild(li); + }); + + blogList.appendChild(list); + + if (!list.children.length) { + const empty = document.createElement("div"); + empty.className = "muted"; + empty.textContent = "No posts yet."; + blogList.appendChild(empty); + } + }) + .catch((err) => { + blogList.textContent = err && err.name === "AbortError" + ? "Blog list timed out." + : "Cannot load posts right now."; + }) + .finally(() => clearTimeout(timeout)); + } + + /* ======================================================================== + Home page: Changelog list (optional) + ======================================================================== */ + const changelogList = byId("changelog-list"); + if (changelogList) { + injectSkeleton(changelogList); + + const changelogPage = "https://www.sillylaird.ca/changelog/latest.php"; + const ctrl = new AbortController(); + const timeout = setTimeout(() => ctrl.abort(), 3500); + + fetch(changelogPage, { signal: ctrl.signal }) + .then((r) => r.text()) + .then((html) => { + changelogList.textContent = ""; + const doc = new DOMParser().parseFromString(html, "text/html"); + const entries = Array.from(doc.querySelectorAll(".changelog-entry")); + + const top = document.createElement("div"); + + const strong = document.createElement("strong"); + strong.textContent = "Entries"; + top.appendChild(strong); + + top.appendChild(document.createTextNode(" (")); + const allLink = document.createElement("a"); + allLink.href = "https://www.sillylaird.ca/changelog/"; + allLink.textContent = "all"; + top.appendChild(allLink); + top.appendChild(document.createTextNode(")")); + + changelogList.appendChild(top); + + const list = document.createElement("ol"); + entries.forEach((entry) => { + const li = document.createElement("li"); + + const titleText = (entry.querySelector("strong, b")?.textContent || "").trim(); + const timeEl = entry.querySelector("time"); + const dateText = (timeEl?.textContent || "").trim(); + const dateIso = (timeEl?.getAttribute("datetime") || "").trim(); + const bodyEl = entry.querySelector(".text"); + const bodyText = (bodyEl?.textContent || "").trim(); + + const title = document.createElement("strong"); + if (titleText) { + title.textContent = titleText; + li.appendChild(title); + + if (dateText) { + const date = document.createElement("div"); + date.className = "muted"; + const time = document.createElement("time"); + if (dateIso) time.setAttribute("datetime", dateIso); + time.textContent = dateText; + date.appendChild(time); + li.appendChild(date); + } + } else if (dateText) { + title.textContent = dateText; + li.appendChild(title); + } + + if (bodyText) { + const body = document.createElement("div"); + body.className = "changelog-body"; + body.textContent = bodyText; + li.appendChild(body); + } + + list.appendChild(li); + }); + + changelogList.appendChild(list); + + const filesSection = doc.querySelector(".changelog-files-section"); + if (filesSection) { + changelogList.appendChild(document.importNode(filesSection, true)); + } + + if (!list.children.length) { + const empty = document.createElement("div"); + empty.className = "muted"; + empty.textContent = "No updates yet."; + changelogList.appendChild(empty); + } + }) + .catch((err) => { + changelogList.textContent = err && err.name === "AbortError" + ? "Changelog list timed out." + : "Cannot load changelog right now."; + }) + .finally(() => clearTimeout(timeout)); + } + + /* ======================================================================== + Guestbook helpers + ======================================================================== */ + const formRefreshBtn = byId("guestbook-form-refresh"); + if (formRefreshBtn) { + formRefreshBtn.addEventListener("click", () => { + const frame = byId("guestbook-form-iframe"); + if (!frame) return; + setIframeLoading(frame); + resetIframe(frame); + }); + } + + function setIframeLoading(frame) { + if (!frame) return; + const clip = frame.closest(".iframe-clip"); + if (!clip) return; + clip.classList.add("is-loading"); + const onLoad = () => { + clip.classList.remove("is-loading"); + }; + frame.addEventListener("load", onLoad, { once: true }); + } + + function resetIframe(frame) { + if (!frame) return; + const src = frame.getAttribute("src") || frame.src; + if (src) { + frame.setAttribute("src", src); + return; + } + if (frame.contentWindow) frame.contentWindow.location.reload(); + } + + function loadGuestbookComments() { + var box = byId("guestbook-comments-inline"); + if (!box) return; + box.innerHTML = '<div class="retro-loading">Loading recent comments...</div>'; + fetch("/api/guestbook-recent.php?limit=6") + .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); }) + .then(function (data) { + var entries = data && data.entries ? data.entries : []; + box.textContent = ""; + if (!entries.length) { + var empty = document.createElement("p"); + empty.className = "muted"; + empty.textContent = "No comments yet — be the first to sign!"; + box.appendChild(empty); + return; + } + entries.forEach(function (row) { + var art = document.createElement("article"); + art.className = "guestbook-entry"; + var strong = document.createElement("strong"); + strong.textContent = row.name || "Anonymous"; + art.appendChild(strong); + if (row.created_at) { + var time = document.createElement("time"); + time.textContent = row.created_at; + time.setAttribute("datetime", row.created_at); + art.appendChild(time); + } + var p = document.createElement("p"); + p.textContent = row.message || ""; + art.appendChild(p); + box.appendChild(art); + }); + }) + .catch(function () { + box.innerHTML = '<p class="muted">Could not load comments. <a href="/guestbook.php">Open guestbook</a></p>'; + }); + } + + var gbReload = byId("guestbook-comments-reload"); + if (gbReload) { + gbReload.addEventListener("click", loadGuestbookComments); + loadGuestbookComments(); + } + + document.querySelectorAll(".iframe-clip iframe, .vibe-embed iframe").forEach(function (frame) { + var clip = frame.closest(".iframe-clip") || frame.closest(".vibe-embed"); + if (!clip) return; + clip.classList.add("is-loading"); + frame.addEventListener("load", function () { + clip.classList.remove("is-loading"); + }, { once: true }); + }); + + /* ======================================================================== + Home page: Last.fm live refresh (The "Pro" Way) + ======================================================================== */ + const lastfmList = byId("lastfm-list"); + const lastfmNow = byId("lastfm-now"); + if (lastfmList || lastfmNow) { + let lastDataStr = ""; + + function renderNowPlaying(tracks) { + if (!lastfmNow) return; + lastfmNow.textContent = ""; + var current = null; + if (Array.isArray(tracks)) { + for (var i = 0; i < tracks.length; i++) { + if (tracks[i].nowplaying) { current = tracks[i]; break; } + } + if (!current && tracks[0]) current = tracks[0]; + } + if (!current) { + var fallback = document.createElement("p"); + fallback.className = "muted"; + fallback.innerHTML = 'Nothing scrobbling — <a href="https://www.last.fm/user/SillyLaird" target="_blank" rel="noopener noreferrer">Last.fm profile</a>'; + lastfmNow.appendChild(fallback); + return; + } + var card = document.createElement("a"); + card.className = "lastfm-track lastfm-track--featured"; + card.href = current.url || "https://www.last.fm/user/SillyLaird"; + card.target = "_blank"; + card.rel = "noopener noreferrer"; + if (current.image) { + var wrap = document.createElement("span"); + wrap.className = "lastfm-image-wrap"; + var img = document.createElement("img"); + img.src = current.image; + img.alt = (current.artist || "") + " — " + (current.name || "track"); + img.loading = "lazy"; + img.decoding = "async"; + wrap.appendChild(img); + card.appendChild(wrap); + } + var info = document.createElement("span"); + info.className = "lastfm-info"; + var status = document.createElement("span"); + status.className = "lastfm-status" + (current.nowplaying ? " is-playing" : ""); + status.textContent = current.nowplaying ? "Now playing" : "Last scrobbled"; + var title = document.createElement("span"); + title.className = "lastfm-title"; + title.textContent = current.name || "Unknown track"; + var artist = document.createElement("span"); + artist.className = "lastfm-artist"; + artist.textContent = current.artist || ""; + info.appendChild(status); + info.appendChild(title); + info.appendChild(artist); + card.appendChild(info); + lastfmNow.appendChild(card); + } + + function updateLastFM() { + fetch("/api/lastfm.php") + .then(res => { + if (!res.ok) throw new Error("API error: " + res.status); + return res.json(); + }) + .then(tracks => { + if (tracks.error) throw new Error(tracks.message || "Unknown proxy error"); + + // Only update DOM if the data has changed + const currentDataStr = JSON.stringify(tracks); + if (currentDataStr === lastDataStr) return; + lastDataStr = currentDataStr; + + renderNowPlaying(tracks); + + if (!lastfmList) return; + var ul = document.createElement("ul"); + ul.className = "lastfm-text-list"; + if (Array.isArray(tracks) && tracks.length > 0) { + tracks.forEach(function (track) { + var li = document.createElement("li"); + if (track.nowplaying) { + var strong = document.createElement("strong"); + strong.textContent = "\u25B6 Now playing: "; + li.appendChild(strong); + } + var a = document.createElement("a"); + a.href = track.url; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + a.textContent = track.name; + li.appendChild(a); + li.appendChild(document.createTextNode(" \u2014 " + track.artist)); + ul.appendChild(li); + }); + } else { + var empty = document.createElement("li"); + empty.className = "muted"; + empty.textContent = "No tracks found."; + ul.appendChild(empty); + } + lastfmList.textContent = ""; + lastfmList.appendChild(ul); + lastfmList.dataset.loaded = "1"; + if (lastfmNow) lastfmNow.dataset.loaded = "1"; + }) + .catch(err => { + console.error("Last.fm Widget Error:", err); + if (lastfmNow && !lastfmNow.dataset.loaded) { + lastfmNow.dataset.loaded = "1"; + lastfmNow.innerHTML = '<p class="muted"><a href="https://www.last.fm/user/SillyLaird" target="_blank" rel="noopener noreferrer">Last.fm profile</a></p>'; + } + if (lastfmList && !lastfmList.dataset.loaded) { + lastfmList.dataset.loaded = "1"; + lastfmList.innerHTML = '<p class="muted"><a href="https://www.last.fm/user/SillyLaird" target="_blank" rel="noopener noreferrer">View recent tracks on Last.fm ↗</a></p>'; + } + }); + } + + // Defer the first fetch until the widget scrolls into view (saves a + // third-party request on initial paint — matters on slow/Tor connections). + let refreshTimer = null; + function startLastFM() { + if (refreshTimer) return; + updateLastFM(); + refreshTimer = setInterval(updateLastFM, 30000); + } + if ("IntersectionObserver" in window) { + const io = new IntersectionObserver(function (entries) { + if (entries.some(function (e) { return e.isIntersecting; })) { + io.disconnect(); + startLastFM(); + } + }, { rootMargin: "200px" }); + io.observe(lastfmList || lastfmNow); + } else { + startLastFM(); + } + + // Manual refresh button + const refreshBtn = byId("lastfm-refresh"); + if (refreshBtn) { + refreshBtn.addEventListener("click", () => { + refreshBtn.textContent = "Updating..."; + refreshBtn.disabled = true; + updateLastFM(); + setTimeout(() => { + refreshBtn.textContent = "Refresh"; + refreshBtn.disabled = false; + }, 500); + }); + } + } + + /* ======================================================================== + Footer helpers + ======================================================================== */ + // Footer year: local time only. + (function () { + const el = byId("copyright-year"); + if (!el) return; + + el.textContent = String(new Date().getFullYear()); + })(); + + // Footer last-updated: based on document.lastModified. + (function () { + const el = byId("last-updated"); + if (!el) return; + + const raw = document.lastModified; + const date = raw ? new Date(raw) : null; + if (!date || Number.isNaN(date.getTime())) return; + + const iso = date.toISOString().slice(0, 10); + el.setAttribute("datetime", iso); + el.textContent = iso; + })(); + + /* ======================================================================== + Force red text in same-origin iframes + ======================================================================== */ + var _gbFrameIds = new Set(["guestbook-form-iframe", "guestbook-comments-iframe"]); + + function forceIframeColors(frame) { + if (_gbFrameIds.has(frame.id)) return; + try { + var doc = frame.contentDocument || frame.contentWindow.document; + if (!doc) return; + if (doc.getElementById('forced-colors-style')) return; + + var style = doc.createElement('style'); + style.id = 'forced-colors-style'; + style.textContent = + ':root { --ink: #8b0000; --bg: #000000; }' + + '* { color: #8b0000 !important; }' + + 'a { color: #8b0000 !important; text-decoration: underline; }' + + 'body { background-color: var(--bg, #000) !important; color: #8b0000 !important; }'; + doc.head.appendChild(style); + } catch (e) { + // Cross-domain frames cannot be styled + } + } + + function attachIframeColorForcing() { + document.querySelectorAll('iframe').forEach(function (frame) { + if (frame._colorForced) return; + frame._colorForced = true; + frame.addEventListener('load', function () { forceIframeColors(frame); }); + // Also try immediately in case already loaded + forceIframeColors(frame); + }); + } + + window.addEventListener('load', attachIframeColorForcing); + document.addEventListener('DOMContentLoaded', attachIframeColorForcing); + + /* ======================================================================== + Global Header Search (unified search w/ engine picker) + Mirrors /startpage/ behavior; uses gh-* IDs to avoid collisions. + ======================================================================== */ + var GH_SEARCH_ENGINES = [ + { key: 'site', label: '⌂', title: 'This site', placeholder: 'Search pages on sillylaird.ca…', action: '#', param: 'q', local: true }, + { key: 'google', label: 'G', title: 'Google Search', placeholder: 'Search Google...', action: 'https://www.google.ca/search', param: 'q' }, + { key: 'youtube', label: 'YT', title: 'YouTube Search', placeholder: 'Search YouTube...', action: 'https://www.youtube.com/results', param: 'search_query' }, + { key: 'sillylaird', label: 'SL', title: 'SillyLaird Search', placeholder: 'Search SillyLaird...', action: 'https://search.sillylaird.ca/search', param: 'q' }, + { key: '4get', label: '4G', title: '4get Search', placeholder: 'Search 4get...', action: 'https://4get.sillylaird.ca/web', param: 's' }, + { key: 'gemini', label: 'GM', title: 'Gemini Search', placeholder: 'Ask Gemini...', action: '/gemini-search.php', param: 'q' } + ]; + var GH_SEARCH_KEY = 'siteHeaderSearchEngine_v1'; + + function initGlobalHeaderSearch() { + var form = byId('gh-search-form'); + var input = byId('gh-search-input'); + var toggle = byId('gh-search-engine-toggle'); + var menu = byId('gh-search-engine-menu'); + if (!form || !input || !toggle || !menu) return; + if (toggle._ghInit) return; + toggle._ghInit = true; + + var saved = null; + try { saved = localStorage.getItem(GH_SEARCH_KEY); } catch (_) {} + var idx = GH_SEARCH_ENGINES.findIndex(function (e) { return e.key === saved; }); + if (idx < 0) idx = GH_SEARCH_ENGINES.findIndex(function (e) { return e.key === 'google'; }); + if (idx < 0) idx = 0; + + GH_SEARCH_ENGINES.forEach(function (e, i) { + var li = document.createElement('li'); + li.setAttribute('role', 'option'); + li.dataset.index = String(i); + var icon = document.createElement('span'); + icon.className = 'engine-icon'; + icon.textContent = e.label; + var name = document.createElement('span'); + name.className = 'engine-name'; + name.textContent = e.title; + li.appendChild(icon); + li.appendChild(name); + li.addEventListener('click', function () { + idx = i; + apply(idx); + closeMenu(); + input.focus(); + }); + menu.appendChild(li); + }); + + function apply(i) { + var e = GH_SEARCH_ENGINES[i]; + form.setAttribute('action', e.local ? '#' : e.action); + if (e.local) { + form.removeAttribute('target'); + } else { + form.setAttribute('target', '_blank'); + form.setAttribute('rel', 'noopener noreferrer'); + } + input.setAttribute('name', e.local ? 'site-q' : e.param); + input.setAttribute('placeholder', e.placeholder); + toggle.textContent = ''; + toggle.appendChild(document.createTextNode(e.label + ' ')); + var caret = document.createElement('span'); + caret.className = 'search-engine-caret'; + caret.textContent = '▾'; + toggle.appendChild(caret); + toggle.setAttribute('title', 'Search engine: ' + e.title); + Array.prototype.forEach.call(menu.children, function (li, li_i) { + li.setAttribute('aria-selected', li_i === i ? 'true' : 'false'); + }); + try { localStorage.setItem(GH_SEARCH_KEY, e.key); } catch (_) {} + } + + function openMenu() { + menu.hidden = false; + toggle.setAttribute('aria-expanded', 'true'); + } + function closeMenu() { + menu.hidden = true; + toggle.setAttribute('aria-expanded', 'false'); + } + + toggle.addEventListener('click', function (ev) { + ev.stopPropagation(); + if (menu.hidden) openMenu(); else closeMenu(); + }); + + document.addEventListener('click', function (ev) { + if (menu.hidden) return; + if (ev.target === toggle || toggle.contains(ev.target)) return; + if (menu.contains(ev.target)) return; + closeMenu(); + }); + + document.addEventListener('keydown', function (ev) { + if (ev.key === 'Escape' && !menu.hidden) closeMenu(); + }); + + var siteHits = byId('site-search-hits'); + var siteIndex = null; + + function loadSiteIndex() { + if (siteIndex) return Promise.resolve(siteIndex); + return fetch('/assets/data/site-index.json') + .then(function (r) { return r.json(); }) + .then(function (j) { siteIndex = Array.isArray(j) ? j : []; return siteIndex; }) + .catch(function () { siteIndex = []; return siteIndex; }); + } + + function renderSiteHits(q) { + if (!siteHits) return; + var query = (q || '').trim().toLowerCase(); + if (!query) { siteHits.hidden = true; siteHits.textContent = ''; return; } + loadSiteIndex().then(function (pages) { + var hits = pages.filter(function (p) { + var hay = (p.title + ' ' + p.url + ' ' + (p.tags || '')).toLowerCase(); + return hay.indexOf(query) !== -1; + }).slice(0, 8); + siteHits.textContent = ''; + if (!hits.length) { siteHits.hidden = true; return; } + hits.forEach(function (p) { + var li = document.createElement('li'); + var a = document.createElement('a'); + a.href = p.url; + if (/^https?:\/\//.test(p.url)) { + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + } + a.textContent = p.title; + li.appendChild(a); + siteHits.appendChild(li); + }); + siteHits.hidden = false; + }); + } + + input.addEventListener('input', function () { + if (GH_SEARCH_ENGINES[idx] && GH_SEARCH_ENGINES[idx].local) { + renderSiteHits(input.value); + } else if (siteHits) { + siteHits.hidden = true; + } + }); + + form.addEventListener('submit', function (ev) { + if (GH_SEARCH_ENGINES[idx] && GH_SEARCH_ENGINES[idx].local) { + ev.preventDefault(); + var first = siteHits && siteHits.querySelector('a'); + if (first) first.click(); + else renderSiteHits(input.value); + } + }); + + apply(idx); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initGlobalHeaderSearch); + } else { + initGlobalHeaderSearch(); + } + + (function initRadioWidget() { + var box = byId('radio-widget'); + if (!box) return; + function render(title) { + box.textContent = ''; + var p = document.createElement('p'); + if (title) { + p.className = 'radio-now-line'; + var strong = document.createElement('strong'); + strong.textContent = 'Radio: '; + p.appendChild(strong); + var span = document.createElement('span'); + span.id = 'radio-nowplaying-title'; + span.textContent = title; + p.appendChild(span); + p.appendChild(document.createTextNode(' — ')); + var a = document.createElement('a'); + a.href = 'https://radio.sillylaird.ca/'; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.textContent = 'Listen live'; + p.appendChild(a); + } else { + p.className = 'muted'; + p.textContent = 'Radio offline — '; + var link = document.createElement('a'); + link.href = 'https://radio.sillylaird.ca/'; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = 'radio.sillylaird.ca'; + p.appendChild(link); + } + box.appendChild(p); + } + function poll() { + fetch('/api/nowplaying.php', { cache: 'no-store' }) + .then(function (r) { return r.ok ? r.json() : Promise.reject(); }) + .then(function (j) { + var title = j && j.title ? String(j.title).trim() : ''; + render(title); + }) + .catch(function () { render(''); }); + } + poll(); + setInterval(poll, 30000); + })(); + + (function initLainEgg() { + var btn = byId('lain-avatar-btn'); + if (!btn) return; + var secrets = [ + btn.getAttribute('data-secret-1'), + btn.getAttribute('data-secret-2'), + btn.getAttribute('data-secret-3'), + ].filter(Boolean); + var i = 0; + btn.addEventListener('click', function () { + var msg = secrets[i % secrets.length] || "Let's all love Lain."; + i += 1; + btn.classList.add('is-lain-pulse'); + window.setTimeout(function () { btn.classList.remove('is-lain-pulse'); }, 600); + var toast = document.createElement('div'); + toast.className = 'lain-toast'; + toast.setAttribute('role', 'status'); + toast.textContent = msg; + document.body.appendChild(toast); + window.setTimeout(function () { toast.classList.add('is-visible'); }, 10); + window.setTimeout(function () { + toast.classList.remove('is-visible'); + window.setTimeout(function () { toast.remove(); }, 350); + }, 2800); + }); + })(); + + // Per-page visit counter: canonical implementation is visit-counter.js + // (nginx injects it network-wide). Fall back here if that script is absent. + (function initVisitCounter() { + if (window.__slVisitCounterInit) return; + if (document.querySelector('script[src*="visit-counter.js"]')) return; + var s = document.createElement('script'); + // Always load from www so sibling hosts (blog/radio/…) resolve correctly + // even when this file itself was loaded cross-origin via nav.js. + s.src = 'https://www.sillylaird.ca/assets/js/visit-counter.js?v=2026080403'; + s.defer = true; + document.head.appendChild(s); + })(); + + // Scroll-to-top: canonical implementation is scroll-to-top.js (nginx injects + // it network-wide alongside the visit counter). Fall back if absent. + (function initScrollToTop() { + if (window.__slScrollTopInit) return; + if (document.querySelector('script[src*="scroll-to-top.js"]')) return; + var s = document.createElement('script'); + s.src = 'https://www.sillylaird.ca/assets/js/scroll-to-top.js?v=20260821b'; + s.defer = true; + document.head.appendChild(s); + })(); + + (function initSectionDraw() { + var titles = document.querySelectorAll('.section-accent-title, .network-subtitle'); + var sections = document.querySelectorAll('.home-content > section'); + var targets = []; + titles.forEach(function (el) { targets.push(el); }); + sections.forEach(function (el) { targets.push(el); }); + if (!targets.length) return; + + function markDrawn(el) { + el.classList.add('is-drawn'); + } + + if (!('IntersectionObserver' in window) || + window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + targets.forEach(markDrawn); + return; + } + + var io = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + markDrawn(entry.target); + io.unobserve(entry.target); + }); + }, { threshold: 0.35, rootMargin: '0px 0px -8% 0px' }); + + targets.forEach(function (el) { io.observe(el); }); + })(); + + (function initNetworkAccordion() { + var items = document.querySelectorAll('.network-collapsible'); + if (!items.length) return; + var mq = window.matchMedia('(max-width: 600px)'); + + function sync() { + var mobile = mq.matches; + items.forEach(function (el, i) { + el.open = mobile ? i === 0 : true; + }); + } + + sync(); + if (typeof mq.addEventListener === 'function') { + mq.addEventListener('change', sync); + } else if (typeof mq.addListener === 'function') { + mq.addListener(sync); + } + })(); + + /* ======================================================================== + Vibe facade: click-to-load embeds (no provider request until clicked) + + The player iframe is created synchronously inside the click handler so + Chrome allows autoplay (gesture-driven). State is read straight from each + provider's postMessage protocol, so when the viewer pauses we show a + "paused" overlay that mirrors the load facade but with a pause glyph. + + One provider entry per supported site. `state(data, iframe)` maps a raw + message to 'paused' | 'playing' | null. Bilibili exposes no cross-origin + pause event, so it has no entry and falls back to a plain autoplay iframe. + ======================================================================== */ + + function vibePost(iframe, msg, origin) { + try { + iframe.contentWindow.postMessage(typeof msg === 'string' ? msg : JSON.stringify(msg), origin || '*'); + } catch (_) {} + } + + function withParam(url, extra) { + return url + (url.indexOf('?') > -1 ? '&' : '?') + extra; + } + + // Pause overlay — same menu as the load facade. Mode 'paused' shows a pause + // glyph + resume copy; mode 'ended' shows a replay glyph + replay copy. + function setOverlayMode(overlay, mode) { + if (mode === 'ended') { + overlay.classList.add('vibe-facade-ended'); + overlay.setAttribute('aria-label', 'Replay the video'); + overlay.innerHTML = + '<span class="vibe-facade-play" aria-hidden="true">↻</span>' + + '<span class="vibe-facade-text">Replay<br><small>Click to watch again</small></span>'; + } else { + overlay.classList.remove('vibe-facade-ended'); + overlay.setAttribute('aria-label', 'Resume the video'); + overlay.innerHTML = + '<span class="vibe-facade-play" aria-hidden="true">⏸</span>' + + '<span class="vibe-facade-text">Paused<br><small>Click to resume</small></span>'; + } + } + + function makePauseOverlay() { + var overlay = document.createElement('button'); + overlay.type = 'button'; + overlay.className = 'vibe-facade vibe-facade-paused'; + overlay.hidden = true; + setOverlayMode(overlay, 'paused'); + return overlay; + } + + var VIBE_PROVIDERS = { + youtube: { + match: /youtube(-nocookie)?\.com\/embed\//, + origin: /^https?:\/\/(www\.)?youtube(-nocookie)?\.com$/, + // youtube.com (not -nocookie): the postMessage API replies from + // youtube.com. Privacy is already moot once the user has clicked. + src: function (e) { return withParam(e.replace('youtube-nocookie.com', 'youtube.com'), 'autoplay=1&enablejsapi=1'); }, + subscribe: function (f) { + vibePost(f, { event: 'listening', id: 1, channel: 'widget' }); + vibePost(f, { event: 'command', func: 'addEventListener', args: ['onStateChange'] }); + }, + play: function (f) { vibePost(f, { event: 'command', func: 'playVideo', args: [] }); }, + state: function (d) { + var s = null; + if (d.event === 'onStateChange') s = d.info; + else if (d.event === 'infoDelivery' && d.info && typeof d.info.playerState === 'number') s = d.info.playerState; + if (s === 0) return 'ended'; // 0 = ended → show replay button + if (s === 2) return 'paused'; // 2 = paused + if (s === 1 || s === 3) return 'playing'; // 1 = playing, 3 = buffering + return null; + } + }, + + vimeo: { + match: /player\.vimeo\.com\/video\//, + origin: /^https?:\/\/player\.vimeo\.com$/, + src: function (e) { return withParam(e, 'autoplay=1'); }, + subscribe: function (f) { + vibePost(f, { method: 'addEventListener', value: 'play' }, 'https://player.vimeo.com'); + vibePost(f, { method: 'addEventListener', value: 'pause' }, 'https://player.vimeo.com'); + }, + play: function (f) { vibePost(f, { method: 'play' }, 'https://player.vimeo.com'); }, + state: function (d, f) { + if (d.event === 'ready') { this.subscribe(f); return null; } // (re)subscribe once ready + if (d.event === 'pause') return 'paused'; + if (d.event === 'play' || d.event === 'playing') return 'playing'; + return null; + } + }, + + dailymotion: { + match: /dailymotion\.com\/player/, + origin: /^https?:\/\/(geo\.)?dailymotion\.com$/, + src: function (e) { return withParam(e, 'autoplay=1'); }, + play: function (f) { + vibePost(f, { command: 'play', parameters: [] }); + vibePost(f, { method: 'play' }); + }, + state: function (d) { + var ev = d.event; + if (ev === 'pause' || ev === 'video_pause') return 'paused'; + if (ev === 'play' || ev === 'playing' || ev === 'video_start') return 'playing'; + return null; + } + }, + + niconico: { + match: /embed\.nicovideo\.jp\/watch\//, + origin: /^https?:\/\/embed\.nicovideo\.jp$/, + // jsapi=1&playerId=1 turns on Niconico's postMessage control API. + src: function (e) { return withParam(e, 'jsapi=1&playerId=1&autoplay=1'); }, + play: function (f) { + vibePost(f, { sourceConnectorType: 1, playerId: f._vibeNicoId || '1', eventName: 'play' }, 'https://embed.nicovideo.jp'); + }, + state: function (d, f) { + if (d.playerId != null) f._vibeNicoId = d.playerId; // captured for play() + if (d.eventName === 'statusChange' || d.eventName === 'playerStatusChange') { + var ps = d.data && d.data.playerStatus; + if (ps === 3) return 'paused'; // 3 = pause + if (ps === 2) return 'playing'; // 2 = play + } + return null; + } + } + }; + + function vibeProviderFor(embed) { + for (var k in VIBE_PROVIDERS) { + if (VIBE_PROVIDERS.hasOwnProperty(k) && VIBE_PROVIDERS[k].match.test(embed)) return VIBE_PROVIDERS[k]; + } + return null; + } + + function loadProviderWithOverlay(wrap, facadeBtn, embed, prov) { + var f = document.createElement('iframe'); + f.src = prov.src(embed); + f.title = 'Current vibe — video'; + f.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); + f.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen'); + f.setAttribute('allowfullscreen', ''); + facadeBtn.replaceWith(f); + + var overlay = makePauseOverlay(); + wrap.appendChild(overlay); + + overlay.addEventListener('click', function () { + overlay.hidden = true; + prov.play(f); + }); + + f.addEventListener('load', function () { if (prov.subscribe) prov.subscribe(f); }); + + window.addEventListener('message', function (ev) { + if (prov.origin && !prov.origin.test(ev.origin)) return; + var data = ev.data; + if (typeof data === 'string') { + try { + data = JSON.parse(data); + } catch (_) { + if (data.indexOf('=') === -1) return; // some players send querystring strings + var o = {}; + data.split('&').forEach(function (kv) { + var p = kv.split('='); + o[decodeURIComponent(p[0])] = decodeURIComponent(p[1] || ''); + }); + data = o; + } + } + if (!data || typeof data !== 'object') return; + var st = prov.state(data, f); + if (st === 'ended') { setOverlayMode(overlay, 'ended'); overlay.hidden = false; } + else if (st === 'paused') { setOverlayMode(overlay, 'paused'); overlay.hidden = false; } + else if (st === 'playing') overlay.hidden = true; + }); + } + + // Direct linked media: native <video>/<audio> get the same pause overlay, + // driven by the element's own play/pause events (no postMessage). Audio has + // no video surface, so it gets a compact (icon + "Paused") variant. + function attachNativePauseOverlay(media, compact) { + var wrap; + if (compact) { + // Audio has no positioned container — wrap it so the overlay can sit on top. + wrap = media.parentNode; + if (!wrap || !wrap.classList.contains('vibe-audio-wrap')) { + wrap = document.createElement('div'); + wrap.className = 'vibe-audio-wrap'; + media.parentNode.insertBefore(wrap, media); + wrap.appendChild(media); + } + } else { + wrap = media.parentNode; // .vibe-embed (position:relative) + } + if (!wrap || wrap._vibePauseDone) return; + wrap._vibePauseDone = true; + + var overlay = makePauseOverlay(); + if (compact) overlay.classList.add('vibe-facade-compact'); + wrap.appendChild(overlay); + + overlay.addEventListener('click', function () { + overlay.hidden = true; + var p = media.play(); + if (p && p.catch) p.catch(function () {}); + }); + // Only after playback has begun — don't cover the not-yet-started media. + media.addEventListener('pause', function () { if (!media.ended) { setOverlayMode(overlay, 'paused'); overlay.hidden = false; } }); + media.addEventListener('play', function () { overlay.hidden = true; }); + media.addEventListener('ended', function () { setOverlayMode(overlay, 'ended'); overlay.hidden = false; }); + } + + function scanNativeVibeMedia() { + var vids = document.querySelectorAll('.vibe-embed > video'); + for (var i = 0; i < vids.length; i++) attachNativePauseOverlay(vids[i], false); + var auds = document.querySelectorAll('.vibe-media-audio'); + for (var j = 0; j < auds.length; j++) attachNativePauseOverlay(auds[j], true); + } + scanNativeVibeMedia(); + document.addEventListener('DOMContentLoaded', scanNativeVibeMedia); + + document.addEventListener('click', function (ev) { + var b = ev.target.closest ? ev.target.closest('.vibe-facade') : null; + if (!b || !b.dataset.embed) return; + var embed = b.dataset.embed; + var wrap = b.parentNode; // .vibe-embed (position:relative) + + // Supported provider → load with pause overlay. + var prov = wrap ? vibeProviderFor(embed) : null; + if (prov) { + loadProviderWithOverlay(wrap, b, embed, prov); + return; + } + + // Bilibili / anything else → plain autoplay iframe. + var src = withParam(embed, 'autoplay=1'); + var f = document.createElement('iframe'); + f.src = src; + f.title = 'Current vibe — video'; + f.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); + f.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen'); + f.setAttribute('allowfullscreen', ''); + b.replaceWith(f); + }); + +})(); |
