aboutsummaryrefslogtreecommitdiffstats
path: root/admin
diff options
context:
space:
mode:
authorsillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
committersillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
commit898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch)
tree85c6ee5ad58b860144551184d4cf86b560c62b91 /admin
downloadwww-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.tar.gz
www-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.zip
import live www.sillylaird.ca webrootHEADmain
Diffstat (limited to '')
-rw-r--r--admin.php3742
-rw-r--r--admin/admin_vibe.php1
-rw-r--r--admin/login.php117
-rw-r--r--admin/logout.php5
-rw-r--r--admin/vibe.php9
5 files changed, 3874 insertions, 0 deletions
diff --git a/admin.php b/admin.php
new file mode 100644
index 0000000..29f8917
--- /dev/null
+++ b/admin.php
@@ -0,0 +1,3742 @@
+<?php
+/**
+ * Unified admin panel for sillylaird.ca.
+ *
+ * One page, one login. Drives every content surface that lives on this server
+ * by direct filesystem/DB access (php-fpm runs as www-data, which owns them):
+ * - Blog -> /mnt/slab/blog.sillylaird.ca/blog.sqlite (SQLite)
+ * - Changelog -> ./changelog/YYYY-changelog.txt (flat files)
+ * - Vibe -> ./vibe.php (PHP config)
+ * - Now -> ./now/now-data.php (PHP config)
+ * - StartPage -> ./startpage/cards.txt (flat file)
+ * - Journal -> ./journal/entries.php (PHP data)
+ * - Guestbook -> guestbook_db.messages (MySQL)
+ * - Collection -> ./gaming/collection/games.json (JSON)
+ * - MOTD -> ./motd/motd.json (JSON, waifu-bot)
+ * - Traffic -> /var/lib/sillylaird/traffic.sqlite (no IPs)
+ *
+ * Auth is the shared www session ($_SESSION['admin'], set by /admin/login.php).
+ * All state changes are POST + CSRF, then redirect (PRG) with a flash message.
+ */
+
+require_once __DIR__ . '/partials/session.php';
+
+if (empty($_SESSION['admin'])) {
+ header('Location: /admin/login.php?ref=' . urlencode($_SERVER['REQUEST_URI'] ?? '/admin.php'));
+ exit;
+}
+if (empty($_SESSION['csrf'])) {
+ $_SESSION['csrf'] = bin2hex(random_bytes(32));
+}
+$csrf = $_SESSION['csrf'];
+
+const BLOG_DB = '/mnt/slab/blog.sillylaird.ca/blog.sqlite';
+const CHANGELOG_DIR = __DIR__ . '/changelog';
+const VIBE_FILE = __DIR__ . '/vibe.php';
+const NOW_FILE = __DIR__ . '/now/now-data.php';
+const GUESTBOOK_CONFIG = '/mnt/slab/guestbook.sillylaird.ca/config.php';
+const JOURNAL_ENTRIES = __DIR__ . '/journal/entries.php';
+const STARTPAGE_CARDS = __DIR__ . '/startpage/cards.txt';
+const COLLECTION_FILE = __DIR__ . '/gaming/collection/games.json';
+const COLLECTION_IMAGES_DIR = __DIR__ . '/gaming/collection/images';
+const COLLECTION_STATUSES = ['Owned', 'Playing', 'Completed', 'Wishlist', 'Sold', 'Digital'];
+const RADIO_STATIONS = '/mnt/slab/radio.sillylaird.ca/stations.json';
+const RADIO_META_TYPES = ['icecast', 'radio', 'plaza', 'gensokyo', 'somafm'];
+const RUNESCAPE_FILE = __DIR__ . '/gaming/runescape/data.json';
+const RUNESCAPE_IMAGES_DIR = __DIR__ . '/gaming/runescape/images';
+const MOTD_FILE = __DIR__ . '/motd/motd.json';
+const MOTD_PAGE_FILE = __DIR__ . '/motd/motd-data.php';
+const MOTD_HISTORY_MAX = 20;
+const MOTD_TITLE_MAX = 80;
+const MOTD_HEADING_MAX = 80;
+const MOTD_EMOJI_MAX = 16;
+const MOTD_URL_MAX = 300;
+const MOTD_BODY_MAX = 2000;
+const MOTD_DEFAULT_URL = 'https://www.sillylaird.ca/motd/';
+const MOTD_DEFAULT_HEADING = 'MOTD';
+
+function h($s): string { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
+
+function check_csrf(): void {
+ global $csrf;
+ if (!isset($_POST['csrf']) || !is_string($_POST['csrf']) || !hash_equals($csrf, $_POST['csrf'])) {
+ http_response_code(403);
+ exit('Invalid CSRF token. Go back, reload, and try again.');
+ }
+}
+
+function flash_set(string $type, string $msg): void { $_SESSION['admin_flash'] = ['type' => $type, 'msg' => $msg]; }
+function flash_get(): ?array { $f = $_SESSION['admin_flash'] ?? null; unset($_SESSION['admin_flash']); return $f; }
+function redirect_section(string $section): void { header('Location: /admin.php?section=' . urlencode($section)); exit; }
+
+function valid_url_or_path(string $url): bool {
+ if ($url === '') return false;
+ if ($url[0] === '/') return !str_starts_with($url, '//') && !str_contains($url, "\0");
+ $scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? '');
+ if (in_array($scheme, ['http', 'https', 'gemini', 'gopher'], true)) return filter_var($url, FILTER_VALIDATE_URL) !== false;
+ return false;
+}
+
+function valid_asset_ref(string $ref): bool {
+ if ($ref === '' || str_contains($ref, "\0")) return false;
+ if (valid_url_or_path($ref)) return true;
+ return !str_contains($ref, '..') && preg_match('/^[A-Za-z0-9][A-Za-z0-9._\/-]*$/', $ref);
+}
+
+/** @return array<int,array{title:string,image:string,alt:string,links:array<int,array{label:string,url:string}>}> */
+function parse_startpage_cards(string $raw): array {
+ $cards = [];
+ $cur = null;
+ foreach (explode("\n", str_replace("\r\n", "\n", $raw)) as $line) {
+ $line = trim($line);
+ if ($line === '') continue;
+ if ($line[0] === '#') {
+ if ($cur !== null) $cards[] = $cur;
+ $parts = array_map('trim', explode('|', ltrim($line, '#'), 3));
+ $title = $parts[0] ?? '';
+ $image = $parts[1] ?? '';
+ $alt = $parts[2] ?? 'Visual';
+ if ($title === '' || !valid_asset_ref($image)) {
+ throw new RuntimeException('Card headers must be "# Title | image | alt".');
+ }
+ $cur = ['title' => $title, 'image' => $image, 'alt' => $alt !== '' ? $alt : 'Visual', 'links' => []];
+ continue;
+ }
+ if ($cur === null) throw new RuntimeException('Start each card with "# Title | image | alt".');
+ $parts = array_map('trim', explode('|', $line, 2));
+ $label = $parts[0] ?? '';
+ $url = $parts[1] ?? '';
+ if ($label === '' || !valid_url_or_path($url)) {
+ throw new RuntimeException('Link rows must be "Label | URL".');
+ }
+ $cur['links'][] = ['label' => $label, 'url' => $url];
+ }
+ if ($cur !== null) $cards[] = $cur;
+ foreach ($cards as $card) {
+ if ($card['links'] === []) throw new RuntimeException('Every StartPage card needs at least one link.');
+ }
+ return $cards;
+}
+
+/** @param array<int,array{title:string,image:string,alt:string,links:array<int,array{label:string,url:string}>}> $cards */
+function format_startpage_cards(array $cards): string {
+ $out = '';
+ foreach ($cards as $card) {
+ $out .= '# ' . $card['title'] . ' | ' . $card['image'] . ' | ' . $card['alt'] . "\n";
+ foreach ($card['links'] as $link) {
+ $out .= $link['label'] . ' | ' . $link['url'] . "\n";
+ }
+ $out .= "\n";
+ }
+ return $out;
+}
+
+function startpage_cards_text(): string {
+ $raw = is_file(STARTPAGE_CARDS) ? file_get_contents(STARTPAGE_CARDS) : '';
+ return is_string($raw) ? $raw : '';
+}
+
+/** @return array<int,array{date:string,body:string}> */
+function journal_entries(): array {
+ $entries = is_file(JOURNAL_ENTRIES) ? require JOURNAL_ENTRIES : [];
+ if (!is_array($entries)) return [];
+ $out = [];
+ foreach ($entries as $entry) {
+ $date = (string)($entry['date'] ?? '');
+ $body = (string)($entry['body'] ?? '');
+ if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) && trim($body) !== '') {
+ $out[] = ['date' => $date, 'body' => $body];
+ }
+ }
+ return $out;
+}
+
+/** @param array<int,array{date:string,body:string}> $entries */
+function save_journal_entries(array $entries): bool {
+ usort($entries, fn($a, $b) => strcmp($b['date'], $a['date']));
+ $php = "<?php\n// Journal entries edited via /admin.php?section=journal.\n"
+ . "if (realpath(__FILE__) === realpath(\$_SERVER['SCRIPT_FILENAME'] ?? '')) { http_response_code(404); exit; }\n\n"
+ . "return " . var_export(array_values($entries), true) . ";\n";
+ return file_put_contents(JOURNAL_ENTRIES, $php, LOCK_EX) !== false;
+}
+
+function collection_slug(string $text, string $fallback = 'item'): string {
+ $slug = strtolower(trim($text));
+ $slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
+ $slug = trim($slug, '-');
+ return $slug !== '' ? $slug : $fallback;
+}
+
+function collection_unique_id(string $base, array $used): string {
+ $id = $base !== '' ? $base : 'item';
+ if (!isset($used[$id])) return $id;
+ $n = 2;
+ while (isset($used[$id . '-' . $n])) $n++;
+ return $id . '-' . $n;
+}
+
+/**
+ * Normalize inventory items (consoles / hardware).
+ * @param mixed $list
+ * @return list<array{id:string,name:string,model:string,status:string,notes:string,image:string}>
+ */
+function collection_normalize_inventory($list): array {
+ $out = [];
+ if (!is_array($list)) return $out;
+ foreach ($list as $item) {
+ if (!is_array($item)) continue;
+ $name = trim((string)($item['name'] ?? ''));
+ if ($name === '') continue;
+ $id = trim((string)($item['id'] ?? ''));
+ if ($id === '') $id = collection_slug($name, 'item');
+ $status = trim((string)($item['status'] ?? 'Owned'));
+ if (!in_array($status, COLLECTION_STATUSES, true)) $status = 'Owned';
+ $image = trim((string)($item['image'] ?? ''));
+ if ($image !== '' && !valid_asset_ref($image)) $image = '';
+ $model = trim((string)($item['model'] ?? ''));
+ if ($model === '') $model = $name;
+ $out[] = [
+ 'id' => $id,
+ 'name' => $name,
+ 'model' => $model,
+ 'status' => $status,
+ 'notes' => (string)($item['notes'] ?? ''),
+ 'image' => $image,
+ ];
+ }
+ return $out;
+}
+
+/**
+ * @return array{
+ * consoles: array<int, array{id:string,name:string,model:string,status:string,notes:string,image:string}>,
+ * hardware: array<int, array{id:string,name:string,model:string,status:string,notes:string,image:string}>,
+ * systems: array<int, array{id:string,name:string,games:array<int,array{id:string,title:string,status:string,notes:string,image:string}>}>,
+ * online: array<int, array{id:string,name:string,entries:array<int,array{id:string,title:string,url:string,status:string,notes:string,image:string}>}>
+ * }
+ */
+function collection_load(): array {
+ $empty = ['consoles' => [], 'hardware' => [], 'systems' => [], 'online' => []];
+ if (!is_file(COLLECTION_FILE)) {
+ return $empty;
+ }
+ $raw = file_get_contents(COLLECTION_FILE);
+ if ($raw === false || trim($raw) === '') {
+ return $empty;
+ }
+ $data = json_decode($raw, true);
+ if (!is_array($data)) {
+ return $empty;
+ }
+ $consoles = collection_normalize_inventory($data['consoles'] ?? $data['videogames'] ?? []);
+ $hardware = collection_normalize_inventory($data['hardware'] ?? []);
+ $systems = [];
+ foreach (($data['systems'] ?? []) as $sys) {
+ if (!is_array($sys)) continue;
+ $name = trim((string)($sys['name'] ?? ''));
+ $id = trim((string)($sys['id'] ?? ''));
+ if ($name === '') continue;
+ if ($id === '') $id = collection_slug($name, 'system');
+ $games = [];
+ foreach (($sys['games'] ?? []) as $game) {
+ if (!is_array($game)) continue;
+ $title = trim((string)($game['title'] ?? ''));
+ if ($title === '') continue;
+ $gid = trim((string)($game['id'] ?? ''));
+ if ($gid === '') $gid = collection_slug($title, 'game');
+ $status = trim((string)($game['status'] ?? 'Owned'));
+ if (!in_array($status, COLLECTION_STATUSES, true)) $status = 'Owned';
+ $image = trim((string)($game['image'] ?? ''));
+ if ($image !== '' && !valid_asset_ref($image)) $image = '';
+ $games[] = [
+ 'id' => $gid,
+ 'title' => $title,
+ 'status' => $status,
+ 'notes' => (string)($game['notes'] ?? ''),
+ 'image' => $image,
+ ];
+ }
+ $systems[] = ['id' => $id, 'name' => $name, 'games' => $games];
+ }
+ $online = [];
+ foreach (($data['online'] ?? []) as $cat) {
+ if (!is_array($cat)) continue;
+ $name = trim((string)($cat['name'] ?? ''));
+ $id = trim((string)($cat['id'] ?? ''));
+ if ($name === '') continue;
+ if ($id === '') $id = collection_slug($name, 'online');
+ $entries = [];
+ foreach (($cat['entries'] ?? []) as $entry) {
+ if (!is_array($entry)) continue;
+ $title = trim((string)($entry['title'] ?? ''));
+ if ($title === '') continue;
+ $eid = trim((string)($entry['id'] ?? ''));
+ if ($eid === '') $eid = collection_slug($title, 'entry');
+ $url = trim((string)($entry['url'] ?? ''));
+ if ($url !== '' && !valid_url_or_path($url)) $url = '';
+ $status = trim((string)($entry['status'] ?? 'Owned'));
+ if (!in_array($status, COLLECTION_STATUSES, true)) $status = 'Owned';
+ $image = trim((string)($entry['image'] ?? ''));
+ if ($image !== '' && !valid_asset_ref($image)) $image = '';
+ $entries[] = [
+ 'id' => $eid,
+ 'title' => $title,
+ 'url' => $url,
+ 'status' => $status,
+ 'notes' => (string)($entry['notes'] ?? ''),
+ 'image' => $image,
+ ];
+ }
+ $online[] = ['id' => $id, 'name' => $name, 'entries' => $entries];
+ }
+ return ['consoles' => $consoles, 'hardware' => $hardware, 'systems' => $systems, 'online' => $online];
+}
+
+/** @param array{consoles?: array<int, mixed>, hardware?: array<int, mixed>, systems?: array<int, mixed>, online?: array<int, mixed>} $data */
+/* ---------------------------------------------------------------------------
+ Radio — station list for radio.sillylaird.ca (stations.json).
+ The landing page, /relay and /meta on that host all read this same file.
+ --------------------------------------------------------------------------- */
+
+/** @return array<int,array<string,mixed>> */
+function radio_load(): array {
+ if (!is_file(RADIO_STATIONS)) return [];
+ $raw = file_get_contents(RADIO_STATIONS);
+ $data = is_string($raw) ? json_decode($raw, true) : null;
+ if (!is_array($data)) return [];
+ $out = [];
+ foreach (($data['stations'] ?? []) as $s) {
+ if (!is_array($s)) continue;
+ $name = trim((string)($s['name'] ?? ''));
+ $url = trim((string)($s['url'] ?? ''));
+ if ($name === '' || $url === '') continue;
+ $id = trim((string)($s['id'] ?? ''));
+ if ($id === '' || !preg_match('/^[A-Za-z0-9._-]{1,64}$/', $id)) $id = collection_slug($name, 'station');
+ $metaType = strtolower(trim((string)($s['meta']['type'] ?? '')));
+ $metaUrl = trim((string)($s['meta']['url'] ?? ''));
+ $row = [
+ 'id' => $id,
+ 'name' => $name,
+ 'url' => $url,
+ 'homepage' => trim((string)($s['homepage'] ?? '')),
+ 'genre' => trim((string)($s['genre'] ?? '')),
+ 'codec' => trim((string)($s['codec'] ?? '')),
+ 'relay' => !empty($s['relay']),
+ ];
+ if (in_array($metaType, RADIO_META_TYPES, true) && $metaUrl !== '') {
+ $row['meta'] = ['type' => $metaType, 'url' => $metaUrl];
+ }
+ $out[] = $row;
+ }
+ return $out;
+}
+
+/** @param array<int,array<string,mixed>> $stations */
+function radio_save(array $stations): bool {
+ $json = json_encode(['stations' => array_values($stations)], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ if ($json === false) return false;
+ return file_put_contents(RADIO_STATIONS, $json . "\n", LOCK_EX) !== false;
+}
+
+/** @return array{enabled:bool,title:string,body:string,updated:string,rev:int,push:int,announce_on_join:bool,announce_on_change:bool,history:array<int,array{title:string,body:string,updated:string,rev:int}>} */
+function motd_default(): array {
+ return [
+ 'enabled' => false,
+ 'title' => '',
+ 'emoji' => '',
+ 'heading' => MOTD_DEFAULT_HEADING,
+ 'url' => MOTD_DEFAULT_URL,
+ 'body' => '',
+ 'updated' => '',
+ 'rev' => 0,
+ 'push' => 0,
+ 'announce_on_join' => false,
+ 'announce_on_change' => false,
+ 'history' => [],
+ ];
+}
+
+/** @return array{enabled:bool,title:string,body:string,updated:string,rev:int,push:int,announce_on_join:bool,announce_on_change:bool,history:array<int,array{title:string,body:string,updated:string,rev:int}>} */
+function motd_load(): array {
+ $out = motd_default();
+ if (!is_file(MOTD_FILE)) return $out;
+ $raw = file_get_contents(MOTD_FILE);
+ $data = is_string($raw) ? json_decode($raw, true) : null;
+ if (!is_array($data)) return $out;
+ $out['enabled'] = !empty($data['enabled']);
+ $out['title'] = (string)($data['title'] ?? '');
+ $out['emoji'] = (string)($data['emoji'] ?? '');
+ $out['heading'] = array_key_exists('heading', $data) ? (string)$data['heading'] : MOTD_DEFAULT_HEADING;
+ $out['url'] = array_key_exists('url', $data) ? (string)$data['url'] : MOTD_DEFAULT_URL;
+ $out['body'] = (string)($data['body'] ?? '');
+ $out['updated'] = (string)($data['updated'] ?? '');
+ $out['rev'] = (int)($data['rev'] ?? 0);
+ $out['push'] = (int)($data['push'] ?? 0);
+ $out['announce_on_join'] = array_key_exists('announce_on_join', $data) ? !empty($data['announce_on_join']) : false;
+ $out['announce_on_change'] = array_key_exists('announce_on_change', $data) ? !empty($data['announce_on_change']) : false;
+ $history = [];
+ foreach (($data['history'] ?? []) as $row) {
+ if (!is_array($row)) continue;
+ $body = (string)($row['body'] ?? '');
+ if (trim($body) === '') continue;
+ $history[] = [
+ 'title' => (string)($row['title'] ?? ''),
+ 'body' => $body,
+ 'updated' => (string)($row['updated'] ?? ''),
+ 'rev' => (int)($row['rev'] ?? 0),
+ ];
+ }
+ $out['history'] = $history;
+ return $out;
+}
+
+function motd_save(array $data): bool {
+ $payload = [
+ 'enabled' => !empty($data['enabled']),
+ 'title' => (string)($data['title'] ?? ''),
+ 'emoji' => (string)($data['emoji'] ?? ''),
+ 'heading' => (string)($data['heading'] ?? MOTD_DEFAULT_HEADING),
+ 'url' => (string)($data['url'] ?? MOTD_DEFAULT_URL),
+ 'body' => (string)($data['body'] ?? ''),
+ 'updated' => (string)($data['updated'] ?? ''),
+ 'rev' => (int)($data['rev'] ?? 0),
+ 'push' => (int)($data['push'] ?? 0),
+ 'announce_on_join' => !empty($data['announce_on_join']),
+ 'announce_on_change' => !empty($data['announce_on_change']),
+ 'history' => array_values($data['history'] ?? []),
+ ];
+ $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ if ($json === false) return false;
+ $dir = dirname(MOTD_FILE);
+ if (!is_dir($dir) && !@mkdir($dir, 0775, true)) return false;
+ $tmp = MOTD_FILE . '.tmp';
+ if (file_put_contents($tmp, $json . "\n", LOCK_EX) === false) return false;
+ if (!rename($tmp, MOTD_FILE)) {
+ @unlink($tmp);
+ return false;
+ }
+ @chmod(MOTD_FILE, 0644);
+
+ $export = var_export([
+ 'title' => $payload['title'],
+ 'heading' => $payload['heading'],
+ 'body' => $payload['body'],
+ 'updated' => $payload['updated'],
+ 'rev' => $payload['rev'],
+ 'history' => $payload['history'],
+ ], true);
+ $php = "<?php\n// motd-data.php — /motd/ page content, edited via /admin.php\n"
+ . "if (realpath(__FILE__) === realpath(\$_SERVER['SCRIPT_FILENAME'] ?? '')) { http_response_code(404); exit; }\n\n"
+ . "return {$export};\n";
+ if (file_put_contents(MOTD_PAGE_FILE, $php, LOCK_EX) === false) return false;
+ @chmod(MOTD_PAGE_FILE, 0644);
+ return true;
+}
+
+function motd_xmpp_text(array $m): string {
+ $body = trim((string)($m['body'] ?? ''));
+ if ($body === '') return '';
+ $emoji = trim((string)($m['emoji'] ?? ''));
+ $heading = trim((string)($m['heading'] ?? ''));
+ $url = trim((string)($m['url'] ?? ''));
+ $head = trim($emoji . ($emoji !== '' && $heading !== '' ? ' ' : '') . $heading);
+ $lines = [];
+ if ($head !== '') $lines[] = $head;
+ $lines[] = $body;
+ if ($url !== '') $lines[] = $url;
+ return implode("\n", $lines);
+}
+
+/** @param array{enabled:bool,title:string,body:string,updated:string,rev:int,history:array} $data */
+function motd_archive_current(array $data): array {
+ $body = trim((string)($data['body'] ?? ''));
+ if ($body === '') return $data;
+ $entry = [
+ 'title' => (string)($data['title'] ?? ''),
+ 'body' => (string)($data['body'] ?? ''),
+ 'updated' => (string)($data['updated'] ?? ''),
+ 'rev' => (int)($data['rev'] ?? 0),
+ ];
+ $history = $data['history'] ?? [];
+ array_unshift($history, $entry);
+ $data['history'] = array_slice($history, 0, MOTD_HISTORY_MAX);
+ return $data;
+}
+
+function radio_stream_url_ok(string $url): bool {
+ if ($url === '') return false;
+ if ($url[0] === '/') return !str_starts_with($url, '//') && !str_contains($url, "\0");
+ $scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
+ return in_array($scheme, ['http', 'https'], true) && parse_url($url, PHP_URL_HOST) !== null;
+}
+
+function collection_save(array $data): bool {
+ $payload = [
+ 'consoles' => array_values($data['consoles'] ?? []),
+ 'hardware' => array_values($data['hardware'] ?? []),
+ 'systems' => array_values($data['systems'] ?? []),
+ 'online' => array_values($data['online'] ?? []),
+ ];
+ $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ if ($json === false) return false;
+ return file_put_contents(COLLECTION_FILE, $json . "\n", LOCK_EX) !== false;
+}
+
+/** Public URL for a collection left-side image (filename, site path, or absolute URL). */
+function collection_image_url(string $image): string {
+ $image = trim($image);
+ if ($image === '') return '';
+ if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://') || str_starts_with($image, '/')) {
+ return $image;
+ }
+ if (str_contains($image, '..') || str_contains($image, "\0")) return '';
+ return '/gaming/collection/images/' . rawurlencode(basename($image));
+}
+
+/**
+ * Handle multipart left-photo upload into gaming/collection/images/.
+ * @return array{ok:bool,filename:string,uploaded:bool,err:string}
+ */
+function collection_process_image_upload(string $field, string $slugBase, string $current): array {
+ if (!empty($_POST['image_clear'])) {
+ return ['ok' => true, 'filename' => '', 'uploaded' => false, 'err' => ''];
+ }
+ if (!isset($_FILES[$field]) || !is_array($_FILES[$field])) {
+ return ['ok' => true, 'filename' => $current, 'uploaded' => false, 'err' => ''];
+ }
+ $f = $_FILES[$field];
+ $err = (int)($f['error'] ?? UPLOAD_ERR_NO_FILE);
+ if ($err === UPLOAD_ERR_NO_FILE) {
+ return ['ok' => true, 'filename' => $current, 'uploaded' => false, 'err' => ''];
+ }
+ if ($err !== UPLOAD_ERR_OK) {
+ return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Image upload failed (code ' . $err . ').'];
+ }
+ if ((int)($f['size'] ?? 0) > 2 * 1024 * 1024) {
+ return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Image too large (max 2 MB).'];
+ }
+ $tmp = (string)($f['tmp_name'] ?? '');
+ if ($tmp === '' || !is_uploaded_file($tmp)) {
+ return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Invalid upload.'];
+ }
+ $mime = '';
+ if (class_exists('finfo')) {
+ $fi = new finfo(FILEINFO_MIME_TYPE);
+ $mime = (string)$fi->file($tmp);
+ } elseif (function_exists('mime_content_type')) {
+ $mime = (string)mime_content_type($tmp);
+ }
+ $map = [
+ 'image/jpeg' => 'jpg',
+ 'image/png' => 'png',
+ 'image/gif' => 'gif',
+ 'image/webp' => 'webp',
+ ];
+ if (!isset($map[$mime])) {
+ return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Only JPEG, PNG, GIF, or WebP allowed.'];
+ }
+ $base = collection_slug($slugBase !== '' ? $slugBase : 'photo', 'photo');
+ $name = $base . '-' . bin2hex(random_bytes(3)) . '.' . $map[$mime];
+ if (!is_dir(COLLECTION_IMAGES_DIR) && !@mkdir(COLLECTION_IMAGES_DIR, 0775, true)) {
+ return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Images folder missing and could not be created.'];
+ }
+ $dest = COLLECTION_IMAGES_DIR . '/' . $name;
+ if (!move_uploaded_file($tmp, $dest)) {
+ return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Could not save image (permissions on images/?).'];
+ }
+ @chmod($dest, 0644);
+ return ['ok' => true, 'filename' => $name, 'uploaded' => true, 'err' => ''];
+}
+
+/**
+ * Resolve image field from text input + optional file upload.
+ * @return array{ok:bool,image:string,err:string}
+ */
+function collection_resolve_image_field(string $slugBase): array {
+ $image = trim((string)($_POST['image'] ?? ''));
+ if ($image !== '' && !valid_asset_ref($image)) {
+ return ['ok' => false, 'image' => '', 'err' => 'Image must be a safe filename, site path, or http(s) URL.'];
+ }
+ $up = collection_process_image_upload('image_file', $slugBase, $image);
+ if (!$up['ok']) {
+ return ['ok' => false, 'image' => $image, 'err' => $up['err']];
+ }
+ if ($up['uploaded']) {
+ return ['ok' => true, 'image' => $up['filename'], 'err' => ''];
+ }
+ if (!empty($_POST['image_clear'])) {
+ return ['ok' => true, 'image' => '', 'err' => ''];
+ }
+ return ['ok' => true, 'image' => $image, 'err' => ''];
+}
+
+/**
+ * @return array{
+ * welcome: array<int,string>,
+ * screenshots: array<int, array{id:string,filename:string,caption:string}>,
+ * eras: array<int, array{id:string,title:string,subtitle:string,servers:array<int,array{id:string,name:string,url:string,featured:bool,desc:string,highlights:array<int,string>,links:array<int,array{label:string,url:string}>}>}>,
+ * links_section: array{title:string,disclaimer:string,items:array<int,array{label:string,url:string,desc:string}>}
+ * }
+ */
+function runescape_load(): array {
+ $empty = [
+ 'welcome' => [],
+ 'screenshots' => [],
+ 'eras' => [],
+ 'links_section' => ['title' => 'Server Lists', 'disclaimer' => '', 'items' => []],
+ ];
+ if (!is_file(RUNESCAPE_FILE)) return $empty;
+ $raw = file_get_contents(RUNESCAPE_FILE);
+ if ($raw === false || trim($raw) === '') return $empty;
+ $data = json_decode($raw, true);
+ if (!is_array($data)) return $empty;
+ $welcome = [];
+ foreach (($data['welcome'] ?? []) as $line) {
+ $line = trim((string)$line);
+ if ($line !== '') $welcome[] = $line;
+ }
+ $screenshots = [];
+ foreach (($data['screenshots'] ?? []) as $shot) {
+ if (!is_array($shot)) continue;
+ $filename = trim((string)($shot['filename'] ?? ''));
+ if ($filename === '' || str_contains($filename, '..') || str_contains($filename, '/')) continue;
+ $screenshots[] = [
+ 'id' => trim((string)($shot['id'] ?? '')) ?: $filename,
+ 'filename' => $filename,
+ 'caption' => (string)($shot['caption'] ?? ''),
+ ];
+ }
+ $eras = [];
+ foreach (($data['eras'] ?? []) as $era) {
+ if (!is_array($era)) continue;
+ $title = trim((string)($era['title'] ?? ''));
+ if ($title === '') continue;
+ $id = trim((string)($era['id'] ?? '')) ?: collection_slug($title, 'era');
+ $servers = [];
+ foreach (($era['servers'] ?? []) as $srv) {
+ if (!is_array($srv)) continue;
+ $name = trim((string)($srv['name'] ?? ''));
+ if ($name === '') continue;
+ $sid = trim((string)($srv['id'] ?? '')) ?: collection_slug($name, 'server');
+ $url = trim((string)($srv['url'] ?? ''));
+ if ($url !== '' && !valid_url_or_path($url)) $url = '';
+ $highlights = [];
+ foreach (($srv['highlights'] ?? []) as $hl) {
+ $hl = trim((string)$hl);
+ if ($hl !== '') $highlights[] = $hl;
+ }
+ $links = [];
+ foreach (($srv['links'] ?? []) as $lk) {
+ if (!is_array($lk)) continue;
+ $label = trim((string)($lk['label'] ?? ''));
+ $lurl = trim((string)($lk['url'] ?? ''));
+ if ($label === '' || $lurl === '' || !valid_url_or_path($lurl)) continue;
+ $links[] = ['label' => $label, 'url' => $lurl];
+ }
+ $srvImage = trim((string)($srv['image'] ?? ''));
+ if ($srvImage !== '' && !valid_asset_ref($srvImage)) $srvImage = '';
+ $servers[] = [
+ 'id' => $sid,
+ 'name' => $name,
+ 'url' => $url,
+ 'featured' => !empty($srv['featured']),
+ 'desc' => (string)($srv['desc'] ?? ''),
+ 'highlights' => $highlights,
+ 'links' => $links,
+ 'image' => $srvImage,
+ ];
+ }
+ $eraImage = trim((string)($era['image'] ?? ''));
+ if ($eraImage !== '' && !valid_asset_ref($eraImage)) $eraImage = '';
+ $eras[] = [
+ 'id' => $id,
+ 'title' => $title,
+ 'subtitle' => (string)($era['subtitle'] ?? ''),
+ 'image' => $eraImage,
+ 'servers' => $servers,
+ ];
+ }
+ $linksSection = ['title' => 'Server Lists', 'disclaimer' => '', 'items' => []];
+ if (is_array($data['links_section'] ?? null)) {
+ $ls = $data['links_section'];
+ $linksSection['title'] = trim((string)($ls['title'] ?? '')) ?: 'Server Lists';
+ $linksSection['disclaimer'] = (string)($ls['disclaimer'] ?? '');
+ foreach (($ls['items'] ?? []) as $item) {
+ if (!is_array($item)) continue;
+ $label = trim((string)($item['label'] ?? ''));
+ $lurl = trim((string)($item['url'] ?? ''));
+ if ($label === '' || $lurl === '' || !valid_url_or_path($lurl)) continue;
+ $linksSection['items'][] = ['label' => $label, 'url' => $lurl, 'desc' => (string)($item['desc'] ?? '')];
+ }
+ }
+ return ['welcome' => $welcome, 'screenshots' => $screenshots, 'eras' => $eras, 'links_section' => $linksSection];
+}
+
+/** @param array{welcome?: array<int,string>, screenshots?: array<int,mixed>, eras?: array<int,mixed>, links_section?: array} $data */
+function runescape_save(array $data): bool {
+ $payload = [
+ 'welcome' => array_values($data['welcome'] ?? []),
+ 'screenshots' => array_values($data['screenshots'] ?? []),
+ 'eras' => array_values($data['eras'] ?? []),
+ 'links_section' => $data['links_section'] ?? ['title' => 'Server Lists', 'disclaimer' => '', 'items' => []],
+ ];
+ $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ if ($json === false) return false;
+ return file_put_contents(RUNESCAPE_FILE, $json . "\n", LOCK_EX) !== false;
+}
+
+/**
+ * Handle multipart screenshot upload into gaming/runescape/images/.
+ * @return array{ok:bool,filename:string,err:string}
+ */
+/**
+ * Handle a multipart upload into gaming/runescape/images/ from an arbitrary $_FILES field.
+ * @return array{ok:bool,filename:string,uploaded:bool,err:string}
+ */
+function runescape_process_image_upload(string $field, string $slugBase, string $current): array {
+ if (!empty($_POST[$field . '_clear'])) {
+ return ['ok' => true, 'filename' => '', 'uploaded' => false, 'err' => ''];
+ }
+ if (!isset($_FILES[$field]) || !is_array($_FILES[$field])) {
+ return ['ok' => true, 'filename' => $current, 'uploaded' => false, 'err' => ''];
+ }
+ $f = $_FILES[$field];
+ $err = (int)($f['error'] ?? UPLOAD_ERR_NO_FILE);
+ if ($err === UPLOAD_ERR_NO_FILE) {
+ return ['ok' => true, 'filename' => $current, 'uploaded' => false, 'err' => ''];
+ }
+ if ($err !== UPLOAD_ERR_OK) {
+ return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Image upload failed (code ' . $err . ').'];
+ }
+ if ((int)($f['size'] ?? 0) > 2 * 1024 * 1024) {
+ return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Image too large (max 2 MB).'];
+ }
+ $tmp = (string)($f['tmp_name'] ?? '');
+ if ($tmp === '' || !is_uploaded_file($tmp)) {
+ return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Invalid upload.'];
+ }
+ $mime = '';
+ if (class_exists('finfo')) {
+ $fi = new finfo(FILEINFO_MIME_TYPE);
+ $mime = (string)$fi->file($tmp);
+ } elseif (function_exists('mime_content_type')) {
+ $mime = (string)mime_content_type($tmp);
+ }
+ $map = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
+ if (!isset($map[$mime])) {
+ return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Only JPEG, PNG, GIF, or WebP allowed.'];
+ }
+ $name = collection_slug($slugBase !== '' ? $slugBase : 'rs', 'rs') . '-' . bin2hex(random_bytes(4)) . '.' . $map[$mime];
+ if (!is_dir(RUNESCAPE_IMAGES_DIR) && !@mkdir(RUNESCAPE_IMAGES_DIR, 0775, true)) {
+ return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Images folder missing and could not be created.'];
+ }
+ $dest = RUNESCAPE_IMAGES_DIR . '/' . $name;
+ if (!move_uploaded_file($tmp, $dest)) {
+ return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Could not save image (permissions on images/?).'];
+ }
+ @chmod($dest, 0644);
+ return ['ok' => true, 'filename' => $name, 'uploaded' => true, 'err' => ''];
+}
+
+/**
+ * Resolve an image field from text input (POST 'image') + optional file upload ($field).
+ * @return array{ok:bool,image:string,err:string}
+ */
+function runescape_resolve_image_field(string $field, string $slugBase): array {
+ $image = trim((string)($_POST['image'] ?? ''));
+ if ($image !== '' && !valid_asset_ref($image)) {
+ return ['ok' => false, 'image' => '', 'err' => 'Image must be a safe filename, site path, or http(s) URL.'];
+ }
+ $up = runescape_process_image_upload($field, $slugBase, $image);
+ if (!$up['ok']) {
+ return ['ok' => false, 'image' => $image, 'err' => $up['err']];
+ }
+ if ($up['uploaded']) {
+ return ['ok' => true, 'image' => $up['filename'], 'err' => ''];
+ }
+ if (!empty($_POST[$field . '_clear'])) {
+ return ['ok' => true, 'image' => '', 'err' => ''];
+ }
+ return ['ok' => true, 'image' => $image, 'err' => ''];
+}
+
+/** Backwards-compatible screenshot upload (fixed field name, no text-path option). */
+function runescape_process_screenshot_upload(): array {
+ $up = runescape_process_image_upload('screenshot', 'rs', '');
+ if (!$up['uploaded'] && $up['ok']) {
+ return ['ok' => false, 'filename' => '', 'err' => 'No file uploaded.'];
+ }
+ return ['ok' => $up['ok'], 'filename' => $up['filename'], 'err' => $up['err']];
+}
+
+/** Public URL for a runescape image filename (screenshot, era, or server). */
+function runescape_image_url(string $image): string {
+ $image = trim($image);
+ if ($image === '') return '';
+ if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://') || str_starts_with($image, '/')) {
+ return $image;
+ }
+ if (str_contains($image, '..') || str_contains($image, "\0")) return '';
+ return '/gaming/runescape/images/' . rawurlencode(basename($image));
+}
+
+function blog_db(): PDO {
+ static $db = null;
+ if ($db === null) {
+ $db = new PDO('sqlite:' . BLOG_DB);
+ $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ }
+ return $db;
+}
+
+/** @return PDO|null null when the guestbook DB can't be reached. */
+function guestbook_db(): ?PDO {
+ static $pdo = null;
+ if ($pdo !== null) return $pdo ?: null;
+ if (!is_file(GUESTBOOK_CONFIG)) { $pdo = false; return null; }
+ $host = $db = $user = $pass = $charset = null;
+ require GUESTBOOK_CONFIG; // sets $host,$db,$user,$pass,$charset
+ try {
+ $pdo = new PDO(
+ "mysql:host=$host;dbname=$db;charset=$charset",
+ $user, $pass,
+ [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
+ );
+ } catch (Throwable $e) {
+ $pdo = false;
+ return null;
+ }
+ return $pdo;
+}
+
+function vibe_type(string $url): string {
+ if ($url === '') return 'none';
+ $ext = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
+ if (in_array($ext, ['jpg','jpeg','png','gif','webp','bmp','svg','avif'], true)) return 'image';
+ if (in_array($ext, ['mp4','webm','ogv','mov'], true)) return 'video';
+ if (in_array($ext, ['mp3','flac','wav','ogg','aac','opus','m4a'], true)) return 'audio';
+ if (preg_match('/(?:youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/shorts\/)([A-Za-z0-9_-]{11})/', $url)) return 'youtube';
+ if (preg_match('/vimeo\.com\/(?:video\/)?\d+/', $url)) return 'vimeo';
+ if (preg_match('#(?:dailymotion\.com/video|dai\.ly)/[A-Za-z0-9]+#i', $url)) return 'dailymotion';
+ if (preg_match('#bilibili\.com/video/(?:BV[A-Za-z0-9]+|av\d+)#i', $url)) return 'bilibili';
+ if (preg_match('#(?:nicovideo\.jp/watch|nico\.ms)/(?:sm|nm|so)?\d+#i', $url)) return 'niconico';
+ return 'link';
+}
+
+/**
+ * Build an iframe embed URL for a supported video provider, or '' if the URL
+ * is not an embeddable player. Mirrors partials/partials_vibe.php so the admin
+ * preview matches what the live homepage renders.
+ */
+function vibe_embed_url(string $url): string {
+ if ($url === '') return '';
+ if (preg_match('/(?:youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/shorts\/)([A-Za-z0-9_-]{11})/', $url, $m)) {
+ return 'https://www.youtube-nocookie.com/embed/' . rawurlencode($m[1]) . '?rel=0&modestbranding=1';
+ }
+ if (preg_match('/vimeo\.com\/(?:video\/)?(\d+)/', $url, $m)) {
+ return 'https://player.vimeo.com/video/' . rawurlencode($m[1]) . '?dnt=1';
+ }
+ if (preg_match('#(?:dailymotion\.com/video|dai\.ly)/([A-Za-z0-9]+)#i', $url, $m)) {
+ return 'https://geo.dailymotion.com/player.html?video=' . rawurlencode($m[1]);
+ }
+ if (preg_match('#bilibili\.com/video/(BV[A-Za-z0-9]+)#i', $url, $m)) {
+ return 'https://player.bilibili.com/player.html?bvid=' . rawurlencode($m[1]) . '&page=1&high_quality=1&danmaku=0';
+ }
+ if (preg_match('#bilibili\.com/video/av(\d+)#i', $url, $m)) {
+ return 'https://player.bilibili.com/player.html?aid=' . rawurlencode($m[1]) . '&page=1&high_quality=1&danmaku=0';
+ }
+ if (preg_match('#(?:nicovideo\.jp/watch|nico\.ms)/((?:sm|nm|so)?\d+)#i', $url, $m)) {
+ return 'https://embed.nicovideo.jp/watch/' . rawurlencode($m[1]);
+ }
+ return '';
+}
+
+$section = $_GET['section'] ?? 'blog';
+if (!in_array($section, ['blog', 'changelog', 'vibe', 'now', 'motd', 'guestbook', 'journal', 'startpage', 'collection', 'runescape', 'radio', 'traffic'], true)) {
+ $section = 'blog';
+}
+
+/* ---------------------------------------------------------------------------
+ Blog ZIP export (read-only, GET) — handled before any output.
+ --------------------------------------------------------------------------- */
+if ($section === 'blog' && isset($_GET['export'])) {
+ try {
+ $rows = blog_db()->query('SELECT * FROM posts ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
+ $zipName = 'blog_export_' . date('Ymd_His') . '.zip';
+ $tmp = tempnam(sys_get_temp_dir(), 'blogzip');
+ $zip = new ZipArchive();
+ if ($zip->open($tmp, ZipArchive::OVERWRITE) === true) {
+ if (is_file(BLOG_DB)) $zip->addFile(BLOG_DB, 'blog.sqlite');
+ $zip->addFromString('posts.json', json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
+ $zip->close();
+ header('Content-Type: application/zip');
+ header('Content-Disposition: attachment; filename="' . $zipName . '"');
+ header('Content-Length: ' . filesize($tmp));
+ readfile($tmp);
+ @unlink($tmp);
+ exit;
+ }
+ @unlink($tmp);
+ flash_set('err', 'Could not create export archive.');
+ } catch (Throwable $e) {
+ flash_set('err', 'Export failed: ' . $e->getMessage());
+ }
+ redirect_section('blog');
+}
+
+/* ---------------------------------------------------------------------------
+ POST dispatch
+ --------------------------------------------------------------------------- */
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ check_csrf();
+ $do = $_POST['do'] ?? '';
+
+ switch ($do) {
+
+ case 'blog_save': {
+ $title = trim($_POST['title'] ?? '');
+ $content = trim($_POST['content'] ?? '');
+ $id = $_POST['id'] ?? '';
+ if ($title === '') { flash_set('err', 'Title cannot be empty.'); redirect_section('blog'); }
+ $slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($title)), '-');
+ if ($slug === '') $slug = 'post';
+ try {
+ if (ctype_digit((string)$id)) {
+ // Keep the post's original date/path location; only refresh the slug.
+ $cur = blog_db()->prepare('SELECT year, month, day FROM posts WHERE id=?');
+ $cur->execute([(int)$id]);
+ $r = $cur->fetch(PDO::FETCH_ASSOC) ?: ['year' => date('Y'), 'month' => date('m'), 'day' => date('d')];
+ $path = sprintf('%04d/%02d/%02d/%s.html', $r['year'], $r['month'], $r['day'], $slug);
+ blog_db()->prepare('UPDATE posts SET title=?, slug=?, content=?, path=? WHERE id=?')
+ ->execute([$title, $slug, $content, $path, (int)$id]);
+ flash_set('ok', 'Post updated.');
+ } else {
+ $y = date('Y'); $m = date('m'); $d = date('d');
+ $path = "$y/$m/$d/$slug.html";
+ blog_db()->prepare(
+ 'INSERT INTO posts (title, slug, content, year, month, day, created_at, path)
+ VALUES (?,?,?,?,?,?,?,?)'
+ )->execute([$title, $slug, $content, $y, $m, $d, date('Y-m-d H:i:s'), $path]);
+ flash_set('ok', 'Post created.');
+ }
+ } catch (Throwable $e) {
+ flash_set('err', 'Blog save failed: ' . $e->getMessage());
+ }
+ redirect_section('blog');
+ }
+
+ case 'blog_delete': {
+ $id = $_POST['id'] ?? '';
+ if (ctype_digit((string)$id)) {
+ try {
+ blog_db()->prepare('DELETE FROM posts WHERE id=?')->execute([(int)$id]);
+ flash_set('ok', 'Post deleted.');
+ } catch (Throwable $e) {
+ flash_set('err', 'Delete failed: ' . $e->getMessage());
+ }
+ }
+ redirect_section('blog');
+ }
+
+ case 'changelog_add': {
+ $text = trim($_POST['entry'] ?? '');
+ $date = trim($_POST['date'] ?? '');
+ if ($date === '') $date = date('j F Y');
+ $year = preg_match('/\b(\d{4})\b/', $date, $mm) ? $mm[1] : date('Y');
+ if ($text === '') { flash_set('err', 'Entry text cannot be empty.'); redirect_section('changelog'); }
+ $file = CHANGELOG_DIR . '/' . $year . '-changelog.txt';
+ $line = '* ' . $date . ': ' . $text . "\n";
+ if (!is_file($file)) {
+ $content = "**$year ChangeLog**\n\n" . $line;
+ } else {
+ $existing = file_get_contents($file);
+ $pos = strpos($existing, "\n\n");
+ if ($pos === false) {
+ $content = rtrim($existing) . "\n" . $line;
+ } else {
+ $content = substr($existing, 0, $pos + 2) . $line . substr($existing, $pos + 2);
+ }
+ }
+ if (file_put_contents($file, $content, LOCK_EX) !== false) {
+ @unlink('/tmp/sillylaird_recent_changelog.json'); // bust homepage cache
+ flash_set('ok', "Entry added to $year-changelog.txt.");
+ } else {
+ flash_set('err', 'Could not write changelog file (permissions?).');
+ }
+ redirect_section('changelog');
+ }
+
+ case 'vibe_save': {
+ $desc = trim($_POST['description'] ?? '');
+ $url = trim($_POST['url'] ?? '');
+ if ($url !== '') {
+ $scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? '');
+ if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array($scheme, ['http', 'https'], true)) {
+ flash_set('err', 'URL must be a valid http:// or https:// URL.');
+ redirect_section('vibe');
+ }
+ }
+ $export = var_export(['description' => $desc, 'url' => $url, 'updated' => date('Y-m-d')], true);
+ $php = "<?php\n// vibe.php — current vibe config, edited via /admin.php\n// Do not edit manually unless you know what you're doing.\n\nreturn {$export};\n";
+ if (file_put_contents(VIBE_FILE, $php, LOCK_EX) !== false) {
+ flash_set('ok', 'Vibe saved.');
+ } else {
+ flash_set('err', 'Could not write vibe.php (permissions?).');
+ }
+ redirect_section('vibe');
+ }
+
+ case 'now_save': {
+ $raw = str_replace("\r\n", "\n", trim($_POST['content'] ?? ''));
+ $sections = [];
+ $cur = null;
+ foreach (explode("\n", $raw) as $line) {
+ $line = trim($line);
+ if ($line === '') continue;
+ if ($line[0] === '#') {
+ if ($cur !== null && $cur['items'] !== []) $sections[] = $cur;
+ $cur = ['title' => trim(ltrim($line, '#')), 'items' => []];
+ } elseif ($cur !== null) {
+ $cur['items'][] = preg_replace('/^[-*]\s+/', '', $line);
+ }
+ }
+ if ($cur !== null && $cur['items'] !== []) $sections[] = $cur;
+ if ($sections === []) {
+ flash_set('err', 'Nothing to save. Start a section with "# Title", then one item per line.');
+ redirect_section('now');
+ }
+ $export = var_export(['updated' => date('Y-m-d'), 'sections' => $sections], true);
+ $php = "<?php\n// now-data.php — /now/ page content, edited via /admin.php\n"
+ . "// Do not edit manually unless you know what you're doing.\n"
+ . "if (realpath(__FILE__) === realpath(\$_SERVER['SCRIPT_FILENAME'] ?? '')) { http_response_code(404); exit; }\n\n"
+ . "return {$export};\n";
+ if (file_put_contents(NOW_FILE, $php, LOCK_EX) !== false) {
+ flash_set('ok', 'Now page saved (' . count($sections) . ' section(s)).');
+ } else {
+ flash_set('err', 'Could not write now-data.php (permissions?).');
+ }
+ redirect_section('now');
+ }
+
+ case 'motd_save': {
+ $title = trim((string)($_POST['title'] ?? ''));
+ $emoji = trim(str_replace("\0", '', (string)($_POST['emoji'] ?? '')));
+ $heading = trim(str_replace("\0", '', (string)($_POST['heading'] ?? '')));
+ $url = trim(str_replace("\0", '', (string)($_POST['url'] ?? '')));
+ $body = str_replace("\r\n", "\n", (string)($_POST['body'] ?? ''));
+ $body = trim(str_replace("\0", '', $body));
+ $title = str_replace("\0", '', $title);
+ if (strlen($title) > MOTD_TITLE_MAX) {
+ flash_set('err', 'Title is too long (max ' . MOTD_TITLE_MAX . ' characters).');
+ redirect_section('motd');
+ }
+ if (strlen($emoji) > MOTD_EMOJI_MAX) {
+ flash_set('err', 'Emoji is too long.');
+ redirect_section('motd');
+ }
+ if (strlen($heading) > MOTD_HEADING_MAX) {
+ flash_set('err', 'Heading is too long (max ' . MOTD_HEADING_MAX . ' characters).');
+ redirect_section('motd');
+ }
+ if (strlen($url) > MOTD_URL_MAX) {
+ flash_set('err', 'URL is too long.');
+ redirect_section('motd');
+ }
+ if ($url !== '' && !valid_url_or_path($url)) {
+ flash_set('err', 'URL must be empty, a site path, or http(s).');
+ redirect_section('motd');
+ }
+ if (strlen($body) > MOTD_BODY_MAX) {
+ flash_set('err', 'Body is too long (max ' . MOTD_BODY_MAX . ' characters).');
+ redirect_section('motd');
+ }
+ if ($body === '') {
+ flash_set('err', 'Body cannot be empty. Use Clear if you want it gone.');
+ redirect_section('motd');
+ }
+ $data = motd_load();
+ $changed = $title !== $data['title'] || $body !== $data['body'] || $heading !== $data['heading'];
+ if ($changed && trim($data['body']) !== '') {
+ $data = motd_archive_current($data);
+ }
+ $data['title'] = $title;
+ $data['emoji'] = $emoji;
+ $data['heading'] = $heading;
+ $data['url'] = $url;
+ $data['body'] = $body;
+ $data['enabled'] = true;
+ $data['announce_on_join'] = false;
+ $data['announce_on_change'] = false;
+ $data['updated'] = date('c');
+ $data['rev'] = (int)$data['rev'] + 1;
+ if (motd_save($data)) {
+ flash_set('ok', 'MOTD saved to /motd/. Use Announce now to post it in the room.');
+ } else {
+ flash_set('err', 'Could not write motd/motd.json (permissions?).');
+ }
+ redirect_section('motd');
+ }
+
+ case 'motd_announce': {
+ $data = motd_load();
+ if (trim($data['body']) === '') {
+ flash_set('err', 'Nothing to announce. Save a MOTD first.');
+ redirect_section('motd');
+ }
+ $data['push'] = time();
+ $data['updated'] = date('c');
+ if (motd_save($data)) {
+ flash_set('ok', 'Pushed MOTD to the XMPP room. waifu will re-post it shortly.');
+ } else {
+ flash_set('err', 'Could not write motd/motd.json (permissions?).');
+ }
+ redirect_section('motd');
+ }
+
+ case 'motd_clear': {
+ $data = motd_load();
+ if (trim($data['body']) !== '') {
+ $data = motd_archive_current($data);
+ }
+ $data['enabled'] = false;
+ $data['title'] = '';
+ $data['body'] = '';
+ $data['updated'] = date('c');
+ $data['rev'] = (int)$data['rev'] + 1;
+ if (motd_save($data)) {
+ flash_set('ok', 'MOTD cleared. Previous text kept in history.');
+ } else {
+ flash_set('err', 'Could not write motd/motd.json (permissions?).');
+ }
+ redirect_section('motd');
+ }
+
+ case 'startpage_save': {
+ $raw = trim((string)($_POST['content'] ?? ''));
+ try {
+ $cards = parse_startpage_cards($raw);
+ if (file_put_contents(STARTPAGE_CARDS, format_startpage_cards($cards), LOCK_EX) !== false) {
+ flash_set('ok', 'StartPage cards saved (' . count($cards) . ' card(s)).');
+ } else {
+ flash_set('err', 'Could not write StartPage cards.');
+ }
+ } catch (Throwable $e) {
+ flash_set('err', 'StartPage save failed: ' . $e->getMessage());
+ }
+ redirect_section('startpage');
+ }
+
+ case 'guestbook_delete': {
+ $db = guestbook_db();
+ if (!$db) { flash_set('err', 'Guestbook DB unavailable.'); redirect_section('guestbook'); }
+ $ids = [];
+ if (isset($_POST['ids']) && is_array($_POST['ids'])) {
+ $ids = array_filter($_POST['ids'], fn($i) => ctype_digit((string)$i));
+ } elseif (isset($_POST['id']) && ctype_digit((string)$_POST['id'])) {
+ $ids = [$_POST['id']];
+ }
+ $ids = array_values(array_unique(array_map('intval', $ids)));
+ if ($ids === []) { flash_set('err', 'No messages selected.'); redirect_section('guestbook'); }
+ try {
+ $ph = implode(',', array_fill(0, count($ids), '?'));
+ $db->prepare("DELETE FROM messages WHERE id IN ($ph)")->execute($ids);
+ flash_set('ok', count($ids) . ' message(s) deleted.');
+ } catch (Throwable $e) {
+ flash_set('err', 'Guestbook delete failed: ' . $e->getMessage());
+ }
+ redirect_section('guestbook');
+ }
+
+ case 'journal_save': {
+ $original = trim($_POST['original_date'] ?? '');
+ $date = trim($_POST['date'] ?? '');
+ $body = trim($_POST['body'] ?? '');
+ if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || $body === '') {
+ flash_set('err', 'Journal entries need a YYYY-MM-DD date and body text.');
+ redirect_section('journal');
+ }
+ $entries = journal_entries();
+ $saved = false;
+ foreach ($entries as &$entry) {
+ if (($original !== '' && $entry['date'] === $original) || ($original === '' && $entry['date'] === $date)) {
+ $entry = ['date' => $date, 'body' => $body];
+ $saved = true;
+ break;
+ }
+ }
+ unset($entry);
+ if (!$saved) $entries[] = ['date' => $date, 'body' => $body];
+ if (save_journal_entries($entries)) {
+ flash_set('ok', $saved ? 'Journal entry updated.' : 'Journal entry added.');
+ } else {
+ flash_set('err', 'Could not write journal entries.');
+ }
+ redirect_section('journal');
+ }
+
+ case 'journal_delete': {
+ $date = trim($_POST['date'] ?? '');
+ $entries = array_values(array_filter(journal_entries(), fn($entry) => $entry['date'] !== $date));
+ if (save_journal_entries($entries)) {
+ flash_set('ok', 'Journal entry deleted.');
+ } else {
+ flash_set('err', 'Could not write journal entries.');
+ }
+ redirect_section('journal');
+ }
+
+ case 'radio_save': {
+ $original = trim((string)($_POST['original_id'] ?? ''));
+ $name = trim((string)($_POST['name'] ?? ''));
+ $url = trim((string)($_POST['url'] ?? ''));
+ $homepage = trim((string)($_POST['homepage'] ?? ''));
+ $genre = trim((string)($_POST['genre'] ?? ''));
+ $codec = trim((string)($_POST['codec'] ?? ''));
+ $metaType = strtolower(trim((string)($_POST['meta_type'] ?? '')));
+ $metaUrl = trim((string)($_POST['meta_url'] ?? ''));
+ $relay = !empty($_POST['relay']);
+
+ if ($name === '') { flash_set('err', 'Station needs a name.'); redirect_section('radio'); }
+ if (!radio_stream_url_ok($url)) { flash_set('err', 'Stream URL must be http(s) or a path on radio.sillylaird.ca.'); redirect_section('radio'); }
+ if ($homepage !== '' && !valid_url_or_path($homepage)) { flash_set('err', 'Homepage is not a valid URL.'); redirect_section('radio'); }
+ if ($metaType !== '' && !in_array($metaType, RADIO_META_TYPES, true)) { flash_set('err', 'Unknown now-playing API type.'); redirect_section('radio'); }
+ if ($metaType !== '' && !radio_stream_url_ok($metaUrl)) { flash_set('err', 'Now-playing API URL is not valid.'); redirect_section('radio'); }
+ // Relaying only makes sense for plain-http upstreams; https plays direct.
+ if ($relay && str_starts_with(strtolower($url), 'https://')) $relay = false;
+
+ $stations = radio_load();
+ $used = [];
+ foreach ($stations as $s) {
+ if ($original === '' || $s['id'] !== $original) $used[$s['id']] = true;
+ }
+ $id = collection_unique_id(collection_slug($name, 'station'), $used);
+
+ $row = [
+ 'id' => $id,
+ 'name' => $name,
+ 'url' => $url,
+ 'homepage' => $homepage,
+ 'genre' => $genre,
+ 'codec' => $codec,
+ 'relay' => $relay,
+ ];
+ if ($metaType !== '') $row['meta'] = ['type' => $metaType, 'url' => $metaUrl];
+
+ $saved = false;
+ foreach ($stations as &$s) {
+ if ($original !== '' && $s['id'] === $original) { $s = $row; $saved = true; break; }
+ }
+ unset($s);
+ if (!$saved) $stations[] = $row;
+
+ if (radio_save($stations)) {
+ flash_set('ok', $saved ? 'Station updated.' : 'Station added.');
+ } else {
+ flash_set('err', 'Could not write stations.json (permissions?).');
+ }
+ redirect_section('radio');
+ }
+
+ case 'radio_delete': {
+ $id = trim((string)($_POST['id'] ?? ''));
+ $stations = array_values(array_filter(radio_load(), fn($s) => $s['id'] !== $id));
+ if (radio_save($stations)) {
+ flash_set('ok', 'Station deleted.');
+ } else {
+ flash_set('err', 'Could not write stations.json (permissions?).');
+ }
+ redirect_section('radio');
+ }
+
+ case 'radio_move': {
+ $id = trim((string)($_POST['id'] ?? ''));
+ $dir = ($_POST['dir'] ?? '') === 'up' ? -1 : 1;
+ $stations = radio_load();
+ foreach ($stations as $i => $s) {
+ if ($s['id'] !== $id) continue;
+ $j = $i + $dir;
+ if ($j >= 0 && $j < count($stations)) {
+ [$stations[$i], $stations[$j]] = [$stations[$j], $stations[$i]];
+ if (!radio_save($stations)) flash_set('err', 'Could not write stations.json (permissions?).');
+ }
+ break;
+ }
+ redirect_section('radio');
+ }
+
+ case 'collection_videogame_save':
+ case 'collection_console_save': {
+ $original = trim((string)($_POST['original_id'] ?? ''));
+ $name = trim((string)($_POST['name'] ?? ''));
+ $model = trim((string)($_POST['model'] ?? ''));
+ $status = trim((string)($_POST['status'] ?? 'Owned'));
+ $notes = (string)($_POST['notes'] ?? '');
+ if ($name === '') {
+ flash_set('err', 'Console needs a name.');
+ redirect_section('collection');
+ }
+ if ($model === '') $model = $name;
+ if (!in_array($status, COLLECTION_STATUSES, true)) $status = 'Owned';
+ $imgRes = collection_resolve_image_field($name);
+ if (!$imgRes['ok']) {
+ flash_set('err', $imgRes['err']);
+ redirect_section('collection');
+ }
+ $data = collection_load();
+ $used = [];
+ foreach ($data['consoles'] as $item) {
+ if ($original === '' || $item['id'] !== $original) {
+ $used[$item['id']] = true;
+ }
+ }
+ $id = collection_unique_id(collection_slug($name, 'console'), $used);
+ $row = [
+ 'id' => $id,
+ 'name' => $name,
+ 'model' => $model,
+ 'status' => $status,
+ 'notes' => $notes,
+ 'image' => $imgRes['image'],
+ ];
+ $saved = false;
+ foreach ($data['consoles'] as &$item) {
+ if ($original !== '' && $item['id'] === $original) {
+ $item = $row;
+ $saved = true;
+ break;
+ }
+ }
+ unset($item);
+ if (!$saved) {
+ $data['consoles'][] = $row;
+ }
+ if (collection_save($data)) {
+ flash_set('ok', $saved ? 'Console updated.' : 'Console added.');
+ } else {
+ flash_set('err', 'Could not write games.json (permissions?).');
+ }
+ redirect_section('collection');
+ }
+
+ case 'collection_videogame_delete':
+ case 'collection_console_delete': {
+ $id = trim((string)($_POST['id'] ?? ''));
+ $data = collection_load();
+ $data['consoles'] = array_values(array_filter(
+ $data['consoles'],
+ fn($item) => $item['id'] !== $id
+ ));
+ if (collection_save($data)) {
+ flash_set('ok', 'Console deleted.');
+ } else {
+ flash_set('err', 'Could not write games.json (permissions?).');
+ }
+ redirect_section('collection');
+ }
+
+ case 'collection_hardware_item_save': {
+ $original = trim((string)($_POST['original_id'] ?? ''));
+ $name = trim((string)($_POST['name'] ?? ''));
+ $model = trim((string)($_POST['model'] ?? ''));
+ $status = trim((string)($_POST['status'] ?? 'Owned'));
+ $notes = (string)($_POST['notes'] ?? '');
+ if ($name === '') {
+ flash_set('err', 'Hardware item needs a name.');
+ redirect_section('collection');
+ }
+ if ($model === '') $model = $name;
+ if (!in_array($status, COLLECTION_STATUSES, true)) $status = 'Owned';
+ $imgRes = collection_resolve_image_field($name);
+ if (!$imgRes['ok']) {
+ flash_set('err', $imgRes['err']);
+ redirect_section('collection');
+ }
+ $data = collection_load();
+ $used = [];
+ foreach ($data['hardware'] as $item) {
+ if ($original === '' || $item['id'] !== $original) {
+ $used[$item['id']] = true;
+ }
+ }
+ $id = collection_unique_id(collection_slug($name, 'hw'), $used);
+ $row = [
+ 'id' => $id,
+ 'name' => $name,
+ 'model' => $model,
+ 'status' => $status,
+ 'notes' => $notes,
+ 'image' => $imgRes['image'],
+ ];
+ $saved = false;
+ foreach ($data['hardware'] as &$item) {
+ if ($original !== '' && $item['id'] === $original) {
+ $item = $row;
+ $saved = true;
+ break;
+ }
+ }
+ unset($item);
+ if (!$saved) {
+ $data['hardware'][] = $row;
+ }
+ if (collection_save($data)) {
+ flash_set('ok', $saved ? 'Hardware updated.' : 'Hardware added.');
+ } else {
+ flash_set('err', 'Could not write games.json (permissions?).');
+ }
+ redirect_section('collection');
+ }
+
+ case 'collection_hardware_item_delete': {
+ $id = trim((string)($_POST['id'] ?? ''));
+ $data = collection_load();
+ $data['hardware'] = array_values(array_filter(
+ $data['hardware'],
+ fn($item) => $item['id'] !== $id
+ ));
+ if (collection_save($data)) {
+ flash_set('ok', 'Hardware deleted.');
+ } else {
+ flash_set('err', 'Could not write games.json (permissions?).');
+ }
+ redirect_section('collection');
+ }
+
+ case 'collection_system_save': {
+ $original = trim((string)($_POST['original_id'] ?? ''));
+ $name = trim((string)($_POST['name'] ?? ''));
+ if ($name === '') {
+ flash_set('err', 'System name cannot be empty.');
+ redirect_section('collection');
+ }
+ $data = collection_load();
+ $used = [];
+ foreach ($data['systems'] as $sys) {
+ if ($original === '' || $sys['id'] !== $original) {
+ $used[$sys['id']] = true;
+ }
+ }
+ $id = collection_unique_id(collection_slug($name, 'system'), $used);
+ $saved = false;
+ foreach ($data['systems'] as &$sys) {
+ if ($original !== '' && $sys['id'] === $original) {
+ $sys['id'] = $id;
+ $sys['name'] = $name;
+ $saved = true;
+ break;
+ }
+ }
+ unset($sys);
+ if (!$saved) {
+ $data['systems'][] = ['id' => $id, 'name' => $name, 'games' => []];
+ }
+ if (collection_save($data)) {
+ flash_set('ok', $saved ? 'System updated.' : 'System added.');
+ } else {
+ flash_set('err', 'Could not write games.json (permissions?).');
+ }
+ redirect_section('collection');
+ }
+
+ case 'collection_system_delete': {
+ $id = trim((string)($_POST['id'] ?? ''));
+ $data = collection_load();
+ $data['systems'] = array_values(array_filter(
+ $data['systems'],
+ fn($sys) => $sys['id'] !== $id
+ ));
+ if (collection_save($data)) {
+ flash_set('ok', 'System deleted.');
+ } else {
+ flash_set('err', 'Could not write games.json (permissions?).');
+ }
+ redirect_section('collection');
+ }
+
+ case 'collection_game_save': {
+ $systemId = trim((string)($_POST['system_id'] ?? ''));
+ $originalId = trim((string)($_POST['original_id'] ?? ''));
+ $title = trim((string)($_POST['title'] ?? ''));
+ $status = trim((string)($_POST['status'] ?? 'Owned'));
+ $notes = (string)($_POST['notes'] ?? '');
+ if ($title === '' || $systemId === '') {
+ flash_set('err', 'Game needs a title and a system.');
+ redirect_section('collection');
+ }
+ if (!in_array($status, COLLECTION_STATUSES, true)) {
+ $status = 'Owned';
+ }
+ $imgRes = collection_resolve_image_field($title);
+ if (!$imgRes['ok']) {
+ flash_set('err', $imgRes['err']);
+ redirect_section('collection');
+ }
+ $image = $imgRes['image'];
+ $data = collection_load();
+ $sysIdx = null;
+ foreach ($data['systems'] as $i => $sys) {
+ if ($sys['id'] === $systemId) { $sysIdx = $i; break; }
+ }
+ if ($sysIdx === null) {
+ flash_set('err', 'Unknown system.');
+ redirect_section('collection');
+ }
+ $used = [];
+ foreach ($data['systems'][$sysIdx]['games'] as $game) {
+ if ($originalId === '' || $game['id'] !== $originalId) {
+ $used[$game['id']] = true;
+ }
+ }
+ $gid = collection_unique_id(collection_slug($title, 'game'), $used);
+ $row = [
+ 'id' => $gid,
+ 'title' => $title,
+ 'status' => $status,
+ 'notes' => $notes,
+ 'image' => $image,
+ ];
+ $saved = false;
+ foreach ($data['systems'][$sysIdx]['games'] as &$game) {
+ if ($originalId !== '' && $game['id'] === $originalId) {
+ $game = $row;
+ $saved = true;
+ break;
+ }
+ }
+ unset($game);
+ if (!$saved) {
+ $data['systems'][$sysIdx]['games'][] = $row;
+ }
+ if (collection_save($data)) {
+ flash_set('ok', $saved ? 'Game updated.' : 'Game added.');
+ } else {
+ flash_set('err', 'Could not write games.json (permissions?).');
+ }
+ redirect_section('collection');
+ }
+
+ case 'collection_game_delete': {
+ $systemId = trim((string)($_POST['system_id'] ?? ''));
+ $gameId = trim((string)($_POST['id'] ?? ''));
+ $data = collection_load();
+ foreach ($data['systems'] as &$sys) {
+ if ($sys['id'] !== $systemId) continue;
+ $sys['games'] = array_values(array_filter(
+ $sys['games'],
+ fn($g) => $g['id'] !== $gameId
+ ));
+ break;
+ }
+ unset($sys);
+ if (collection_save($data)) {
+ flash_set('ok', 'Game deleted.');
+ } else {
+ flash_set('err', 'Could not write games.json (permissions?).');
+ }
+ redirect_section('collection');
+ }
+
+ case 'collection_online_cat_save': {
+ $original = trim((string)($_POST['original_id'] ?? ''));
+ $name = trim((string)($_POST['name'] ?? ''));
+ if ($name === '') {
+ flash_set('err', 'Online category name cannot be empty.');
+ redirect_section('collection');
+ }
+ $data = collection_load();
+ $used = [];
+ foreach ($data['online'] as $cat) {
+ if ($original === '' || $cat['id'] !== $original) {
+ $used[$cat['id']] = true;
+ }
+ }
+ $id = collection_unique_id(collection_slug($name, 'online'), $used);
+ $saved = false;
+ foreach ($data['online'] as &$cat) {
+ if ($original !== '' && $cat['id'] === $original) {
+ $cat['id'] = $id;
+ $cat['name'] = $name;
+ $saved = true;
+ break;
+ }
+ }
+ unset($cat);
+ if (!$saved) {
+ $data['online'][] = ['id' => $id, 'name' => $name, 'entries' => []];
+ }
+ if (collection_save($data)) {
+ flash_set('ok', $saved ? 'Online category updated.' : 'Online category added.');
+ } else {
+ flash_set('err', 'Could not write games.json (permissions?).');
+ }
+ redirect_section('collection');
+ }
+
+ case 'collection_online_cat_delete': {
+ $id = trim((string)($_POST['id'] ?? ''));
+ $data = collection_load();
+ $data['online'] = array_values(array_filter(
+ $data['online'],
+ fn($cat) => $cat['id'] !== $id
+ ));
+ if (collection_save($data)) {
+ flash_set('ok', 'Online category deleted.');
+ } else {
+ flash_set('err', 'Could not write games.json (permissions?).');
+ }
+ redirect_section('collection');
+ }
+
+ case 'collection_online_entry_save': {
+ $catId = trim((string)($_POST['category_id'] ?? ''));
+ $originalId = trim((string)($_POST['original_id'] ?? ''));
+ $title = trim((string)($_POST['title'] ?? ''));
+ $url = trim((string)($_POST['url'] ?? ''));
+ $status = trim((string)($_POST['status'] ?? 'Owned'));
+ $notes = (string)($_POST['notes'] ?? '');
+ if ($title === '' || $catId === '') {
+ flash_set('err', 'Song pack needs a title and a game/category.');
+ redirect_section('collection');
+ }
+ if ($url !== '' && !valid_url_or_path($url)) {
+ flash_set('err', 'URL must be http(s)/gemini/gopher or a site path starting with /.');
+ redirect_section('collection');
+ }
+ if (!in_array($status, COLLECTION_STATUSES, true)) {
+ $status = 'Owned';
+ }
+ $imgRes = collection_resolve_image_field($title);
+ if (!$imgRes['ok']) {
+ flash_set('err', $imgRes['err']);
+ redirect_section('collection');
+ }
+ $image = $imgRes['image'];
+ $data = collection_load();
+ $catIdx = null;
+ foreach ($data['online'] as $i => $cat) {
+ if ($cat['id'] === $catId) { $catIdx = $i; break; }
+ }
+ if ($catIdx === null) {
+ flash_set('err', 'Unknown online category.');
+ redirect_section('collection');
+ }
+ $used = [];
+ foreach ($data['online'][$catIdx]['entries'] as $entry) {
+ if ($originalId === '' || $entry['id'] !== $originalId) {
+ $used[$entry['id']] = true;
+ }
+ }
+ $eid = collection_unique_id(collection_slug($title, 'entry'), $used);
+ $row = [
+ 'id' => $eid,
+ 'title' => $title,
+ 'url' => $url,
+ 'status' => $status,
+ 'notes' => $notes,
+ 'image' => $image,
+ ];
+ $saved = false;
+ foreach ($data['online'][$catIdx]['entries'] as &$entry) {
+ if ($originalId !== '' && $entry['id'] === $originalId) {
+ $entry = $row;
+ $saved = true;
+ break;
+ }
+ }
+ unset($entry);
+ if (!$saved) {
+ $data['online'][$catIdx]['entries'][] = $row;
+ }
+ if (collection_save($data)) {
+ flash_set('ok', $saved ? 'Song pack updated.' : 'Song pack added.');
+ } else {
+ flash_set('err', 'Could not write games.json (permissions?).');
+ }
+ redirect_section('collection');
+ }
+
+ case 'collection_online_entry_delete': {
+ $catId = trim((string)($_POST['category_id'] ?? ''));
+ $entryId = trim((string)($_POST['id'] ?? ''));
+ $data = collection_load();
+ foreach ($data['online'] as &$cat) {
+ if ($cat['id'] !== $catId) continue;
+ $cat['entries'] = array_values(array_filter(
+ $cat['entries'],
+ fn($e) => $e['id'] !== $entryId
+ ));
+ break;
+ }
+ unset($cat);
+ if (collection_save($data)) {
+ flash_set('ok', 'Online entry deleted.');
+ } else {
+ flash_set('err', 'Could not write games.json (permissions?).');
+ }
+ redirect_section('collection');
+ }
+
+ case 'runescape_welcome_save': {
+ $raw = str_replace("\r\n", "\n", trim($_POST['welcome'] ?? ''));
+ $lines = [];
+ foreach (explode("\n", $raw) as $line) {
+ $line = trim($line);
+ if ($line !== '') $lines[] = $line;
+ }
+ $data = runescape_load();
+ $data['welcome'] = $lines;
+ if (runescape_save($data)) {
+ flash_set('ok', 'Welcome list saved.');
+ } else {
+ flash_set('err', 'Could not write runescape data.json (permissions?).');
+ }
+ redirect_section('runescape');
+ }
+
+ case 'runescape_screenshot_add': {
+ $caption = trim((string)($_POST['caption'] ?? ''));
+ $up = runescape_process_screenshot_upload();
+ if (!$up['ok']) {
+ flash_set('err', $up['err']);
+ redirect_section('runescape');
+ }
+ $data = runescape_load();
+ $data['screenshots'][] = [
+ 'id' => bin2hex(random_bytes(4)),
+ 'filename' => $up['filename'],
+ 'caption' => $caption,
+ ];
+ if (runescape_save($data)) {
+ flash_set('ok', 'Screenshot added.');
+ } else {
+ flash_set('err', 'Could not write runescape data.json (permissions?).');
+ }
+ redirect_section('runescape');
+ }
+
+ case 'runescape_screenshot_delete': {
+ $id = trim((string)($_POST['id'] ?? ''));
+ $data = runescape_load();
+ $toDelete = null;
+ foreach ($data['screenshots'] as $shot) {
+ if ($shot['id'] === $id) { $toDelete = $shot; break; }
+ }
+ $data['screenshots'] = array_values(array_filter($data['screenshots'], fn($s) => $s['id'] !== $id));
+ if (runescape_save($data)) {
+ if ($toDelete !== null) {
+ $path = RUNESCAPE_IMAGES_DIR . '/' . basename($toDelete['filename']);
+ if (is_file($path)) @unlink($path);
+ }
+ flash_set('ok', 'Screenshot deleted.');
+ } else {
+ flash_set('err', 'Could not write runescape data.json (permissions?).');
+ }
+ redirect_section('runescape');
+ }
+
+ case 'runescape_era_save': {
+ $original = trim((string)($_POST['original_id'] ?? ''));
+ $title = trim((string)($_POST['title'] ?? ''));
+ $subtitle = trim((string)($_POST['subtitle'] ?? ''));
+ if ($title === '') {
+ flash_set('err', 'Era needs a title.');
+ redirect_section('runescape');
+ }
+ $data = runescape_load();
+ $imgRes = runescape_resolve_image_field('era_image_file', $title);
+ if (!$imgRes['ok']) {
+ flash_set('err', $imgRes['err']);
+ redirect_section('runescape');
+ }
+ $used = [];
+ foreach ($data['eras'] as $era) {
+ if ($original === '' || $era['id'] !== $original) $used[$era['id']] = true;
+ }
+ $saved = false;
+ foreach ($data['eras'] as &$era) {
+ if ($original !== '' && $era['id'] === $original) {
+ $era['title'] = $title;
+ $era['subtitle'] = $subtitle;
+ $era['image'] = $imgRes['image'];
+ $saved = true;
+ break;
+ }
+ }
+ unset($era);
+ if (!$saved) {
+ $id = collection_unique_id(collection_slug($title, 'era'), $used);
+ $data['eras'][] = ['id' => $id, 'title' => $title, 'subtitle' => $subtitle, 'image' => $imgRes['image'], 'servers' => []];
+ }
+ if (runescape_save($data)) {
+ flash_set('ok', $saved ? 'Era updated.' : 'Era added.');
+ } else {
+ flash_set('err', 'Could not write runescape data.json (permissions?).');
+ }
+ redirect_section('runescape');
+ }
+
+ case 'runescape_era_delete': {
+ $id = trim((string)($_POST['id'] ?? ''));
+ $data = runescape_load();
+ $data['eras'] = array_values(array_filter($data['eras'], fn($e) => $e['id'] !== $id));
+ if (runescape_save($data)) {
+ flash_set('ok', 'Era deleted (and its servers).');
+ } else {
+ flash_set('err', 'Could not write runescape data.json (permissions?).');
+ }
+ redirect_section('runescape');
+ }
+
+ case 'runescape_server_save': {
+ $eraId = trim((string)($_POST['era_id'] ?? ''));
+ $original = trim((string)($_POST['original_id'] ?? ''));
+ $name = trim((string)($_POST['name'] ?? ''));
+ $url = trim((string)($_POST['url'] ?? ''));
+ $featured = !empty($_POST['featured']);
+ $desc = trim((string)($_POST['desc'] ?? ''));
+ $highlights = [];
+ foreach (explode("\n", str_replace("\r\n", "\n", (string)($_POST['highlights'] ?? ''))) as $line) {
+ $line = trim($line);
+ if ($line !== '') $highlights[] = $line;
+ }
+ $links = [];
+ foreach (explode("\n", str_replace("\r\n", "\n", (string)($_POST['links'] ?? ''))) as $line) {
+ $line = trim($line);
+ if ($line === '') continue;
+ $parts = array_map('trim', explode('|', $line, 2));
+ if (count($parts) === 2 && $parts[0] !== '' && $parts[1] !== '') {
+ $links[] = ['label' => $parts[0], 'url' => $parts[1]];
+ }
+ }
+ if ($name === '') {
+ flash_set('err', 'Server needs a name.');
+ redirect_section('runescape');
+ }
+ if ($url !== '' && !valid_url_or_path($url)) {
+ flash_set('err', 'Server URL must be a valid http(s) URL.');
+ redirect_section('runescape');
+ }
+ $imgRes = runescape_resolve_image_field('srv_image_file', $name);
+ if (!$imgRes['ok']) {
+ flash_set('err', $imgRes['err']);
+ redirect_section('runescape');
+ }
+ $data = runescape_load();
+ $eraIdx = null;
+ foreach ($data['eras'] as $i => $era) {
+ if ($era['id'] === $eraId) { $eraIdx = $i; break; }
+ }
+ if ($eraIdx === null) {
+ flash_set('err', 'Pick an era for this server.');
+ redirect_section('runescape');
+ }
+ $used = [];
+ foreach ($data['eras'][$eraIdx]['servers'] as $srv) {
+ if ($original === '' || $srv['id'] !== $original) $used[$srv['id']] = true;
+ }
+ $row = [
+ 'id' => '',
+ 'name' => $name,
+ 'url' => $url,
+ 'featured' => $featured,
+ 'desc' => $desc,
+ 'highlights' => $highlights,
+ 'links' => $links,
+ 'image' => $imgRes['image'],
+ ];
+ $saved = false;
+ foreach ($data['eras'][$eraIdx]['servers'] as &$srv) {
+ if ($original !== '' && $srv['id'] === $original) {
+ $row['id'] = $srv['id'];
+ $srv = $row;
+ $saved = true;
+ break;
+ }
+ }
+ unset($srv);
+ if (!$saved) {
+ $row['id'] = collection_unique_id(collection_slug($name, 'server'), $used);
+ $data['eras'][$eraIdx]['servers'][] = $row;
+ }
+ if (runescape_save($data)) {
+ flash_set('ok', $saved ? 'Server updated.' : 'Server added.');
+ } else {
+ flash_set('err', 'Could not write runescape data.json (permissions?).');
+ }
+ redirect_section('runescape');
+ }
+
+ case 'runescape_server_delete': {
+ $eraId = trim((string)($_POST['era_id'] ?? ''));
+ $id = trim((string)($_POST['id'] ?? ''));
+ $data = runescape_load();
+ foreach ($data['eras'] as &$era) {
+ if ($era['id'] !== $eraId) continue;
+ $era['servers'] = array_values(array_filter($era['servers'], fn($s) => $s['id'] !== $id));
+ break;
+ }
+ unset($era);
+ if (runescape_save($data)) {
+ flash_set('ok', 'Server deleted.');
+ } else {
+ flash_set('err', 'Could not write runescape data.json (permissions?).');
+ }
+ redirect_section('runescape');
+ }
+
+ case 'traffic_clear_recent': {
+ require_once __DIR__ . '/partials/traffic.php';
+ try {
+ $n = traffic_clear_recent();
+ flash_set('ok', 'Cleared ' . $n . ' recent hit log row(s). Page totals kept.');
+ } catch (Throwable $e) {
+ flash_set('err', 'Could not clear traffic log: ' . $e->getMessage());
+ }
+ break;
+ }
+
+ case 'traffic_reset_all': {
+ require_once __DIR__ . '/partials/traffic.php';
+ try {
+ traffic_reset_all();
+ flash_set('ok', 'Reset all traffic stats and hit log.');
+ } catch (Throwable $e) {
+ flash_set('err', 'Could not reset traffic: ' . $e->getMessage());
+ }
+ break;
+ }
+
+ case 'runescape_links_save': {
+ $title = trim((string)($_POST['links_title'] ?? '')) ?: 'Server Lists';
+ $disclaimer = trim((string)($_POST['links_disclaimer'] ?? ''));
+ $items = [];
+ foreach (explode("\n", str_replace("\r\n", "\n", (string)($_POST['links_items'] ?? ''))) as $line) {
+ $line = trim($line);
+ if ($line === '') continue;
+ $parts = array_map('trim', explode('|', $line, 3));
+ if (count($parts) >= 2 && $parts[0] !== '' && $parts[1] !== '' && valid_url_or_path($parts[1])) {
+ $items[] = ['label' => $parts[0], 'url' => $parts[1], 'desc' => $parts[2] ?? ''];
+ }
+ }
+ $data = runescape_load();
+ $data['links_section'] = ['title' => $title, 'disclaimer' => $disclaimer, 'items' => $items];
+ if (runescape_save($data)) {
+ flash_set('ok', 'Server Lists saved.');
+ } else {
+ flash_set('err', 'Could not write runescape data.json (permissions?).');
+ }
+ redirect_section('runescape');
+ }
+
+ }
+ redirect_section($section);
+}
+
+/* ---------------------------------------------------------------------------
+ View data
+ --------------------------------------------------------------------------- */
+$flash = flash_get();
+
+// Blog: post being edited (GET ?edit=ID) + list
+$blog_edit = null;
+$blog_posts = [];
+$blog_err = '';
+try {
+ if ($section === 'blog' && isset($_GET['edit']) && ctype_digit((string)$_GET['edit'])) {
+ $st = blog_db()->prepare('SELECT * FROM posts WHERE id=?');
+ $st->execute([(int)$_GET['edit']]);
+ $blog_edit = $st->fetch(PDO::FETCH_ASSOC) ?: null;
+ }
+ if ($section === 'blog') {
+ $blog_posts = blog_db()->query('SELECT * FROM posts ORDER BY id DESC')->fetchAll(PDO::FETCH_ASSOC);
+ }
+} catch (Throwable $e) {
+ $blog_err = $e->getMessage();
+}
+
+// Changelog files
+$changelog_files = [];
+if ($section === 'changelog') {
+ foreach (glob(CHANGELOG_DIR . '/*-changelog.txt') ?: [] as $f) $changelog_files[] = $f;
+ rsort($changelog_files);
+}
+
+// Vibe current
+$vibe = is_file(VIBE_FILE) ? (require VIBE_FILE) : ['description' => '', 'url' => '', 'updated' => ''];
+$vibe_t = vibe_type($vibe['url'] ?? '');
+$vibe_embed = vibe_embed_url($vibe['url'] ?? '');
+
+// Now page: current data flattened back to the textarea format
+$now_data = is_file(NOW_FILE) ? (require NOW_FILE) : ['updated' => '', 'sections' => []];
+$now_text = '';
+foreach (($now_data['sections'] ?? []) as $sec) {
+ $now_text .= '# ' . ($sec['title'] ?? '') . "\n";
+ foreach (($sec['items'] ?? []) as $item) $now_text .= $item . "\n";
+ $now_text .= "\n";
+}
+$now_text = rtrim($now_text) . "\n";
+
+$motd = $section === 'motd' ? motd_load() : motd_default();
+$motd_preview = motd_xmpp_text($motd);
+
+// Guestbook messages
+$gb_rows = [];
+$gb_err = '';
+$gb_q = trim($_GET['q'] ?? '');
+if ($section === 'guestbook') {
+ $db = guestbook_db();
+ if (!$db) {
+ $gb_err = 'Guestbook database is unreachable.';
+ } else {
+ try {
+ if ($gb_q !== '') {
+ $st = $db->prepare('SELECT * FROM messages WHERE CAST(id AS CHAR)=:eid OR name LIKE :q OR message LIKE :q ORDER BY created_at DESC');
+ $st->execute([':eid' => $gb_q, ':q' => '%' . $gb_q . '%']);
+ } else {
+ $st = $db->query('SELECT * FROM messages ORDER BY created_at DESC');
+ }
+ $gb_rows = $st->fetchAll(PDO::FETCH_ASSOC);
+ } catch (Throwable $e) {
+ $gb_err = $e->getMessage();
+ }
+ }
+}
+
+$journal_rows = journal_entries();
+$journal_edit = null;
+if ($section === 'journal' && isset($_GET['journal_edit'])) {
+ $edit_date = trim($_GET['journal_edit']);
+ foreach ($journal_rows as $row) {
+ if ($row['date'] === $edit_date) { $journal_edit = $row; break; }
+ }
+}
+$startpage_text = startpage_cards_text();
+
+$collection = collection_load();
+$collection_vg_edit = null;
+$collection_hw_edit = null;
+$collection_sys_edit = null;
+$collection_game_edit = null;
+$collection_game_system = '';
+$collection_online_cat_edit = null;
+$collection_online_entry_edit = null;
+$collection_online_entry_cat = '';
+if ($section === 'collection') {
+ if (isset($_GET['vg_edit']) || isset($_GET['console_edit'])) {
+ $vid = trim((string)($_GET['console_edit'] ?? $_GET['vg_edit'] ?? ''));
+ foreach ($collection['consoles'] as $item) {
+ if ($item['id'] === $vid) { $collection_vg_edit = $item; break; }
+ }
+ }
+ if (isset($_GET['hw_edit'])) {
+ $hid = trim((string)$_GET['hw_edit']);
+ foreach ($collection['hardware'] as $item) {
+ if ($item['id'] === $hid) { $collection_hw_edit = $item; break; }
+ }
+ }
+ if (isset($_GET['sys_edit'])) {
+ $sid = trim((string)$_GET['sys_edit']);
+ foreach ($collection['systems'] as $sys) {
+ if ($sys['id'] === $sid) { $collection_sys_edit = $sys; break; }
+ }
+ }
+ if (isset($_GET['game_edit'], $_GET['system'])) {
+ $gid = trim((string)$_GET['game_edit']);
+ $sid = trim((string)$_GET['system']);
+ foreach ($collection['systems'] as $sys) {
+ if ($sys['id'] !== $sid) continue;
+ foreach ($sys['games'] as $game) {
+ if ($game['id'] === $gid) {
+ $collection_game_edit = $game;
+ $collection_game_system = $sid;
+ break 2;
+ }
+ }
+ }
+ }
+ if (isset($_GET['online_cat_edit'])) {
+ $cid = trim((string)$_GET['online_cat_edit']);
+ foreach ($collection['online'] as $cat) {
+ if ($cat['id'] === $cid) { $collection_online_cat_edit = $cat; break; }
+ }
+ }
+ if (isset($_GET['online_entry_edit'], $_GET['category'])) {
+ $eid = trim((string)$_GET['online_entry_edit']);
+ $cid = trim((string)$_GET['category']);
+ foreach ($collection['online'] as $cat) {
+ if ($cat['id'] !== $cid) continue;
+ foreach ($cat['entries'] as $entry) {
+ if ($entry['id'] === $eid) {
+ $collection_online_entry_edit = $entry;
+ $collection_online_entry_cat = $cid;
+ break 2;
+ }
+ }
+ }
+ }
+}
+
+$runescape = runescape_load();
+$runescape_welcome_text = implode("\n", $runescape['welcome']);
+$rs_era_edit = null;
+$rs_server_edit = null;
+$rs_server_era = '';
+if ($section === 'runescape') {
+ if (isset($_GET['era_edit'])) {
+ $reid = trim((string)$_GET['era_edit']);
+ foreach ($runescape['eras'] as $era) {
+ if ($era['id'] === $reid) { $rs_era_edit = $era; break; }
+ }
+ }
+ if (isset($_GET['server_edit'], $_GET['era'])) {
+ $sid = trim((string)$_GET['server_edit']);
+ $reid = trim((string)$_GET['era']);
+ foreach ($runescape['eras'] as $era) {
+ if ($era['id'] !== $reid) continue;
+ foreach ($era['servers'] as $srv) {
+ if ($srv['id'] === $sid) {
+ $rs_server_edit = $srv;
+ $rs_server_era = $reid;
+ break 2;
+ }
+ }
+ }
+ }
+}
+$rs_server_era_default = $rs_server_era !== '' ? $rs_server_era : ($runescape['eras'][0]['id'] ?? '');
+
+// Radio stations (radio.sillylaird.ca)
+$radio_stations = $section === 'radio' ? radio_load() : [];
+$radio_edit = null;
+if ($section === 'radio' && isset($_GET['radio_edit'])) {
+ $reid = trim((string)$_GET['radio_edit']);
+ foreach ($radio_stations as $s) {
+ if ($s['id'] === $reid) { $radio_edit = $s; break; }
+ }
+}
+
+// Traffic (privacy-friendly hit log — no IPs)
+$traffic_summary = null;
+$traffic_top = [];
+$traffic_recent = [];
+$traffic_analytics = null;
+$traffic_err = null;
+if ($section === 'traffic') {
+ require_once __DIR__ . '/partials/traffic.php';
+ try {
+ $traffic_summary = traffic_summary();
+ $traffic_top = traffic_top_pages(80);
+ $traffic_recent = traffic_recent(150);
+ $traffic_analytics = traffic_analytics();
+ } catch (Throwable $e) {
+ $traffic_err = $e->getMessage();
+ }
+}
+
+$tabs = ['blog' => 'Blog', 'changelog' => 'Changelog', 'vibe' => 'Vibe', 'now' => 'Now', 'motd' => 'MOTD', 'startpage' => 'StartPage', 'journal' => 'Journal', 'guestbook' => 'Guestbook', 'collection' => 'Collection', 'runescape' => 'RuneScape', 'radio' => 'Radio', 'traffic' => 'Traffic'];
+?>
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="robots" content="noindex,nofollow" />
+ <title>Admin — SillyLaird</title>
+ <link rel="icon" href="https://www.sillylaird.ca/assets/img/lain.png" />
+ <link rel="stylesheet" href="/assets/css/fonts.css" />
+ <link rel="stylesheet" href="/assets/css/site.css" />
+ <link rel="stylesheet" href="/assets/css/skeleton.css" />
+ <link rel="stylesheet" href="/assets/css/pages/admin.css?v=<?= @filemtime(__DIR__ . '/assets/css/pages/admin.css') ?: time() ?>" />
+</head>
+<body>
+ <a class="skip-link" href="#main">Skip to content</a>
+ <?php include __DIR__ . '/partials/header.php'; ?>
+
+ <main id="main" class="wrap stack">
+ <section aria-labelledby="admin-title">
+ <h1 id="admin-title">Admin</h1>
+ <p class="muted">Unified control panel — <a href="/admin/logout.php">logout</a></p>
+
+ <nav class="admin-tabs" aria-label="Admin sections">
+ <?php foreach ($tabs as $key => $label): ?>
+ <a href="/admin.php?section=<?= $key ?>" class="<?= $section === $key ? 'active' : '' ?>"><?= $label ?></a>
+ <?php endforeach; ?>
+ </nav>
+
+ <?php if ($flash): ?>
+ <div class="admin-flash <?= h($flash['type']) ?>"><?= h($flash['msg']) ?></div>
+ <?php endif; ?>
+
+ <?php /* ============================ BLOG ============================ */ ?>
+ <?php if ($section === 'blog'): ?>
+ <?php if ($blog_err): ?><div class="admin-flash err">Blog DB error: <?= h($blog_err) ?></div><?php endif; ?>
+ <div style="margin-bottom:var(--space-md);">
+ <a href="/admin.php?section=blog&export=1">⬇ Export ZIP</a>
+ <?php if ($blog_edit): ?> &middot; <a href="/admin.php?section=blog">+ New post</a><?php endif; ?>
+ </div>
+
+ <div class="admin-cols">
+ <div>
+ <form method="post" action="/admin.php?section=blog">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="blog_save" />
+ <?php if ($blog_edit): ?><input type="hidden" name="id" value="<?= (int)$blog_edit['id'] ?>" /><?php endif; ?>
+ <div class="panel">
+ <div class="panel-head"><?= $blog_edit ? 'Edit post #' . (int)$blog_edit['id'] : 'New post' ?></div>
+ <div class="panel-body">
+ <div class="admin-field">
+ <label for="b-title">Title</label>
+ <input id="b-title" name="title" value="<?= h($blog_edit['title'] ?? '') ?>" />
+ </div>
+ <div class="admin-field">
+ <label for="md-editor">Content (Markdown)</label>
+ <div class="md-toolbar" aria-hidden="true">
+ <button type="button" data-md="wrap" data-a="**" data-b="**">Bold</button>
+ <button type="button" data-md="wrap" data-a="*" data-b="*">Italic</button>
+ <button type="button" data-md="wrap" data-a="`" data-b="`">Code</button>
+ <button type="button" data-md="wrap" data-a="```&#10;" data-b="&#10;```">Block</button>
+ <button type="button" data-md="line" data-a="# ">H1</button>
+ <button type="button" data-md="line" data-a="## ">H2</button>
+ <button type="button" data-md="line" data-a="### ">H3</button>
+ <button type="button" data-md="line" data-a="- ">List</button>
+ <button type="button" data-md="line" data-a="> ">Quote</button>
+ <button type="button" data-md="wrap" data-a="[" data-b="](https://)">Link</button>
+ <button type="button" data-md="wrap" data-a="![](" data-b=")">Image</button>
+ <button type="button" data-md="insert" data-a="&#10;---&#10;">HR</button>
+ </div>
+ <textarea id="md-editor" name="content" rows="16"><?= h($blog_edit['content'] ?? '') ?></textarea>
+ </div>
+ <button type="submit"><?= $blog_edit ? 'Update post' : 'Create post' ?></button>
+ </div>
+ </div>
+ </form>
+ </div>
+
+ <div class="panel">
+ <div class="panel-head">Live preview</div>
+ <div class="panel-body" id="md-preview"><em class="muted">Type to preview…</em></div>
+ </div>
+ </div>
+
+ <div class="panel">
+ <div class="panel-head">Posts (<?= count($blog_posts) ?>)</div>
+ <div class="panel-body">
+ <?php if (!$blog_posts): ?>
+ <p class="muted">No posts yet.</p>
+ <?php else: ?>
+ <ul class="admin-list">
+ <?php foreach ($blog_posts as $p): ?>
+ <li class="admin-item">
+ <strong><?= h($p['title']) ?></strong><br>
+ <small><?= h($p['path']) ?></small><br>
+ <a href="https://blog.sillylaird.ca/<?= h(ltrim($p['path'], '/')) ?>" target="_blank" rel="noopener noreferrer">View</a> &middot;
+ <a href="/admin.php?section=blog&edit=<?= (int)$p['id'] ?>">Edit</a> &middot;
+ <form class="inline-form" method="post" action="/admin.php?section=blog" onsubmit="return confirm('Delete this post?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="blog_delete" />
+ <input type="hidden" name="id" value="<?= (int)$p['id'] ?>" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Delete</button>
+ </form>
+ </li>
+ <?php endforeach; ?>
+ </ul>
+ <?php endif; ?>
+ </div>
+ </div>
+
+ <?php /* ========================== CHANGELOG ========================== */ ?>
+ <?php elseif ($section === 'changelog'): ?>
+ <div class="panel">
+ <div class="panel-head">Add entry</div>
+ <div class="panel-body">
+ <form method="post" action="/admin.php?section=changelog">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="changelog_add" />
+ <div class="admin-field">
+ <label for="c-date">Date (e.g. <?= date('j F Y') ?>)</label>
+ <input id="c-date" name="date" value="<?= date('j F Y') ?>" />
+ </div>
+ <div class="admin-field">
+ <label for="c-entry">Description</label>
+ <textarea id="c-entry" name="entry" rows="4" required placeholder="Added a new feature..."></textarea>
+ </div>
+ <button type="submit">Add entry</button>
+ </form>
+ </div>
+ </div>
+ <div class="panel">
+ <div class="panel-head">Files</div>
+ <div class="panel-body">
+ <ul class="admin-list">
+ <?php foreach ($changelog_files as $f): $b = basename($f); ?>
+ <li class="admin-item"><a href="/changelog/<?= h($b) ?>" target="_blank" rel="noopener noreferrer"><?= h($b) ?></a> <small>(<?= filesize($f) ?> bytes)</small></li>
+ <?php endforeach; ?>
+ <?php if (!$changelog_files): ?><li class="muted">No changelog files.</li><?php endif; ?>
+ </ul>
+ </div>
+ </div>
+
+ <?php /* ============================ VIBE ============================ */ ?>
+ <?php elseif ($section === 'vibe'): ?>
+ <div class="admin-cols">
+ <div>
+ <form method="post" action="/admin.php?section=vibe">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="vibe_save" />
+ <div class="panel">
+ <div class="panel-head">Config</div>
+ <div class="panel-body">
+ <div class="admin-field">
+ <label for="v-desc">Description</label>
+ <textarea id="v-desc" name="description" rows="3"><?= h($vibe['description'] ?? '') ?></textarea>
+ </div>
+ <div class="admin-field">
+ <label for="v-url">URL</label>
+ <input type="url" id="v-url" name="url" value="<?= h($vibe['url'] ?? '') ?>" placeholder="https://..." />
+ <p class="muted" style="margin-top:6px;">detected: <span class="type-badge" id="v-type"><?= h($vibe_t) ?></span></p>
+ </div>
+ <?php if (!empty($vibe['updated'])): ?><p class="muted">last saved: <?= h($vibe['updated']) ?></p><?php endif; ?>
+ <button type="submit">Save vibe</button>
+ </div>
+ </div>
+ </form>
+ </div>
+ <div class="panel preview-panel">
+ <div class="panel-head">Preview</div>
+ <div class="panel-body">
+ <?php $u = h($vibe['url'] ?? ''); if (($vibe['url'] ?? '') === ''): ?>
+ <p class="muted">// no url set</p>
+ <?php elseif ($vibe_t === 'image'): ?>
+ <a href="<?= $u ?>" target="_blank" rel="noopener noreferrer"><img src="<?= $u ?>" alt="vibe preview" loading="lazy"></a>
+ <?php elseif ($vibe_t === 'video'): ?>
+ <video controls preload="none"><source src="<?= $u ?>"></video>
+ <?php elseif ($vibe_t === 'audio'): ?>
+ <audio controls preload="none" style="width:100%"><source src="<?= $u ?>"></audio>
+ <?php elseif ($vibe_embed !== ''): ?>
+ <div class="ratio-wrap">
+ <iframe src="<?= h($vibe_embed) ?>" title="<?= h($vibe_t) ?> preview" loading="lazy"
+ referrerpolicy="strict-origin-when-cross-origin"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
+ allowfullscreen></iframe>
+ </div>
+ <p class="muted" style="margin-top:6px;"><?= h($vibe_t) ?> &middot; <a href="<?= $u ?>" target="_blank" rel="noopener noreferrer">open ↗</a></p>
+ <?php else: ?>
+ <p><a href="<?= $u ?>" target="_blank" rel="noopener noreferrer"><?= $u ?></a></p>
+ <?php endif; ?>
+ </div>
+ </div>
+ </div>
+
+ <?php /* ============================ NOW ============================= */ ?>
+ <?php elseif ($section === 'now'): ?>
+ <div class="panel">
+ <div class="panel-head">Edit /now/ page</div>
+ <div class="panel-body">
+ <form method="post" action="/admin.php?section=now">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="now_save" />
+ <div class="admin-field">
+ <label for="n-content">Content — <code># Section Title</code> starts a section, one item per line</label>
+ <textarea id="n-content" name="content" rows="18" required><?= h($now_text) ?></textarea>
+ </div>
+ <?php if (!empty($now_data['updated'])): ?><p class="muted">last saved: <?= h($now_data['updated']) ?></p><?php endif; ?>
+ <button type="submit">Save now page</button>
+ <a href="/now/" target="_blank" rel="noopener noreferrer" style="margin-left:var(--space-sm);">View /now/ ↗</a>
+ </form>
+ </div>
+ </div>
+
+ <?php /* ============================ MOTD ============================ */ ?>
+ <?php elseif ($section === 'motd'): ?>
+ <p class="muted">Edit the bot line (emoji, heading, body, URL). Save updates <a href="/motd/" target="_blank" rel="noopener noreferrer">/motd/</a>. waifu only posts to the room when you click Announce now.</p>
+ <div class="admin-cols">
+ <div>
+ <form method="post" action="/admin.php?section=motd">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="motd_save" />
+ <div class="panel">
+ <div class="panel-head">Edit MOTD</div>
+ <div class="panel-body">
+ <div class="admin-field">
+ <label for="motd-emoji">Emoji (optional)</label>
+ <input id="motd-emoji" name="emoji" maxlength="<?= MOTD_EMOJI_MAX ?>" value="<?= h($motd['emoji']) ?>" placeholder="📜" />
+ </div>
+ <div class="admin-field">
+ <label for="motd-heading">Heading</label>
+ <input id="motd-heading" name="heading" maxlength="<?= MOTD_HEADING_MAX ?>" value="<?= h($motd['heading']) ?>" placeholder="MOTD" />
+ </div>
+ <div class="admin-field">
+ <label for="motd-title">Page title (optional, /motd/ only)</label>
+ <input id="motd-title" name="title" maxlength="<?= MOTD_TITLE_MAX ?>" value="<?= h($motd['title']) ?>" placeholder="Welcome to based@" />
+ </div>
+ <div class="admin-field">
+ <label for="motd-body">Body</label>
+ <textarea id="motd-body" name="body" rows="8" maxlength="<?= MOTD_BODY_MAX ?>" placeholder="watching baldy"><?= h($motd['body']) ?></textarea>
+ </div>
+ <div class="admin-field">
+ <label for="motd-url">URL (optional, leave blank to omit)</label>
+ <input id="motd-url" name="url" maxlength="<?= MOTD_URL_MAX ?>" value="<?= h($motd['url']) ?>" placeholder="https://www.sillylaird.ca/motd/" />
+ </div>
+ <?php if ($motd['updated'] !== ''): ?><p class="muted">last saved: <?= h($motd['updated']) ?> · rev <?= (int)$motd['rev'] ?></p><?php endif; ?>
+ <button type="submit">Save MOTD</button>
+ <a href="/motd/" target="_blank" rel="noopener noreferrer" style="margin-left:var(--space-sm);">View /motd/ ↗</a>
+ </div>
+ </div>
+ </form>
+ <div class="admin-motd-actions">
+ <form method="post" action="/admin.php?section=motd" class="inline-form">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="motd_announce" />
+ <button type="submit">Announce now</button>
+ </form>
+ <form method="post" action="/admin.php?section=motd" class="inline-form" onsubmit="return confirm('Clear the live MOTD? Previous text is kept in history.');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="motd_clear" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Clear MOTD</button>
+ </form>
+ </div>
+ </div>
+ <div class="panel preview-panel">
+ <div class="panel-head">XMPP preview</div>
+ <div class="panel-body">
+ <pre class="motd-xmpp-preview" id="motd-live-preview"><?= $motd_preview !== '' ? h($motd_preview) : '// fill in a body' ?></pre>
+ <p class="muted" style="margin-top:6px;">This is exactly what waifu will say when you click Announce now.</p>
+ </div>
+ </div>
+ </div>
+ <?php if ($motd['history']): ?>
+ <div class="panel">
+ <div class="panel-head">History (<?= count($motd['history']) ?>)</div>
+ <div class="panel-body">
+ <ul class="admin-list">
+ <?php foreach ($motd['history'] as $past): ?>
+ <li class="admin-item">
+ <strong><?= h($past['title'] !== '' ? $past['title'] : 'MOTD') ?></strong>
+ <?php if ($past['updated'] !== ''): ?> <small><?= h($past['updated']) ?></small><?php endif; ?><br>
+ <small><?= h(strlen($past['body']) > 180 ? substr($past['body'], 0, 180) . '...' : $past['body']) ?></small>
+ </li>
+ <?php endforeach; ?>
+ </ul>
+ </div>
+ </div>
+ <?php endif; ?>
+
+ <?php /* ========================== STARTPAGE ========================= */ ?>
+ <?php elseif ($section === 'startpage'): ?>
+ <div class="panel">
+ <div class="panel-head">Edit StartPage cards</div>
+ <div class="panel-body">
+ <form method="post" action="/admin.php?section=startpage">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="startpage_save" />
+ <div class="admin-field">
+ <label for="sp-content">Content — <code># Card Title | image.png | alt text</code>, then <code>Label | URL</code> rows</label>
+ <textarea id="sp-content" name="content" rows="26" required spellcheck="false"><?= h($startpage_text) ?></textarea>
+ </div>
+ <button type="submit">Save StartPage cards</button>
+ <a href="/startpage/" target="_blank" rel="noopener noreferrer" style="margin-left:var(--space-sm);">View StartPage</a>
+ </form>
+ </div>
+ </div>
+
+ <?php /* =========================== JOURNAL ========================== */ ?>
+ <?php elseif ($section === 'journal'): ?>
+ <div class="admin-cols">
+ <div>
+ <div class="panel">
+ <div class="panel-head"><?= $journal_edit ? 'Edit journal entry' : 'Add journal entry' ?></div>
+ <div class="panel-body">
+ <form method="post" action="/admin.php?section=journal">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="journal_save" />
+ <input type="hidden" name="original_date" value="<?= h($journal_edit['date'] ?? '') ?>" />
+ <div class="admin-field">
+ <label for="j-date">Date</label>
+ <input id="j-date" type="date" name="date" value="<?= h($journal_edit['date'] ?? date('Y-m-d')) ?>" required />
+ </div>
+ <div class="admin-field">
+ <label for="j-body">Entry text</label>
+ <textarea id="j-body" name="body" rows="8" required><?= h($journal_edit['body'] ?? '') ?></textarea>
+ </div>
+ <button type="submit"><?= $journal_edit ? 'Update journal entry' : 'Add journal entry' ?></button>
+ <?php if ($journal_edit): ?><a href="/admin.php?section=journal" style="margin-left:var(--space-sm);">Cancel</a><?php endif; ?>
+ <a href="/journal/" target="_blank" rel="noopener noreferrer" style="margin-left:var(--space-sm);">View Journal</a>
+ </form>
+ </div>
+ </div>
+ </div>
+
+ <div>
+ <div class="panel">
+ <div class="panel-head">Journal entries (<?= count($journal_rows) ?>)</div>
+ <div class="panel-body">
+ <ul class="admin-list">
+ <?php foreach ($journal_rows as $entry): ?>
+ <li class="admin-item">
+ <strong><?= h($entry['date']) ?></strong><br>
+ <?php $excerpt = strlen($entry['body']) > 180 ? substr($entry['body'], 0, 180) . '...' : $entry['body']; ?>
+ <small><?= h($excerpt) ?></small><br>
+ <a href="/admin.php?section=journal&journal_edit=<?= urlencode($entry['date']) ?>">Edit</a>
+ &middot;
+ <form class="inline-form" method="post" action="/admin.php?section=journal" onsubmit="return confirm('Delete this journal entry?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="journal_delete" />
+ <input type="hidden" name="date" value="<?= h($entry['date']) ?>" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Delete</button>
+ </form>
+ </li>
+ <?php endforeach; ?>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <?php /* ========================== GUESTBOOK ========================== */ ?>
+ <?php elseif ($section === 'guestbook'): ?>
+ <?php if ($gb_err): ?><div class="admin-flash err"><?= h($gb_err) ?></div><?php endif; ?>
+ <form method="get" action="/admin.php" style="margin-bottom:var(--space-md);">
+ <input type="hidden" name="section" value="guestbook" />
+ <input type="text" name="q" value="<?= h($gb_q) ?>" placeholder="Search id / name / message" style="padding:8px;background:#050000;border:1px solid var(--border);color:#d00000;border-radius:var(--r);" />
+ <button type="submit">Search</button>
+ <?php if ($gb_q !== ''): ?><a href="/admin.php?section=guestbook">Clear</a><?php endif; ?>
+ </form>
+
+ <?php if (!$gb_err): ?>
+ <form method="post" action="/admin.php?section=guestbook" id="gb-form" onsubmit="return confirm('Delete selected message(s)?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="guestbook_delete" />
+ <div style="margin-bottom:var(--space-sm);">
+ <button type="submit">Delete selected</button>
+ <span class="muted"><?= count($gb_rows) ?> message(s)</span>
+ </div>
+ <table class="admin-table">
+ <tr><th></th><th>ID</th><th>Name</th><th>Message</th><th>Time</th></tr>
+ <?php foreach ($gb_rows as $row): ?>
+ <tr>
+ <td><input type="checkbox" name="ids[]" value="<?= h($row['id']) ?>" aria-label="Select <?= h($row['id']) ?>" /></td>
+ <td><?= h($row['id']) ?></td>
+ <td><?= h($row['name']) ?></td>
+ <td><?= nl2br(h($row['message'])) ?></td>
+ <td><small><?= h($row['created_at']) ?></small></td>
+ </tr>
+ <?php endforeach; ?>
+ <?php if (!$gb_rows): ?><tr><td colspan="5" class="muted">No messages.</td></tr><?php endif; ?>
+ </table>
+ </form>
+ <?php endif; ?>
+
+ <?php /* ========================= COLLECTION ========================= */ ?>
+ <?php elseif ($section === 'collection'): ?>
+ <?php
+ $adminSelectStyle = 'width:100%;box-sizing:border-box;background:#050000;border:1px solid var(--border);color:#d00000;padding:10px;border-radius:var(--r);font:inherit;';
+ $vgImgPreview = $collection_vg_edit ? collection_image_url((string)($collection_vg_edit['image'] ?? '')) : '';
+ $hwImgPreview = $collection_hw_edit ? collection_image_url((string)($collection_hw_edit['image'] ?? '')) : '';
+ $gameImgPreview = $collection_game_edit ? collection_image_url((string)($collection_game_edit['image'] ?? '')) : '';
+ $packImgPreview = $collection_online_entry_edit ? collection_image_url((string)($collection_online_entry_edit['image'] ?? '')) : '';
+ ?>
+ <style>
+ .col-catalog { display:table; width:100%; border-collapse:separate; border-spacing:0 12px; margin:0 0 var(--space-xl); }
+ .col-row { display:table-row; }
+ .col-pic, .col-body { display:table-cell; vertical-align:middle; }
+ .col-pic { width:1%; padding-right:14px; white-space:nowrap; }
+ .col-pic img { display:block; max-width:120px; max-height:90px; border:1px solid var(--border); background:#050000; }
+ .col-pic-empty { width:100px; height:72px; border:1px dashed var(--border); color:var(--muted); font-size:var(--fs-xs); display:flex; align-items:center; justify-content:center; background:#050000; }
+ .col-name { font-family:var(--font-mono); font-size:var(--fs-lg); font-weight:700; color:var(--gold); margin:0 0 4px; }
+ .col-name .sep { color:var(--muted); font-weight:400; margin:0 .25em; }
+ .col-meta { font-size:var(--fs-sm); color:var(--muted); }
+ .col-preview-head { text-align:center; margin:0 0 var(--space-md); }
+ .col-preview-menu { text-align:center; margin:0 0 var(--space-lg); font-size:var(--fs-sm); text-transform:uppercase; letter-spacing:.06em; color:var(--muted); }
+ .col-preview-menu a { color:var(--muted); text-decoration:none; }
+ .col-preview-menu a:hover { color:var(--ink); }
+ .col-preview-menu .d { margin:0 .4rem; color:var(--border); }
+ .col-img-preview { max-width:160px; max-height:120px; border:1px solid var(--border); margin:6px 0 10px; display:block; background:#050000; }
+ @media (max-width:640px) {
+ .col-catalog, .col-row, .col-pic, .col-body { display:block; width:100%; }
+ .col-pic { margin-bottom:8px; }
+ }
+ </style>
+
+ <p class="muted" style="margin-bottom:var(--space-md);">
+ Catalog data: <code>gaming/collection/games.json</code> · photos: <code>gaming/collection/images/</code>
+ · <a href="/gaming/collection/" target="_blank" rel="noopener noreferrer">Video Games</a>
+ / <a href="/gaming/collection/consoles.php" target="_blank" rel="noopener noreferrer">Consoles</a>
+ / <a href="/gaming/collection/hardware.php" target="_blank" rel="noopener noreferrer">Hardware</a>
+ / <a href="/gaming/collection/packs.php" target="_blank" rel="noopener noreferrer">Song Packs</a>
+ / <a href="/gaming/collection/wishlist.php" target="_blank" rel="noopener noreferrer">Wishlist</a>
+ </p>
+
+ <?php /* ---- Ola-style inventory list at top ---- */ ?>
+ <div class="panel" style="margin-bottom:var(--space-xl);">
+ <div class="panel-head">Live catalog preview (click Edit to change left photo / fields)</div>
+ <div class="panel-body">
+ <p class="col-preview-head" style="font-family:var(--font-mono);font-size:var(--fs-2xl);font-weight:700;color:var(--ink);">Game Collection</p>
+ <nav class="col-preview-menu" aria-label="Public collection pages">
+ <a href="/gaming/collection/" target="_blank" rel="noopener noreferrer">Video Games</a>
+ / <a href="/gaming/collection/consoles.php" target="_blank" rel="noopener noreferrer">Consoles</a>
+ / <a href="/gaming/collection/hardware.php" target="_blank" rel="noopener noreferrer">Hardware</a>
+ / <a href="/gaming/collection/packs.php" target="_blank" rel="noopener noreferrer">Song Packs</a>
+ / <a href="/gaming/collection/wishlist.php" target="_blank" rel="noopener noreferrer">Wishlist</a>
+ </nav>
+
+ <h3 style="font-size:var(--fs-sm);text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin:0 0 var(--space-sm);">Video Games (titles)</h3>
+ <?php
+ $libAny = false;
+ foreach ($collection['systems'] as $sys) {
+ if (!empty($sys['games'])) { $libAny = true; break; }
+ }
+ ?>
+ <?php if (!$libAny): ?>
+ <p class="muted">No video game titles yet — add a system and title below.</p>
+ <?php else: ?>
+ <div class="col-catalog">
+ <?php foreach ($collection['systems'] as $sys):
+ foreach ($sys['games'] as $game):
+ $thumb = collection_image_url((string)($game['image'] ?? ''));
+ ?>
+ <div class="col-row">
+ <div class="col-pic">
+ <?php if ($thumb !== ''): ?>
+ <img src="<?= h($thumb) ?>" alt="" loading="lazy" />
+ <?php else: ?>
+ <div class="col-pic-empty">no photo</div>
+ <?php endif; ?>
+ </div>
+ <div class="col-body">
+ <p class="col-name"><?= h($sys['name']) ?><span class="sep">::</span><?= h($game['title']) ?></p>
+ <p class="col-meta">
+ <span class="type-badge"><?= h($game['status']) ?></span>
+ <br>
+ <a href="/admin.php?section=collection&game_edit=<?= urlencode($game['id']) ?>&system=<?= urlencode($sys['id']) ?>#col-game-form">Edit photo / fields</a>
+ ·
+ <form class="inline-form" method="post" action="/admin.php?section=collection" onsubmit="return confirm('Delete this title?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="collection_game_delete" />
+ <input type="hidden" name="system_id" value="<?= h($sys['id']) ?>" />
+ <input type="hidden" name="id" value="<?= h($game['id']) ?>" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Delete</button>
+ </form>
+ </p>
+ </div>
+ </div>
+ <?php endforeach; endforeach; ?>
+ </div>
+ <?php endif; ?>
+
+ <h3 style="font-size:var(--fs-sm);text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin:var(--space-xl) 0 var(--space-sm);">Consoles</h3>
+ <?php if (empty($collection['consoles'])): ?>
+ <p class="muted">No consoles yet — add one below.</p>
+ <?php else: ?>
+ <div class="col-catalog">
+ <?php foreach ($collection['consoles'] as $vg):
+ $thumb = collection_image_url((string)($vg['image'] ?? ''));
+ ?>
+ <div class="col-row">
+ <div class="col-pic">
+ <?php if ($thumb !== ''): ?>
+ <img src="<?= h($thumb) ?>" alt="" loading="lazy" />
+ <?php else: ?>
+ <div class="col-pic-empty">no photo</div>
+ <?php endif; ?>
+ </div>
+ <div class="col-body">
+ <p class="col-name"><?= h($vg['name']) ?><span class="sep">::</span><?= h($vg['model']) ?></p>
+ <p class="col-meta">
+ <span class="type-badge"><?= h($vg['status']) ?></span>
+ <?php if (($vg['notes'] ?? '') !== ''): ?>
+ — <?= h(strlen($vg['notes']) > 80 ? substr($vg['notes'], 0, 80) . '…' : $vg['notes']) ?>
+ <?php endif; ?>
+ <br>
+ <a href="/admin.php?section=collection&console_edit=<?= urlencode($vg['id']) ?>#col-vg-form">Edit photo / fields</a>
+ ·
+ <form class="inline-form" method="post" action="/admin.php?section=collection" onsubmit="return confirm('Delete this console?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="collection_console_delete" />
+ <input type="hidden" name="id" value="<?= h($vg['id']) ?>" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Delete</button>
+ </form>
+ </p>
+ </div>
+ </div>
+ <?php endforeach; ?>
+ </div>
+ <?php endif; ?>
+
+ <h3 style="font-size:var(--fs-sm);text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin:var(--space-xl) 0 var(--space-sm);">Hardware</h3>
+ <?php if (empty($collection['hardware'])): ?>
+ <p class="muted">No hardware yet — add items below.</p>
+ <?php else: ?>
+ <div class="col-catalog">
+ <?php foreach ($collection['hardware'] as $hw):
+ $thumb = collection_image_url((string)($hw['image'] ?? ''));
+ ?>
+ <div class="col-row">
+ <div class="col-pic">
+ <?php if ($thumb !== ''): ?>
+ <img src="<?= h($thumb) ?>" alt="" loading="lazy" />
+ <?php else: ?>
+ <div class="col-pic-empty">no photo</div>
+ <?php endif; ?>
+ </div>
+ <div class="col-body">
+ <p class="col-name"><?= h($hw['name']) ?><span class="sep">::</span><?= h($hw['model']) ?></p>
+ <p class="col-meta">
+ <span class="type-badge"><?= h($hw['status']) ?></span>
+ <br>
+ <a href="/admin.php?section=collection&hw_edit=<?= urlencode($hw['id']) ?>#col-hw-form">Edit photo / fields</a>
+ ·
+ <form class="inline-form" method="post" action="/admin.php?section=collection" onsubmit="return confirm('Delete this hardware item?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="collection_hardware_item_delete" />
+ <input type="hidden" name="id" value="<?= h($hw['id']) ?>" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Delete</button>
+ </form>
+ </p>
+ </div>
+ </div>
+ <?php endforeach; ?>
+ </div>
+ <?php endif; ?>
+
+ <h3 style="font-size:var(--fs-sm);text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin:var(--space-xl) 0 var(--space-sm);">Song Packs</h3>
+ <?php
+ $pkAny = false;
+ foreach ($collection['online'] as $cat) {
+ if (!empty($cat['entries'])) { $pkAny = true; break; }
+ }
+ ?>
+ <?php if (!$pkAny): ?>
+ <p class="muted">No song packs yet — add a game category and pack below.</p>
+ <?php else: ?>
+ <div class="col-catalog">
+ <?php foreach ($collection['online'] as $cat):
+ foreach ($cat['entries'] as $entry):
+ $thumb = collection_image_url((string)($entry['image'] ?? ''));
+ ?>
+ <div class="col-row">
+ <div class="col-pic">
+ <?php if ($thumb !== ''): ?>
+ <img src="<?= h($thumb) ?>" alt="" loading="lazy" />
+ <?php else: ?>
+ <div class="col-pic-empty">no photo</div>
+ <?php endif; ?>
+ </div>
+ <div class="col-body">
+ <p class="col-name"><?= h($cat['name']) ?><span class="sep">::</span><?= h($entry['title']) ?></p>
+ <p class="col-meta">
+ <span class="type-badge"><?= h($entry['status'] ?? 'Owned') ?></span>
+ <?php if (($entry['url'] ?? '') !== ''): ?>
+ · <a href="<?= h($entry['url']) ?>" target="_blank" rel="noopener noreferrer">store</a>
+ <?php endif; ?>
+ <br>
+ <a href="/admin.php?section=collection&online_entry_edit=<?= urlencode($entry['id']) ?>&category=<?= urlencode($cat['id']) ?>#col-pack-form">Edit photo / fields</a>
+ ·
+ <form class="inline-form" method="post" action="/admin.php?section=collection" onsubmit="return confirm('Delete this song pack?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="collection_online_entry_delete" />
+ <input type="hidden" name="category_id" value="<?= h($cat['id']) ?>" />
+ <input type="hidden" name="id" value="<?= h($entry['id']) ?>" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Delete</button>
+ </form>
+ </p>
+ </div>
+ </div>
+ <?php endforeach; endforeach; ?>
+ </div>
+ <?php endif; ?>
+ </div>
+ </div>
+
+ <h2 style="font-size:var(--fs-lg);margin:var(--space-lg) 0 var(--space-md);">Edit Video Games (titles)</h2>
+ <p class="muted" style="margin-bottom:var(--space-md);">Titles under a system — shown as <code>System :: Title</code> on the public Video Games page.</p>
+ <div class="admin-cols">
+ <div>
+ <div class="panel">
+ <div class="panel-head"><?= $collection_sys_edit ? 'Edit game system' : 'Add game system' ?></div>
+ <div class="panel-body">
+ <form method="post" action="/admin.php?section=collection">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="collection_system_save" />
+ <input type="hidden" name="original_id" value="<?= h($collection_sys_edit['id'] ?? '') ?>" />
+ <div class="admin-field">
+ <label for="col-sys-name">System name</label>
+ <input id="col-sys-name" name="name" value="<?= h($collection_sys_edit['name'] ?? '') ?>" required placeholder="SNES" />
+ </div>
+ <button type="submit"><?= $collection_sys_edit ? 'Update system' : 'Add system' ?></button>
+ <?php if ($collection_sys_edit): ?>
+ <a href="/admin.php?section=collection" style="margin-left:var(--space-sm);">Cancel</a>
+ <?php endif; ?>
+ </form>
+ </div>
+ </div>
+
+ <div class="panel" id="col-game-form">
+ <div class="panel-head"><?= $collection_game_edit ? 'Edit video game title (left photo + fields)' : 'Add video game title' ?></div>
+ <div class="panel-body">
+ <?php if (!$collection['systems']): ?>
+ <p class="muted">Add a system first.</p>
+ <?php else: ?>
+ <form method="post" action="/admin.php?section=collection" enctype="multipart/form-data">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="collection_game_save" />
+ <input type="hidden" name="original_id" value="<?= h($collection_game_edit['id'] ?? '') ?>" />
+ <div class="admin-field">
+ <label for="col-game-sys">System</label>
+ <select id="col-game-sys" name="system_id" required style="<?= h($adminSelectStyle) ?>">
+ <?php
+ $selectedSys = $collection_game_system !== '' ? $collection_game_system : ($collection['systems'][0]['id'] ?? '');
+ foreach ($collection['systems'] as $sys):
+ ?>
+ <option value="<?= h($sys['id']) ?>" <?= $sys['id'] === $selectedSys ? 'selected' : '' ?>><?= h($sys['name']) ?></option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <div class="admin-field">
+ <label for="col-game-title">Title</label>
+ <input id="col-game-title" name="title" value="<?= h($collection_game_edit['title'] ?? '') ?>" required />
+ </div>
+ <div class="admin-field">
+ <label for="col-game-status">Status</label>
+ <select id="col-game-status" name="status" style="<?= h($adminSelectStyle) ?>">
+ <?php
+ $curStatus = $collection_game_edit['status'] ?? 'Owned';
+ foreach (COLLECTION_STATUSES as $st):
+ ?>
+ <option value="<?= h($st) ?>" <?= $st === $curStatus ? 'selected' : '' ?>><?= h($st) ?></option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <div class="admin-field">
+ <label for="col-game-notes">Notes</label>
+ <textarea id="col-game-notes" name="notes" rows="4"><?= h($collection_game_edit['notes'] ?? '') ?></textarea>
+ </div>
+ <div class="admin-field">
+ <label>Left-side photo (like the inventory catalog)</label>
+ <?php if ($gameImgPreview !== ''): ?>
+ <img class="col-img-preview" src="<?= h($gameImgPreview) ?>" alt="Current photo" loading="lazy" />
+ <?php endif; ?>
+ <label for="col-game-image-file" style="font-size:var(--fs-sm);">Upload new photo</label>
+ <input id="col-game-image-file" type="file" name="image_file" accept="image/jpeg,image/png,image/gif,image/webp" />
+ <p class="muted" style="font-size:var(--fs-xs);margin:6px 0;">JPEG / PNG / GIF / WebP · max 2&nbsp;MB · saved under <code>images/</code></p>
+ <label for="col-game-image" style="font-size:var(--fs-sm);">Or filename / path / URL</label>
+ <input id="col-game-image" name="image" value="<?= h($collection_game_edit['image'] ?? '') ?>" placeholder="smw.jpg" />
+ <?php if ($collection_game_edit && ($collection_game_edit['image'] ?? '') !== ''): ?>
+ <label style="display:block;margin-top:8px;font-size:var(--fs-sm);">
+ <input type="checkbox" name="image_clear" value="1" /> Clear left photo
+ </label>
+ <?php endif; ?>
+ </div>
+ <button type="submit"><?= $collection_game_edit ? 'Save game' : 'Add game' ?></button>
+ <?php if ($collection_game_edit): ?>
+ <a href="/admin.php?section=collection" style="margin-left:var(--space-sm);">Cancel</a>
+ <?php endif; ?>
+ </form>
+ <?php endif; ?>
+ </div>
+ </div>
+ </div>
+
+ <div>
+ <div class="panel">
+ <div class="panel-head">Game systems (<?= count($collection['systems']) ?>)</div>
+ <div class="panel-body">
+ <?php if (!$collection['systems']): ?>
+ <p class="muted">No systems yet.</p>
+ <?php else: ?>
+ <ul class="admin-list">
+ <?php foreach ($collection['systems'] as $sys): ?>
+ <li class="admin-item">
+ <strong><?= h($sys['name']) ?></strong>
+ <small> (<?= count($sys['games']) ?> title(s))</small><br>
+ <a href="/admin.php?section=collection&sys_edit=<?= urlencode($sys['id']) ?>">Rename</a>
+ ·
+ <form class="inline-form" method="post" action="/admin.php?section=collection" onsubmit="return confirm('Delete system and all its titles?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="collection_system_delete" />
+ <input type="hidden" name="id" value="<?= h($sys['id']) ?>" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Delete system</button>
+ </form>
+ </li>
+ <?php endforeach; ?>
+ </ul>
+ <?php endif; ?>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <h2 id="col-vg-form" style="font-size:var(--fs-lg);margin:var(--space-2xl) 0 var(--space-md);">Edit Consoles</h2>
+ <p class="muted" style="margin-bottom:var(--space-md);">Consoles as inventory — <code>Name :: Model</code> on the Consoles page.</p>
+ <div class="admin-cols" style="margin-bottom:var(--space-2xl);">
+ <div>
+ <div class="panel">
+ <div class="panel-head"><?= $collection_vg_edit ? 'Edit console (left photo + fields)' : 'Add console' ?></div>
+ <div class="panel-body">
+ <form method="post" action="/admin.php?section=collection" enctype="multipart/form-data">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="collection_console_save" />
+ <input type="hidden" name="original_id" value="<?= h($collection_vg_edit['id'] ?? '') ?>" />
+ <div class="admin-field">
+ <label for="col-vg-name">Short name</label>
+ <input id="col-vg-name" name="name" value="<?= h($collection_vg_edit['name'] ?? '') ?>" required placeholder="SNES" />
+ </div>
+ <div class="admin-field">
+ <label for="col-vg-model">Full model / description</label>
+ <input id="col-vg-model" name="model" value="<?= h($collection_vg_edit['model'] ?? '') ?>" placeholder="Super Nintendo Entertainment System" />
+ </div>
+ <div class="admin-field">
+ <label for="col-vg-status">Status</label>
+ <select id="col-vg-status" name="status" style="<?= h($adminSelectStyle) ?>">
+ <?php $vgStatus = $collection_vg_edit['status'] ?? 'Owned'; foreach (COLLECTION_STATUSES as $st): ?>
+ <option value="<?= h($st) ?>" <?= $st === $vgStatus ? 'selected' : '' ?>><?= h($st) ?></option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <div class="admin-field">
+ <label for="col-vg-notes">Notes</label>
+ <textarea id="col-vg-notes" name="notes" rows="3"><?= h($collection_vg_edit['notes'] ?? '') ?></textarea>
+ </div>
+ <div class="admin-field">
+ <label>Left-side photo</label>
+ <?php if ($vgImgPreview !== ''): ?><img class="col-img-preview" src="<?= h($vgImgPreview) ?>" alt="" loading="lazy" /><?php endif; ?>
+ <input type="file" name="image_file" accept="image/jpeg,image/png,image/gif,image/webp" />
+ <input name="image" value="<?= h($collection_vg_edit['image'] ?? '') ?>" placeholder="snes.jpg" style="margin-top:6px;width:100%;box-sizing:border-box;background:#050000;border:1px solid var(--border);color:#d00000;padding:10px;border-radius:var(--r);font:inherit;" />
+ <?php if ($collection_vg_edit && ($collection_vg_edit['image'] ?? '') !== ''): ?>
+ <label style="display:block;margin-top:8px;font-size:var(--fs-sm);"><input type="checkbox" name="image_clear" value="1" /> Clear left photo</label>
+ <?php endif; ?>
+ </div>
+ <button type="submit"><?= $collection_vg_edit ? 'Save console' : 'Add console' ?></button>
+ <?php if ($collection_vg_edit): ?><a href="/admin.php?section=collection" style="margin-left:var(--space-sm);">Cancel</a><?php endif; ?>
+ </form>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div class="panel">
+ <div class="panel-head">Consoles (<?= count($collection['consoles']) ?>)</div>
+ <div class="panel-body">
+ <?php if (empty($collection['consoles'])): ?><p class="muted">None yet.</p>
+ <?php else: ?><ul class="admin-list">
+ <?php foreach ($collection['consoles'] as $vg): ?>
+ <li class="admin-item"><strong><?= h($vg['name']) ?></strong> <span class="type-badge"><?= h($vg['status']) ?></span><br>
+ <small><?= h($vg['model']) ?></small><br>
+ <a href="/admin.php?section=collection&console_edit=<?= urlencode($vg['id']) ?>#col-vg-form">Edit</a> ·
+ <form class="inline-form" method="post" action="/admin.php?section=collection" onsubmit="return confirm('Delete console?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" /><input type="hidden" name="do" value="collection_console_delete" /><input type="hidden" name="id" value="<?= h($vg['id']) ?>" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Delete</button>
+ </form>
+ </li>
+ <?php endforeach; ?>
+ </ul><?php endif; ?>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <h2 id="col-hw-form" style="font-size:var(--fs-lg);margin:var(--space-2xl) 0 var(--space-md);">Edit Hardware</h2>
+ <p class="muted" style="margin-bottom:var(--space-md);">Cabinets, controllers, PCs — <code>Name :: Model</code> on the Hardware page.</p>
+ <div class="admin-cols" style="margin-bottom:var(--space-2xl);">
+ <div>
+ <div class="panel">
+ <div class="panel-head"><?= $collection_hw_edit ? 'Edit hardware (left photo + fields)' : 'Add hardware' ?></div>
+ <div class="panel-body">
+ <form method="post" action="/admin.php?section=collection" enctype="multipart/form-data">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="collection_hardware_item_save" />
+ <input type="hidden" name="original_id" value="<?= h($collection_hw_edit['id'] ?? '') ?>" />
+ <div class="admin-field">
+ <label for="col-hw-name">Short name</label>
+ <input id="col-hw-name" name="name" value="<?= h($collection_hw_edit['name'] ?? '') ?>" required placeholder="DTXMania" />
+ </div>
+ <div class="admin-field">
+ <label for="col-hw-model">Full model / description</label>
+ <input id="col-hw-model" name="model" value="<?= h($collection_hw_edit['model'] ?? '') ?>" placeholder="Drum cabinet / sim rig" />
+ </div>
+ <div class="admin-field">
+ <label for="col-hw-status">Status</label>
+ <select id="col-hw-status" name="status" style="<?= h($adminSelectStyle) ?>">
+ <?php $hwStatus = $collection_hw_edit['status'] ?? 'Owned'; foreach (COLLECTION_STATUSES as $st): ?>
+ <option value="<?= h($st) ?>" <?= $st === $hwStatus ? 'selected' : '' ?>><?= h($st) ?></option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <div class="admin-field">
+ <label for="col-hw-notes">Notes</label>
+ <textarea id="col-hw-notes" name="notes" rows="3"><?= h($collection_hw_edit['notes'] ?? '') ?></textarea>
+ </div>
+ <div class="admin-field">
+ <label>Left-side photo</label>
+ <?php if ($hwImgPreview !== ''): ?><img class="col-img-preview" src="<?= h($hwImgPreview) ?>" alt="" loading="lazy" /><?php endif; ?>
+ <input type="file" name="image_file" accept="image/jpeg,image/png,image/gif,image/webp" />
+ <input name="image" value="<?= h($collection_hw_edit['image'] ?? '') ?>" placeholder="dtx.jpg" style="margin-top:6px;width:100%;box-sizing:border-box;background:#050000;border:1px solid var(--border);color:#d00000;padding:10px;border-radius:var(--r);font:inherit;" />
+ <?php if ($collection_hw_edit && ($collection_hw_edit['image'] ?? '') !== ''): ?>
+ <label style="display:block;margin-top:8px;font-size:var(--fs-sm);"><input type="checkbox" name="image_clear" value="1" /> Clear left photo</label>
+ <?php endif; ?>
+ </div>
+ <button type="submit"><?= $collection_hw_edit ? 'Save hardware' : 'Add hardware' ?></button>
+ <?php if ($collection_hw_edit): ?><a href="/admin.php?section=collection" style="margin-left:var(--space-sm);">Cancel</a><?php endif; ?>
+ </form>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div class="panel">
+ <div class="panel-head">Hardware (<?= count($collection['hardware']) ?>)</div>
+ <div class="panel-body">
+ <?php if (empty($collection['hardware'])): ?><p class="muted">None yet.</p>
+ <?php else: ?><ul class="admin-list">
+ <?php foreach ($collection['hardware'] as $hw): ?>
+ <li class="admin-item"><strong><?= h($hw['name']) ?></strong> <span class="type-badge"><?= h($hw['status']) ?></span><br>
+ <small><?= h($hw['model']) ?></small><br>
+ <a href="/admin.php?section=collection&hw_edit=<?= urlencode($hw['id']) ?>#col-hw-form">Edit</a> ·
+ <form class="inline-form" method="post" action="/admin.php?section=collection" onsubmit="return confirm('Delete hardware?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" /><input type="hidden" name="do" value="collection_hardware_item_delete" /><input type="hidden" name="id" value="<?= h($hw['id']) ?>" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Delete</button>
+ </form>
+ </li>
+ <?php endforeach; ?>
+ </ul><?php endif; ?>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <h2 id="online-admin" style="font-size:var(--fs-lg);margin:var(--space-2xl) 0 var(--space-md);">Edit Song Packs</h2>
+ <div class="admin-cols">
+ <div>
+ <div class="panel">
+ <div class="panel-head"><?= $collection_online_cat_edit ? 'Edit game (category)' : 'Add game (category)' ?></div>
+ <div class="panel-body">
+ <form method="post" action="/admin.php?section=collection">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="collection_online_cat_save" />
+ <input type="hidden" name="original_id" value="<?= h($collection_online_cat_edit['id'] ?? '') ?>" />
+ <div class="admin-field">
+ <label for="col-online-cat-name">Game name</label>
+ <input id="col-online-cat-name" name="name" value="<?= h($collection_online_cat_edit['name'] ?? '') ?>" required placeholder="Pop'n Music Lively" />
+ </div>
+ <button type="submit"><?= $collection_online_cat_edit ? 'Update game' : 'Add game' ?></button>
+ <?php if ($collection_online_cat_edit): ?>
+ <a href="/admin.php?section=collection#online-admin" style="margin-left:var(--space-sm);">Cancel</a>
+ <?php endif; ?>
+ </form>
+ </div>
+ </div>
+
+ <div class="panel" id="col-pack-form">
+ <div class="panel-head"><?= $collection_online_entry_edit ? 'Edit song pack (left photo + fields)' : 'Add song pack' ?></div>
+ <div class="panel-body">
+ <?php if (!$collection['online']): ?>
+ <p class="muted">Add a game category first (e.g. Pop'n Music Lively).</p>
+ <?php else: ?>
+ <form method="post" action="/admin.php?section=collection" enctype="multipart/form-data">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="collection_online_entry_save" />
+ <input type="hidden" name="original_id" value="<?= h($collection_online_entry_edit['id'] ?? '') ?>" />
+ <div class="admin-field">
+ <label for="col-online-entry-cat">Game</label>
+ <select id="col-online-entry-cat" name="category_id" required style="<?= h($adminSelectStyle) ?>">
+ <?php
+ $selectedCat = $collection_online_entry_cat !== '' ? $collection_online_entry_cat : ($collection['online'][0]['id'] ?? '');
+ foreach ($collection['online'] as $cat):
+ ?>
+ <option value="<?= h($cat['id']) ?>" <?= $cat['id'] === $selectedCat ? 'selected' : '' ?>><?= h($cat['name']) ?></option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <div class="admin-field">
+ <label for="col-online-entry-title">Song pack name</label>
+ <input id="col-online-entry-title" name="title" value="<?= h($collection_online_entry_edit['title'] ?? '') ?>" required placeholder="Music Pack vol.1" />
+ </div>
+ <div class="admin-field">
+ <label for="col-online-entry-url">Store / pack URL</label>
+ <input id="col-online-entry-url" name="url" type="url" value="<?= h($collection_online_entry_edit['url'] ?? '') ?>" placeholder="https://p.eagate.573.jp/..." />
+ </div>
+ <div class="admin-field">
+ <label for="col-online-entry-status">Status</label>
+ <select id="col-online-entry-status" name="status" style="<?= h($adminSelectStyle) ?>">
+ <?php
+ $curOnlineStatus = $collection_online_entry_edit['status'] ?? 'Owned';
+ foreach (COLLECTION_STATUSES as $st):
+ ?>
+ <option value="<?= h($st) ?>" <?= $st === $curOnlineStatus ? 'selected' : '' ?>><?= h($st) ?></option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <div class="admin-field">
+ <label for="col-online-entry-notes">Notes</label>
+ <textarea id="col-online-entry-notes" name="notes" rows="3"><?= h($collection_online_entry_edit['notes'] ?? '') ?></textarea>
+ </div>
+ <div class="admin-field">
+ <label>Left-side photo</label>
+ <?php if ($packImgPreview !== ''): ?>
+ <img class="col-img-preview" src="<?= h($packImgPreview) ?>" alt="Current photo" loading="lazy" />
+ <?php endif; ?>
+ <label for="col-online-entry-image-file" style="font-size:var(--fs-sm);">Upload new photo</label>
+ <input id="col-online-entry-image-file" type="file" name="image_file" accept="image/jpeg,image/png,image/gif,image/webp" />
+ <p class="muted" style="font-size:var(--fs-xs);margin:6px 0;">JPEG / PNG / GIF / WebP · max 2&nbsp;MB</p>
+ <label for="col-online-entry-image" style="font-size:var(--fs-sm);">Or filename / path / URL</label>
+ <input id="col-online-entry-image" name="image" value="<?= h($collection_online_entry_edit['image'] ?? '') ?>" placeholder="pack.jpg" />
+ <?php if ($collection_online_entry_edit && ($collection_online_entry_edit['image'] ?? '') !== ''): ?>
+ <label style="display:block;margin-top:8px;font-size:var(--fs-sm);">
+ <input type="checkbox" name="image_clear" value="1" /> Clear left photo
+ </label>
+ <?php endif; ?>
+ </div>
+ <button type="submit"><?= $collection_online_entry_edit ? 'Save song pack' : 'Add song pack' ?></button>
+ <?php if ($collection_online_entry_edit): ?>
+ <a href="/admin.php?section=collection#online-admin" style="margin-left:var(--space-sm);">Cancel</a>
+ <?php endif; ?>
+ </form>
+ <?php endif; ?>
+ </div>
+ </div>
+ </div>
+
+ <div>
+ <div class="panel">
+ <div class="panel-head">Pack games (<?= count($collection['online']) ?>)</div>
+ <div class="panel-body">
+ <?php if (!$collection['online']): ?>
+ <p class="muted">No pack games yet.</p>
+ <?php else: ?>
+ <ul class="admin-list">
+ <?php foreach ($collection['online'] as $cat): ?>
+ <li class="admin-item">
+ <strong><?= h($cat['name']) ?></strong>
+ <small> (<?= count($cat['entries']) ?> pack(s))</small><br>
+ <a href="/admin.php?section=collection&online_cat_edit=<?= urlencode($cat['id']) ?>#online-admin">Rename</a>
+ ·
+ <form class="inline-form" method="post" action="/admin.php?section=collection" onsubmit="return confirm('Delete this game and all its song packs?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="collection_online_cat_delete" />
+ <input type="hidden" name="id" value="<?= h($cat['id']) ?>" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Delete game</button>
+ </form>
+ </li>
+ <?php endforeach; ?>
+ </ul>
+ <?php endif; ?>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <?php /* =========================== RUNESCAPE ========================= */ ?>
+ <?php elseif ($section === 'runescape'): ?>
+ <div class="admin-cols">
+ <div>
+ <div class="panel">
+ <div class="panel-head">Edit Welcome list</div>
+ <div class="panel-body">
+ <form method="post" action="/admin.php?section=runescape">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="runescape_welcome_save" />
+ <div class="admin-field">
+ <label for="rs-welcome">Content &mdash; one bullet per line. A line wrapped like <code>::Label::</code> renders as a bold heading instead of a bullet, same as the site's colon-nav style.</label>
+ <textarea id="rs-welcome" name="welcome" rows="12" spellcheck="false"><?= h($runescape_welcome_text) ?></textarea>
+ </div>
+ <button type="submit">Save Welcome list</button>
+ <a href="/gaming/runescape/" target="_blank" rel="noopener noreferrer" style="margin-left:var(--space-sm);">View page &#8599;</a>
+ </form>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div class="panel">
+ <div class="panel-head">Add bank / inventory screenshot</div>
+ <div class="panel-body">
+ <form method="post" action="/admin.php?section=runescape" enctype="multipart/form-data">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="runescape_screenshot_add" />
+ <div class="admin-field">
+ <label for="rs-shot-file">Screenshot (JPEG, PNG, GIF, or WebP, max 2 MB)</label>
+ <input id="rs-shot-file" type="file" name="screenshot" accept="image/jpeg,image/png,image/gif,image/webp" required />
+ </div>
+ <div class="admin-field">
+ <label for="rs-shot-caption">Caption</label>
+ <input id="rs-shot-caption" name="caption" placeholder="Full bank, tab 1" />
+ </div>
+ <button type="submit">Add screenshot</button>
+ </form>
+ </div>
+ </div>
+
+ <div class="panel">
+ <div class="panel-head">Screenshots (<?= count($runescape['screenshots']) ?>)</div>
+ <div class="panel-body">
+ <?php if (empty($runescape['screenshots'])): ?><p class="muted">None yet &mdash; the public page shows placeholder boxes until you add some.</p>
+ <?php else: ?><ul class="admin-list">
+ <?php foreach ($runescape['screenshots'] as $shot): ?>
+ <li class="admin-item">
+ <img src="<?= h(runescape_image_url($shot['filename'])) ?>" alt="" loading="lazy" class="col-img-preview" /><br>
+ <strong><?= h($shot['caption'] !== '' ? $shot['caption'] : $shot['filename']) ?></strong><br>
+ <form class="inline-form" method="post" action="/admin.php?section=runescape" onsubmit="return confirm('Delete screenshot?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" /><input type="hidden" name="do" value="runescape_screenshot_delete" /><input type="hidden" name="id" value="<?= h($shot['id']) ?>" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Delete</button>
+ </form>
+ </li>
+ <?php endforeach; ?>
+ </ul><?php endif; ?>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <h2 id="rs-era-form" style="font-size:var(--fs-lg);margin:var(--space-2xl) 0 var(--space-md);">Edit Eras</h2>
+ <p class="muted" style="margin-bottom:var(--space-md);">Groups like "2004 &middot; Lost City Era" or "Other Era Servers" &mdash; each holds one or more servers below.</p>
+ <div class="admin-cols" style="margin-bottom:var(--space-2xl);">
+ <div>
+ <div class="panel">
+ <div class="panel-head"><?= $rs_era_edit ? 'Edit era' : 'Add era' ?></div>
+ <div class="panel-body">
+ <form method="post" action="/admin.php?section=runescape" enctype="multipart/form-data">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="runescape_era_save" />
+ <input type="hidden" name="original_id" value="<?= h($rs_era_edit['id'] ?? '') ?>" />
+ <div class="admin-field">
+ <label for="rs-era-title">Title</label>
+ <input id="rs-era-title" name="title" value="<?= h($rs_era_edit['title'] ?? '') ?>" required placeholder="2004 &middot; Lost City Era" />
+ </div>
+ <div class="admin-field">
+ <label for="rs-era-subtitle">Subtitle (optional, shown under the title)</label>
+ <input id="rs-era-subtitle" name="subtitle" value="<?= h($rs_era_edit['subtitle'] ?? '') ?>" placeholder="Preservation servers recreating RuneScape as it was in 2004." />
+ </div>
+ <div class="admin-field">
+ <label>Banner image (optional)</label>
+ <?php $rsEraImgSrc = runescape_image_url($rs_era_edit['image'] ?? ''); ?>
+ <?php if ($rsEraImgSrc !== ''): ?><img class="col-img-preview" src="<?= h($rsEraImgSrc) ?>" alt="" loading="lazy" /><?php endif; ?>
+ <input type="file" name="era_image_file" accept="image/jpeg,image/png,image/gif,image/webp" />
+ <input name="image" value="<?= h($rs_era_edit['image'] ?? '') ?>" placeholder="era-banner.jpg" style="margin-top:6px;width:100%;box-sizing:border-box;background:#050000;border:1px solid var(--border);color:#d00000;padding:10px;border-radius:var(--r);font:inherit;" />
+ <?php if (!empty($rs_era_edit['image'])): ?>
+ <label style="display:block;margin-top:8px;font-size:var(--fs-sm);"><input type="checkbox" name="era_image_file_clear" value="1" /> Clear banner image</label>
+ <?php endif; ?>
+ </div>
+ <button type="submit"><?= $rs_era_edit ? 'Save era' : 'Add era' ?></button>
+ <?php if ($rs_era_edit): ?><a href="/admin.php?section=runescape#rs-era-form" style="margin-left:var(--space-sm);">Cancel</a><?php endif; ?>
+ </form>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div class="panel">
+ <div class="panel-head">Eras (<?= count($runescape['eras']) ?>)</div>
+ <div class="panel-body">
+ <?php if (empty($runescape['eras'])): ?><p class="muted">None yet.</p>
+ <?php else: ?><ul class="admin-list">
+ <?php foreach ($runescape['eras'] as $era): ?>
+ <?php $rsEraThumb = runescape_image_url($era['image'] ?? ''); ?>
+ <li class="admin-item">
+ <?php if ($rsEraThumb !== ''): ?><img src="<?= h($rsEraThumb) ?>" alt="" loading="lazy" class="col-img-preview" /><br><?php endif; ?>
+ <strong><?= h($era['title']) ?></strong> <span class="type-badge"><?= count($era['servers']) ?> server<?= count($era['servers']) === 1 ? '' : 's' ?></span><br>
+ <a href="/admin.php?section=runescape&era_edit=<?= urlencode($era['id']) ?>#rs-era-form">Edit</a> ·
+ <form class="inline-form" method="post" action="/admin.php?section=runescape" onsubmit="return confirm('Delete era and all its servers?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" /><input type="hidden" name="do" value="runescape_era_delete" /><input type="hidden" name="id" value="<?= h($era['id']) ?>" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Delete</button>
+ </form>
+ </li>
+ <?php endforeach; ?>
+ </ul><?php endif; ?>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <h2 id="rs-server-form" style="font-size:var(--fs-lg);margin:var(--space-2xl) 0 var(--space-md);">Edit Servers</h2>
+ <p class="muted" style="margin-bottom:var(--space-md);">One entry per server card &mdash; add an era above first if you need a new group.</p>
+ <div class="admin-cols" style="margin-bottom:var(--space-2xl);">
+ <div>
+ <div class="panel">
+ <div class="panel-head"><?= $rs_server_edit ? 'Edit server' : 'Add server' ?></div>
+ <div class="panel-body">
+ <?php if (!$runescape['eras']): ?>
+ <p class="muted">Add an era first.</p>
+ <?php else: ?>
+ <form method="post" action="/admin.php?section=runescape" enctype="multipart/form-data">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="runescape_server_save" />
+ <input type="hidden" name="original_id" value="<?= h($rs_server_edit['id'] ?? '') ?>" />
+ <div class="admin-field">
+ <label for="rs-srv-era">Era</label>
+ <select id="rs-srv-era" name="era_id" required style="<?= h($adminSelectStyle) ?>">
+ <?php foreach ($runescape['eras'] as $era): ?>
+ <option value="<?= h($era['id']) ?>" <?= $era['id'] === $rs_server_era_default ? 'selected' : '' ?>><?= h($era['title']) ?></option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <div class="admin-field">
+ <label for="rs-srv-name">Server name</label>
+ <input id="rs-srv-name" name="name" value="<?= h($rs_server_edit['name'] ?? '') ?>" required placeholder="Lost City" />
+ </div>
+ <div class="admin-field">
+ <label for="rs-srv-url">URL (also used as the name link)</label>
+ <input id="rs-srv-url" name="url" value="<?= h($rs_server_edit['url'] ?? '') ?>" placeholder="https://2004.lostcity.rs/" />
+ </div>
+ <div class="admin-field">
+ <label><input type="checkbox" name="featured" value="1" <?= !empty($rs_server_edit['featured']) ? 'checked' : '' ?> /> Featured (accent-colored card)</label>
+ </div>
+ <div class="admin-field">
+ <label>Server image / icon (optional)</label>
+ <?php $rsSrvImgSrc = runescape_image_url($rs_server_edit['image'] ?? ''); ?>
+ <?php if ($rsSrvImgSrc !== ''): ?><img class="col-img-preview" src="<?= h($rsSrvImgSrc) ?>" alt="" loading="lazy" /><?php endif; ?>
+ <input type="file" name="srv_image_file" accept="image/jpeg,image/png,image/gif,image/webp" />
+ <input name="image" value="<?= h($rs_server_edit['image'] ?? '') ?>" placeholder="lost-city.png" style="margin-top:6px;width:100%;box-sizing:border-box;background:#050000;border:1px solid var(--border);color:#d00000;padding:10px;border-radius:var(--r);font:inherit;" />
+ <?php if (!empty($rs_server_edit['image'])): ?>
+ <label style="display:block;margin-top:8px;font-size:var(--fs-sm);"><input type="checkbox" name="srv_image_file_clear" value="1" /> Clear image</label>
+ <?php endif; ?>
+ </div>
+ <div class="admin-field">
+ <label for="rs-srv-desc">Description</label>
+ <textarea id="rs-srv-desc" name="desc" rows="3"><?= h($rs_server_edit['desc'] ?? '') ?></textarea>
+ </div>
+ <div class="admin-field">
+ <label for="rs-srv-highlights">Highlights &mdash; one per line</label>
+ <textarea id="rs-srv-highlights" name="highlights" rows="4"><?= h(implode("\n", $rs_server_edit['highlights'] ?? [])) ?></textarea>
+ </div>
+ <div class="admin-field">
+ <label for="rs-srv-links">Extra links &mdash; one per line as <code>Label | URL</code></label>
+ <textarea id="rs-srv-links" name="links" rows="2" placeholder="Play | https://2004.lostcity.rs/&#10;Website | https://lostcity.rs/"><?php
+ $rs_srv_links_text = [];
+ foreach (($rs_server_edit['links'] ?? []) as $lk) $rs_srv_links_text[] = $lk['label'] . ' | ' . $lk['url'];
+ echo h(implode("\n", $rs_srv_links_text));
+ ?></textarea>
+ </div>
+ <button type="submit"><?= $rs_server_edit ? 'Save server' : 'Add server' ?></button>
+ <?php if ($rs_server_edit): ?><a href="/admin.php?section=runescape#rs-server-form" style="margin-left:var(--space-sm);">Cancel</a><?php endif; ?>
+ </form>
+ <?php endif; ?>
+ </div>
+ </div>
+ </div>
+ <div>
+ <?php foreach ($runescape['eras'] as $era): ?>
+ <div class="panel">
+ <div class="panel-head"><?= h($era['title']) ?> (<?= count($era['servers']) ?>)</div>
+ <div class="panel-body">
+ <?php if (empty($era['servers'])): ?><p class="muted">None yet.</p>
+ <?php else: ?><ul class="admin-list">
+ <?php foreach ($era['servers'] as $srv): ?>
+ <?php $rsSrvThumb = runescape_image_url($srv['image'] ?? ''); ?>
+ <li class="admin-item">
+ <?php if ($rsSrvThumb !== ''): ?><img src="<?= h($rsSrvThumb) ?>" alt="" loading="lazy" class="col-img-preview" /><br><?php endif; ?>
+ <strong><?= h($srv['name']) ?></strong><?= $srv['featured'] ? ' <span class="type-badge">Featured</span>' : '' ?><br>
+ <a href="/admin.php?section=runescape&server_edit=<?= urlencode($srv['id']) ?>&era=<?= urlencode($era['id']) ?>#rs-server-form">Edit</a> ·
+ <form class="inline-form" method="post" action="/admin.php?section=runescape" onsubmit="return confirm('Delete server?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" /><input type="hidden" name="do" value="runescape_server_delete" /><input type="hidden" name="era_id" value="<?= h($era['id']) ?>" /><input type="hidden" name="id" value="<?= h($srv['id']) ?>" />
+ <button type="submit" class="reset-button" style="color:var(--accent);">Delete</button>
+ </form>
+ </li>
+ <?php endforeach; ?>
+ </ul><?php endif; ?>
+ </div>
+ </div>
+ <?php endforeach; ?>
+ </div>
+ </div>
+
+ <h2 id="rs-links-form" style="font-size:var(--fs-lg);margin:var(--space-2xl) 0 var(--space-md);">Edit Server Lists section</h2>
+ <div class="panel" style="margin-bottom:var(--space-2xl);">
+ <div class="panel-body">
+ <form method="post" action="/admin.php?section=runescape">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="runescape_links_save" />
+ <div class="admin-field">
+ <label for="rs-links-title">Section title</label>
+ <input id="rs-links-title" name="links_title" value="<?= h($runescape['links_section']['title']) ?>" />
+ </div>
+ <div class="admin-field">
+ <label for="rs-links-items">Links &mdash; one per line as <code>Label | URL | description</code> (description optional)</label>
+ <textarea id="rs-links-items" name="links_items" rows="4" placeholder="Rune-Server | https://rune-server.org/ | private server development community"><?php
+ $rs_links_text = [];
+ foreach ($runescape['links_section']['items'] as $it) $rs_links_text[] = $it['label'] . ' | ' . $it['url'] . ($it['desc'] !== '' ? ' | ' . $it['desc'] : '');
+ echo h(implode("\n", $rs_links_text));
+ ?></textarea>
+ </div>
+ <div class="admin-field">
+ <label for="rs-links-disclaimer">Disclaimer line</label>
+ <input id="rs-links-disclaimer" name="links_disclaimer" value="<?= h($runescape['links_section']['disclaimer']) ?>" placeholder="Unofficial private servers, not affiliated with Jagex Ltd." />
+ </div>
+ <button type="submit">Save Server Lists</button>
+ </form>
+ </div>
+ </div>
+
+ <?php /* ============================ RADIO ============================ */ ?>
+ <?php elseif ($section === 'radio'): ?>
+ <p class="muted">Stations for <a href="https://radio.sillylaird.ca/" target="_blank" rel="noopener noreferrer">radio.sillylaird.ca</a> — the list order here is the order on the page. Plain <code>http://</code> streams need <em>Relay</em> ticked, otherwise browsers block them as mixed content on the HTTPS page.</p>
+
+ <div class="admin-cols">
+ <div>
+ <form method="post" action="/admin.php?section=radio">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="radio_save" />
+ <?php if ($radio_edit): ?><input type="hidden" name="original_id" value="<?= h($radio_edit['id']) ?>" /><?php endif; ?>
+ <div class="panel">
+ <div class="panel-head"><?= $radio_edit ? 'Edit station — ' . h($radio_edit['name']) : 'Add station' ?></div>
+ <div class="panel-body">
+ <div class="admin-field">
+ <label for="ra-name">Name</label>
+ <input id="ra-name" name="name" value="<?= h($radio_edit['name'] ?? '') ?>" required />
+ </div>
+ <div class="admin-field">
+ <label for="ra-url">Stream URL</label>
+ <input id="ra-url" name="url" value="<?= h($radio_edit['url'] ?? '') ?>" placeholder="https://host/stream or /stream for my own Icecast" required />
+ </div>
+ <div class="admin-field">
+ <label for="ra-homepage">Homepage (optional)</label>
+ <input id="ra-homepage" name="homepage" value="<?= h($radio_edit['homepage'] ?? '') ?>" placeholder="https://example.org/" />
+ </div>
+ <div class="admin-field">
+ <label for="ra-genre">Genre / blurb</label>
+ <input id="ra-genre" name="genre" value="<?= h($radio_edit['genre'] ?? '') ?>" placeholder="anime / weeb / live DJs" />
+ </div>
+ <div class="admin-field">
+ <label for="ra-codec">Codec</label>
+ <input id="ra-codec" name="codec" value="<?= h($radio_edit['codec'] ?? '') ?>" placeholder="mp3 128" />
+ </div>
+ <div class="admin-field">
+ <label><input type="checkbox" name="relay" value="1" <?= !empty($radio_edit['relay']) ? 'checked' : '' ?> /> Relay through this server (required for http:// streams)</label>
+ </div>
+ <div class="admin-field">
+ <label for="ra-meta-type">Now-playing API type (optional)</label>
+ <select id="ra-meta-type" name="meta_type">
+ <option value="">— none —</option>
+ <?php foreach (RADIO_META_TYPES as $mt): ?>
+ <option value="<?= h($mt) ?>" <?= ($radio_edit['meta']['type'] ?? '') === $mt ? 'selected' : '' ?>><?= h($mt) ?></option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <div class="admin-field">
+ <label for="ra-meta-url">Now-playing API URL</label>
+ <input id="ra-meta-url" name="meta_url" value="<?= h($radio_edit['meta']['url'] ?? '') ?>" placeholder="https://host/status-json.xsl" />
+ </div>
+ <button type="submit"><?= $radio_edit ? 'Update station' : 'Add station' ?></button>
+ <?php if ($radio_edit): ?> &middot; <a href="/admin.php?section=radio">cancel</a><?php endif; ?>
+ </div>
+ </div>
+ </form>
+ </div>
+
+ <div class="panel">
+ <div class="panel-head">Now-playing API types</div>
+ <div class="panel-body">
+ <ul class="admin-list">
+ <li class="admin-item"><strong>icecast</strong> — any Icecast <code>status-json.xsl</code></li>
+ <li class="admin-item"><strong>radio</strong> — r/a/dio style <code>/api</code></li>
+ <li class="admin-item"><strong>plaza</strong> — plaza.one <code>/status</code></li>
+ <li class="admin-item"><strong>gensokyo</strong> — Gensokyo Radio playing API</li>
+ <li class="admin-item"><strong>somafm</strong> — <code>somafm.com/songs/&lt;channel&gt;.json</code></li>
+ </ul>
+ <p class="muted">Leave the type empty and the player just shows the station name. Lookups are proxied by <code>/meta</code> on radio and cached 10s.</p>
+ </div>
+ </div>
+ </div>
+
+ <div class="panel">
+ <div class="panel-head">Stations (<?= count($radio_stations) ?>)</div>
+ <div class="panel-body">
+ <?php if (!$radio_stations): ?>
+ <p class="muted">No stations yet.</p>
+ <?php else: ?>
+ <ul class="admin-list">
+ <?php foreach ($radio_stations as $rs): ?>
+ <li class="admin-item">
+ <strong><?= h($rs['name']) ?></strong>
+ <?php if ($rs['genre'] !== ''): ?> <span class="muted">— <?= h($rs['genre']) ?></span><?php endif; ?>
+ <?php if ($rs['relay']): ?> <span class="muted">[relay]</span><?php endif; ?>
+ <?php if (!empty($rs['meta']['type'])): ?> <span class="muted">[<?= h($rs['meta']['type']) ?>]</span><?php endif; ?>
+ <br>
+ <small><?= h($rs['url']) ?></small><br>
+ <a href="/admin.php?section=radio&radio_edit=<?= urlencode($rs['id']) ?>">Edit</a> &middot;
+ <form class="inline-form" method="post" action="/admin.php?section=radio">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="radio_move" />
+ <input type="hidden" name="id" value="<?= h($rs['id']) ?>" />
+ <input type="hidden" name="dir" value="up" />
+ <button type="submit">↑</button>
+ </form>
+ <form class="inline-form" method="post" action="/admin.php?section=radio">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="radio_move" />
+ <input type="hidden" name="id" value="<?= h($rs['id']) ?>" />
+ <input type="hidden" name="dir" value="down" />
+ <button type="submit">↓</button>
+ </form>
+ &middot;
+ <form class="inline-form" method="post" action="/admin.php?section=radio" onsubmit="return confirm('Delete this station?');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="radio_delete" />
+ <input type="hidden" name="id" value="<?= h($rs['id']) ?>" />
+ <button type="submit">Delete</button>
+ </form>
+ </li>
+ <?php endforeach; ?>
+ </ul>
+ <?php endif; ?>
+ </div>
+ </div>
+
+ <?php elseif ($section === 'traffic'): ?>
+ <p class="muted">Privacy-friendly traffic log for all sillylaird.ca pages. Stores host, path, coarse browser · OS label, language, referrer host, and the visitor’s local hit number. <strong>No IP addresses</strong> are stored. <strong>Bots are counted separately</strong> and never inflate human hit / session totals.</p>
+
+ <?php if ($traffic_err): ?>
+ <div class="admin-flash err">Traffic DB error: <?= h($traffic_err) ?></div>
+ <?php else: ?>
+ <div id="traffic-live-root"
+ data-poll="/api/traffic-stats.php"
+ data-poll-ms="2000"
+ data-summary="<?= h(json_encode($traffic_summary, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) ?>">
+ <div class="traffic-live-bar" id="traffic-live-bar" aria-live="polite">
+ <span class="traffic-live-dot" aria-hidden="true"></span>
+ <span id="traffic-live-status">Live</span>
+ <span class="muted" id="traffic-live-ago">· connecting…</span>
+ </div>
+
+ <div class="traffic-kpi-grid" id="traffic-kpi-grid" aria-label="Traffic summary">
+ <div class="traffic-kpi">
+ <span class="traffic-kpi-val" data-kpi="total_hits" data-value="<?= (int)$traffic_summary['total_hits'] ?>"><?= (int)$traffic_summary['total_hits'] ?></span>
+ <span class="traffic-kpi-label">Human hits</span>
+ </div>
+ <div class="traffic-kpi">
+ <span class="traffic-kpi-val" data-kpi="unique_sessions" data-value="<?= (int)$traffic_summary['unique_sessions'] ?>"><?= (int)$traffic_summary['unique_sessions'] ?></span>
+ <span class="traffic-kpi-label">Unique sessions</span>
+ </div>
+ <div class="traffic-kpi">
+ <span class="traffic-kpi-val" data-kpi="today" data-value="<?= (int)$traffic_summary['today'] ?>"><?= (int)$traffic_summary['today'] ?></span>
+ <span class="traffic-kpi-label">Humans · 24h</span>
+ </div>
+ <div class="traffic-kpi">
+ <span class="traffic-kpi-val" data-kpi="week" data-value="<?= (int)$traffic_summary['week'] ?>"><?= (int)$traffic_summary['week'] ?></span>
+ <span class="traffic-kpi-label">Humans · 7 days</span>
+ </div>
+ <div class="traffic-kpi traffic-kpi--bots">
+ <span class="traffic-kpi-val" data-kpi="bot_hits" data-value="<?= (int)($traffic_summary['bot_hits'] ?? 0) ?>"><?= (int)($traffic_summary['bot_hits'] ?? 0) ?></span>
+ <span class="traffic-kpi-label">Bot hits (all time)</span>
+ </div>
+ <div class="traffic-kpi traffic-kpi--bots">
+ <span class="traffic-kpi-val" data-kpi="bot_today" data-value="<?= (int)($traffic_summary['bot_today'] ?? 0) ?>"><?= (int)($traffic_summary['bot_today'] ?? 0) ?></span>
+ <span class="traffic-kpi-label">Bots · 24h</span>
+ </div>
+ <div class="traffic-kpi traffic-kpi--bots">
+ <span class="traffic-kpi-val" data-kpi="bot_week" data-value="<?= (int)($traffic_summary['bot_week'] ?? 0) ?>"><?= (int)($traffic_summary['bot_week'] ?? 0) ?></span>
+ <span class="traffic-kpi-label">Bots · 7 days</span>
+ </div>
+ <div class="traffic-kpi">
+ <span class="traffic-kpi-val" data-kpi="total_pages" data-value="<?= (int)$traffic_summary['total_pages'] ?>"><?= (int)$traffic_summary['total_pages'] ?></span>
+ <span class="traffic-kpi-label">Pages seen</span>
+ </div>
+ </div>
+
+ <?php if ($traffic_analytics): ?>
+ <div id="traffic-analytics" class="traffic-analytics" data-ready="0">
+ <pre id="traffic-analytics-data" class="visually-hidden" hidden><?= h(json_encode($traffic_analytics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) ?></pre>
+
+ <div class="panel traffic-chart-panel">
+ <div class="panel-head">Hits — last 30 days <span class="muted">(UTC)</span></div>
+ <div class="panel-body">
+ <canvas id="chart-daily" class="traffic-chart" width="900" height="280" role="img" aria-label="Line chart of human and bot hits over the last 30 days"></canvas>
+ <div class="traffic-chart-legend" aria-hidden="true">
+ <span class="traffic-swatch traffic-swatch--hits"></span> Human hits
+ <span class="traffic-swatch traffic-swatch--uniq"></span> Unique sessions
+ <span class="traffic-swatch traffic-swatch--bots"></span> Bots
+ </div>
+ </div>
+ </div>
+
+ <div class="panel traffic-chart-panel">
+ <div class="panel-head">Hits — last 24 hours <span class="muted">(UTC)</span></div>
+ <div class="panel-body">
+ <canvas id="chart-hourly" class="traffic-chart" width="900" height="240" role="img" aria-label="Bar chart of human and bot hits by hour for the last 24 hours"></canvas>
+ <div class="traffic-chart-legend" aria-hidden="true">
+ <span class="traffic-swatch traffic-swatch--hits"></span> Human hits
+ <span class="traffic-swatch traffic-swatch--bots"></span> Bots
+ </div>
+ </div>
+ </div>
+
+ <div class="admin-cols traffic-chart-row">
+ <div class="panel traffic-chart-panel">
+ <div class="panel-head">Top pages <span class="muted">(humans)</span></div>
+ <div class="panel-body">
+ <canvas id="chart-pages" class="traffic-chart traffic-chart--hbar" width="420" height="320" role="img" aria-label="Horizontal bar chart of top pages"></canvas>
+ </div>
+ </div>
+ <div class="panel traffic-chart-panel">
+ <div class="panel-head">Visitors (browser · OS) — 7 days <span class="muted">(humans only)</span></div>
+ <div class="panel-body">
+ <canvas id="chart-visitors" class="traffic-chart traffic-chart--hbar" width="420" height="320" role="img" aria-label="Horizontal bar chart of visitor types"></canvas>
+ </div>
+ </div>
+ </div>
+
+ <div class="panel traffic-chart-panel">
+ <div class="panel-head">Bots — 7 days <span class="muted">(not counted as visitors)</span></div>
+ <div class="panel-body">
+ <canvas id="chart-bots" class="traffic-chart traffic-chart--hbar" width="900" height="220" role="img" aria-label="Horizontal bar chart of bot types"></canvas>
+ </div>
+ </div>
+
+ <div class="admin-cols traffic-chart-row">
+ <div class="panel traffic-chart-panel">
+ <div class="panel-head">Hosts — 7 days</div>
+ <div class="panel-body">
+ <canvas id="chart-hosts" class="traffic-chart traffic-chart--hbar" width="420" height="260" role="img" aria-label="Horizontal bar chart of hosts"></canvas>
+ </div>
+ </div>
+ <div class="panel traffic-chart-panel">
+ <div class="panel-head">Languages — 7 days</div>
+ <div class="panel-body">
+ <canvas id="chart-langs" class="traffic-chart traffic-chart--hbar" width="420" height="260" role="img" aria-label="Horizontal bar chart of languages"></canvas>
+ </div>
+ </div>
+ </div>
+
+ <div class="panel traffic-chart-panel">
+ <div class="panel-head">Referrers — 7 days</div>
+ <div class="panel-body">
+ <canvas id="chart-refs" class="traffic-chart traffic-chart--hbar" width="900" height="280" role="img" aria-label="Horizontal bar chart of referrer hosts"></canvas>
+ </div>
+ </div>
+ </div>
+ <?php endif; ?>
+
+ <div class="admin-cols" style="margin-bottom:var(--space-md);">
+ <div class="panel">
+ <div class="panel-head">Maintenance</div>
+ <div class="panel-body">
+ <p class="muted" style="margin-top:0;">Hit detail rows auto-prune after <?= (int)TRAFFIC_HIT_RETENTION_DAYS ?> days. Page totals are kept until you reset them. Charts use the hit log (not lifetime page totals).</p>
+ <form method="post" action="/admin.php?section=traffic" style="display:inline;" onsubmit="return confirm('Clear recent hit log? Page totals will be kept.');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="traffic_clear_recent" />
+ <button type="submit">Clear recent log</button>
+ </form>
+ <form method="post" action="/admin.php?section=traffic" style="display:inline;margin-left:.5rem;" onsubmit="return confirm('Reset ALL traffic stats and logs? This cannot be undone.');">
+ <input type="hidden" name="csrf" value="<?= h($csrf) ?>" />
+ <input type="hidden" name="do" value="traffic_reset_all" />
+ <button type="submit">Reset everything</button>
+ </form>
+ </div>
+ </div>
+ </div>
+
+ <div class="panel">
+ <div class="panel-head">Top pages (by hits)</div>
+ <div class="panel-body" style="overflow-x:auto;" id="traffic-top-wrap">
+ <?php if (!$traffic_top): ?>
+ <p class="muted" id="traffic-top-empty">No hits recorded yet. Browse a page and refresh.</p>
+ <table class="admin-table" id="traffic-top-table" hidden>
+ <thead>
+ <tr>
+ <th>Page</th>
+ <th>Human hits</th>
+ <th>Uniq</th>
+ <th>Bots</th>
+ <th>Local #</th>
+ <th>Last visitor</th>
+ <th>Last hit</th>
+ </tr>
+ </thead>
+ <tbody id="traffic-top-body"></tbody>
+ </table>
+ <?php else: ?>
+ <p class="muted" id="traffic-top-empty" hidden>No hits recorded yet. Browse a page and refresh.</p>
+ <table class="admin-table" id="traffic-top-table">
+ <thead>
+ <tr>
+ <th>Page</th>
+ <th>Human hits</th>
+ <th>Uniq</th>
+ <th>Bots</th>
+ <th>Local #</th>
+ <th>Last visitor</th>
+ <th>Last hit</th>
+ </tr>
+ </thead>
+ <tbody id="traffic-top-body">
+ <?php foreach ($traffic_top as $p): ?>
+ <tr>
+ <td>
+ <code><?= h($p['host'] . $p['path']) ?></code>
+ </td>
+ <td><?= (int)$p['hits'] ?></td>
+ <td><?= (int)$p['unique_sessions'] ?></td>
+ <td><?= (int)($p['bot_hits'] ?? 0) ?></td>
+ <td><?= (int)$p['last_local_count'] ?></td>
+ <td><?= h($p['last_visitor'] !== '' ? $p['last_visitor'] : '—') ?></td>
+ <td><?= $p['last_hit'] ? h(gmdate('Y-m-d H:i', (int)$p['last_hit'])) . ' UTC' : '—' ?></td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ </table>
+ <?php endif; ?>
+ </div>
+ </div>
+
+ <div class="panel">
+ <div class="panel-head">Recent hits (visitor log)</div>
+ <div class="panel-body" style="overflow-x:auto;" id="traffic-recent-wrap">
+ <?php if (!$traffic_recent): ?>
+ <p class="muted" id="traffic-recent-empty">No recent hits.</p>
+ <table class="admin-table" id="traffic-recent-table" hidden>
+ <thead>
+ <tr>
+ <th>When (UTC)</th>
+ <th>Visitor</th>
+ <th>Type</th>
+ <th>Page</th>
+ <th>Local #</th>
+ <th>Lang</th>
+ <th>From</th>
+ <th>New?</th>
+ </tr>
+ </thead>
+ <tbody id="traffic-recent-body"></tbody>
+ </table>
+ <?php else: ?>
+ <p class="muted" id="traffic-recent-empty" hidden>No recent hits.</p>
+ <table class="admin-table" id="traffic-recent-table">
+ <thead>
+ <tr>
+ <th>When (UTC)</th>
+ <th>Visitor</th>
+ <th>Type</th>
+ <th>Page</th>
+ <th>Local #</th>
+ <th>Lang</th>
+ <th>From</th>
+ <th>New?</th>
+ </tr>
+ </thead>
+ <tbody id="traffic-recent-body">
+ <?php foreach ($traffic_recent as $r):
+ $rowIsBot = !empty($r['is_bot']) || traffic_visitor_is_bot((string)($r['visitor'] ?? ''));
+ ?>
+ <tr class="<?= $rowIsBot ? 'traffic-row--bot' : '' ?>">
+ <td><?= h(gmdate('Y-m-d H:i:s', (int)$r['ts'])) ?></td>
+ <td><?= h($r['visitor'] !== '' ? $r['visitor'] : 'Unknown visitor') ?></td>
+ <td><?= $rowIsBot ? '<span class="type-badge traffic-badge--bot">bot</span>' : '<span class="type-badge">human</span>' ?></td>
+ <td><code><?= h($r['host'] . $r['path']) ?></code></td>
+ <td><?= (int)$r['local_count'] ?></td>
+ <td><?= h($r['lang'] !== '' ? $r['lang'] : '—') ?></td>
+ <td><?= h($r['ref_host'] !== '' ? $r['ref_host'] : '—') ?></td>
+ <td><?= (!$rowIsBot && !empty($r['is_new'])) ? 'yes' : '' ?></td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ </table>
+ <?php endif; ?>
+ </div>
+ </div>
+ </div><!-- #traffic-live-root -->
+ <?php endif; ?>
+
+ <?php endif; ?>
+ </section>
+ </main>
+
+ <?php include __DIR__ . '/partials/footer.php'; ?>
+
+ <?php $_ajs = @filemtime(__DIR__ . '/assets/js/pages/admin.js'); ?>
+ <script defer src="/assets/js/pages/admin.js<?= $_ajs ? '?v=' . $_ajs : '' ?>"></script>
+</body>
+</html>
diff --git a/admin/admin_vibe.php b/admin/admin_vibe.php
new file mode 100644
index 0000000..b418ef4
--- /dev/null
+++ b/admin/admin_vibe.php
@@ -0,0 +1 @@
+<?php header("Location: /admin.php?section=vibe", true, 302); exit;
diff --git a/admin/login.php b/admin/login.php
new file mode 100644
index 0000000..0fe0d1d
--- /dev/null
+++ b/admin/login.php
@@ -0,0 +1,117 @@
+<?php
+require_once __DIR__ . '/../partials/session.php';
+require_once __DIR__ . '/../partials/proxy_helpers.php';
+
+// Post-login destination: honour a local ?ref= path (no open redirects),
+// otherwise land on the unified admin panel.
+$ref = $_GET['ref'] ?? '';
+$dest = (is_string($ref) && $ref !== '' && $ref[0] === '/' && substr($ref, 0, 2) !== '//') ? $ref : '/admin.php';
+
+if (!empty($_SESSION['admin'])) {
+ header('Location: ' . $dest);
+ exit;
+}
+
+if (empty($_SESSION['csrf'])) {
+ $_SESSION['csrf'] = bin2hex(random_bytes(32));
+}
+
+/**
+ * Load admin credentials from outside the document root.
+ * Override path with ADMIN_AUTH_FILE in the php-fpm environment.
+ *
+ * File returns: ['algo' => 'password_hash'|'sha256', 'hash' => string, 'salt' => string?]
+ */
+function admin_auth_config(): ?array {
+ $path = getenv('ADMIN_AUTH_FILE');
+ if (!is_string($path) || $path === '') {
+ $path = '/var/lib/sillylaird/admin_auth.php';
+ }
+ if (!is_file($path) || !is_readable($path)) {
+ return null;
+ }
+ $cfg = include $path;
+ return is_array($cfg) ? $cfg : null;
+}
+
+function admin_password_ok(string $provided): bool {
+ $cfg = admin_auth_config();
+ if ($cfg === null) {
+ return false;
+ }
+ $hash = (string)($cfg['hash'] ?? '');
+ if ($hash === '') {
+ return false;
+ }
+ $algo = strtolower((string)($cfg['algo'] ?? 'password_hash'));
+ if ($algo === 'password_hash' || str_starts_with($hash, '$2y$') || str_starts_with($hash, '$2a$') || str_starts_with($hash, '$argon')) {
+ return password_verify($provided, $hash);
+ }
+ // Legacy: sha256(password + salt)
+ $salt = (string)($cfg['salt'] ?? '');
+ $computed = hash('sha256', $provided . $salt);
+ return hash_equals($hash, $computed);
+}
+
+$error = '';
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!proxy_rate_limit('admin_login', 5, 300)) {
+ http_response_code(429);
+ $error = 'Too many attempts. Try again in a few minutes.';
+ } else {
+ $token = $_POST['csrf'] ?? '';
+ if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
+ $error = 'Bad request.';
+ } else {
+ $provided = $_POST['password'] ?? '';
+ if (is_string($provided) && admin_password_ok($provided)) {
+ session_regenerate_id(true);
+ $_SESSION['admin'] = true;
+ unset($_SESSION['csrf']);
+ header('Location: ' . $dest);
+ exit;
+ }
+ $error = 'Wrong password.';
+ usleep(random_int(150000, 400000));
+ }
+ }
+}
+?>
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <title>Admin Login — SillyLaird</title>
+ <link rel="icon" href="https://www.sillylaird.ca/assets/img/lain.png" />
+ <link rel="stylesheet" href="/assets/css/fonts.css" />
+ <link rel="stylesheet" href="/assets/css/site.css?v=1.5" />
+ <link rel="stylesheet" href="/assets/css/skeleton.css" />
+</head>
+<body>
+ <a class="skip-link" href="#main">Skip to content</a>
+ <?php include $_SERVER['DOCUMENT_ROOT'] . '/partials/header.php'; ?>
+
+ <main id="main" class="wrap stack">
+ <section aria-labelledby="login-title">
+ <h1 id="login-title">Admin Login</h1>
+
+ <?php if ($error): ?>
+ <p style="color: var(--accent); font-size: var(--fs-sm);"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p>
+ <?php endif; ?>
+
+ <form method="post" action="" style="max-width: 360px;">
+ <input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf'], ENT_QUOTES, 'UTF-8') ?>" />
+ <div class="field-group">
+ <label for="password">Password</label>
+ <input type="password" id="password" name="password" autofocus autocomplete="current-password" />
+ </div>
+ <button type="submit">Enter</button>
+ </form>
+ </section>
+ </main>
+
+ <?php include $_SERVER['DOCUMENT_ROOT'] . '/partials/footer.php'; ?>
+</body>
+</html>
diff --git a/admin/logout.php b/admin/logout.php
new file mode 100644
index 0000000..28aa8b3
--- /dev/null
+++ b/admin/logout.php
@@ -0,0 +1,5 @@
+<?php
+require_once __DIR__ . '/../partials/session.php';
+session_destroy();
+header('Location: /admin/login.php');
+exit;
diff --git a/admin/vibe.php b/admin/vibe.php
new file mode 100644
index 0000000..2f93c94
--- /dev/null
+++ b/admin/vibe.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' => '2026-03-17',
+];