aboutsummaryrefslogtreecommitdiffstats
path: root/assets/js
diff options
context:
space:
mode:
authorsillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
committersillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
commit898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch)
tree85c6ee5ad58b860144551184d4cf86b560c62b91 /assets/js
downloadwww-main.tar.gz
www-main.zip
import live www.sillylaird.ca webrootHEADmain
Diffstat (limited to 'assets/js')
-rw-r--r--assets/js/includes.js33
-rw-r--r--assets/js/nav.js177
-rw-r--r--assets/js/pages/404.js5
-rw-r--r--assets/js/pages/50x.js5
-rw-r--r--assets/js/pages/admin-vibe.js71
-rw-r--r--assets/js/pages/admin.js714
-rw-r--r--assets/js/pages/guestbook-comments.js39
-rw-r--r--assets/js/pages/guestbook_jp.js91
-rw-r--r--assets/js/pages/guestbook_zh.js91
-rw-r--r--assets/js/pages/hitcounter.js26
-rw-r--r--assets/js/pages/latest.js10
-rw-r--r--assets/js/pages/mstartpage-index.js118
-rw-r--r--assets/js/pages/radio-nowplaying.js41
-rw-r--r--assets/js/pages/vibe-admin.js48
-rw-r--r--assets/js/pages/whatsleft.js16
-rw-r--r--assets/js/scroll-to-top.js180
-rw-r--r--assets/js/site.js1341
-rw-r--r--assets/js/visit-counter.js141
18 files changed, 3147 insertions, 0 deletions
diff --git a/assets/js/includes.js b/assets/js/includes.js
new file mode 100644
index 0000000..13a7933
--- /dev/null
+++ b/assets/js/includes.js
@@ -0,0 +1,33 @@
+(async function () {
+ async function loadInto(selector, url) {
+ const mount = document.querySelector(selector);
+ if (!mount) return;
+
+ try {
+ const res = await fetch(url);
+ if (!res.ok) throw new Error("HTTP " + res.status);
+ const html = await res.text();
+ mount.innerHTML = html;
+ } catch (e) {
+ // Fail open: if includes fail, keep page usable.
+ // Intentionally no console noise.
+ }
+ }
+
+ // Pages opt-in by including: <div data-include="header"></div>
+ // and/or <div data-include="footer"></div>
+ await Promise.all([
+ loadInto('[data-include="head-meta"]', "/partials/head-meta.html"),
+ loadInto('[data-include="header"]', "/partials/header.html"),
+ loadInto('[data-include="footer"]', "/partials/footer.html"),
+ ]);
+
+ // If a page uses includes, it likely removed the built-in header/footer.
+ // Re-run site.js behaviors after injection.
+ if (document.querySelector('[data-include="header"], [data-include="footer"]')) {
+ const s = document.createElement("script");
+ s.src = "/assets/js/site.js";
+ s.defer = true;
+ document.head.appendChild(s);
+ }
+})();
diff --git a/assets/js/nav.js b/assets/js/nav.js
new file mode 100644
index 0000000..e2fd6b6
--- /dev/null
+++ b/assets/js/nav.js
@@ -0,0 +1,177 @@
+/* ============================================================================
+ Shared SillyLaird site nav — single source of truth.
+
+ Drop this on any sibling site to get the same top nav as www.sillylaird.ca:
+
+ <div id="sl-nav"></div>
+ <script defer src="https://www.sillylaird.ca/assets/js/nav.js"></script>
+
+ The script injects the header markup, pulls in the shared stylesheet, and
+ then loads site.js (which wires the theme toggle, drawer menu, and language
+ menu). Edit the markup HERE and every site updates on next load.
+
+ If #sl-nav is absent it replaces an existing <header class="site-header">,
+ else it prepends to <body>. Skips entirely on www itself (server-rendered).
+ ============================================================================ */
+(function () {
+ "use strict";
+
+ var WWW = "https://www.sillylaird.ca";
+ // Bump when nav.css / site.js behavior for siblings changes — static assets
+ // are cached ~30d, so without this edits won't reach visitors.
+ var NAV_VER = "2026080401";
+
+ // Already server-rendered on www — don't double up.
+ if (location.hostname === "www.sillylaird.ca") return;
+
+ var HEADER_HTML =
+ '<header class="site-header" style="position: fixed; top: 0; left: 0; right: 0;">' +
+ '<div class="header-inner">' +
+ '<div class="brand">' +
+ '<a href="' + WWW + '/" class="logo" aria-label="SillyLaird home">' +
+ '<img src="' + WWW + '/assets/img/lain.png" alt="SillyLaird logo" width="48" height="48" decoding="async" />' +
+ '</a>' +
+ '<div class="brand-text">' +
+ '<strong>SillyLaird</strong>' +
+ '<span>personal hub: links, logs, and vibes</span>' +
+ '</div>' +
+ '</div>' +
+
+ '<div class="header-controls">' +
+ '<div class="protocol-pills" aria-label="Alternate network protocols">' +
+ '<a class="protocol-pill protocol-pill--gemini" href="gemini://sillylaird.ca" target="_blank" rel="noopener noreferrer" title="Open the Gemini capsule">Gemini</a>' +
+ '<a class="protocol-pill protocol-pill--gopher" href="gopher://sillylaird.ca" target="_blank" rel="noopener noreferrer" title="Open the Gopher hole">Gopher</a>' +
+ '<a class="protocol-pill protocol-pill--tor" href="http://xyb4zkpbnssz2kgiklg5vfbepbnblykgfvemwadvufwzfcrjny46q6qd.onion/" title="Open the Tor onion service">Tor</a>' +
+ '<a class="protocol-pill protocol-pill--i2p" href="http://sillylaird.i2p/" title="Open the I2P site">I2P</a>' +
+ '</div>' +
+
+ '<a href="' + WWW + '/startpage/" class="header-button header-button--startpage" title="Open the power-user StartPage">StartPage</a>' +
+ '<a href="' + WWW + '/" class="header-button" title="Go to the homepage">Home</a>' +
+
+ '<button class="header-button header-icon-button comfort-toggle" id="comfort-toggle" type="button" title="Toggle comfortable line spacing" aria-pressed="false" aria-label="Comfort spacing off">↕</button>' +
+
+ '<button class="header-button header-icon-button" id="theme-toggle" type="button" title="Toggle light / dark colour theme" aria-label="Switch to light theme">☀</button>' +
+
+ '<div class="lang">' +
+ '<div class="lang-toggle-wrapper">' +
+ '<button class="header-button header-icon-button" id="lang-toggle" type="button" title="Choose language" aria-haspopup="menu" aria-expanded="false">文<span class="visually-hidden">Language: <span id="current-lang">EN</span></span></button>' +
+ '</div>' +
+ '<div class="lang-menu" id="lang-options" role="menu" hidden>' +
+ '<a role="menuitem" href="' + WWW + '/?lang=en" hreflang="en" lang="en" data-lang="en" data-lang-href>English</a>' +
+ '<a role="menuitem" href="' + WWW + '/?lang=zh" hreflang="zh" lang="zh" data-lang="zh" data-lang-href>中文</a>' +
+ '<a role="menuitem" href="' + WWW + '/?lang=jp" hreflang="ja" lang="ja" data-lang="jp" data-lang-href>日本語</a>' +
+ '</div>' +
+ '</div>' +
+
+ '<div class="menu-wrapper">' +
+ '<button id="menu-toggle" type="button" aria-label="Menu" aria-controls="site-nav" aria-expanded="false">☰</button>' +
+ '<nav id="site-nav" class="site-nav" aria-label="Main navigation" hidden>' +
+ '<ul class="nav-section" aria-label="Main">' +
+ '<li class="nav-label">Main</li>' +
+ '<li><a href="' + WWW + '/">Home</a></li>' +
+ '<li><a href="https://os.sillylaird.ca/" target="_blank" rel="noopener noreferrer">OS</a></li>' +
+ '<li><a href="' + WWW + '/now/">Now</a></li>' +
+ '<li><a href="' + WWW + '/motd/">MOTD</a></li>' +
+ '<li><a href="' + WWW + '/startpage/" class="nav-link--featured">StartPage ★</a></li>' +
+ '<li><a href="' + WWW + '/map/">Site Map</a></li>' +
+ '</ul>' +
+ '<ul class="nav-section" aria-label="Writing">' +
+ '<li class="nav-label">Writing</li>' +
+ '<li><a href="https://blog.sillylaird.ca/" target="_blank" rel="noopener noreferrer">Blog</a></li>' +
+ '<li><a href="' + WWW + '/journal/">Journal</a></li>' +
+ '<li><a href="' + WWW + '/changelog/">Changelog</a></li>' +
+ '<li><a href="https://diary.sillylaird.ca/bbs.php" target="_blank" rel="noopener noreferrer">Diary</a></li>' +
+ '<li><a href="https://guestbook.sillylaird.ca/" target="_blank" rel="noopener noreferrer">Guestbook</a></li>' +
+ '</ul>' +
+ '<ul class="nav-section" aria-label="Collections">' +
+ '<li class="nav-label">Collections</li>' +
+ '<li><a href="' + WWW + '/gaming/">Gaming</a></li>' +
+ '<li><a href="' + WWW + '/bookmarks/">Bookmarks</a></li>' +
+ '<li><a href="' + WWW + '/accounts/">Accounts</a></li>' +
+ '<li><a href="' + WWW + '/computers/">Computers</a></li>' +
+ '</ul>' +
+ '<ul class="nav-section" aria-label="Media">' +
+ '<li class="nav-label">Media</li>' +
+ '<li><a href="https://radio.sillylaird.ca/" target="_blank" rel="noopener noreferrer">Radio</a></li>' +
+ '<li><a href="https://live.sillylaird.ca/" target="_blank" rel="noopener noreferrer">Live</a></li>' +
+ '</ul>' +
+ '<ul class="nav-section" aria-label="Mail">' +
+ '<li class="nav-label">Mail</li>' +
+ '<li><a href="https://mail.google.com/mail/u/0/#inbox">Gmail</a></li>' +
+ '<li><a href="https://mail.sillylaird.ca/">Mail</a></li>' +
+ '<li><a href="http://xyb4zkpbnssz2kgiklg5vfbepbnblykgfvemwadvufwzfcrjny46q6qd.onion:8025/src/login.php">Mail@Tor</a></li>' +
+ '</ul>' +
+ '<ul class="nav-section" aria-label="Networks">' +
+ '<li class="nav-label">Networks</li>' +
+ '<li><a href="http://xyb4zkpbnssz2kgiklg5vfbepbnblykgfvemwadvufwzfcrjny46q6qd.onion/">Tor</a></li>' +
+ '<li><a href="http://sillylaird.i2p/">I2P</a></li>' +
+ '<li><a href="http://9p.sillylaird.ca" target="_blank" rel="noopener noreferrer">9p/9f</a></li>' +
+ '<li><a href="http://xp.sillylaird.ca" target="_blank" rel="noopener noreferrer">xp</a></li>' +
+ '<li><a href="gopher://sillylaird.ca" target="_blank" rel="noopener noreferrer">Gopher</a></li>' +
+ '<li><a href="gemini://sillylaird.ca" target="_blank" rel="noopener noreferrer">Gemini</a></li>' +
+ '</ul>' +
+ '<ul class="nav-section" aria-label="External">' +
+ '<li class="nav-label">External</li>' +
+ '<li><a href="https://www.heyaoi.net" target="_blank" rel="noopener noreferrer">Heyaoi</a></li>' +
+ '<li><a href="https://git.sillylaird.ca/" target="_blank" rel="noopener noreferrer">Git</a></li>' +
+ '<li><a href="https://cvs.sillylaird.ca/" target="_blank" rel="noopener noreferrer">CVS</a></li>' +
+ '</ul>' +
+ '</nav>' +
+ '</div>' +
+ '</div>' +
+ '</div>' +
+ '</header>';
+
+ // Ensure a stylesheet is present (idempotent by href). Prepended to the very
+ // top of <head> so the host page's own <style>/<link> (which come after) win
+ // on any shared selector. nav.css is already header-scoped (no base element
+ // rules), so it won't restyle the host page (e.g. diary's inline body colour).
+ function ensureCss(href) {
+ var links = document.getElementsByTagName("link");
+ for (var i = 0; i < links.length; i++) {
+ if (links[i].rel === "stylesheet" && links[i].href.indexOf(href) > -1) return;
+ }
+ var l = document.createElement("link");
+ l.rel = "stylesheet";
+ l.href = href;
+ document.head.insertBefore(l, document.head.firstChild);
+ }
+
+ function injectNav() {
+ var holder = document.getElementById("sl-nav");
+ if (holder) {
+ holder.outerHTML = HEADER_HTML;
+ } else {
+ var existing = document.querySelector("header.site-header");
+ if (existing) {
+ existing.outerHTML = HEADER_HTML;
+ } else {
+ document.body.insertAdjacentHTML("afterbegin", HEADER_HTML);
+ }
+ }
+
+ // site.js binds the theme/menu/lang controls by id at execution time, so it
+ // must run AFTER the nav exists. Load it now unless the page already has it.
+ if (!document.querySelector('script[src*="/assets/js/site.js"]')) {
+ var s = document.createElement("script");
+ s.src = WWW + "/assets/js/site.js?v=" + NAV_VER;
+ document.body.appendChild(s);
+ }
+ }
+
+ ensureCss(WWW + "/assets/css/fonts.css");
+ // If the host page already loads the full site.css (e.g. radio), its header
+ // rules are authoritative and identical to www — skip nav.css so the header is
+ // byte-for-byte the same as www and can never drift from the header-scoped subset.
+ var _hasSiteCss = false, _links = document.getElementsByTagName("link");
+ for (var _i = 0; _i < _links.length; _i++) {
+ if (_links[_i].rel === "stylesheet" && _links[_i].href.indexOf("/assets/css/site.css") > -1) { _hasSiteCss = true; break; }
+ }
+ if (!_hasSiteCss) ensureCss(WWW + "/assets/css/nav.css?v=" + NAV_VER);
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", injectNav);
+ } else {
+ injectNav();
+ }
+})();
diff --git a/assets/js/pages/404.js b/assets/js/pages/404.js
new file mode 100644
index 0000000..a97a5b1
--- /dev/null
+++ b/assets/js/pages/404.js
@@ -0,0 +1,5 @@
+(function(){
+ var el = document.getElementById('y');
+ if (!el) return;
+ el.textContent = String(new Date().getFullYear());
+ })();
diff --git a/assets/js/pages/50x.js b/assets/js/pages/50x.js
new file mode 100644
index 0000000..a97a5b1
--- /dev/null
+++ b/assets/js/pages/50x.js
@@ -0,0 +1,5 @@
+(function(){
+ var el = document.getElementById('y');
+ if (!el) return;
+ el.textContent = String(new Date().getFullYear());
+ })();
diff --git a/assets/js/pages/admin-vibe.js b/assets/js/pages/admin-vibe.js
new file mode 100644
index 0000000..0f7b79e
--- /dev/null
+++ b/assets/js/pages/admin-vibe.js
@@ -0,0 +1,71 @@
+function detectType(url) {
+ if (!url) return 'none';
+ try { new URL(url); } catch(e) { return 'none'; }
+ const path = (new URL(url)).pathname.toLowerCase();
+ const ext = path.split('.').pop();
+ const imgs = ['jpg','jpeg','png','gif','webp','bmp','svg','avif'];
+ const vids = ['mp4','webm','ogv','mov'];
+ const auds = ['mp3','flac','wav','ogg','aac','opus','m4a'];
+ if (imgs.includes(ext)) return 'image';
+ if (vids.includes(ext)) return 'video';
+ if (auds.includes(ext)) return 'audio';
+ if (/(?:youtube\.com\/watch\?.*v=|youtu\.be\/)([A-Za-z0-9_-]{11})/.test(url)) return 'youtube';
+ if (/vimeo\.com\/(\d+)/.test(url)) return 'vimeo';
+ return 'link';
+}
+
+function getYTId(url) {
+ const m = url.match(/(?:v=|youtu\.be\/)([A-Za-z0-9_-]{11})/);
+ return m ? m[1] : null;
+}
+function getVimeoId(url) {
+ const m = url.match(/vimeo\.com\/(\d+)/);
+ return m ? m[1] : null;
+}
+
+function updateBadge(type) {
+ const b = document.getElementById('type-badge');
+ b.textContent = type;
+}
+
+function updatePreview(url) {
+ const body = document.getElementById('preview-body');
+ if (!url) { body.innerHTML = '<p class="preview-empty">// no url set</p>'; return; }
+ const type = detectType(url);
+ const safeUrl = url.replace(/"/g, '%22');
+ let html = `<div class="preview-url"><a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${url}</a></div>`;
+ if (type === 'image') {
+ html += `<img src="${safeUrl}" alt="vibe preview" loading="lazy" class="vibe-preview-media">`;
+ } else if (type === 'video') {
+ html += `<video controls preload="none" class="vibe-preview-media"><source src="${safeUrl}"></video>`;
+ } else if (type === 'audio') {
+ html += `<audio controls preload="none" class="vibe-preview-audio"><source src="${safeUrl}"></audio>`;
+ } else if (type === 'youtube') {
+ const id = getYTId(url);
+ if (id) html += `<div class="ratio-wrap"><iframe src="https://www.youtube.com/embed/${id}" allowfullscreen loading="lazy"></iframe></div>`;
+ } else if (type === 'vimeo') {
+ const id = getVimeoId(url);
+ if (id) html += `<div class="ratio-wrap"><iframe src="https://player.vimeo.com/video/${id}" allowfullscreen loading="lazy"></iframe></div>`;
+ } else {
+ html += `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer" class="vibe-preview-link">${url}</a>`;
+ }
+ body.innerHTML = html;
+ updateBadge(type);
+}
+
+const urlInput = document.getElementById('url');
+let debounce;
+urlInput.addEventListener('input', () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => updatePreview(urlInput.value.trim()), 400);
+});
+
+document.querySelectorAll('.preset-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const url = btn.dataset.url;
+ const desc = btn.dataset.desc;
+ urlInput.value = url;
+ if (desc) document.getElementById('description').value = desc;
+ updatePreview(url);
+ });
+});
diff --git a/assets/js/pages/admin.js b/assets/js/pages/admin.js
new file mode 100644
index 0000000..076c5e9
--- /dev/null
+++ b/assets/js/pages/admin.js
@@ -0,0 +1,714 @@
+/* Unified admin panel client behaviour.
+ * Lives in an external file because the site's enforced CSP (nginx) uses
+ * script-src 'self' with no 'unsafe-inline' — inline <script> would be blocked.
+ */
+(function () {
+ // ---- Blog: markdown toolbar + live preview ----
+ var ed = document.getElementById('md-editor');
+ var pv = document.getElementById('md-preview');
+ if (ed) {
+ var esc = function (s) {
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+ };
+ var render = function (t) {
+ return esc(t)
+ .replace(/```([\s\S]*?)```/g, function (_, c) { return '<pre><code>' + c + '</code></pre>'; })
+ .replace(/^### (.*)$/gm, '<h3>$1</h3>')
+ .replace(/^## (.*)$/gm, '<h2>$1</h2>')
+ .replace(/^# (.*)$/gm, '<h1>$1</h1>')
+ .replace(/^&gt; (.*)$/gm, '<blockquote>$1</blockquote>')
+ .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
+ .replace(/\*([^*]+)\*/g, '<i>$1</i>')
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2" style="max-width:100%">')
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
+ .replace(/\n/g, '<br>');
+ };
+ var upd = function () {
+ if (pv) pv.innerHTML = ed.value.trim() ? render(ed.value) : '<em class="muted">Type to preview…</em>';
+ };
+ ed.addEventListener('input', upd);
+ upd();
+
+ document.querySelectorAll('.md-toolbar button[data-md]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var mode = btn.dataset.md, a = btn.dataset.a || '', b = btn.dataset.b || '';
+ var s = ed.selectionStart, e = ed.selectionEnd, v = ed.value;
+ if (mode === 'wrap') {
+ ed.setRangeText(a + v.substring(s, e) + b, s, e, 'end');
+ } else if (mode === 'line') {
+ var ls = s; while (ls > 0 && v[ls - 1] !== '\n') ls--;
+ ed.setRangeText(a, ls, ls, 'end');
+ } else {
+ ed.setRangeText(a, s, e, 'end');
+ }
+ ed.dispatchEvent(new Event('input'));
+ ed.focus();
+ });
+ });
+ }
+
+ // ---- MOTD: live XMPP preview (emoji / heading / body / url) ----
+ var motdPreview = document.getElementById('motd-live-preview');
+ if (motdPreview) {
+ var motdVal = function (id) {
+ var el = document.getElementById(id);
+ return el ? String(el.value || '').trim() : '';
+ };
+ var motdPaint = function () {
+ var emoji = motdVal('motd-emoji');
+ var heading = motdVal('motd-heading');
+ var body = motdVal('motd-body');
+ var url = motdVal('motd-url');
+ var head = [emoji, heading].filter(Boolean).join(' ');
+ var lines = [];
+ if (head) lines.push(head);
+ if (body) lines.push(body);
+ if (url) lines.push(url);
+ motdPreview.textContent = lines.join('\n') || '// fill in a body';
+ };
+ ['motd-emoji', 'motd-heading', 'motd-body', 'motd-url'].forEach(function (id) {
+ var el = document.getElementById(id);
+ if (el) el.addEventListener('input', motdPaint);
+ });
+ motdPaint();
+ }
+
+ // ---- Vibe: live "detected type" badge ----
+ var url = document.getElementById('v-url');
+ var badge = document.getElementById('v-type');
+ if (url && badge) {
+ var detect = function (u) {
+ if (!u) return 'none';
+ if (/\.(jpe?g|png|gif|webp|bmp|svg|avif)(\?|$)/i.test(u)) return 'image';
+ if (/\.(mp4|webm|ogv|mov)(\?|$)/i.test(u)) return 'video';
+ if (/\.(mp3|flac|wav|ogg|aac|opus|m4a)(\?|$)/i.test(u)) return 'audio';
+ if (/(youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/shorts\/)[\w-]{11}/.test(u)) return 'youtube';
+ if (/vimeo\.com\/(video\/)?\d+/.test(u)) return 'vimeo';
+ if (/(dailymotion\.com\/video|dai\.ly)\/[\w]+/i.test(u)) return 'dailymotion';
+ if (/bilibili\.com\/video\/(BV[\w]+|av\d+)/i.test(u)) return 'bilibili';
+ if (/(nicovideo\.jp\/watch|nico\.ms)\/(sm|nm|so)?\d+/i.test(u)) return 'niconico';
+ return 'link';
+ };
+ url.addEventListener('input', function () { badge.textContent = detect(url.value.trim()); });
+ }
+
+ // ---- Traffic analytics charts + live numbers (vanilla canvas, no CDN) ----
+ (function initTrafficLive() {
+ var liveRoot = document.getElementById('traffic-live-root');
+ var chartRoot = document.getElementById('traffic-analytics');
+ var dataEl = document.getElementById('traffic-analytics-data');
+ // Need at least KPI root or charts to run live updates
+ if (!liveRoot && !chartRoot) return;
+
+ var data = {};
+ if (dataEl) {
+ try {
+ data = JSON.parse(dataEl.textContent || '{}');
+ } catch (e) {
+ data = {};
+ }
+ }
+
+ var light = document.body.classList.contains('light-mode');
+ var colors = {
+ bg: light ? '#f4f7fa' : '#050000',
+ grid: light ? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.08)',
+ axis: light ? 'rgba(0,0,0,0.45)' : 'rgba(200,200,210,0.55)',
+ hits: light ? '#1a7a12' : '#39ff14',
+ hitsFill: light ? 'rgba(26,122,18,0.15)' : 'rgba(57,255,20,0.12)',
+ uniq: light ? '#1a4f9c' : '#5b9dff',
+ uniqFill: light ? 'rgba(26,79,156,0.12)' : 'rgba(91,157,255,0.12)',
+ bar: light ? '#1a7a12' : '#39ff14',
+ barAlt: light ? '#1a4f9c' : '#5b9dff',
+ bot: light ? '#a65c00' : '#ff9f1a',
+ botFill: light ? 'rgba(166,92,0,0.12)' : 'rgba(255,159,26,0.14)',
+ botAlt: light ? '#8a3d00' : '#ff6b35',
+ text: light ? 'rgba(20,20,30,0.75)' : 'rgba(200,200,210,0.8)',
+ muted: light ? 'rgba(20,20,30,0.45)' : 'rgba(160,160,170,0.7)',
+ };
+
+ function setupCanvas(canvas) {
+ if (!canvas) return null;
+ var dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
+ var rect = canvas.getBoundingClientRect();
+ var cssW = Math.max(280, Math.floor(rect.width || canvas.width || 600));
+ var cssH = Math.max(160, Math.floor(canvas.height * (cssW / (canvas.width || cssW))));
+ // Prefer intrinsic aspect from attributes
+ var attrW = parseInt(canvas.getAttribute('width'), 10) || 600;
+ var attrH = parseInt(canvas.getAttribute('height'), 10) || 240;
+ cssH = Math.round(cssW * (attrH / attrW));
+ canvas.style.width = cssW + 'px';
+ canvas.style.height = cssH + 'px';
+ canvas.width = Math.floor(cssW * dpr);
+ canvas.height = Math.floor(cssH * dpr);
+ var ctx = canvas.getContext('2d');
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ return { ctx: ctx, w: cssW, h: cssH };
+ }
+
+ function maxOf(arr) {
+ var m = 0;
+ for (var i = 0; i < arr.length; i++) if (arr[i] > m) m = arr[i];
+ return m || 1;
+ }
+
+ function niceMax(n) {
+ if (n <= 1) return 1;
+ var p = Math.pow(10, Math.floor(Math.log10(n)));
+ var m = n / p;
+ var nice = m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10;
+ return nice * p;
+ }
+
+ function drawEmpty(ctx, w, h, msg) {
+ ctx.fillStyle = colors.bg;
+ ctx.fillRect(0, 0, w, h);
+ ctx.fillStyle = colors.muted;
+ ctx.font = '12px ui-monospace, Menlo, Consolas, monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText(msg || 'No data yet', w / 2, h / 2);
+ }
+
+ /** Multi-series line chart */
+ function drawLineChart(canvas, series, labels, opts) {
+ var s = setupCanvas(canvas);
+ if (!s) return;
+ var ctx = s.ctx, w = s.w, h = s.h;
+ var pad = { t: 16, r: 12, b: 36, l: 40 };
+ var plotW = w - pad.l - pad.r;
+ var plotH = h - pad.t - pad.b;
+ ctx.fillStyle = colors.bg;
+ ctx.fillRect(0, 0, w, h);
+
+ var all = [];
+ series.forEach(function (ser) {
+ ser.values.forEach(function (v) { all.push(v); });
+ });
+ if (!labels.length || maxOf(all) === 0) {
+ drawEmpty(ctx, w, h, 'No hits in this range yet');
+ return;
+ }
+ var yMax = niceMax(maxOf(all));
+ var n = labels.length;
+ var xAt = function (i) {
+ return pad.l + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW);
+ };
+ var yAt = function (v) {
+ return pad.t + plotH - (v / yMax) * plotH;
+ };
+
+ // grid
+ ctx.strokeStyle = colors.grid;
+ ctx.lineWidth = 1;
+ ctx.fillStyle = colors.axis;
+ ctx.font = '10px ui-monospace, Menlo, Consolas, monospace';
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ var ticks = 4;
+ for (var t = 0; t <= ticks; t++) {
+ var val = Math.round((yMax * t) / ticks);
+ var y = yAt(val);
+ ctx.beginPath();
+ ctx.moveTo(pad.l, y);
+ ctx.lineTo(pad.l + plotW, y);
+ ctx.stroke();
+ ctx.fillText(String(val), pad.l - 6, y);
+ }
+
+ // x labels (sparse)
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = colors.axis;
+ var step = Math.max(1, Math.ceil(n / 8));
+ for (var i = 0; i < n; i += step) {
+ var lab = labels[i];
+ if (opts && opts.labelFmt) lab = opts.labelFmt(lab);
+ ctx.fillText(lab, xAt(i), pad.t + plotH + 8);
+ }
+
+ series.forEach(function (ser) {
+ // fill
+ if (ser.fill) {
+ ctx.beginPath();
+ ctx.moveTo(xAt(0), yAt(0));
+ for (var j = 0; j < ser.values.length; j++) {
+ ctx.lineTo(xAt(j), yAt(ser.values[j]));
+ }
+ ctx.lineTo(xAt(ser.values.length - 1), yAt(0));
+ ctx.closePath();
+ ctx.fillStyle = ser.fill;
+ ctx.fill();
+ }
+ // line
+ ctx.beginPath();
+ for (var k = 0; k < ser.values.length; k++) {
+ var x = xAt(k), y = yAt(ser.values[k]);
+ if (k === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+ ctx.strokeStyle = ser.color;
+ ctx.lineWidth = 2;
+ ctx.lineJoin = 'round';
+ ctx.stroke();
+ // dots on last points / sparse
+ ctx.fillStyle = ser.color;
+ for (var d = 0; d < ser.values.length; d++) {
+ if (ser.values[d] <= 0 && d !== ser.values.length - 1) continue;
+ if (d % Math.max(1, Math.floor(n / 12)) !== 0 && d !== n - 1) continue;
+ ctx.beginPath();
+ ctx.arc(xAt(d), yAt(ser.values[d]), 2.5, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ });
+ }
+
+ /**
+ * Vertical bar chart.
+ * values: number[] (single series) OR { human: number[], bots: number[] }
+ * Bots draw in orange beside human bars.
+ */
+ function drawBarChart(canvas, values, labels, opts) {
+ var s = setupCanvas(canvas);
+ if (!s) return;
+ var ctx = s.ctx, w = s.w, h = s.h;
+ var pad = { t: 16, r: 12, b: 36, l: 40 };
+ var plotW = w - pad.l - pad.r;
+ var plotH = h - pad.t - pad.b;
+ ctx.fillStyle = colors.bg;
+ ctx.fillRect(0, 0, w, h);
+
+ var human = Array.isArray(values) ? values : (values.human || []);
+ var bots = Array.isArray(values) ? null : (values.bots || null);
+ var dual = !!(bots && bots.length === human.length);
+ var all = human.slice();
+ if (dual) bots.forEach(function (v) { all.push(v); });
+
+ if (!human.length || maxOf(all) === 0) {
+ drawEmpty(ctx, w, h, 'No hits in this range yet');
+ return;
+ }
+ var yMax = niceMax(maxOf(all));
+ var n = human.length;
+ var gap = 2;
+ var groupW = plotW / n;
+ var barW = dual
+ ? Math.max(2, (groupW - gap) / 2 - 1)
+ : Math.max(2, groupW - gap);
+
+ ctx.strokeStyle = colors.grid;
+ ctx.fillStyle = colors.axis;
+ ctx.font = '10px ui-monospace, Menlo, Consolas, monospace';
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ for (var t = 0; t <= 4; t++) {
+ var val = Math.round((yMax * t) / 4);
+ var y = pad.t + plotH - (val / yMax) * plotH;
+ ctx.beginPath();
+ ctx.moveTo(pad.l, y);
+ ctx.lineTo(pad.l + plotW, y);
+ ctx.stroke();
+ ctx.fillText(String(val), pad.l - 6, y);
+ }
+
+ for (var i = 0; i < n; i++) {
+ var gx = pad.l + i * groupW + gap / 2;
+ // Human (green)
+ var bh = (human[i] / yMax) * plotH;
+ var hy = pad.t + plotH - bh;
+ ctx.fillStyle = colors.bar;
+ ctx.globalAlpha = human[i] > 0 ? 0.9 : 0.15;
+ ctx.fillRect(gx, hy, barW, Math.max(bh, human[i] > 0 ? 1 : 0));
+ // Bots (orange) — separate colour, not mixed into human bars
+ if (dual) {
+ var bb = (bots[i] / yMax) * plotH;
+ var by = pad.t + plotH - bb;
+ ctx.fillStyle = colors.bot;
+ ctx.globalAlpha = bots[i] > 0 ? 0.9 : 0.15;
+ ctx.fillRect(gx + barW + 1, by, barW, Math.max(bb, bots[i] > 0 ? 1 : 0));
+ }
+ ctx.globalAlpha = 1;
+ }
+
+ ctx.fillStyle = colors.axis;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ var step = Math.max(1, Math.ceil(n / 8));
+ for (var j = 0; j < n; j += step) {
+ var lab = labels[j] || '';
+ if (opts && opts.labelFmt) lab = opts.labelFmt(lab);
+ var lx = pad.l + j * groupW + groupW / 2;
+ ctx.fillText(lab, lx, pad.t + plotH + 8);
+ }
+ }
+
+ /** Horizontal bar chart. opts.palette: 'bot' uses orange bot colors. */
+ function drawHBarChart(canvas, items, opts) {
+ var s = setupCanvas(canvas);
+ if (!s) return;
+ var ctx = s.ctx, w = s.w, h = s.h;
+ ctx.fillStyle = colors.bg;
+ ctx.fillRect(0, 0, w, h);
+ if (!items || !items.length) {
+ drawEmpty(ctx, w, h, 'No data yet');
+ return;
+ }
+ var pad = { t: 8, r: 48, b: 8, l: 8 };
+ var rowH = Math.min(28, (h - pad.t - pad.b) / items.length);
+ var maxLabel = 0;
+ ctx.font = '11px ui-monospace, Menlo, Consolas, monospace';
+ items.forEach(function (it) {
+ var lab = truncate(it.label, 36);
+ maxLabel = Math.max(maxLabel, ctx.measureText(lab).width);
+ });
+ var labelW = Math.min(Math.max(80, maxLabel + 8), w * 0.45);
+ var barLeft = pad.l + labelW + 8;
+ var barMaxW = w - barLeft - pad.r;
+ var maxC = maxOf(items.map(function (it) { return it.count; }));
+ maxC = niceMax(maxC);
+ var cA = (opts && opts.palette === 'bot') ? colors.bot : colors.bar;
+ var cB = (opts && opts.palette === 'bot') ? colors.botAlt : colors.barAlt;
+
+ items.forEach(function (it, i) {
+ var y = pad.t + i * rowH + rowH * 0.15;
+ var bh = rowH * 0.7;
+ var lab = truncate(it.label, 36);
+ ctx.fillStyle = colors.text;
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ ctx.font = '11px ui-monospace, Menlo, Consolas, monospace';
+ ctx.fillText(lab, pad.l + labelW, y + bh / 2);
+
+ var bw = maxC ? (it.count / maxC) * barMaxW : 0;
+ ctx.fillStyle = i % 2 === 0 ? cA : cB;
+ ctx.globalAlpha = 0.85;
+ ctx.fillRect(barLeft, y, Math.max(bw, it.count > 0 ? 2 : 0), bh);
+ ctx.globalAlpha = 1;
+
+ ctx.fillStyle = colors.muted;
+ ctx.textAlign = 'left';
+ ctx.fillText(String(it.count), barLeft + bw + 6, y + bh / 2);
+ });
+ }
+
+ function truncate(s, n) {
+ s = String(s || '');
+ if (s.length <= n) return s;
+ return s.slice(0, n - 1) + '…';
+ }
+
+ function esc(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;');
+ }
+
+ function pad2(n) {
+ n = String(n);
+ return n.length < 2 ? '0' + n : n;
+ }
+
+ function fmtUtc(ts, withSec) {
+ if (!ts) return '—';
+ var d = new Date(ts * 1000);
+ var s =
+ d.getUTCFullYear() +
+ '-' + pad2(d.getUTCMonth() + 1) +
+ '-' + pad2(d.getUTCDate()) +
+ ' ' + pad2(d.getUTCHours()) +
+ ':' + pad2(d.getUTCMinutes());
+ if (withSec) s += ':' + pad2(d.getUTCSeconds());
+ return s + ' UTC';
+ }
+
+ function drawAllCharts(analytics) {
+ analytics = analytics || {};
+ var daily = analytics.daily || [];
+ var dailySeries = [
+ { values: daily.map(function (d) { return d.hits; }), color: colors.hits, fill: colors.hitsFill },
+ { values: daily.map(function (d) { return d.unique; }), color: colors.uniq, fill: colors.uniqFill },
+ { values: daily.map(function (d) { return d.bots || 0; }), color: colors.bot, fill: colors.botFill },
+ ];
+ drawLineChart(
+ document.getElementById('chart-daily'),
+ dailySeries,
+ daily.map(function (d) { return d.day; }),
+ { labelFmt: function (d) { return d.slice(5); } }
+ );
+
+ var hourly = analytics.hourly || [];
+ drawBarChart(
+ document.getElementById('chart-hourly'),
+ {
+ human: hourly.map(function (d) { return d.hits; }),
+ bots: hourly.map(function (d) { return d.bots || 0; }),
+ },
+ hourly.map(function (d) { return d.hour; }),
+ { labelFmt: function (h) { return h.slice(11, 13) + 'h'; } }
+ );
+
+ drawHBarChart(document.getElementById('chart-pages'), analytics.top_pages || []);
+ drawHBarChart(document.getElementById('chart-visitors'), analytics.visitors || []);
+ drawHBarChart(document.getElementById('chart-bots'), analytics.bots || [], { palette: 'bot' });
+ drawHBarChart(document.getElementById('chart-hosts'), analytics.hosts || []);
+ drawHBarChart(document.getElementById('chart-langs'), analytics.langs || []);
+ drawHBarChart(document.getElementById('chart-refs'), analytics.refs || []);
+ }
+
+ // Animated count-up for KPI big numbers
+ var kpiRaf = {};
+ function animateKpi(el, from, to) {
+ var key = el.getAttribute('data-kpi') || el.id || 'x';
+ if (kpiRaf[key]) cancelAnimationFrame(kpiRaf[key]);
+ from = Math.round(Number(from) || 0);
+ to = Math.round(Number(to) || 0);
+ if (from === to) {
+ el.textContent = String(to);
+ el.setAttribute('data-value', String(to));
+ return;
+ }
+ var start = performance.now();
+ var dur = Math.min(700, 180 + Math.abs(to - from) * 12);
+ function frame(now) {
+ var t = Math.min(1, (now - start) / dur);
+ // ease-out cubic
+ var e = 1 - Math.pow(1 - t, 3);
+ var v = Math.round(from + (to - from) * e);
+ el.textContent = String(v);
+ if (t < 1) {
+ kpiRaf[key] = requestAnimationFrame(frame);
+ } else {
+ el.textContent = String(to);
+ el.setAttribute('data-value', String(to));
+ delete kpiRaf[key];
+ }
+ }
+ kpiRaf[key] = requestAnimationFrame(frame);
+ }
+
+ function updateKpis(summary, opts) {
+ if (!summary) return;
+ var animate = !opts || opts.animate !== false;
+ var keys = [
+ 'total_hits', 'unique_sessions', 'today', 'week',
+ 'bot_hits', 'bot_today', 'bot_week', 'total_pages',
+ ];
+ keys.forEach(function (k) {
+ if (summary[k] == null) return;
+ var el = document.querySelector('[data-kpi="' + k + '"]');
+ if (!el) return;
+ var next = Math.round(Number(summary[k]) || 0);
+ var prev = parseInt(el.getAttribute('data-value') || el.textContent || '0', 10) || 0;
+ if (prev === next && el.textContent === String(next)) return;
+ el.classList.remove('traffic-kpi-flash');
+ void el.offsetWidth;
+ el.classList.add('traffic-kpi-flash');
+ if (animate && Math.abs(next - prev) < 5000) {
+ animateKpi(el, prev, next);
+ } else {
+ el.textContent = String(next);
+ el.setAttribute('data-value', String(next));
+ }
+ });
+ }
+
+ function updateTopTable(rows) {
+ var body = document.getElementById('traffic-top-body');
+ var table = document.getElementById('traffic-top-table');
+ var empty = document.getElementById('traffic-top-empty');
+ if (!body || !table) return;
+ rows = rows || [];
+ if (!rows.length) {
+ body.innerHTML = '';
+ table.hidden = true;
+ if (empty) empty.hidden = false;
+ return;
+ }
+ if (empty) empty.hidden = true;
+ table.hidden = false;
+ var html = '';
+ for (var i = 0; i < rows.length; i++) {
+ var p = rows[i];
+ html +=
+ '<tr>' +
+ '<td><code>' + esc((p.host || '') + (p.path || '')) + '</code></td>' +
+ '<td>' + (p.hits | 0) + '</td>' +
+ '<td>' + (p.unique_sessions | 0) + '</td>' +
+ '<td>' + (p.bot_hits | 0) + '</td>' +
+ '<td>' + (p.last_local_count | 0) + '</td>' +
+ '<td>' + esc(p.last_visitor || '—') + '</td>' +
+ '<td>' + esc(p.last_hit ? fmtUtc(p.last_hit, false) : '—') + '</td>' +
+ '</tr>';
+ }
+ body.innerHTML = html;
+ }
+
+ function updateRecentTable(rows) {
+ var body = document.getElementById('traffic-recent-body');
+ var table = document.getElementById('traffic-recent-table');
+ var empty = document.getElementById('traffic-recent-empty');
+ if (!body || !table) return;
+ rows = rows || [];
+ if (!rows.length) {
+ body.innerHTML = '';
+ table.hidden = true;
+ if (empty) empty.hidden = false;
+ return;
+ }
+ if (empty) empty.hidden = true;
+ table.hidden = false;
+ var html = '';
+ for (var i = 0; i < rows.length; i++) {
+ var r = rows[i];
+ var isBot = !!(r.is_bot | 0);
+ html +=
+ '<tr class="' + (isBot ? 'traffic-row--bot' : '') + '">' +
+ '<td>' + esc(fmtUtc(r.ts, true)) + '</td>' +
+ '<td>' + esc(r.visitor || 'Unknown visitor') + '</td>' +
+ '<td>' +
+ (isBot
+ ? '<span class="type-badge traffic-badge--bot">bot</span>'
+ : '<span class="type-badge">human</span>') +
+ '</td>' +
+ '<td><code>' + esc((r.host || '') + (r.path || '')) + '</code></td>' +
+ '<td>' + (r.local_count | 0) + '</td>' +
+ '<td>' + esc(r.lang || '—') + '</td>' +
+ '<td>' + esc(r.ref_host || '—') + '</td>' +
+ '<td>' + (!isBot && (r.is_new | 0) ? 'yes' : '') + '</td>' +
+ '</tr>';
+ }
+ body.innerHTML = html;
+ }
+
+ function setLiveStatus(kind, msg) {
+ var bar = document.getElementById('traffic-live-bar');
+ var status = document.getElementById('traffic-live-status');
+ var ago = document.getElementById('traffic-live-ago');
+ if (bar) {
+ bar.classList.toggle('traffic-live-bar--err', kind === 'err');
+ bar.classList.toggle('traffic-live-bar--ok', kind === 'ok');
+ }
+ if (status) status.textContent = kind === 'err' ? 'Offline' : 'Live';
+ if (ago) ago.textContent = msg ? '· ' + msg : '';
+ }
+
+ // Initial paint from server-rendered payload
+ if (chartRoot && dataEl) {
+ drawAllCharts(data);
+ chartRoot.setAttribute('data-ready', '1');
+ }
+
+ // Redraw on resize (debounced)
+ var resizeT = null;
+ var lastAnalytics = data;
+ window.addEventListener('resize', function () {
+ clearTimeout(resizeT);
+ resizeT = setTimeout(function () {
+ if (chartRoot) drawAllCharts(lastAnalytics);
+ }, 150);
+ });
+
+ // ---- Live polling: numbers + charts + tables ----
+ var pollHost = liveRoot || chartRoot;
+ var pollUrl = (pollHost && pollHost.getAttribute('data-poll')) || '/api/traffic-stats.php';
+ var pollMs = parseInt((pollHost && pollHost.getAttribute('data-poll-ms')) || '2000', 10) || 2000;
+ pollMs = Math.max(1500, Math.min(30000, pollMs));
+ var lastRev = '';
+ var lastOkAt = 0;
+ var pollTimer = null;
+ var inFlight = false;
+ var aborted = false;
+
+ function agoLabel(ts) {
+ if (!ts) return 'waiting…';
+ var sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
+ if (sec < 2) return 'just now';
+ if (sec < 60) return sec + 's ago';
+ return Math.floor(sec / 60) + 'm ago';
+ }
+
+ function tickAgo() {
+ if (lastOkAt) setLiveStatus('ok', 'updated ' + agoLabel(lastOkAt));
+ }
+
+ function applyPayload(payload, force) {
+ if (!payload || !payload.ok) return;
+ lastOkAt = Date.now();
+ setLiveStatus('ok', 'updated ' + agoLabel(lastOkAt));
+
+ var changed = force || !payload.rev || payload.rev !== lastRev;
+ if (payload.rev) lastRev = payload.rev;
+ if (!changed) return;
+
+ // Numbers first so KPIs feel instant
+ if (payload.summary) updateKpis(payload.summary, { animate: true });
+ if (payload.analytics) {
+ lastAnalytics = payload.analytics;
+ if (dataEl) dataEl.textContent = JSON.stringify(payload.analytics);
+ if (chartRoot) drawAllCharts(payload.analytics);
+ }
+ if (payload.top) updateTopTable(payload.top);
+ if (payload.recent) updateRecentTable(payload.recent);
+ }
+
+ function pollOnce() {
+ if (aborted || inFlight || document.hidden) return;
+ inFlight = true;
+ var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
+ var to = setTimeout(function () {
+ if (ctrl) ctrl.abort();
+ }, 12000);
+ fetch(pollUrl + (pollUrl.indexOf('?') >= 0 ? '&' : '?') + '_=' + Date.now(), {
+ method: 'GET',
+ credentials: 'same-origin',
+ cache: 'no-store',
+ headers: { Accept: 'application/json' },
+ signal: ctrl ? ctrl.signal : undefined,
+ })
+ .then(function (res) {
+ if (res.status === 401) {
+ setLiveStatus('err', 'session expired — reload');
+ aborted = true;
+ return null;
+ }
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ return res.json();
+ })
+ .then(function (json) {
+ if (!json) return;
+ if (!json.ok) throw new Error(json.err || 'bad response');
+ applyPayload(json, false);
+ })
+ .catch(function () {
+ if (!aborted) setLiveStatus('err', 'retrying…');
+ })
+ .finally(function () {
+ clearTimeout(to);
+ inFlight = false;
+ });
+ }
+
+ function schedulePoll() {
+ clearTimeout(pollTimer);
+ if (aborted) return;
+ pollTimer = setTimeout(function () {
+ pollOnce();
+ schedulePoll();
+ }, pollMs);
+ }
+
+ // Immediate first poll so numbers move as soon as hits land
+ setTimeout(pollOnce, 400);
+ schedulePoll();
+ setInterval(tickAgo, 1000);
+
+ document.addEventListener('visibilitychange', function () {
+ if (!document.hidden && !aborted) {
+ pollOnce();
+ }
+ });
+ })();
+
+})();
diff --git a/assets/js/pages/guestbook-comments.js b/assets/js/pages/guestbook-comments.js
new file mode 100644
index 0000000..135cc24
--- /dev/null
+++ b/assets/js/pages/guestbook-comments.js
@@ -0,0 +1,39 @@
+fetch("/api/guestbook-proxy.php")
+ .then(r => r.text())
+ .then(html => {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ const entries = doc.querySelectorAll(".entry");
+ const container = document.getElementById("comments");
+ container.innerHTML = "";
+
+ entries.forEach(e => {
+ const entry = document.createElement("article");
+ entry.className = "guestbook-entry";
+
+ const nameText = e.querySelector("strong")?.textContent || "Anonymous";
+ const timeEl = e.querySelector("time");
+ const messageText = e.querySelector("p")?.textContent || "";
+
+ const strong = document.createElement("strong");
+ strong.textContent = nameText;
+ entry.appendChild(strong);
+
+ if (timeEl) {
+ const t = document.createElement("time");
+ const dt = timeEl.getAttribute("datetime");
+ if (dt) t.setAttribute("datetime", dt);
+ t.textContent = timeEl.textContent || "";
+ entry.appendChild(t);
+ }
+
+ const p = document.createElement("p");
+ p.textContent = messageText;
+ entry.appendChild(p);
+
+ container.appendChild(entry);
+ });
+ })
+ .catch(() => {
+ document.getElementById("comments").innerHTML =
+ '<p class="guestbook-error">Unable to load comments. Please try refreshing.</p>';
+ });
diff --git a/assets/js/pages/guestbook_jp.js b/assets/js/pages/guestbook_jp.js
new file mode 100644
index 0000000..5f3a055
--- /dev/null
+++ b/assets/js/pages/guestbook_jp.js
@@ -0,0 +1,91 @@
+document.addEventListener("DOMContentLoaded", function () {
+ const form = document.getElementById("guestbook-form");
+ const entriesContainer = document.getElementById("guestbook-entries");
+ const captchaCanvas = document.getElementById("captcha-canvas");
+ const refreshCaptchaButton = document.getElementById("refresh-captcha");
+ const captchaInput = document.getElementById("captcha-input");
+ const GUESTBOOK_KEY = "guestbookEntries";
+
+ let currentCaptcha = "";
+
+ function generateCaptcha() {
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ let captcha = "";
+ for (let i = 0; i < 6; i++) captcha += chars.charAt(Math.floor(Math.random() * chars.length));
+ return captcha;
+ }
+
+ function drawCaptcha(captcha) {
+ const ctx = captchaCanvas.getContext("2d");
+ ctx.clearRect(0, 0, captchaCanvas.width, captchaCanvas.height);
+ ctx.font = "22px Arial";
+ ctx.fillStyle = "#000";
+ ctx.fillText(captcha, 10, 32);
+ }
+
+ function refreshCaptcha() {
+ currentCaptcha = generateCaptcha();
+ drawCaptcha(currentCaptcha);
+ }
+
+ function formatMessage(message) {
+ return message
+ .split("\n")
+ .map((line) => {
+ if (line.startsWith(">")) return `<span class="anonymous">${line}</span>`;
+ return line;
+ })
+ .join("<br>");
+ }
+
+ function loadEntries() {
+ const entries = JSON.parse(localStorage.getItem(GUESTBOOK_KEY)) || [];
+ entriesContainer.innerHTML = "";
+
+ entries
+ .slice()
+ .reverse()
+ .forEach((entry) => {
+ const entryElement = document.createElement("div");
+ entryElement.className = "entry";
+
+ const nameElement = document.createElement("h4");
+ if (entry.name === "Anonymous") nameElement.className = "anonymous";
+ nameElement.textContent = entry.name;
+ entryElement.appendChild(nameElement);
+
+ const messageElement = document.createElement("p");
+ messageElement.innerHTML = formatMessage(entry.message);
+ entryElement.appendChild(messageElement);
+
+ entriesContainer.appendChild(entryElement);
+ });
+ }
+
+ form.addEventListener("submit", function (event) {
+ event.preventDefault();
+
+ let name = document.getElementById("name").value.trim();
+ if (!name) name = "Anonymous";
+
+ const message = document.getElementById("message").value.trim();
+ const captchaValue = captchaInput.value.trim();
+
+ if (name && message && captchaValue === currentCaptcha) {
+ const entries = JSON.parse(localStorage.getItem(GUESTBOOK_KEY)) || [];
+ entries.push({ name, message });
+ localStorage.setItem(GUESTBOOK_KEY, JSON.stringify(entries));
+
+ form.reset();
+ document.getElementById("name").value = "Anonymous";
+ loadEntries();
+ refreshCaptcha();
+ } else {
+ alert("CAPTCHA is incorrect. Please try again.");
+ }
+ });
+
+ refreshCaptcha();
+ refreshCaptchaButton.addEventListener("click", refreshCaptcha);
+ loadEntries();
+ });
diff --git a/assets/js/pages/guestbook_zh.js b/assets/js/pages/guestbook_zh.js
new file mode 100644
index 0000000..5f3a055
--- /dev/null
+++ b/assets/js/pages/guestbook_zh.js
@@ -0,0 +1,91 @@
+document.addEventListener("DOMContentLoaded", function () {
+ const form = document.getElementById("guestbook-form");
+ const entriesContainer = document.getElementById("guestbook-entries");
+ const captchaCanvas = document.getElementById("captcha-canvas");
+ const refreshCaptchaButton = document.getElementById("refresh-captcha");
+ const captchaInput = document.getElementById("captcha-input");
+ const GUESTBOOK_KEY = "guestbookEntries";
+
+ let currentCaptcha = "";
+
+ function generateCaptcha() {
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ let captcha = "";
+ for (let i = 0; i < 6; i++) captcha += chars.charAt(Math.floor(Math.random() * chars.length));
+ return captcha;
+ }
+
+ function drawCaptcha(captcha) {
+ const ctx = captchaCanvas.getContext("2d");
+ ctx.clearRect(0, 0, captchaCanvas.width, captchaCanvas.height);
+ ctx.font = "22px Arial";
+ ctx.fillStyle = "#000";
+ ctx.fillText(captcha, 10, 32);
+ }
+
+ function refreshCaptcha() {
+ currentCaptcha = generateCaptcha();
+ drawCaptcha(currentCaptcha);
+ }
+
+ function formatMessage(message) {
+ return message
+ .split("\n")
+ .map((line) => {
+ if (line.startsWith(">")) return `<span class="anonymous">${line}</span>`;
+ return line;
+ })
+ .join("<br>");
+ }
+
+ function loadEntries() {
+ const entries = JSON.parse(localStorage.getItem(GUESTBOOK_KEY)) || [];
+ entriesContainer.innerHTML = "";
+
+ entries
+ .slice()
+ .reverse()
+ .forEach((entry) => {
+ const entryElement = document.createElement("div");
+ entryElement.className = "entry";
+
+ const nameElement = document.createElement("h4");
+ if (entry.name === "Anonymous") nameElement.className = "anonymous";
+ nameElement.textContent = entry.name;
+ entryElement.appendChild(nameElement);
+
+ const messageElement = document.createElement("p");
+ messageElement.innerHTML = formatMessage(entry.message);
+ entryElement.appendChild(messageElement);
+
+ entriesContainer.appendChild(entryElement);
+ });
+ }
+
+ form.addEventListener("submit", function (event) {
+ event.preventDefault();
+
+ let name = document.getElementById("name").value.trim();
+ if (!name) name = "Anonymous";
+
+ const message = document.getElementById("message").value.trim();
+ const captchaValue = captchaInput.value.trim();
+
+ if (name && message && captchaValue === currentCaptcha) {
+ const entries = JSON.parse(localStorage.getItem(GUESTBOOK_KEY)) || [];
+ entries.push({ name, message });
+ localStorage.setItem(GUESTBOOK_KEY, JSON.stringify(entries));
+
+ form.reset();
+ document.getElementById("name").value = "Anonymous";
+ loadEntries();
+ refreshCaptcha();
+ } else {
+ alert("CAPTCHA is incorrect. Please try again.");
+ }
+ });
+
+ refreshCaptcha();
+ refreshCaptchaButton.addEventListener("click", refreshCaptcha);
+ loadEntries();
+ });
diff --git a/assets/js/pages/hitcounter.js b/assets/js/pages/hitcounter.js
new file mode 100644
index 0000000..7cbced6
--- /dev/null
+++ b/assets/js/pages/hitcounter.js
@@ -0,0 +1,26 @@
+function countUniqueVisitors() {
+ // Per-page local counter (host + path), matching the site footer logic.
+ function pageKey() {
+ var path = (location.pathname || '/').replace(/\/+$/, '') || '/';
+ path = path.replace(/\/index(?:_[a-z]{2})?\.php$/i, '') || '/';
+ path = path.replace(/_(jp|zh)\.php$/i, '.php');
+ return location.hostname + path;
+ }
+ var page = pageKey();
+ var VISITOR_KEY = 'uniqueVisitorCount_v2:' + page;
+ var SESSION_KEY = 'hasVisited_v2:' + page;
+ var visitorCount = localStorage.getItem(VISITOR_KEY);
+ if (!visitorCount) visitorCount = 0;
+ else visitorCount = parseInt(visitorCount, 10) || 0;
+
+ if (!sessionStorage.getItem(SESSION_KEY)) {
+ visitorCount++;
+ localStorage.setItem(VISITOR_KEY, String(visitorCount));
+ sessionStorage.setItem(SESSION_KEY, 'true');
+ }
+
+ var el = document.getElementById('visitor-counter');
+ if (el) el.innerText = String(visitorCount);
+ }
+
+ countUniqueVisitors();
diff --git a/assets/js/pages/latest.js b/assets/js/pages/latest.js
new file mode 100644
index 0000000..85d1595
--- /dev/null
+++ b/assets/js/pages/latest.js
@@ -0,0 +1,10 @@
+(function () {
+ function sendHeight() {
+ const height = document.body.scrollHeight;
+ window.parent.postMessage({ changelogHeight: height }, "*");
+ }
+ window.addEventListener("load", sendHeight);
+ if (typeof ResizeObserver !== "undefined") {
+ new ResizeObserver(sendHeight).observe(document.body);
+ }
+})();
diff --git a/assets/js/pages/mstartpage-index.js b/assets/js/pages/mstartpage-index.js
new file mode 100644
index 0000000..db518ab
--- /dev/null
+++ b/assets/js/pages/mstartpage-index.js
@@ -0,0 +1,118 @@
+// Date and time functionality
+ function updateDateRealtime() {
+ const dateElement = document.getElementById("realtime-date");
+ const clockElement = document.getElementById("realtime-clock");
+
+ const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+ const months = ["January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December"];
+
+ const now = new Date();
+
+ // Date parts
+ const dayName = days[now.getDay()];
+ const month = months[now.getMonth()];
+ const dayNumber = now.getDate();
+ const year = now.getFullYear();
+
+ const ordinal = (dayNumber > 3 && dayNumber < 21) ? 'th' :
+ (dayNumber % 10 === 1) ? 'st' :
+ (dayNumber % 10 === 2) ? 'nd' :
+ (dayNumber % 10 === 3) ? 'rd' : 'th';
+
+ dateElement.textContent = `${dayName}, ${month} ${dayNumber}${ordinal}, ${year}.`;
+
+ // Clock parts (12-hour format)
+ let hours = now.getHours();
+ const minutes = now.getMinutes();
+ const seconds = now.getSeconds();
+
+ const period = hours >= 12 ? 'PM' : 'AM';
+ hours = hours % 12 || 12;
+
+ const formattedHours = hours.toString().padStart(2, "0");
+ const formattedMinutes = minutes.toString().padStart(2, "0");
+ const formattedSeconds = seconds.toString().padStart(2, "0");
+
+ clockElement.textContent = `${formattedHours}:${formattedMinutes}:${formattedSeconds} ${period}`;
+ }
+
+ updateDateRealtime();
+ setInterval(updateDateRealtime, 1000);
+
+ // Menu toggle functionality
+ document.getElementById('menu-toggle').addEventListener('click', function() {
+ const menu = document.getElementById('main-menu');
+ menu.classList.toggle('show');
+ });
+
+ // Collapsible sections
+ const collapsibles = document.getElementsByClassName("collapsible");
+
+ for (let i = 0; i < collapsibles.length; i++) {
+ collapsibles[i].addEventListener("click", function() {
+ this.classList.toggle("active");
+ const content = this.nextElementSibling;
+ if (content.style.display === "block") {
+ content.style.display = "none";
+ } else {
+ content.style.display = "block";
+ }
+ });
+ }
+
+ // XXX Verification functionality
+ const xxxToggle = document.getElementById('xxx-toggle');
+ const xxxModal = document.getElementById('xxx-modal');
+ const xxxConfirm = document.getElementById('xxx-confirm');
+ const xxxDeny = document.getElementById('xxx-deny');
+ const xxxList = document.getElementById('xxx-list');
+
+ // Check if already verified
+ let isVerified = localStorage.getItem('xxx-verified') === 'true';
+ let isContentVisible = localStorage.getItem('xxx-visible') === 'true';
+
+ // Initialize based on stored state
+ if (isVerified && isContentVisible) {
+ xxxToggle.textContent = 'Hide XXX Content';
+ xxxList.style.display = 'block';
+ } else if (isVerified && !isContentVisible) {
+ xxxToggle.textContent = 'Show XXX Content';
+ xxxList.style.display = 'none';
+ }
+
+ xxxToggle.addEventListener('click', function() {
+ if (isVerified) {
+ // Toggle visibility if already verified
+ if (xxxList.style.display === 'block') {
+ // Hide the content
+ xxxList.style.display = 'none';
+ xxxToggle.textContent = 'Show XXX Content';
+ localStorage.setItem('xxx-visible', 'false');
+ } else {
+ // Show the content
+ xxxList.style.display = 'block';
+ xxxToggle.textContent = 'Hide XXX Content';
+ localStorage.setItem('xxx-visible', 'true');
+ }
+ } else {
+ // Show verification modal if not verified
+ xxxModal.style.display = 'flex';
+ }
+ });
+
+ xxxConfirm.addEventListener('click', function() {
+ // Set verification status
+ isVerified = true;
+ localStorage.setItem('xxx-verified', 'true');
+ localStorage.setItem('xxx-visible', 'true');
+
+ // Hide modal and show content
+ xxxModal.style.display = 'none';
+ xxxList.style.display = 'block';
+ xxxToggle.textContent = 'Hide XXX Content';
+ });
+
+ xxxDeny.addEventListener('click', function() {
+ xxxModal.style.display = 'none';
+ });
diff --git a/assets/js/pages/radio-nowplaying.js b/assets/js/pages/radio-nowplaying.js
new file mode 100644
index 0000000..e263e5b
--- /dev/null
+++ b/assets/js/pages/radio-nowplaying.js
@@ -0,0 +1,41 @@
+(function () {
+ var line = document.getElementById('radio-nowplaying');
+ var el = document.getElementById('radio-nowplaying-title');
+ if (!line || !el) return;
+ var pollMs = 30000;
+ var backoffMs = pollMs;
+
+ // childNodes[0] is the "Now playing: " text node before the <span>.
+ function setLabel(text) {
+ if (line.childNodes[0] && line.childNodes[0].nodeType === 3) {
+ line.childNodes[0].textContent = text;
+ }
+ }
+ function setOffline() {
+ setLabel('Radio: ');
+ el.textContent = 'currently offline';
+ }
+ function setPlaying(title) {
+ setLabel('Now playing: ');
+ el.textContent = title;
+ }
+
+ function tick() {
+ fetch('/api/nowplaying.php', { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
+ .then(function (j) {
+ var title = j && j.title ? String(j.title).trim() : '';
+ if (title) { setPlaying(title); } else { setOffline(); }
+ backoffMs = pollMs;
+ })
+ .catch(function () {
+ setOffline();
+ backoffMs = Math.min(backoffMs * 2, 5 * 60 * 1000);
+ })
+ .finally(function () { setTimeout(tick, backoffMs); });
+ }
+ if (document.visibilityState !== 'hidden') tick();
+ document.addEventListener('visibilitychange', function () {
+ if (document.visibilityState === 'visible') tick();
+ });
+})();
diff --git a/assets/js/pages/vibe-admin.js b/assets/js/pages/vibe-admin.js
new file mode 100644
index 0000000..576111c
--- /dev/null
+++ b/assets/js/pages/vibe-admin.js
@@ -0,0 +1,48 @@
+function detectType(url) {
+ if (!url) return 'none';
+ try { new URL(url); } catch(e) { return 'none'; }
+ const path = (new URL(url)).pathname.toLowerCase();
+ const ext = path.split('.').pop();
+ if (['jpg','jpeg','png','gif','webp','bmp','svg','avif'].includes(ext)) return 'image';
+ if (['mp4','webm','ogv','mov'].includes(ext)) return 'video';
+ if (['mp3','flac','wav','ogg','aac','opus','m4a'].includes(ext)) return 'audio';
+ if (/(?:youtube\.com\/watch\?.*v=|youtu\.be\/)([A-Za-z0-9_-]{11})/.test(url)) return 'youtube';
+ if (/vimeo\.com\/(\d+)/.test(url)) return 'vimeo';
+ return 'link';
+}
+
+function ytId(url) { const m = url.match(/(?:v=|youtu\.be\/)([A-Za-z0-9_-]{11})/); return m ? m[1] : null; }
+function vimeoId(url) { const m = url.match(/vimeo\.com\/(\d+)/); return m ? m[1] : null; }
+
+function updatePreview(url) {
+ const el = document.getElementById('preview-body');
+ const badge = document.getElementById('type-badge');
+ if (!url) { el.innerHTML = '<p class="preview-empty">// no url set</p>'; badge.textContent = 'none'; badge.className = 'badge t-none'; return; }
+ const type = detectType(url);
+ badge.textContent = type;
+ badge.className = 'badge t-' + type.replace(/:.*/, '');
+ const s = url.replace(/"/g, '%22');
+ let h = '<div class="preview-url"><a href="' + s + '" target="_blank" rel="noopener noreferrer">' + url + '</a></div>';
+ if (type === 'image') h += '<img src="' + s + '" alt="preview" loading="lazy">';
+ else if (type === 'video') h += '<video controls preload="none"><source src="' + s + '"></video>';
+ else if (type === 'audio') h += '<audio controls preload="none" class="vibe-audio-full"><source src="' + s + '"></audio>';
+ else if (type === 'youtube') { const id = ytId(url); if (id) h += '<div class="ratio-wrap"><iframe src="https://www.youtube.com/embed/' + id + '" allowfullscreen loading="lazy"></iframe></div>'; }
+ else if (type === 'vimeo') { const id = vimeoId(url); if (id) h += '<div class="ratio-wrap"><iframe src="https://player.vimeo.com/video/' + id + '" allowfullscreen loading="lazy"></iframe></div>'; }
+ else h += '<a href="' + s + '" target="_blank" rel="noopener noreferrer" class="vibe-preview-link">' + url + '</a>';
+ el.innerHTML = h;
+}
+
+const urlInput = document.getElementById('url');
+let debounce;
+urlInput.addEventListener('input', () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => updatePreview(urlInput.value.trim()), 350);
+});
+
+document.querySelectorAll('.preset').forEach(btn => {
+ btn.addEventListener('click', () => {
+ urlInput.value = btn.dataset.url;
+ if (btn.dataset.desc) document.getElementById('description').value = btn.dataset.desc;
+ updatePreview(btn.dataset.url);
+ });
+});
diff --git a/assets/js/pages/whatsleft.js b/assets/js/pages/whatsleft.js
new file mode 100644
index 0000000..bf979ee
--- /dev/null
+++ b/assets/js/pages/whatsleft.js
@@ -0,0 +1,16 @@
+(function () {
+ var target = new Date(new Date().getFullYear() + 1, 0, 1);
+ function tick() {
+ var diff = Math.max(0, target - Date.now());
+ var d = Math.floor(diff / 86400000);
+ var h = Math.floor((diff % 86400000) / 3600000);
+ var m = Math.floor((diff % 3600000) / 60000);
+ var s = Math.floor((diff % 60000) / 1000);
+ document.getElementById('wl-days').textContent = d;
+ document.getElementById('wl-hours').textContent = h;
+ document.getElementById('wl-mins').textContent = m;
+ document.getElementById('wl-secs').textContent = s;
+ }
+ tick();
+ setInterval(tick, 1000);
+})();
diff --git a/assets/js/scroll-to-top.js b/assets/js/scroll-to-top.js
new file mode 100644
index 0000000..b2d6e67
--- /dev/null
+++ b/assets/js/scroll-to-top.js
@@ -0,0 +1,180 @@
+/* ============================================================================
+ SillyLaird scroll-to-top (network-wide).
+
+ Injected by nginx before </body> on HTML responses across *.sillylaird.ca
+ (see /etc/nginx/snippets/sillylaird-visit-counter.conf). Also loaded as a
+ fallback from site.js / nav.js. Runs once (window.__slScrollTopInit).
+
+ Shows a fixed "▲ TOP" control after the page has been scrolled, and only
+ when the document is actually taller than the viewport. Works without the
+ host page loading site.css (styles come from scroll-to-top.css).
+ ============================================================================ */
+(function () {
+ "use strict";
+
+ if (window.__slScrollTopInit) return;
+ window.__slScrollTopInit = true;
+
+ // Embedded forms / comments / changelog iframe: the parent page owns scroll.
+ try {
+ if (window.self !== window.top) return;
+ } catch (_) {
+ return;
+ }
+
+ var path = location.pathname || "/";
+ if (/\/(admin|api|login|logout|auth|captcha|relay|meta|submit)(\/|$|\.)/i.test(path)) {
+ return;
+ }
+ if (/\.(xml|json|m3u|pls|css|js|map|ico|png|jpe?g|gif|webp|svg|woff2?|mp3|flac|webm|mp4)(\?|$)/i.test(path)) {
+ return;
+ }
+
+ var CSS_HREF = "https://www.sillylaird.ca/assets/css/scroll-to-top.css?v=20260821b";
+ var THRESHOLD = 280;
+ var MIN_OVERFLOW = 240;
+
+ function labelForLang() {
+ var lang = (document.documentElement.lang || "").toLowerCase();
+ if (lang.indexOf("ja") === 0) return "先頭へ戻る";
+ if (lang.indexOf("zh") === 0) return "回到顶部";
+ return "Scroll to top";
+ }
+
+ function prefersReducedMotion() {
+ return window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ }
+
+ function scrollY() {
+ return window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;
+ }
+
+ function overflowPx() {
+ var el = document.scrollingElement || document.documentElement;
+ return (el ? el.scrollHeight : 0) - (window.innerHeight || 0);
+ }
+
+ function ensureCss() {
+ var links = document.getElementsByTagName("link");
+ var i;
+ for (i = 0; i < links.length; i++) {
+ if (links[i].rel === "stylesheet" && (links[i].href || "").indexOf("scroll-to-top.css") !== -1) {
+ return;
+ }
+ }
+ var l = document.createElement("link");
+ l.rel = "stylesheet";
+ l.href = CSS_HREF;
+ document.head.appendChild(l);
+ }
+
+ function ensureTopTarget() {
+ if (document.getElementById("top")) return;
+ document.documentElement.id = "top";
+ }
+
+ function dodgeVisitCounter() {
+ var c = document.getElementById("site-visit-counter");
+ if (!c) return;
+ var pos = (c.style && c.style.position) || "";
+ var computed = window.getComputedStyle ? window.getComputedStyle(c).position : "";
+ if (pos === "fixed" || computed === "fixed") {
+ c.setAttribute("data-stt-docked", "1");
+ // Inline bottom from visit-counter.js wins over the stylesheet, so
+ // move it here or the two chips sit on top of each other.
+ c.style.bottom = "4.65rem";
+ }
+ }
+
+ function ensureButton() {
+ var existing = document.getElementById("scroll-to-top");
+ if (existing) return existing;
+
+ var a = document.createElement("a");
+ a.id = "scroll-to-top";
+ a.className = "scroll-to-top";
+ a.href = "#top";
+ var label = labelForLang();
+ a.setAttribute("aria-label", label);
+ a.title = label;
+
+ var caret = document.createElement("span");
+ caret.className = "scroll-to-top-caret";
+ caret.setAttribute("aria-hidden", "true");
+ caret.appendChild(document.createTextNode("\u25B2"));
+
+ var text = document.createElement("span");
+ text.className = "scroll-to-top-label";
+ text.setAttribute("aria-hidden", "true");
+ text.appendChild(document.createTextNode("TOP"));
+
+ a.appendChild(caret);
+ a.appendChild(text);
+ document.body.appendChild(a);
+ return a;
+ }
+
+ function goTop(e) {
+ if (e) e.preventDefault();
+ if (prefersReducedMotion() || typeof window.scrollTo !== "function") {
+ if (typeof window.scrollTo === "function") {
+ window.scrollTo(0, 0);
+ } else {
+ document.documentElement.scrollTop = 0;
+ document.body.scrollTop = 0;
+ }
+ return;
+ }
+ try {
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ } catch (_) {
+ window.scrollTo(0, 0);
+ }
+ }
+
+ function init() {
+ ensureCss();
+ ensureTopTarget();
+ var btn = ensureButton();
+ if (!btn) return;
+
+ document.body.className = (document.body.className ? document.body.className + " " : "") + "has-scroll-to-top";
+ dodgeVisitCounter();
+ window.setTimeout(dodgeVisitCounter, 50);
+ window.setTimeout(dodgeVisitCounter, 400);
+
+ var ticking = false;
+ function update() {
+ ticking = false;
+ var show = overflowPx() > MIN_OVERFLOW && scrollY() > THRESHOLD;
+ if (show) {
+ if (btn.className.indexOf("is-visible") === -1) {
+ btn.className += " is-visible";
+ }
+ } else {
+ btn.className = btn.className.replace(/\s*is-visible\b/g, "");
+ }
+ }
+
+ function onScroll() {
+ if (ticking) return;
+ ticking = true;
+ if (window.requestAnimationFrame) {
+ window.requestAnimationFrame(update);
+ } else {
+ window.setTimeout(update, 16);
+ }
+ }
+
+ window.addEventListener("scroll", onScroll, false);
+ window.addEventListener("resize", onScroll, false);
+ btn.addEventListener("click", goTop);
+ update();
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init);
+ } else {
+ init();
+ }
+})();
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);
+ });
+
+})();
diff --git a/assets/js/visit-counter.js b/assets/js/visit-counter.js
new file mode 100644
index 0000000..1715fdb
--- /dev/null
+++ b/assets/js/visit-counter.js
@@ -0,0 +1,141 @@
+/* ============================================================================
+ SillyLaird per-page visit counter (network-wide).
+
+ Injected by nginx before </body> on HTML responses across *.sillylaird.ca
+ (see /etc/nginx/snippets/sillylaird-visit-counter.conf). Also safe to load
+ from site.js / nav.js. Runs once per page load (window.__slVisitCounterInit).
+
+ Storage is localStorage + sessionStorage, keyed by host + path so each page
+ (and each subdomain) keeps its own count. Language variants share a key.
+
+ Also beacons a privacy-friendly hit to /api/hit.php (no IP stored server-side;
+ only host/path, coarse browser·OS label, language, referrer host, local count).
+ ============================================================================ */
+(function () {
+ "use strict";
+
+ if (window.__slVisitCounterInit) return;
+ window.__slVisitCounterInit = true;
+
+ var path = location.pathname || "/";
+ // Skip admin / API / auth / binary endpoints — no counter chrome there.
+ if (/\/(admin|api|login|logout|auth|captcha|relay|meta|submit)(\/|$|\.)/i.test(path)) {
+ return;
+ }
+ if (/\.(xml|json|m3u|pls|css|js|map|ico|png|jpe?g|gif|webp|svg|woff2?|mp3|flac|webm|mp4)(\?|$)/i.test(path)) {
+ return;
+ }
+
+ var HIT_URL = "https://www.sillylaird.ca/api/hit.php";
+
+ function pageKey() {
+ var p = (location.pathname || "/").replace(/\/+$/, "") || "/";
+ // /foo/index.php and /foo/index_jp.php → /foo
+ p = p.replace(/\/index(?:_[a-z]{2})?\.php$/i, "") || "/";
+ // /links_jp.php / links_zh.php → /links.php
+ p = p.replace(/_(jp|zh)\.php$/i, ".php");
+ return location.hostname + p;
+ }
+
+ function ensureStyles() {
+ if (document.getElementById("sl-visit-counter-style")) return;
+ var s = document.createElement("style");
+ s.id = "sl-visit-counter-style";
+ s.textContent =
+ ".site-visit-counter{margin:.5rem 0 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;" +
+ "font-size:0.8rem;letter-spacing:0.08em;color:rgba(160,160,170,.9);}" +
+ ".site-visit-counter-digits{color:#39ff14;text-shadow:0 0 6px rgba(57,255,20,.35);}" +
+ "body.light-mode .site-visit-counter{color:rgba(40,40,50,.75);}" +
+ "body.light-mode .site-visit-counter-digits{color:#1a7a12;text-shadow:none;}";
+ (document.head || document.documentElement).appendChild(s);
+ }
+
+ function ensureMarkup() {
+ var digits = document.getElementById("site-visit-digits");
+ if (digits) return digits;
+
+ ensureStyles();
+
+ var wrap = document.createElement("p");
+ wrap.className = "site-visit-counter";
+ wrap.id = "site-visit-counter";
+ wrap.setAttribute("aria-live", "polite");
+ wrap.innerHTML =
+ '<span class="site-visit-counter-label">You are visitor</span> ' +
+ '<span class="site-visit-counter-digits" id="site-visit-digits">------</span>';
+
+ var footerInner = document.querySelector(".site-footer .footer-inner, footer .footer-inner");
+ var footer = document.querySelector("footer.site-footer, footer");
+ if (footerInner) {
+ footerInner.appendChild(wrap);
+ } else if (footer) {
+ footer.appendChild(wrap);
+ } else {
+ wrap.style.cssText =
+ "position:fixed;right:10px;bottom:8px;z-index:40;margin:0;padding:4px 8px;" +
+ "background:rgba(0,0,0,.55);border-radius:4px;backdrop-filter:blur(4px);";
+ document.body.appendChild(wrap);
+ }
+ return document.getElementById("site-visit-digits");
+ }
+
+ function reportHit(localCount, isNew) {
+ var payload = {
+ host: location.hostname,
+ path: location.pathname || "/",
+ local_count: localCount,
+ unique: !!isNew,
+ lang: (navigator.language || "").slice(0, 16),
+ ref: document.referrer || "",
+ };
+ var body = JSON.stringify(payload);
+ try {
+ if (navigator.sendBeacon) {
+ var blob = new Blob([body], { type: "application/json" });
+ if (navigator.sendBeacon(HIT_URL, blob)) return;
+ }
+ } catch (_) {}
+ try {
+ fetch(HIT_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: body,
+ mode: "cors",
+ credentials: "omit",
+ keepalive: true,
+ }).catch(function () {});
+ } catch (_) {}
+ }
+
+ function count() {
+ var el = ensureMarkup();
+ if (!el) return;
+
+ var page = pageKey();
+ var KEY = "sillylaird_visit_count_v2:" + page;
+ var SESS = "sillylaird_visit_sess_v2:" + page;
+ var n = 0;
+ var isNew = false;
+ try {
+ n = parseInt(localStorage.getItem(KEY) || "0", 10) || 0;
+ } catch (_) {}
+ try {
+ if (!sessionStorage.getItem(SESS)) {
+ n += 1;
+ isNew = true;
+ localStorage.setItem(KEY, String(n));
+ sessionStorage.setItem(SESS, "1");
+ }
+ } catch (_) {}
+ el.textContent = String(n).padStart(6, "0");
+
+ // Log every page view server-side (unique flag only when new session for page).
+ reportHit(n, isNew);
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", count);
+ } else {
+ count();
+ }
+})();