BROOKO icon
BROOKO UK NETWORK
Where code meets creativity & adventure
File viewer

maintenance.php

Type
php
Size
55.95 KB
Modified
15 May
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 &amp; 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'; ?>