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 /startpage/startpage.js | |
| download | www-main.tar.gz www-main.zip | |
Diffstat (limited to 'startpage/startpage.js')
| -rw-r--r-- | startpage/startpage.js | 824 |
1 files changed, 824 insertions, 0 deletions
diff --git a/startpage/startpage.js b/startpage/startpage.js new file mode 100644 index 0000000..f9040c9 --- /dev/null +++ b/startpage/startpage.js @@ -0,0 +1,824 @@ +(function () { + function byId(id) { + return document.getElementById(id); + } + + function updateClock() { + const dateEl = byId('realtime-date'); + const timeEl = byId('realtime-clock'); + if (!dateEl || !timeEl) return; + + const now = new Date(); + const options = { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }; + dateEl.textContent = now.toLocaleDateString(undefined, options).toUpperCase(); + timeEl.textContent = now.toLocaleTimeString(); + } + + setInterval(updateClock, 1000); + updateClock(); + + // Last.fm now-playing widget + const LASTFM_CACHE_KEY = 'lastfmLastGood'; + const LASTFM_BASE_INTERVAL = 10000; + const LASTFM_MAX_INTERVAL = 60000; + let lastfmFailures = 0; + let lastfmTimer = null; + + function renderLastFmTrack(track) { + const statusEl = byId('lastfm-status'); + const widgetEl = byId('lastfm-widget'); + if (!statusEl || !track || !track.name || !track.artist) return; + const prefix = track.nowplaying ? 'Now playing: ' : 'Last played: '; + statusEl.innerHTML = ''; + const prefixEl = document.createElement('span'); + prefixEl.className = 'lastfm-prefix'; + prefixEl.textContent = prefix; + const nameEl = document.createElement('span'); + nameEl.className = 'lastfm-track-name'; + nameEl.textContent = track.name; + const sepEl = document.createElement('span'); + sepEl.className = 'lastfm-sep'; + sepEl.textContent = ' — '; + const artistEl = document.createElement('span'); + artistEl.className = 'lastfm-track-artist'; + artistEl.textContent = track.artist; + statusEl.appendChild(prefixEl); + statusEl.appendChild(nameEl); + statusEl.appendChild(sepEl); + statusEl.appendChild(artistEl); + if (widgetEl) widgetEl.style.display = ''; + } + + + (function primeFromCache() { + try { + const raw = localStorage.getItem(LASTFM_CACHE_KEY); + if (raw) renderLastFmTrack(JSON.parse(raw)); + } catch (_) {} + })(); + + function scheduleLastFm() { + const backoff = Math.min( + LASTFM_MAX_INTERVAL, + LASTFM_BASE_INTERVAL * Math.pow(2, lastfmFailures) + ); + clearTimeout(lastfmTimer); + lastfmTimer = setTimeout(updateLastFm, backoff); + } + + function updateLastFm() { + const statusEl = byId('lastfm-status'); + if (!statusEl) return; + + fetch('/api/lastfm.php') + .then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }) + .then(function (data) { + if (data && data.error) throw new Error(data.message || 'api error'); + const tracks = Array.isArray(data) ? data : []; + // Prefer a currently-playing track; otherwise fall back to the most + // recent scrobble so the widget always has something to show. + const track = tracks.find(function (t) { return t && t.nowplaying; }) + || tracks.find(function (t) { return t && t.name; }) + || null; + if (track) { + renderLastFmTrack(track); + try { localStorage.setItem(LASTFM_CACHE_KEY, JSON.stringify(track)); } catch (_) {} + } + lastfmFailures = 0; + }) + .catch(function () { + // Never show an error message. Keep whatever is currently rendered + // (primed from localStorage on load), or leave the slot blank. + lastfmFailures++; + }) + .finally(scheduleLastFm); + } + + updateLastFm(); + + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') { + lastfmFailures = 0; + updateLastFm(); + } + }); + + // Unified search engine switcher + const SEARCH_ENGINES = [ + { key: 'google', label: 'G', title: 'Google Search..', placeholder: 'Search Google...', action: 'https://www.google.ca/search', param: 'q', site: 'https://www.google.ca' }, + { key: 'brave', label: 'B', title: 'Brave Search..', placeholder: 'Search Brave...', action: 'https://search.brave.com/search', param: 'q', site: 'https://search.brave.com' }, + { key: 'startpage', label: 'SP', title: 'Startpage Search..', placeholder: 'Search Startpage...', action: 'https://www.startpage.com/sp/search', param: 'query', site: 'https://www.startpage.com' }, + { key: 'youtube', label: 'YT', title: 'YouTube Search..', placeholder: 'Search YouTube...', action: 'https://www.youtube.com/results', param: 'search_query', site: 'https://www.youtube.com' }, + { key: 'sillylaird',label: 'SL', title: 'SillyLaird Search..', placeholder: 'Search SillyLaird...', action: 'https://search.sillylaird.ca/search', param: 'q', site: 'https://search.sillylaird.ca' }, + { key: '4get', label: '4G', title: '4get Search..', placeholder: 'Search 4get...', action: 'https://4get.sillylaird.ca/web', param: 's', site: 'https://4get.sillylaird.ca' } + ]; + const SEARCH_ENGINE_KEY = 'startpageSearchEngine_v2'; + + (function initUnifiedSearch() { + const form = byId('unified-search-form'); + const input = byId('unified-search-input'); + const toggle = byId('search-engine-toggle'); + const menu = byId('search-engine-menu'); + const title = byId('unified-search-title'); + const clearBtn = byId('unified-search-clear'); + if (!form || !input || !toggle || !menu) return; + + function syncClear() { + if (!clearBtn) return; + clearBtn.hidden = input.value.length === 0; + } + if (clearBtn) { + input.addEventListener('input', syncClear); + clearBtn.addEventListener('click', function () { + input.value = ''; + syncClear(); + input.focus(); + }); + syncClear(); + } + + let idx = 0; + try { + const saved = localStorage.getItem(SEARCH_ENGINE_KEY); + if (saved) { + const savedIdx = SEARCH_ENGINES.findIndex(function (e) { return e.key === saved; }); + if (savedIdx >= 0) idx = savedIdx; + } else { + idx = SEARCH_ENGINES.findIndex(function (e) { return e.key === 'google'; }); + } + } catch (_) { + idx = SEARCH_ENGINES.findIndex(function (e) { return e.key === 'google'; }); + } + if (idx < 0) idx = 0; + + SEARCH_ENGINES.forEach(function (e, i) { + const li = document.createElement('li'); + li.setAttribute('role', 'option'); + li.dataset.index = String(i); + li.innerHTML = '<span class="engine-icon"></span><span class="engine-name"></span>'; + li.querySelector('.engine-icon').textContent = e.label; + li.querySelector('.engine-name').textContent = e.title.replace(/\.\.$/, ''); + li.addEventListener('click', function () { + idx = i; + apply(idx); + closeMenu(); + input.focus(); + }); + menu.appendChild(li); + }); + + function apply(i) { + const e = SEARCH_ENGINES[i]; + form.setAttribute('action', e.action); + input.setAttribute('name', e.param); + input.setAttribute('placeholder', e.placeholder); + toggle.innerHTML = ''; + toggle.appendChild(document.createTextNode(e.label + ' ')); + const caret = document.createElement('span'); + caret.className = 'search-engine-caret'; + caret.textContent = '▾'; + toggle.appendChild(caret); + toggle.setAttribute('title', 'Search engine: ' + e.title); + if (title) { + title.innerHTML = '<a href="' + e.site + '" target="_blank" rel="noopener noreferrer">' + e.title + '</a>'; + } + Array.prototype.forEach.call(menu.children, function (li, li_i) { + li.setAttribute('aria-selected', li_i === i ? 'true' : 'false'); + }); + try { localStorage.setItem(SEARCH_ENGINE_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(); + }); + + apply(idx); + })(); + +})(); // close the main outer IIFE that wraps clock/lastfm/unified/etc. + + // Live card filter (works on data-attrs emitted by render_cards.php) + (function initCardFilter() { + const byId = (id) => document.getElementById(id); + const filterInput = byId('card-filter'); + const grid = byId('cards-grid'); + const countEl = byId('card-filter-count'); + if (!filterInput || !grid) return; + + const cards = Array.from(grid.querySelectorAll('.card')); + if (!cards.length) return; + + // Ensure clean state (in case of previous inline or classes) + cards.forEach(function (c) { c.classList.remove('hidden'); }); + + // Debug (open console if needed): console.log('[startpage filter] initialized with', cards.length, 'cards'); + + function normalize(s) { + return (s || '').toLowerCase().trim(); + } + + function applyFilter() { + const q = normalize(filterInput.value); + let visible = 0; + cards.forEach(function (card) { + const title = normalize(card.dataset.title); + const labels = normalize(card.dataset.labels); + const hay = title + ' ' + labels; + const match = !q || hay.indexOf(q) !== -1; + card.classList.toggle('hidden', !match); + if (match) visible++; + }); + if (countEl) { + countEl.textContent = q ? (visible + ' / ' + cards.length) : ''; + } + } + + filterInput.addEventListener('input', applyFilter); + + // Keyboard: '/' focuses the card filter (when not in input already) + document.addEventListener('keydown', function (e) { + const ae = document.activeElement; + const isTyping = ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable); + if (e.key === '/' && !isTyping) { + e.preventDefault(); + filterInput.focus(); + filterInput.select(); + } + if (e.key === 'Escape' && ae === filterInput) { + filterInput.value = ''; + applyFilter(); + filterInput.blur(); + } + }); + + // Initial + applyFilter(); + })(); + + // Keyboard help overlay (?) + (function initKeyboardHelp() { + document.addEventListener('keydown', function (e) { + if (e.key === '?' && !e.shiftKey) { + const ae = document.activeElement; + if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) return; + e.preventDefault(); + let help = document.getElementById('kbd-help'); + if (!help) { + help = document.createElement('div'); + help.id = 'kbd-help'; + help.style.cssText = 'position:fixed;bottom:12px;right:12px;background:#111;border:1px solid #8b0000;padding:10px 14px;font-size:0.8rem;z-index:999;color:#8b0000;max-width:280px;line-height:1.5;'; + help.innerHTML = '<strong>Keyboard</strong><br>/ — focus card filter<br>Esc — clear / close<br>? — toggle this<br>Click engine picker or cards<br>Theme toggle in header'; + document.body.appendChild(help); + const remover = function (ev) { + if (ev.key === '?' || ev.key === 'Escape') { + help.remove(); + document.removeEventListener('keydown', remover); + } + }; + document.addEventListener('keydown', remover); + } else { + help.remove(); + } + } + }); + })(); + +/* Click-to-open cascading Sports menu (left click toggles, like a context menu) */ +document.querySelectorAll('.ctx-menu').forEach(function (menu) { + function closeAll() { + menu.querySelectorAll('.ctx-parent.open').forEach(function (p) { + p.classList.remove('open'); + }); + } + + // A "trigger" is any element that owns a flyout: the Sports root or a category label + menu.querySelectorAll('.ctx-trigger, .ctx-has-sub').forEach(function (trigger) { + trigger.addEventListener('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + const parent = trigger.closest('.ctx-parent'); + const wasOpen = parent.classList.contains('open'); + // close siblings at the same level + const siblings = parent.parentElement.querySelectorAll(':scope > .ctx-parent.open'); + siblings.forEach(function (s) { if (s !== parent) s.classList.remove('open'); }); + // toggle this one (also closes its descendants when collapsing) + if (wasOpen) { + parent.classList.remove('open'); + parent.querySelectorAll('.ctx-parent.open').forEach(function (d) { d.classList.remove('open'); }); + } else { + parent.classList.add('open'); + positionFlyout(parent); + } + }); + }); + + // Place a category's (fixed-position) link list beside its label, cascading right, + // flipping left / clamping when it would run off a screen edge. + // Skips the root dropdown (direct child of .ctx-root), which CSS drops straight down. + function positionFlyout(parent) { + const flyout = parent.querySelector(':scope > .ctx-flyout'); + if (!flyout || parent.parentElement.classList.contains('ctx-root')) return; + + const label = parent.getBoundingClientRect(); + const box = parent.parentElement.getBoundingClientRect(); // the whole menu box (incl. scrollbar) + const fw = flyout.offsetWidth; + const fh = flyout.offsetHeight; + const margin = 4; + const gap = 2; + + // Top-right menus cascade LEFT (toward screen centre); everything else cascades right. + // Flip to the other side only when the preferred side runs off the viewport edge. + const openLeft = parent.closest('.top-right-links') !== null; + let left; + if (openLeft) { + left = box.left - fw - gap; // open to the left of the whole box + if (left < margin) { + left = box.right + gap; // no room left: flip to the right + } + } else { + left = box.right + gap; // top-left menus only ever cascade right (match the ▶ arrow) + } + if (left < margin) left = margin; + if (left + fw > window.innerWidth - margin) left = window.innerWidth - fw - margin; + + // align top with the label, clamp into the viewport + let top = label.top; + if (top + fh > window.innerHeight - margin) { + top = Math.max(margin, window.innerHeight - fh - margin); + } + + flyout.style.left = left + 'px'; + flyout.style.top = top + 'px'; + } + + // Clicking a real link closes the whole menu + menu.querySelectorAll('a.ctx-item').forEach(function (a) { + a.addEventListener('click', closeAll); + }); + + // Scrolling the category list closes any open (fixed) submenu so it can't float detached + const rootFlyout = menu.querySelector('.ctx-root > .ctx-parent > .ctx-flyout'); + if (rootFlyout) { + rootFlyout.addEventListener('scroll', function () { + rootFlyout.querySelectorAll(':scope > .ctx-parent.open').forEach(function (p) { + p.classList.remove('open'); + }); + }); + } + + // Click outside closes everything + document.addEventListener('click', function (e) { + if (!menu.contains(e.target)) closeAll(); + }); +}); + +/* Auto-extend the red section box so it always contains the absolutely-positioned link columns */ +(function () { + function fitSectionToLinks() { + const section = document.querySelector('.search-section'); + if (!section) return; + const groups = section.querySelectorAll('.top-left-links, .top-right-links'); + let needed = 0; + groups.forEach(function (g) { + // offsetTop (relative to section) + full height of the link column + needed = Math.max(needed, g.offsetTop + g.offsetHeight); + }); + // small breathing room below the last link + section.style.minHeight = needed > 0 ? (needed + 20) + 'px' : ''; + } + + window.addEventListener('load', fitSectionToLinks); + window.addEventListener('resize', fitSectionToLinks); + if (document.readyState === 'complete') fitSectionToLinks(); +})(); + +/* Subtle terminal boot sequence on first visit per session */ +(function () { + var overlay = document.getElementById('boot-overlay'); + var logEl = document.getElementById('boot-log'); + var body = document.body; + if (!overlay || !logEl) return; + + var skipBoot = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (skipBoot || sessionStorage.getItem('startpageBooted') === '1') { + overlay.hidden = true; + body.classList.remove('startpage-booting'); + body.classList.add('startpage-ready'); + return; + } + + /* OpenBSD /etc/rc prints plain lines, no OpenRC stars or [ ok ] brackets. */ + var steps = [ + 'savecore: no core dump', + 'checking quotas: done.', + 'mounting /startpage/cards.txt', + 'starting network daemons: sshd httpd sndiod.', + 'starting local daemons: cron scrobbler.', + 'bringing up the Wired.' + ]; + var idx = 0; + + function el(tag, cls, text) { + var n = document.createElement(tag); + if (cls) n.className = cls; + if (text) n.textContent = text; + return n; + } + + /* The overlay never scrolls (CSS overflow:hidden), so the log has to fit. + Measure the untransformed layout size (scrollWidth/Height ignore the + transform) against the overlay's content box and scale down to fit. + Runs after every append and on resize/orientation change. */ + /* body{overflow:hidden} only reaches the viewport if the UA propagates it, + and here it does not — the document stayed scrollable behind the overlay. + Lock the root directly; CSS does the same via :has for pre-JS paint. */ + var rootStyle = document.documentElement.style; + var prevRootOverflow = rootStyle.overflow; + rootStyle.overflow = 'hidden'; + + var fitRaf = 0; + function fitBootLog() { + fitRaf = 0; + var cs = window.getComputedStyle(overlay); + var availW = overlay.clientWidth + - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); + var availH = overlay.clientHeight + - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); + if (availW <= 0 || availH <= 0) return; + + logEl.style.transform = 'none'; + var w = logEl.scrollWidth; + var h = logEl.scrollHeight; + if (!w || !h) return; + + var scale = Math.min(1, availW / w, availH / h); + logEl.style.transform = scale < 1 ? 'scale(' + scale + ')' : 'none'; + } + + function scheduleFit() { + if (fitRaf) return; + fitRaf = window.requestAnimationFrame(fitBootLog); + } + + window.addEventListener('resize', scheduleFit); + window.addEventListener('orientationchange', scheduleFit); + + var OPENBSD_WORDMARK = + ' ____ ____ _____ ____' + '\n' + + ' / __ \\____ ___ ____ / __ ) ___// __ \\' + '\n' + + ' / / / / __ \\/ _ \\/ __ \\/ __ \\__ \\/ / / /' + '\n' + + '/ /_/ / /_/ / __/ / / / /_/ /__/ / /_/ /' + '\n' + + '\\____/ .___/\\___/_/ /_/_____/____/_____/' + '\n' + + ' /_/'; + + var PUFFY_ASCII = + ' _' + '\n' + + ' (_)' + '\n' + + ' | .' + '\n' + + ' . |L /| . _' + '\n' + + ' _ . |\\ _| \\--+._/| . (_)' + '\n' + + ' / ||\\| Y J ) / |/| ./' + '\n' + + ' J |)\'( | ` F`.\'/ _' + '\n' + + ' -<| F __ .-< (_)' + '\n' + + ' | / .-\'. `. /-. L___' + '\n' + + ' J \\ < \\ | | O\\|.-\' _' + '\n' + + ' _J \\ .- \\/ O | | \\ |F (_)' + '\n' + + ' \'-F -<_. \\ .-\' `-\' L__' + '\n' + + '__J _ _. >-\' )._. |-\'' + '\n' + + ' `-|.\' /_. \\_| F' + '\n' + + ' /.- . _.<' + '\n' + + ' /\' /.\' .\' `\\' + '\n' + + ' /L /\' |/ _.-\'-\\' + '\n' + + ' /\'J ___.---\'\\|' + '\n' + + ' |\\ .--\' V | `. `' + '\n' + + ' |/`. `-. `._)' + '\n' + + ' / .-.\\' + '\n' + + ' \\ ( `\\' + '\n' + + ' `.\\'; + + var avatar = el('img', 'boot-avatar'); + avatar.src = '/assets/img/lain.png'; + avatar.alt = ''; + avatar.width = 96; + avatar.height = 96; + + /* Lain sits directly above Puffy's bubble column (the `(_)` marks on the + right of the ASCII), so the bubbles read as rising toward her. */ + var puffyWrap = el('div', 'boot-puffy-wrap'); + puffyWrap.appendChild(avatar); + puffyWrap.appendChild(el('pre', 'boot-puffy-ascii', PUFFY_ASCII)); + + var asciiWrap = el('div', 'boot-ascii-wrap'); + asciiWrap.appendChild(puffyWrap); + asciiWrap.appendChild(el('pre', 'boot-openbsd-wordmark', OPENBSD_WORDMARK)); + logEl.appendChild(asciiWrap); + fitBootLog(); + /* lain.png may land after first layout and change the log's height. */ + avatar.addEventListener('load', scheduleFit); + + /* Prefer the version cached by tools/update_openbsd_version.sh (cron pulls + openbsd.org). Fallback: OpenBSD ships ~1 May and ~1 Nov, rolling + x.9 -> (x+1).0; anchor 7.9 = May 2026 so the banner never goes stale. */ + function openbsdRelease() { + var cached = (typeof window.OPENBSD_RELEASE === 'string') + ? window.OPENBSD_RELEASE.trim() : ''; + if (/^\d+\.\d+$/.test(cached)) return cached; + + var now = new Date(); + var m = now.getMonth(); + var halves = (now.getFullYear() - 2026) * 2 + (m >= 10 ? 1 : (m >= 4 ? 0 : -1)); + var minor = 9 + halves; + return (7 + Math.floor(minor / 10)) + '.' + (minor % 10); + } + + logEl.appendChild(el('div', 'boot-title', + 'OpenBSD ' + openbsdRelease() + ' (GENERIC.MP) #1: sillylaird.ca — amd64')); + fitBootLog(); + + function finishBoot() { + if (fitRaf) window.cancelAnimationFrame(fitRaf); + rootStyle.overflow = prevRootOverflow; + window.removeEventListener('resize', scheduleFit); + window.removeEventListener('orientationchange', scheduleFit); + sessionStorage.setItem('startpageBooted', '1'); + overlay.hidden = true; + body.classList.remove('startpage-booting'); + body.classList.add('startpage-ready'); + } + + function fadeOut() { + overlay.classList.add('boot-fade'); + window.setTimeout(finishBoot, 380); + } + + function showPrompt() { + var prompt = el('div', 'boot-login'); + prompt.appendChild(el('span', 'boot-user', 'sillylaird@Navi')); + prompt.appendChild(el('span', null, ':')); + prompt.appendChild(el('span', 'boot-path', '~')); + prompt.appendChild(el('span', null, '$ ')); + prompt.appendChild(el('span', 'boot-cursor', '█')); + logEl.appendChild(prompt); + fitBootLog(); + window.setTimeout(fadeOut, 420); + } + + function typeLogin() { + var login = el('div', 'boot-login'); + var label = el('span', 'boot-dim', 'Navi login: '); + var typed = el('span', null, ''); + var cursor = el('span', 'boot-cursor', '█'); + login.appendChild(label); + login.appendChild(typed); + login.appendChild(cursor); + logEl.appendChild(login); + fitBootLog(); + + var name = 'sillylaird'; + var i = 0; + function typeChar() { + if (i < name.length) { + typed.textContent += name.charAt(i); + fitBootLog(); + i += 1; + window.setTimeout(typeChar, 70); + return; + } + cursor.remove(); + window.setTimeout(function () { + logEl.appendChild(el('div', 'boot-login boot-dim', + 'Password: ')); + logEl.appendChild(el('div', 'boot-dim', + 'Welcome to OpenBSD: The proactively secure Unix-like operating system.')); + fitBootLog(); + window.setTimeout(showPrompt, 240); + }, 180); + } + window.setTimeout(typeChar, 150); + } + + function nextLine() { + if (idx >= steps.length) { + typeLogin(); + return; + } + var line = el('div', 'boot-line'); + var left = el('span', 'boot-svc', steps[idx]); + line.appendChild(left); + logEl.appendChild(line); + fitBootLog(); + idx += 1; + window.setTimeout(nextLine, idx === 1 ? 110 : 130); + } + + window.setTimeout(nextLine, 140); +})(); + +/* Comfortable line spacing — synced with site-wide comfort toggle (header ↕) */ +(function () { + var KEY = 'siteComfortSpacing_v1'; + var btn = document.getElementById('comfort-toggle'); + var body = document.body; + if (!btn) return; + + function apply(on) { + body.classList.toggle('startpage-comfort', on); + body.classList.toggle('comfort-spacing', on); + // Keep the header icon button compact (do not replace ↕ with long text). + 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'); + } + + // Use comfortable spacing unless this browser has explicitly opted out. + apply(localStorage.getItem(KEY) !== '0'); + + btn.addEventListener('click', function () { + var next = !body.classList.contains('startpage-comfort'); + localStorage.setItem(KEY, next ? '1' : '0'); + apply(next); + }); +})(); + +/* Konaste / BEMANI PC maintenance status — computed client-side from the fixed + e-amusement schedule (shared backend, so both games share windows): + - Daily maintenance 05:00–07:00 JST + - Extended maintenance 02:00–07:00 JST on the 3rd Tuesday of the month + JST is a fixed UTC+9 offset (no DST). Display times use the timezone + configured on the viewer's computer/browser. */ +(function () { + var root = document.getElementById('konaste-status'); + if (!root) return; + + var JST_OFFSET_MS = 9 * 3600 * 1000; + var DISPLAY_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone; + + // JST wall-clock fields for a real Date. + function jstFields(date) { + var j = new Date(date.getTime() + JST_OFFSET_MS); + return { + y: j.getUTCFullYear(), + mo: j.getUTCMonth(), + d: j.getUTCDate(), + dow: j.getUTCDay() + }; + } + + // Real epoch ms for a JST wall-clock instant. + function jstToEpoch(y, mo, d, h, m) { + return Date.UTC(y, mo, d, h, m) - JST_OFFSET_MS; + } + + // 3rd Tuesday => Tuesday (dow 2) with day-of-month 15..21. + function isThirdTuesday(f) { + return f.dow === 2 && f.d >= 15 && f.d <= 21; + } + + // Maintenance window [start, end) in real epoch ms for the JST calendar day + // that a given JST-field set falls on. offsetDays shifts that day. + function windowFor(baseFields, offsetDays) { + var day = new Date(Date.UTC(baseFields.y, baseFields.mo, baseFields.d + offsetDays)); + var f = { y: day.getUTCFullYear(), mo: day.getUTCMonth(), d: day.getUTCDate(), dow: day.getUTCDay() }; + var startHour = isThirdTuesday(f) ? 2 : 5; + return { + start: jstToEpoch(f.y, f.mo, f.d, startHour, 0), + end: jstToEpoch(f.y, f.mo, f.d, 7, 0), + extended: isThirdTuesday(f) + }; + } + + function pad2(n) { return n < 10 ? '0' + n : '' + n; } + + function fmtLocal(epoch) { + var d = new Date(epoch); + try { + var formatted = new Intl.DateTimeFormat(navigator.language || undefined, { + weekday: 'short', hour: '2-digit', minute: '2-digit', + timeZone: DISPLAY_TIME_ZONE + }).format(d); + return formatted; + } catch (e) { + return d.toLocaleString(); + } + } + + function fmtCountdown(ms) { + var total = Math.max(0, Math.floor(ms / 60000)); + var h = Math.floor(total / 60); + var m = total % 60; + if (h > 0) return h + 'h ' + m + 'm'; + return m + 'm'; + } + + // Determine status at the current moment. + function compute(now) { + var f = jstFields(now); + var t = now.getTime(); + // Today's window plus the previous day's (guards a window that we might + // still be inside just after JST midnight — none currently cross it, but + // this keeps the logic correct if hours ever change). + var candidates = [windowFor(f, -1), windowFor(f, 0)]; + for (var i = 0; i < candidates.length; i++) { + var w = candidates[i]; + if (t >= w.start && t < w.end) { + return { online: false, window: w, until: w.end }; + } + } + // Online: find the next upcoming window start. + var next = null; + for (var d = 0; d <= 40; d++) { + var w2 = windowFor(f, d); + if (w2.start > t) { next = w2; break; } + } + return { online: true, next: next }; + } + + // Live local clock — hour / minute / second kept in separate fields, ticks in + // real time using the computer's connected timezone. + function renderClock(now) { + var el = document.getElementById('konaste-clock'); + if (!el) return; + var h = el.querySelector('[data-clock="h"]'); + var m = el.querySelector('[data-clock="m"]'); + var s = el.querySelector('[data-clock="s"]'); + var ap = el.querySelector('[data-clock="ap"]'); + var parts = new Intl.DateTimeFormat('en-US', { + timeZone: DISPLAY_TIME_ZONE, + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true + }).formatToParts(now); + var clockParts = {}; + for (var i = 0; i < parts.length; i++) clockParts[parts[i].type] = parts[i].value; + if (h) h.textContent = clockParts.hour || '--'; + if (m) m.textContent = clockParts.minute || '--'; + if (s) s.textContent = clockParts.second || '--'; + if (ap) ap.textContent = clockParts.dayPeriod || ''; + var tz = root.querySelector('[data-clock="tz"]'); + if (tz) tz.textContent = ''; + } + + // Single shared online/offline line — pop'n Lively and GITADORA use the same + // e-amusement maintenance window, so one status covers both. + function renderStatus(now) { + var state = compute(now); + var row = root.querySelector('.konaste-game'); + if (!row) return; + var dot = row.querySelector('[data-role="dot"]'); + var text = row.querySelector('[data-role="text"]'); + var cd = row.querySelector('[data-role="countdown"]'); + + dot.className = 'konaste-dot'; + cd.textContent = ''; + + if (!state.online) { + dot.classList.add('is-maintenance'); + text.textContent = (state.window.extended ? 'Extended maintenance' : 'Under maintenance') + + '\nStarted: ' + fmtLocal(state.window.start) + + '\nEnds: ' + fmtLocal(state.window.end); + cd.textContent = 'Remaining: ' + fmtCountdown(state.until - now.getTime()); + } else { + dot.classList.add('is-online'); + text.textContent = 'Online • play now'; + if (state.next) { + text.textContent += '\nNext maintenance: ' + fmtLocal(state.next.start) + + ' to ' + fmtLocal(state.next.end); + cd.textContent = 'Starts in: ' + fmtCountdown(state.next.start - now.getTime()); + } + } + } + + function tick() { + var now = new Date(); + renderClock(now); + renderStatus(now); + } + + tick(); + setInterval(tick, 1000); + document.addEventListener('visibilitychange', function () { + if (!document.hidden) tick(); + }); +})(); |
