From 81d0f5a42957f7939c2f58f8fc1ff5a8bcb2dee2 Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Mon, 8 Dec 2025 10:19:54 +0100 Subject: [PATCH] Backup --- .../ServerManager/Services/HetznerService.php | 8 + examples/ServerManager/UI/KanbanTab.php | 53 +- examples/ServerManager/UI/ServerListTab.php | 493 +++++++++++++++++- examples/ServerManager/UI/SettingsModal.php | 35 +- examples/ServerManager/UI/SftpManagerTab.php | 70 ++- src/Tailwind/Parser/Background.php | 4 +- src/Tailwind/Parser/Border.php | 76 ++- src/Ui/Component.php | 69 +++ src/Ui/Widget/FileBrowser.php | 42 +- src/Ui/Widget/Table.php | 4 +- src/Ui/Widget/VirtualListView.php | 32 +- 11 files changed, 764 insertions(+), 122 deletions(-) diff --git a/examples/ServerManager/Services/HetznerService.php b/examples/ServerManager/Services/HetznerService.php index 48dc2c7..203fc52 100644 --- a/examples/ServerManager/Services/HetznerService.php +++ b/examples/ServerManager/Services/HetznerService.php @@ -32,6 +32,10 @@ class HetznerService 'needs_reboot' => 'unbekannt', 'updates_available' => 'unbekannt', 'os_version' => 'unbekannt', + 'release' => 'unbekannt', + 'root' => 'unbekannt', + 'data' => 'unbekannt', + 'last_backup' => 'unbekannt', ]; } @@ -63,6 +67,10 @@ class HetznerService 'needs_reboot' => 'nein', 'updates_available' => 'nein', 'os_version' => 'Ubuntu 22.04 LTS', + 'release' => 'v1.0.0', + 'root' => '35%', + 'data' => '42%', + 'last_backup' => 'unbekannt', ]; } return $testData; diff --git a/examples/ServerManager/UI/KanbanTab.php b/examples/ServerManager/UI/KanbanTab.php index f9a2054..45300d8 100644 --- a/examples/ServerManager/UI/KanbanTab.php +++ b/examples/ServerManager/UI/KanbanTab.php @@ -8,9 +8,9 @@ use PHPNative\Ui\Widget\Button; use PHPNative\Ui\Widget\Container; use PHPNative\Ui\Widget\Icon; use PHPNative\Ui\Widget\Label; -use PHPNative\Ui\Widget\TextInput; -use PHPNative\Ui\Widget\TextArea; use PHPNative\Ui\Widget\Modal; +use PHPNative\Ui\Widget\TextArea; +use PHPNative\Ui\Widget\TextInput; class KanbanTab { @@ -125,7 +125,7 @@ class KanbanTab } foreach ($boards as $boardName) { - $isSelected = ($boardName === $this->currentEditingBoard); + $isSelected = $boardName === $this->currentEditingBoard; $style = 'px-2 py-1 rounded text-xs border '; if ($isSelected) { @@ -296,8 +296,20 @@ class KanbanTab $tasks = []; } + // Map server IDs to names if the server list tab is available + $serverNames = []; + if ($this->serverListTab !== null) { + foreach ($this->serverListTab->currentServerData as $row) { + $id = $row['id'] ?? null; + $name = $row['name'] ?? null; + if ($id !== null && $name !== null) { + $serverNames[(int) $id] = (string) $name; + } + } + } + foreach ($boards as $boardName) { - $column = new Container('flex flex-col bg-white rounded shadow-md w-64 max-h-full'); + $column = new Container('flex flex-col bg-white rounded shadow-md w-128 max-h-full'); $columnHeader = new Container('px-3 py-2 border-b border-gray-300 bg-gray-100'); $columnHeader->addComponent(new Label($boardName, 'text-sm font-semibold text-gray-800')); @@ -316,11 +328,19 @@ class KanbanTab foreach ($boardTasks as $task) { $title = (string) ($task['title'] ?? ''); $serverId = $task['server_id'] ?? null; - $serverLabel = $serverId !== null ? ('Server #' . $serverId) : 'Kein Server'; + $serverName = null; + if ($serverId !== null && isset($serverNames[(int) $serverId])) { + $serverName = $serverNames[(int) $serverId]; + } + if ($serverName !== null) { + $serverLabel = $serverName . ' (#' . $serverId . ')'; + } else { + $serverLabel = $serverId !== null ? ('Server #' . $serverId) : 'Kein Server'; + } $taskId = $task['id'] ?? null; $card = new Container( - 'flex flex-col gap-1 px-3 py-2 bg-white border border-gray-200 rounded shadow-sm', + 'flex flex-col gap-1 px-3 py-2 bg-lime-100 border border-red-500 rounded-sm shadow-lg', ); // Header row with title and action icons @@ -328,12 +348,7 @@ class KanbanTab $headerRow->addComponent(new Label($title, 'text-xs text-gray-900 flex-1')); // Edit icon button - $editButton = new Button( - '', - 'p-1 rounded hover:bg-blue-100', - null, - 'text-blue-600', - ); + $editButton = new Button('', 'p-1 rounded hover:bg-blue-100', null, 'text-blue-600'); $editIcon = new Icon(IconName::edit, 12, 'text-blue-600'); $editButton->setIcon($editIcon); @@ -343,12 +358,7 @@ class KanbanTab }); // Delete icon button - $deleteButton = new Button( - '', - 'p-1 rounded hover:bg-red-100', - null, - 'text-red-600', - ); + $deleteButton = new Button('', 'p-1 rounded hover:bg-red-100', null, 'text-red-600'); $deleteIcon = new Icon(IconName::trash, 12, 'text-red-600'); $deleteButton->setIcon($deleteIcon); @@ -362,10 +372,7 @@ class KanbanTab return; } - $tasks = array_values(array_filter( - $tasks, - static fn($t) => ($t['id'] ?? null) !== $taskId, - )); + $tasks = array_values(array_filter($tasks, static fn($t) => ($t['id'] ?? null) !== $taskId)); $kanbanTab->settings->set('kanban.tasks', $tasks); $kanbanTab->settings->save(); @@ -381,7 +388,7 @@ class KanbanTab $card->addComponent($headerRow); // Server label - $card->addComponent(new Label($serverLabel, 'text-[10px] text-gray-500')); + $card->addComponent(new Label($serverLabel, 'text-sm text-gray-500')); $columnBody->addComponent($card); } diff --git a/examples/ServerManager/UI/ServerListTab.php b/examples/ServerManager/UI/ServerListTab.php index 9b43ce8..f8007cc 100644 --- a/examples/ServerManager/UI/ServerListTab.php +++ b/examples/ServerManager/UI/ServerListTab.php @@ -118,12 +118,47 @@ class ServerListTab 'render' => [$this, 'renderSelectCell'], ], ['key' => 'id', 'title' => 'ID', 'width' => 100], + [ + 'key' => 'tasks_indicator', + 'title' => '', + 'width' => 40, + 'render' => [$this, 'renderTasksIndicatorCell'], + ], ['key' => 'name', 'title' => 'Name'], ['key' => 'status', 'title' => 'Status', 'width' => 90], ['key' => 'type', 'title' => 'Typ', 'width' => 80], - ['key' => 'ipv4', 'title' => 'IPv4', 'width' => 140], - ['key' => 'docker_status', 'title' => 'Docker', 'width' => 80], - ['key' => 'os_version', 'title' => 'Ubuntu', 'width' => 180], + ['key' => 'ipv4', 'title' => 'IPv4', 'width' => 150], + [ + 'key' => 'docker_status', + 'title' => 'Docker', + 'width' => 100, + 'render' => [$this, 'renderDockerStatusCell'], + ], + [ + 'key' => 'docker_running', + 'title' => 'Running', + 'width' => 100, + 'render' => [$this, 'renderDockerRunningCell'], + ], + [ + 'key' => 'os_version', + 'title' => 'Ubuntu', + 'width' => 180, + 'render' => [$this, 'renderOsVersionCell'], + ], + ['key' => 'release', 'title' => 'Release', 'width' => 140], + [ + 'key' => 'root', + 'title' => 'Root', + 'width' => 100, + 'render' => [$this, 'renderRootCell'], + ], + [ + 'key' => 'data', + 'title' => 'Data', + 'width' => 90, + 'render' => [$this, 'renderDataCell'], + ], [ 'key' => 'needs_reboot', 'title' => 'Neustart', @@ -136,6 +171,12 @@ class ServerListTab 'width' => 130, 'render' => [$this, 'renderUpdatesCell'], ], + [ + 'key' => 'last_backup', + 'title' => 'Letztes Backup', + 'width' => 180, + 'render' => [$this, 'renderLastBackupCell'], + ], ]); // Load initial test data @@ -145,7 +186,7 @@ class ServerListTab $leftSide->addComponent($this->table); // Right side: Detail panel - $detailPanel = new Container('flex flex-col gap-3 w-120 bg-white border-2 border-gray-300 rounded p-4'); + $detailPanel = new Container('flex flex-col gap-3 w-120 bg-white border-2 border-gray-300 rounded p-4 overflow-y-auto'); $detailTitle = new Label('Server Details', 'text-xl font-bold text-black mb-2'); $detailPanel->addComponent($detailTitle); @@ -385,16 +426,26 @@ class ServerListTab // Basisdaten setzen, Docker-Status initial auf "pending" $serverListTab->currentServerData = array_map(static fn($row) => array_merge([ 'docker_status' => 'pending', + 'docker_running' => 'pending', 'docker' => null, 'docker_error' => null, 'needs_reboot' => 'unbekannt', 'updates_available' => 'unbekannt', 'os_version' => 'unbekannt', + 'release' => 'unbekannt', + 'root' => 'unbekannt', + 'data' => 'unbekannt', + 'last_backup' => 'unbekannt', ], $row), $result['servers']); $serverListTab->table->setData($serverListTab->currentServerData, false); $serverListTab->statusLabel->setText('Server geladen: ' . $result['count'] . ' gefunden'); + // Kanban-Board aktualisieren, damit Servernamen in den Karten up-to-date sind + if ($serverListTab->kanbanTab !== null) { + $serverListTab->kanbanTab->refresh(); + } + if (function_exists('desktop_notify')) { desktop_notify( 'Serverliste aktualisiert', @@ -410,6 +461,7 @@ class ServerListTab if (empty($ip) || empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { $serverListTab->currentServerData[$index]['docker_error'] = 'Kein gültiger Private-Key oder IP'; $serverListTab->currentServerData[$index]['docker_status'] = 'error'; + $serverListTab->currentServerData[$index]['docker_running'] = 'error'; continue; } @@ -427,7 +479,7 @@ class ServerListTab if (!$ssh->login('root', $key)) { return [ 'index' => $index, - 'docker' => null, + 'docker' => 'fetch usage', 'docker_error' => 'SSH Login fehlgeschlagen', 'docker_status' => 'error', ]; @@ -451,18 +503,88 @@ class ServerListTab )); $osVersion = $osOutput !== '' ? $osOutput : 'unbekannt'; - // Docker status for main application container - $output = $ssh->exec('docker inspect psc-web-1'); + // Last backup from borgmatic + $lastBackup = 'unbekannt'; + $borgmaticOutput = trim($ssh->exec( + 'borgmatic list --last 1 --json 2>/dev/null || echo ""', + )); + if ($borgmaticOutput !== '') { + $borgmaticData = json_decode($borgmaticOutput, true); + if ( + json_last_error() === JSON_ERROR_NONE && + is_array($borgmaticData) && + !empty($borgmaticData) + ) { + // borgmatic list --json gibt ein Array von Repositories zurück + $firstRepo = reset($borgmaticData); + if ( + is_array($firstRepo) && + isset($firstRepo['archives']) && + is_array($firstRepo['archives']) && + !empty($firstRepo['archives']) + ) { + $lastArchive = reset($firstRepo['archives']); + if ( + is_array($lastArchive) && + isset($lastArchive['time']) && + is_string($lastArchive['time']) + ) { + try { + $backupTime = new \DateTime($lastArchive['time']); + $lastBackup = $backupTime->format('d.m.Y H:i'); + } catch (\Exception $e) { + $lastBackup = $lastArchive['time']; + } + } + } + } + } + // Disk usage for / and /data + $rootUsage = 'unbekannt'; + $dfRoot = trim($ssh->exec('df -P / 2>/dev/null')); + if ($dfRoot !== '') { + $lines = preg_split('/\r\n|\r|\n/', $dfRoot); + if (count($lines) >= 2) { + $parts = preg_split('/\s+/', trim($lines[1])); + if (isset($parts[4]) && $parts[4] !== '') { + $rootUsage = $parts[4]; + } + } + } + + $dataUsage = 'unbekannt'; + $dfData = trim($ssh->exec('df -P /data 2>/dev/null')); + if ($dfData !== '') { + $lines = preg_split('/\r\n|\r|\n/', $dfData); + if (count($lines) >= 2) { + $parts = preg_split('/\s+/', trim($lines[1])); + if (isset($parts[4]) && $parts[4] !== '') { + $dataUsage = $parts[4]; + } + } + } + + // Docker status for main application container + $runningOutput = trim($ssh->exec( + "docker ps --filter status=running --format '{{.ID}}' | wc -l", + )); + $runningCount = is_numeric($runningOutput) ? ((int) $runningOutput) : null; + + $output = $ssh->exec('docker inspect psc-web-1'); if (empty($output)) { return [ 'index' => $index, 'docker' => null, 'docker_error' => 'Leere Docker-Antwort', 'docker_status' => 'error', + 'docker_running' => $runningCount, 'needs_reboot' => $needsReboot, 'updates' => $updatesCount, 'os_version' => $osVersion, + 'root' => $rootUsage, + 'data' => $dataUsage, + 'last_backup' => $lastBackup, ]; } @@ -473,20 +595,111 @@ class ServerListTab 'docker' => null, 'docker_error' => 'Ungültige Docker-JSON-Antwort', 'docker_status' => 'error', + 'docker_running' => $runningCount, 'needs_reboot' => $needsReboot, 'updates' => $updatesCount, 'os_version' => $osVersion, + 'root' => $rootUsage, + 'data' => $dataUsage, + 'last_backup' => $lastBackup, ]; } + $domains = []; + if (isset($json[0]['Config']['Env']) && is_array($json[0]['Config']['Env'])) { + $hosts = array_filter($json[0]['Config']['Env'], static function ($item) { + return is_string($item) && str_starts_with($item, 'LETSENCRYPT_HOST'); + }); + if (!empty($hosts)) { + $hostEntry = array_first($hosts); + $hostString = substr((string) $hostEntry, strlen('LETSENCRYPT_HOST=')); + $domains = array_values(array_filter( + array_map('trim', explode(',', $hostString)), + static fn($d) => $d !== '', + )); + } + } + + $release = 'unbekannt'; + if (!empty($domains)) { + $releaseDomain = $domains[0]; + $releaseDomain = trim($releaseDomain); + if ($releaseDomain !== '') { + $url = $releaseDomain; + if ( + !str_starts_with($url, 'http://') && + !str_starts_with($url, 'https://') + ) { + $url = 'https://' . $url; + } + $url = rtrim($url, '/') . '/apps/api/system/version'; + + try { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => 5, + ], + ]); + + $response = file_get_contents($url, false, $context); + if ($response !== false) { + $data = json_decode($response, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($data)) { + $releaseValue = null; + if (isset($data['version']) && is_string($data['version'])) { + $releaseValue = trim($data['version']); + } elseif ( + isset($data['release']) && + is_string($data['release']) + ) { + $releaseValue = trim($data['release']); + } + + $dateValue = null; + if (isset($data['datum']) && is_string($data['datum'])) { + $dateValue = trim($data['datum']); + } + + if ( + $releaseValue !== null && + $dateValue !== null && + $dateValue !== '' + ) { + $release = $releaseValue . ' (' . $dateValue . ')'; + } elseif ($releaseValue !== null) { + $release = $releaseValue; + } elseif ($dateValue !== null && $dateValue !== '') { + $release = $dateValue; + } else { + $release = 'unbekannt'; + } + } else { + $release = 'Ungültig'; + } + } else { + $release = 'Fehler'; + } + } catch (\Throwable) { + $release = 'Fehler'; + } + } + } + return [ 'index' => $index, 'docker' => $json, 'docker_error' => null, 'docker_status' => 'ok', + 'docker_running' => $runningCount, 'needs_reboot' => $needsReboot, 'updates' => $updatesCount, 'os_version' => $osVersion, + 'root' => $rootUsage, + 'data' => $dataUsage, + 'domains' => $domains, + 'release' => $release, + 'last_backup' => $lastBackup, ]; } catch (\Throwable $e) { return [ @@ -494,9 +707,13 @@ class ServerListTab 'docker' => null, 'docker_error' => 'SSH-Fehler: ' . $e->getMessage(), 'docker_status' => 'error', + 'docker_running' => null, 'needs_reboot' => null, 'updates' => null, 'os_version' => null, + 'root' => 'unbekannt', + 'data' => 'unbekannt', + 'last_backup' => 'unbekannt', ]; } }); @@ -512,23 +729,6 @@ class ServerListTab } if (array_key_exists('docker', $dockerResult)) { - if (isset($dockerResult['docker'][0]['Config']['Env'])) { - $hosts = array_filter( - $dockerResult['docker'][0]['Config']['Env'], - function ($item) { - if (str_starts_with($item, 'LETSENCRYPT_HOST')) { - return true; - } - return false; - }, - ); - $domains = explode( - ',', - substr(array_first($hosts), strlen('LETSENCRYPT_HOST=')), - ); - - $serverListTab->currentServerData[$i]['domains'] = $domains; - } $serverListTab->currentServerData[$i]['docker'] = $dockerResult['docker']; $searchTerm = $serverListTab->searchInput->getValue(); if (empty($searchTerm)) { @@ -551,6 +751,14 @@ class ServerListTab } } + if (array_key_exists('domains', $dockerResult)) { + $domains = $dockerResult['domains']; + if (!is_array($domains)) { + $domains = []; + } + $serverListTab->currentServerData[$i]['domains'] = $domains; + } + // Map system status into human-readable table fields if (array_key_exists('needs_reboot', $dockerResult)) { $needsReboot = $dockerResult['needs_reboot']; @@ -577,6 +785,27 @@ class ServerListTab : 'unbekannt'; } + if (array_key_exists('root', $dockerResult)) { + $rootUsage = $dockerResult['root']; + $serverListTab->currentServerData[$i]['root'] = $rootUsage !== null + ? $rootUsage + : 'unbekannt'; + } + + if (array_key_exists('data', $dockerResult)) { + $dataUsage = $dockerResult['data']; + $serverListTab->currentServerData[$i]['data'] = $dataUsage !== null + ? $dataUsage + : 'unbekannt'; + } + + if (array_key_exists('release', $dockerResult)) { + $release = $dockerResult['release']; + $serverListTab->currentServerData[$i]['release'] = $release !== null + ? $release + : 'unbekannt'; + } + if (array_key_exists('docker_error', $dockerResult)) { $serverListTab->currentServerData[$i]['docker_error'] = $dockerResult['docker_error']; @@ -586,6 +815,20 @@ class ServerListTab $serverListTab->currentServerData[$i]['docker_status'] = $dockerResult['docker_status']; } + + if (array_key_exists('docker_running', $dockerResult)) { + $serverListTab->currentServerData[$i]['docker_running'] = + $dockerResult['docker_running'] !== null + ? $dockerResult['docker_running'] + : 'unbekannt'; + } + + if (array_key_exists('last_backup', $dockerResult)) { + $lastBackup = $dockerResult['last_backup']; + $serverListTab->currentServerData[$i]['last_backup'] = $lastBackup !== null + ? $lastBackup + : 'unbekannt'; + } }); $dockerTask->onError(function ($error) use (&$serverListTab, $index) { @@ -595,6 +838,7 @@ class ServerListTab if (isset($serverListTab->currentServerData[$index])) { $serverListTab->currentServerData[$index]['docker_error'] = 'Async Fehler: ' . $errorMsg; + $serverListTab->currentServerData[$index]['docker_running'] = 'error'; } }); } @@ -894,12 +1138,57 @@ class ServerListTab return $this->sftpButton; } + private static function fetchReleaseFromDomain(string $domain): string + { + $domain = trim($domain); + if ($domain === '') { + return 'unbekannt'; + } + + $url = $domain; + if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) { + $url = 'https://' . $url; + } + + $url = rtrim($url, '/') . '/apps/api/system/version'; + try { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => 5, + ], + ]); + + $response = file_get_contents($url, false, $context); + if ($response === false) { + return 'Fehler'; + } + + $data = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { + return 'Ungültig'; + } + + if (isset($data['version']) && is_string($data['version'])) { + return $data['version']; + } + + if (isset($data['release']) && is_string($data['release'])) { + return $data['release']; + } + + return 'unbekannt'; + } catch (\Throwable $e) { + return 'Fehler'; + } + } + public function renderNeedsRebootCell(array $rowData, int $rowIndex): Label { $value = (string) ($rowData['needs_reboot'] ?? ''); $normalized = strtolower(trim($value)); - $baseStyle = 'px-4 border-r border-gray-300 text-sm '; + $baseStyle = 'px-4 text-sm '; if ($normalized === 'nein') { $style = $baseStyle . 'text-green-600'; @@ -917,7 +1206,7 @@ class ServerListTab $value = (string) ($rowData['updates_available'] ?? ''); $normalized = strtolower(trim($value)); - $baseStyle = 'px-4 border-r border-gray-300 text-sm '; + $baseStyle = 'px-4 text-sm '; if ($normalized === 'nein' || $normalized === 'nein (0)') { $style = $baseStyle . 'text-green-600'; @@ -930,6 +1219,158 @@ class ServerListTab return new Label($value, $style); } + public function renderTasksIndicatorCell(array $rowData, int $rowIndex): Label + { + $serverId = $rowData['id'] ?? null; + $hasOpenTasks = false; + + if ($serverId !== null) { + $tasks = $this->settings->get('kanban.tasks', []); + if (is_array($tasks)) { + $intServerId = (int) $serverId; + $doneBoard = (string) $this->settings->get('kanban.done_board', 'fertig'); + foreach ($tasks as $task) { + if (((int) ($task['server_id'] ?? 0)) === $intServerId) { + $taskBoard = (string) ($task['board'] ?? 'neu'); + if ($taskBoard !== $doneBoard) { + $hasOpenTasks = true; + break; + } + } + } + } + } + + $symbol = '●'; + $baseStyle = 'px-2 text-center text-sm '; + $style = $baseStyle . ($hasOpenTasks ? 'text-red-500' : 'text-green-500'); + + return new Label($symbol, $style); + } + + public function renderDockerStatusCell(array $rowData, int $rowIndex): Label + { + $status = (string) ($rowData['docker_status'] ?? ''); + $hasError = strtolower(trim($status)) === 'error' || !empty($rowData['docker_error'] ?? null); + + $baseStyle = 'px-4 text-sm '; + $style = $hasError ? ($baseStyle . 'text-red-600') : ($baseStyle . 'text-gray-800'); + + return new Label($status, $style); + } + + public function renderDockerRunningCell(array $rowData, int $rowIndex): Label + { + $value = $rowData['docker_running'] ?? 'unbekannt'; + $baseStyle = 'px-4 text-sm '; + + $style = $baseStyle . 'text-gray-800'; + $text = (string) $value; + + if (is_numeric($value)) { + $count = (int) $value; + $text = (string) $count; + if ($count < 6) { + $style = $baseStyle . 'text-red-600'; + } + } else { + $style = $baseStyle . 'text-gray-500 italic'; + } + + return new Label($text, $style); + } + + public function renderOsVersionCell(array $rowData, int $rowIndex): Label + { + $value = (string) ($rowData['os_version'] ?? ''); + $normalized = strtolower(trim($value)); + + $baseStyle = 'px-4 text-sm '; + $style = $baseStyle . 'text-gray-800'; + + if (preg_match('/ubuntu\s+(\d+)\./i', $value, $matches)) { + $major = (int) ($matches[1] ?? 0); + if ($major > 0 && $major < 24) { + $style = $baseStyle . 'text-red-600'; + } + } + + if ($normalized === 'unbekannt') { + $style = $baseStyle . 'text-gray-500 italic'; + } + + return new Label($value, $style); + } + + public function renderRootCell(array $rowData, int $rowIndex): Label + { + $value = (string) ($rowData['root'] ?? ''); + return $this->renderDiskUsageCell($value); + } + + public function renderDataCell(array $rowData, int $rowIndex): Label + { + $value = (string) ($rowData['data'] ?? ''); + return $this->renderDiskUsageCell($value); + } + + private function renderDiskUsageCell(string $value): Label + { + $trimmed = trim($value); + $baseStyle = 'px-4 text-sm '; + + $style = $baseStyle . 'text-gray-800'; + + if ($trimmed === '' || strtolower($trimmed) === 'unbekannt') { + $style = $baseStyle . 'text-gray-500 italic'; + } else { + $numeric = $trimmed; + if (str_ends_with($numeric, '%')) { + $numeric = substr($numeric, 0, -1); + } + if (is_numeric($numeric) && ((int) $numeric) >= 90) { + $style = $baseStyle . 'text-red-600'; + } + } + + return new Label($value, $style); + } + + public function renderLastBackupCell(array $rowData, int $rowIndex): Label + { + $value = (string) ($rowData['last_backup'] ?? 'unbekannt'); + $normalized = strtolower(trim($value)); + + $baseStyle = 'px-4 text-sm '; + + if ($normalized === 'unbekannt' || $normalized === '') { + $style = $baseStyle . 'text-gray-500 italic'; + return new Label($value, $style); + } + + // Check if backup is older than 24 hours + try { + $backupTime = \DateTime::createFromFormat('d.m.Y H:i', $value); + if ($backupTime !== false) { + $now = new \DateTime(); + $diff = $now->diff($backupTime); + $hoursAgo = ($diff->days * 24) + $diff->h; + + if ($hoursAgo > 24) { + $style = $baseStyle . 'text-red-600'; + } else { + $style = $baseStyle . 'text-green-600'; + } + } else { + $style = $baseStyle . 'text-gray-800'; + } + } catch (\Exception $e) { + $style = $baseStyle . 'text-gray-800'; + } + + return new Label($value, $style); + } + private function updateDomainDetails(array $domains): void { $this->detailDomainsContainer->clearChildren(); diff --git a/examples/ServerManager/UI/SettingsModal.php b/examples/ServerManager/UI/SettingsModal.php index 455ae54..b5b8245 100644 --- a/examples/ServerManager/UI/SettingsModal.php +++ b/examples/ServerManager/UI/SettingsModal.php @@ -18,6 +18,7 @@ class SettingsModal private TextInput $apiKeyInput; private TextInput $privateKeyPathInput; private TextInput $remoteStartDirInput; + private TextInput $doneBoardInput; public function __construct(Settings $settings, MenuBar $menuBar, string &$apiKey, string &$privateKeyPath, string &$remoteStartDir) { @@ -31,11 +32,17 @@ class SettingsModal 'Remote Start Directory', 'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black' ); + $this->doneBoardInput = new TextInput( + 'Fertig-Board (z.B. fertig)', + 'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black' + ); // Set initial values $this->apiKeyInput->setValue($apiKey); $this->privateKeyPathInput->setValue($privateKeyPath); $this->remoteStartDirInput->setValue($remoteStartDir); + $doneBoard = (string) $settings->get('kanban.done_board', 'fertig'); + $this->doneBoardInput->setValue($doneBoard); // Create modal dialog $modalDialog = new Container('bg-white rounded-lg p-6 flex flex-col w-96 gap-3'); @@ -63,6 +70,15 @@ class SettingsModal $remoteStartDirFieldContainer->addComponent($this->remoteStartDirInput); $modalDialog->addComponent($remoteStartDirFieldContainer); + // Done-board field + $doneBoardFieldContainer = new Container('flex flex-col gap-1'); + $doneBoardFieldContainer->addComponent(new Label( + 'Board-Name für erledigte Tasks (z.B. "fertig")', + 'text-sm text-gray-600' + )); + $doneBoardFieldContainer->addComponent($this->doneBoardInput); + $modalDialog->addComponent($doneBoardFieldContainer); + // Buttons $buttonRow = new Container('flex flex-row gap-2 justify-end'); $cancelButton = new Button('Abbrechen', 'px-4 py-2 bg-gray-200 text-black rounded hover:bg-gray-300'); @@ -80,20 +96,37 @@ class SettingsModal $apiKeyInputRef = $this->apiKeyInput; $privateKeyPathInputRef = $this->privateKeyPathInput; $remoteStartDirInputRef = $this->remoteStartDirInput; + $doneBoardInputRef = $this->doneBoardInput; $cancelButton->setOnClick(function () use ($menuBar, $settingsModal) { $menuBar->closeAllMenus(); $settingsModal->hide(); }); - $saveButton->setOnClick(function () use ($menuBar, $settingsModal, &$apiKey, &$privateKeyPath, &$remoteStartDir, $settings, $apiKeyInputRef, $privateKeyPathInputRef, $remoteStartDirInputRef) { + $saveButton->setOnClick(function () use ( + $menuBar, + $settingsModal, + &$apiKey, + &$privateKeyPath, + &$remoteStartDir, + $settings, + $apiKeyInputRef, + $privateKeyPathInputRef, + $remoteStartDirInputRef, + $doneBoardInputRef + ) { $apiKey = trim($apiKeyInputRef->getValue()); $privateKeyPath = trim($privateKeyPathInputRef->getValue()); $remoteStartDir = trim($remoteStartDirInputRef->getValue()); + $doneBoard = trim($doneBoardInputRef->getValue()); + if ($doneBoard === '') { + $doneBoard = 'fertig'; + } $settings->set('api_key', $apiKey); $settings->set('private_key_path', $privateKeyPath); $settings->set('remote_start_dir', $remoteStartDir); + $settings->set('kanban.done_board', $doneBoard); $settings->save(); $menuBar->closeAllMenus(); diff --git a/examples/ServerManager/UI/SftpManagerTab.php b/examples/ServerManager/UI/SftpManagerTab.php index 0c0d3df..df97048 100644 --- a/examples/ServerManager/UI/SftpManagerTab.php +++ b/examples/ServerManager/UI/SftpManagerTab.php @@ -26,7 +26,9 @@ class SftpManagerTab private TextInput $filenameInput; private Modal $renameModal; private TextInput $renameInput; + private Label $renamePathLabel; private string $currentRenameFilePath = ''; + private string $currentRenameMode = 'remote'; private Modal $deleteConfirmModal; private string $currentDeleteFilePath = ''; private Label $deleteConfirmLabel; @@ -146,6 +148,11 @@ class SftpManagerTab // Create delete confirmation modal $this->createDeleteConfirmModal($currentPrivateKeyPath, $serverListTab, $statusLabel); + // Setup local rename handler + $this->localFileBrowser->setOnRenameFile(function ($path, $row) use ($sftpTab, $statusLabel) { + $sftpTab->handleLocalRename($path, $row, $statusLabel); + }); + // Setup transfer button handlers $uploadButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { $sftpTab->handleUpload($currentPrivateKeyPath, $serverListTab, $statusLabel); @@ -1406,13 +1413,14 @@ class SftpManagerTab $modalContent = new Container('flex flex-col bg-white rounded-lg shadow-lg p-4 gap-3 w-96'); // Title - $modalContent->addComponent(new Label('Datei umbenennen', 'text-lg font-bold text-black')); + $modalContent->addComponent(new Label('Umbenennen', 'text-lg font-bold text-black')); + + // Path info + $this->renamePathLabel = new Label('', 'text-xs text-gray-600 font-mono break-words'); + $modalContent->addComponent($this->renamePathLabel); // Filename input - $this->renameInput = new TextInput( - 'Neuer Dateiname', - 'w-full border-2 border-gray-300 rounded px-3 py-2 bg-white text-black', - ); + $this->renameInput = new TextInput('Neuer Name', 'w-full border-2 border-gray-300 rounded px-3 py-2 bg-white text-black'); $modalContent->addComponent($this->renameInput); // Button container @@ -1437,12 +1445,25 @@ class SftpManagerTab } $oldPath = $sftpTab->currentRenameFilePath; - $directory = dirname($oldPath); - $newPath = $directory . '/' . $newFilename; + $separator = $sftpTab->currentRenameMode === 'local' ? DIRECTORY_SEPARATOR : '/'; + $directory = $sftpTab->normalizeDirectory(dirname($oldPath), $separator); + $newPath = $sftpTab->joinPath($directory, $newFilename, $separator); // Close rename modal $sftpTab->renameModal->setVisible(false); + if ($sftpTab->currentRenameMode === 'local') { + $succeeded = @rename($oldPath, $newPath); + if ($succeeded === false) { + $statusLabel->setText('Lokales Umbenennen fehlgeschlagen'); + return; + } + + $statusLabel->setText('Lokal umbenannt: ' . basename($newPath)); + $sftpTab->localFileBrowser->loadDirectory($directory); + return; + } + // Perform rename via SFTP $selectedServerRef = &$serverListTab->selectedServer; @@ -1606,9 +1627,25 @@ class SftpManagerTab return; } + $this->currentRenameMode = 'remote'; $this->currentRenameFilePath = $path; $filename = basename($path); $this->renameInput->setValue($filename); + $this->renamePathLabel->setText('Remote: ' . $path); + $this->renameModal->setVisible(true); + } + + private function handleLocalRename(string $path, array $row, Label $statusLabel): void + { + if (!file_exists($path)) { + $statusLabel->setText('Lokale Datei/Ordner nicht gefunden'); + return; + } + + $this->currentRenameMode = 'local'; + $this->currentRenameFilePath = $path; + $this->renameInput->setValue(basename($path)); + $this->renamePathLabel->setText('Lokal: ' . $path); $this->renameModal->setVisible(true); } @@ -1629,4 +1666,23 @@ class SftpManagerTab $this->deleteConfirmLabel->setText('Möchten Sie die Datei "' . $filename . '" wirklich löschen?'); $this->deleteConfirmModal->setVisible(true); } + + private function normalizeDirectory(string $directory, string $separator): string + { + $normalized = rtrim($directory, $separator); + if ($normalized === '') { + return $separator; + } + + return $normalized; + } + + private function joinPath(string $directory, string $filename, string $separator): string + { + if ($directory === $separator) { + return $directory . $filename; + } + + return $directory . $separator . $filename; + } } diff --git a/src/Tailwind/Parser/Background.php b/src/Tailwind/Parser/Background.php index a897d83..3d3aed9 100644 --- a/src/Tailwind/Parser/Background.php +++ b/src/Tailwind/Parser/Background.php @@ -10,7 +10,9 @@ class Background implements Parser { $color = new \PHPNative\Tailwind\Style\Color(); - preg_match_all('/bg-(.*)/', $style, $output_array); + // Nur den ersten bg-* Token ohne nachfolgende Klassen extrahieren + // Beispiel: "bg-lime-300 border ..." -> "lime-300" + preg_match_all('/bg-([^\s]+)/', $style, $output_array); if (count($output_array[0]) > 0) { $colorStyle = $output_array[1][0]; diff --git a/src/Tailwind/Parser/Border.php b/src/Tailwind/Parser/Border.php index 04ab533..574e131 100644 --- a/src/Tailwind/Parser/Border.php +++ b/src/Tailwind/Parser/Border.php @@ -10,6 +10,7 @@ class Border implements Parser { $color = new \PHPNative\Tailwind\Style\Color(); + // Rounded per side: rounded-t-sm / rounded-b-lg / rounded-l-md / rounded-r-xl preg_match_all('/rounded-(t|b|l|r)-(none|sm|md|lg|xl|2xl|3xl)/', $style, $output_array); if (count($output_array[0]) > 0) { $size = match ((string) $output_array[2][0]) { @@ -41,6 +42,7 @@ class Border implements Parser }; } + // Rounded all corners: rounded-sm / rounded-lg / ... preg_match_all('/rounded-(none|sm|md|lg|xl|2xl|3xl)/', $style, $output_array); if (count($output_array[0]) > 0) { $size = match ((string) $output_array[1][0]) { @@ -61,7 +63,8 @@ class Border implements Parser ); } - preg_match_all('/rounded/', $style, $output_array); + // Generic rounded (no size) -> small radius + preg_match_all('/(? 0) { $size = 4; @@ -73,24 +76,41 @@ class Border implements Parser ); } - preg_match_all('/border-([tblr])-(.*)/', $style, $output_array); - if (count($output_array[0]) > 0) { - return match ((string) $output_array[1][0]) { - 't' => new \PHPNative\Tailwind\Style\Border(true, top: (int) $output_array[2][0]), - 'b' => new \PHPNative\Tailwind\Style\Border(true, bottom: (int) $output_array[2][0]), - 'r' => new \PHPNative\Tailwind\Style\Border(true, right: (int) $output_array[2][0]), - 'l' => new \PHPNative\Tailwind\Style\Border(true, left: (int) $output_array[2][0]), + // Directional borders with explicit numeric width, e.g. border-t-2 + if (preg_match('/^border-(t|b|l|r)-(\d+)$/', $style, $m)) { + $w = (int) $m[2]; + return match ($m[1]) { + 't' => new \PHPNative\Tailwind\Style\Border(true, top: $w), + 'b' => new \PHPNative\Tailwind\Style\Border(true, bottom: $w), + 'r' => new \PHPNative\Tailwind\Style\Border(true, right: $w), + 'l' => new \PHPNative\Tailwind\Style\Border(true, left: $w), }; } - preg_match_all('/border-(.*)/', $style, $output_array); - if (count($output_array[0]) > 0) { - $colorStyle = $output_array[1][0]; - $color = Color::parse($colorStyle); - return new \PHPNative\Tailwind\Style\Border(false, $color); + // Directional borders without width -> default 1px, e.g. border-r + if (preg_match('/^border-(t|b|l|r)$/', $style, $m)) { + $w = 1; + return match ($m[1]) { + 't' => new \PHPNative\Tailwind\Style\Border(true, top: $w), + 'b' => new \PHPNative\Tailwind\Style\Border(true, bottom: $w), + 'r' => new \PHPNative\Tailwind\Style\Border(true, right: $w), + 'l' => new \PHPNative\Tailwind\Style\Border(true, left: $w), + }; } - preg_match_all('/border/', $style, $output_array); + // Color-only border: border-red-500 / border-lime-300 + if (preg_match('/^border-(.+)$/', $style, $output_array)) { + $colorStyle = $output_array[1]; + $color = Color::parse($colorStyle); + // Nur Farbe setzen, keine Breiten – Breiten kommen von "border" oder "border-r" etc. + return new \PHPNative\Tailwind\Style\Border( + enabled: false, + color: $color, + ); + } + + // Plain "border" -> 1px Rahmen rundherum, Standardfarbe + preg_match_all('/(? 0) { return new \PHPNative\Tailwind\Style\Border( enabled: true, @@ -111,41 +131,41 @@ class Border implements Parser if ($style2->enabled && !$style1->enabled) { $style1->enabled = true; } - if ($style2->color->red != null) { + + // Farbe nur übernehmen, wenn im zweiten Style wirklich gesetzt ist + if ($style2->color->isNotSet()) { $style1->color->red = $style2->color->red; - } - if ($style2->color->green != null) { $style1->color->green = $style2->color->green; - } - if ($style2->color->blue != null) { $style1->color->blue = $style2->color->blue; } - if ($style2->color->alpha != null) { + if ($style2->color->alpha !== null) { $style1->color->alpha = $style2->color->alpha; } - if ($style2->top != null) { + + if ($style2->top !== null) { $style1->top = $style2->top; } - if ($style2->bottom != null) { + if ($style2->bottom !== null) { $style1->bottom = $style2->bottom; } - if ($style2->left != null) { + if ($style2->left !== null) { $style1->left = $style2->left; } - if ($style2->right != null) { + if ($style2->right !== null) { $style1->right = $style2->right; } - if ($style2->roundTopLeft != null) { + if ($style2->roundTopLeft !== null) { $style1->roundTopLeft = $style2->roundTopLeft; } - if ($style2->roundTopRight != null) { + if ($style2->roundTopRight !== null) { $style1->roundTopRight = $style2->roundTopRight; } - if ($style2->roundBottomLeft != null) { + if ($style2->roundBottomLeft !== null) { $style1->roundBottomLeft = $style2->roundBottomLeft; } - if ($style2->roundBottomRight != null) { + if ($style2->roundBottomRight !== null) { $style1->roundBottomRight = $style2->roundBottomRight; } } } + diff --git a/src/Ui/Component.php b/src/Ui/Component.php index a4f2b8c..190b082 100644 --- a/src/Ui/Component.php +++ b/src/Ui/Component.php @@ -478,6 +478,75 @@ abstract class Component ]); } } + + // Optional border stroke (separate von der Hintergrundfüllung) + if (isset($this->computedStyles[\PHPNative\Tailwind\Style\Border::class])) { + /** @var \PHPNative\Tailwind\Style\Border $border */ + $border = $this->computedStyles[\PHPNative\Tailwind\Style\Border::class]; + + $top = (int) ($border->top ?? 0); + $bottom = (int) ($border->bottom ?? 0); + $left = (int) ($border->left ?? 0); + $right = (int) ($border->right ?? 0); + + $hasStroke = $border->enabled || $top > 0 || $bottom > 0 || $left > 0 || $right > 0; + + if ($hasStroke) { + $color = $border->color; + // Fallback-Farbe, wenn keine explizite gesetzt ist + $red = ($color->red >= 0) ? $color->red : 160; + $green = ($color->green >= 0) ? $color->green : 160; + $blue = ($color->blue >= 0) ? $color->blue : 160; + $alpha = $color->alpha; + + sdl_set_render_draw_color($renderer, $red, $green, $blue, $alpha); + + $x = (int) $this->viewport->x; + $y = (int) $this->viewport->y; + $w = (int) $this->viewport->width; + $h = (int) $this->viewport->height; + + // Obere Kante + if ($top > 0) { + sdl_render_fill_rect($renderer, [ + 'x' => $x, + 'y' => $y, + 'w' => $w, + 'h' => $top, + ]); + } + + // Untere Kante + if ($bottom > 0) { + sdl_render_fill_rect($renderer, [ + 'x' => $x, + 'y' => $y + $h - $bottom, + 'w' => $w, + 'h' => $bottom, + ]); + } + + // Linke Kante + if ($left > 0) { + sdl_render_fill_rect($renderer, [ + 'x' => $x, + 'y' => $y, + 'w' => $left, + 'h' => $h, + ]); + } + + // Rechte Kante + if ($right > 0) { + sdl_render_fill_rect($renderer, [ + 'x' => $x + $w - $right, + 'y' => $y, + 'w' => $right, + 'h' => $h, + ]); + } + } + } if (defined('DEBUG_RENDERING') && DEBUG_RENDERING) { sdl_set_render_draw_color($renderer, rand(0, 255), rand(0, 255), rand(0, 255), 10); sdl_render_rect($renderer, [ diff --git a/src/Ui/Widget/FileBrowser.php b/src/Ui/Widget/FileBrowser.php index 7f5297b..7d5d060 100644 --- a/src/Ui/Widget/FileBrowser.php +++ b/src/Ui/Widget/FileBrowser.php @@ -285,13 +285,21 @@ class FileBrowser extends Container public function renderActionsCell(array $rowData, int $rowIndex): Container { // Match the cell style from Table (100px width for icon buttons) - $container = new Container('w-25 border-r border-gray-300 flex flex-row items-center justify-center gap-1'); + $container = new Container('w-25 flex flex-row items-center justify-center gap-1'); - // Only show action buttons for files (not directories) - if (!($rowData['isDir'] ?? false) && !empty($rowData['path'])) { - $fileBrowser = $this; + $isDir = (bool) ($rowData['isDir'] ?? false); + $hasPath = !empty($rowData['path']); + $isParentEntry = ($rowData['name'] ?? '') === '..'; - // Edit button + // Skip action buttons for invalid rows or parent navigation + if (!$hasPath || $isParentEntry) { + return $container; + } + + $fileBrowser = $this; + + // Edit button only for files + if (!$isDir) { $editButton = new Button('', 'text-blue-500 hover:text-blue-600 flex items-center justify-center'); $editIcon = new Icon(\PHPNative\Tailwind\Data\Icon::edit, 16, 'text-blue-500'); $editButton->setIcon($editIcon); @@ -301,19 +309,21 @@ class FileBrowser extends Container } }); $container->addComponent($editButton); + } - // Rename button - $renameButton = new Button('', 'text-amber-500 hover:text-amber-600 flex items-center justify-center'); - $renameIcon = new Icon(\PHPNative\Tailwind\Data\Icon::pen, 16, 'text-amber-500'); - $renameButton->setIcon($renameIcon); - $renameButton->setOnClick(function () use ($fileBrowser, $rowData) { - if ($fileBrowser->onRenameFile !== null) { - ($fileBrowser->onRenameFile)($rowData['path'], $rowData); - } - }); - $container->addComponent($renameButton); + // Rename button for files and directories + $renameButton = new Button('', 'text-amber-500 hover:text-amber-600 flex items-center justify-center'); + $renameIcon = new Icon(\PHPNative\Tailwind\Data\Icon::pen, 16, 'text-amber-500'); + $renameButton->setIcon($renameIcon); + $renameButton->setOnClick(function () use ($fileBrowser, $rowData) { + if ($fileBrowser->onRenameFile !== null) { + ($fileBrowser->onRenameFile)($rowData['path'], $rowData); + } + }); + $container->addComponent($renameButton); - // Delete button + // Delete button only for files + if (!$isDir) { $deleteButton = new Button('', 'text-red-500 hover:text-red-600 flex items-center justify-center'); $deleteIcon = new Icon(\PHPNative\Tailwind\Data\Icon::trash, 16, 'text-red-500'); $deleteButton->setIcon($deleteIcon); diff --git a/src/Ui/Widget/Table.php b/src/Ui/Widget/Table.php index 52db974..22bb925 100644 --- a/src/Ui/Widget/Table.php +++ b/src/Ui/Widget/Table.php @@ -55,7 +55,7 @@ class Table extends Container $title = $column['title'] ?? $key; $width = $column['width'] ?? null; - $style = 'px-4 py-2 text-black font-bold border-r border-gray-300 hover:bg-gray-300 cursor-pointer'; + $style = 'w-full px-4 py-2 text-black font-bold border-r border-gray-300 hover:bg-gray-300 cursor-pointer'; if ($width) { $style .= ' w-' . ((int) ($width / 4)); } else { @@ -126,7 +126,7 @@ class Table extends Container $value = $rowData[$key] ?? ''; $width = $column['width'] ?? null; - $cellStyle = 'px-4 py-2 text-black border-r border-gray-300'; + $cellStyle = 'w-full px-4 py-2 text-black border-r border-gray-300'; if ($width) { $cellStyle .= ' w-' . ((int) ($width / 4)); } else { diff --git a/src/Ui/Widget/VirtualListView.php b/src/Ui/Widget/VirtualListView.php index 58d57b2..86d8b57 100644 --- a/src/Ui/Widget/VirtualListView.php +++ b/src/Ui/Widget/VirtualListView.php @@ -13,7 +13,7 @@ class VirtualListView extends Container private float $scrollY = 0.0; private int $firstVisibleRow = 0; private int $lastVisibleRow = -1; - private ?int $selectedRowIndex = null; + private null|int $selectedRowIndex = null; private $onRowSelect = null; private const SCROLLBAR_WIDTH = 16; @@ -46,7 +46,7 @@ class VirtualListView extends Container $this->onRowSelect = $callback; } - public function getSelectedRow(): ?array + public function getSelectedRow(): null|array { if ($this->selectedRowIndex === null) { return null; @@ -101,7 +101,7 @@ class VirtualListView extends Container return $height; } - private function updateVisibleRows(?TextRenderer $textRenderer): void + private function updateVisibleRows(null|TextRenderer $textRenderer): void { $this->clearChildren(); @@ -116,8 +116,8 @@ class VirtualListView extends Container $rowCount = count($this->rows); $first = (int) floor($this->scrollY / $this->rowHeight); - $visibleCount = (int) ceil($viewportHeight / $this->rowHeight) + self::VISIBLE_BUFFER; - $last = min($rowCount - 1, $first + $visibleCount - 1); + $visibleCount = ((int) ceil($viewportHeight / $this->rowHeight)) + self::VISIBLE_BUFFER; + $last = min($rowCount - 1, ($first + $visibleCount) - 1); $this->firstVisibleRow = $first; $this->lastVisibleRow = $last; @@ -126,7 +126,7 @@ class VirtualListView extends Container $rowData = $this->rows[$i]; $rowContainer = $this->buildRowContainer($rowData, $i); - $rowY = $this->contentViewport->y + ($i * $this->rowHeight) - $this->scrollY; + $rowY = ($this->contentViewport->y + ($i * $this->rowHeight)) - $this->scrollY; $rowViewport = new Viewport( x: $this->contentViewport->x, @@ -161,7 +161,7 @@ class VirtualListView extends Container $value = $rowData[$key] ?? ''; $width = $column['width'] ?? null; - $cellStyle = 'px-4 py-2 text-black border-r border-gray-300'; + $cellStyle = 'w-full px-4 py-2 text-black border-r border-gray-300'; if ($width) { $cellStyle .= ' w-' . ((int) ($width / 4)); } else { @@ -202,9 +202,9 @@ class VirtualListView extends Container if ( $mouseX < $this->contentViewport->x || - $mouseX > ($this->contentViewport->x + $this->contentViewport->width) || - $mouseY < $this->contentViewport->y || - $mouseY > ($this->contentViewport->y + $this->contentViewport->height) + $mouseX > ($this->contentViewport->x + $this->contentViewport->width) || + $mouseY < $this->contentViewport->y || + $mouseY > ($this->contentViewport->y + $this->contentViewport->height) ) { return parent::handleMouseWheel($mouseX, $mouseY, $deltaY); } @@ -267,10 +267,7 @@ class VirtualListView extends Container } $scrollbarHeight = $viewportHeight; - $thumbHeight = max( - self::SCROLLBAR_MIN_SIZE, - ($viewportHeight / $totalHeight) * $scrollbarHeight, - ); + $thumbHeight = max(self::SCROLLBAR_MIN_SIZE, ($viewportHeight / $totalHeight) * $scrollbarHeight); $maxScroll = $this->getMaxScroll(); if ($maxScroll <= 0.0) { @@ -337,9 +334,9 @@ class VirtualListRow extends Container if ( $mouseX >= $this->viewport->x && - $mouseX <= ($this->viewport->x + $this->viewport->width) && - $mouseY >= $this->viewport->y && - $mouseY <= ($this->viewport->y + $this->viewport->height) + $mouseX <= ($this->viewport->x + $this->viewport->width) && + $mouseY >= $this->viewport->y && + $mouseY <= ($this->viewport->y + $this->viewport->height) ) { $this->table->selectRow($this->rowIndex); return true; @@ -348,4 +345,3 @@ class VirtualListRow extends Container return false; } } -