diff options
Diffstat (limited to 'admin.php')
| -rw-r--r-- | admin.php | 3742 |
1 files changed, 3742 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): ?> · <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="``` " data-b=" ```">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="">Image</button> + <button type="button" data-md="insert" data-a=" --- ">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> · + <a href="/admin.php?section=blog&edit=<?= (int)$p['id'] ?>">Edit</a> · + <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) ?> · <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> + · + <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 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 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 — 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 ↗</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 — 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 · Lost City Era" or "Other Era Servers" — 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 · 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 — 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 — 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 — one per line as <code>Label | URL</code></label> + <textarea id="rs-srv-links" name="links" rows="2" placeholder="Play | https://2004.lostcity.rs/ 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 — 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): ?> · <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/<channel>.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> · + <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> + · + <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> |
