feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ $csrfToken = (string) ($_SESSION[$csrfKey] ?? '');
|
||||
$isLoggedIn = !empty($user['id']);
|
||||
$sessionIdleMinutes = (int) (appSetting('session_idle_timeout_minutes') ?? 30);
|
||||
$sessionIdleSeconds = max($sessionIdleMinutes, 5) * 60;
|
||||
$sessionPingUrl = lurl('admin/session-ping/data');
|
||||
$sessionLogoutUrl = lurl('logout');
|
||||
$telemetryEnabled = frontendTelemetryEnabled();
|
||||
$telemetrySampleRate = frontendTelemetrySampleRate();
|
||||
$telemetryAllowedEvents = implode(',', frontendTelemetryAllowedEvents());
|
||||
@@ -20,6 +22,118 @@ $telemetryUrl = lurl('admin/frontend-telemetry/ingest');
|
||||
$pageRequestId = RequestContext::id();
|
||||
$viewAuth = is_array($viewAuth ?? null) ? $viewAuth : [];
|
||||
$viewAuth['layout'] = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
|
||||
$layoutAuth = $viewAuth['layout'];
|
||||
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
|
||||
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
|
||||
$moduleHeadStyleSlots = is_array($moduleSlots['layout.head_style'] ?? null) ? $moduleSlots['layout.head_style'] : [];
|
||||
$moduleBodyEndTemplateSlots = is_array($moduleSlots['layout.body_end_template'] ?? null) ? $moduleSlots['layout.body_end_template'] : [];
|
||||
$moduleRuntimeComponentSlots = is_array($moduleSlots['runtime.component'] ?? null) ? $moduleSlots['runtime.component'] : [];
|
||||
|
||||
/**
|
||||
* @param array<mixed> $value
|
||||
*/
|
||||
$isAssocArray = static function (array $value): bool {
|
||||
if ($value === []) {
|
||||
return false;
|
||||
}
|
||||
return array_keys($value) !== range(0, count($value) - 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $target
|
||||
* @param array<string, mixed> $source
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
$strictMergeConfig = static function (array $target, array $source, string $context, callable $isAssocArray) use (&$strictMergeConfig): array {
|
||||
foreach ($source as $key => $sourceValue) {
|
||||
if (!is_string($key) || trim($key) === '') {
|
||||
throw new RuntimeException("Invalid default_config key in {$context}.");
|
||||
}
|
||||
if (!array_key_exists($key, $target)) {
|
||||
$target[$key] = $sourceValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetValue = $target[$key];
|
||||
if (is_array($targetValue) && is_array($sourceValue) && $isAssocArray($targetValue) && $isAssocArray($sourceValue)) {
|
||||
$target[$key] = $strictMergeConfig($targetValue, $sourceValue, $context . '.' . $key, $isAssocArray);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($targetValue !== $sourceValue) {
|
||||
throw new RuntimeException("default_config collision at {$context}.{$key}.");
|
||||
}
|
||||
}
|
||||
|
||||
return $target;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $root
|
||||
* @param array<string, mixed> $value
|
||||
*/
|
||||
$mergeConfigAtPath = static function (array &$root, string $path, array $value, string $context, callable $strictMergeConfig): void {
|
||||
$segments = array_values(array_filter(array_map('trim', explode('.', $path)), static fn (string $item): bool => $item !== ''));
|
||||
if ($segments === []) {
|
||||
throw new RuntimeException("Invalid config_path in {$context}: '{$path}'");
|
||||
}
|
||||
|
||||
$cursor = &$root;
|
||||
$fullPath = '';
|
||||
foreach ($segments as $segment) {
|
||||
if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $segment)) {
|
||||
throw new RuntimeException("Invalid config_path segment '{$segment}' in {$context}.");
|
||||
}
|
||||
$fullPath = $fullPath === '' ? $segment : $fullPath . '.' . $segment;
|
||||
if (!array_key_exists($segment, $cursor)) {
|
||||
$cursor[$segment] = [];
|
||||
}
|
||||
if (!is_array($cursor[$segment])) {
|
||||
throw new RuntimeException("Config path '{$fullPath}' in {$context} is not an object.");
|
||||
}
|
||||
$cursor = &$cursor[$segment];
|
||||
}
|
||||
|
||||
$cursor = $strictMergeConfig(
|
||||
is_array($cursor) ? $cursor : [],
|
||||
$value,
|
||||
$context,
|
||||
static fn (array $raw): bool => $raw !== [] && array_keys($raw) !== range(0, count($raw) - 1)
|
||||
);
|
||||
};
|
||||
|
||||
$normalizeConfigPathSegment = static function (string $raw): string {
|
||||
$segment = preg_replace('/[^a-zA-Z0-9_]+/', '_', strtolower(trim($raw))) ?? '';
|
||||
$segment = trim($segment, '_');
|
||||
if ($segment === '') {
|
||||
return 'item';
|
||||
}
|
||||
if (preg_match('/^[0-9]/', $segment)) {
|
||||
return 'n_' . $segment;
|
||||
}
|
||||
return $segment;
|
||||
};
|
||||
|
||||
$moduleHeadStylePaths = [];
|
||||
$moduleHeadSeen = [];
|
||||
foreach ($moduleHeadStyleSlots as $slot) {
|
||||
if (!is_array($slot)) {
|
||||
continue;
|
||||
}
|
||||
$slotPermission = trim((string) ($slot['permission'] ?? ''));
|
||||
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) {
|
||||
continue;
|
||||
}
|
||||
$path = ltrim(trim((string) ($slot['path'] ?? '')), '/');
|
||||
if ($path === '') {
|
||||
continue;
|
||||
}
|
||||
if (isset($moduleHeadSeen[$path])) {
|
||||
continue;
|
||||
}
|
||||
$moduleHeadSeen[$path] = true;
|
||||
$moduleHeadStylePaths[] = $path;
|
||||
}
|
||||
$pageTitle = $defaultTitle;
|
||||
ob_start();
|
||||
Buffer::get('title');
|
||||
@@ -27,6 +141,146 @@ $bufferTitle = trim((string) ob_get_clean());
|
||||
if ($bufferTitle !== '') {
|
||||
$pageTitle = $bufferTitle . ' - ' . $defaultTitle;
|
||||
}
|
||||
$componentPageConfig = [
|
||||
'components' => [
|
||||
'confirmActions' => [],
|
||||
'flashAutoDismiss' => [
|
||||
'selector' => '.notice[data-flash-timeout]',
|
||||
],
|
||||
'passwordHints' => [],
|
||||
'copyBadge' => [],
|
||||
'multiSelect' => [
|
||||
'selector' => '[data-multi-select]',
|
||||
],
|
||||
'navHistory' => [],
|
||||
'detailsState' => [
|
||||
'selector' => '[data-details-storage]',
|
||||
'storageNamespace' => 'app-ui',
|
||||
'storageVersion' => 'v1',
|
||||
'storageScope' => 'details-open',
|
||||
],
|
||||
'settingsTelemetry' => [],
|
||||
'autoSubmit' => [
|
||||
'selector' => '[data-auto-submit]',
|
||||
],
|
||||
'sidebarToggle' => [
|
||||
'buttonSelector' => '[data-sidebar-toggle]',
|
||||
'asideSelector' => '#app-sidebar',
|
||||
'containerSelector' => '.app-container',
|
||||
'storageNamespace' => 'app-ui',
|
||||
'storageVersion' => 'v1',
|
||||
'storageScope' => 'sidebar',
|
||||
'collapsedStorageKey' => 'sidebar-collapsed',
|
||||
'hiddenStorageKey' => 'sidebar-hidden',
|
||||
],
|
||||
'asidePanels' => [
|
||||
'barSelector' => '.aside-icon-bar',
|
||||
'panelSelector' => '.app-sidebar-panel',
|
||||
'titleSelector' => '.app-sidebar-title',
|
||||
'toolsSelector' => '[data-aside-tools]',
|
||||
'storageNamespace' => 'app-ui',
|
||||
'storageVersion' => 'v1',
|
||||
'storageScope' => 'aside-panels',
|
||||
'detailsStorageScope' => 'aside-details',
|
||||
],
|
||||
'detailsAsideToggle' => [
|
||||
'buttonSelector' => '#toggle-main-content-aside',
|
||||
'asideSelector' => '#app-details-aside-section',
|
||||
'containerSelector' => '.app-details-container',
|
||||
'storageNamespace' => 'app-ui',
|
||||
'storageVersion' => 'v1',
|
||||
'storageScope' => 'details-aside',
|
||||
],
|
||||
'themeControls' => [
|
||||
'menuSelector' => '[data-theme-menu]',
|
||||
'toggleSelector' => '[data-theme-toggle]',
|
||||
],
|
||||
'contrastToggle' => [
|
||||
'selector' => '[data-contrast-toggle]',
|
||||
'storageNamespace' => 'app-ui',
|
||||
'storageVersion' => 'v1',
|
||||
'storageScope' => 'contrast',
|
||||
'storageKey' => 'contrast-mode',
|
||||
],
|
||||
'tenantSwitcher' => [
|
||||
'rootSelector' => '[data-tenant-switcher]',
|
||||
'linkSelector' => '[data-switch-tenant]',
|
||||
],
|
||||
'colorDefaultToggle' => [],
|
||||
'customFieldOptionsToggle' => [],
|
||||
'tenantSsoToggle' => [],
|
||||
'standardDetailPage' => [],
|
||||
'globalSearch' => [
|
||||
'inputSelector' => '#side-search',
|
||||
'resultsSelector' => '[data-global-search-results]',
|
||||
'appBase' => localeBase(),
|
||||
],
|
||||
'tabs' => [
|
||||
'selector' => '[data-app-component="tabs"]',
|
||||
'storageNamespace' => 'app-ui',
|
||||
'storageVersion' => 'v1',
|
||||
'storageScope' => 'tabs',
|
||||
],
|
||||
'sessionWarning' => [
|
||||
'enabled' => $isLoggedIn,
|
||||
'selector' => '[data-app-session-warning-dialog]',
|
||||
'idleSeconds' => $sessionIdleSeconds,
|
||||
'pingUrl' => $sessionPingUrl,
|
||||
'logoutUrl' => $sessionLogoutUrl,
|
||||
'csrfKey' => $csrfKey,
|
||||
'csrfToken' => $csrfToken,
|
||||
],
|
||||
'fslightboxRefresh' => [],
|
||||
],
|
||||
];
|
||||
$moduleRuntimeComponents = [];
|
||||
foreach ($moduleRuntimeComponentSlots as $slot) {
|
||||
if (!is_array($slot)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slotPermission = trim((string) ($slot['permission'] ?? ''));
|
||||
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slotKey = trim((string) ($slot['key'] ?? ''));
|
||||
$moduleId = trim((string) ($slot['module_id'] ?? ''));
|
||||
$scriptPath = ltrim(trim((string) ($slot['script'] ?? '')), '/');
|
||||
if ($slotKey === '' || $moduleId === '' || $scriptPath === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$phase = strtolower(trim((string) ($slot['phase'] ?? 'late')));
|
||||
if ($phase !== 'early' && $phase !== 'default' && $phase !== 'late') {
|
||||
throw new RuntimeException("Invalid runtime.component phase '{$phase}' for slot '{$slotKey}'.");
|
||||
}
|
||||
|
||||
$configPath = trim((string) ($slot['config_path'] ?? ''));
|
||||
if ($configPath === '') {
|
||||
$configPath = 'components.modules.' . $normalizeConfigPathSegment($moduleId) . '.' . $normalizeConfigPathSegment($slotKey);
|
||||
}
|
||||
|
||||
$defaultConfig = is_array($slot['default_config'] ?? null) ? $slot['default_config'] : [];
|
||||
if ($defaultConfig !== []) {
|
||||
$mergeConfigAtPath($componentPageConfig, $configPath, $defaultConfig, "runtime.component '{$slotKey}'", $strictMergeConfig);
|
||||
}
|
||||
|
||||
$moduleRuntimeComponents[] = [
|
||||
'key' => $slotKey,
|
||||
'moduleId' => $moduleId,
|
||||
'componentName' => $moduleId . '.' . $slotKey,
|
||||
'script' => assetVersion($scriptPath),
|
||||
'export' => trim((string) ($slot['export'] ?? '')),
|
||||
'selector' => trim((string) ($slot['selector'] ?? '')),
|
||||
'scope' => trim((string) ($slot['scope'] ?? '')),
|
||||
'configPath' => $configPath,
|
||||
'phase' => $phase,
|
||||
'order' => (int) ($slot['order'] ?? 100),
|
||||
];
|
||||
}
|
||||
|
||||
$componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents;
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
@@ -43,8 +297,8 @@ if ($bufferTitle !== '') {
|
||||
data-csrf-token="<?php e($csrfToken); ?>"
|
||||
<?php if ($isLoggedIn): ?>
|
||||
data-session-idle-seconds="<?php e((string) $sessionIdleSeconds); ?>"
|
||||
data-session-ping-url="<?php e(lurl('admin/session-ping/data')); ?>"
|
||||
data-session-logout-url="<?php e(lurl('logout')); ?>"
|
||||
data-session-ping-url="<?php e($sessionPingUrl); ?>"
|
||||
data-session-logout-url="<?php e($sessionLogoutUrl); ?>"
|
||||
<?php endif; ?>
|
||||
class="no-js"
|
||||
<?php if ($primaryVars !== ''): ?>style="<?php e($primaryVars); ?>" <?php endif; ?>
|
||||
@@ -62,6 +316,7 @@ if ($bufferTitle !== '') {
|
||||
<link rel="apple-touch-icon" href="<?php e(appFaviconUrl('apple-touch-icon.png')); ?>">
|
||||
<link rel="manifest" href="<?php e(asset('favicon/site.webmanifest')); ?>">
|
||||
<?php renderTemplateStyles('default'); ?>
|
||||
<?php renderStylePaths($moduleHeadStylePaths); ?>
|
||||
<?php renderPageStyles(); ?>
|
||||
</head>
|
||||
|
||||
@@ -86,10 +341,24 @@ if ($bufferTitle !== '') {
|
||||
<?php require __DIR__ . '/partials/app-confirm-dialog.phtml'; ?>
|
||||
<?php if ($isLoggedIn): ?>
|
||||
<?php require __DIR__ . '/partials/app-session-warning-dialog.phtml'; ?>
|
||||
<?php require __DIR__ . '/partials/app-bookmark-dialog.phtml'; ?>
|
||||
<script type="module" src="<?php e(assetVersion('js/components/app-session-warning.js')); ?>"></script>
|
||||
<script type="module" src="<?php e(assetVersion('js/components/app-bookmark-init.js')); ?>"></script>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($moduleBodyEndTemplateSlots as $slot): ?>
|
||||
<?php
|
||||
if (!is_array($slot)) {
|
||||
continue;
|
||||
}
|
||||
$slotPermission = trim((string) ($slot['permission'] ?? ''));
|
||||
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) {
|
||||
continue;
|
||||
}
|
||||
$slotTemplate = trim((string) ($slot['template'] ?? ''));
|
||||
if ($slotTemplate === '' || !is_file($slotTemplate)) {
|
||||
continue;
|
||||
}
|
||||
include $slotTemplate;
|
||||
?>
|
||||
<?php endforeach; ?>
|
||||
<script type="application/json" id="page-config-app-components"><?php gridJsonForJs($componentPageConfig); ?></script>
|
||||
<script type="module" src="<?php e(assetVersion('js/app-init.js')); ?>"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -12,6 +12,25 @@ $bufferTitle = trim((string) ob_get_clean());
|
||||
if ($bufferTitle !== '') {
|
||||
$pageTitle = $bufferTitle . ' - ' . $defaultTitle;
|
||||
}
|
||||
$loginComponentConfig = [
|
||||
'components' => [
|
||||
'flashAutoDismiss' => [
|
||||
'selector' => '.notice[data-flash-timeout]',
|
||||
],
|
||||
'passwordHints' => [
|
||||
'selector' => '[data-password-hints]',
|
||||
],
|
||||
'passwordToggle' => [
|
||||
'selector' => 'input[type="password"]',
|
||||
],
|
||||
'loginErrorFocus' => [
|
||||
'selector' => '.notice[data-variant="error"][role="alert"][tabindex="-1"]',
|
||||
],
|
||||
'loginSubmitLock' => [
|
||||
'selector' => 'form[data-login-submit-lock="1"]',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
@@ -48,6 +67,7 @@ if ($bufferTitle !== '') {
|
||||
</main>
|
||||
<?php require __DIR__ . '/partials/app-footer.phtml'; ?>
|
||||
<div id="gradient"></div>
|
||||
<script type="application/json" id="page-config-login-components"><?php gridJsonForJs($loginComponentConfig); ?></script>
|
||||
<script type="module" src="<?php e(assetVersion('js/app-login-init.js')); ?>"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ $timeouts = [
|
||||
];
|
||||
|
||||
?>
|
||||
<div class="flash-stack" role="status" aria-live="polite">
|
||||
<div class="flash-stack" data-app-component="flash-auto-dismiss" role="status" aria-live="polite">
|
||||
<?php foreach ($messages as $message) : ?>
|
||||
<?php $type = $message['type'] ?? 'info'; ?>
|
||||
<?php $timeout = $timeouts[$type] ?? 5000; ?>
|
||||
|
||||
@@ -51,14 +51,6 @@ $hasAdminPanel = layoutHasAdminPanel($layoutAuth);
|
||||
</button>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
<li>
|
||||
<button type="button" id="aside-tab-bookmarks" data-aside-target="bookmarks" role="tab"
|
||||
aria-selected="false" aria-controls="aside-panel-bookmarks"
|
||||
aria-label="<?php e(t('Bookmarks')); ?>"
|
||||
data-tooltip="<?php e(t('Bookmarks')); ?>" data-tooltip-pos="right">
|
||||
<i class="bi bi-bookmark-star"></i>
|
||||
</button>
|
||||
</li>
|
||||
<?php if ($hasAdminPanel): ?>
|
||||
<li>
|
||||
<button type="button" id="aside-tab-admin" data-aside-target="admin" role="tab" aria-selected="false"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Service\Docs\DocsCatalogService;
|
||||
use MintyPHP\Support\BookmarkUrlNormalizer;
|
||||
|
||||
$layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
|
||||
$canViewTenants = (bool) ($layoutAuth['can_view_tenants'] ?? false);
|
||||
@@ -255,6 +254,9 @@ $tenantName = trim((string) ($tenantAvatar['name'] ?? ''));
|
||||
$hasTenantAvatar = !empty($tenantAvatar['hasAvatar']);
|
||||
$csrfKey = trim((string) ($layoutNav['csrfKey'] ?? \MintyPHP\Session::$csrfSessionKey));
|
||||
$csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
||||
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
|
||||
$modulePanelSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleSlots['aside.tab_panel'] : [];
|
||||
$moduleSearchSlots = is_array($moduleSlots['search.resource_item'] ?? null) ? $moduleSlots['search.resource_item'] : [];
|
||||
?>
|
||||
<aside class="app-sidebar" id="app-sidebar" data-app-component="sidebar-toggle">
|
||||
<?php if ($currentTenant): ?>
|
||||
@@ -302,8 +304,6 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
||||
|
||||
<?php
|
||||
// ── Module-contributed aside panels (aside.tab_panel slot) ──
|
||||
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
|
||||
$modulePanelSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleSlots['aside.tab_panel'] : [];
|
||||
foreach ($modulePanelSlots as $panelSlot):
|
||||
if (!is_array($panelSlot)) { continue; }
|
||||
$panelKey = $panelSlot['key'] ?? '';
|
||||
@@ -357,17 +357,6 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
||||
<ul class="app-search-preview" data-search-preview></ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if ($canViewAddressBook): ?>
|
||||
<?php $addressBookSearch = navActive('address-book', true); ?>
|
||||
<li data-search-key="address-book" data-search-base="<?php e(lurl('address-book')); ?>">
|
||||
<a href="<?php e(lurl('address-book')); ?>" class="<?php e($addressBookSearch['class'] ?? ''); ?>"
|
||||
<?php echo $addressBookSearch['aria'] ?? ''; ?>>
|
||||
<span><?php e(t('Address book')); ?></span>
|
||||
<span class="badge" data-search-count>0</span>
|
||||
</a>
|
||||
<ul class="app-search-preview" data-search-preview></ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if ($canViewTenants): ?>
|
||||
<?php $tenantsSearch = navActive('admin/tenants', true); ?>
|
||||
<li data-search-key="tenants" data-search-base="<?php e(lurl('admin/tenants')); ?>">
|
||||
@@ -482,246 +471,49 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
||||
<ul class="app-search-preview" data-search-preview></ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$coreSearchKeys = [
|
||||
'users' => true,
|
||||
'tenants' => true,
|
||||
'departments' => true,
|
||||
'roles' => true,
|
||||
'permissions' => true,
|
||||
'scheduled-jobs' => true,
|
||||
'pages' => true,
|
||||
'docs' => true,
|
||||
'hotkeys' => true,
|
||||
'mail-log' => true,
|
||||
'api-audit' => true,
|
||||
'system-audit' => true,
|
||||
];
|
||||
foreach ($moduleSearchSlots as $searchSlot):
|
||||
if (!is_array($searchSlot)) { continue; }
|
||||
$slotKey = trim((string) ($searchSlot['key'] ?? ''));
|
||||
if ($slotKey === '' || isset($coreSearchKeys[$slotKey])) { continue; }
|
||||
$slotPermission = trim((string) ($searchSlot['permission'] ?? ''));
|
||||
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) { continue; }
|
||||
$slotLabel = trim((string) ($searchSlot['label'] ?? ''));
|
||||
if ($slotLabel === '') { continue; }
|
||||
$slotBase = trim((string) ($searchSlot['base_url'] ?? ''));
|
||||
if ($slotBase === '') { continue; }
|
||||
$slotHref = $slotBase;
|
||||
$slotActive = ['class' => '', 'aria' => ''];
|
||||
if (!str_starts_with($slotBase, '/') && !str_starts_with($slotBase, 'http://') && !str_starts_with($slotBase, 'https://')) {
|
||||
$slotHref = lurl($slotBase);
|
||||
$slotActive = navActive($slotBase, true);
|
||||
}
|
||||
?>
|
||||
<li data-search-key="<?php e($slotKey); ?>" data-search-base="<?php e($slotHref); ?>">
|
||||
<a href="<?php e($slotHref); ?>" class="<?php e($slotActive['class'] ?? ''); ?>"
|
||||
<?php echo $slotActive['aria'] ?? ''; ?>>
|
||||
<span><?php e(t($slotLabel)); ?></span>
|
||||
<span class="badge" data-search-count>0</span>
|
||||
</a>
|
||||
<ul class="app-search-preview" data-search-preview></ul>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$bookmarkData = is_array($layoutNav['bookmarks'] ?? null) ? $layoutNav['bookmarks'] : ['groups' => [], 'ungrouped' => []];
|
||||
$bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['groups'] : [];
|
||||
$bookmarkUngrouped = is_array($bookmarkData['ungrouped'] ?? null) ? $bookmarkData['ungrouped'] : [];
|
||||
$currentPath = BookmarkUrlNormalizer::canonicalizeRequestUri((string) ($_SERVER['REQUEST_URI'] ?? ''), localeBase());
|
||||
?>
|
||||
<nav id="aside-panel-bookmarks" class="app-sidebar-panel" data-app-component="bookmark-panel" data-aside-panel="bookmarks"
|
||||
data-aside-title="<?php e(t('Bookmarks')); ?>"
|
||||
data-aside-details-storage="aside-bookmarks-groups-v1"
|
||||
data-bookmark-reorder-url="<?php e(lurl('bookmarks/reorder-data')); ?>"
|
||||
data-bookmark-group-delete-url="<?php e(lurl('bookmarks/group-delete-data')); ?>"
|
||||
data-bookmark-delete-url="<?php e(lurl('bookmarks/delete-data')); ?>"
|
||||
data-bookmark-msg-group-delete-confirm="<?php e(t('Delete group and keep bookmarks?')); ?>"
|
||||
data-bookmark-msg-group-deleted="<?php e(t('Group deleted')); ?>"
|
||||
data-bookmark-msg-group-updated="<?php e(t('Group updated')); ?>"
|
||||
data-bookmark-msg-bookmark-delete-confirm="<?php e(t('Delete this bookmark?')); ?>"
|
||||
data-bookmark-msg-bookmark-deleted="<?php e(t('Bookmark deleted')); ?>"
|
||||
data-bookmark-msg-group-action-failed="<?php e(t('Group action failed')); ?>"
|
||||
data-bookmark-msg-bookmark-action-failed="<?php e(t('Bookmark action failed')); ?>"
|
||||
data-bookmark-msg-group-delete-failed="<?php e(t('Group delete failed')); ?>"
|
||||
data-bookmark-msg-bookmark-delete-failed="<?php e(t('Bookmark action failed')); ?>"
|
||||
data-bookmark-msg-reorder-saved="<?php e(t('Reorder saved')); ?>"
|
||||
data-bookmark-msg-reorder-failed="<?php e(t('Reorder failed')); ?>"
|
||||
data-bookmark-label-delete="<?php e(t('Delete')); ?>"
|
||||
role="tabpanel" aria-labelledby="aside-tab-bookmarks" hidden>
|
||||
<?php if (!$bookmarkGroups && !$bookmarkUngrouped): ?>
|
||||
<?php
|
||||
$emptyState = [
|
||||
'message' => t('No bookmarks yet'),
|
||||
'size' => 'compact',
|
||||
'align' => 'center',
|
||||
];
|
||||
require templatePath('partials/app-empty-state.phtml');
|
||||
?>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
$bookmarkRootItems = [];
|
||||
foreach ($bookmarkUngrouped as $bm) {
|
||||
$bmId = (int) ($bm['id'] ?? 0);
|
||||
if ($bmId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$bookmarkRootItems[] = [
|
||||
'type' => 'bookmark',
|
||||
'id' => $bmId,
|
||||
'sort_order' => (int) ($bm['sort_order'] ?? 0),
|
||||
'bookmark' => $bm,
|
||||
];
|
||||
}
|
||||
foreach ($bookmarkGroups as $group) {
|
||||
$gId = (int) ($group['id'] ?? 0);
|
||||
if ($gId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$bookmarkRootItems[] = [
|
||||
'type' => 'group',
|
||||
'id' => $gId,
|
||||
'sort_order' => (int) ($group['sort_order'] ?? 0),
|
||||
'group' => $group,
|
||||
];
|
||||
}
|
||||
usort($bookmarkRootItems, static function (array $a, array $b): int {
|
||||
$sortCmp = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0));
|
||||
if ($sortCmp !== 0) {
|
||||
return $sortCmp;
|
||||
}
|
||||
$typeCmp = strcmp((string) ($a['type'] ?? ''), (string) ($b['type'] ?? ''));
|
||||
if ($typeCmp !== 0) {
|
||||
return $typeCmp;
|
||||
}
|
||||
return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
|
||||
});
|
||||
$rootCount = count($bookmarkRootItems);
|
||||
?>
|
||||
<ul data-bookmark-root-list>
|
||||
<?php foreach ($bookmarkRootItems as $rootIndex => $rootItem): ?>
|
||||
<?php
|
||||
$rootType = (string) ($rootItem['type'] ?? '');
|
||||
$rootFirst = $rootIndex === 0;
|
||||
$rootLast = $rootIndex === ($rootCount - 1);
|
||||
?>
|
||||
<?php if ($rootType === 'bookmark'): ?>
|
||||
<?php
|
||||
$bm = is_array($rootItem['bookmark'] ?? null) ? $rootItem['bookmark'] : [];
|
||||
$bmId = (int) ($bm['id'] ?? 0);
|
||||
$bmName = trim((string) ($bm['name'] ?? ''));
|
||||
$bmUrl = trim((string) ($bm['url'] ?? ''));
|
||||
$bmCanonicalUrl = BookmarkUrlNormalizer::canonicalizeRelative($bmUrl);
|
||||
$bmActive = $bmCanonicalUrl !== '' && $currentPath === $bmCanonicalUrl;
|
||||
?>
|
||||
<li class="app-sidebar-bookmark-root-item app-sidebar-bookmark-item"
|
||||
data-bookmark-root-kind="bookmark"
|
||||
data-bookmark-root-id="<?php e($bmId); ?>"
|
||||
data-bookmark-id="<?php e($bmId); ?>"
|
||||
data-bookmark-name="<?php e($bmName); ?>"
|
||||
data-bookmark-group-id="">
|
||||
<a href="<?php e(lurl($bmUrl)); ?>"
|
||||
class="<?php e($bmActive ? 'active' : ''); ?>"
|
||||
<?php echo $bmActive ? 'aria-current="page"' : ''; ?>>
|
||||
<?php e($bmName); ?>
|
||||
</a>
|
||||
<details class="app-sidebar-bookmark-action-menu" data-bookmark-action-menu>
|
||||
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</summary>
|
||||
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Bookmark actions')); ?>">
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="edit">
|
||||
<?php e(t('Edit')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="move-up" data-bookmark-item-action-scope="root" <?php if ($rootFirst): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move bookmark up')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="move-down" data-bookmark-item-action-scope="root" <?php if ($rootLast): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move bookmark down')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-item-action="delete">
|
||||
<?php e(t('Delete bookmark')); ?>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
$group = is_array($rootItem['group'] ?? null) ? $rootItem['group'] : [];
|
||||
$gId = (int) ($group['id'] ?? 0);
|
||||
$gName = trim((string) ($group['name'] ?? ''));
|
||||
$gIcon = trim((string) ($group['icon'] ?? 'bi-folder'));
|
||||
$gBookmarks = is_array($group['bookmarks'] ?? null) ? $group['bookmarks'] : [];
|
||||
?>
|
||||
<li class="app-sidebar-group app-sidebar-bookmark-group app-sidebar-bookmark-root-item"
|
||||
data-bookmark-root-kind="group"
|
||||
data-bookmark-root-id="<?php e($gId); ?>"
|
||||
data-bookmark-group-id="<?php e($gId); ?>"
|
||||
data-bookmark-group-name="<?php e($gName); ?>"
|
||||
data-bookmark-group-icon="<?php e($gIcon); ?>">
|
||||
<div class="app-sidebar-bookmark-group-shell">
|
||||
<div class="app-sidebar-bookmark-group-header">
|
||||
<small class="app-sidebar-bookmark-group-label">
|
||||
<i class="bi <?php e($gIcon); ?>" aria-hidden="true"></i>
|
||||
<span><?php e($gName); ?></span>
|
||||
</small>
|
||||
<details class="app-sidebar-bookmark-action-menu app-sidebar-bookmark-group-menu" data-bookmark-action-menu>
|
||||
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</summary>
|
||||
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Group actions')); ?>">
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-group-action="edit">
|
||||
<?php e(t('Edit')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-group-action="move-up" <?php if ($rootFirst): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move group up')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-group-action="move-down" <?php if ($rootLast): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move group down')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-group-action="delete">
|
||||
<?php e(t('Delete group')); ?>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
<?php if ($gBookmarks): ?>
|
||||
<ul data-bookmark-group-bookmark-list>
|
||||
<?php $groupBookmarkCount = count($gBookmarks); ?>
|
||||
<?php foreach ($gBookmarks as $bookmarkIndex => $bm): ?>
|
||||
<?php
|
||||
$bmId = (int) ($bm['id'] ?? 0);
|
||||
$bmName = trim((string) ($bm['name'] ?? ''));
|
||||
$bmUrl = trim((string) ($bm['url'] ?? ''));
|
||||
$bmCanonicalUrl = BookmarkUrlNormalizer::canonicalizeRelative($bmUrl);
|
||||
$bmActive = $bmCanonicalUrl !== '' && $currentPath === $bmCanonicalUrl;
|
||||
$bookmarkFirst = $bookmarkIndex === 0;
|
||||
$bookmarkLast = $bookmarkIndex === ($groupBookmarkCount - 1);
|
||||
?>
|
||||
<li class="app-sidebar-bookmark-item"
|
||||
data-bookmark-id="<?php e($bmId); ?>"
|
||||
data-bookmark-name="<?php e($bmName); ?>"
|
||||
data-bookmark-group-id="<?php e($gId); ?>">
|
||||
<a href="<?php e(lurl($bmUrl)); ?>"
|
||||
class="<?php e($bmActive ? 'active' : ''); ?>"
|
||||
<?php echo $bmActive ? 'aria-current="page"' : ''; ?>>
|
||||
<?php e($bmName); ?>
|
||||
</a>
|
||||
<details class="app-sidebar-bookmark-action-menu" data-bookmark-action-menu>
|
||||
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</summary>
|
||||
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Bookmark actions')); ?>">
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="edit">
|
||||
<?php e(t('Edit')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="move-up" <?php if ($bookmarkFirst): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move bookmark up')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="move-down" <?php if ($bookmarkLast): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move bookmark down')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-item-action="delete">
|
||||
<?php e(t('Delete bookmark')); ?>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<dialog
|
||||
data-app-session-warning-dialog
|
||||
data-app-component="session-warning"
|
||||
aria-labelledby="app-session-warning-title"
|
||||
aria-describedby="app-session-warning-message"
|
||||
aria-live="assertive"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\BookmarkUrlNormalizer;
|
||||
|
||||
$user = $_SESSION['user'] ?? [];
|
||||
$accountUrl = accountUrl();
|
||||
@@ -22,26 +21,13 @@ if ($tenantLabel === '') {
|
||||
}
|
||||
$canSwitchTenant = is_array($currentTenant) && count($availableTenants) > 1;
|
||||
$allowUserTheme = allowUserTheme();
|
||||
|
||||
// Bookmark toggle: check if current page is already bookmarked
|
||||
$bmData = is_array($_SESSION['user_bookmarks'] ?? null) ? $_SESSION['user_bookmarks'] : ['groups' => [], 'ungrouped' => []];
|
||||
$bmCurrentUrl = BookmarkUrlNormalizer::canonicalizeRequestUri((string) ($_SERVER['REQUEST_URI'] ?? ''), localeBase());
|
||||
$bmExisting = null;
|
||||
foreach (($bmData['ungrouped'] ?? []) as $bm) {
|
||||
$bmUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($bm['url'] ?? ''));
|
||||
if ($bmUrl !== '' && $bmUrl === $bmCurrentUrl) { $bmExisting = $bm; break; }
|
||||
}
|
||||
if ($bmExisting === null) {
|
||||
foreach (($bmData['groups'] ?? []) as $grp) {
|
||||
foreach (($grp['bookmarks'] ?? []) as $bm) {
|
||||
$bmUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($bm['url'] ?? ''));
|
||||
if ($bmUrl !== '' && $bmUrl === $bmCurrentUrl) { $bmExisting = $bm; break 2; }
|
||||
}
|
||||
}
|
||||
}
|
||||
$layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
|
||||
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
|
||||
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
|
||||
$moduleTopbarSlots = is_array($moduleSlots['topbar.right_item'] ?? null) ? $moduleSlots['topbar.right_item'] : [];
|
||||
|
||||
?>
|
||||
<header class="app-header">
|
||||
<header class="app-header" data-app-component="nav-history">
|
||||
<nav class="app-topbar">
|
||||
<ul class="app-topbar-left">
|
||||
<li>
|
||||
@@ -55,7 +41,7 @@ if ($bmExisting === null) {
|
||||
<div class="app-topbar-center" aria-hidden="true"></div>
|
||||
<ul class="app-topbar-right">
|
||||
<?php if ($canSwitchTenant): ?>
|
||||
<li class="app-topbar-tenant-slot" data-tenant-switcher data-tooltip="<?php e(t('Tenant')); ?>" data-tooltip-pos="bottom">
|
||||
<li class="app-topbar-tenant-slot" data-app-component="tenant-switcher" data-tenant-switcher data-tooltip="<?php e(t('Tenant')); ?>" data-tooltip-pos="bottom">
|
||||
<details class="dropdown app-topbar-tenant-menu">
|
||||
<summary class="app-topbar-tenant-chip" aria-label="<?php e(t('Switch tenant')); ?>" title="<?php e($tenantLabel); ?>">
|
||||
<span class="app-topbar-tenant-name"><?php e($tenantLabel); ?></span>
|
||||
@@ -93,20 +79,24 @@ if ($bmExisting === null) {
|
||||
</details>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($moduleTopbarSlots as $slot): ?>
|
||||
<?php
|
||||
if (!is_array($slot)) {
|
||||
continue;
|
||||
}
|
||||
$slotPermission = trim((string) ($slot['permission'] ?? ''));
|
||||
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) {
|
||||
continue;
|
||||
}
|
||||
$slotTemplate = trim((string) ($slot['template'] ?? ''));
|
||||
if ($slotTemplate === '' || !is_file($slotTemplate)) {
|
||||
continue;
|
||||
}
|
||||
include $slotTemplate;
|
||||
?>
|
||||
<?php endforeach; ?>
|
||||
<li>
|
||||
<button type="button" data-bookmark-open-dialog
|
||||
aria-label="<?php e($bmExisting ? t('Edit bookmark') : t('Bookmarks')); ?>"
|
||||
data-tooltip="<?php e($bmExisting ? t('Edit bookmark') : t('Bookmarks')); ?>" data-tooltip-pos="bottom"
|
||||
<?php if ($bmExisting): ?>
|
||||
data-bookmark-existing-id="<?php e((int) ($bmExisting['id'] ?? 0)); ?>"
|
||||
data-bookmark-existing-name="<?php e(trim((string) ($bmExisting['name'] ?? ''))); ?>"
|
||||
data-bookmark-existing-group="<?php e($bmExisting['group_id'] ?? ''); ?>"
|
||||
<?php endif; ?>>
|
||||
<i class="bi <?php e($bmExisting ? 'bi-bookmark-check-fill' : 'bi-bookmark-plus'); ?>"></i>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<details class="dropdown app-topbar-user-menu"
|
||||
<details class="dropdown app-topbar-user-menu" data-app-component="theme-controls"
|
||||
<?php if ($allowUserTheme): ?>
|
||||
data-theme-menu
|
||||
data-theme-url="admin/users/theme"
|
||||
@@ -125,7 +115,7 @@ if ($bmExisting === null) {
|
||||
</button>
|
||||
</li>
|
||||
<li class="app-topbar-menu-item-detail-sidebar">
|
||||
<button type="button" id="toggle-main-content-aside" aria-label="<?php e(t('Toggle Detail Sidebar')); ?>">
|
||||
<button type="button" id="toggle-main-content-aside" data-app-component="details-aside-toggle" aria-label="<?php e(t('Toggle Detail Sidebar')); ?>">
|
||||
<?php e(t('Toggle Detail Sidebar')); ?>
|
||||
</button>
|
||||
</li>
|
||||
@@ -147,7 +137,7 @@ if ($bmExisting === null) {
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<li>
|
||||
<button type="button" data-contrast-toggle aria-label="<?php e(t('Toggle contrast')); ?>" aria-pressed="false">
|
||||
<button type="button" data-app-component="contrast-toggle" data-contrast-toggle aria-label="<?php e(t('Toggle contrast')); ?>" aria-pressed="false">
|
||||
<?php e(t('High contrast')); ?>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user