- 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.
+
+
+
+ Dashboard
+ = e((string) ($defaultDashboard['title'] ?? 'Mein Dashboard')) ?>
+ = e((string) ($defaultDashboard['description'] ?? 'Persönliches Standard-Dashboard')) ?>
+ Öffnen
+
+
+ Elemente
+ = count($dashboardItems) ?>
+ Aktive Dashboard-Elemente im Standard-Dashboard.
+
+
+ Seitenmodule
+ = count($pageModules) ?>
+ Eigene, on-the-fly angelegte Seitenmodule.
+
+
+ Klassische Module
+ = count($modules) ?>
+ Aktuell sichtbare, klassische Nexus-Module.
+
+
+
+
+
+
+ Dashboards
+ Eigene und freigegebene Dashboards im globalen Nexus-System.
+
+
+
+
+
+
= e((string) ($dashboard['title'] ?? 'Dashboard')) ?>
+
= e((string) ($dashboard['description'] ?? 'Flexible Dashboard-Fläche für Widgets und Seitenmodule.')) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Seitenmodule
+
On-the-fly angelegte Zielseiten und eingebettete Tools.
+
+
+
+
+
+
+
+
+
+
Integrationen
+
Zentrale Anbindungen an Home Assistant, Pi-hole, Proxmox und andere Systeme.
+
+
+
+
+
+
+
+
+
Dashboards konfigurieren
+
Eigene Dashboard-Flächen, Reihenfolge und Standard-Dashboard verwalten.
+
+
+
+
+
+
+ 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.';
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= e((string) ($integration['name'] ?? 'Integration')) ?>
+
= e((string) (($integration['config']['notes'] ?? '') ?: (string) ($integration['base_url'] ?? 'Zentrale externe Anbindung.'))) ?>
+
+
+
+
+
+
+
+
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.';
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= e((string) ($pageModule['title'] ?? 'Seitenmodul')) ?>
+
= e((string) ($pageModule['description'] ?? ($pageModule['target_url'] ?? ''))) ?>
+
+
+
+
+
+
+
+
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.');
+?>
+
+
+
+
+ = e((string) ($pageModule['title'] ?? 'Seitenmodul')) ?>
+ = e((string) ($pageModule['description'] ?? '')) ?>
+
+ 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);