diff --git a/partials/landingpages/dashboard.php b/partials/landingpages/dashboard.php new file mode 100644 index 0000000..0a295a1 --- /dev/null +++ b/partials/landingpages/dashboard.php @@ -0,0 +1,261 @@ +available() || $ownerKey === '') { + echo '
Dashboard-System nicht verfügbar.
'; + return; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = trim((string) ($_POST['action'] ?? '')); + try { + if ($action === 'add_item') { + $dashboardId = (int) ($_POST['dashboard_id'] ?? 0); + $itemType = trim((string) ($_POST['item_type'] ?? 'link')); + $config = []; + if ($itemType === 'page_module') { + $config['page_module_id'] = (int) ($_POST['page_module_id'] ?? 0); + } else { + $config['url'] = trim((string) ($_POST['target_url'] ?? '')); + } + $service->createItem($dashboardId, $ownerKey, [ + 'item_type' => $itemType, + 'title' => trim((string) ($_POST['title'] ?? '')), + 'description' => trim((string) ($_POST['description'] ?? '')), + 'grid_column' => trim((string) ($_POST['grid_column'] ?? '')), + 'grid_row' => trim((string) ($_POST['grid_row'] ?? '')), + 'column_span' => (int) ($_POST['column_span'] ?? 1), + 'row_span' => (int) ($_POST['row_span'] ?? 1), + 'config' => $config, + ]); + $notice = 'Dashboard-Element hinzugefügt.'; + } elseif ($action === 'delete_item') { + $service->deleteItem((int) ($_POST['item_id'] ?? 0), (int) ($_POST['dashboard_id'] ?? 0), $ownerKey); + $notice = 'Dashboard-Element entfernt.'; + } + } catch (\Throwable $exception) { + $error = $exception->getMessage(); + } +} + +$accessibleDashboards = $service->listAccessibleDashboards($ownerKey, $groups); +$selectedDashboardId = (int) ($_GET['id'] ?? 0); +$currentDashboard = null; +foreach ($accessibleDashboards as $dashboard) { + if ((int) ($dashboard['id'] ?? 0) === $selectedDashboardId) { + $currentDashboard = $dashboard; + break; + } +} +if ($currentDashboard === null) { + $currentDashboard = $service->ensureDefaultDashboard($ownerKey, 'Mein Dashboard'); +} +$currentDashboardId = (int) ($currentDashboard['id'] ?? 0); +$dashboardItems = $service->listItems($currentDashboardId); +$ownPageModules = $service->listPageModulesForOwner($ownerKey); +$pageModuleMap = []; +foreach ($ownPageModules as $pageModule) { + $pageModuleMap[(int) ($pageModule['id'] ?? 0)] = $pageModule; +} + +$GLOBALS['layout_header_base_title'] = 'Nexus'; +$GLOBALS['layout_header_title'] = 'Nexus'; +$GLOBALS['layout_header_context'] = 'Dashboard'; +$GLOBALS['layout_header_text'] = 'Persönliche Arbeitsfläche mit frei platzierbaren Dashboard-Elementen.'; +?> +
+ + + +
+ +
+ + +
+

+

+
+ +
+
+ +
+

Element hinzufügen

+

V1 unterstützt direkte Links, iFrames und gespeicherte Seitenmodule.

+
+ + +
+ + +
+
+ + + +
+
+ + + +
+ +
+
+ + +
Noch keine Elemente vorhanden.
+ +
+ + +
+
+
+ +

+ +

+ +
+
+ + + + +
+
+ + + + +
+

+ Öffnen +
+ +
+

+ Öffnen +
+ +

Für dieses Element ist noch kein Inhalt hinterlegt.

+ +
+ +
+ +
+ diff --git a/partials/landingpages/dashboards.php b/partials/landingpages/dashboards.php new file mode 100644 index 0000000..2029e4d --- /dev/null +++ b/partials/landingpages/dashboards.php @@ -0,0 +1,142 @@ +available() || $ownerKey === '') { + echo '
Dashboard-System nicht verfügbar.
'; + return; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = trim((string) ($_POST['action'] ?? '')); + try { + if ($action === 'create_dashboard') { + $service->createDashboard($ownerKey, [ + 'title' => trim((string) ($_POST['title'] ?? '')), + 'slug' => trim((string) ($_POST['slug'] ?? '')), + 'description' => trim((string) ($_POST['description'] ?? '')), + 'visibility' => trim((string) ($_POST['visibility'] ?? 'private')), + 'is_default' => isset($_POST['is_default']), + ]); + $notice = 'Dashboard angelegt.'; + } elseif ($action === 'set_default') { + $service->setDefaultDashboard($ownerKey, (int) ($_POST['dashboard_id'] ?? 0)); + $notice = 'Standard-Dashboard gesetzt.'; + } elseif ($action === 'delete_dashboard') { + $service->deleteDashboard((int) ($_POST['dashboard_id'] ?? 0), $ownerKey); + $notice = 'Dashboard gelöscht.'; + } + } catch (\Throwable $exception) { + $error = $exception->getMessage(); + } +} + +$dashboardsList = $service->listDashboardsForOwner($ownerKey); + +$GLOBALS['layout_header_base_title'] = 'Nexus'; +$GLOBALS['layout_header_title'] = 'Nexus'; +$GLOBALS['layout_header_context'] = 'Dashboards'; +$GLOBALS['layout_header_text'] = 'Verwaltung persönlicher und später freigebbarer Dashboard-Flächen.'; +?> +
+ + + +
+ +
+ + +
+

Neues Dashboard

+
+ +
+ + +
+
+ + +
+ + +
+
+ +
+ +
+
+
+

+

+
+
+
+
+ Sichtbarkeit + +
+
+ Standard + +
+
+
+ Öffnen + +
+ + + +
+ +
+ + + +
+
+
+ +
+
diff --git a/partials/landingpages/index.php b/partials/landingpages/index.php old mode 100755 new mode 100644 index e56595a..dc21f01 --- a/partials/landingpages/index.php +++ b/partials/landingpages/index.php @@ -3,39 +3,202 @@ declare(strict_types=1); $auth = app()->auth(); $authUser = $auth->user(); +$ownerKey = auth_user_key(); +$groups = auth_groups(); +$service = dashboards(); $modules = array_values(array_filter( $auth->filterModules(modules()->all()), static fn (array $module): bool => !empty($module['enabled']) )); -?> -
- -
-
- Module verwalten - - Nexus Einstellungen - SQL-Export - -
-
- - -
- Keine Module für den aktuellen Zugriff sichtbar. +$dashboardsList = []; +$pageModules = []; +$defaultDashboard = null; +$dashboardItems = []; + +if ($authUser !== null && $service->available() && $ownerKey !== '') { + $defaultDashboard = $service->ensureDefaultDashboard($ownerKey, 'Mein Dashboard'); + $dashboardsList = $service->listAccessibleDashboards($ownerKey, $groups); + $pageModules = $service->listPageModulesForOwner($ownerKey); + if ($defaultDashboard !== []) { + $dashboardItems = $service->listItems((int) ($defaultDashboard['id'] ?? 0)); + } +} + +$GLOBALS['layout_header_base_title'] = 'Nexus'; +$GLOBALS['layout_header_title'] = 'Nexus'; +$GLOBALS['layout_header_context'] = 'Übersicht'; +$GLOBALS['layout_header_text'] = $authUser === null + ? 'Zentraler Einstieg in Nexus und die verfügbaren Bereiche.' + : 'Persönliche Übersicht aus Dashboards, Seitenmodulen und klassischen Modulen.'; +?> +
+ + + +
+

Mein Standard-Dashboard

+

Der schnellste Einstieg in deine wichtigsten Nexus-Inhalte.

+ +
Kein Dashboard verfügbar.
+ +
+ +
+ Elemente + +

Aktive Dashboard-Elemente im Standard-Dashboard.

+
+
+ Seitenmodule + +

Eigene, on-the-fly angelegte Seitenmodule.

+
+
+ Klassische Module + +

Aktuell sichtbare, klassische Nexus-Module.

+
+
+ +
+ +
+

Dashboards

+

Eigene und freigegebene Dashboards im globalen Nexus-System.

+
+ +
+
+
+

+

+
+
+
+
+ Sichtbarkeit + +
+
+
+ Öffnen +
+
+ +
+
+ +
+
+
+
+

Seitenmodule

+

On-the-fly angelegte Zielseiten und eingebettete Tools.

+
+
+
+
+ Anzahl + +
+
+
+ Verwalten +
+
+ +
+
+
+

Integrationen

+

Zentrale Anbindungen an Home Assistant, Pi-hole, Proxmox und andere Systeme.

+
+
+
+ Verwalten +
+
+ +
+
+
+

Dashboards konfigurieren

+

Eigene Dashboard-Flächen, Reihenfolge und Standard-Dashboard verwalten.

+
+
+
+ Öffnen +
+
+ +
+

Klassische Module

+

Die bisherigen Nexus-Module bleiben parallel zum neuen Grundgerüst bestehen.

+ +
- +
+

Nexus Einstieg

+

Nach der Anmeldung stehen persönliche Dashboards, Integrationen und Seitenmodule bereit.

+
+
+
+
+

Persönliche Dashboards

+

Mehrere frei konfigurierbare Übersichten pro Benutzer.

+
+
+
+
+
+
+

Integrationen

+

Zentrale Anbindungen für Fremdsysteme und externe Datenquellen.

+
+
+
+
+
+
+

Seitenmodule

+

On-the-fly angelegte Links und eingebettete Weboberflächen.

+
+
+
+
+
-
+ diff --git a/partials/landingpages/integrations.php b/partials/landingpages/integrations.php new file mode 100644 index 0000000..96f994e --- /dev/null +++ b/partials/landingpages/integrations.php @@ -0,0 +1,149 @@ +available() || $ownerKey === '') { + echo '
Integrationssystem nicht verfügbar.
'; + return; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = trim((string) ($_POST['action'] ?? '')); + try { + if ($action === 'create_integration') { + $service->createIntegration($ownerKey, [ + 'type' => trim((string) ($_POST['type'] ?? 'generic')), + 'name' => trim((string) ($_POST['name'] ?? '')), + 'base_url' => trim((string) ($_POST['base_url'] ?? '')), + 'visibility' => trim((string) ($_POST['visibility'] ?? 'private')), + 'is_active' => isset($_POST['is_active']), + 'config' => [ + 'notes' => trim((string) ($_POST['notes'] ?? '')), + ], + ]); + $notice = 'Integration angelegt.'; + } elseif ($action === 'delete_integration') { + $service->deleteIntegration((int) ($_POST['integration_id'] ?? 0), $ownerKey); + $notice = 'Integration gelöscht.'; + } + } catch (\Throwable $exception) { + $error = $exception->getMessage(); + } +} + +$integrations = $service->listIntegrationsForOwner($ownerKey); + +$GLOBALS['layout_header_base_title'] = 'Nexus'; +$GLOBALS['layout_header_title'] = 'Nexus'; +$GLOBALS['layout_header_context'] = 'Integrationen'; +$GLOBALS['layout_header_text'] = 'Zentrale Anbindungen an externe Systeme, getrennt vom klassischen Modulsystem.'; +?> +
+ + + +
+ +
+ + +
+

Neue Integration

+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ +
+
+
+

+

+
+
+
+
+ Typ + +
+
+ Status + +
+
+
+ + Öffnen + +
+ + + +
+
+
+ +
+
diff --git a/partials/landingpages/page_modules.php b/partials/landingpages/page_modules.php new file mode 100644 index 0000000..6a87e04 --- /dev/null +++ b/partials/landingpages/page_modules.php @@ -0,0 +1,158 @@ +available() || $ownerKey === '') { + echo '
Seitenmodul-System nicht verfügbar.
'; + return; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = trim((string) ($_POST['action'] ?? '')); + try { + if ($action === 'create_page_module') { + $service->createPageModule($ownerKey, [ + 'title' => trim((string) ($_POST['title'] ?? '')), + 'slug' => trim((string) ($_POST['slug'] ?? '')), + 'module_type' => trim((string) ($_POST['module_type'] ?? 'link')), + 'target_url' => trim((string) ($_POST['target_url'] ?? '')), + 'description' => trim((string) ($_POST['description'] ?? '')), + 'visibility' => trim((string) ($_POST['visibility'] ?? 'private')), + 'open_mode' => trim((string) ($_POST['open_mode'] ?? 'embed')), + 'is_active' => isset($_POST['is_active']), + ]); + $notice = 'Seitenmodul angelegt.'; + } elseif ($action === 'delete_page_module') { + $service->deletePageModule((int) ($_POST['page_module_id'] ?? 0), $ownerKey); + $notice = 'Seitenmodul gelöscht.'; + } + } catch (\Throwable $exception) { + $error = $exception->getMessage(); + } +} + +$pageModules = $service->listPageModulesForOwner($ownerKey); + +$GLOBALS['layout_header_base_title'] = 'Nexus'; +$GLOBALS['layout_header_title'] = 'Nexus'; +$GLOBALS['layout_header_context'] = 'Seitenmodule'; +$GLOBALS['layout_header_text'] = 'On-the-fly angelegte Link- und iFrame-Module ohne eigenen Modulordner.'; +?> +
+ + + +
+ +
+ + +
+

Neues Seitenmodul

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ +
+
+
+

+

+
+
+
+
+ Typ + +
+
+ Öffnen + +
+
+
+ Öffnen +
+ + + +
+
+
+ +
+
diff --git a/partials/landingpages/page_modules_view.php b/partials/landingpages/page_modules_view.php new file mode 100644 index 0000000..2160486 --- /dev/null +++ b/partials/landingpages/page_modules_view.php @@ -0,0 +1,58 @@ +getPageModule($pageModuleId, $ownerKey, $groups); + +if ($pageModule === null) { + http_response_code(404); + echo '
Seitenmodul nicht gefunden.
'; + return; +} + +$targetUrl = trim((string) ($pageModule['target_url'] ?? '')); +$openMode = (string) ($pageModule['open_mode'] ?? 'embed'); +if ($targetUrl !== '' && $openMode === 'same_tab') { + redirect($targetUrl); +} + +$GLOBALS['layout_header_base_title'] = 'Nexus'; +$GLOBALS['layout_header_title'] = 'Nexus'; +$GLOBALS['layout_header_context'] = (string) ($pageModule['title'] ?? 'Seitenmodul'); +$GLOBALS['layout_header_text'] = (string) ($pageModule['description'] ?? 'On-the-fly angelegtes Seitenmodul.'); +?> +
+ + +
+

+

+ +
Dieses Seitenmodul hat noch keine Ziel-URL.
+ + + + + +
+
diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 0d88520..7d5ecd7 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -776,6 +776,108 @@ body.has-modal-open { margin: 0; } +.nexus-quick-grid { + display: grid; + gap: 18px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + margin-top: 22px; +} + +.nexus-stat-card { + display: grid; + gap: 10px; + align-content: start; +} + +.nexus-stat-card strong { + font-size: clamp(1.35rem, 2vw, 2rem); + line-height: 1.1; +} + +.dashboard-grid { + display: grid; + gap: 20px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: stretch; +} + +.dashboard-widget { + display: grid; + gap: 14px; + min-height: 220px; + overflow: hidden; +} + +.dashboard-widget__head { + display: flex; + gap: 16px; + justify-content: space-between; + align-items: start; +} + +.dashboard-widget__head h2 { + margin: 6px 0 0; + font-size: 1.35rem; +} + +.dashboard-widget__head p { + margin: 8px 0 0; + color: var(--text-soft); +} + +.dashboard-widget__meta { + display: grid; + gap: 12px; + align-content: start; +} + +.dashboard-widget__meta p { + margin: 0; + color: var(--text-soft); + word-break: break-word; +} + +.dashboard-widget__frame { + width: 100%; + min-height: 300px; + border: 1px solid color-mix(in srgb, var(--border-soft) 85%, transparent); + border-radius: 16px; + background: rgba(255, 255, 255, 0.82); +} + +.dashboard-widget__frame--page { + min-height: 70vh; +} + +.dashboard-empty { + padding: 18px 20px; + border: 1px dashed color-mix(in srgb, var(--border-soft) 85%, transparent); + border-radius: 16px; + color: var(--text-soft); + background: rgba(255, 255, 255, 0.58); +} + +@media (max-width: 1100px) { + .dashboard-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .dashboard-grid { + grid-template-columns: 1fr; + } + + .dashboard-widget { + grid-column: auto !important; + grid-row: auto !important; + } + + .dashboard-widget__head { + flex-direction: column; + } +} + .setup-shell { display: grid; gap: 10px; diff --git a/public/index.php b/public/index.php index b709e07..677b07b 100755 --- a/public/index.php +++ b/public/index.php @@ -24,9 +24,12 @@ $publicPaths = [ 'auth/me', 'module/pi_control/terminal_info', ]; -$requiresGlobalAuth = in_array($uriPath, ['settings', 'users', 'modules', 'modules/install', 'modules/sql-import', 'debug', 'exports/database.sql'], true) +$requiresGlobalAuth = in_array($uriPath, ['settings', 'users', 'modules', 'modules/install', 'modules/sql-import', 'debug', 'exports/database.sql', 'dashboard', 'dashboards', 'integrations', 'page-modules'], true) || str_starts_with($uriPath, 'modules/setup/') || str_starts_with($uriPath, 'modules/access/'); +if (str_starts_with($uriPath, 'page-modules/view/')) { + $requiresGlobalAuth = true; +} if (defined('APP_AUTH_ENABLED') && APP_AUTH_ENABLED && $requiresGlobalAuth && !in_array($uriPath, $publicPaths, true)) { $user = auth_user(); if (!$user) { @@ -274,6 +277,17 @@ if (str_starts_with($uriPath, 'modules/install')) { $target = $pagesBase . '/users/settings.php'; } elseif ($uriPath === 'users') { $target = $pagesBase . '/users/index.php'; +} elseif ($uriPath === 'dashboard') { + $target = $pagesBase . '/dashboard.php'; +} elseif ($uriPath === 'dashboards') { + $target = $pagesBase . '/dashboards.php'; +} elseif ($uriPath === 'integrations') { + $target = $pagesBase . '/integrations.php'; +} elseif ($uriPath === 'page-modules') { + $target = $pagesBase . '/page_modules.php'; +} elseif (preg_match('~^page-modules/view/(\d+)$~', $uriPath, $pageModuleMatch)) { + $_GET['id'] = (string) $pageModuleMatch[1]; + $target = $pagesBase . '/page_modules_view.php'; } elseif ($uriPath === 'debug') { $target = $pagesBase . '/retool/debug.php'; } elseif (preg_match('~^module/([a-zA-Z0-9_-]+)(?:/(.+))?$~', $uriPath, $m)) { diff --git a/src/App/App.php b/src/App/App.php index f4c0e59..ef374a3 100755 --- a/src/App/App.php +++ b/src/App/App.php @@ -16,6 +16,7 @@ final class App private ?\PDO $basePdo; private ModuleManager $modules; private AuthService $auth; + private NexusDashboardService $dashboards; private function __construct(private Config $config) { @@ -43,6 +44,7 @@ final class App $this->modules = new ModuleManager($this->basePdo, $modulesPath); $this->modules->bootEnabled(); $this->auth = new AuthService($this); + $this->dashboards = new NexusDashboardService($this->basePdo); } public static function init(Config $config): self @@ -71,4 +73,5 @@ final class App public function basePdo(): ?\PDO { return $this->basePdo; } public function modules(): ModuleManager { return $this->modules; } public function auth(): AuthService { return $this->auth; } + public function dashboards(): NexusDashboardService { return $this->dashboards; } } diff --git a/src/App/BaseSchema.php b/src/App/BaseSchema.php index 43753dd..f9310b6 100644 --- a/src/App/BaseSchema.php +++ b/src/App/BaseSchema.php @@ -148,6 +148,88 @@ final class BaseSchema updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )" ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dashboards ( + id BIGSERIAL PRIMARY KEY, + owner_key TEXT NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NULL, + visibility TEXT NOT NULL DEFAULT 'private', + sort_order INTEGER NOT NULL DEFAULT 0, + is_default BOOLEAN NOT NULL DEFAULT false, + config TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ); + $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS nexus_dashboards_owner_slug_idx ON nexus_dashboards (owner_key, slug)"); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dashboard_items ( + id BIGSERIAL PRIMARY KEY, + dashboard_id BIGINT NOT NULL, + item_type TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NULL, + grid_column INTEGER NULL, + grid_row INTEGER NULL, + column_span INTEGER NOT NULL DEFAULT 1, + row_span INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + config TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ); + $pdo->exec("CREATE INDEX IF NOT EXISTS nexus_dashboard_items_dashboard_idx ON nexus_dashboard_items (dashboard_id, sort_order, id)"); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_integrations ( + id BIGSERIAL PRIMARY KEY, + owner_key TEXT NOT NULL, + type TEXT NOT NULL, + name TEXT NOT NULL, + base_url TEXT NULL, + visibility TEXT NOT NULL DEFAULT 'private', + is_active BOOLEAN NOT NULL DEFAULT true, + config TEXT NOT NULL DEFAULT '{}', + secrets TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_page_modules ( + id BIGSERIAL PRIMARY KEY, + owner_key TEXT NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + module_type TEXT NOT NULL, + target_url TEXT NULL, + description TEXT NULL, + visibility TEXT NOT NULL DEFAULT 'private', + open_mode TEXT NOT NULL DEFAULT 'embed', + is_active BOOLEAN NOT NULL DEFAULT true, + config TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ); + $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS nexus_page_modules_owner_slug_idx ON nexus_page_modules (owner_key, slug)"); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dashboard_shares ( + id BIGSERIAL PRIMARY KEY, + dashboard_id BIGINT NOT NULL, + share_type TEXT NOT NULL, + share_value TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ); + $pdo->exec("CREATE INDEX IF NOT EXISTS nexus_dashboard_shares_dashboard_idx ON nexus_dashboard_shares (dashboard_id, share_type, share_value)"); } private static function ensureSqlite(\PDO $pdo): void @@ -276,6 +358,88 @@ final class BaseSchema updated_at TEXT NOT NULL DEFAULT (datetime('now')) )" ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dashboards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_key TEXT NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NULL, + visibility TEXT NOT NULL DEFAULT 'private', + sort_order INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0, + config TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS nexus_dashboards_owner_slug_idx ON nexus_dashboards (owner_key, slug)"); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dashboard_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dashboard_id INTEGER NOT NULL, + item_type TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NULL, + grid_column INTEGER NULL, + grid_row INTEGER NULL, + column_span INTEGER NOT NULL DEFAULT 1, + row_span INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + config TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + $pdo->exec("CREATE INDEX IF NOT EXISTS nexus_dashboard_items_dashboard_idx ON nexus_dashboard_items (dashboard_id, sort_order, id)"); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_integrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_key TEXT NOT NULL, + type TEXT NOT NULL, + name TEXT NOT NULL, + base_url TEXT NULL, + visibility TEXT NOT NULL DEFAULT 'private', + is_active INTEGER NOT NULL DEFAULT 1, + config TEXT NOT NULL DEFAULT '{}', + secrets TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_page_modules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_key TEXT NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + module_type TEXT NOT NULL, + target_url TEXT NULL, + description TEXT NULL, + visibility TEXT NOT NULL DEFAULT 'private', + open_mode TEXT NOT NULL DEFAULT 'embed', + is_active INTEGER NOT NULL DEFAULT 1, + config TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS nexus_page_modules_owner_slug_idx ON nexus_page_modules (owner_key, slug)"); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dashboard_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dashboard_id INTEGER NOT NULL, + share_type TEXT NOT NULL, + share_value TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + $pdo->exec("CREATE INDEX IF NOT EXISTS nexus_dashboard_shares_dashboard_idx ON nexus_dashboard_shares (dashboard_id, share_type, share_value)"); } private static function ensureGeneric(\PDO $pdo): void @@ -404,6 +568,88 @@ final class BaseSchema updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )" ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dashboards ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + owner_key VARCHAR(190) NOT NULL, + title VARCHAR(255) NOT NULL, + slug VARCHAR(190) NOT NULL, + description TEXT NULL, + visibility VARCHAR(32) NOT NULL DEFAULT 'private', + sort_order INT NOT NULL DEFAULT 0, + is_default TINYINT NOT NULL DEFAULT 0, + config TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY nexus_dashboards_owner_slug_idx (owner_key, slug) + )" + ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dashboard_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + dashboard_id BIGINT NOT NULL, + item_type VARCHAR(64) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + grid_column INT NULL, + grid_row INT NULL, + column_span INT NOT NULL DEFAULT 1, + row_span INT NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + config TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY nexus_dashboard_items_dashboard_idx (dashboard_id, sort_order, id) + )" + ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_integrations ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + owner_key VARCHAR(190) NOT NULL, + type VARCHAR(64) NOT NULL, + name VARCHAR(255) NOT NULL, + base_url TEXT NULL, + visibility VARCHAR(32) NOT NULL DEFAULT 'private', + is_active TINYINT NOT NULL DEFAULT 1, + config TEXT NOT NULL, + secrets TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )" + ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_page_modules ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + owner_key VARCHAR(190) NOT NULL, + title VARCHAR(255) NOT NULL, + slug VARCHAR(190) NOT NULL, + module_type VARCHAR(64) NOT NULL, + target_url TEXT NULL, + description TEXT NULL, + visibility VARCHAR(32) NOT NULL DEFAULT 'private', + open_mode VARCHAR(32) NOT NULL DEFAULT 'embed', + is_active TINYINT NOT NULL DEFAULT 1, + config TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY nexus_page_modules_owner_slug_idx (owner_key, slug) + )" + ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_dashboard_shares ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + dashboard_id BIGINT NOT NULL, + share_type VARCHAR(32) NOT NULL, + share_value VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY nexus_dashboard_shares_dashboard_idx (dashboard_id, share_type, share_value) + )" + ); } private static function seedTimezones(\PDO $pdo): void diff --git a/src/App/NexusDashboardService.php b/src/App/NexusDashboardService.php new file mode 100644 index 0000000..2b6d473 --- /dev/null +++ b/src/App/NexusDashboardService.php @@ -0,0 +1,628 @@ +pdo instanceof \PDO; + } + + public function ensureDefaultDashboard(string $ownerKey, string $title = 'Mein Dashboard'): array + { + $dashboards = $this->listDashboardsForOwner($ownerKey); + if ($dashboards !== []) { + foreach ($dashboards as $dashboard) { + if (!empty($dashboard['is_default'])) { + return $dashboard; + } + } + $this->setDefaultDashboard($ownerKey, (int) $dashboards[0]['id']); + return $this->getDashboardById((int) $dashboards[0]['id']) ?? $dashboards[0]; + } + + $id = $this->createDashboard($ownerKey, [ + 'title' => $title, + 'slug' => 'mein-dashboard', + 'description' => 'Persönliches Nexus-Dashboard.', + 'visibility' => 'private', + 'is_default' => true, + ]); + + return $this->getDashboardById($id) ?? []; + } + + public function listDashboardsForOwner(string $ownerKey): array + { + if (!$this->available() || $ownerKey === '') { + return []; + } + + $stmt = $this->pdo->prepare( + "SELECT * + FROM nexus_dashboards + WHERE owner_key = :owner + ORDER BY is_default DESC, sort_order ASC, title ASC, id ASC" + ); + $stmt->execute(['owner' => $ownerKey]); + + return $this->hydrateRows($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []); + } + + public function listAccessibleDashboards(string $ownerKey, array $groups = []): array + { + if (!$this->available()) { + return []; + } + + $rows = []; + $seen = []; + foreach ($this->listDashboardsForOwner($ownerKey) as $dashboard) { + $id = (int) ($dashboard['id'] ?? 0); + if ($id > 0) { + $rows[] = $dashboard; + $seen[$id] = true; + } + } + + $stmt = $this->pdo->prepare( + "SELECT * + FROM nexus_dashboards + WHERE visibility = 'public' + ORDER BY sort_order ASC, title ASC, id ASC" + ); + $stmt->execute(); + foreach ($this->hydrateRows($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []) as $dashboard) { + $id = (int) ($dashboard['id'] ?? 0); + if ($id > 0 && !isset($seen[$id])) { + $rows[] = $dashboard; + $seen[$id] = true; + } + } + + if ($ownerKey !== '') { + $shared = $this->listSharedDashboardIds($ownerKey, $groups); + foreach ($shared as $dashboardId) { + if (isset($seen[$dashboardId])) { + continue; + } + $dashboard = $this->getDashboardById($dashboardId); + if ($dashboard !== null) { + $rows[] = $dashboard; + $seen[$dashboardId] = true; + } + } + } + + usort($rows, static function (array $left, array $right): int { + return [$left['sort_order'] ?? 0, (string) ($left['title'] ?? ''), (int) ($left['id'] ?? 0)] + <=> [$right['sort_order'] ?? 0, (string) ($right['title'] ?? ''), (int) ($right['id'] ?? 0)]; + }); + + return $rows; + } + + public function getDashboardById(int $id): ?array + { + if (!$this->available() || $id <= 0) { + return null; + } + + $stmt = $this->pdo->prepare("SELECT * FROM nexus_dashboards WHERE id = :id LIMIT 1"); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + return is_array($row) ? $this->hydrateRow($row) : null; + } + + public function createDashboard(string $ownerKey, array $data): int + { + if (!$this->available() || $ownerKey === '') { + return 0; + } + + $title = trim((string) ($data['title'] ?? 'Neues Dashboard')); + $slug = $this->uniqueSlug('nexus_dashboards', $ownerKey, (string) ($data['slug'] ?? $title), 0); + $description = trim((string) ($data['description'] ?? '')); + $visibility = $this->normalizeVisibility((string) ($data['visibility'] ?? 'private')); + $sortOrder = (int) ($data['sort_order'] ?? $this->nextSortOrder('nexus_dashboards', 'owner_key', $ownerKey)); + $isDefault = !empty($data['is_default']); + $config = $this->encodeJson((array) ($data['config'] ?? [])); + + $stmt = $this->pdo->prepare( + "INSERT INTO nexus_dashboards + (owner_key, title, slug, description, visibility, sort_order, is_default, config, created_at, updated_at) + VALUES + (:owner_key, :title, :slug, :description, :visibility, :sort_order, :is_default, :config, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + ); + $stmt->execute([ + 'owner_key' => $ownerKey, + 'title' => $title !== '' ? $title : 'Neues Dashboard', + 'slug' => $slug, + 'description' => $description, + 'visibility' => $visibility, + 'sort_order' => $sortOrder, + 'is_default' => $isDefault ? 1 : 0, + 'config' => $config, + ]); + + $id = (int) $this->pdo->lastInsertId(); + if ($isDefault) { + $this->setDefaultDashboard($ownerKey, $id); + } + + return $id; + } + + public function updateDashboard(int $id, string $ownerKey, array $data): void + { + if (!$this->available() || $id <= 0 || $ownerKey === '') { + return; + } + + $dashboard = $this->getOwnedDashboard($id, $ownerKey); + if ($dashboard === null) { + return; + } + + $title = trim((string) ($data['title'] ?? ($dashboard['title'] ?? 'Dashboard'))); + $slug = $this->uniqueSlug('nexus_dashboards', $ownerKey, (string) ($data['slug'] ?? $title), $id); + $description = trim((string) ($data['description'] ?? ($dashboard['description'] ?? ''))); + $visibility = $this->normalizeVisibility((string) ($data['visibility'] ?? ($dashboard['visibility'] ?? 'private'))); + $sortOrder = isset($data['sort_order']) ? (int) $data['sort_order'] : (int) ($dashboard['sort_order'] ?? 0); + $config = $this->encodeJson((array) ($data['config'] ?? ($dashboard['config'] ?? []))); + $isDefault = !empty($data['is_default']); + + $stmt = $this->pdo->prepare( + "UPDATE nexus_dashboards + SET title = :title, + slug = :slug, + description = :description, + visibility = :visibility, + sort_order = :sort_order, + is_default = :is_default, + config = :config, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id AND owner_key = :owner_key" + ); + $stmt->execute([ + 'id' => $id, + 'owner_key' => $ownerKey, + 'title' => $title !== '' ? $title : 'Dashboard', + 'slug' => $slug, + 'description' => $description, + 'visibility' => $visibility, + 'sort_order' => $sortOrder, + 'is_default' => $isDefault ? 1 : 0, + 'config' => $config, + ]); + + if ($isDefault) { + $this->setDefaultDashboard($ownerKey, $id); + } + } + + public function deleteDashboard(int $id, string $ownerKey): void + { + if (!$this->available() || $id <= 0 || $ownerKey === '') { + return; + } + + $dashboard = $this->getOwnedDashboard($id, $ownerKey); + if ($dashboard === null) { + return; + } + + $stmt = $this->pdo->prepare("DELETE FROM nexus_dashboard_items WHERE dashboard_id = :dashboard_id"); + $stmt->execute(['dashboard_id' => $id]); + + $stmt = $this->pdo->prepare("DELETE FROM nexus_dashboard_shares WHERE dashboard_id = :dashboard_id"); + $stmt->execute(['dashboard_id' => $id]); + + $stmt = $this->pdo->prepare("DELETE FROM nexus_dashboards WHERE id = :id AND owner_key = :owner_key"); + $stmt->execute(['id' => $id, 'owner_key' => $ownerKey]); + + if (!empty($dashboard['is_default'])) { + $remaining = $this->listDashboardsForOwner($ownerKey); + if ($remaining !== []) { + $this->setDefaultDashboard($ownerKey, (int) $remaining[0]['id']); + } + } + } + + public function setDefaultDashboard(string $ownerKey, int $dashboardId): void + { + if (!$this->available() || $ownerKey === '' || $dashboardId <= 0) { + return; + } + + $stmt = $this->pdo->prepare("UPDATE nexus_dashboards SET is_default = 0, updated_at = CURRENT_TIMESTAMP WHERE owner_key = :owner_key"); + $stmt->execute(['owner_key' => $ownerKey]); + + $stmt = $this->pdo->prepare("UPDATE nexus_dashboards SET is_default = 1, updated_at = CURRENT_TIMESTAMP WHERE id = :id AND owner_key = :owner_key"); + $stmt->execute(['id' => $dashboardId, 'owner_key' => $ownerKey]); + } + + public function listItems(int $dashboardId): array + { + if (!$this->available() || $dashboardId <= 0) { + return []; + } + + $stmt = $this->pdo->prepare( + "SELECT * + FROM nexus_dashboard_items + WHERE dashboard_id = :dashboard_id + ORDER BY sort_order ASC, id ASC" + ); + $stmt->execute(['dashboard_id' => $dashboardId]); + + return $this->hydrateRows($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []); + } + + public function createItem(int $dashboardId, string $ownerKey, array $data): int + { + if (!$this->available() || $dashboardId <= 0) { + return 0; + } + + $dashboard = $this->getOwnedDashboard($dashboardId, $ownerKey); + if ($dashboard === null) { + return 0; + } + + $itemType = $this->normalizeItemType((string) ($data['item_type'] ?? 'link')); + $title = trim((string) ($data['title'] ?? 'Element')); + $description = trim((string) ($data['description'] ?? '')); + $gridColumn = $this->nullableInt($data['grid_column'] ?? null); + $gridRow = $this->nullableInt($data['grid_row'] ?? null); + $columnSpan = max(1, min(4, (int) ($data['column_span'] ?? 1))); + $rowSpan = max(1, min(4, (int) ($data['row_span'] ?? 1))); + $sortOrder = (int) ($data['sort_order'] ?? $this->nextItemSortOrder($dashboardId)); + $config = $this->encodeJson((array) ($data['config'] ?? [])); + + $stmt = $this->pdo->prepare( + "INSERT INTO nexus_dashboard_items + (dashboard_id, item_type, title, description, grid_column, grid_row, column_span, row_span, sort_order, config, created_at, updated_at) + VALUES + (:dashboard_id, :item_type, :title, :description, :grid_column, :grid_row, :column_span, :row_span, :sort_order, :config, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + ); + $stmt->execute([ + 'dashboard_id' => $dashboardId, + 'item_type' => $itemType, + 'title' => $title !== '' ? $title : 'Element', + 'description' => $description, + 'grid_column' => $gridColumn, + 'grid_row' => $gridRow, + 'column_span' => $columnSpan, + 'row_span' => $rowSpan, + 'sort_order' => $sortOrder, + 'config' => $config, + ]); + + return (int) $this->pdo->lastInsertId(); + } + + public function deleteItem(int $itemId, int $dashboardId, string $ownerKey): void + { + if (!$this->available() || $itemId <= 0 || $dashboardId <= 0) { + return; + } + + $dashboard = $this->getOwnedDashboard($dashboardId, $ownerKey); + if ($dashboard === null) { + return; + } + + $stmt = $this->pdo->prepare("DELETE FROM nexus_dashboard_items WHERE id = :id AND dashboard_id = :dashboard_id"); + $stmt->execute([ + 'id' => $itemId, + 'dashboard_id' => $dashboardId, + ]); + } + + public function listIntegrationsForOwner(string $ownerKey): array + { + if (!$this->available() || $ownerKey === '') { + return []; + } + + $stmt = $this->pdo->prepare( + "SELECT * + FROM nexus_integrations + WHERE owner_key = :owner_key + ORDER BY is_active DESC, name ASC, id ASC" + ); + $stmt->execute(['owner_key' => $ownerKey]); + + return $this->hydrateRows($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []); + } + + public function createIntegration(string $ownerKey, array $data): int + { + if (!$this->available() || $ownerKey === '') { + return 0; + } + + $stmt = $this->pdo->prepare( + "INSERT INTO nexus_integrations + (owner_key, type, name, base_url, visibility, is_active, config, secrets, created_at, updated_at) + VALUES + (:owner_key, :type, :name, :base_url, :visibility, :is_active, :config, :secrets, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + ); + $stmt->execute([ + 'owner_key' => $ownerKey, + 'type' => trim((string) ($data['type'] ?? 'generic')) ?: 'generic', + 'name' => trim((string) ($data['name'] ?? 'Integration')) ?: 'Integration', + 'base_url' => trim((string) ($data['base_url'] ?? '')), + 'visibility' => $this->normalizeVisibility((string) ($data['visibility'] ?? 'private')), + 'is_active' => !empty($data['is_active']) ? 1 : 0, + 'config' => $this->encodeJson((array) ($data['config'] ?? [])), + 'secrets' => $this->encodeJson((array) ($data['secrets'] ?? [])), + ]); + + return (int) $this->pdo->lastInsertId(); + } + + public function deleteIntegration(int $id, string $ownerKey): void + { + if (!$this->available() || $id <= 0 || $ownerKey === '') { + return; + } + + $stmt = $this->pdo->prepare("DELETE FROM nexus_integrations WHERE id = :id AND owner_key = :owner_key"); + $stmt->execute(['id' => $id, 'owner_key' => $ownerKey]); + } + + public function listPageModulesForOwner(string $ownerKey): array + { + if (!$this->available() || $ownerKey === '') { + return []; + } + + $stmt = $this->pdo->prepare( + "SELECT * + FROM nexus_page_modules + WHERE owner_key = :owner_key + ORDER BY is_active DESC, title ASC, id ASC" + ); + $stmt->execute(['owner_key' => $ownerKey]); + + return $this->hydrateRows($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []); + } + + public function createPageModule(string $ownerKey, array $data): int + { + if (!$this->available() || $ownerKey === '') { + return 0; + } + + $title = trim((string) ($data['title'] ?? 'Seitenmodul')) ?: 'Seitenmodul'; + $slug = $this->uniqueSlug('nexus_page_modules', $ownerKey, (string) ($data['slug'] ?? $title), 0); + + $stmt = $this->pdo->prepare( + "INSERT INTO nexus_page_modules + (owner_key, title, slug, module_type, target_url, description, visibility, open_mode, is_active, config, created_at, updated_at) + VALUES + (:owner_key, :title, :slug, :module_type, :target_url, :description, :visibility, :open_mode, :is_active, :config, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + ); + $stmt->execute([ + 'owner_key' => $ownerKey, + 'title' => $title, + 'slug' => $slug, + 'module_type' => $this->normalizePageModuleType((string) ($data['module_type'] ?? 'link')), + 'target_url' => trim((string) ($data['target_url'] ?? '')), + 'description' => trim((string) ($data['description'] ?? '')), + 'visibility' => $this->normalizeVisibility((string) ($data['visibility'] ?? 'private')), + 'open_mode' => $this->normalizeOpenMode((string) ($data['open_mode'] ?? 'embed')), + 'is_active' => !empty($data['is_active']) ? 1 : 0, + 'config' => $this->encodeJson((array) ($data['config'] ?? [])), + ]); + + return (int) $this->pdo->lastInsertId(); + } + + public function deletePageModule(int $id, string $ownerKey): void + { + if (!$this->available() || $id <= 0 || $ownerKey === '') { + return; + } + + $stmt = $this->pdo->prepare("DELETE FROM nexus_page_modules WHERE id = :id AND owner_key = :owner_key"); + $stmt->execute(['id' => $id, 'owner_key' => $ownerKey]); + } + + public function getPageModule(int $id, string $ownerKey = '', array $groups = []): ?array + { + if (!$this->available() || $id <= 0) { + return null; + } + + $stmt = $this->pdo->prepare("SELECT * FROM nexus_page_modules WHERE id = :id LIMIT 1"); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + if (!is_array($row)) { + return null; + } + + $module = $this->hydrateRow($row); + if ($module['owner_key'] === $ownerKey || $module['visibility'] === 'public') { + return $module; + } + if ($module['visibility'] === 'group' && $ownerKey !== '') { + foreach ($groups as $group) { + if (in_array($group, (array) ($module['config']['groups'] ?? []), true)) { + return $module; + } + } + } + + return null; + } + + private function listSharedDashboardIds(string $ownerKey, array $groups): array + { + $result = []; + $stmt = $this->pdo->prepare( + "SELECT dashboard_id, share_type, share_value + FROM nexus_dashboard_shares + WHERE share_type = 'user' AND share_value = :share_value" + ); + $stmt->execute(['share_value' => $ownerKey]); + foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) { + $result[] = (int) ($row['dashboard_id'] ?? 0); + } + + foreach ($groups as $group) { + $group = trim((string) $group); + if ($group === '') { + continue; + } + $stmt = $this->pdo->prepare( + "SELECT dashboard_id + FROM nexus_dashboard_shares + WHERE share_type = 'group' AND share_value = :share_value" + ); + $stmt->execute(['share_value' => $group]); + foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) { + $result[] = (int) ($row['dashboard_id'] ?? 0); + } + } + + return array_values(array_unique(array_filter($result, static fn (int $id): bool => $id > 0))); + } + + private function getOwnedDashboard(int $id, string $ownerKey): ?array + { + $dashboard = $this->getDashboardById($id); + if ($dashboard === null || (string) ($dashboard['owner_key'] ?? '') !== $ownerKey) { + return null; + } + + return $dashboard; + } + + private function uniqueSlug(string $table, string $ownerKey, string $candidate, int $ignoreId): string + { + $slug = $this->slugify($candidate); + $suffix = 1; + while ($this->slugExists($table, $ownerKey, $slug, $ignoreId)) { + $suffix++; + $slug = $this->slugify($candidate) . '-' . $suffix; + } + return $slug; + } + + private function slugExists(string $table, string $ownerKey, string $slug, int $ignoreId): bool + { + $sql = "SELECT id FROM {$table} WHERE owner_key = :owner_key AND slug = :slug"; + if ($ignoreId > 0) { + $sql .= " AND id <> :id"; + } + $sql .= " LIMIT 1"; + $stmt = $this->pdo->prepare($sql); + $params = ['owner_key' => $ownerKey, 'slug' => $slug]; + if ($ignoreId > 0) { + $params['id'] = $ignoreId; + } + $stmt->execute($params); + return (bool) $stmt->fetchColumn(); + } + + private function slugify(string $value): string + { + $value = trim(mb_strtolower($value)); + $value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? ''; + $value = trim($value, '-'); + return $value !== '' ? $value : 'eintrag'; + } + + private function nextSortOrder(string $table, string $field, string $value): int + { + $stmt = $this->pdo->prepare("SELECT COALESCE(MAX(sort_order), 0) + 10 FROM {$table} WHERE {$field} = :value"); + $stmt->execute(['value' => $value]); + return (int) $stmt->fetchColumn(); + } + + private function nextItemSortOrder(int $dashboardId): int + { + $stmt = $this->pdo->prepare("SELECT COALESCE(MAX(sort_order), 0) + 10 FROM nexus_dashboard_items WHERE dashboard_id = :dashboard_id"); + $stmt->execute(['dashboard_id' => $dashboardId]); + return (int) $stmt->fetchColumn(); + } + + private function normalizeVisibility(string $value): string + { + $value = trim($value); + return in_array($value, ['private', 'group', 'public'], true) ? $value : 'private'; + } + + private function normalizeItemType(string $value): string + { + $value = trim($value); + return in_array($value, ['link', 'iframe', 'page_module', 'bookmark_group', 'module_link'], true) ? $value : 'link'; + } + + private function normalizePageModuleType(string $value): string + { + $value = trim($value); + return in_array($value, ['link', 'iframe', 'bookmark_group', 'external_status'], true) ? $value : 'link'; + } + + private function normalizeOpenMode(string $value): string + { + $value = trim($value); + return in_array($value, ['embed', 'new_tab', 'same_tab'], true) ? $value : 'embed'; + } + + private function encodeJson(array $value): string + { + $encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return $encoded === false ? '{}' : $encoded; + } + + private function decodeJson(mixed $value): array + { + if (!is_string($value) || trim($value) === '') { + return []; + } + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } + + private function hydrateRows(array $rows): array + { + return array_values(array_map(fn (array $row): array => $this->hydrateRow($row), $rows)); + } + + private function hydrateRow(array $row): array + { + foreach (['config', 'secrets'] as $jsonField) { + if (array_key_exists($jsonField, $row)) { + $row[$jsonField] = $this->decodeJson($row[$jsonField]); + } + } + + foreach (['is_default', 'is_active'] as $boolField) { + if (array_key_exists($boolField, $row)) { + $row[$boolField] = !empty($row[$boolField]); + } + } + + return $row; + } + + private function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + return (int) $value; + } +} diff --git a/src/App/functions.php b/src/App/functions.php index abf4b85..f80359a 100644 --- a/src/App/functions.php +++ b/src/App/functions.php @@ -90,6 +90,23 @@ function auth_display_name(): string return $email; } +function auth_user_key(): string +{ + $user = auth_user(); + if (!$user) { + return ''; + } + + foreach (['sub', 'email', 'username', 'name'] as $field) { + $value = trim((string) ($user[$field] ?? '')); + if ($value !== '') { + return $value; + } + } + + return ''; +} + function auth_initials(): string { $name = auth_display_name(); @@ -188,6 +205,11 @@ function modules(): \App\ModuleManager return app()->modules(); } +function dashboards(): \App\NexusDashboardService +{ + return app()->dashboards(); +} + function module_fn(string $module, string $name, mixed ...$args): mixed { return modules()->call($module, $name, ...$args);