aboutsummaryrefslogtreecommitdiffstats
path: root/partials
diff options
context:
space:
mode:
Diffstat (limited to 'partials')
-rw-r--r--partials/admin_vibe.php201
-rw-r--r--partials/asset_version.php21
-rw-r--r--partials/footer.php40
-rw-r--r--partials/head_meta.php103
-rw-r--r--partials/header.php177
-rw-r--r--partials/header_search.php34
-rw-r--r--partials/i18n.php40
-rw-r--r--partials/partials_vibe.php212
-rw-r--r--partials/proxy_helpers.php80
-rw-r--r--partials/recent_static.php195
-rw-r--r--partials/session.php15
-rw-r--r--partials/site_nav_panel.php44
-rw-r--r--partials/site_status.php70
-rw-r--r--partials/traffic.php626
-rw-r--r--partials/vibe.php73
-rw-r--r--partials/vibe_config.php9
16 files changed, 1940 insertions, 0 deletions
diff --git a/partials/admin_vibe.php b/partials/admin_vibe.php
new file mode 100644
index 0000000..74227c3
--- /dev/null
+++ b/partials/admin_vibe.php
@@ -0,0 +1,201 @@
+<?php
+// admin/vibe.php — admin editor for "My Current Vibe" section
+// Requires an active session with $_SESSION['admin'] === true (your existing auth).
+
+require_once __DIR__ . '/session.php';
+
+if (empty($_SESSION['admin'])) {
+ header('Location: /admin/login.php');
+ exit;
+}
+
+$vibe_file = __DIR__ . '/../vibe.php';
+
+// Load existing config
+$vibe = file_exists($vibe_file)
+ ? require $vibe_file
+ : ['description' => '', 'url' => '', 'updated' => ''];
+
+$errors = [];
+$success = false;
+
+// Handle POST save
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $desc = trim($_POST['description'] ?? '');
+ $url = trim($_POST['url'] ?? '');
+ $updated = date('Y-m-d');
+
+ // Basic URL validation (allow empty)
+ if ($url !== '' && !filter_var($url, FILTER_VALIDATE_URL)) {
+ $errors[] = 'URL does not appear to be valid.';
+ }
+
+ if (empty($errors)) {
+ $export = var_export([
+ 'description' => $desc,
+ 'url' => $url,
+ 'updated' => $updated,
+ ], true);
+
+ $content = "<?php\n// vibe.php — current vibe config, edited via admin/vibe.php\n// Do not edit manually unless you know what you're doing.\n\nreturn {$export};\n";
+
+ if (file_put_contents($vibe_file, $content) !== false) {
+ $success = true;
+ $vibe = ['description' => $desc, 'url' => $url, 'updated' => $updated];
+ } else {
+ $errors[] = 'Could not write vibe.php — check file permissions.';
+ }
+ }
+}
+
+// Detect embed type for preview
+function admin_vibe_detect_type(string $url): string {
+ if (empty($url)) return 'none';
+ $path = strtolower(parse_url($url, PHP_URL_PATH) ?? '');
+ $ext = pathinfo($path, PATHINFO_EXTENSION);
+ $images = ['jpg','jpeg','png','gif','webp','bmp','svg','avif'];
+ $videos = ['mp4','webm','ogv','mov'];
+ $audio = ['mp3','flac','wav','ogg','aac','opus','m4a'];
+ if (in_array($ext, $images)) return 'image';
+ if (in_array($ext, $videos)) return 'video';
+ if (in_array($ext, $audio)) return 'audio';
+ if (preg_match('/(?:youtube\.com\/watch\?.*v=|youtu\.be\/)([A-Za-z0-9_-]{11})/', $url, $m)) return 'youtube:' . $m[1];
+ if (preg_match('/vimeo\.com\/(\d+)/', $url, $m)) return 'vimeo:' . $m[1];
+ return 'link';
+}
+
+$preview_type = admin_vibe_detect_type($vibe['url'] ?? '');
+$safe_url = htmlspecialchars($vibe['url'] ?? '', ENT_QUOTES, 'UTF-8');
+
+// Human-readable label for detected type
+function type_label(string $type): string {
+ if ($type === 'none') return '—';
+ if ($type === 'image') return 'Image';
+ if ($type === 'video') return 'Video';
+ if ($type === 'audio') return 'Audio';
+ if ($type === 'link') return 'Plain link';
+ if (str_starts_with($type, 'youtube:')) return 'YouTube embed';
+ if (str_starts_with($type, 'vimeo:')) return 'Vimeo embed';
+ return 'Unknown';
+}
+?>
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <title>Edit Current Vibe — SillyLaird</title>
+ <link rel="stylesheet" href="/assets/css/fonts.css" />
+ <link rel="stylesheet" href="/assets/css/site.css?v=1.5" />
+ <style>
+ .admin-wrap { max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
+ .field { margin-bottom: 1.25rem; }
+ .field label { display: block; margin-bottom: 0.35rem; font-weight: bold; font-size: 0.9em; }
+ .field input,
+ .field textarea { width: 100%; box-sizing: border-box; font-family: inherit; font-size: 1rem; padding: 0.45rem 0.6rem; border: 1px solid #555; background: #111; color: #eee; border-radius: 3px; }
+ .field textarea { resize: vertical; min-height: 80px; }
+ .detect-badge { display: inline-block; margin-top: 0.4rem; font-size: 0.8em; padding: 2px 8px; border: 1px solid #555; border-radius: 3px; color: #aaa; }
+ .btn-save { padding: 0.5rem 1.4rem; font-size: 1rem; cursor: pointer; }
+ .notice-ok { color: #7f7; border: 1px solid #484; padding: 0.5rem 0.8rem; border-radius: 3px; margin-bottom: 1rem; }
+ .notice-err { color: #f77; border: 1px solid #844; padding: 0.5rem 0.8rem; border-radius: 3px; margin-bottom: 1rem; }
+ .preview-box { margin-top: 1.5rem; border-top: 1px solid #333; padding-top: 1rem; }
+ .preview-box h3 { margin-bottom: 0.75rem; font-size: 0.95em; color: #aaa; text-transform: uppercase; letter-spacing: 0.05em; }
+ .preview-box img,
+ .preview-box video,
+ .preview-box audio { max-width: 100%; display: block; margin-top: 0.5rem; }
+ .preview-box iframe { width: 100%; border: 0; display: block; margin-top: 0.5rem; }
+ .back-link { font-size: 0.85em; margin-bottom: 1.5rem; display: inline-block; }
+ </style>
+</head>
+<body>
+<div class="admin-wrap">
+ <a class="back-link" href="/admin/">&larr; Back to admin</a>
+ <h1>Edit: My Current Vibe</h1>
+
+ <?php if ($success): ?>
+ <p class="notice-ok">&#10003; Saved successfully.</p>
+ <?php endif; ?>
+
+ <?php if (!empty($errors)): ?>
+ <div class="notice-err">
+ <?php foreach ($errors as $e): ?>
+ <p><?= htmlspecialchars($e, ENT_QUOTES, 'UTF-8') ?></p>
+ <?php endforeach; ?>
+ </div>
+ <?php endif; ?>
+
+ <form method="post" action="">
+ <div class="field">
+ <label for="description">Description / caption</label>
+ <textarea id="description" name="description" rows="3"><?= htmlspecialchars($vibe['description'] ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
+ </div>
+
+ <div class="field">
+ <label for="url">URL (image, video, audio, YouTube, Vimeo, or plain link)</label>
+ <input
+ type="url"
+ id="url"
+ name="url"
+ value="<?= $safe_url ?>"
+ placeholder="https://uploads.sillylaird.ca/..."
+ autocomplete="off"
+ />
+ <span class="detect-badge">
+ Detected type: <strong><?= type_label($preview_type) ?></strong>
+ </span>
+ </div>
+
+ <?php if ($vibe['updated'] ?? ''): ?>
+ <p class="muted" style="font-size:0.85em;">Last saved: <?= htmlspecialchars($vibe['updated'], ENT_QUOTES, 'UTF-8') ?></p>
+ <?php endif; ?>
+
+ <button class="btn-save" type="submit">Save Vibe</button>
+ </form>
+
+ <?php if (!empty($vibe['url'])): ?>
+ <div class="preview-box">
+ <h3>Current Preview</h3>
+
+ <?php if ($preview_type === 'image'): ?>
+ <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p>
+ <img src="<?= $safe_url ?>" alt="Vibe preview" loading="lazy">
+
+ <?php elseif ($preview_type === 'video'): ?>
+ <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p>
+ <video controls preload="none" style="max-width:100%;">
+ <source src="<?= $safe_url ?>">
+ </video>
+
+ <?php elseif ($preview_type === 'audio'): ?>
+ <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p>
+ <audio controls preload="none" style="width:100%;">
+ <source src="<?= $safe_url ?>">
+ </audio>
+
+ <?php elseif (str_starts_with($preview_type, 'youtube:')): ?>
+ <?php $yt_id = substr($preview_type, 8); ?>
+ <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p>
+ <div style="position:relative;padding-top:56.25%;">
+ <iframe src="https://www.youtube.com/embed/<?= htmlspecialchars($yt_id) ?>"
+ style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;"
+ allowfullscreen loading="lazy"></iframe>
+ </div>
+
+ <?php elseif (str_starts_with($preview_type, 'vimeo:')): ?>
+ <?php $vimeo_id = substr($preview_type, 6); ?>
+ <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p>
+ <div style="position:relative;padding-top:56.25%;">
+ <iframe src="https://player.vimeo.com/video/<?= htmlspecialchars($vimeo_id) ?>"
+ style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;"
+ allowfullscreen loading="lazy"></iframe>
+ </div>
+
+ <?php else: ?>
+ <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p>
+ <?php endif; ?>
+ </div>
+ <?php endif; ?>
+
+</div>
+</body>
+</html>
diff --git a/partials/asset_version.php b/partials/asset_version.php
new file mode 100644
index 0000000..ff5204f
--- /dev/null
+++ b/partials/asset_version.php
@@ -0,0 +1,21 @@
+<?php
+// partials/asset_version.php — cache-bust helper for static assets.
+//
+// Usage:
+// require_once __DIR__ . '/partials/asset_version.php'; // adjust path
+// Then: htmlspecialchars(asset_v('assets/css/site.css'), ENT_QUOTES)
+// inside an href/src attribute.
+//
+// Paths are relative to the site document root (parent of partials/).
+
+if (!function_exists('asset_v')) {
+ function asset_v(string $rel): string {
+ static $root = null;
+ if ($root === null) {
+ $root = dirname(__DIR__);
+ }
+ $abs = $root . '/' . ltrim($rel, '/');
+ $mt = @filemtime($abs);
+ return $rel . ($mt ? ('?v=' . $mt) : '');
+ }
+}
diff --git a/partials/footer.php b/partials/footer.php
new file mode 100644
index 0000000..e519306
--- /dev/null
+++ b/partials/footer.php
@@ -0,0 +1,40 @@
+<?php
+ // "Explore SillyLaird" side panel retired — it fought dense layouts on www
+ // and subdomains (blog, startpage, etc.). Site map + header nav cover this.
+?>
+<footer class="site-footer">
+ <div class="footer-inner">
+ <p class="footer-back-to-top"><a href="#top"><?php
+ echo htmlspecialchars(function_exists('t') ? t('common.top') : '↑ Top', ENT_QUOTES, 'UTF-8');
+ ?></a></p>
+ <div>© <span id="copyright-year">2026</span> SillyLaird — Licensed under <a href="/wtfpl.txt">WTFPL</a></div>
+ <?php
+ require_once __DIR__ . '/site_status.php';
+ render_site_update_status('site-status-row footer-status-row');
+ ?>
+ <div>Privacy: Some pages load third-party content (Last.fm, guestbook, blog, embeds).</div>
+
+ <div class="footer-joke">Best viewed with two eyes, in any browser, on any network. 🕸️</div>
+
+ <p class="site-visit-counter" id="site-visit-counter" aria-live="polite">
+ <span class="site-visit-counter-label">You are visitor</span>
+ <span class="site-visit-counter-digits" id="site-visit-digits">------</span>
+ </p>
+
+ <p class="footer-smallweb"><?php
+ $madeWith = function_exists('t') ? t('home.made_with') : 'Made with ♥ on the small web';
+ $madeWith = htmlspecialchars($madeWith, ENT_QUOTES, 'UTF-8');
+ echo str_replace('♥', '<span class="footer-heart" aria-hidden="true">♥</span>', $madeWith);
+ ?></p>
+ <p class="footer-colophon">Colophon: hand-built with PHP, nginx, and an Emacs-shaped workflow. No surveillance analytics — just a small visitor counter.</p>
+
+ <div class="footer-badges">
+ <a class="badge own-button" href="https://www.sillylaird.ca/" title="Link back to SillyLaird — grab the button!">
+ <img src="/assets/img/sillylaird-88x31.png" width="88" height="31" alt="SillyLaird — 88x31 link button" loading="lazy" decoding="async">
+ </a>
+ <a class="badge eff-badge" href="https://www.eff.org/issues/free-speech" target="_blank" rel="noopener noreferrer">
+ <img src="/assets/img/rib_bar_red.png" width="150" height="31" alt="EFF free speech badge" loading="lazy" decoding="async" class="s9572e08b37">
+ </a>
+ </div>
+ </div>
+</footer>
diff --git a/partials/head_meta.php b/partials/head_meta.php
new file mode 100644
index 0000000..4f1bbd4
--- /dev/null
+++ b/partials/head_meta.php
@@ -0,0 +1,103 @@
+<?php
+// partials/head_meta.php — render the OG / Twitter / canonical / hreflang
+// boilerplate that every triplicated page needs.
+//
+// Caller passes in:
+// $meta = [
+// 'lang' => 'en'|'ja'|'zh', // current locale
+// 'title' => 'Page title', // og/twitter title
+// 'desc' => 'Page description', // og/twitter description
+// 'base' => 'Laird_Lonergan_profile', // filename without lang suffix or .php
+// 'image' => 'https://www.sillylaird.ca/...', // optional, default og-default.png
+// 'image_alt' => 'alt text for the OG image', // optional
+// 'image_width' => 1200, 'image_height' => 630, // optional, override defaults
+// 'twitter_desc' => '...', // optional, defaults to $desc
+// 'type' => 'website'|'article'|'profile', // optional, default website
+// 'author' => 'Laird Lonergan', // optional, for articles
+// 'published' => '2026-05-26', // optional ISO date for articles
+// 'updated' => '2026-05-26', // optional ISO date
+// 'og_url' => 'https://www.sillylaird.ca/', // optional; defaults to self_url
+// ]
+
+$_lang = $meta['lang'] ?? 'en';
+$_title = $meta['title'] ?? '';
+$_desc = $meta['desc'] ?? '';
+$_base = $meta['base'] ?? 'index';
+$_image = $meta['image'] ?? 'https://www.sillylaird.ca/assets/img/og-default.png';
+$_imgW = (int)($meta['image_width'] ?? 1200);
+$_imgH = (int)($meta['image_height'] ?? 630);
+$_imgAlt = $meta['image_alt'] ?? ($_title !== '' ? $_title : 'SillyLaird');
+$_twDesc = $meta['twitter_desc'] ?? $_desc;
+$_type = $meta['type'] ?? 'website';
+$_author = $meta['author'] ?? '';
+$_published= $meta['published'] ?? '';
+$_updated = $meta['updated'] ?? '';
+
+$_suffix = ['en' => '', 'ja' => '_jp', 'zh' => '_zh'][$_lang] ?? '';
+$_self = '/' . $_base . $_suffix . '.php';
+// URL overrides let pages with non-default canonicals (e.g. the homepage,
+// whose EN canonical is the root "/" not "/index.php") reuse this partial.
+$_selfUrl = $meta['self_url'] ?? ('https://www.sillylaird.ca' . $_self);
+$_ogUrl = $meta['og_url'] ?? $_selfUrl;
+$_enUrl = $meta['en_url'] ?? ('https://www.sillylaird.ca/' . $_base . '.php');
+$_jaUrl = $meta['ja_url'] ?? ('https://www.sillylaird.ca/' . $_base . '_jp.php');
+$_zhUrl = $meta['zh_url'] ?? ('https://www.sillylaird.ca/' . $_base . '_zh.php');
+$_xDefault = $meta['x_default'] ?? $_enUrl;
+$_ogLocale = ['en' => 'en_US', 'ja' => 'ja_JP', 'zh' => 'zh_CN'][$_lang];
+
+$_h = fn(string $s) => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
+?>
+ <meta name="description" content="<?= $_h($_desc) ?>" />
+ <meta name="author" content="<?= $_h($_author !== '' ? $_author : 'Laird Lonergan (SillyLaird)') ?>" />
+ <meta name="theme-color" content="#000000" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-title" content="SillyLaird" />
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="referrer" content="strict-origin-when-cross-origin" />
+
+ <meta property="og:site_name" content="SillyLaird" />
+ <meta property="og:title" content="<?= $_h($_title) ?>" />
+ <meta property="og:description" content="<?= $_h($_desc) ?>" />
+ <meta property="og:image" content="<?= $_h($_image) ?>" />
+ <meta property="og:image:width" content="<?= $_imgW ?>" />
+ <meta property="og:image:height" content="<?= $_imgH ?>" />
+ <meta property="og:image:alt" content="<?= $_h($_imgAlt) ?>" />
+ <meta property="og:type" content="<?= $_h($_type) ?>" />
+ <meta property="og:url" content="<?= $_h($_ogUrl) ?>" />
+ <meta property="og:locale" content="<?= $_ogLocale ?>" />
+<?php foreach (['en_US', 'ja_JP', 'zh_CN'] as $_alt): if ($_alt !== $_ogLocale): ?>
+ <meta property="og:locale:alternate" content="<?= $_alt ?>" />
+<?php endif; endforeach; ?>
+<?php if ($_type === 'article'): ?>
+<?php if ($_published): ?>
+ <meta property="article:published_time" content="<?= $_h($_published) ?>" />
+<?php endif; ?>
+<?php if ($_updated): ?>
+ <meta property="article:modified_time" content="<?= $_h($_updated) ?>" />
+<?php endif; ?>
+<?php if ($_author): ?>
+ <meta property="article:author" content="<?= $_h($_author) ?>" />
+<?php endif; ?>
+<?php endif; ?>
+
+ <meta name="twitter:card" content="summary_large_image" />
+ <meta name="twitter:title" content="<?= $_h($_title) ?>" />
+ <meta name="twitter:description" content="<?= $_h($_twDesc) ?>" />
+ <meta name="twitter:image" content="<?= $_h($_image) ?>" />
+ <meta name="twitter:image:alt" content="<?= $_h($_imgAlt) ?>" />
+
+ <link rel="canonical" href="<?= $_h($_selfUrl) ?>" />
+ <link rel="alternate" hreflang="x-default" href="<?= $_h($_xDefault) ?>" />
+ <link rel="alternate" hreflang="en" href="<?= $_h($_enUrl) ?>" />
+ <link rel="alternate" hreflang="zh" href="<?= $_h($_zhUrl) ?>" />
+ <link rel="alternate" hreflang="ja" href="<?= $_h($_jaUrl) ?>" />
+
+ <link rel="alternate" type="application/rss+xml" title="SillyLaird Blog (RSS)" href="https://blog.sillylaird.ca/feed.xml.php" />
+ <link rel="alternate" type="application/atom+xml" title="SillyLaird Changelog (Atom)" href="https://www.sillylaird.ca/changelog/feed.xml.php" />
+ <link rel="alternate" type="application/atom+xml" title="SillyLaird Journal (Atom)" href="https://www.sillylaird.ca/journal/feed.xml.php" />
+
+ <link rel="icon" type="image/x-icon" href="/favicon.ico" />
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
+ <link rel="manifest" href="/site.webmanifest" />
diff --git a/partials/header.php b/partials/header.php
new file mode 100644
index 0000000..a9a8e88
--- /dev/null
+++ b/partials/header.php
@@ -0,0 +1,177 @@
+<?php
+$_file = basename($_SERVER['PHP_SELF'] ?? 'index.php');
+if (str_ends_with($_file, '_zh.php')) {
+ $_cur_lang = 'ZH';
+} elseif (str_ends_with($_file, '_jp.php')) {
+ $_cur_lang = 'JP';
+} else {
+ $_cur_lang = 'EN';
+}
+$_sjs = @filemtime(__DIR__ . '/../assets/js/site.js');
+$_is_startpage = str_contains($_SERVER['SCRIPT_NAME'] ?? '', '/startpage/');
+?>
+<script defer src="/assets/js/site.js<?= $_sjs ? '?v=' . $_sjs : '' ?>"></script>
+<span id="top"></span>
+<header class="site-header">
+ <div class="header-inner">
+ <div class="brand">
+ <a href="/"
+ class="logo"
+ aria-label="SillyLaird home">
+ <img src="/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="/startpage/" class="header-button header-button--startpage" title="Open the power-user StartPage">StartPage</a>
+
+ <a href="/" 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="Comfort spacing: OFF"
+ 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"><?= $_cur_lang ?></span></span>
+ </button>
+ </div>
+
+ <div class="lang-menu"
+ id="lang-options"
+ role="menu"
+ hidden>
+ <a role="menuitem"
+ href="?lang=en"
+ hreflang="en"
+ lang="en"
+ data-lang="en"
+ data-lang-href>English</a>
+ <a role="menuitem"
+ href="?lang=zh"
+ hreflang="zh"
+ lang="zh"
+ data-lang="zh"
+ data-lang-href>中文</a>
+ <a role="menuitem"
+ href="?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="https://www.sillylaird.ca/">Home</a></li>
+ <li><a href="https://os.sillylaird.ca/" target="_blank" rel="noopener noreferrer">OS</a></li>
+ <li><a href="https://www.sillylaird.ca/now/">Now</a></li>
+ <li><a href="https://www.sillylaird.ca/motd/">MOTD</a></li>
+ <li><a href="https://www.sillylaird.ca/startpage/" class="nav-link--featured">StartPage ★</a></li>
+ <li><a href="https://www.sillylaird.ca/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="https://www.sillylaird.ca/journal/">Journal</a></li>
+ <li><a href="https://www.sillylaird.ca/changelog/">Changelog</a></li>
+ <li><a href="https://diary.sillylaird.ca/bbs.php" target="_blank" rel="noopener noreferrer">Diary</a></li>
+ <li><a href="/guestbook.php" 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="https://www.sillylaird.ca/gaming/">Gaming</a></li>
+ <li><a href="https://www.sillylaird.ca/gaming/collection/">Game Collection</a></li>
+ <li><a href="https://www.sillylaird.ca/bookmarks/">Bookmarks</a></li>
+ <li><a href="https://www.sillylaird.ca/accounts/">Accounts</a></li>
+ <li><a href="https://www.sillylaird.ca/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://micro.heyaoi.online/" target="_blank" rel="noopener noreferrer">Heyaoi Online</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>
+ <?php if (!$_is_startpage) {
+ include __DIR__ . '/header_search.php';
+ } ?>
+</header>
diff --git a/partials/header_search.php b/partials/header_search.php
new file mode 100644
index 0000000..85b57e4
--- /dev/null
+++ b/partials/header_search.php
@@ -0,0 +1,34 @@
+<?php
+/** Header search bar — sits inside site-header, aligned with header-inner width. */
+?>
+<div class="header-search" id="header-search" role="search" aria-label="Search the web or this site">
+ <div class="header-search-inner">
+ <form id="gh-search-form"
+ class="gh-search-form"
+ method="get"
+ action="https://www.google.ca/search"
+ target="_blank"
+ rel="noopener noreferrer">
+ <div class="unified-search">
+ <div class="search-engine-picker">
+ <button type="button"
+ id="gh-search-engine-toggle"
+ class="search-engine-toggle"
+ aria-haspopup="listbox"
+ aria-expanded="false"
+ aria-label="Choose search engine">G <span class="search-engine-caret" aria-hidden="true">▾</span></button>
+ <ul id="gh-search-engine-menu" class="search-engine-menu" role="listbox" hidden></ul>
+ </div>
+ <label class="visually-hidden" for="gh-search-input">Search query</label>
+ <input type="search"
+ id="gh-search-input"
+ name="q"
+ class="unified-search-input"
+ placeholder="Search Google…"
+ autocomplete="off"
+ enterkeyhint="search" />
+ </div>
+ </form>
+ <ul id="site-search-hits" class="site-search-hits" role="listbox" aria-label="Matching pages on this site" hidden></ul>
+ </div>
+</div> \ No newline at end of file
diff --git a/partials/i18n.php b/partials/i18n.php
new file mode 100644
index 0000000..6f76419
--- /dev/null
+++ b/partials/i18n.php
@@ -0,0 +1,40 @@
+<?php
+// Minimal i18n helper. Detects language from the current filename suffix
+// (_jp.php / _zh.php) so it matches the existing URL scheme, and loads a
+// flat key -> string dict from locales/{code}.php.
+//
+// Usage in a page:
+// require_once __DIR__ . '/../partials/i18n.php'; // adjust relative path
+// echo t('home.title');
+//
+// Missing keys fall back to the English dict, then to the key itself.
+
+if (!function_exists('t')) {
+ function _i18n_lang(): string {
+ // Prefer SCRIPT_NAME: under nginx + php-fpm, PHP_SELF is often empty.
+ $f = basename((string)($_SERVER['SCRIPT_NAME'] ?? $_SERVER['PHP_SELF'] ?? 'index.php'));
+ if ($f === '') $f = 'index.php';
+ if (str_ends_with($f, '_zh.php')) return 'zh';
+ if (str_ends_with($f, '_jp.php')) return 'ja';
+ return 'en';
+ }
+
+ function _i18n_load(string $code): array {
+ static $cache = [];
+ if (isset($cache[$code])) return $cache[$code];
+ $path = __DIR__ . '/../locales/' . $code . '.php';
+ $cache[$code] = is_file($path) ? (array)include $path : [];
+ return $cache[$code];
+ }
+
+ function t(string $key, ?string $lang = null): string {
+ $lang = $lang ?? _i18n_lang();
+ $dict = _i18n_load($lang);
+ if (array_key_exists($key, $dict)) return $dict[$key];
+ if ($lang !== 'en') {
+ $en = _i18n_load('en');
+ if (array_key_exists($key, $en)) return $en[$key];
+ }
+ return $key;
+ }
+}
diff --git a/partials/partials_vibe.php b/partials/partials_vibe.php
new file mode 100644
index 0000000..aa77598
--- /dev/null
+++ b/partials/partials_vibe.php
@@ -0,0 +1,212 @@
+<?php
+// partials/vibe.php — renders "My Current Vibe" section
+// Reads from vibe.php config, detects URL type and embeds accordingly.
+
+$vibe = file_exists(__DIR__ . '/../vibe.php')
+ ? require __DIR__ . '/../vibe.php'
+ : ['description' => '', 'url' => '', 'updated' => ''];
+
+$vibe_section_title_raw = $vibe_section_title ?? 'My Current Vibe';
+$vibe_desc_raw = trim((string)($vibe['description'] ?? ''));
+$vibe_url = $vibe['url'] ?? '';
+$vibe_updated = htmlspecialchars($vibe['updated'] ?? '', ENT_QUOTES, 'UTF-8');
+
+// Hide description if it is empty or duplicates the section heading
+$show_desc = $vibe_desc_raw !== '' && strcasecmp($vibe_desc_raw, $vibe_section_title_raw) !== 0;
+$vibe_desc = htmlspecialchars($vibe_desc_raw, ENT_QUOTES, 'UTF-8');
+
+function vibe_detect_type(string $url): string {
+ if (empty($url)) return 'none';
+ $path = strtolower(parse_url($url, PHP_URL_PATH) ?? '');
+ $ext = pathinfo($path, PATHINFO_EXTENSION);
+
+ $images = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif'];
+ $videos = ['mp4', 'webm', 'ogv', 'mov'];
+ $audio = ['mp3', 'flac', 'wav', 'ogg', 'aac', 'opus', 'm4a'];
+
+ if (in_array($ext, $images)) return 'image';
+ if (in_array($ext, $videos)) return 'video';
+ if (in_array($ext, $audio)) return 'audio';
+
+ if (preg_match('/(?:youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/shorts\/)([A-Za-z0-9_-]{11})/', $url, $m))
+ return 'youtube:' . $m[1];
+
+ if (preg_match('/vimeo\.com\/(?:video\/)?(\d+)/', $url, $m))
+ return 'vimeo:' . $m[1];
+
+ // Dailymotion: dailymotion.com/video/{id} or dai.ly/{id} (id may have a _slug)
+ if (preg_match('#(?:dailymotion\.com/video|dai\.ly)/([A-Za-z0-9]+)#i', $url, $m))
+ return 'dailymotion:' . $m[1];
+
+ // Bilibili: bilibili.com/video/{BVxxxx} (BV id) or /video/av{n}
+ if (preg_match('#bilibili\.com/video/(BV[A-Za-z0-9]+)#i', $url, $m))
+ return 'bilibili:bvid:' . $m[1];
+ if (preg_match('#bilibili\.com/video/av(\d+)#i', $url, $m))
+ return 'bilibili:aid:' . $m[1];
+
+ // Niconico: nicovideo.jp/watch/{sm123} or nico.ms/{sm123}
+ if (preg_match('#(?:nicovideo\.jp/watch|nico\.ms)/((?:sm|nm|so)?\d+)#i', $url, $m))
+ return 'niconico:' . $m[1];
+
+ return 'link';
+}
+
+// Extract ?t= / &t= / #t= start offset for YouTube (supports `90`, `1m30s`, `1h2m3s`)
+function vibe_youtube_start(string $url): int {
+ $q = [];
+ parse_str((string)parse_url($url, PHP_URL_QUERY), $q);
+ $t = $q['t'] ?? $q['start'] ?? null;
+ if ($t === null) {
+ $frag = parse_url($url, PHP_URL_FRAGMENT) ?? '';
+ if (preg_match('/(?:^|&)t=([^&]+)/', $frag, $m)) $t = $m[1];
+ }
+ if ($t === null) return 0;
+ if (ctype_digit((string)$t)) return (int)$t;
+ if (preg_match('/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/', $t, $m)) {
+ return ((int)($m[1] ?? 0)) * 3600 + ((int)($m[2] ?? 0)) * 60 + ((int)($m[3] ?? 0));
+ }
+ return 0;
+}
+
+// Map a detected iframe-provider type to an embed URL + friendly label.
+// Returns [] for non-iframe types (image/audio/video/link/none).
+function vibe_provider_embed(string $type, string $url): array {
+ if (str_starts_with($type, 'youtube:')) {
+ $id = substr($type, 8);
+ $start = vibe_youtube_start($url);
+ $embed = 'https://www.youtube-nocookie.com/embed/' . rawurlencode($id)
+ . '?rel=0&modestbranding=1' . ($start > 0 ? '&start=' . $start : '');
+ return ['embed' => $embed, 'label' => 'YouTube'];
+ }
+ if (str_starts_with($type, 'vimeo:')) {
+ $id = substr($type, 6);
+ return ['embed' => 'https://player.vimeo.com/video/' . rawurlencode($id) . '?dnt=1', 'label' => 'Vimeo'];
+ }
+ if (str_starts_with($type, 'dailymotion:')) {
+ $id = substr($type, 12);
+ return ['embed' => 'https://geo.dailymotion.com/player.html?video=' . rawurlencode($id), 'label' => 'Dailymotion'];
+ }
+ if (str_starts_with($type, 'bilibili:bvid:')) {
+ $id = substr($type, 14);
+ return ['embed' => 'https://player.bilibili.com/player.html?bvid=' . rawurlencode($id) . '&page=1&high_quality=1&danmaku=0', 'label' => 'Bilibili'];
+ }
+ if (str_starts_with($type, 'bilibili:aid:')) {
+ $id = substr($type, 13);
+ return ['embed' => 'https://player.bilibili.com/player.html?aid=' . rawurlencode($id) . '&page=1&high_quality=1&danmaku=0', 'label' => 'Bilibili'];
+ }
+ if (str_starts_with($type, 'niconico:')) {
+ $id = substr($type, 9);
+ return ['embed' => 'https://embed.nicovideo.jp/watch/' . rawurlencode($id), 'label' => 'Niconico'];
+ }
+ return [];
+}
+
+$type = vibe_detect_type($vibe_url);
+$safe_url = htmlspecialchars($vibe_url, ENT_QUOTES, 'UTF-8');
+?>
+
+<style>
+ .vibe-embed {
+ position: relative;
+ padding-top: 56.25%;
+ overflow: hidden;
+ border-radius: 6px;
+ background: #000;
+ margin: 0.5rem 0;
+ }
+ .vibe-embed > iframe,
+ .vibe-embed > video,
+ .vibe-embed > img {
+ position: absolute;
+ top: 0; left: 0;
+ width: 100%; height: 100%;
+ border: 0;
+ object-fit: contain;
+ }
+ .vibe-media-audio { width: 100%; margin: 0.5rem 0; }
+ .vibe-media-img { max-width: 100%; height: auto; border-radius: 6px; }
+ .vibe-caption {
+ margin: 0.25rem 0 0;
+ font-size: 0.9em;
+ opacity: 0.8;
+ }
+ .vibe-meta {
+ font-size: 0.85em;
+ opacity: 0.7;
+ margin: 0.5rem 0 0;
+ }
+</style>
+
+<section id="vibe" aria-labelledby="vibe-title" class="vibe-section">
+ <h2 id="vibe-title" class="section-accent-title"><?= htmlspecialchars($vibe_section_title_raw, ENT_QUOTES, 'UTF-8') ?></h2>
+
+ <?php if ($show_desc): ?>
+ <p><?= $vibe_desc ?></p>
+ <?php endif; ?>
+
+ <?php if ($vibe_url): ?>
+ <?php if ($type === 'image'): ?>
+ <a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer">
+ <img class="vibe-media-img" src="<?= $safe_url ?>" alt="Current vibe" loading="lazy" decoding="async">
+ </a>
+
+ <?php elseif ($type === 'video'): ?>
+ <div class="vibe-embed">
+ <video controls preload="none">
+ <source src="<?= $safe_url ?>">
+ </video>
+ </div>
+ <p class="vibe-caption">
+ <a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer">Open video ↗</a>
+ </p>
+
+ <?php elseif ($type === 'audio'): ?>
+ <audio class="vibe-media-audio" controls preload="none">
+ <source src="<?= $safe_url ?>">
+ Your browser does not support audio. <a href="<?= $safe_url ?>">Download it</a>.
+ </audio>
+ <p class="vibe-caption">
+ <a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer">Open audio ↗</a>
+ </p>
+
+ <?php elseif ($prov = vibe_provider_embed($type, $vibe_url)): ?>
+ <?php
+ $embed = $prov['embed'];
+ $label = $prov['label'];
+ $safe_embed = htmlspecialchars($embed, ENT_QUOTES);
+ $safe_label = htmlspecialchars($label, ENT_QUOTES);
+ ?>
+ <div class="vibe-embed">
+ <noscript>
+ <iframe
+ src="<?= $safe_embed ?>"
+ title="Current vibe — <?= $safe_label ?> video"
+ loading="lazy"
+ referrerpolicy="strict-origin-when-cross-origin"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
+ allowfullscreen></iframe>
+ </noscript>
+ <button type="button" class="vibe-facade js-only"
+ data-embed="<?= $safe_embed ?>"
+ aria-label="Load and play the <?= $safe_label ?> video">
+ <span class="vibe-facade-play" aria-hidden="true">▶</span>
+ <span class="vibe-facade-text">Load <?= $safe_label ?> video<br><small>Nothing loads from <?= $safe_label ?> until you click</small></span>
+ </button>
+ </div>
+ <p class="vibe-caption">
+ <a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer">Watch on <?= $safe_label ?> ↗</a>
+ </p>
+
+ <?php else: ?>
+ <p>
+ <a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a>
+ </p>
+ <?php endif; ?>
+ <?php endif; ?>
+
+ <?php if ($vibe_updated): ?>
+ <p class="vibe-meta">Updated <?= $vibe_updated ?></p>
+ <?php endif; ?>
+
+ <?php if (!empty($vibe_section_extra)) echo $vibe_section_extra; ?>
+</section>
diff --git a/partials/proxy_helpers.php b/partials/proxy_helpers.php
new file mode 100644
index 0000000..59ac3cb
--- /dev/null
+++ b/partials/proxy_helpers.php
@@ -0,0 +1,80 @@
+<?php
+// partials/proxy_helpers.php — small shared helpers for the *-proxy.php endpoints.
+//
+// - lastfm_api_key(): read LASTFM_API_KEY from env (with legacy fallback).
+// - proxy_host_is_safe(): reject hosts that resolve to private/reserved IPs (SSRF guard).
+// - proxy_rate_limit(): simple per-IP sliding-window limit, file-backed.
+
+if (!function_exists('lastfm_api_key')) {
+ function lastfm_api_key(): string {
+ $env = getenv('LASTFM_API_KEY');
+ if (is_string($env) && $env !== '') return $env;
+ throw new RuntimeException('LASTFM_API_KEY is not set in the php-fpm environment');
+ }
+}
+
+if (!function_exists('proxy_host_is_safe')) {
+ function proxy_host_is_safe(string $host): bool {
+ if ($host === '') return false;
+ // Reject anything that looks like a literal IPv6 with brackets stripped earlier.
+ $records = @dns_get_record($host, DNS_A | DNS_AAAA);
+ $ips = [];
+ if (is_array($records)) {
+ foreach ($records as $r) {
+ if (!empty($r['ip'])) $ips[] = $r['ip'];
+ if (!empty($r['ipv6'])) $ips[] = $r['ipv6'];
+ }
+ }
+ // Also check if the host is itself an IP literal.
+ if (filter_var($host, FILTER_VALIDATE_IP)) {
+ $ips[] = $host;
+ }
+ if (!$ips) return false; // unresolvable -> deny
+ foreach ($ips as $ip) {
+ $ok = filter_var(
+ $ip,
+ FILTER_VALIDATE_IP,
+ FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
+ );
+ if ($ok === false) return false;
+ }
+ return true;
+ }
+}
+
+if (!function_exists('proxy_rate_limit')) {
+ /**
+ * Returns true if the request is allowed. Uses a tiny file per
+ * (endpoint, client IP) holding recent unix timestamps.
+ */
+ function proxy_rate_limit(string $endpoint, int $max = 30, int $windowSec = 60): bool {
+ $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
+ $key = preg_replace('/[^A-Za-z0-9._-]/', '_', $endpoint . '_' . $ip);
+ $dir = sys_get_temp_dir() . '/sillylaird_ratelimit';
+ if (!is_dir($dir)) @mkdir($dir, 0700, true);
+ $file = $dir . '/' . $key;
+
+ $now = time();
+ $cutoff = $now - $windowSec;
+
+ $fp = @fopen($file, 'c+');
+ if (!$fp) return true; // fail-open: don't block legit traffic on disk error
+ try {
+ flock($fp, LOCK_EX);
+ $data = stream_get_contents($fp);
+ $stamps = $data === '' ? [] : array_map('intval', explode("\n", trim($data)));
+ $stamps = array_values(array_filter($stamps, fn($t) => $t >= $cutoff));
+ if (count($stamps) >= $max) {
+ return false;
+ }
+ $stamps[] = $now;
+ ftruncate($fp, 0);
+ rewind($fp);
+ fwrite($fp, implode("\n", $stamps));
+ return true;
+ } finally {
+ flock($fp, LOCK_UN);
+ fclose($fp);
+ }
+ }
+}
diff --git a/partials/recent_static.php b/partials/recent_static.php
new file mode 100644
index 0000000..484bd18
--- /dev/null
+++ b/partials/recent_static.php
@@ -0,0 +1,195 @@
+<?php
+// partials/recent_static.php
+// Cheap server-side prerender of recent blog/changelog items so users do
+// not stare at "Loading…" while JS is still booting (and so the page is
+// useful even when the fetch fails).
+
+const RECENT_CACHE_TTL = 300; // 5 min
+
+function _recent_cache_get(string $key, int $ttl, int $invalidate_before = 0) {
+ $f = sys_get_temp_dir() . '/sillylaird_recent_' . $key . '.json';
+ if (!is_file($f)) return null;
+ $mt = filemtime($f);
+ // Stale if older than TTL, or older than the upstream source mtime
+ if ((time() - $mt) >= $ttl) return null;
+ if ($invalidate_before > 0 && $mt < $invalidate_before) return null;
+ $raw = @file_get_contents($f);
+ if ($raw === false) return null;
+ $data = json_decode($raw, true);
+ return is_array($data) ? $data : null;
+}
+
+function _recent_changelog_source_mtime(): int {
+ $latest = 0;
+ foreach (glob(__DIR__ . '/../changelog/*-changelog.txt') ?: [] as $f) {
+ $m = @filemtime($f);
+ if ($m && $m > $latest) $latest = $m;
+ }
+ return $latest;
+}
+
+function _recent_cache_put(string $key, array $data): void {
+ $f = sys_get_temp_dir() . '/sillylaird_recent_' . $key . '.json';
+ @file_put_contents($f, json_encode($data), LOCK_EX);
+}
+
+// Last-resort fallback: return whatever is cached regardless of age, so an
+// upstream hiccup never blanks the list (stale-on-error).
+function _recent_cache_get_stale(string $key): ?array {
+ $f = sys_get_temp_dir() . '/sillylaird_recent_' . $key . '.json';
+ if (!is_file($f)) return null;
+ $raw = @file_get_contents($f);
+ if ($raw === false) return null;
+ $data = json_decode($raw, true);
+ return is_array($data) ? $data : null;
+}
+
+const BLOG_SQLITE = '/mnt/slab/blog.sillylaird.ca/blog.sqlite';
+
+function _recent_fetch(string $url, float $timeout = 1.5): ?string {
+ $ctx = stream_context_create(['http' => [
+ 'timeout' => $timeout,
+ 'ignore_errors' => true,
+ 'header' => "User-Agent: sillylaird-static-render/1.0\r\n",
+ ]]);
+ $body = @file_get_contents($url, false, $ctx);
+ return $body === false ? null : $body;
+}
+
+function recent_blog_posts(int $limit = 2): array {
+ // Invalidate cache when the blog DB changes so new posts show within the
+ // window without waiting out the full TTL.
+ $src_mtime = @filemtime(BLOG_SQLITE) ?: 0;
+ $cached = _recent_cache_get('blog', RECENT_CACHE_TTL, $src_mtime);
+ if ($cached !== null) return array_slice($cached, 0, $limit);
+
+ // Blog runs on the same box; read its SQLite directly instead of an HTTP
+ // round-trip to blog.sillylaird.ca (the old fetch blocked ~1.5s on every
+ // cache miss and stalled when the blog vhost was slow/down).
+ $posts = null;
+ if (is_file(BLOG_SQLITE)) {
+ try {
+ $db = new PDO('sqlite:' . BLOG_SQLITE, null, null, [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_TIMEOUT => 2,
+ ]);
+ $stmt = $db->query('SELECT title, path, year, month, day FROM posts ORDER BY id DESC');
+ $posts = $stmt->fetchAll(PDO::FETCH_ASSOC);
+ } catch (Throwable $e) {
+ $posts = null;
+ }
+ }
+
+ if (!is_array($posts)) {
+ // DB unavailable — serve the last good list rather than blanking.
+ $stale = _recent_cache_get_stale('blog');
+ return $stale !== null ? array_slice($stale, 0, $limit) : [];
+ }
+
+ _recent_cache_put('blog', $posts);
+ return array_slice($posts, 0, $limit);
+}
+
+function recent_changelog_entries(int $limit = 2): array {
+ $cached = _recent_cache_get('changelog', RECENT_CACHE_TTL, _recent_changelog_source_mtime());
+ if ($cached !== null) return array_slice($cached, 0, $limit);
+
+ $html = _recent_fetch('https://www.sillylaird.ca/changelog/latest.php');
+ if ($html === null) {
+ $stale = _recent_cache_get_stale('changelog');
+ return $stale !== null ? array_slice($stale, 0, $limit) : [];
+ }
+
+ $entries = [];
+ $doc = new DOMDocument();
+ libxml_use_internal_errors(true);
+ if (!$doc->loadHTML('<?xml encoding="UTF-8">' . $html)) {
+ libxml_clear_errors();
+ $stale = _recent_cache_get_stale('changelog');
+ return $stale !== null ? array_slice($stale, 0, $limit) : [];
+ }
+ libxml_clear_errors();
+ $xp = new DOMXPath($doc);
+ foreach ($xp->query('//div[contains(@class,"changelog-entry")]') as $node) {
+ /** @var DOMElement $node */
+ $title = '';
+ foreach (['b', 'strong'] as $tag) {
+ $el = $node->getElementsByTagName($tag)->item(0);
+ if ($el) { $title = trim($el->textContent); break; }
+ }
+ $timeEl = $node->getElementsByTagName('time')->item(0);
+ $iso = $timeEl ? trim($timeEl->getAttribute('datetime')) : '';
+ $when = $timeEl ? trim($timeEl->textContent) : '';
+
+ $body = '';
+ foreach ($xp->query('.//span[contains(@class,"text")]', $node) as $b) {
+ $body = trim($b->textContent);
+ break;
+ }
+ $entries[] = compact('title', 'iso', 'when', 'body');
+ }
+
+ _recent_cache_put('changelog', $entries);
+ return array_slice($entries, 0, $limit);
+}
+
+function render_recent_blog(int $limit = 2): void {
+ $posts = recent_blog_posts($limit);
+ if (!$posts) {
+ echo '<div class="muted">No posts to show right now.</div>';
+ return;
+ }
+ $h = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
+ echo '<div><strong>Posts</strong> (<a href="https://blog.sillylaird.ca/">all</a>)</div>';
+ echo '<ol class="recent-list">';
+ foreach ($posts as $p) {
+ $path = ltrim((string)($p['path'] ?? ''), '/');
+ $iso = sprintf('%04d-%02d-%02d', (int)($p['year'] ?? 0), (int)($p['month'] ?? 0), (int)($p['day'] ?? 0));
+ $iso = $iso === '0000-00-00' ? '' : $iso;
+ echo '<li><a href="https://blog.sillylaird.ca/' . $h($path) . '">' . $h($p['title'] ?? 'Untitled') . '</a>';
+ if ($iso) echo '<div class="muted"><time datetime="' . $h($iso) . '">' . $h($iso) . '</time></div>';
+ echo '</li>';
+ }
+ echo '</ol>';
+}
+
+/** Latest changelog row for sidebar widgets (reads local TXT, no HTTP). */
+function latest_changelog_entry(): ?array {
+ static $entry = null;
+ static $loaded = false;
+ if ($loaded) return $entry;
+ $loaded = true;
+
+ $parser = __DIR__ . '/../changelog/api/parser.php';
+ if (!is_file($parser)) return null;
+ require_once $parser;
+
+ $rows = get_changelog_entries(1);
+ $entry = $rows[0] ?? null;
+ return $entry;
+}
+
+function render_recent_changelog(int $limit = 2): void {
+ $entries = recent_changelog_entries($limit);
+ if (!$entries) {
+ echo '<div class="muted">No entries to show right now.</div>';
+ return;
+ }
+ $h = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
+ echo '<div><strong>Entries</strong> (<a href="https://www.sillylaird.ca/changelog/">all</a>)</div>';
+ echo '<ol class="recent-list">';
+ foreach ($entries as $e) {
+ echo '<li>';
+ if ($e['title']) echo '<strong>' . $h($e['title']) . '</strong>';
+ if ($e['iso'] || $e['when']) {
+ echo '<div class="muted"><time';
+ if ($e['iso']) echo ' datetime="' . $h($e['iso']) . '"';
+ echo '>' . $h($e['when'] ?: $e['iso']) . '</time></div>';
+ }
+ if ($e['body']) echo '<div>' . $h($e['body']) . '</div>';
+ $yr = $e['iso'] ? substr((string)$e['iso'], 0, 4) : '';
+ if ($yr) echo '<a class="changelog-more" href="https://www.sillylaird.ca/changelog/#year-' . $h($yr) . '">more (' . $h($yr) . ') &rarr;</a>';
+ echo '</li>';
+ }
+ echo '</ol>';
+}
diff --git a/partials/session.php b/partials/session.php
new file mode 100644
index 0000000..d4ee141
--- /dev/null
+++ b/partials/session.php
@@ -0,0 +1,15 @@
+<?php
+// Shared session bootstrap. Must be included before any output.
+// Security headers (CSP, HSTS, nosniff, Referrer-Policy, Permissions-Policy)
+// are owned by nginx — do not re-emit them here (browsers can conflict on
+// duplicate policies).
+if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_set_cookie_params([
+ 'lifetime' => 0,
+ 'path' => '/',
+ 'secure' => true,
+ 'httponly' => true,
+ 'samesite' => 'Lax',
+ ]);
+ session_start();
+}
diff --git a/partials/site_nav_panel.php b/partials/site_nav_panel.php
new file mode 100644
index 0000000..a23d104
--- /dev/null
+++ b/partials/site_nav_panel.php
@@ -0,0 +1,44 @@
+<?php
+// partials/site_nav_panel.php — curated "Explore SillyLaird" link panel.
+// xahlee.info <nav class="lpanelxl"> style: a titled box with a tight vertical
+// link list, some entries carrying an enlarged emoji (.emojixl).
+// Shown on every non-homepage page (included from partials/footer.php).
+//
+// To curate: edit $panel_links below. ['href','label','emoji','external'].
+$panel_links = [
+ ['/now/', 'Now', '📌'],
+ ['/motd/', 'MOTD', '📜'],
+ ['/journal/', 'Journal', '📓'],
+ ['https://blog.sillylaird.ca/', 'Blog', '✍', true],
+ ['/gaming/', 'Gaming archive', '🎮'],
+ ['/gaming/collection/', 'Game Collection', '📼'],
+ ['/gaming/multibox/', 'Multiboxing', '⚔'],
+ ['/musictaste/', 'Music taste', '🎵'],
+ ['/computers/', 'Computers', '🖥'],
+ ['/bookmarks/', 'Bookmarks', '🔖'],
+ ['/links.php', 'Links', '🔗'],
+ ['/hatCollectionWebsite.php', 'Hat collection', '🧢'],
+ ['/startpage/', 'StartPage', '🏠'],
+ ['/changelog/', 'Changelog', '🛠'],
+ ['/guestbook.php', 'Guestbook', '📖'],
+ ['/vblog/', 'vBlog', '🎬'],
+ ['/whatsleft/', "What's left", '🧹'],
+ ['/Laird_Lonergan_profile.php', 'Profile', '👤'],
+ ['/map/', 'Site map', '🗺'],
+];
+?>
+<div class="wrap">
+ <nav class="lpanelxl" aria-label="Explore SillyLaird">
+ <h3>Explore SillyLaird</h3>
+ <ul>
+<?php foreach ($panel_links as $l):
+ $href = htmlspecialchars($l[0], ENT_QUOTES, 'UTF-8');
+ $label = htmlspecialchars($l[1], ENT_QUOTES, 'UTF-8');
+ $emoji = htmlspecialchars($l[2] ?? '', ENT_QUOTES, 'UTF-8');
+ $ext = !empty($l[3]);
+?>
+ <li><a href="<?= $href ?>"<?= $ext ? ' target="_blank" rel="noopener noreferrer"' : '' ?>><?= $label ?><?php if ($emoji): ?> <span class="emojixl"><?= $emoji ?></span><?php endif; ?></a></li>
+<?php endforeach; ?>
+ </ul>
+ </nav>
+</div>
diff --git a/partials/site_status.php b/partials/site_status.php
new file mode 100644
index 0000000..c758186
--- /dev/null
+++ b/partials/site_status.php
@@ -0,0 +1,70 @@
+<?php
+
+function site_latest_update_timestamp(): int
+{
+ static $latest = null;
+ if ($latest !== null) {
+ return $latest;
+ }
+
+ $root = dirname(__DIR__);
+ $latest = 0;
+ $extensions = ['php' => true, 'html' => true, 'css' => true, 'js' => true, 'txt' => true];
+ $skipDirs = ['.git' => true, '.agents' => true, 'node_modules' => true, 'vendor' => true];
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveCallbackFilterIterator(
+ new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
+ static function (SplFileInfo $current) use ($skipDirs): bool {
+ if ($current->isDir()) {
+ return !isset($skipDirs[$current->getFilename()]);
+ }
+ return true;
+ }
+ )
+ );
+
+ foreach ($iterator as $file) {
+ if (!$file->isFile()) {
+ continue;
+ }
+ $extension = strtolower($file->getExtension());
+ if (!isset($extensions[$extension])) {
+ continue;
+ }
+ $mtime = @filemtime($file->getPathname());
+ if ($mtime && $mtime > $latest) {
+ $latest = $mtime;
+ }
+ }
+
+ return $latest;
+}
+
+function site_update_status(): array
+{
+ $timezone = new DateTimeZone('America/New_York');
+ $latest = site_latest_update_timestamp();
+ $date = $latest
+ ? (new DateTimeImmutable('@' . $latest))->setTimezone($timezone)->format('Y-m-d')
+ : (new DateTimeImmutable('now', $timezone))->format('Y-m-d');
+ $today = (new DateTimeImmutable('now', $timezone))->format('Y-m-d');
+
+ return [
+ 'date' => $date,
+ 'updated_today' => $date === $today,
+ ];
+}
+
+function render_site_update_status(string $class = 'site-status-row'): void
+{
+ $status = site_update_status();
+ $date = htmlspecialchars($status['date'], ENT_QUOTES, 'UTF-8');
+ $label = $status['updated_today'] ? 'Updated today: check the changelog' : 'View changelog';
+ ?>
+ <div class="<?= htmlspecialchars($class, ENT_QUOTES, 'UTF-8') ?>" aria-label="Site status">
+ <span>Last updated: <time datetime="<?= $date ?>"><?= $date ?></time></span>
+ <a href="https://www.sillylaird.ca/changelog/"><?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?></a>
+ </div>
+ <?php
+}
diff --git a/partials/traffic.php b/partials/traffic.php
new file mode 100644
index 0000000..085b4a7
--- /dev/null
+++ b/partials/traffic.php
@@ -0,0 +1,626 @@
+<?php
+/**
+ * Traffic log helpers — privacy-friendly hit storage for sillylaird.ca.
+ *
+ * DB path: /var/lib/sillylaird/traffic.sqlite (outside the document root).
+ * Never write client IPs into this database.
+ */
+
+declare(strict_types=1);
+
+const TRAFFIC_DB = '/var/lib/sillylaird/traffic.sqlite';
+const TRAFFIC_HIT_RETENTION_DAYS = 90;
+const TRAFFIC_MAX_RECENT = 5000;
+
+function traffic_host_allowed(string $host): bool {
+ $host = strtolower(trim($host));
+ if ($host === '' || str_contains($host, '/') || str_contains($host, ' ')) {
+ return false;
+ }
+ // Strip port if present
+ if (str_contains($host, ':')) {
+ $host = explode(':', $host, 2)[0];
+ }
+ return $host === 'sillylaird.ca'
+ || $host === 'www.sillylaird.ca'
+ || str_ends_with($host, '.sillylaird.ca');
+}
+
+function traffic_normalize_path(string $path): string {
+ $path = trim($path);
+ if ($path === '') return '/';
+ // Drop query/fragment if a full URL or path?query was sent
+ $path = preg_replace('/[?#].*$/', '', $path) ?? $path;
+ if ($path === '' || $path[0] !== '/') {
+ $path = '/' . ltrim($path, '/');
+ }
+ // Collapse //
+ $path = preg_replace('#/+#', '/', $path) ?? $path;
+ $path = rtrim($path, '/') ?: '/';
+ // /foo/index.php and lang variants → /foo
+ $path = preg_replace('#/index(?:_[a-z]{2})?\.php$#i', '', $path) ?? $path;
+ $path = $path === '' ? '/' : $path;
+ // /links_jp.php → /links.php
+ $path = preg_replace('#_(jp|zh)\.php$#i', '.php', $path) ?? $path;
+ // Block path traversal leftovers
+ if (str_contains($path, '..')) return '/';
+ return $path;
+}
+
+function traffic_page_key(string $host, string $path): string {
+ $host = strtolower(trim($host));
+ if (str_contains($host, ':')) {
+ $host = explode(':', $host, 2)[0];
+ }
+ return $host . traffic_normalize_path($path);
+}
+
+/**
+ * True when the User-Agent looks like a crawler, scraper, previewer, or monitor.
+ * Checked before browser matching so bots that pretend to be Chrome/etc. are not
+ * counted as normal visitors.
+ */
+function traffic_is_bot(string $ua): bool {
+ $ua = trim($ua);
+ if ($ua === '') {
+ // Empty UA is almost never a real browser hit from our JS beacon.
+ return true;
+ }
+ return (bool)preg_match(
+ '/(?:'
+ . 'bot|crawler|crawl|spider|slurp|fetcher|scraper|scrapy'
+ . '|bingpreview|facebookexternalhit|facebot|ia_archiver|twitterbot'
+ . '|linkedinbot|discordbot|telegrambot|slackbot|applebot'
+ . '|googlebot|adsbot|mediapartners-google|apis-google|feedfetcher'
+ . '|yandex|baiduspider|duckduckbot|semrush|ahrefs|mj12bot|dotbot'
+ . '|petalbot|bytespider|gptbot|chatgpt|claudebot|anthropic|ccbot'
+ . '|amazonbot|meta-externalagent|omgilibot|imagesiftbot'
+ . '|wget|curl|python-requests|python-urllib|httpx|libwww|httpclient'
+ . '|go-http-client|java\/|okhttp|php\/|node-fetch|axios\/'
+ . '|headlesschrome|phantomjs|selenium|puppeteer|playwright'
+ . '|uptimerobot|pingdom|statuscake|site24x7|check_http'
+ . '|pagespeed|gtmetrix'
+ . ')/i',
+ $ua
+ );
+}
+
+/** True if a stored visitor label is a bot (new + legacy rows). */
+function traffic_visitor_is_bot(string $visitor, ?int $isBotFlag = null): bool {
+ if ($isBotFlag !== null) {
+ return $isBotFlag === 1;
+ }
+ $v = strtolower(trim($visitor));
+ if ($v === '' || $v === 'unknown visitor') {
+ return false;
+ }
+ return $v === 'bot' || str_starts_with($v, 'bot ·') || str_starts_with($v, 'bot ');
+}
+
+/**
+ * Coarse browser · OS label from User-Agent. Intentionally low-resolution —
+ * not a fingerprint, not an IP, and not the raw UA string.
+ * Bots are labeled separately (never as a normal browser · OS pair).
+ */
+function traffic_visitor_label(string $ua): string {
+ $ua = trim($ua);
+ if ($ua === '') return 'Bot · empty-ua';
+
+ // Bots first — do not classify them as Chrome/Firefox/etc.
+ if (traffic_is_bot($ua)) {
+ if (preg_match('/googlebot|adsbot-google|mediapartners-google/i', $ua)) return 'Bot · Google';
+ if (preg_match('/bingbot|bingpreview|msnbot/i', $ua)) return 'Bot · Bing';
+ if (preg_match('/yandex/i', $ua)) return 'Bot · Yandex';
+ if (preg_match('/baiduspider/i', $ua)) return 'Bot · Baidu';
+ if (preg_match('/duckduckbot/i', $ua)) return 'Bot · DuckDuckGo';
+ if (preg_match('/applebot/i', $ua)) return 'Bot · Apple';
+ if (preg_match('/facebookexternalhit|facebot|meta-externalagent/i', $ua)) return 'Bot · Meta';
+ if (preg_match('/twitterbot/i', $ua)) return 'Bot · Twitter';
+ if (preg_match('/discordbot/i', $ua)) return 'Bot · Discord';
+ if (preg_match('/slackbot/i', $ua)) return 'Bot · Slack';
+ if (preg_match('/telegrambot/i', $ua)) return 'Bot · Telegram';
+ if (preg_match('/linkedinbot/i', $ua)) return 'Bot · LinkedIn';
+ if (preg_match('/gptbot|chatgpt|claudebot|anthropic|ccbot/i', $ua)) return 'Bot · AI';
+ if (preg_match('/semrush|ahrefs|mj12bot|dotbot|petalbot|bytespider/i', $ua)) return 'Bot · SEO';
+ if (preg_match('/wget|curl|python-|httpx|libwww|go-http-client|java\/|okhttp|php\//i', $ua)) return 'Bot · script';
+ if (preg_match('/uptimerobot|pingdom|statuscake|site24x7|check_http|monitoring/i', $ua)) return 'Bot · monitor';
+ return 'Bot';
+ }
+
+ $browser = 'Browser';
+ if (preg_match('/Edg(?:e|A|iOS)?\//i', $ua)) $browser = 'Edge';
+ elseif (preg_match('/OPR\/|Opera/i', $ua)) $browser = 'Opera';
+ elseif (preg_match('/SamsungBrowser\//i', $ua)) $browser = 'Samsung';
+ elseif (preg_match('/Firefox\//i', $ua)) $browser = 'Firefox';
+ elseif (preg_match('/CriOS\//i', $ua)) $browser = 'Chrome';
+ elseif (preg_match('/Chrome\//i', $ua) && !preg_match('/Chromium/i', $ua)) $browser = 'Chrome';
+ elseif (preg_match('/Chromium\//i', $ua)) $browser = 'Chromium';
+ elseif (preg_match('/Safari\//i', $ua) && preg_match('/Version\//i', $ua)) $browser = 'Safari';
+ elseif (preg_match('/MSIE |Trident\//i', $ua)) $browser = 'IE';
+
+ $os = 'Unknown OS';
+ if (preg_match('/Android/i', $ua)) $os = 'Android';
+ elseif (preg_match('/iPhone|iPad|iPod/i', $ua)) $os = 'iOS';
+ elseif (preg_match('/Windows NT/i', $ua)) $os = 'Windows';
+ elseif (preg_match('/Mac OS X|Macintosh/i', $ua)) $os = 'macOS';
+ elseif (preg_match('/CrOS/i', $ua)) $os = 'ChromeOS';
+ elseif (preg_match('/Linux/i', $ua)) $os = 'Linux';
+ elseif (preg_match('/FreeBSD/i', $ua)) $os = 'BSD';
+
+ return $browser . ' · ' . $os;
+}
+
+function traffic_lang(string $acceptLang): string {
+ $acceptLang = trim($acceptLang);
+ if ($acceptLang === '') return '';
+ // Primary tag only: "en-CA,en;q=0.9" → "en"
+ if (preg_match('/^([a-zA-Z]{2,3})(?:[-_][a-zA-Z0-9]+)?/', $acceptLang, $m)) {
+ return strtolower($m[1]);
+ }
+ return '';
+}
+
+function traffic_ref_host(string $ref): string {
+ $ref = trim($ref);
+ if ($ref === '') return '';
+ $host = parse_url($ref, PHP_URL_HOST);
+ if (!is_string($host) || $host === '') return '';
+ $host = strtolower($host);
+ // Don't store long junk
+ return strlen($host) > 200 ? '' : $host;
+}
+
+function traffic_db(): PDO {
+ static $pdo = null;
+ if ($pdo instanceof PDO) return $pdo;
+
+ $dir = dirname(TRAFFIC_DB);
+ if (!is_dir($dir)) {
+ @mkdir($dir, 02770, true);
+ }
+
+ $pdo = new PDO('sqlite:' . TRAFFIC_DB, null, null, [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ ]);
+ $pdo->exec('PRAGMA journal_mode=WAL');
+ $pdo->exec('PRAGMA synchronous=NORMAL');
+ $pdo->exec('PRAGMA busy_timeout=3000');
+
+ $pdo->exec(<<<'SQL'
+CREATE TABLE IF NOT EXISTS hits (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ts INTEGER NOT NULL,
+ host TEXT NOT NULL,
+ path TEXT NOT NULL,
+ page_key TEXT NOT NULL,
+ visitor TEXT NOT NULL DEFAULT '',
+ lang TEXT NOT NULL DEFAULT '',
+ ref_host TEXT NOT NULL DEFAULT '',
+ local_count INTEGER NOT NULL DEFAULT 0,
+ is_new INTEGER NOT NULL DEFAULT 0,
+ is_bot INTEGER NOT NULL DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS idx_hits_ts ON hits(ts);
+CREATE INDEX IF NOT EXISTS idx_hits_page ON hits(page_key);
+CREATE INDEX IF NOT EXISTS idx_hits_host ON hits(host);
+
+CREATE TABLE IF NOT EXISTS pages (
+ page_key TEXT PRIMARY KEY,
+ host TEXT NOT NULL,
+ path TEXT NOT NULL,
+ hits INTEGER NOT NULL DEFAULT 0,
+ unique_sessions INTEGER NOT NULL DEFAULT 0,
+ bot_hits INTEGER NOT NULL DEFAULT 0,
+ last_hit INTEGER NOT NULL DEFAULT 0,
+ last_visitor TEXT NOT NULL DEFAULT '',
+ last_local_count INTEGER NOT NULL DEFAULT 0
+);
+SQL);
+
+ // Migrations for DBs created before bot separation.
+ $hitCols = $pdo->query('PRAGMA table_info(hits)')->fetchAll();
+ $hitColNames = array_column($hitCols, 'name');
+ if (!in_array('is_bot', $hitColNames, true)) {
+ $pdo->exec('ALTER TABLE hits ADD COLUMN is_bot INTEGER NOT NULL DEFAULT 0');
+ // Backfill: legacy "Bot" labels (and "Bot · …" if any) are bots.
+ $pdo->exec(
+ "UPDATE hits SET is_bot = 1
+ WHERE lower(trim(visitor)) = 'bot'
+ OR lower(visitor) LIKE 'bot ·%'
+ OR lower(visitor) LIKE 'bot %'"
+ );
+ $pdo->exec('CREATE INDEX IF NOT EXISTS idx_hits_is_bot ON hits(is_bot)');
+ }
+
+ $pageCols = $pdo->query('PRAGMA table_info(pages)')->fetchAll();
+ $pageColNames = array_column($pageCols, 'name');
+ if (!in_array('bot_hits', $pageColNames, true)) {
+ $pdo->exec('ALTER TABLE pages ADD COLUMN bot_hits INTEGER NOT NULL DEFAULT 0');
+ // Move retained bot hits out of human page totals where we still have log rows.
+ $pdo->exec(
+ "UPDATE pages SET
+ bot_hits = COALESCE((
+ SELECT COUNT(*) FROM hits h
+ WHERE h.page_key = pages.page_key AND h.is_bot = 1
+ ), 0),
+ hits = MAX(0, hits - COALESCE((
+ SELECT COUNT(*) FROM hits h
+ WHERE h.page_key = pages.page_key AND h.is_bot = 1
+ ), 0)),
+ unique_sessions = MAX(0, unique_sessions - COALESCE((
+ SELECT SUM(CASE WHEN h.is_new = 1 THEN 1 ELSE 0 END) FROM hits h
+ WHERE h.page_key = pages.page_key AND h.is_bot = 1
+ ), 0))"
+ );
+ // Pages that only had bots (or were missing) still need a pages row for bot totals.
+ $pdo->exec(
+ "INSERT OR IGNORE INTO pages (page_key, host, path, hits, unique_sessions, bot_hits, last_hit, last_visitor, last_local_count)
+ SELECT h.page_key, h.host, h.path, 0, 0, COUNT(*), MAX(h.ts), 'Bot', 0
+ FROM hits h
+ WHERE h.is_bot = 1
+ AND NOT EXISTS (SELECT 1 FROM pages p WHERE p.page_key = h.page_key)
+ GROUP BY h.page_key, h.host, h.path"
+ );
+ }
+
+ return $pdo;
+}
+
+/**
+ * @param array{host:string,path:string,visitor:string,lang:string,ref_host:string,local_count:int,is_new:int,is_bot?:int|bool} $row
+ * @return array{page_key:string,hits:int,unique_sessions:int,bot_hits:int,is_bot:int}
+ */
+function traffic_record_hit(array $row): array {
+ $host = strtolower(trim($row['host']));
+ if (str_contains($host, ':')) {
+ $host = explode(':', $host, 2)[0];
+ }
+ $path = traffic_normalize_path($row['path']);
+ $pageKey = $host . $path;
+ $now = time();
+ $visitor = mb_substr((string)$row['visitor'], 0, 80);
+ $lang = mb_substr((string)$row['lang'], 0, 16);
+ $refHost = mb_substr((string)$row['ref_host'], 0, 200);
+ $local = (int)$row['local_count'];
+ $isNew = !empty($row['is_new']) ? 1 : 0;
+ // Explicit flag wins; otherwise infer from visitor label.
+ if (array_key_exists('is_bot', $row)) {
+ $isBot = !empty($row['is_bot']) ? 1 : 0;
+ } else {
+ $isBot = traffic_visitor_is_bot($visitor) ? 1 : 0;
+ }
+ // Never treat bots as human uniques.
+ if ($isBot) {
+ $isNew = 0;
+ }
+
+ $db = traffic_db();
+ $db->beginTransaction();
+ try {
+ $ins = $db->prepare(
+ 'INSERT INTO hits (ts, host, path, page_key, visitor, lang, ref_host, local_count, is_new, is_bot)
+ VALUES (:ts, :host, :path, :pk, :vis, :lang, :ref, :lc, :new, :bot)'
+ );
+ $ins->execute([
+ ':ts' => $now,
+ ':host' => $host,
+ ':path' => $path,
+ ':pk' => $pageKey,
+ ':vis' => $visitor,
+ ':lang' => $lang,
+ ':ref' => $refHost,
+ ':lc' => $local,
+ ':new' => $isNew,
+ ':bot' => $isBot,
+ ]);
+
+ if ($isBot) {
+ // Bots: log the hit, bump bot_hits only — never human hits / uniques.
+ $up = $db->prepare(
+ 'INSERT INTO pages (page_key, host, path, hits, unique_sessions, bot_hits, last_hit, last_visitor, last_local_count)
+ VALUES (:pk, :host, :path, 0, 0, 1, :ts, :vis, :lc)
+ ON CONFLICT(page_key) DO UPDATE SET
+ bot_hits = bot_hits + 1,
+ last_hit = :ts,
+ last_visitor = :vis,
+ last_local_count = :lc'
+ );
+ $up->execute([
+ ':pk' => $pageKey,
+ ':host' => $host,
+ ':path' => $path,
+ ':ts' => $now,
+ ':vis' => $visitor,
+ ':lc' => $local,
+ ]);
+ } else {
+ $up = $db->prepare(
+ 'INSERT INTO pages (page_key, host, path, hits, unique_sessions, bot_hits, last_hit, last_visitor, last_local_count)
+ VALUES (:pk, :host, :path, 1, :uniq, 0, :ts, :vis, :lc)
+ ON CONFLICT(page_key) DO UPDATE SET
+ hits = hits + 1,
+ unique_sessions = unique_sessions + :uniq,
+ last_hit = :ts,
+ last_visitor = :vis,
+ last_local_count = :lc'
+ );
+ $up->execute([
+ ':pk' => $pageKey,
+ ':host' => $host,
+ ':path' => $path,
+ ':uniq' => $isNew,
+ ':ts' => $now,
+ ':vis' => $visitor,
+ ':lc' => $local,
+ ]);
+ }
+
+ // Occasional prune of old hit rows (keep page totals).
+ if (random_int(1, 50) === 1) {
+ $cutoff = $now - (TRAFFIC_HIT_RETENTION_DAYS * 86400);
+ $db->exec('DELETE FROM hits WHERE ts < ' . (int)$cutoff);
+ // Cap absolute row count
+ $cnt = (int)$db->query('SELECT COUNT(*) FROM hits')->fetchColumn();
+ if ($cnt > TRAFFIC_MAX_RECENT) {
+ $drop = $cnt - TRAFFIC_MAX_RECENT;
+ $db->exec('DELETE FROM hits WHERE id IN (SELECT id FROM hits ORDER BY id ASC LIMIT ' . (int)$drop . ')');
+ }
+ }
+
+ $stats = $db->prepare('SELECT hits, unique_sessions, bot_hits FROM pages WHERE page_key = :pk');
+ $stats->execute([':pk' => $pageKey]);
+ $s = $stats->fetch() ?: ['hits' => $isBot ? 0 : 1, 'unique_sessions' => $isBot ? 0 : $isNew, 'bot_hits' => $isBot ? 1 : 0];
+
+ $db->commit();
+ return [
+ 'page_key' => $pageKey,
+ 'hits' => (int)$s['hits'],
+ 'unique_sessions' => (int)$s['unique_sessions'],
+ 'bot_hits' => (int)($s['bot_hits'] ?? 0),
+ 'is_bot' => $isBot,
+ ];
+ } catch (Throwable $e) {
+ if ($db->inTransaction()) $db->rollBack();
+ throw $e;
+ }
+}
+
+/** SQL fragment: human (non-bot) hits — works with is_bot column after migration. */
+function traffic_human_where(string $alias = ''): string {
+ $p = $alias !== '' ? $alias . '.' : '';
+ return "({$p}is_bot = 0)";
+}
+
+/**
+ * @return array{total_hits:int,total_pages:int,today:int,week:int,unique_sessions:int,bot_hits:int,bot_today:int,bot_week:int}
+ */
+function traffic_summary(): array {
+ $db = traffic_db();
+ $now = time();
+ $day = $now - 86400;
+ $week = $now - 7 * 86400;
+ $human = traffic_human_where();
+ return [
+ // Human-only page totals (bots never increment pages.hits).
+ 'total_hits' => (int)$db->query('SELECT COALESCE(SUM(hits),0) FROM pages')->fetchColumn(),
+ 'total_pages' => (int)$db->query('SELECT COUNT(*) FROM pages')->fetchColumn(),
+ 'today' => (int)$db->query("SELECT COUNT(*) FROM hits WHERE ts >= {$day} AND {$human}")->fetchColumn(),
+ 'week' => (int)$db->query("SELECT COUNT(*) FROM hits WHERE ts >= {$week} AND {$human}")->fetchColumn(),
+ 'unique_sessions' => (int)$db->query('SELECT COALESCE(SUM(unique_sessions),0) FROM pages')->fetchColumn(),
+ 'bot_hits' => (int)$db->query('SELECT COALESCE(SUM(bot_hits),0) FROM pages')->fetchColumn(),
+ 'bot_today' => (int)$db->query("SELECT COUNT(*) FROM hits WHERE ts >= {$day} AND is_bot = 1")->fetchColumn(),
+ 'bot_week' => (int)$db->query("SELECT COUNT(*) FROM hits WHERE ts >= {$week} AND is_bot = 1")->fetchColumn(),
+ ];
+}
+
+/** @return list<array<string,mixed>> */
+function traffic_top_pages(int $limit = 50): array {
+ $limit = max(1, min(200, $limit));
+ $st = traffic_db()->query(
+ 'SELECT page_key, host, path, hits, unique_sessions, bot_hits, last_hit, last_visitor, last_local_count
+ FROM pages ORDER BY hits DESC LIMIT ' . $limit
+ );
+ return $st->fetchAll();
+}
+
+/** @return list<array<string,mixed>> */
+function traffic_recent(int $limit = 100): array {
+ $limit = max(1, min(500, $limit));
+ $st = traffic_db()->query(
+ 'SELECT id, ts, host, path, visitor, lang, ref_host, local_count, is_new, is_bot
+ FROM hits ORDER BY id DESC LIMIT ' . $limit
+ );
+ return $st->fetchAll();
+}
+
+function traffic_clear_recent(): int {
+ $db = traffic_db();
+ $n = (int)$db->query('SELECT COUNT(*) FROM hits')->fetchColumn();
+ $db->exec('DELETE FROM hits');
+ return $n;
+}
+
+function traffic_reset_all(): void {
+ $db = traffic_db();
+ $db->exec('DELETE FROM hits');
+ $db->exec('DELETE FROM pages');
+}
+
+/**
+ * Daily hit counts for the last $days calendar days (UTC), zero-filled.
+ * Humans and bots are separate series (bots never fold into hits/unique).
+ * @return list<array{day:string,hits:int,unique:int,bots:int}>
+ */
+function traffic_hits_by_day(int $days = 30): array {
+ $days = max(1, min(90, $days));
+ $db = traffic_db();
+ $now = time();
+ $startDay = gmdate('Y-m-d', $now - ($days - 1) * 86400);
+ $startTs = strtotime($startDay . ' 00:00:00 UTC') ?: ($now - ($days - 1) * 86400);
+
+ $st = $db->prepare(
+ 'SELECT strftime(\'%Y-%m-%d\', ts, \'unixepoch\') AS day,
+ SUM(CASE WHEN is_bot = 0 THEN 1 ELSE 0 END) AS hits,
+ SUM(CASE WHEN is_bot = 0 AND is_new = 1 THEN 1 ELSE 0 END) AS uniq,
+ SUM(CASE WHEN is_bot = 1 THEN 1 ELSE 0 END) AS bots
+ FROM hits
+ WHERE ts >= :start
+ GROUP BY day
+ ORDER BY day ASC'
+ );
+ $st->execute([':start' => $startTs]);
+ $map = [];
+ foreach ($st->fetchAll() as $row) {
+ $map[$row['day']] = [
+ 'hits' => (int)$row['hits'],
+ 'unique' => (int)$row['uniq'],
+ 'bots' => (int)$row['bots'],
+ ];
+ }
+
+ $out = [];
+ for ($i = 0; $i < $days; $i++) {
+ $day = gmdate('Y-m-d', $startTs + $i * 86400);
+ $out[] = [
+ 'day' => $day,
+ 'hits' => $map[$day]['hits'] ?? 0,
+ 'unique' => $map[$day]['unique'] ?? 0,
+ 'bots' => $map[$day]['bots'] ?? 0,
+ ];
+ }
+ return $out;
+}
+
+/**
+ * Hourly hit counts for the last 24 hours (UTC), zero-filled.
+ * @return list<array{hour:string,hits:int,unique:int,bots:int}>
+ */
+function traffic_hits_by_hour(int $hours = 24): array {
+ $hours = max(1, min(48, $hours));
+ $db = traffic_db();
+ $now = time();
+ // Align to hour start
+ $endHour = intdiv($now, 3600) * 3600;
+ $startTs = $endHour - ($hours - 1) * 3600;
+
+ $st = $db->prepare(
+ 'SELECT strftime(\'%Y-%m-%d %H:00\', ts, \'unixepoch\') AS hour,
+ SUM(CASE WHEN is_bot = 0 THEN 1 ELSE 0 END) AS hits,
+ SUM(CASE WHEN is_bot = 0 AND is_new = 1 THEN 1 ELSE 0 END) AS uniq,
+ SUM(CASE WHEN is_bot = 1 THEN 1 ELSE 0 END) AS bots
+ FROM hits
+ WHERE ts >= :start
+ GROUP BY hour
+ ORDER BY hour ASC'
+ );
+ $st->execute([':start' => $startTs]);
+ $map = [];
+ foreach ($st->fetchAll() as $row) {
+ $map[$row['hour']] = [
+ 'hits' => (int)$row['hits'],
+ 'unique' => (int)$row['uniq'],
+ 'bots' => (int)$row['bots'],
+ ];
+ }
+
+ $out = [];
+ for ($i = 0; $i < $hours; $i++) {
+ $t = $startTs + $i * 3600;
+ $label = gmdate('Y-m-d H:00', $t);
+ $out[] = [
+ 'hour' => $label,
+ 'hits' => $map[$label]['hits'] ?? 0,
+ 'unique' => $map[$label]['unique'] ?? 0,
+ 'bots' => $map[$label]['bots'] ?? 0,
+ ];
+ }
+ return $out;
+}
+
+/**
+ * Grouped counts from hits table.
+ * @param bool $humansOnly When true (default), exclude bot hits from the breakdown.
+ * @return list<array{label:string,count:int}>
+ */
+function traffic_breakdown(string $column, int $limit = 12, int $sinceTs = 0, bool $humansOnly = true): array {
+ $allowed = ['visitor' => 'visitor', 'host' => 'host', 'lang' => 'lang', 'ref_host' => 'ref_host'];
+ if (!isset($allowed[$column])) return [];
+ $col = $allowed[$column];
+ $limit = max(1, min(50, $limit));
+ $db = traffic_db();
+
+ $conds = [];
+ if ($sinceTs > 0) $conds[] = 'ts >= ' . (int)$sinceTs;
+ if ($humansOnly) $conds[] = 'is_bot = 0';
+ $where = $conds ? ('WHERE ' . implode(' AND ', $conds)) : '';
+ // Empty labels → "(direct)" / "(unknown)" for display later
+ $sql = "SELECT CASE WHEN TRIM({$col}) = '' THEN '' ELSE {$col} END AS label,
+ COUNT(*) AS cnt
+ FROM hits {$where}
+ GROUP BY label
+ ORDER BY cnt DESC
+ LIMIT {$limit}";
+ $rows = $db->query($sql)->fetchAll();
+ $out = [];
+ foreach ($rows as $r) {
+ $label = (string)$r['label'];
+ if ($label === '') {
+ $label = $col === 'ref_host' ? '(direct / none)' : ($col === 'lang' ? '(unknown)' : '(unknown)');
+ }
+ $out[] = ['label' => $label, 'count' => (int)$r['cnt']];
+ }
+ return $out;
+}
+
+/**
+ * Bot visitor breakdown (for admin bot chart / list).
+ * @return list<array{label:string,count:int}>
+ */
+function traffic_bot_breakdown(int $limit = 10, int $sinceTs = 0): array {
+ $limit = max(1, min(50, $limit));
+ $db = traffic_db();
+ $conds = ['is_bot = 1'];
+ if ($sinceTs > 0) $conds[] = 'ts >= ' . (int)$sinceTs;
+ $where = 'WHERE ' . implode(' AND ', $conds);
+ $sql = "SELECT CASE WHEN TRIM(visitor) = '' THEN 'Bot' ELSE visitor END AS label,
+ COUNT(*) AS cnt
+ FROM hits {$where}
+ GROUP BY label
+ ORDER BY cnt DESC
+ LIMIT {$limit}";
+ $rows = $db->query($sql)->fetchAll();
+ $out = [];
+ foreach ($rows as $r) {
+ $out[] = ['label' => (string)$r['label'], 'count' => (int)$r['cnt']];
+ }
+ return $out;
+}
+
+/**
+ * Bundle of series for the admin analytics charts.
+ * Human traffic only in main series; bots are a separate series/KPI.
+ * @return array<string,mixed>
+ */
+function traffic_analytics(): array {
+ $now = time();
+ $weekAgo = $now - 7 * 86400;
+ return [
+ 'generated_at' => $now,
+ 'daily' => traffic_hits_by_day(30),
+ 'hourly' => traffic_hits_by_hour(24),
+ 'visitors' => traffic_breakdown('visitor', 10, $weekAgo, true),
+ 'bots' => traffic_bot_breakdown(10, $weekAgo),
+ 'hosts' => traffic_breakdown('host', 10, $weekAgo, true),
+ 'langs' => traffic_breakdown('lang', 8, $weekAgo, true),
+ 'refs' => traffic_breakdown('ref_host', 10, $weekAgo, true),
+ 'top_pages' => array_map(static function ($p) {
+ return [
+ 'label' => $p['host'] . $p['path'],
+ 'count' => (int)$p['hits'],
+ 'unique' => (int)$p['unique_sessions'],
+ 'bots' => (int)($p['bot_hits'] ?? 0),
+ ];
+ }, traffic_top_pages(10)),
+ ];
+}
diff --git a/partials/vibe.php b/partials/vibe.php
new file mode 100644
index 0000000..84b148a
--- /dev/null
+++ b/partials/vibe.php
@@ -0,0 +1,73 @@
+<?php
+$vibe = file_exists(__DIR__ . '/../vibe.php')
+ ? require __DIR__ . '/../vibe.php'
+ : ['description' => '', 'url' => '', 'updated' => ''];
+
+$vibe_desc = htmlspecialchars($vibe['description'] ?? '', ENT_QUOTES, 'UTF-8');
+$vibe_url = $vibe['url'] ?? '';
+$safe_url = htmlspecialchars($vibe_url, ENT_QUOTES, 'UTF-8');
+
+function vibe_detect_type(string $url): string {
+ if (empty($url)) return 'none';
+ $path = strtolower(parse_url($url, PHP_URL_PATH) ?? '');
+ $ext = pathinfo($path, PATHINFO_EXTENSION);
+ if (in_array($ext, ['jpg','jpeg','png','gif','webp','bmp','svg','avif'])) return 'image';
+ if (in_array($ext, ['mp4','webm','ogv','mov'])) return 'video';
+ if (in_array($ext, ['mp3','flac','wav','ogg','aac','opus','m4a'])) return 'audio';
+ if (preg_match('/(?:youtube\.com\/watch\?.*v=|youtu\.be\/)([A-Za-z0-9_-]{11})/', $url, $m)) return 'youtube:' . $m[1];
+ if (preg_match('/vimeo\.com\/(\d+)/', $url, $m)) return 'vimeo:' . $m[1];
+ return 'link';
+}
+
+$type = vibe_detect_type($vibe_url);
+?>
+
+<section aria-labelledby="vibe-title">
+ <h2 id="vibe-title">My Current Vibe</h2>
+
+ <?php if ($vibe_desc): ?>
+ <p><?= $vibe_desc ?></p>
+ <?php endif; ?>
+
+ <?php if ($vibe_url): ?>
+
+ <?php if ($type === 'image'): ?>
+ <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p>
+ <img src="<?= $safe_url ?>" alt="Current vibe" style="max-width:100%;height:auto;" loading="lazy">
+
+ <?php elseif ($type === 'video'): ?>
+ <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p>
+ <video controls style="max-width:100%;" preload="none">
+ <source src="<?= $safe_url ?>">
+ </video>
+
+ <?php elseif ($type === 'audio'): ?>
+ <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p>
+ <audio controls preload="none" style="width:100%;">
+ <source src="<?= $safe_url ?>">
+ </audio>
+
+ <?php elseif (str_starts_with($type, 'youtube:')): ?>
+ <?php $yt_id = substr($type, 8); ?>
+ <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p>
+ <div style="position:relative;padding-top:56.25%;overflow:hidden;">
+ <iframe src="https://www.youtube.com/embed/<?= htmlspecialchars($yt_id) ?>"
+ style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;"
+ allowfullscreen loading="lazy"></iframe>
+ </div>
+
+ <?php elseif (str_starts_with($type, 'vimeo:')): ?>
+ <?php $vimeo_id = substr($type, 6); ?>
+ <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p>
+ <div style="position:relative;padding-top:56.25%;overflow:hidden;">
+ <iframe src="https://player.vimeo.com/video/<?= htmlspecialchars($vimeo_id) ?>"
+ style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;"
+ allowfullscreen loading="lazy"></iframe>
+ </div>
+
+ <?php else: ?>
+ <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p>
+ <?php endif; ?>
+
+ <?php endif; ?>
+</section>
diff --git a/partials/vibe_config.php b/partials/vibe_config.php
new file mode 100644
index 0000000..6a93013
--- /dev/null
+++ b/partials/vibe_config.php
@@ -0,0 +1,9 @@
+<?php
+// vibe.php — current vibe config, edited via admin/vibe.php
+// Do not edit manually unless you know what you're doing.
+
+return [
+ 'description' => 'A throwback to the golden age of internet content.',
+ 'url' => 'https://summer2.ytmnd.com/',
+ 'updated' => '2025-01-01',
+];