From b82fe91a3048c0989301a7f070e08da88c4268f7 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Thu, 12 Mar 2026 06:00:47 +0100 Subject: [PATCH] feat: Nag Responsive UI The Nag Responsive UI will supersede the SmartMobile UI. The current implementation achieves more than feature parity with SmartMobile UI. Route-based instead of URI parameters for rest-like experience. Largely server rendered, limited JSON/AJAX operation. --- .gitignore | 20 + .horde.yml | 3 +- composer.json | 4 +- config/routes.php | 96 ++++ doc/changelog.yml | 18 + js/responsive.js | 16 + lib/Application.php | 2 +- src/Responsive/ResponsiveController.php | 630 ++++++++++++++++++++++ src/Responsive/ResponsiveTemplateView.php | 71 +++ templates/responsive/add.html.php | 159 ++++++ templates/responsive/browse.html.php | 103 ++++ templates/responsive/task.html.php | 157 ++++++ themes/default/responsive.css | 553 +++++++++++++++++++ 13 files changed, 1828 insertions(+), 4 deletions(-) create mode 100644 js/responsive.js create mode 100644 src/Responsive/ResponsiveController.php create mode 100644 src/Responsive/ResponsiveTemplateView.php create mode 100644 templates/responsive/add.html.php create mode 100644 templates/responsive/browse.html.php create mode 100644 templates/responsive/task.html.php create mode 100644 themes/default/responsive.css diff --git a/.gitignore b/.gitignore index fdc56954..8263702a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,23 @@ run-tests.log /config/*.local.php /config/*-*.php .php-cs-fixer.cache + +# Added by horde-components QC --fix-qc-issues +# Build artifacts directory +/build/ +# Composer dependencies directory +/vendor/ +# PHPStorm IDE settings +/.idea/ +# VSCode IDE settings +/.vscode/ +# Claude Code CLI cache and state +/.claude/ +# Cline extension data +/.cline/ +# PHPUnit result cache +/.phpunit.result.cache +# PHPStan local configuration (if not committed) +/phpstan.neon +# PHPStan cache directory +/.phpstan.cache/ diff --git a/.horde.yml b/.horde.yml index bf2e1396..61208f1d 100644 --- a/.horde.yml +++ b/.horde.yml @@ -31,7 +31,7 @@ authors: active: false role: lead version: - release: 5.0.0-alpha11 + release: 5.0.0-alpha12 api: 5.0.0alpha1 state: release: alpha @@ -83,3 +83,4 @@ autoload: - lib/ psr-4: Horde\Nag\: /src +vendor: horde diff --git a/composer.json b/composer.json index 5d0fbf6e..3d41c7fa 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "role": "lead" } ], - "time": "2025-06-04", + "time": "2026-03-07", "repositories": [], "require": { "horde/horde-installer-plugin": "dev-FRAMEWORK_6_0 || ^3 || ^2", @@ -67,7 +67,7 @@ "lib/" ], "psr-4": { - "Horde\\Nag\\": "/src" + "Horde\\Nag\\": "src/" } }, "autoload-dev": { diff --git a/config/routes.php b/config/routes.php index 23074083..a39b943a 100644 --- a/config/routes.php +++ b/config/routes.php @@ -2,6 +2,10 @@ /** * Setup default routes */ +namespace Horde\Nag; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; + $mapper->connect('/t/complete', array( 'controller' => 'CompleteTask', @@ -11,3 +15,95 @@ array( 'controller' => 'SaveTask', )); + +// Responsive routes +$mapper->connect( + 'ResponsiveTasks', + 'responsive', + [ + 'controller' => Responsive\ResponsiveController::class, + 'HordeAuthType' => 'authenticate', + 'stack' => [], + ] +); + +// Responsive filter routes +$mapper->connect( + 'ResponsiveTasksAll', + 'responsive/all', + [ + 'controller' => Responsive\ResponsiveController::class, + 'HordeAuthType' => 'authenticate', + 'stack' => [], + ] +); + +$mapper->connect( + 'ResponsiveTasksIncomplete', + 'responsive/incomplete', + [ + 'controller' => Responsive\ResponsiveController::class, + 'HordeAuthType' => 'authenticate', + 'stack' => [], + ] +); + +$mapper->connect( + 'ResponsiveTasksComplete', + 'responsive/complete', + [ + 'controller' => Responsive\ResponsiveController::class, + 'HordeAuthType' => 'authenticate', + 'stack' => [], + ] +); + +$mapper->connect( + 'ResponsiveTasksFuture', + 'responsive/future', + [ + 'controller' => Responsive\ResponsiveController::class, + 'HordeAuthType' => 'authenticate', + 'stack' => [], + ] +); + +$mapper->connect( + 'ResponsiveTasksFutureIncomplete', + 'responsive/future-incomplete', + [ + 'controller' => Responsive\ResponsiveController::class, + 'HordeAuthType' => 'authenticate', + 'stack' => [], + ] +); + +$mapper->connect( + 'ResponsiveTaskAdd', + 'responsive/add', + [ + 'controller' => Responsive\ResponsiveController::class, + 'HordeAuthType' => 'authenticate', + 'stack' => [], + ] +); + +$mapper->connect( + 'ResponsiveTask', + 'responsive/task/:tasklist/:id', + [ + 'controller' => Responsive\ResponsiveController::class, + 'HordeAuthType' => 'authenticate', + 'stack' => [], + ] +); + +$mapper->connect( + 'ResponsiveTaskEdit', + 'responsive/edit/:tasklist/:id', + [ + 'controller' => Responsive\ResponsiveController::class, + 'HordeAuthType' => 'authenticate', + 'stack' => [], + ] +); diff --git a/doc/changelog.yml b/doc/changelog.yml index c9983987..8eafae59 100644 --- a/doc/changelog.yml +++ b/doc/changelog.yml @@ -1,4 +1,22 @@ --- +5.0.0-alpha12: + api: 5.0.0-alpha12 + state: + release: alpha + api: alpha + date: 2026-03-07 + license: + identifier: GPL-2.0-only + uri: https://spdx.org/licenses/GPL-2.0-only.html + notes: |- + fix(forms): declare $_completedVar property to fix PHP 8.2+ deprecation + fix(forms): handle null values in strcasecmp calls + Merge pull request #5 from amulet1/fix_hook_class + Revert "fix: Add conditional to hook class template to prevent double declaration issues" + Merge pull request #4 from amulet1/autoload_renderer + fix: Modify VarRenderer class name to facilitate autoloading + fix: Correctly set message in isValid() calls, simplify getInfo() calls + fix: Drop unnecessary / uninitialized form parameter 5.0.0-alpha11: api: 5.0.0-alpha1 state: diff --git a/js/responsive.js b/js/responsive.js new file mode 100644 index 00000000..a5e00595 --- /dev/null +++ b/js/responsive.js @@ -0,0 +1,16 @@ +/** + * Nag Responsive JavaScript + * Client-side enhancements for responsive mode + * + * Copyright 2026 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (GPL). If you + * did not receive this file, see http://www.horde.org/licenses/gpl. + */ + +document.addEventListener('DOMContentLoaded', function() { + // Add any client-side enhancements here + // For Phase 1, we'll keep it minimal + + console.log('Nag responsive mode initialized'); +}); diff --git a/lib/Application.php b/lib/Application.php index 31cc9f15..dbcbb7eb 100644 --- a/lib/Application.php +++ b/lib/Application.php @@ -45,7 +45,7 @@ class Nag_Application extends Horde_Registry_Application /** */ - public $version = '5.0.0-alpha11'; + public $version = '5.0.0-alpha12'; /** * Global variables defined: diff --git a/src/Responsive/ResponsiveController.php b/src/Responsive/ResponsiveController.php new file mode 100644 index 00000000..e7314d62 --- /dev/null +++ b/src/Responsive/ResponsiveController.php @@ -0,0 +1,630 @@ + + * @category Horde + * @license http://www.horde.org/licenses/gpl GPL + * @package Nag + */ + +declare(strict_types=1); + +namespace Horde\Nag\Responsive; + +use Horde\Core\Controller\ResponsiveControllerTrait; +use Horde_Registry; +use Horde_Variables; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\UriFactoryInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Nag; +use Nag_Driver; + +/** + * Nag Responsive Controller + * + * @category Horde + * @license http://www.horde.org/licenses/gpl GPL + * @package Nag + */ +class ResponsiveController implements RequestHandlerInterface +{ + use ResponsiveControllerTrait; + + public function __construct( + private Horde_Registry $registry, + private UriFactoryInterface $uriFactory, + private ResponseFactoryInterface $responseFactory, + private StreamFactoryInterface $streamFactory + ) { + } + + /** + * Get application name for topbar display + */ + protected function getAppName(): string + { + return _("Tasks"); + } + + /** + * Get template base path for this application + */ + protected function getTemplateBasePath(): string + { + return NAG_TEMPLATES . '/responsive/'; + } + + /** + * Get Horde registry instance + */ + protected function getRegistry(): Horde_Registry + { + return $this->registry; + } + + /** + * Get PSR-7 URI factory instance + */ + protected function getUriFactory(): UriFactoryInterface + { + return $this->uriFactory; + } + + /** + * Get PSR-7 response factory instance + */ + protected function getResponseFactory(): ResponseFactoryInterface + { + return $this->responseFactory; + } + + /** + * Get PSR-7 stream factory instance + */ + protected function getStreamFactory(): StreamFactoryInterface + { + return $this->streamFactory; + } + + /** + * Build application URL using PSR-7 UriFactory (inherited from trait) + * + * Note: This method is provided by ResponsiveControllerTrait. + * Uses dependency-injected UriFactory for proper URL construction. + */ + + /** + * Handle the request and return a response. + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + global $notification; + + // Get route parameters from request attributes + $route = $request->getAttribute('route', []); + $path = $request->getUri()->getPath(); + + // Check for POST actions (create, update, delete, complete) + if ($request->getMethod() === 'POST') { + $params = $request->getQueryParams(); + $action = $params['action'] ?? ''; + + switch ($action) { + case 'create': + return $this->create($request); + case 'update': + return $this->update($request); + case 'delete': + return $this->delete($request); + case 'complete': + return $this->complete($request); + } + } + + // Route based on path + if (isset($route['tasklist']) && isset($route['id'])) { + // Check if this is an edit request + if (str_contains($path, '/responsive/edit/')) { + return $this->edit($request, $route['tasklist'], $route['id']); + } + // Task detail view + return $this->task($request, $route['tasklist'], $route['id']); + } + + // Add task form + if (str_contains($path, '/responsive/add')) { + return $this->add($request); + } + + // Filter routes + if (str_contains($path, '/responsive/incomplete')) { + return $this->browse($request, Nag::VIEW_INCOMPLETE); + } + if (str_contains($path, '/responsive/complete')) { + return $this->browse($request, Nag::VIEW_COMPLETE); + } + if (str_contains($path, '/responsive/future-incomplete')) { + return $this->browse($request, Nag::VIEW_FUTURE_INCOMPLETE); + } + if (str_contains($path, '/responsive/future')) { + return $this->browse($request, Nag::VIEW_FUTURE); + } + if (str_contains($path, '/responsive/all')) { + return $this->browse($request, Nag::VIEW_ALL); + } + + // Default: browse view (all tasks) + return $this->browse($request, Nag::VIEW_ALL); + } + + /** + * Browse view - List all tasks grouped by task list + * + * @param ServerRequestInterface $request The request + * @param int $filter Filter constant (Nag::VIEW_*) + */ + protected function browse(ServerRequestInterface $request, int $filter = Nag::VIEW_ALL): ResponseInterface + { + global $registry, $prefs, $injector; + + // Get task lists to display + $taskLists = Nag::listTasklists(); + $tasklistIds = array_keys($taskLists); + + // Fetch all tasks from visible task lists + $options = [ + 'include_history' => false, + 'tasklists' => $tasklistIds, // Explicitly pass all visible tasklists + ]; + + // Apply filter + switch ($filter) { + case Nag::VIEW_INCOMPLETE: + $options['completed'] = Nag::VIEW_INCOMPLETE; + break; + case Nag::VIEW_COMPLETE: + $options['completed'] = Nag::VIEW_COMPLETE; + break; + case Nag::VIEW_FUTURE: + $options['completed'] = Nag::VIEW_FUTURE; + break; + case Nag::VIEW_FUTURE_INCOMPLETE: + $options['completed'] = Nag::VIEW_FUTURE_INCOMPLETE; + break; + case Nag::VIEW_ALL: + $options['completed'] = Nag::VIEW_ALL; + break; + default: + // Default to VIEW_ALL + $options['completed'] = Nag::VIEW_ALL; + break; + } + + $tasks = Nag::listTasks($options); + + // Group tasks by task list + $groupedTasks = []; + // $taskLists already fetched above for filtering + + // Initialize groups + foreach ($taskLists as $id => $taskList) { + $groupedTasks[$id] = [ + 'name' => $taskList->get('name'), + 'count' => 0, + 'tasks' => [], + ]; + } + + // Group tasks + $tasks->reset(); + while ($task = $tasks->each()) { + $tasklistId = $task->tasklist; + if (!isset($groupedTasks[$tasklistId])) { + continue; + } + + $groupedTasks[$tasklistId]['tasks'][] = $this->buildTaskData($task); + $groupedTasks[$tasklistId]['count']++; + } + + // Keep all task lists, including empty ones (removed filter) + // Users should see all their task lists even if empty + + // Prepare view data + $viewData = [ + 'groupedTasks' => $groupedTasks, + 'filter' => $filter, + 'taskLists' => $taskLists, + 'totalTasks' => $tasks->count(), + ]; + + return $this->renderTemplate('browse.html.php', $viewData); + } + + /** + * Task detail view + */ + protected function task(ServerRequestInterface $request, string $tasklistId, string $taskId): ResponseInterface + { + global $registry, $nag_shares; + + try { + $task = Nag::getTask($tasklistId, $taskId); + } catch (\Nag_Exception $e) { + return $this->redirectToBrowse('Task not found: ' . $e->getMessage()); + } + + // Check permissions + try { + $share = $nag_shares->getShare($tasklistId); + } catch (\Horde_Share_Exception $e) { + return $this->redirectToBrowse('Access denied: ' . $e->getMessage()); + } + + $canEdit = $share->hasPermission($registry->getAuth(), \Horde_Perms::EDIT); + $canDelete = $share->hasPermission($registry->getAuth(), \Horde_Perms::DELETE); + + $viewData = [ + 'task' => $this->buildTaskData($task, true), + 'tasklistName' => $share->get('name'), + 'canEdit' => $canEdit, + 'canDelete' => $canDelete, + ]; + + return $this->renderTemplate('task.html.php', $viewData); + } + + /** + * Add task form + */ + protected function add(ServerRequestInterface $request): ResponseInterface + { + global $registry; + + // Get available task lists + $taskLists = Nag::listTasklists(false, \Horde_Perms::EDIT); + + // Get default task list + $defaultTasklist = Nag::getDefaultTasklist(\Horde_Perms::EDIT); + + $viewData = [ + 'taskLists' => $taskLists, + 'defaultTasklist' => $defaultTasklist, + 'priorities' => $this->getPriorities(), + ]; + + return $this->renderTemplate('add.html.php', $viewData); + } + + /** + * Create new task (POST handler) + */ + protected function create(ServerRequestInterface $request): ResponseInterface + { + global $registry, $injector, $notification; + + $postData = $request->getParsedBody(); + + // Validate required fields + if (empty($postData['name'])) { + $notification->push(_("Task name is required"), 'horde.error'); + return $this->redirectToAdd(); + } + + $tasklistId = $postData['tasklist'] ?? Nag::getDefaultTasklist(\Horde_Perms::EDIT); + + // Build task array + $task = [ + 'name' => $postData['name'], + 'desc' => $postData['description'] ?? '', + 'priority' => (int)($postData['priority'] ?? 3), + 'owner' => $registry->getAuth(), + 'private' => !empty($postData['private']), + ]; + + // Handle due date + if (!empty($postData['due'])) { + try { + $date = new \Horde_Date($postData['due']); + $task['due'] = $date->timestamp(); + } catch (\Exception $e) { + // Invalid date, skip it + } + } + + // Handle start date + if (!empty($postData['start'])) { + try { + $date = new \Horde_Date($postData['start']); + $task['start'] = $date->timestamp(); + } catch (\Exception $e) { + // Invalid date, skip it + } + } + + // Handle estimate + if (!empty($postData['estimate'])) { + $task['estimate'] = (float)$postData['estimate']; + } + + // Handle assignee + if (!empty($postData['assignee'])) { + $task['assignee'] = trim($postData['assignee']); + } + + try { + $storage = $injector->getInstance('Nag_Factory_Driver')->create($tasklistId); + $taskIds = $storage->add($task); + + $notification->push(sprintf(_("Created task: %s"), $task['name']), 'horde.success'); + + // Redirect to task detail + return $this->redirectToTask($taskIds[0], $tasklistId); + } catch (\Nag_Exception $e) { + $notification->push(sprintf(_("Error creating task: %s"), $e->getMessage()), 'horde.error'); + return $this->redirectToAdd(); + } + } + + /** + * Edit task form + */ + protected function edit(ServerRequestInterface $request, string $tasklistId, string $taskId): ResponseInterface + { + global $registry, $nag_shares; + + try { + $task = Nag::getTask($tasklistId, $taskId); + $share = $nag_shares->getShare($tasklistId); + } catch (\Exception $e) { + return $this->redirectToBrowse('Error: ' . $e->getMessage()); + } + + // Check edit permission + if (!$share->hasPermission($registry->getAuth(), \Horde_Perms::EDIT)) { + return $this->redirectToBrowse('Access denied'); + } + + // Get available task lists (user might move task) + $taskLists = Nag::listTasklists(false, \Horde_Perms::EDIT); + + $viewData = [ + 'task' => $this->buildTaskData($task, true), + 'taskLists' => $taskLists, + 'priorities' => $this->getPriorities(), + 'isEdit' => true, + ]; + + return $this->renderTemplate('add.html.php', $viewData); + } + + /** + * Update task (POST handler) + */ + protected function update(ServerRequestInterface $request): ResponseInterface + { + global $registry, $injector, $notification, $nag_shares; + + $postData = $request->getParsedBody(); + + $taskId = $postData['task_id'] ?? null; + $tasklistId = $postData['original_tasklist'] ?? null; + + if (!$taskId || !$tasklistId) { + $notification->push(_("Missing task ID or tasklist"), 'horde.error'); + return $this->redirectToBrowse(); + } + + try { + $storage = $injector->getInstance('Nag_Factory_Driver')->create($tasklistId); + $task = $storage->get($taskId); + + // Check permission + $share = $nag_shares->getShare($tasklistId); + if (!$share->hasPermission($registry->getAuth(), \Horde_Perms::EDIT)) { + throw new \Nag_Exception(_("Access denied")); + } + + // Build update array + $updates = [ + 'name' => $postData['name'] ?? $task->name, + 'desc' => $postData['description'] ?? '', + 'priority' => (int)($postData['priority'] ?? 3), + 'private' => !empty($postData['private']), + 'assignee' => trim($postData['assignee'] ?? ''), + ]; + + // Handle dates + if (!empty($postData['due'])) { + $date = new \Horde_Date($postData['due']); + $updates['due'] = $date->timestamp(); + } + if (!empty($postData['start'])) { + $date = new \Horde_Date($postData['start']); + $updates['start'] = $date->timestamp(); + } + if (!empty($postData['estimate'])) { + $updates['estimate'] = (float)$postData['estimate']; + } + + // Merge and save + $task->merge($updates); + $task->save(); + + $notification->push(sprintf(_("Updated task: %s"), $task->name), 'horde.success'); + return $this->redirectToTask($taskId, $tasklistId); + } catch (\Exception $e) { + $notification->push(sprintf(_("Error updating task: %s"), $e->getMessage()), 'horde.error'); + return $this->redirectToBrowse(); + } + } + + /** + * Delete task + */ + protected function delete(ServerRequestInterface $request): ResponseInterface + { + global $registry, $injector, $notification, $nag_shares; + + $params = $request->getQueryParams(); + $taskId = $params['id'] ?? null; + $tasklistId = $params['tasklist'] ?? null; + + if (!$taskId || !$tasklistId) { + $notification->push(_("Missing task ID or tasklist"), 'horde.error'); + return $this->redirectToBrowse(); + } + + try { + $storage = $injector->getInstance('Nag_Factory_Driver')->create($tasklistId); + $task = $storage->get($taskId); + + // Check permission + $share = $nag_shares->getShare($tasklistId); + if (!$share->hasPermission($registry->getAuth(), \Horde_Perms::DELETE)) { + throw new \Nag_Exception(_("Access denied")); + } + + $taskName = $task->name; + $storage->delete($taskId); + + $notification->push(sprintf(_("Deleted task: %s"), $taskName), 'horde.success'); + } catch (\Exception $e) { + $notification->push(sprintf(_("Error deleting task: %s"), $e->getMessage()), 'horde.error'); + } + + return $this->redirectToBrowse(); + } + + /** + * Toggle task completion + */ + protected function complete(ServerRequestInterface $request): ResponseInterface + { + global $notification; + + $params = $request->getQueryParams(); + $taskId = $params['id'] ?? null; + $tasklistId = $params['tasklist'] ?? null; + + if (!$taskId || !$tasklistId) { + $notification->push(_("Missing task ID or tasklist"), 'horde.error'); + return $this->redirectToBrowse(); + } + + try { + $completeTask = new \Nag_CompleteTask(); + $result = $completeTask->result($taskId, $tasklistId); + + if ($result['data'] === 'complete') { + $notification->push(_("Task marked as complete"), 'horde.success'); + } else { + $notification->push(_("Task marked as incomplete"), 'horde.success'); + } + } catch (\Exception $e) { + $notification->push(sprintf(_("Error: %s"), $e->getMessage()), 'horde.error'); + } + + return $this->redirectToBrowse(); + } + + /** + * Build task data array for templates + */ + protected function buildTaskData($task, bool $includeAll = false): array + { + $data = [ + 'id' => $task->id, + 'tasklist' => $task->tasklist, + 'name' => $task->name, + 'completed' => $task->completed, + 'priority' => $task->priority, + ]; + + // Due date + if ($task->due) { + $dueDate = new \Horde_Date($task->due); + $data['due'] = $dueDate->format('Y-m-d'); + $data['dueFormatted'] = $dueDate->format('M j, Y'); + + // Check if overdue + $now = new \Horde_Date(time()); + $data['overdue'] = !$task->completed && $dueDate->compareDate($now) < 0; + } + + if ($includeAll) { + $data['description'] = $task->desc ?? ''; + $data['private'] = $task->private ?? false; + + if ($task->start) { + $startDate = new \Horde_Date($task->start); + $data['start'] = $startDate->format('Y-m-d'); + $data['startFormatted'] = $startDate->format('M j, Y'); + } + + if ($task->estimate) { + $data['estimate'] = $task->estimate; + } + + $data['owner'] = $task->owner ?? ''; + $data['assignee'] = $task->assignee ?? ''; + $data['assigneeFormatted'] = $task->assignee ? Nag::formatAssignee($task->assignee, true) : ''; + $data['organizer'] = $task->organizer ?? ''; + $data['organizerFormatted'] = $task->organizer ? Nag::formatOrganizer($task->organizer, true) : ''; + } + + return $data; + } + + /** + * Get priority labels + */ + protected function getPriorities(): array + { + return [ + 1 => _("Highest"), + 2 => _("High"), + 3 => _("Normal"), + 4 => _("Low"), + 5 => _("Lowest"), + ]; + } + + /** + * Redirect helpers + */ + protected function redirectToBrowse(string $message = ''): ResponseInterface + { + return $this->redirectTo( + (string) \Horde::url('responsive', true), + $message + ); + } + + protected function redirectToTask(string $taskId, string $tasklistId): ResponseInterface + { + return $this->redirectTo( + (string) \Horde::url('responsive/task/' . $tasklistId . '/' . $taskId, true) + ); + } + + protected function redirectToAdd(): ResponseInterface + { + return $this->redirectTo( + (string) \Horde::url('responsive/add', true) + ); + } +} diff --git a/src/Responsive/ResponsiveTemplateView.php b/src/Responsive/ResponsiveTemplateView.php new file mode 100644 index 00000000..8d4d9baa --- /dev/null +++ b/src/Responsive/ResponsiveTemplateView.php @@ -0,0 +1,71 @@ +templatePath = $templatePath; + $this->data = $data; + } + + /** + * Render the template + */ + public function render(): string + { + // Extract data to variables + extract($this->data, EXTR_SKIP); + + // Helper function for escaping + $escape = function($value) { + return htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + }; + + // Start output buffering + ob_start(); + + // Include template + require $this->templatePath; + + // Return buffered content + return ob_get_clean(); + } +} diff --git a/templates/responsive/add.html.php b/templates/responsive/add.html.php new file mode 100644 index 00000000..bf45efb8 --- /dev/null +++ b/templates/responsive/add.html.php @@ -0,0 +1,159 @@ + + + + + + <?php echo isset($isEdit) ? _("Edit Task") : _("New Task") ?> - Horde + + + + + + + + + +
+ + + + + + + +
+

+ +
+ + + + + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + " + value=""> +
+ + +
+ +
+ + +
+ + + + +
+
+
+
+ + + + + + + diff --git a/templates/responsive/browse.html.php b/templates/responsive/browse.html.php new file mode 100644 index 00000000..cd95a86e --- /dev/null +++ b/templates/responsive/browse.html.php @@ -0,0 +1,103 @@ + + + + + + <?php echo _("Tasks") ?> - Horde + + + + + + + + + + + +
+ + + + +
+ +
+ + + +
+
+

+

+
+ +
+ $taskList): ?> +
+ + + + + + +
+ +
+ +
+ + + + + + + diff --git a/templates/responsive/task.html.php b/templates/responsive/task.html.php new file mode 100644 index 00000000..e0cc735c --- /dev/null +++ b/templates/responsive/task.html.php @@ -0,0 +1,157 @@ + + + + + + <?php echo $escape($task['name']) ?> - <?php echo _("Tasks") ?> + + + + + + + + + +
+ + + + + + + +
+ +

+ + + + +

+ + +
+
+ +
+ + + + + + + + + +
+ +
+ +
+ + +
+ +
+
+
+
+
+ + +
+
+
+ + + + +
+
+ + + +
+
+
+
+ + +
+
+
+ + _("Highest"), 2 => _("High"), 3 => _("Normal"), 4 => _("Low"), 5 => _("Lowest")]; + echo $escape($priorities[$task['priority']] ?? _("Normal")); + ?> + +
+
+ + +
+
+
+
+ + + +
+
+
🔒
+
+ + + +
+
+
+
+ + + +
+
+
👤
+
+ + + +
+
+
+
+ +
+
+ + + +
+ +
+
+ +
+
+
+ +
+
+ + + + + + + diff --git a/themes/default/responsive.css b/themes/default/responsive.css new file mode 100644 index 00000000..7596a7cb --- /dev/null +++ b/themes/default/responsive.css @@ -0,0 +1,553 @@ +/** + * Nag Responsive Styles + * Extends base responsive.css with Nag-specific components + * + * Copyright 2026 Horde LLC (http://www.horde.org/) + */ + +/* ============================================================================ + App Icon Override + ========================================================================= */ + +/* Override Horde base icon with Nag-specific icon */ +.topbar-menu-app-nag::before { + content: ''; + display: inline-block; + width: 20px; + height: 20px; + background-image: url('graphics/nag.png'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + vertical-align: middle; +} + +/* ============================================================================ + Layout + ========================================================================= */ + +.nag-container { + padding-top: var(--space-lg); + padding-bottom: var(--space-2xl); +} + +/* ============================================================================ + Page Header + ========================================================================= */ + +.page-header { + margin-bottom: var(--space-lg); +} + +.page-header h1 { + margin: 0 0 var(--space-md) 0; + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +/* Add task button */ +.add-task-btn { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-lg); + background-color: var(--horde-primary); + color: white; + text-decoration: none; + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + border-radius: var(--radius-md); + transition: all var(--transition-fast); +} + +.add-task-btn:hover { + background-color: #2563eb; + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.add-icon { + font-size: var(--font-size-xl); + line-height: 1; + font-weight: var(--font-weight-bold); +} + +/* Back link */ +.back-link { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + margin-bottom: var(--space-md); + padding: var(--space-xs) var(--space-sm); + color: var(--horde-primary); + text-decoration: none; + font-size: var(--font-size-sm); + border-radius: var(--radius-sm); + transition: background-color var(--transition-fast); +} + +.back-link:hover { + background-color: var(--bg-hover); +} + +.back-arrow { + font-size: var(--font-size-xl); + line-height: 1; + font-weight: var(--font-weight-bold); +} + +/* ============================================================================ + Filter Controls + ========================================================================= */ + +.filter-controls { + margin-bottom: var(--space-lg); +} + +.filter-select { + width: 100%; + max-width: 300px; + padding: var(--space-sm) var(--space-md); + font-size: var(--font-size-base); + border: 1px solid var(--border-medium); + border-radius: var(--radius-md); + background-color: var(--bg-card); + color: var(--text-primary); +} + +/* ============================================================================ + Task Lists + ========================================================================= */ + +#task-lists { + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.task-list { + background: var(--bg-card); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.tasklist-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-md); + background-color: var(--bg-accent); + cursor: pointer; + user-select: none; + list-style: none; +} + +.tasklist-header::-webkit-details-marker { + display: none; +} + +.tasklist-title { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.tasklist-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + padding: 0.25rem 0.5rem; + background-color: var(--horde-primary); + color: white; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border-radius: var(--radius-full); +} + +/* ============================================================================ + Task Items + ========================================================================= */ + +.task-items { + list-style: none; + padding: 0; + margin: 0; +} + +.task-item { + display: flex; + align-items: center; + border-bottom: 1px solid var(--border-light); + position: relative; +} + +.task-item:last-child { + border-bottom: none; +} + +.task-item.task-completed { + opacity: 0.6; +} + +.task-complete-btn { + flex-shrink: 0; + padding: var(--space-md); + font-size: 24px; + line-height: 1; + text-decoration: none; + color: var(--text-primary); + transition: background-color var(--transition-fast); + /* Button reset for POST form */ + background: none; + border: none; + cursor: pointer; + margin: 0; +} + +.task-complete-btn:hover { + background-color: var(--bg-hover); +} + +.complete-checkbox { + display: inline-block; +} + +.task-link { + flex: 1; + display: flex; + align-items: center; + padding: var(--space-md); + padding-left: 0; + text-decoration: none; + color: var(--text-primary); + transition: background-color var(--transition-fast); +} + +.task-link:hover { + background-color: var(--bg-hover); +} + +.task-info { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + +.task-name { + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-name.strikethrough { + text-decoration: line-through; +} + +.task-meta { + display: flex; + gap: var(--space-md); + font-size: var(--font-size-sm); + color: var(--text-secondary); +} + +.task-due { + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +.task-due.overdue { + color: #dc2626; + font-weight: var(--font-weight-medium); +} + +.task-priority { + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +.priority-1 { color: #dc2626; } +.priority-2 { color: #f59e0b; } + +.task-chevron { + flex-shrink: 0; + font-size: var(--font-size-lg); + color: var(--text-tertiary); + font-weight: var(--font-weight-bold); +} + +/* ============================================================================ + Task Detail + ========================================================================= */ + +.task-detail { + background: var(--bg-card); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + padding: var(--space-xl); +} + +.task-name-header { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + margin: 0 0 var(--space-lg) 0; +} + +.task-name-header.strikethrough { + text-decoration: line-through; +} + +.completion-badge { + display: inline-block; + margin-left: var(--space-sm); + padding: 0.25rem 0.75rem; + background-color: #10b981; + color: white; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-normal); + border-radius: var(--radius-full); + vertical-align: middle; +} + +/* Quick actions */ +.quick-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: var(--space-md); + margin-bottom: var(--space-xl); +} + +.quick-action-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-xs); + padding: var(--space-md); + background-color: var(--bg-accent); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + text-decoration: none; + color: var(--text-primary); + transition: all var(--transition-fast); + /* Button reset for POST forms */ + cursor: pointer; + font-family: inherit; + font-size: inherit; + width: 100%; +} + +.quick-action-btn:hover { + background-color: var(--bg-hover); + border-color: var(--horde-primary); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); +} + +.quick-action-icon { + font-size: 24px; + line-height: 1; +} + +.quick-action-label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + text-align: center; +} + +.quick-action-complete { + border-left: 3px solid #10b981; +} + +.quick-action-edit { + border-left: 3px solid #3b82f6; +} + +.quick-action-delete { + border-left: 3px solid #dc2626; +} + +/* Task sections */ +.task-section { + margin-bottom: var(--space-lg); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + overflow: hidden; +} + +.task-section:last-child { + margin-bottom: 0; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-md); + background-color: var(--bg-accent); + cursor: pointer; + user-select: none; + list-style: none; + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.section-header::-webkit-details-marker { + display: none; +} + +.section-content { + padding: var(--space-md); +} + +.task-field { + margin-bottom: var(--space-md); +} + +.task-field:last-child { + margin-bottom: 0; +} + +.field-label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-secondary); + margin-bottom: var(--space-xs); +} + +.field-value { + font-size: var(--font-size-base); + color: var(--text-primary); +} + +.text-error { + color: #dc2626; +} + +.overdue-badge { + display: inline-block; + margin-left: var(--space-sm); + padding: 0.125rem 0.5rem; + background-color: #dc2626; + color: white; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + border-radius: var(--radius-full); +} + +.priority-badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: var(--radius-full); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); +} + +.priority-badge.priority-1 { + background-color: #dc2626; + color: white; +} + +.priority-badge.priority-2 { + background-color: #f59e0b; + color: white; +} + +.priority-badge.priority-3 { + background-color: #6b7280; + color: white; +} + +.priority-badge.priority-4, +.priority-badge.priority-5 { + background-color: #d1d5db; + color: #374151; +} + +.task-description { + line-height: var(--line-height-relaxed); + white-space: pre-wrap; +} + +/* ============================================================================ + Task Form + ========================================================================= */ + +.task-form-container { + background: var(--bg-card); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + padding: var(--space-xl); +} + +.task-form { + max-width: 600px; +} + +.task-form-container h1 { + margin: 0 0 var(--space-lg) 0; + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +/* ============================================================================ + Empty State + ========================================================================= */ + +.empty-state { + text-align: center; + padding: var(--space-2xl) var(--space-md); + color: var(--text-secondary); +} + +.empty-state-icon { + font-size: 64px; + margin-bottom: var(--space-lg); + opacity: 0.5; +} + +.empty-state h2 { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-medium); + color: var(--text-primary); + margin: 0 0 var(--space-sm) 0; +} + +.empty-state p { + font-size: var(--font-size-base); + margin: 0; +} + +/* ============================================================================ + Responsive Adjustments + ========================================================================= */ + +@media (min-width: 768px) { + .task-detail { + max-width: 800px; + margin: 0 auto; + } + + .quick-actions { + max-width: 500px; + margin-left: auto; + margin-right: auto; + } +} + +@media (min-width: 1024px) { + .nag-container { + max-width: 1200px; + } +}