maintenance.php
55.95 KB
<?php
require_once __DIR__ . '/../bootstrap.php';
requireAdmin();
global $pdo;
$pageTitle = 'Maintenance';
$message = '';
$msgType = '';
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
if (isset($_SESSION['wp_flash_message'])) {
$message = (string)$_SESSION['wp_flash_message'];
$msgType = (string)($_SESSION['wp_flash_type'] ?? 'success');
unset($_SESSION['wp_flash_message'], $_SESSION['wp_flash_type']);
}
$flashDetails = null;
if (isset($_SESSION['wp_flash_details'])) {
$flashDetails = $_SESSION['wp_flash_details'];
unset($_SESSION['wp_flash_details']);
}
function wp_flash(string $msg, string $type = 'success', array $details = []): void {
$_SESSION['wp_flash_message'] = $msg;
$_SESSION['wp_flash_type'] = $type;
if (!empty($details)) {
$_SESSION['wp_flash_details'] = $details;
} else {
unset($_SESSION['wp_flash_details']);
}
}
function wp_redirect_maintenance(string $anchor = '', array $query = []): void {
$url = app_url('admin/maintenance');
if (!empty($query)) $url .= '?' . http_build_query($query);
if ($anchor) $url .= $anchor;
header('Location: ' . $url, true, 303);
exit;
}
function wp_finish_post(string $msg, string $type = 'success', string $anchor = '', array $details = []): void {
wp_flash($msg, $type, $details);
wp_redirect_maintenance($anchor);
}
// ── SQL HELPERS (migrations) ───────────────────────────────────────────
function wp_split_sql_statements(string $sql): array {
$sql = str_replace("\r\n", "\n", $sql);
$stmts = [];
$buf = '';
$inSingle = false;
$inDouble = false;
$inBacktick = false;
$escape = false;
$len = strlen($sql);
for ($i = 0; $i < $len; $i++) {
$ch = $sql[$i];
if ($escape) {
$buf .= $ch;
$escape = false;
continue;
}
// MySQL-style escaping inside quoted strings
if (($inSingle || $inDouble) && $ch === "\\") {
$buf .= $ch;
$escape = true;
continue;
}
if (!$inDouble && !$inBacktick && $ch === "'") {
$inSingle = !$inSingle;
$buf .= $ch;
continue;
}
if (!$inSingle && !$inBacktick && $ch === '"') {
$inDouble = !$inDouble;
$buf .= $ch;
continue;
}
if (!$inSingle && !$inDouble && $ch === '`') {
$inBacktick = !$inBacktick;
$buf .= $ch;
continue;
}
if (!$inSingle && !$inDouble && !$inBacktick && $ch === ';') {
$stmt = trim($buf);
if ($stmt !== '') $stmts[] = $stmt;
$buf = '';
continue;
}
$buf .= $ch;
}
$tail = trim($buf);
if ($tail !== '') $stmts[] = $tail;
return $stmts;
}
function wp_run_sql_file(PDO $pdo, string $path): void {
$sql = @file_get_contents($path);
if ($sql === false) return;
foreach (wp_split_sql_statements($sql) as $stmt) {
if ($stmt !== '') $pdo->exec($stmt);
}
}
// ── CHANGELOG ARCHIVE (single file) ───────────────────────────────────
function wp_changelog_dir(): ?string {
$dir = realpath(__DIR__ . '/../assets/changelog');
return ($dir && is_dir($dir)) ? $dir : null;
}
function wp_changelog_archive_path(): ?string {
$dir = wp_changelog_dir();
if (!$dir) return null;
return $dir . '/updates.zip';
}
// Ensure archive exists. If missing, create it and import any legacy update_log_*.md files.
function wp_changelog_archive_ensure(): void {
$dir = wp_changelog_dir();
if (!$dir) return;
$archive = $dir . '/updates.zip';
if (file_exists($archive)) return;
$zip = new ZipArchive();
if ($zip->open($archive, ZipArchive::CREATE) !== true) return;
foreach (glob($dir . '/update_log_*.md') ?: [] as $path) {
$zip->addFile($path, basename($path));
}
$zip->close();
}
// Add/replace a log entry in the archive.
function wp_changelog_archive_add(string $version, string $content): void {
$dir = wp_changelog_dir();
if (!$dir) return;
wp_changelog_archive_ensure();
$archive = $dir . '/updates.zip';
$zip = new ZipArchive();
if ($zip->open($archive) !== true) return;
$name = 'update_log_' . $version . '.md';
// Replace existing entry if present
if ($zip->locateName($name) !== false) {
$zip->deleteName($name);
}
$zip->addFromString($name, str_replace("\r\n", "\n", $content));
$zip->close();
}
// If archive exists, remove legacy loose files to avoid bloat.
function wp_changelog_cleanup_legacy_files(): void {
$dir = wp_changelog_dir();
if (!$dir) return;
$archive = $dir . '/updates.zip';
if (!file_exists($archive)) return;
foreach (glob($dir . '/update_log_*.md') ?: [] as $path) {
@unlink($path);
}
}
// ── LOGO UPLOAD ──────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'upload_logo') {
if (empty($_FILES['logo']['name']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) {
$message = 'No file received or upload error.';
$msgType = 'error';
} else {
$allowed = ['image/png','image/jpeg','image/jpg','image/gif','image/svg+xml','image/webp'];
$mimeType = (new finfo(FILEINFO_MIME_TYPE))->file($_FILES['logo']['tmp_name']);
if (!in_array($mimeType, $allowed)) {
$message = 'File must be an image (JPG, PNG, GIF, SVG, WebP).';
$msgType = 'error';
} elseif ($_FILES['logo']['size'] > 2 * 1024 * 1024) {
$message = 'Logo must be under 2MB.';
$msgType = 'error';
} else {
$ext = strtolower(pathinfo($_FILES['logo']['name'], PATHINFO_EXTENSION));
$imgDir = __DIR__ . '/../assets/images/';
if (!is_dir($imgDir)) @mkdir($imgDir, 0755, true);
foreach (glob($imgDir . 'logo.*') as $old) @unlink($old);
$target = $imgDir . 'logo.' . $ext;
if (!move_uploaded_file($_FILES['logo']['tmp_name'], $target)) {
$message = 'Could not save logo. Check permissions on assets/images/';
$msgType = 'error';
} else {
$logoPath = '/assets/images/logo.' . $ext;
try { $pdo->prepare("INSERT INTO system_info (`key`,`value`) VALUES ('logo_path',?) ON DUPLICATE KEY UPDATE `value`=?")->execute([$logoPath,$logoPath]); } catch (Throwable $e) {}
logActivity('logo_updated', 'settings', null, 'Company logo updated');
$message = '✅ Logo updated! Refresh the page to see it.';
$msgType = 'success';
}
}
}
wp_finish_post($message ?: 'Done.', $msgType ?: 'success', '#company');
}
// ── FAVICON UPLOAD ────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'upload_favicon') {
if (empty($_FILES['favicon']['name']) || $_FILES['favicon']['error'] !== UPLOAD_ERR_OK) {
$message = 'No file received or upload error.';
$msgType = 'error';
} else {
$allowed = ['image/png','image/svg+xml','image/webp','image/x-icon','image/vnd.microsoft.icon'];
$mimeType = (new finfo(FILEINFO_MIME_TYPE))->file($_FILES['favicon']['tmp_name']);
if (!in_array($mimeType, $allowed, true)) {
$message = 'Favicon must be PNG, ICO, SVG or WebP.';
$msgType = 'error';
} elseif ($_FILES['favicon']['size'] > 600 * 1024) {
$message = 'Favicon must be under 600KB.';
$msgType = 'error';
} else {
$ext = strtolower(pathinfo($_FILES['favicon']['name'], PATHINFO_EXTENSION));
if ($ext === 'jpeg') $ext = 'jpg';
if ($ext === 'ico' || $ext === 'png' || $ext === 'svg' || $ext === 'webp') {
$imgDir = __DIR__ . '/../assets/images/';
if (!is_dir($imgDir)) @mkdir($imgDir, 0755, true);
foreach (glob($imgDir . 'favicon.*') as $old) @unlink($old);
$target = $imgDir . 'favicon.' . $ext;
if (!move_uploaded_file($_FILES['favicon']['tmp_name'], $target)) {
$message = 'Could not save favicon. Check permissions on assets/images/';
$msgType = 'error';
} else {
$favPath = '/assets/images/favicon.' . $ext;
try { $pdo->prepare("INSERT INTO system_info (`key`,`value`) VALUES ('favicon_path',?) ON DUPLICATE KEY UPDATE `value`=?")->execute([$favPath,$favPath]); } catch (Throwable $e) {}
logActivity('favicon_updated', 'settings', null, 'Favicon updated');
$message = '✅ Favicon updated! Refresh the page to see it.';
$msgType = 'success';
}
} else {
$message = 'Unsupported favicon file type.';
$msgType = 'error';
}
}
}
wp_finish_post($message ?: 'Done.', $msgType ?: 'success', '#company');
}
// ── COMPANY INFO UPDATE ──────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'update_company') {
$fields = [
'company_name' => trim($_POST['company_name'] ?? ''),
'company_website' => trim($_POST['company_website'] ?? ''),
'company_email' => trim($_POST['company_email'] ?? ''),
'company_phone' => trim($_POST['company_phone'] ?? ''),
'company_address' => trim($_POST['company_address'] ?? ''),
];
if (!$fields['company_name']) {
$message = 'Company name is required.';
$msgType = 'error';
} else {
try {
$stmt = $pdo->prepare("INSERT INTO system_info (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)");
foreach ($fields as $k => $v) $stmt->execute([$k, $v]);
logActivity('company_updated', 'settings', null, 'Company information updated');
$message = '✅ Company information saved.';
$msgType = 'success';
} catch (Throwable $e) {
$message = 'Failed to save: ' . $e->getMessage();
$msgType = 'error';
}
}
wp_finish_post($message ?: 'Done.', $msgType ?: 'success', '#company');
}
// ── DATABASE SETTINGS (edit + test) ─────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array(($_POST['action'] ?? ''), ['db_test','db_save'], true)) {
// Read current config from globals set by config/database.php
$curHost = $GLOBALS['db_host'] ?? '';
$curPort = $GLOBALS['db_port'] ?? '3306';
$curName = $GLOBALS['db_name'] ?? '';
$curUser = $GLOBALS['db_user'] ?? '';
$curPass = $GLOBALS['db_pass'] ?? '';
$host = trim($_POST['db_host'] ?? $curHost);
$port = trim($_POST['db_port'] ?? $curPort);
$name = trim($_POST['db_name'] ?? $curName);
$user = trim($_POST['db_user'] ?? $curUser);
$pass = (string)($_POST['db_pass'] ?? '');
if ($pass === '') $pass = (string)$curPass; // keep existing if blank
if ($host === '' || $name === '' || $user === '') {
$message = 'Database host, name and username are required.';
$msgType = 'error';
} else {
try {
$dsn = "mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4";
$pdoTest = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdoTest->query('SELECT 1');
if (($_POST['action'] ?? '') === 'db_save') {
$cfgPath = __DIR__ . '/../config/database.php';
$h = var_export($host, true);
$p = var_export($port, true);
$n = var_export($name, true);
$u = var_export($user, true);
$w = var_export($pass, true);
$cfg = "<?php\n// Auto-generated by WorkersPanel Maintenance\n"
. "\$db_host={$h};\n\$db_port={$p};\n\$db_name={$n};\n\$db_user={$u};\n\$db_pass={$w};\n"
. "\$dsn=\"mysql:host=\".{$h}.\";port=\".{$p}.\";dbname=\".{$n}.\";charset=utf8mb4\";\n"
. "\$pdo=new PDO(\$dsn,\$db_user,\$db_pass,[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION,PDO::ATTR_DEFAULT_FETCH_MODE=>PDO::FETCH_ASSOC]);\n";
if (@file_put_contents($cfgPath, $cfg) === false) {
$message = 'Could not write config/database.php — check file permissions.';
$msgType = 'error';
} else {
// Swap the runtime connection so the page continues to work.
$pdo = $pdoTest;
$GLOBALS['db_host'] = $host;
$GLOBALS['db_port'] = $port;
$GLOBALS['db_name'] = $name;
$GLOBALS['db_user'] = $user;
$GLOBALS['db_pass'] = $pass;
logActivity('db_settings_updated', 'system', null, 'Database connection settings updated');
$message = '✅ Database settings saved and connection test passed.';
$msgType = 'success';
}
} else {
$message = '✅ Database connection test passed.';
$msgType = 'success';
}
} catch (Throwable $e) {
$message = 'Database connection failed: ' . $e->getMessage();
$msgType = 'error';
}
}
wp_finish_post($message ?: 'Done.', $msgType ?: 'success', '#database');
}
// ── APPLY UPDATE ─────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'apply_update') {
$file = $_FILES['update_package'] ?? null;
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
$message = 'Upload failed. Max upload size is 50MB.';
$msgType = 'error';
} elseif (!preg_match('/\.zip$/i', $file['name'])) {
$message = 'Please upload a valid .zip file.';
$msgType = 'error';
} else {
$zip = new ZipArchive();
if ($zip->open($file['tmp_name']) === true) {
$tmpDir = sys_get_temp_dir() . '/wp_update_' . time();
mkdir($tmpDir);
$zip->extractTo($tmpDir);
$zip->close();
$appDir = null;
foreach (glob($tmpDir . '/*/workerspanel_app', GLOB_ONLYDIR) as $d) { $appDir = $d; break; }
foreach (glob($tmpDir . '/*/*/workerspanel_app', GLOB_ONLYDIR) as $d) { if (!$appDir) $appDir = $d; break; }
if (!$appDir && is_dir($tmpDir . '/workerspanel_app')) $appDir = $tmpDir . '/workerspanel_app';
if ($appDir) {
// Version + update-log / manifest validation
$abort = false;
$newVer = null;
$manifestData = null;
$manifestFile = null;
// 1) Prefer version.txt (recommended)
foreach (["{$tmpDir}/version.txt", ...glob("{$tmpDir}/*/version.txt") ?: [], ...glob("{$tmpDir}/*/*/version.txt") ?: []] as $vf) {
if (file_exists($vf)) { $newVer = trim((string)file_get_contents($vf)); break; }
}
// 2) Fallback: package workerspanel_app/config/version.php
if (!$newVer) {
$vphp = $appDir . '/config/version.php';
if (file_exists($vphp)) {
$raw = (string)@file_get_contents($vphp);
if (preg_match("/define\('APP_VERSION'\s*,\s*'([^']+)'\s*\)\s*;/", $raw, $m)) {
$newVer = trim($m[1]);
}
}
}
// 3) Optional: package-level manifest (preferred for future updates)
foreach (["{$tmpDir}/update_manifest.json", ...glob("{$tmpDir}/*/update_manifest.json") ?: [], ...glob("{$tmpDir}/*/*/update_manifest.json") ?: []] as $mf) {
if (is_readable($mf)) { $manifestFile = $mf; break; }
}
if ($manifestFile) {
$raw = (string)@file_get_contents($manifestFile);
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
$message = 'Update package has an invalid update_manifest.json (must be valid JSON).';
$msgType = 'error';
$abort = true;
} else {
$manifestData = $decoded;
// If the manifest declares a version, it must match version.txt/version.php (if present)
if (!empty($manifestData['version'])) {
$mver = trim((string)$manifestData['version']);
if ($newVer && $mver !== $newVer) {
$message = 'Update manifest version does not match version.txt/version.php (' . $mver . ' vs ' . $newVer . ').';
$msgType = 'error';
$abort = true;
} elseif (!$newVer) {
$newVer = $mver;
}
}
}
}
if (!$newVer) {
$message = 'Update package is missing version.txt (or workerspanel_app/config/version.php / update_manifest.json).';
$msgType = 'error';
$abort = true;
}
// Backwards compatible validation:
// - If a manifest is present, it replaces the need for update_log_<version>.md.
// - If no manifest, require the classic update log file.
$requiredLog = $appDir . '/assets/changelog/update_log_' . $newVer . '.md';
if (!$abort && !$manifestData) {
if (!is_readable($requiredLog)) {
$message = 'Update package is missing the required update log: workerspanel_app/assets/changelog/update_log_' . $newVer . '.md (or include update_manifest.json).';
$msgType = 'error';
$abort = true;
}
}
if ($abort) {
// Cleanup extracted package
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
foreach ($rii as $it) { $it->isDir() ? @rmdir($it->getPathname()) : @unlink($it->getPathname()); }
@rmdir($tmpDir);
} else {
$dest = realpath(__DIR__ . '/..');
// Never overwrite instance-specific configuration or any deployed migration folders.
$protect = [
'config/database.php',
'install.lock',
'install/migrations',
'install/migration.sql',
// Instance branding is uploaded/set by the installer or Maintenance screen.
// Updates must not overwrite or delete these user-owned files.
'assets/images',
];
$updateDetails = [
'updated' => [],
'added' => [],
'deleted' => [],
'skipped' => [],
];
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($appDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($it as $item) {
$rel = str_replace('\\', '/', substr($item->getPathname(), strlen($appDir) + 1));
$skip = false;
foreach ($protect as $p) {
$p = trim((string)$p, '/');
if ($rel === $p || str_starts_with($rel, $p . '/') || str_ends_with($rel, '/' . $p) || str_ends_with($rel, $p)) {
$skip = true;
break;
}
}
if ($skip) { $updateDetails['skipped'][] = $rel; continue; }
$target = $dest . '/' . $rel;
if ($item->isDir()) {
if (!is_dir($target)) {
mkdir($target, 0755, true);
}
} else {
$existed = file_exists($target);
copy($item->getPathname(), $target);
if ($existed) $updateDetails['updated'][] = $rel;
else $updateDetails['added'][] = $rel;
}
}
// Delete manifest (optional)
$manifest = null;
foreach (["{$tmpDir}/delete_manifest.txt", ...glob("{$tmpDir}/*/delete_manifest.txt") ?: [], ...glob("{$tmpDir}/*/*/delete_manifest.txt") ?: []] as $mf) {
if (file_exists($mf)) { $manifest = $mf; break; }
}
if ($manifest) {
$lines = file($manifest, FILE_IGNORE_NEW_LINES);
foreach ($lines as $ln) {
$ln = trim($ln);
// Allow manifests that include a leading workerspanel_app/ prefix (older packages)
$ln = preg_replace('#^(?:\./)?workerspanel_app/#', '', $ln);
$ln = ltrim($ln, '/');
if ($ln === '' || str_starts_with($ln, '#')) continue;
$protectedDelete = false;
foreach ($protect as $p) {
$p = trim((string)$p, '/');
if ($ln === $p || str_starts_with($ln, $p . '/') || str_ends_with($ln, '/' . $p) || str_ends_with($ln, $p)) {
$protectedDelete = true;
break;
}
}
if ($protectedDelete) { $updateDetails['skipped'][] = $ln; continue; }
$path = realpath($dest);
$target = $dest . '/' . ltrim($ln, '/');
// Prevent escaping app root
$realTarget = realpath($target) ?: $target;
if (!str_starts_with(str_replace('\\','/', (string)$realTarget), str_replace('\\','/', (string)$path))) continue;
// delete file or directory
if (is_dir($target)) {
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($target, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
foreach ($rii as $it) { $it->isDir() ? @rmdir($it->getPathname()) : @unlink($it->getPathname()); }
@rmdir($target);
$updateDetails['deleted'][] = $ln;
} elseif (file_exists($target)) {
@unlink($target);
$updateDetails['deleted'][] = $ln;
}
}
}
// Normalize update details (dedupe + sort for display)
foreach (['updated','added','deleted','skipped'] as $k) {
if (!isset($updateDetails[$k]) || !is_array($updateDetails[$k])) $updateDetails[$k] = [];
$updateDetails[$k] = array_values(array_unique($updateDetails[$k]));
sort($updateDetails[$k]);
}
// Migrations (package-level root /migrations)
$migsDir = null;
foreach (["{$tmpDir}/migrations", ...glob("{$tmpDir}/*/migrations") ?: [], ...glob("{$tmpDir}/*/*/migrations") ?: []] as $md) {
if (is_dir($md)) { $migsDir = $md; break; }
}
// Ensure migration tracking table exists
try {
$pdo->exec("CREATE TABLE IF NOT EXISTS system_migrations (id INT AUTO_INCREMENT PRIMARY KEY, filename VARCHAR(255) NOT NULL, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY filename (filename))");
} catch (Throwable $e) {}
$migCheck = $pdo->prepare("SELECT id FROM system_migrations WHERE filename = ?");
$migInsert = $pdo->prepare("INSERT INTO system_migrations (filename) VALUES (?)");
// Apply package migrations (ZIP root)
if ($migsDir && is_dir($migsDir)) {
$files = array_values(array_filter(scandir($migsDir), fn($f) => str_ends_with($f, '.sql')));
sort($files);
foreach ($files as $f) {
try {
$migCheck->execute([$f]);
if ($migCheck->fetch()) continue;
wp_run_sql_file($pdo, $migsDir . '/' . $f);
$migInsert->execute([$f]);
} catch (Throwable $e) {}
}
}
// Apply any legacy "deployed" migrations that might still exist in the live app
// (older installs left these behind, which causes "pending" to show).
$legacyDir = $dest . '/install/migrations';
if (is_dir($legacyDir)) {
$legacyFiles = glob($legacyDir . '/*.sql') ?: [];
sort($legacyFiles);
foreach ($legacyFiles as $full) {
$f = basename($full);
try {
$migCheck->execute([$f]);
if ($migCheck->fetch()) continue;
wp_run_sql_file($pdo, $full);
$migInsert->execute([$f]);
} catch (Throwable $e) {}
}
}
// Remove any deployed migrations (they should be package-only)
$liveMigs = $dest . '/install/migrations';
if (is_dir($liveMigs)) {
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($liveMigs, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
foreach ($rii as $it) { $it->isDir() ? @rmdir($it->getPathname()) : @unlink($it->getPathname()); }
@rmdir($liveMigs);
}
if (file_exists($dest . '/install/migration.sql')) {
@unlink($dest . '/install/migration.sql');
}
// Version sync (single canonical key)
// $newVer was detected and validated before applying the update.
try { $pdo->prepare("INSERT INTO system_info (`key`,`value`) VALUES ('version',?) ON DUPLICATE KEY UPDATE `value`=?")->execute([$newVer,$newVer]); } catch (Throwable $e) {}
// Cleanup legacy keys
try { $pdo->exec("DELETE FROM system_info WHERE `key` IN ('app_version','db_version')"); } catch (Throwable $e) {}
// Persist release notes into changelog archive (single file on disk)
try {
$histTitle = null;
$histNotes = null;
if (is_array($manifestData)) {
$histTitle = isset($manifestData['title']) ? trim((string)$manifestData['title']) : null;
$histNotes = isset($manifestData['notes']) ? (string)$manifestData['notes'] : null;
}
// Fallback: store classic update log content when no manifest notes are provided.
if (!$histNotes && isset($requiredLog) && is_readable($requiredLog)) {
$histNotes = (string)@file_get_contents($requiredLog);
}
if ($histNotes) {
$content = (string)$histNotes;
// Ensure there's a heading
if ($histTitle && !preg_match('/^\s*#\s+/m', $content)) {
$content = '# ' . $histTitle . "
" . $content;
}
wp_changelog_archive_add($newVer, $content);
wp_changelog_cleanup_legacy_files();
}
} catch (Throwable $e) {
// Don't block the update if changelog write fails.
}
logActivity('update_applied', 'system', null, 'Updated to ' . $newVer);
$uCnt = count($updateDetails['updated'] ?? []);
$aCnt = count($updateDetails['added'] ?? []);
$dCnt = count($updateDetails['deleted'] ?? []);
$message = "✅ Updated to v{$newVer}! Files: {$uCnt} updated, {$aCnt} added, {$dCnt} deleted.";
$msgType = 'success';
// Cleanup extracted package
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
foreach ($rii as $it) { $it->isDir() ? @rmdir($it->getPathname()) : @unlink($it->getPathname()); }
@rmdir($tmpDir);
}
} else {
$message = 'ZIP must contain a workerspanel_app/ folder.';
$msgType = 'error';
}
} else {
$message = 'Could not open ZIP file.';
$msgType = 'error';
}
}
wp_finish_post($message ?: 'Done.', $msgType ?: 'success', '', $updateDetails ?? []);
}
// ── Load current values ───────────────────────────────────────────────────
$pending = 0;
$migsPath = __DIR__ . '/../install/migrations'; // (should be empty; migrations are package-level)
try {
$pdo->exec("CREATE TABLE IF NOT EXISTS system_migrations (id INT AUTO_INCREMENT PRIMARY KEY, filename VARCHAR(255) NOT NULL, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY filename (filename))");
if (is_dir($migsPath))
foreach (array_diff(scandir($migsPath), ['.','..']) as $f) {
if (!str_ends_with($f, '.sql')) continue;
$c = $pdo->prepare("SELECT id FROM system_migrations WHERE filename = ?"); $c->execute([$f]);
if (!$c->fetch()) $pending++;
}
} catch (Throwable $e) {}
// Canonical system version key: `version`
$systemVersion = getSystemInfo('version', getSystemInfo('app_version', APP_VERSION));
// Self-heal older installs: if everything is applied, sync `version` and remove legacy keys.
if ($pending === 0 && $systemVersion !== APP_VERSION) {
try {
$pdo->prepare("INSERT INTO system_info (`key`,`value`) VALUES ('version',?) ON DUPLICATE KEY UPDATE `value`=?")
->execute([APP_VERSION, APP_VERSION]);
$pdo->exec("DELETE FROM system_info WHERE `key` IN ('app_version','db_version')");
$systemVersion = APP_VERSION;
} catch (Throwable $e) {}
}
$logoAsset = function_exists('wp_logo_asset') ? wp_logo_asset() : null;
$logoFile = $logoAsset['file'] ?? null;
$logoUrl = $logoAsset['url'] ?? null;
$favAsset = function_exists('wp_branding_asset') ? wp_branding_asset('favicon') : null;
$favFile = $favAsset['file'] ?? null;
$favUrl = $favAsset['url'] ?? null;
// Load all company fields
function sysVal(string $key, string $default = ''): string {
global $pdo;
try {
$s = $pdo->prepare("SELECT value FROM system_info WHERE `key`=?");
$s->execute([$key]);
$r = $s->fetchColumn();
return $r !== false ? (string)$r : $default;
} catch (Throwable $e) { return $default; }
}
$co = [
'name' => sysVal('company_name'),
'website' => sysVal('company_website'),
'email' => sysVal('company_email'),
'phone' => sysVal('company_phone'),
'address' => sysVal('company_address'),
];
// Update history lives in assets/changelog/updates.zip (Release Notes on the Administration dashboard).
// DB config (from config/database.php)
$dbCfg = [
'host' => $GLOBALS['db_host'] ?? '',
'port' => $GLOBALS['db_port'] ?? '3306',
'name' => $GLOBALS['db_name'] ?? '',
'user' => $GLOBALS['db_user'] ?? '',
'pass_set' => isset($GLOBALS['db_pass']) && (string)$GLOBALS['db_pass'] !== '',
];
$dbStatus = 'OK';
$dbMeta = [
'product' => '',
'version' => '',
'comment' => '',
'driver' => '',
'server' => '',
'charset' => '',
'collation'=> '',
'sql_mode' => '',
'ssl' => '',
];
try {
$pdo->query('SELECT 1');
$dbMeta['server'] = (string)$pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
$dbMeta['driver'] = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
try { $dbMeta['version'] = (string)$pdo->query('SELECT VERSION()')->fetchColumn(); } catch (Throwable $e) {}
try { $dbMeta['comment'] = (string)$pdo->query('SELECT @@version_comment')->fetchColumn(); } catch (Throwable $e) {}
try { $dbMeta['charset'] = (string)$pdo->query('SELECT @@character_set_database')->fetchColumn(); } catch (Throwable $e) {}
try { $dbMeta['collation'] = (string)$pdo->query('SELECT @@collation_database')->fetchColumn(); } catch (Throwable $e) {}
try { $dbMeta['sql_mode'] = (string)$pdo->query('SELECT @@sql_mode')->fetchColumn(); } catch (Throwable $e) {}
try {
$row = $pdo->query("SHOW STATUS LIKE 'Ssl_cipher'")->fetch(PDO::FETCH_ASSOC);
$dbMeta['ssl'] = (string)($row['Value'] ?? '');
} catch (Throwable $e) {}
$hint = strtolower(trim(($dbMeta['comment'] ?: '') . ' ' . ($dbMeta['version'] ?: '')));
$dbMeta['product'] = (str_contains($hint, 'mariadb') ? 'MariaDB' : 'MySQL');
} catch (Throwable $e) {
$dbStatus = 'ERROR';
}
$dbSummary = $dbMeta['product'] ? trim($dbMeta['product'] . ' ' . ($dbMeta['version'] ?: $dbMeta['server'])) : ($dbMeta['server'] ?: '—');
$dbSummaryShort = $dbSummary;
if (!empty($dbCfg['host']) && !empty($dbCfg['name'])) {
$dbSummaryShort .= ' • ' . $dbCfg['host'] . ':' . ($dbCfg['port'] ?: '3306') . '/' . $dbCfg['name'];
}
include __DIR__ . '/../partials/header.php';
?>
<style>
/* Single flow layout (no 2-column grid) */
.maint-layout{display:flex;flex-direction:column;gap:18px}
.maint-section-title{display:flex;align-items:center;gap:.6rem;margin:0 0 .35rem 0}
.maint-section-title .badge{font-size:.75rem;padding:.15rem .55rem;border:1px solid var(--border);border-radius:999px;color:var(--text-muted)}
.branding-grid{display:grid;grid-template-columns:1fr 1fr;gap:0;margin-top:14px;padding-top:14px;border-top:1px solid var(--border)}
.branding-col{padding:0 1rem}
.branding-col + .branding-col{border-left:1px solid var(--border)}
.branding-title{font-weight:900}
.branding-sub{font-size:.88rem;color:var(--text-muted)}
@media(max-width:900px){.branding-grid{grid-template-columns:1fr;gap:18px}.branding-col{padding:0}.branding-col + .branding-col{border-left:0;border-top:1px solid var(--border);padding-top:18px}}
.upload-dropzone{border:1.5px dashed var(--border);border-radius:12px;padding:1rem;text-align:center;cursor:pointer;transition:border-color .2s,background .2s;background:var(--bg-card);min-height:140px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.25rem}
.upload-dropzone:hover,.upload-dropzone.drag-over{border-color:var(--accent);background:rgba(255,140,26,0.05)}
.upload-icon{font-size:2.0rem;margin-bottom:.15rem}
.logo-current{display:flex;align-items:center;gap:1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:1rem 1.1rem;margin-bottom:1rem}
.logo-thumb{width:60px;height:60px;border-radius:10px;background:var(--bg-card);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0}
.logo-thumb img{width:100%;height:100%;object-fit:contain;padding:5px}
.logo-info strong{display:block;font-size:.9rem;margin-bottom:.15rem}
.logo-info small{color:var(--text-muted);font-size:.76rem}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem}
@media(max-width:720px){.form-row{grid-template-columns:1fr}}
.form-field{margin-bottom:1rem}
.form-field label{display:block;font-size:.78rem;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.35rem}
.form-field input,.form-field select{width:100%;padding:.6rem .85rem;background:var(--bg-panel);border:1px solid var(--border);border-radius:10px;color:var(--text-main);font-size:.92rem;outline:none;transition:border-color .2s,box-shadow .2s}
.form-field input:focus,.form-field select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,140,26,.12)}
.kv{display:grid;grid-template-columns:160px 1fr;gap:.6rem .9rem}
@media(max-width:720px){.kv{grid-template-columns:1fr}}
.kv div{padding:.25rem 0}
.kv .k{color:var(--text-muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.04em}
.kv .v{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:.9rem;overflow-wrap:anywhere;word-break:break-word}
.maint-actions{display:flex;gap:.6rem;flex-wrap:wrap}
.hint{color:var(--text-muted);font-size:.85rem}
.maint-header{display:flex;justify-content:space-between;align-items:flex-end;gap:1rem;flex-wrap:wrap}
.maint-meta{display:flex;gap:.6rem;flex-wrap:wrap}
.maint-pill{background:var(--bg-panel);border:1px solid var(--border);border-radius:999px;padding:.35rem .65rem;font-size:.85rem;color:var(--text-muted);max-width:640px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.maint-pill strong{color:var(--text-main);font-weight:800}
.maint-details{border:1px solid var(--border);border-radius:12px;padding:.6rem .85rem;background:var(--bg-panel)}
.maint-details>summary{cursor:pointer;list-style:none;font-weight:700;color:var(--text-main)}
.maint-details>summary::-webkit-details-marker{display:none}
.maint-details>summary:after{content:"▾";float:right;color:var(--text-muted)}
.maint-details[open]>summary:after{content:"▴"}
.file-list{max-height:320px;overflow:auto;white-space:pre;background:var(--bg-panel);border:1px solid var(--border);border-radius:12px;padding:.75rem;font-size:.85rem;line-height:1.35}
</style>
<div class="content-header maint-header">
<div>
<h1 class="content-title">🔧 Maintenance</h1>
<p class="content-subtitle">Updates, database, and company settings</p>
</div>
<div class="maint-meta">
<div class="maint-pill">Version <strong><?= e($systemVersion) ?></strong></div>
<div class="maint-pill">Database <strong><?= e($dbSummaryShort) ?></strong></div>
<div class="maint-pill">Migrations <strong><?= $pending > 0 ? e($pending . " pending") : "All applied" ?></strong></div>
</div>
</div>
<?php if ($message): ?>
<div class="alert alert-<?= $msgType === 'success' ? 'success' : 'error' ?>" style="margin-bottom:1.5rem">
<?= e($message) ?>
</div>
<?php endif; ?>
<?php if (!empty($flashDetails) && is_array($flashDetails)): ?>
<details class="maint-details" open style="margin-bottom:1.5rem">
<summary>Update details (files changed)</summary>
<div class="kv" style="margin-top:.75rem">
<div class="k">Updated</div><div class="v"><?= e((string)count($flashDetails['updated'] ?? [])) ?></div>
<div class="k">Added</div><div class="v"><?= e((string)count($flashDetails['added'] ?? [])) ?></div>
<div class="k">Deleted</div><div class="v"><?= e((string)count($flashDetails['deleted'] ?? [])) ?></div>
<div class="k">Skipped</div><div class="v"><?= e((string)count($flashDetails['skipped'] ?? [])) ?></div>
</div>
<?php if (!empty($flashDetails['updated'])): ?>
<div style="margin-top:1rem">
<div class="k" style="margin-bottom:.35rem">Updated files</div>
<pre class="file-list"><?= e(implode("\n", $flashDetails['updated'])) ?></pre>
</div>
<?php endif; ?>
<?php if (!empty($flashDetails['added'])): ?>
<div style="margin-top:1rem">
<div class="k" style="margin-bottom:.35rem">Added files</div>
<pre class="file-list"><?= e(implode("\n", $flashDetails['added'])) ?></pre>
</div>
<?php endif; ?>
<?php if (!empty($flashDetails['deleted'])): ?>
<div style="margin-top:1rem">
<div class="k" style="margin-bottom:.35rem">Deleted paths</div>
<pre class="file-list"><?= e(implode("\n", $flashDetails['deleted'])) ?></pre>
</div>
<?php endif; ?>
<?php if (!empty($flashDetails['skipped'])): ?>
<div style="margin-top:1rem">
<div class="k" style="margin-bottom:.35rem">Skipped (protected)</div>
<pre class="file-list"><?= e(implode("\n", $flashDetails['skipped'])) ?></pre>
</div>
<?php endif; ?>
</details>
<script>window.scrollTo(0,0);</script>
<?php endif; ?>
<div class="maint-layout">
<!-- ── Company Information + Branding (combined) ── -->
<div class="card" id="company">
<div class="card-header">
<h3 class="maint-section-title">🏢 Company Information <span class="badge">Branding</span></h3>
<p class="text-muted">Company details and logo used across the app.</p>
</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="action" value="update_company">
<div class="form-row">
<div class="form-field">
<label>Company Name *</label>
<input type="text" name="company_name" value="<?= e($co['name']) ?>" placeholder="Company Name" required>
</div>
<div class="form-field">
<label>Phone</label>
<input type="text" name="company_phone" value="<?= e($co['phone']) ?>" placeholder="+44 7700 000000">
</div>
</div>
<div class="form-row">
<div class="form-field">
<label>Website</label>
<input type="text" name="company_website" value="<?= e($co['website']) ?>" placeholder="https://company.com">
</div>
<div class="form-field">
<label>Contact Email</label>
<input type="text" name="company_email" value="<?= e($co['email']) ?>" placeholder="contact@company.com">
</div>
</div>
<div class="form-field">
<label>Address</label>
<input type="text" name="company_address" value="<?= e($co['address']) ?>" placeholder="Company Address">
</div>
<button type="submit" class="btn btn-primary">💾 Save Company Info</button>
</form>
<div class="branding-grid">
<div class="branding-col">
<div style="display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
<div>
<div class="branding-title">🖼️ Logo</div>
<div class="branding-sub">Appears on login, installer, and sidebar.</div>
</div>
</div>
<div class="logo-current">
<div class="logo-thumb">
<?php if ($logoUrl): ?>
<img src="<?= e($logoUrl) ?>" alt="Current logo">
<?php else: ?>
<span style="font-size:1.4rem;color:var(--text-muted)">🖼️</span>
<?php endif; ?>
</div>
<div class="logo-info">
<strong><?= $logoFile ? e($logoFile) : 'No logo set' ?></strong>
<small><?= $logoFile ? 'Current logo — will be replaced on upload' : 'No logo uploaded yet' ?></small>
</div>
</div>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="upload_logo">
<div class="upload-dropzone" id="logoZone"
onclick="document.getElementById('logoFile2').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="dropFile(event,'logoFile2','logoLabel','logoZone');previewLogo(document.getElementById('logoFile2'))">
<div id="logoPreviewArea"><div class="upload-icon">🖼️</div></div>
<div id="logoLabel">Click or drag image here</div>
<small style="color:var(--text-muted)">PNG · JPG · SVG · WebP · Max 2MB</small>
</div>
<input type="file" id="logoFile2" name="logo" accept="image/*" style="display:none"
onchange="previewLogo(this);document.getElementById('logoLabel').textContent=this.files[0]?.name||'No file'">
<div style="text-align:center;margin-top:1rem">
<button type="submit" class="btn btn-primary" onclick="return confirm('Replace the current logo?')">📤 Upload Logo</button>
</div>
</form>
</div>
<div class="branding-col">
<div style="display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
<div>
<div class="branding-title">⭐ Favicon</div>
<div class="branding-sub">Shown in the browser tab. Defaults to your logo if not set.</div>
</div>
</div>
<div class="logo-current">
<div class="logo-thumb">
<?php if ($favUrl): ?>
<img src="<?= e($favUrl) ?>" alt="Current favicon">
<?php elseif ($logoUrl): ?>
<img src="<?= e($logoUrl) ?>" alt="Default favicon (logo)">
<?php else: ?>
<span style="font-size:1.4rem;color:var(--text-muted)">⭐</span>
<?php endif; ?>
</div>
<div class="logo-info">
<strong><?= $favFile ? e($favFile) : 'Using logo (default)' ?></strong>
<small><?= $favFile ? 'Current favicon — will be replaced on upload' : 'Upload a custom favicon (optional)' ?></small>
</div>
</div>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="upload_favicon">
<div class="upload-dropzone" id="favZone"
onclick="document.getElementById('favFile').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="dropFile(event,'favFile','favLabel','favZone')">
<div class="upload-icon">⭐</div>
<div id="favLabel">Click or drag favicon here</div>
<small style="color:var(--text-muted)">PNG · ICO · SVG · WebP · Max 600KB</small>
</div>
<input type="file" id="favFile" name="favicon" accept=".png,.ico,.svg,.webp,image/*" style="display:none"
onchange="document.getElementById('favLabel').textContent=this.files[0]?.name||'No file'">
<div style="text-align:center;margin-top:1rem">
<button type="submit" class="btn btn-primary" onclick="return confirm('Replace the current favicon?')">📤 Upload Favicon</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- ── Database ── -->
<div class="card" id="database">
<div class="card-header">
<h3 class="maint-section-title">🗄️ Database <span class="badge">Connection</span></h3>
<p class="text-muted">View and edit database connection settings. A test is run before saving.</p>
</div>
<div class="card-body">
<div class="kv" style="margin-bottom:.9rem">
<div class="k">Status</div><div class="v" style="color:<?= $dbStatus === 'OK' ? '#10b981' : 'var(--accent)' ?>"><?= e($dbStatus) ?></div>
<div class="k">Host</div><div class="v"><?= e($dbCfg['host']) ?></div>
<div class="k">Port</div><div class="v"><?= e($dbCfg['port']) ?></div>
<div class="k">DB Name</div><div class="v"><?= e($dbCfg['name']) ?></div>
<div class="k">User</div><div class="v"><?= e($dbCfg['user']) ?></div>
<div class="k">Password</div><div class="v"><?= $dbCfg['pass_set'] ? '********' : '(not set)' ?></div>
<div class="k">Engine</div><div class="v"><?= e(trim(($dbMeta['product'] ?: '') . ' ' . ($dbMeta['version'] ?: $dbMeta['server']))) ?></div>
</div>
<details class="maint-details">
<summary>Advanced details</summary>
<div class="kv" style="margin-top:.75rem">
<div class="k">Driver</div><div class="v"><?= e($dbMeta['driver'] ?: '—') ?></div>
<div class="k">Charset</div><div class="v"><?= e($dbMeta['charset'] ?: '—') ?></div>
<div class="k">Collation</div><div class="v"><?= e($dbMeta['collation'] ?: '—') ?></div>
<div class="k">SQL Mode</div><div class="v"><?= e($dbMeta['sql_mode'] ?: '—') ?></div>
<div class="k">SSL</div><div class="v"><?= e($dbMeta['ssl'] ? ('Enabled (' . $dbMeta['ssl'] . ')') : 'Not enabled') ?></div>
</div>
</details>
<div class="maint-actions">
<button type="button" class="btn btn-secondary" onclick="openDbModal()">⚙️ Edit DB Settings</button>
<a class="btn btn-secondary" href="<?= e(app_url('admin/logs')) ?>">📜 Activity Logs</a>
</div>
<div class="hint" style="margin-top:.75rem">
Changing these values affects the whole app. If you’re unsure, don’t save.
</div>
</div>
</div>
<!-- ── Update Manager (bottom) ── -->
<div class="card" id="updates">
<div class="card-header">
<h3 class="maint-section-title">📦 Update Manager <span class="badge">ZIP</span></h3>
<p class="text-muted">Upload a WorkersPanel ZIP. Config & data are preserved. (Refresh the page after applying.)</p>
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="apply_update">
<div class="upload-dropzone" id="updZone"
onclick="document.getElementById('updFile').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="dropFile(event,'updFile','updLabel','updZone')">
<div class="upload-icon">📦</div>
<div id="updLabel">Click or drag ZIP here</div>
<small style="color:var(--text-muted)">WorkersPanel_X.X.X-alpha_*.zip · Max 50MB</small>
</div>
<input type="file" id="updFile" name="update_package" accept=".zip" style="display:none"
onchange="document.getElementById('updLabel').textContent=this.files[0]?.name||'No file'">
<div style="text-align:center;margin-top:1.1rem">
<button type="submit" class="btn btn-primary"
onclick="return confirm('Apply this update? Config and data will be preserved.')">
🚀 Apply Update
</button>
</div>
</form>
<div class="hint" style="margin-top:.9rem;">
Tip: if the version badge doesn’t change immediately, do a hard refresh (<strong>Ctrl+Shift+R</strong>).
</div>
</div>
</div>
</div><!-- /maint-layout -->
<!-- DB Settings Modal -->
<div id="dbModal" class="modal-overlay" style="display:none;">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="dbTitle">
<div class="modal-header">
<h3 class="modal-title" id="dbTitle">Database Settings</h3>
<button class="modal-close" type="button" aria-label="Close" onclick="closeDbModal()">×</button>
</div>
<div class="modal-body">
<form method="POST" id="dbForm">
<div class="form-row">
<div class="form-field">
<label>Host</label>
<input type="text" name="db_host" value="<?= e($dbCfg['host']) ?>" placeholder="db.example.com" required>
</div>
<div class="form-field">
<label>Port</label>
<input type="text" name="db_port" value="<?= e($dbCfg['port']) ?>" placeholder="3306" required>
</div>
</div>
<div class="form-row">
<div class="form-field">
<label>Database Name</label>
<input type="text" name="db_name" value="<?= e($dbCfg['name']) ?>" placeholder="workerspanel" required>
</div>
<div class="form-field">
<label>Username</label>
<input type="text" name="db_user" value="<?= e($dbCfg['user']) ?>" placeholder="wp_user" required>
</div>
</div>
<div class="form-field">
<label>Password (leave blank to keep current)</label>
<input type="password" name="db_pass" value="" autocomplete="new-password" placeholder="••••••••">
</div>
<div class="maint-actions" style="margin-top: .6rem;">
<button type="submit" name="action" value="db_test" class="btn btn-secondary">✅ Test Connection</button>
<button type="submit" name="action" value="db_save" class="btn btn-primary" onclick="return confirm('Save database settings? This affects the whole app.')">💾 Save</button>
<button type="button" class="btn btn-secondary" onclick="closeDbModal()">Close</button>
</div>
<div class="hint" style="margin-top:.75rem">A connection test is performed before saving. If it fails, nothing is written.</div>
</form>
</div>
</div>
</div>
<script>
function dropFile(e, inputId, labelId, zoneId) {
e.preventDefault();
document.getElementById(zoneId).classList.remove('drag-over');
const files = e.dataTransfer.files;
if (!files || !files[0]) return;
const dt = new DataTransfer();
dt.items.add(files[0]);
document.getElementById(inputId).files = dt.files;
document.getElementById(labelId).textContent = files[0].name;
}
function previewLogo(input) {
if (!input.files || !input.files[0]) return;
const reader = new FileReader();
reader.onload = e => {
document.getElementById('logoPreviewArea').innerHTML =
'<img src="'+e.target.result+'" style="max-height:72px;max-width:180px;border-radius:8px;margin-bottom:.4rem">';
};
reader.readAsDataURL(input.files[0]);
}
function openDbModal(){
const o = document.getElementById('dbModal');
if (!o) return;
o.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closeDbModal(){
const o = document.getElementById('dbModal');
if (!o) return;
o.style.display = 'none';
document.body.style.overflow = '';
}
(function(){
// Close DB modal on overlay click + ESC
const db = document.getElementById('dbModal');
if (db) {
db.addEventListener('click', (e) => { if (e.target === db) closeDbModal(); });
}
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const db2 = document.getElementById('dbModal');
if (db2 && db2.style.display === 'flex') closeDbModal();
});
})();
</script>
<?php include __DIR__ . '/../partials/footer.php'; ?>