diff --git a/app/pages/admin.php b/app/pages/admin.php
new file mode 100644
index 0000000..895c040
--- /dev/null
+++ b/app/pages/admin.php
@@ -0,0 +1,356 @@
+hasRight($userId, 'superuser');
+}
+
+if (!$canAdmin) {
+ Feedback::flash('SECURITY', 'PERMISSION_DENIED');
+ header('Location: ' . $app_root);
+ exit;
+}
+
+$postAction = $_POST['action'] ?? '';
+$queryAction = $_GET['action'] ?? '';
+$action = $postAction ?: $queryAction;
+$targetId = isset($_REQUEST['id']) ? (int)$_REQUEST['id'] : null;
+$sectionRegistry = [
+ 'overview' => ['label' => 'Overview', 'position' => 100, 'hook' => null, 'type' => 'core'],
+ 'maintenance' => ['label' => 'Maintenance', 'position' => 200, 'hook' => null, 'type' => 'core'],
+ 'migrations' => ['label' => 'Migrations', 'position' => 300, 'hook' => null, 'type' => 'core'],
+];
+
+$registerSection = static function(array $section) use (&$sectionRegistry): void {
+ $key = strtolower(trim($section['key'] ?? ''));
+ $label = trim((string)($section['label'] ?? ''));
+ if ($key === '' || $label === '') {
+ return;
+ }
+
+ $position = (int)($section['position'] ?? 900);
+ $sectionRegistry[$key] = [
+ 'label' => $label,
+ 'position' => $position,
+ 'hook' => $section['hook'] ?? ('admin.' . $key . '.render'),
+ 'type' => $section['type'] ?? 'plugin',
+ ];
+};
+
+// Hooks sections for plugins
+do_hook('admin.sections.register', [
+ 'register' => $registerSection,
+ 'app_root' => $app_root,
+ 'sections' => &$sectionRegistry,
+]);
+
+uasort($sectionRegistry, static function(array $a, array $b): int {
+ if ($a['position'] === $b['position']) {
+ return strcmp($a['label'], $b['label']);
+ }
+ return $a['position'] <=> $b['position'];
+});
+
+if (empty($sectionRegistry)) {
+ $sectionRegistry = [
+ 'overview' => ['label' => 'Overview', 'position' => 100, 'hook' => null, 'type' => 'core'],
+ ];
+}
+
+$validSections = array_keys($sectionRegistry);
+
+$buildAdminUrl = static function(string $section = 'overview') use ($app_root, &$sectionRegistry): string {
+ if (!isset($sectionRegistry[$section])) {
+ $section = array_key_first($sectionRegistry) ?? 'overview';
+ }
+ $suffix = $section !== 'overview' ? ('§ion=' . urlencode($section)) : '';
+ return $app_root . '?page=admin' . $suffix;
+};
+
+$sectionUrls = [];
+foreach (array_keys($sectionRegistry) as $sectionKey) {
+ $sectionUrls[$sectionKey] = $buildAdminUrl($sectionKey);
+}
+
+$requestedSection = strtolower(trim($_GET['section'] ?? 'overview'));
+if (!isset($sectionRegistry[$requestedSection])) {
+ $requestedSection = array_key_first($sectionRegistry) ?? 'overview';
+}
+$activeSection = $requestedSection;
+
+$adminTabs = [];
+foreach ($sectionRegistry as $key => $meta) {
+ $adminTabs[$key] = [
+ 'label' => $meta['label'],
+ 'url' => $sectionUrls[$key],
+ 'hook' => $meta['hook'],
+ 'type' => $meta['type'],
+ 'position' => $meta['position'],
+ ];
+}
+
+// Hooks section for plugins
+$sectionStatePayload = \App\Core\HookDispatcher::applyFilters('admin.sections.state', [
+ 'sections' => $sectionRegistry,
+ 'state' => [],
+ 'db' => $db ?? null,
+ 'user_id' => $userId,
+ 'app_root' => $app_root,
+]);
+$sectionState = [];
+if (is_array($sectionStatePayload)) {
+ $sectionState = $sectionStatePayload['state'] ?? (is_array($sectionStatePayload) ? $sectionStatePayload : []);
+}
+
+$migrationsDir = __DIR__ . '/../../doc/database/migrations';
+
+if ($postAction === 'read_migration') {
+ header('Content-Type: application/json');
+ $csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
+ $csrfToken = $_POST['csrf_token'] ?? $csrfHeader;
+ if (!$security->verifyCsrfToken($csrfToken)) {
+ echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
+ exit;
+ }
+
+ if (!$canAdmin) {
+ echo json_encode(['success' => false, 'error' => 'Permission denied']);
+ exit;
+ }
+
+ $filename = basename($_POST['filename'] ?? '');
+ if ($filename === '' || !preg_match('/^[A-Za-z0-9_\-]+\.sql$/', $filename)) {
+ echo json_encode(['success' => false, 'error' => 'Invalid filename']);
+ exit;
+ }
+
+ $path = realpath($migrationsDir . '/' . $filename);
+ if ($path === false || strpos($path, realpath($migrationsDir)) !== 0) {
+ echo json_encode(['success' => false, 'error' => 'File not found']);
+ exit;
+ }
+
+ $content = @file_get_contents($path);
+ if ($content === false) {
+ echo json_encode(['success' => false, 'error' => 'Could not read file']);
+ exit;
+ }
+
+ echo json_encode(['success' => true, 'name' => $filename, 'content' => $content]);
+ exit;
+}
+
+// Hooks actions for plugins
+if ($action !== '' && $action !== 'read_migration') {
+ $customActionPayload = \App\Core\HookDispatcher::applyFilters('admin.actions.handle', [
+ 'handled' => false,
+ 'action' => $action,
+ 'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
+ 'request' => $_REQUEST,
+ 'security' => $security,
+ 'app_root' => $app_root,
+ 'build_admin_url' => $buildAdminUrl,
+ 'user_id' => $userId,
+ 'db' => $db ?? null,
+ 'target_id' => $targetId,
+ 'section_state' => $sectionState,
+ ]);
+
+ if (!empty($customActionPayload['handled'])) {
+ return;
+ }
+}
+
+if ($postAction !== '' && $postAction !== 'read_migration') {
+ if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
+ Feedback::flash('SECURITY', 'CSRF_INVALID');
+ header('Location: ' . $buildAdminUrl($activeSection));
+ exit;
+ }
+
+ $postSection = strtolower(trim($_POST['section'] ?? $activeSection));
+ if (!in_array($postSection, $validSections, true)) {
+ $postSection = 'overview';
+ }
+
+ try {
+ if ($postAction === 'maintenance_on') {
+ $msg = trim($_POST['maintenance_message'] ?? '');
+ \App\Core\Maintenance::enable($msg);
+ Feedback::flash('NOTICE', 'DEFAULT', 'Maintenance mode enabled.', true);
+ } elseif ($postAction === 'maintenance_off') {
+ \App\Core\Maintenance::disable();
+ Feedback::flash('NOTICE', 'DEFAULT', 'Maintenance mode disabled.', true);
+ } elseif ($postAction === 'migrate_up') {
+ $runner = new \App\Core\MigrationRunner($db, $migrationsDir);
+ $applied = $runner->applyPendingMigrations();
+ Feedback::flash('NOTICE', 'DEFAULT', empty($applied) ? 'No pending migrations.' : 'Applied migrations: ' . implode(', ', $applied), true);
+ } elseif ($postAction === 'migrate_apply_one') {
+ $runner = new \App\Core\MigrationRunner($db, $migrationsDir);
+ $migrationName = trim($_POST['migration_name'] ?? '');
+ $applied = $migrationName !== '' ? $runner->applyMigrationByName($migrationName) : [];
+ if (empty($applied)) {
+ Feedback::flash('NOTICE', 'DEFAULT', 'No pending migrations.', true);
+ $_SESSION['migration_modal_result'] = [
+ 'name' => $migrationName ?: null,
+ 'status' => 'info',
+ 'message' => 'No pending migrations to apply.'
+ ];
+ if (!empty($migrationName)) {
+ $_SESSION['migration_modal_open'] = $migrationName;
+ }
+ } else {
+ Feedback::flash('NOTICE', 'DEFAULT', 'Applied migration: ' . implode(', ', $applied), true);
+ $_SESSION['migration_modal_result'] = [
+ 'name' => $applied[0],
+ 'status' => 'success',
+ 'message' => 'Migration ' . $applied[0] . ' applied successfully.'
+ ];
+ $_SESSION['migration_modal_open'] = $applied[0];
+ }
+ } elseif ($postAction === 'create_test_migration') {
+ $timestamp = date('Ymd_His');
+ $filename = $timestamp . '_test_migration.sql';
+ $filepath = $migrationsDir . '/' . $filename;
+ $testMigration = "-- Test migration for testing purposes\n";
+ $testMigration .= "-- This migration adds a test setting to settings table\n";
+ $testMigration .= "INSERT INTO settings (`key`, `value`, updated_at) VALUES ('test_migration_flag', '1', NOW())\n";
+ $testMigration .= "ON DUPLICATE KEY UPDATE `value` = '1', updated_at = NOW();\n";
+ if (file_put_contents($filepath, $testMigration)) {
+ Feedback::flash('NOTICE', 'DEFAULT', 'Test migration created: ' . $filename, true);
+ } else {
+ Feedback::flash('ERROR', 'DEFAULT', 'Failed to create test migration file', false);
+ }
+ } elseif ($postAction === 'clear_test_migrations') {
+ $testFiles = glob($migrationsDir . '/*_test_migration.sql') ?: [];
+ $removedCount = 0;
+ foreach ($testFiles as $file) {
+ $filename = basename($file);
+ if (file_exists($file)) {
+ unlink($file);
+ $removedCount++;
+ }
+ $stmt = $db->getConnection()->prepare('DELETE FROM migrations WHERE migration = :migration');
+ $stmt->execute([':migration' => $filename]);
+ }
+ Feedback::flash('NOTICE', 'DEFAULT', $removedCount > 0 ? ('Cleared ' . $removedCount . ' test migration(s)') : 'No test migrations to clear', true);
+ }
+ } catch (Throwable $e) {
+ Feedback::flash('ERROR', 'DEFAULT', 'Action failed: ' . $e->getMessage(), false);
+ }
+
+ header('Location: ' . $buildAdminUrl($postSection));
+ exit;
+}
+
+$maintenance_enabled = \App\Core\Maintenance::isEnabled();
+$maintenance_message = \App\Core\Maintenance::getMessage();
+
+$pending = [];
+$applied = [];
+$next_pending = null;
+$migration_contents = [];
+$test_migrations_exist = false;
+$migration_records = [];
+$migration_error = null;
+
+$migration_modal_result = $_SESSION['migration_modal_result'] ?? null;
+if (isset($_SESSION['migration_modal_result'])) {
+ unset($_SESSION['migration_modal_result']);
+}
+$modal_to_open = $_SESSION['migration_modal_open'] ?? null;
+if (isset($_SESSION['migration_modal_open'])) {
+ unset($_SESSION['migration_modal_open']);
+}
+
+try {
+ $runner = new \App\Core\MigrationRunner($db, $migrationsDir);
+ $pending = $runner->listPendingMigrations();
+ $applied = $runner->listAppliedMigrations();
+
+ $sortTestFirst = static function (array $items): array {
+ usort($items, static function ($a, $b) {
+ $aTest = strpos($a, '_test_migration') !== false;
+ $bTest = strpos($b, '_test_migration') !== false;
+ if ($aTest === $bTest) {
+ return strcmp($a, $b);
+ }
+ return $aTest ? -1 : 1;
+ });
+ return $items;
+ };
+
+ $pending = $sortTestFirst($pending);
+ $applied = $sortTestFirst($applied);
+ $next_pending = $pending[0] ?? null;
+ $test_migrations_exist = !empty(glob($migrationsDir . '/*_test_migration.sql'));
+
+ $all = array_unique(array_merge($pending, $applied));
+ foreach ($all as $fname) {
+ $path = realpath($migrationsDir . '/' . $fname);
+ $content = false;
+ if ($path && strpos($path, realpath($migrationsDir)) === 0) {
+ $content = @file_get_contents($path);
+ }
+
+ $record = $runner->getMigrationRecord($fname);
+ if ($record) {
+ $migration_records[$fname] = $record;
+ }
+
+ if ($content !== false && $content !== null) {
+ $migration_contents[$fname] = $content;
+ } elseif (!empty($record['content'])) {
+ $migration_contents[$fname] = $record['content'];
+ }
+ }
+} catch (Throwable $e) {
+ $migration_error = $e->getMessage();
+}
+
+$overviewPillsPayload = \App\Core\HookDispatcher::applyFilters('admin.overview.pills', [
+ 'pills' => [],
+ 'sections' => $sectionRegistry,
+ 'section_state' => $sectionState,
+ 'app_root' => $app_root,
+ 'user_id' => $userId,
+]);
+$adminOverviewPills = [];
+if (is_array($overviewPillsPayload)) {
+ $adminOverviewPills = $overviewPillsPayload['pills'] ?? (is_array($overviewPillsPayload) ? $overviewPillsPayload : []);
+}
+
+$overviewStatusesPayload = \App\Core\HookDispatcher::applyFilters('admin.overview.statuses', [
+ 'statuses' => [],
+ 'sections' => $sectionRegistry,
+ 'section_state' => $sectionState,
+ 'app_root' => $app_root,
+ 'user_id' => $userId,
+]);
+$adminOverviewStatuses = [];
+if (is_array($overviewStatusesPayload)) {
+ $adminOverviewStatuses = $overviewStatusesPayload['statuses'] ?? (is_array($overviewStatusesPayload) ? $overviewStatusesPayload : []);
+}
+
+$csrf_token = $security->generateCsrfToken();
+
+include '../app/templates/admin.php';
diff --git a/app/templates/admin.php b/app/templates/admin.php
new file mode 100644
index 0000000..a974f24
--- /dev/null
+++ b/app/templates/admin.php
@@ -0,0 +1,449 @@
+
+
+ [
+ 'label' => 'Overview',
+ 'url' => $sectionUrls['overview'] ?? ($app_root . '?page=admin'),
+ 'type' => 'core',
+ 'hook' => null,
+ 'position' => 100,
+ ],
+ ];
+}
+
+$heroPills = [
+ [
+ 'label' => 'Maintenance',
+ 'value' => $maintenance_enabled ? 'enabled' : 'off',
+ 'icon' => 'fas fa-power-off',
+ 'tone' => $maintenance_enabled ? 'danger' : 'success',
+ ],
+ [
+ 'label' => 'Migrations',
+ 'value' => count($pending) . ' pending',
+ 'icon' => 'fas fa-database',
+ 'tone' => empty($pending) ? 'neutral' : 'warning',
+ ],
+];
+
+if (!empty($adminOverviewPills) && is_array($adminOverviewPills)) {
+ foreach ($adminOverviewPills as $pill) {
+ if (!is_array($pill)) {
+ continue;
+ }
+ $heroPills[] = [
+ 'label' => (string)($pill['label'] ?? 'Status'),
+ 'value' => (string)($pill['value'] ?? ''),
+ 'icon' => (string)($pill['icon'] ?? 'fas fa-info-circle'),
+ 'tone' => (string)($pill['tone'] ?? 'info'),
+ ];
+ }
+}
+
+$statusItems = [
+ [
+ 'label' => 'Maintenance mode',
+ 'description' => $maintenance_enabled ? 'Live site shows downtime banner.' : 'Visitors see the normal experience.',
+ 'value' => $maintenance_enabled ? 'ON' : 'OFF',
+ 'tone' => $maintenance_enabled ? 'warning' : 'success',
+ ],
+ [
+ 'label' => 'Schema migrations',
+ 'description' => empty($pending) ? 'Database matches code.' : 'Pending updates detected.',
+ 'value' => count($pending) . ' pending',
+ 'tone' => empty($pending) ? 'success' : 'warning',
+ ],
+];
+
+if (!empty($adminOverviewStatuses) && is_array($adminOverviewStatuses)) {
+ foreach ($adminOverviewStatuses as $status) {
+ if (!is_array($status)) {
+ continue;
+ }
+ $statusItems[] = [
+ 'label' => (string)($status['label'] ?? 'Status'),
+ 'description' => (string)($status['description'] ?? ''),
+ 'value' => (string)($status['value'] ?? ''),
+ 'tone' => (string)($status['tone'] ?? 'info'),
+ ];
+ }
+}
+?>
+
+
+
+
+
+
Admin control center
+
+ Centralized administration dashboard for system-wide management.
+
+
+
+
+
+
+
+
+
+
+
+ $tabMeta):
+ $panelUrl = $tabMeta['url'] ?? ($sectionUrls[$sectionKey] ?? ($app_root . '?page=admin§ion=' . urlencode($sectionKey)));
+ $isActive = $activeSection === $sectionKey;
+?>
+
+
+
+
+
+
+
+ -
+
+
= htmlspecialchars($status['label']) ?>
+
+
= htmlspecialchars($status['description']) ?>
+
+
+
+ = htmlspecialchars($status['value']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Next: = htmlspecialchars($next_pending) ?>
+
+
+ No migrations queued.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error: = htmlspecialchars($migration_error) ?>
+
+
+
+
+
+
+
Pending migrations
+
+
Next: = htmlspecialchars($next_pending) ?>
+
+
+
+
+ - No pending migrations
+
+
+ -
+
+
+
+ = htmlspecialchars($fname) ?>
+
+
+
+
+
+
+
+
+
Applied migrations
+
= count($applied) ?>
+
+
+
+ - No applied migrations yet
+
+
+ -
+
+
+
+ = htmlspecialchars($fname) ?>
+
+
+
+
+
+
+
+
+
+
+ $sectionKey,
+ 'active_section' => $activeSection,
+ 'app_root' => $app_root,
+ 'section_url' => $panelUrl,
+ 'section_urls' => $sectionUrls ?? [],
+ 'csrf_token' => $csrf_token,
+ 'state' => $sectionState[$sectionKey] ?? [],
+ 'section_state' => $sectionState,
+ 'db' => $db ?? null,
+ ]);
+ ?>
+
+
+ No renderer available for this section.
+
+
+
+
+
+
+ $content):
+ $modalId = 'migrationModal' . md5($name);
+ $record = $migration_records[$name] ?? null;
+ $appliedAtRaw = $record['applied_at'] ?? null;
+ $appliedAtFormatted = null;
+ if (!empty($appliedAtRaw)) {
+ $timestamp = strtotime($appliedAtRaw);
+ $appliedAtFormatted = $timestamp ? date('M d, Y H:i', $timestamp) : $appliedAtRaw;
+ }
+ $isModalNext = (!empty($next_pending) && $next_pending === $name);
+ $modalResult = (!empty($migration_modal_result) && ($migration_modal_result['name'] ?? '') === $name) ? $migration_modal_result : null;
+?>
+
+
+
+
+
+
= htmlspecialchars($content) ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/app/templates/page-header.php b/app/templates/page-header.php
index f78181b..a6b9c28 100644
--- a/app/templates/page-header.php
+++ b/app/templates/page-header.php
@@ -36,7 +36,7 @@
-