diff options
| author | sillylaird <sillyfanboy@gmail.com> | 2026-09-03 00:33:59 +0000 |
|---|---|---|
| committer | sillylaird <sillyfanboy@gmail.com> | 2026-09-03 00:33:59 +0000 |
| commit | 898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch) | |
| tree | 85c6ee5ad58b860144551184d4cf86b560c62b91 /assets/js | |
| download | www-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.tar.gz www-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.zip | |
Diffstat (limited to '')
| -rw-r--r-- | assets/js/includes.js | 33 | ||||
| -rw-r--r-- | assets/js/nav.js | 177 | ||||
| -rw-r--r-- | assets/js/pages/404.js | 5 | ||||
| -rw-r--r-- | assets/js/pages/50x.js | 5 | ||||
| -rw-r--r-- | assets/js/pages/admin-vibe.js | 71 | ||||
| -rw-r--r-- | assets/js/pages/admin.js | 714 | ||||
| -rw-r--r-- | assets/js/pages/guestbook-comments.js | 39 | ||||
| -rw-r--r-- | assets/js/pages/guestbook_jp.js | 91 | ||||
| -rw-r--r-- | assets/js/pages/guestbook_zh.js | 91 | ||||
| -rw-r--r-- | assets/js/pages/hitcounter.js | 26 | ||||
| -rw-r--r-- | assets/js/pages/latest.js | 10 | ||||
| -rw-r--r-- | assets/js/pages/mstartpage-index.js | 118 | ||||
| -rw-r--r-- | assets/js/pages/radio-nowplaying.js | 41 | ||||
| -rw-r--r-- | assets/js/pages/vibe-admin.js | 48 | ||||
| -rw-r--r-- | assets/js/pages/whatsleft.js | 16 | ||||
| -rw-r--r-- | assets/js/scroll-to-top.js | 180 | ||||
| -rw-r--r-- | assets/js/site.js | 1341 | ||||
| -rw-r--r-- | assets/js/visit-counter.js | 141 |
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, '&').replace(/</g, '<').replace(/>/g, '>'); + }; + 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(/^> (.*)$/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, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); + } + + 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(); + } +})(); |
