addComponent($title); if (!empty($task['note'] ?? '')) { $this->addComponent(new Label($task['note'], 'text-xs text-gray-500')); } } public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool { if ($button !== 1) { return false; } $this->boardView->beginDrag($this->boardId, $this->task); return true; } } final class KanbanBoardView extends Container { private array $boards; private array $boardViews = []; private null|array $dragState = null; private null|string $hoverBoardId = null; public function __construct( private readonly string $storagePath, array $initialBoards, private readonly Label $statusLabel, ) { parent::__construct('flex flex-row gap-4 flex-1'); $this->boards = $initialBoards; $this->renderBoards(); } public function addBoard(string $title): void { $title = trim($title); if ($title === '') { $this->statusLabel->setText('Board-Namen eingeben, um es hinzuzufügen.'); return; } $this->boards[] = [ 'id' => uniqid('board_', true), 'title' => $title, 'tasks' => [], ]; $this->saveBoards(); $this->renderBoards(); $this->statusLabel->setText('Board hinzugefügt: ' . $title); } public function addTask(string $boardId, string $title): void { $title = trim($title); if ($title === '') { $this->statusLabel->setText('Bitte einen Aufgabentitel eingeben.'); return; } foreach ($this->boards as &$board) { if ($board['id'] === $boardId) { $board['tasks'][] = [ 'id' => uniqid('task_', true), 'title' => $title, 'note' => '', ]; $this->saveBoards(); $this->renderBoards(); $this->statusLabel->setText(sprintf('Aufgabe "%s" zu "%s" hinzugefügt.', $title, $board['title'])); return; } } $this->statusLabel->setText('Board nicht gefunden.'); } public function beginDrag(string $boardId, array $task): void { $this->dragState = [ 'fromBoardId' => $boardId, 'taskId' => $task['id'], 'taskData' => $task, ]; $this->statusLabel->setText('Verschiebe: ' . $task['title']); } public function handleMouseMove(float $mouseX, float $mouseY): void { parent::handleMouseMove($mouseX, $mouseY); if ($this->dragState === null) { return; } $target = $this->findBoardAt($mouseX, $mouseY); if ($target !== $this->hoverBoardId) { if ($this->hoverBoardId !== null) { $this->setBoardHighlight($this->hoverBoardId, false); } if ($target !== null) { $this->setBoardHighlight($target, true); } $this->hoverBoardId = $target; } } public function handleMouseRelease(float $mouseX, float $mouseY, int $button): void { parent::handleMouseRelease($mouseX, $mouseY, $button); if ($this->dragState === null || $button !== 1) { return; } $dropTarget = $this->findBoardAt($mouseX, $mouseY); $this->completeDrag($dropTarget); } private function renderBoards(): void { $this->clearChildren(); $this->boardViews = []; foreach ($this->boards as $board) { $style = 'flex flex-col w-[260] flex-none bg-white border border-black rounded-lg p-3 gap-2'; $column = new Container($style); $column->addComponent(new Label('Board: ' . $board['title'], 'text-lg font-bold text-black')); $taskTitles = array_map(static fn($t) => $t['title'], $board['tasks']); $tasksText = empty($taskTitles) ? '(keine Aufgaben)' : implode(', ', $taskTitles); $column->addComponent(new Label('Tasks: ' . $tasksText, 'text-xs text-black')); if (!empty($board['tasks'])) { $taskList = new Container('flex flex-col gap-2 mt-2'); foreach ($board['tasks'] as $task) { $card = new Container('bg-white border-2 border-gray-400 rounded px-2 py-1 shadow-md'); $card->addComponent(new Label($task['title'], 'text-sm text-black')); $taskList->addComponent($card); } $column->addComponent($taskList); } $this->addComponent($column); $this->boardViews[$board['id']] = [ 'column' => $column, 'defaultStyle' => $style, 'highlightStyle' => $style, ]; } } private function findBoardAt(float $mouseX, float $mouseY): null|string { foreach ($this->boardViews as $boardId => $info) { $viewport = $info['column']->getViewport(); if ( $mouseX >= $viewport->x && $mouseX <= ($viewport->x + $viewport->width) && $mouseY >= $viewport->y && $mouseY <= ($viewport->y + $viewport->height) ) { return $boardId; } } return null; } private function setBoardHighlight(string $boardId, bool $active): void { if (!isset($this->boardViews[$boardId])) { return; } $column = $this->boardViews[$boardId]['column']; $style = $active ? $this->boardViews[$boardId]['highlightStyle'] : $this->boardViews[$boardId]['defaultStyle']; $column->setStyle($style); } private function completeDrag(null|string $targetBoardId): void { $state = $this->dragState; $this->dragState = null; if ($this->hoverBoardId !== null) { $this->setBoardHighlight($this->hoverBoardId, false); $this->hoverBoardId = null; } if (!$state) { return; } if ($targetBoardId === null) { $this->statusLabel->setText('Ziehen abgebrochen.'); return; } if ($targetBoardId === $state['fromBoardId']) { $this->statusLabel->setText('Aufgabe blieb im gleichen Board.'); return; } $movedTask = null; foreach ($this->boards as &$board) { if ($board['id'] === $state['fromBoardId']) { foreach ($board['tasks'] as $index => $task) { if ($task['id'] === $state['taskId']) { $movedTask = $task; array_splice($board['tasks'], $index, 1); break 2; } } } } if ($movedTask === null) { $this->statusLabel->setText('Aufgabe konnte nicht verschoben werden.'); return; } foreach ($this->boards as &$board) { if ($board['id'] === $targetBoardId) { $board['tasks'][] = $movedTask; break; } } $this->saveBoards(); $this->renderBoards(); $this->statusLabel->setText(sprintf( '"%s" nach "%s" verschoben.', $movedTask['title'], $this->getBoardTitle($targetBoardId), )); } private function getBoardTitle(string $boardId): string { foreach ($this->boards as $board) { if ($board['id'] === $boardId) { return $board['title']; } } return $boardId; } private function saveBoards(): void { file_put_contents( $this->storagePath, json_encode($this->boards, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX, ); } } $storagePath = __DIR__ . '/kanban_data.json'; $boards = []; if (is_file($storagePath)) { $raw = file_get_contents($storagePath); $decoded = json_decode($raw ?: '[]', true); if (is_array($decoded)) { $boards = $decoded; } } if (empty($boards)) { $boards = [ [ 'id' => uniqid('board_', true), 'title' => 'Backlog', 'tasks' => [ ['id' => uniqid('task_', true), 'title' => 'Idee sammeln', 'note' => ''], ['id' => uniqid('task_', true), 'title' => 'Mockups skizzieren', 'note' => ''], ], ], [ 'id' => uniqid('board_', true), 'title' => 'In Arbeit', 'tasks' => [ ['id' => uniqid('task_', true), 'title' => 'API anbinden', 'note' => ''], ], ], [ 'id' => uniqid('board_', true), 'title' => 'Erledigt', 'tasks' => [ ['id' => uniqid('task_', true), 'title' => 'UI Grundlayout', 'note' => ''], ], ], ]; file_put_contents($storagePath, json_encode($boards, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX); } $app = new Application(); $window = new Window('Kanban Beispiel', 1200, 800); $root = new Container('flex flex-col h-full w-full bg-gray-100 gap-4 p-4'); $title = new Label('Kanban Board', 'text-3xl font-bold text-black'); $subTitle = new Label( 'Boards hinzufügen, Aufgaben erstellen und per Drag & Drop verschieben.', 'text-base text-gray-700', ); $root->addComponent($title); $root->addComponent($subTitle); $statusLabel = new Label('Bereit.', 'text-sm text-gray-600'); $kanbanView = new KanbanBoardView($storagePath, $boards, $statusLabel); $boardInput = new TextInput('Neues Board', 'flex-1 border border-gray-300 rounded px-3 py-2 bg-white text-black'); $boardAddButton = new Button('Board hinzufügen', 'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700'); $boardAddButton->setOnClick(function () use ($kanbanView, $boardInput, $statusLabel): void { $kanbanView->addBoard($boardInput->getValue()); $boardInput->setValue(''); }); $boardRow = new Container('flex flex-row gap-3 items-center'); $boardRow->addComponent($boardInput); $boardRow->addComponent($boardAddButton); $root->addComponent($boardRow); $root->addComponent($kanbanView); $root->addComponent($statusLabel); $window->setRoot($root); $app->addWindow($window); $app->run();