Adds "admin" page for all admin tasks
parent
b609aca2cc
commit
5d62380c8b
|
|
@ -0,0 +1,356 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Admin control center
|
||||||
|
*
|
||||||
|
* Provides maintenance/migration tooling and exposes hook placeholders
|
||||||
|
* so plugins can contribute additional sections, actions, and metrics.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../core/Maintenance.php';
|
||||||
|
require_once __DIR__ . '/../core/MigrationRunner.php';
|
||||||
|
require_once '../app/helpers/security.php';
|
||||||
|
include_once '../app/helpers/feedback.php';
|
||||||
|
|
||||||
|
$security = SecurityHelper::getInstance();
|
||||||
|
|
||||||
|
if (!Session::isValidSession()) {
|
||||||
|
header('Location: ' . $app_root . '?page=login');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$canAdmin = false;
|
||||||
|
if (isset($userId) && isset($userObject) && method_exists($userObject, 'hasRight')) {
|
||||||
|
$canAdmin = ($userId === 1) || (bool)$userObject->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';
|
||||||
|
|
@ -0,0 +1,449 @@
|
||||||
|
<?php
|
||||||
|
/** @var bool $maintenance_enabled */
|
||||||
|
/** @var string $maintenance_message */
|
||||||
|
/** @var array $pending */
|
||||||
|
/** @var array $applied */
|
||||||
|
/** @var string $csrf_token */
|
||||||
|
/** @var string|null $next_pending */
|
||||||
|
/** @var array $migration_contents */
|
||||||
|
/** @var array $migration_records */
|
||||||
|
/** @var bool $test_migrations_exist */
|
||||||
|
/** @var array|null $migration_modal_result */
|
||||||
|
/** @var string|null $modal_to_open */
|
||||||
|
/** @var string|null $migration_error */
|
||||||
|
/** @var array $adminOverviewPills */
|
||||||
|
/** @var array $adminOverviewStatuses */
|
||||||
|
/** @var array $sectionState */
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$preselectModalId = null;
|
||||||
|
if (!empty($modal_to_open)) {
|
||||||
|
$preselectModalId = 'migrationModal' . md5($modal_to_open);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tabs = $adminTabs ?? [];
|
||||||
|
if (empty($tabs)) {
|
||||||
|
$tabs = [
|
||||||
|
'overview' => [
|
||||||
|
'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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section class="tm-hero">
|
||||||
|
<div class="tm-hero-card tm-hero-card--admin">
|
||||||
|
<div class="tm-hero-body">
|
||||||
|
<div class="tm-hero-heading">
|
||||||
|
<h1 class="tm-hero-title">Admin control center</h1>
|
||||||
|
<p class="tm-hero-subtitle">
|
||||||
|
Centralized administration dashboard for system-wide management.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="tm-hero-meta tm-hero-meta--stacked">
|
||||||
|
<?php foreach ($heroPills as $pill):
|
||||||
|
$toneClass = 'pill-' . preg_replace('/[^a-z0-9_-]/i', '', $pill['tone'] ?? 'info');
|
||||||
|
?>
|
||||||
|
<div class="tm-hero-pill <?= htmlspecialchars($toneClass) ?>">
|
||||||
|
<i class="<?= htmlspecialchars($pill['icon']) ?>"></i>
|
||||||
|
<?= htmlspecialchars($pill['label']) ?> <?= htmlspecialchars($pill['value']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tm-hero-actions">
|
||||||
|
<a class="btn btn-primary tm-directory-cta" href="<?= htmlspecialchars($app_root) ?>?page=dashboard">
|
||||||
|
<i class="fas fa-arrow-left"></i> Back to dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tm-admin tm-admin--dashboard">
|
||||||
|
<div class="tm-admin-tabs" role="tablist">
|
||||||
|
<?php foreach ($tabs as $sectionKey => $tabMeta):
|
||||||
|
$isActive = $activeSection === $sectionKey;
|
||||||
|
$tabUrl = $tabMeta['url'] ?? ($sectionUrls[$sectionKey] ?? ($app_root . '?page=admin§ion=' . urlencode($sectionKey)));
|
||||||
|
?>
|
||||||
|
<a class="tm-admin-tab-button <?= $isActive ? 'active' : '' ?>"
|
||||||
|
href="<?= htmlspecialchars($tabUrl) ?>"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="<?= $isActive ? 'true' : 'false' ?>"
|
||||||
|
aria-controls="tm-admin-tab-<?= htmlspecialchars($sectionKey) ?>">
|
||||||
|
<?= htmlspecialchars($tabMeta['label'] ?? ucfirst($sectionKey)) ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php foreach ($tabs as $sectionKey => $tabMeta):
|
||||||
|
$panelUrl = $tabMeta['url'] ?? ($sectionUrls[$sectionKey] ?? ($app_root . '?page=admin§ion=' . urlencode($sectionKey)));
|
||||||
|
$isActive = $activeSection === $sectionKey;
|
||||||
|
?>
|
||||||
|
<div class="tm-admin-tab-panel <?= $isActive ? 'active' : '' ?>" id="tm-admin-tab-<?= htmlspecialchars($sectionKey) ?>" role="tabpanel">
|
||||||
|
<?php if (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'overview'): ?>
|
||||||
|
<div class="tm-admin-grid tm-admin-grid--three">
|
||||||
|
<article class="tm-admin-card">
|
||||||
|
<header>
|
||||||
|
<h2 class="tm-admin-card-title">Current status</h2>
|
||||||
|
<p class="tm-admin-card-subtitle">High-level signals that require your attention.</p>
|
||||||
|
</header>
|
||||||
|
<ul class="tm-admin-status-list">
|
||||||
|
<?php foreach ($statusItems as $status):
|
||||||
|
$statusTone = 'status-' . preg_replace('/[^a-z0-9_-]/i', '', $status['tone'] ?? 'info');
|
||||||
|
?>
|
||||||
|
<li class="<?= htmlspecialchars($statusTone) ?>">
|
||||||
|
<div>
|
||||||
|
<strong><?= htmlspecialchars($status['label']) ?></strong>
|
||||||
|
<?php if (!empty($status['description'])): ?>
|
||||||
|
<p><?= htmlspecialchars($status['description']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<span class="tm-admin-status-value <?= htmlspecialchars($statusTone) ?>">
|
||||||
|
<?= htmlspecialchars($status['value']) ?>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="tm-admin-card">
|
||||||
|
<header>
|
||||||
|
<h2 class="tm-admin-card-title">Maintenance</h2>
|
||||||
|
<p class="tm-admin-card-subtitle">Toggle maintenance or update visitor message.</p>
|
||||||
|
</header>
|
||||||
|
<form method="post" class="tm-admin-controls">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="action" value="maintenance_on">
|
||||||
|
<input type="hidden" name="section" value="overview">
|
||||||
|
<label for="maintenance_message_overview" class="form-label">Maintenance message</label>
|
||||||
|
<input type="text"
|
||||||
|
id="maintenance_message_overview"
|
||||||
|
name="maintenance_message"
|
||||||
|
class="form-control tm-admin-message-input"
|
||||||
|
value="<?= htmlspecialchars($maintenance_message) ?>"
|
||||||
|
placeholder="Custom message. Default is 'Please try again later.'">
|
||||||
|
<div class="tm-admin-inline-actions">
|
||||||
|
<button type="submit" class="btn btn-warning" <?= $maintenance_enabled ? 'disabled' : '' ?>>Enable</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" <?= $maintenance_enabled ? '' : 'disabled' ?>
|
||||||
|
onclick="document.getElementById('maintenance-disable-form-overview').submit();">Disable</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="post" id="maintenance-disable-form-overview" class="d-none">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="action" value="maintenance_off">
|
||||||
|
<input type="hidden" name="section" value="overview">
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="tm-admin-card">
|
||||||
|
<header>
|
||||||
|
<h2 class="tm-admin-card-title">Next migration</h2>
|
||||||
|
<p class="tm-admin-card-subtitle">Peek at what will run when you apply updates.</p>
|
||||||
|
</header>
|
||||||
|
<?php if ($next_pending): ?>
|
||||||
|
<p class="text-muted mb-2">Next: <strong><?= htmlspecialchars($next_pending) ?></strong></p>
|
||||||
|
<button class="btn btn-outline-primary btn-sm" data-toggle="modal" data-target="#migrationModal<?= md5($next_pending) ?>">
|
||||||
|
View SQL
|
||||||
|
</button>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="tm-admin-empty">No migrations queued.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<hr>
|
||||||
|
<form method="post" class="tm-confirm" data-confirm="Apply all pending migrations?">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="action" value="migrate_up">
|
||||||
|
<input type="hidden" name="section" value="overview">
|
||||||
|
<button type="submit" class="btn btn-danger w-100" <?= empty($pending) ? 'disabled' : '' ?>>Apply all pending</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<?php elseif (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'maintenance'): ?>
|
||||||
|
<div class="tm-admin-grid">
|
||||||
|
<article class="tm-admin-card">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h2 class="tm-admin-card-title">Maintenance mode</h2>
|
||||||
|
<p class="tm-admin-card-subtitle">Let your users know when maintenance is in progress.</p>
|
||||||
|
</div>
|
||||||
|
<span class="tm-hero-pill <?= $maintenance_enabled ? 'pill-danger' : 'pill-neutral' ?>">
|
||||||
|
<?= $maintenance_enabled ? 'ENABLED' : 'DISABLED' ?>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div class="tm-admin-section">
|
||||||
|
<p class="tm-admin-section-title">Message</p>
|
||||||
|
<form method="post" class="tm-admin-controls">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="action" value="maintenance_on">
|
||||||
|
<textarea id="maintenance_message"
|
||||||
|
name="maintenance_message"
|
||||||
|
class="form-control tm-admin-message-input"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Custom message. Default is 'Please try again later.'"><?= htmlspecialchars($maintenance_message) ?></textarea>
|
||||||
|
<div class="tm-admin-inline-actions">
|
||||||
|
<button type="submit" class="btn btn-warning" <?= $maintenance_enabled ? 'disabled' : '' ?>>Enable maintenance</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" <?= $maintenance_enabled ? '' : 'disabled' ?> onclick="document.getElementById('maintenance-disable-form').submit();">Disable maintenance</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="post" id="maintenance-disable-form" class="d-none">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="action" value="maintenance_off">
|
||||||
|
<input type="hidden" name="section" value="maintenance">
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<?php elseif (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'migrations'): ?>
|
||||||
|
<div class="tm-admin-grid">
|
||||||
|
<article class="tm-admin-card tm-admin-card--migrations">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h2 class="tm-admin-card-title">Database migrations</h2>
|
||||||
|
<p class="tm-admin-card-subtitle">Review pending SQL and apply with confidence.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<?php if (!empty($migration_error)): ?>
|
||||||
|
<div class="alert alert-danger">Error: <?= htmlspecialchars($migration_error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="tm-admin-test-tools">
|
||||||
|
<p><strong>Test migration tools</strong></p>
|
||||||
|
<div class="tm-admin-inline-actions">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="action" value="create_test_migration">
|
||||||
|
<input type="hidden" name="section" value="migrations">
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm" <?= !empty($test_migrations_exist) ? 'disabled' : '' ?>>Create test migration</button>
|
||||||
|
</form>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="action" value="clear_test_migrations">
|
||||||
|
<input type="hidden" name="section" value="migrations">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm" <?= empty($test_migrations_exist) ? 'disabled' : '' ?>>Clear test migrations</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tm-admin-section">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<p class="tm-admin-section-title mb-0">Pending migrations</p>
|
||||||
|
<?php if (!empty($next_pending)): ?>
|
||||||
|
<span class="badge bg-info text-dark">Next: <?= htmlspecialchars($next_pending) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<ul class="tm-admin-list">
|
||||||
|
<?php if (empty($pending)): ?>
|
||||||
|
<li class="tm-admin-empty">No pending migrations</li>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($pending as $fname): ?>
|
||||||
|
<li>
|
||||||
|
<div class="tm-admin-list-actions">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" data-toggle="modal" data-target="#migrationModal<?= md5($fname) ?>">View</button>
|
||||||
|
</div>
|
||||||
|
<span><?= htmlspecialchars($fname) ?></span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tm-admin-section">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<p class="tm-admin-section-title mb-0">Applied migrations</p>
|
||||||
|
<span class="badge bg-secondary"><?= count($applied) ?></span>
|
||||||
|
</div>
|
||||||
|
<ul class="tm-admin-list">
|
||||||
|
<?php if (empty($applied)): ?>
|
||||||
|
<li class="tm-admin-empty">No applied migrations yet</li>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($applied as $fname):
|
||||||
|
if (strpos($fname, '_test_migration') !== false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<li>
|
||||||
|
<div class="tm-admin-list-actions">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-toggle="modal" data-target="#migrationModal<?= md5($fname) ?>">View</button>
|
||||||
|
</div>
|
||||||
|
<span><?= htmlspecialchars($fname) ?></span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="tm-confirm" data-confirm="Apply all pending migrations?">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="action" value="migrate_up">
|
||||||
|
<input type="hidden" name="section" value="migrations">
|
||||||
|
<button type="submit" class="btn btn-danger w-100" <?= empty($pending) ? 'disabled' : '' ?>>Apply all pending</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<?php elseif (!empty($tabMeta['hook'])): ?>
|
||||||
|
<?php
|
||||||
|
do_hook($tabMeta['hook'], [
|
||||||
|
'section' => $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,
|
||||||
|
]);
|
||||||
|
?>
|
||||||
|
<?php else: ?>
|
||||||
|
<article class="tm-admin-card">
|
||||||
|
<p class="tm-admin-empty mb-0">No renderer available for this section.</p>
|
||||||
|
</article>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if (!empty($migration_contents)):
|
||||||
|
foreach ($migration_contents as $name => $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;
|
||||||
|
?>
|
||||||
|
<div class="modal fade" id="<?= $modalId ?>" tabindex="-1" aria-labelledby="<?= $modalId ?>Label" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="<?= $modalId ?>Label"><?= htmlspecialchars($name) ?></h5>
|
||||||
|
<button type="button" class="btn-close" data-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0">
|
||||||
|
<pre class="tm-admin-modal-code"><code style="border-radius: 0.5rem;"><?= htmlspecialchars($content) ?></code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<?php if ($isModalNext): ?>
|
||||||
|
<form method="post" class="me-auto tm-confirm" data-confirm="Apply migration <?= htmlspecialchars($name) ?>?">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="action" value="migrate_apply_one">
|
||||||
|
<input type="hidden" name="migration_name" value="<?= htmlspecialchars($name) ?>">
|
||||||
|
<input type="hidden" name="section" value="migrations">
|
||||||
|
<button type="submit" class="btn btn-danger">Apply migration</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($modalResult): ?>
|
||||||
|
<div class="alert alert-<?= $modalResult['status'] === 'success' ? 'success' : 'info' ?> mb-0 small">
|
||||||
|
<?= htmlspecialchars($modalResult['message']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($appliedAtFormatted): ?>
|
||||||
|
<div class="tm-admin-modal-meta">
|
||||||
|
<span class="tm-admin-pill pill-success">
|
||||||
|
<i class="far fa-clock"></i>
|
||||||
|
Applied <?= htmlspecialchars($appliedAtFormatted) ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
endforeach;
|
||||||
|
endif; ?>
|
||||||
|
|
||||||
|
<form method="post" id="tm-admin-hidden-read-migration" class="d-none">
|
||||||
|
<input type="hidden" name="action" value="read_migration">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
|
||||||
|
<input type="hidden" name="filename" value="">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
document.querySelectorAll('form.tm-confirm').forEach((form) => {
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
const message = form.getAttribute('data-confirm') || 'Are you sure?';
|
||||||
|
if (!confirm(message)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const preselectModal = <?= $preselectModalId ? '"#' . htmlspecialchars($preselectModalId) . '"' : 'null' ?>;
|
||||||
|
if (preselectModal) {
|
||||||
|
const el = document.querySelector(preselectModal);
|
||||||
|
if (el && window.$) {
|
||||||
|
window.$(el).modal('show');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
<script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-adapter-moment.min.js"></script>
|
<script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-adapter-moment.min.js"></script>
|
||||||
<script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-plugin-zoom.min.js"></script>
|
<script src="<?= htmlspecialchars($app_root) ?>static/libs/chartjs/chartjs-plugin-zoom.min.js"></script>
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
<?php if ($page === 'admin-tools') {
|
<?php if ($page === 'admin') {
|
||||||
// Use local highlight.js assets if available
|
// Use local highlight.js assets if available
|
||||||
$hlBaseFs = __DIR__ . '/../../public_html/static/libs/highlightjs';
|
$hlBaseFs = __DIR__ . '/../../public_html/static/libs/highlightjs';
|
||||||
$hlBaseUrl = htmlspecialchars($app_root) . 'static/libs/highlightjs/';
|
$hlBaseUrl = htmlspecialchars($app_root) . 'static/libs/highlightjs/';
|
||||||
|
|
|
||||||
|
|
@ -775,6 +775,10 @@ html, body {
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tm-hero-card--admin {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tm-admin-card {
|
.tm-admin-card {
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: rgba(255, 255, 255, 0.98);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
|
|
@ -786,6 +790,52 @@ html, body {
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tm-admin-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-tab-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.45rem 1.2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #475569;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-tab-button:is(:hover, :focus-visible) {
|
||||||
|
border-color: rgba(148, 163, 184, 0.35);
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-tab-button.active {
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
box-shadow: 0 12px 30px rgba(29, 78, 216, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-tab-panel {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-tab-panel.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.tm-admin-card header {
|
.tm-admin-card header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -820,6 +870,51 @@ html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tm-admin-status-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-status-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-status-value {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 64px;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-status-value.status-success {
|
||||||
|
background: rgba(34, 197, 94, 0.18);
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-status-value.status-warning {
|
||||||
|
background: rgba(234, 179, 8, 0.25);
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-admin-status-value.status-info {
|
||||||
|
background: rgba(59, 130, 246, 0.18);
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.tm-admin-controls {
|
.tm-admin-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -839,13 +934,19 @@ html, body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tm-admin-message-input::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.tm-admin-list {
|
.tm-admin-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
max-height: 260px;
|
/* max-height: 260px;*/
|
||||||
|
max-height: 222px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -923,12 +1024,8 @@ html, body {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
font-family: 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
font-family: 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
/* background: #0f172a;*/
|
|
||||||
/* background: #0f172a8f;*/
|
|
||||||
/* background: #e2e8f0;*/
|
|
||||||
background: lightgray;
|
background: lightgray;
|
||||||
color: #e2e8f0;
|
color: rgb(226, 232, 240);
|
||||||
/* border-radius: 0 0 1rem 1rem;*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue