UNPKG

web-mojo

Version:

WEB-MOJO - A lightweight JavaScript framework for building data-driven web applications

1,546 lines (1,533 loc) 84.3 kB
import { V as View, W as WebApp, d as dataFormatter, M as Mustache } from "./chunks/WebApp-B2r2EDj7.js"; import { B, D, g, E, h, r, R, b, a, c, e, f } from "./chunks/WebApp-B2r2EDj7.js"; import { P as Page } from "./chunks/Page-tbcVc_Wl.js"; import { G as GroupList, T as ToastService, U as User, a as Group } from "./chunks/User-DI3U4oRV.js"; import { C, b as b2, M, e as e2, f as f2, g as g2, h as h2, i, d, c as c2 } from "./chunks/User-DI3U4oRV.js"; import { M as Member } from "./chunks/FilePreviewView-Crpalaos.js"; import { E as E2, i as i2, h as h3, p, r as r2, q, v, x, w, s, u, t, F, e as e3, am, an, A, I, z, y, C as C2, K, N, B as B2, H, J, D as D2, G, V, W, $, a0, Y, X, Z, _, a2, a4, a3, a1, L, c as c3, a5, a6, j, l, k, a8, a7, ab, a9, aa, P, ag, ak, ah, ai, aj, ac, ad, ae, al, af, Q, U, R as R2, O, S, g as g3, f as f3, m, o, n, d as d2, b as b3, a as a10, T, ao, as, ap, aq, ar } from "./chunks/FilePreviewView-Crpalaos.js"; import { T as TopNav } from "./chunks/TopNav-mQvYJp04.js"; import { T as TokenManager } from "./chunks/TokenManager-5HHjYzTo.js"; import Dialog from "./chunks/Dialog-eoLNdg-d.js"; import { default as default2 } from "./chunks/DataView-UjG66gmW.js"; import { F as F2, a as a11 } from "./chunks/FormView-fUbbKQQU.js"; import { W as W2 } from "./chunks/WebSocketClient-Dvl3AYx1.js"; class ResultsView extends View { constructor(options = {}) { super({ className: "search-results-view flex-grow-1 overflow-auto d-flex flex-column", template: ` <div class="flex-grow-1 overflow-auto"> {{#data.loading}} <div class="text-center p-4"> <div class="spinner-border spinner-border-sm text-muted" role="status"> <span class="visually-hidden">Loading...</span> </div> <div class="mt-2 small text-muted">{{data.loadingText}}</div> </div> {{/data.loading}} {{^data.loading}} {{#data.items}} <div class="simple-search-item position-relative" data-action="select-item" data-item-index="{{index}}"> {{{itemContent}}} <i class="bi bi-chevron-right position-absolute end-0 top-50 translate-middle-y me-3 text-muted"></i> </div> {{/data.items}} {{#data.showNoResults}} <div class="text-center p-4"> <i class="bi bi-search text-muted mb-2" style="font-size: 1.5rem;"></i> <div class="text-muted small">{{data.noResultsText}}</div> <button type="button" class="btn btn-link btn-sm mt-2 p-0" data-action="clear-search"> Clear search </button> </div> {{/data.showNoResults}} {{#data.showEmpty}} <div class="text-center p-4"> <i class="{{data.emptyIcon}} text-muted mb-2" style="font-size: 2rem;"></i> <div class="text-muted small mb-2">{{data.emptyText}}</div> {{#data.emptySubtext}} <div class="text-muted" style="font-size: 0.75rem;"> {{data.emptySubtext}} </div> {{/data.emptySubtext}} </div> {{/data.showEmpty}} {{/data.loading}} </div> {{#data.showResultsCount}} <div class="border-top bg-light p-2 text-center"> <small class="text-muted"> {{data.filteredCount}} of {{data.totalCount}} </small> </div> {{/data.showResultsCount}} `, ...options }); this.parentView = options.parentView; } async handleActionSelectItem(event, element) { event.preventDefault(); const itemIndex = parseInt(element.getAttribute("data-item-index")); if (this.parentView) { this.parentView.handleItemSelection(itemIndex); } } async handleActionClearSearch(event, _element) { event.preventDefault(); if (this.parentView) { this.parentView.clearSearch(); } } } class SimpleSearchView extends View { constructor(options = {}) { super({ className: "simple-search-view h-100 d-flex flex-column", template: ` <div class="p-3 border-bottom bg-light"> <div class="d-flex justify-content-between align-items-start mb-3"> <h6 class="text-muted fw-semibold mb-0"> {{#data.headerIcon}}<i class="{{data.headerIcon}} me-2"></i>{{/data.headerIcon}} {{{data.headerText}}} </h6> {{#data.showExitButton}} <button class="btn btn-link p-0 text-muted simple-search-exit-btn" type="button" data-action="exit-view" title="Exit" aria-label="Exit view"> <i class="bi bi-x-lg" aria-hidden="true"></i> </button> {{/data.showExitButton}} </div> <div class="position-relative"> <input type="text" class="form-control form-control-sm pe-5" placeholder="{{data.searchPlaceholder}}" value="{{data.searchValue}}" data-filter="live-search" data-filter-debounce="{{data.debounceMs}}" data-change-action="search-items"> <button class="btn btn-link p-0 position-absolute top-50 end-0 translate-middle-y me-2 text-muted simple-search-clear-btn" type="button" data-action="clear-search" title="Clear search" aria-label="Clear search"> <i class="bi bi-x-circle-fill" aria-hidden="true"></i> </button> </div> </div> <div data-container="results"></div> {{#data.showFooter}} <div class="p-3 border-top bg-light"> <small class="text-muted"> <i class="{{data.footerIcon}} me-1"></i> {{{data.footerContent}}} </small> </div> {{/data.showFooter}} `, ...options }); this.Collection = options.Collection; this.collection = options.collection; this.itemTemplate = options.itemTemplate || this.getDefaultItemTemplate(); this.searchFields = options.searchFields || ["name"]; this.collectionParams = { size: 25, ...options.collectionParams }; this.headerText = options.headerText || "Select Item"; this.headerIcon = options.headerIcon || "bi bi-list"; this.searchPlaceholder = options.searchPlaceholder || "Search..."; this.loadingText = options.loadingText || "Loading items..."; this.noResultsText = options.noResultsText || "No items match your search"; this.emptyText = options.emptyText || "No items available"; this.emptySubtext = options.emptySubtext || null; this.emptyIcon = options.emptyIcon || "bi bi-inbox"; this.footerContent = options.footerContent || null; this.footerIcon = options.footerIcon || "bi bi-info-circle"; this.showExitButton = options.showExitButton || false; this.searchValue = ""; this.filteredItems = []; this.loading = false; this.hasSearched = false; this.searchTimer = null; this.debounceMs = options.debounceMs || 800; this.resultsView = new ResultsView({ parentView: this }); if (!this.collection && this.Collection) { this.collection = new this.Collection(); } this.addChild(this.resultsView); } onInit() { if (this.collection) { this.setupCollection(); } if (this.collection && this.options.autoLoad !== false) { this.loadItems(); } } setupCollection() { Object.assign(this.collection.params, this.collectionParams); this.collection.on("fetch:success", () => { this.loading = false; this.updateFilteredItems(); }); this.collection.on("fetch:error", () => { this.loading = false; }); } async loadItems() { if (!this.collection) { console.warn("SimpleSearchView: No collection provided"); return; } this.loading = true; this.updateResultsView(); try { await this.collection.fetch(); this.updateFilteredItems(); } catch (error) { console.error("Error loading items:", error); const app = this.getApp(); app?.showError?.("Failed to load items. Please try again."); } finally { this.loading = false; this.updateFilteredItems(); } } updateFilteredItems() { if (!this.collection) { this.filteredItems = []; return; } const items = this.collection.toJSON(); if (!this.searchValue || !this.searchValue.trim()) { this.filteredItems = items; } else { const searchTerm = this.searchValue.toLowerCase().trim(); this.filteredItems = items.filter((item) => { return this.searchFields.some((field) => { const value = this.getNestedValue(item, field); return value && value.toString().toLowerCase().includes(searchTerm); }); }); } this.updateResultsView(); } getNestedValue(obj, path) { return path.split(".").reduce((current, key) => current?.[key], obj); } async getViewData() { return { searchValue: this.searchValue, showFooter: !!this.footerContent, showExitButton: this.showExitButton, debounceMs: this.debounceMs, // UI text headerText: this.headerText, headerIcon: this.headerIcon, searchPlaceholder: this.searchPlaceholder, footerContent: this.footerContent, footerIcon: this.footerIcon }; } updateResultsView() { if (!this.resultsView) return; const hasItems = this.collection && this.collection.length() > 0; const hasFilteredItems = this.filteredItems.length > 0; const hasSearchValue = this.searchValue.length > 0; const processedItems = this.filteredItems.map((item, index2) => { return { ...item, index: index2, itemContent: this.processItemTemplate(item) }; }); this.resultsView.data = { loading: this.loading, items: processedItems, showEmpty: !this.loading && !hasItems, showNoResults: !this.loading && hasItems && !hasFilteredItems && hasSearchValue, showResultsCount: !this.loading && hasItems, filteredCount: this.filteredItems.length, totalCount: this.collection?.restEnabled ? this.collection?.meta?.count || 0 : this.collection?.length() || 0, // UI text loadingText: this.loadingText, noResultsText: this.noResultsText, emptyText: this.emptyText, emptySubtext: this.emptySubtext, emptyIcon: this.emptyIcon }; this.resultsView.render(); } processItemTemplate(item) { let template = this.itemTemplate; template = template.replace(/\{\{(\w+)\}\}/g, (match, prop) => { return this.getNestedValue(item, prop) || ""; }); return template; } getDefaultItemTemplate() { return ` <div class="p-3 border-bottom"> <div class="fw-semibold text-dark">{{name}}</div> <small class="text-muted">{{id}}</small> </div> `; } async onPassThruActionSearchItems(event, element) { const searchValue = element.value || ""; console.log("search change..."); this.searchValue = searchValue; this.hasSearched = true; if (this.searchTimer) { clearTimeout(this.searchTimer); } this.performSearch(); } async performSearch() { const searchParams = { ...this.collectionParams }; if (this.searchValue && this.searchValue.length > 1) { searchParams.search = this.searchValue.trim(); } this.collection.setParams(searchParams, true); } handleItemSelection(itemIndex) { if (isNaN(itemIndex) || itemIndex < 0 || itemIndex >= this.filteredItems.length) { console.error("Invalid item index:", itemIndex); return; } const item = this.filteredItems[itemIndex]; const model = this.collection ? this.collection.get(item.id) : null; this.emit("item:selected", { item, model, index: itemIndex }); } /** * Set the collection for this search view */ setCollection(collection) { this.collection = collection; this.setupCollection(); return this; } /** * Set the item template */ setItemTemplate(template) { this.itemTemplate = template; this.updateResultsView(); return this; } /** * Set search fields */ setSearchFields(fields) { this.searchFields = Array.isArray(fields) ? fields : [fields]; return this; } /** * Refresh items list */ async refresh() { await this.loadItems(); } /** * Focus the search input */ focusSearch() { const searchInput = this.element?.querySelector('input[data-action="search-items"]'); if (searchInput) { searchInput.focus(); } } /** * Handle exit button click - emits event instead of closing */ async handleActionExitView(event, element) { this.emit("exit", { view: this }); } /** * Clear search and reset */ async handleActionClearSearch(event, element) { this.clearSearch(); } clearSearch() { this.searchValue = ""; this.hasSearched = false; const searchInput = this.element?.querySelector('input[data-change-action="search-items"]'); if (searchInput) { searchInput.value = ""; searchInput.focus(); } this.performSearch(); } /** * Get the number of available items */ getItemCount() { return this.collection ? this.collection.length() : 0; } /** * Get the number of filtered items */ getFilteredItemCount() { return this.filteredItems.length; } /** * Check if items are loaded */ hasItems() { return this.getItemCount() > 0; } /** * Get current search value */ getSearchValue() { return this.searchValue; } /** * Set search value programmatically */ setSearchValue(value) { this.searchValue = value || ""; this.hasSearched = !!this.searchValue; const searchInput = this.element?.querySelector('input[data-action="search-items"]'); if (searchInput) { searchInput.value = this.searchValue; } this.performSearch(); return this; } async onAfterRender() { await super.onAfterRender(); if (this.resultsView && !this.resultsView.isMounted()) { const container = this.element?.querySelector('[data-container="results"]'); if (container) { await this.resultsView.render(true, container); } } this.updateResultsView(); } /** * Cleanup on destroy */ async onBeforeDestroy() { if (this.searchTimer) { clearTimeout(this.searchTimer); } if (this.collection) { this.collection.off("update"); } await super.onBeforeDestroy(); } } class Sidebar extends View { constructor(options = {}) { super({ tagName: "nav", className: "sidebar", id: "sidebar", ...options }); this.menus = /* @__PURE__ */ new Map(); this.activeMenuName = null; this.currentRoute = null; this.showToggle = options.showToggle; this.isCollapsed = false; this.sidebarTheme = options.theme || "sidebar-light"; this.customView = null; if (this.sidebarTheme) { this.addClass(this.sidebarTheme); } this.initializeMenus(options); this.setupRouteListeners(); if (options.autoCollapseMobile !== false) { this.setupResponsiveBehavior(); } } /** * Initialize sidebar and auto-switch to correct menu based on current route */ async onInit() { await super.onInit(); const app = this.getApp(); const router = app?.router; if (router) { const currentPath = router.getCurrentPath(); if (currentPath) { this.autoSwitchToMenuForRoute(currentPath); } } this.initializeTooltips(); this.searchView = new SimpleSearchView({ noAppend: true, showExitButton: true, headerText: "Select Group", containerId: "sidebar-search-container", Collection: GroupList, itemTemplate: ` <div class="p-3 border-bottom"> <div class="fw-semibold text-dark">{{name}}</div> <small class="text-muted">#{{id}} {{kind}}</small> </div> ` }); this.addChild(this.searchView); this.searchView.on("item:selected", (evt) => { console.log(evt); this.getApp().setActiveGroup(evt.model); }); this.searchView.on("exit", (item) => { console.log(item); this.hideGroupSearch(); }); } showGroupSearch() { this.setClass("sidebar"); this.showSearch = true; this.render(); } hideGroupSearch() { this.setClass("sidebar"); this.showSearch = false; this.render(); } onActionShowGroupSearch() { this.showGroupSearch(); } /** * Find and switch to the menu that contains the given route */ autoSwitchToMenuForRoute(route) { for (const [menuName, menuConfig] of this.menus) { if (menuConfig.groupKind && !this.getApp().activeGroup) continue; if (this.menuContainsRoute(menuConfig, route)) { this._setActiveMenu(menuName); this.currentRoute = route; this.clearAllActiveStates(); this.setActiveItemByRoute(route); this.render(); console.log(`Auto-switched to menu '${menuName}' for route '${route}'`); this.emit("menu-auto-switched", { menuName, route, config: menuConfig, sidebar: this }); return true; } } return false; } /** * Clear active state from all menu items in all menus */ clearAllActiveStates() { for (const [menuName, menuConfig] of this.menus) { for (const item of menuConfig.items || []) { item.active = false; if (item.children) { for (const child of item.children) { child.active = false; } } } } } /** * Set active state for item matching the given route */ setActiveItemByRoute(route) { const normalizeRoute = (r3) => { if (!r3) return "/"; const decoded = decodeURIComponent(r3); return decoded.startsWith("/") ? decoded : `/${decoded}`; }; const targetRoute = normalizeRoute(route); for (const [menuName, menuConfig] of this.menus) { if (menuConfig.groupKind && !this.getApp().activeGroup) continue; for (const item of menuConfig.items || []) { if (item.route) { const itemRoute = normalizeRoute(item.route); if (this.routesMatch(targetRoute, itemRoute)) { item.active = true; this.activeMenuItem = item; return true; } } if (item.children) { for (const child of item.children) { if (child.route) { const childRoute = normalizeRoute(child.route); if (this.routesMatch(targetRoute, childRoute)) { child.active = true; item.active = true; return true; } } } } } } return false; } /** * Check if a menu contains a specific route in its items or children */ menuContainsRoute(menuConfig, route) { const normalizeRoute = (r3) => { if (!r3) return "/"; const decoded = decodeURIComponent(r3); return decoded.startsWith("/") ? decoded : `/${decoded}`; }; const targetRoute = normalizeRoute(route); for (const item of menuConfig.items || []) { if (item.route) { const itemRoute = normalizeRoute(item.route); if (this.routesMatch(targetRoute, itemRoute)) { return true; } } if (item.children) { for (const child of item.children) { if (child.route) { const childRoute = normalizeRoute(child.route); if (this.routesMatch(targetRoute, childRoute)) { return true; } } } } } return false; } /** * Check if two routes match (using same logic as isItemActive) */ routesMatch(currentRoute, itemRoute) { return this.getApp().router.doRoutesMatch(currentRoute, itemRoute); } getTemplate() { if (this.customView) { return '<div class="sidebar-container" id="sidebar-custom-view-container"></div>'; } if (this.showSearch) return this.getSearchTemplate(); return this.getMenuTemplate(); } getSearchTemplate() { return ` <div class="sidebar-container" id="sidebar-search-container"> </div> `; } getMenuTemplate() { return ` <div class="sidebar-container"> {{#data.currentMenu}} <!-- Header --> {{#header}} <div class="sidebar-header"> {{{header}}} {{#showToggle}} <button class="sidebar-toggle" data-action="toggle-sidebar" aria-label="Toggle Sidebar"> <i class="bi bi-chevron-left toggle-icon"></i> <i class="bi bi-chevron-right toggle-icon"></i> </button> {{/showToggle}} </div> {{/header}} <!-- Navigation Items --> <div class="sidebar-body"> <ul class="nav nav-pills flex-column sidebar-nav" id="sidebar-nav-menu"> {{#items}} {{>nav-item}} {{/items}} </ul> </div> <!-- Footer --> {{#footer}} <div class="sidebar-footer"> {{{footer}}} </div> {{/footer}} {{/data.currentMenu}} {{^data.currentMenu}} <div class="sidebar-empty"> <p class="text-danger text-center">No menu configured</p> </div> {{/data.currentMenu}} </div> `; } /** * Get template partials for rendering */ getPartials() { return { "nav-item": ` {{#isDivider}} {{>nav-divider}} {{/isDivider}} {{#isSpacer}} {{>nav-spacer}} {{/isSpacer}} {{#isLabel}} {{>nav-label}} {{/isLabel}} {{^isDivider}} {{^isSpacer}} {{^isLabel}} <li class="nav-item"> {{#hasChildren}} <!-- Item with submenu --> <a class="nav-link {{#active}}active{{/active}} has-children collapsed" data-bs-toggle="collapse" href="#collapse-{{id}}" role="button" aria-expanded="{{#active}}true{{/active}}{{^active}}false{{/active}}" data-action="toggle-submenu"> {{#icon}}<i class="{{icon}} me-2"></i>{{/icon}} <span class="nav-text">{{text}}</span> {{#badge}} <span class="{{badge.class}} ms-auto">{{badge.text}}</span> {{/badge}} <i class="bi bi-chevron-down nav-arrow ms-auto"></i> </a> <div class="collapse {{#active}}show{{/active}}" id="collapse-{{id}}" data-bs-parent="#sidebar-nav-menu"> <ul class="nav flex-column nav-submenu"> {{#children}} <li class="nav-item"> <a class="nav-link {{#active}}active{{/active}}" {{#action}}data-action="{{action}}"{{/action}} {{#href}}href="{{href}}"{{/href}}> {{#icon}}<i class="{{icon}} me-2"></i>{{/icon}} <span class="nav-text">{{text}}</span> {{#badge}} <span class="{{badge.class}} ms-auto">{{badge.text}}</span> {{/badge}} </a> </li> {{/children}} </ul> </div> {{/hasChildren}} {{^hasChildren}} <!-- Simple item --> <a class="nav-link {{#active}}active{{/active}} {{#disabled}}disabled{{/disabled}}" {{#action}}{{^disabled}}data-action="{{action}}"{{/disabled}}{{/action}} {{#href}}{{^disabled}}href="{{href}}"{{/disabled}}{{/href}}> {{#icon}}<i class="{{icon}} me-2"></i>{{/icon}} <span class="nav-text">{{text}}</span> {{#badge}} <span class="{{badge.class}} ms-auto">{{badge.text}}</span> {{/badge}} </a> {{/hasChildren}} </li> {{/isLabel}} {{/isSpacer}} {{/isDivider}} `, "nav-divider": ` <li class="nav-divider-item"> <hr class="nav-divider-line"> </li> `, "nav-spacer": ` <li class="nav-spacer-item"></li> `, "nav-label": ` <li class="nav-item {{className}}"> <div class="nav-text px-3">{{text}}</div> </li> ` }; } getGroupHeader() { return ` <div class="sidebar-group-header py-3" data-action="show-group-search"> <div class='text-center text-muted fs-7 collapsed-hidden'>active group</div> <div class='text-center fs-5 px-1 collapsed-hidden'>{{group.name}}</div> <div class='text-center fs-6 collapsed-hidden'>kind: {{group.kind}}</div> </div> `; } /** * Add a menu configuration */ addMenu(name, config) { if (config.groupKind && !config.header) { config.header = this.getGroupHeader(); } this.menus.set(name, { name, groupKind: config.groupKind || null, header: config.header || null, footer: config.footer || null, items: config.items || [], data: config.data || {}, className: config.className || "sidebar sidebar-dark" }); if (!this.activeMenuName) { this._setActiveMenu(name); } return this; } _setActiveMenu(name) { this.showSearch = false; this.activeMenuName = name; const config = this.getCurrentMenuConfig(); if (config.className) { this.setClass(config.className); } else { this.setClass("sidebar"); } } /** * Set the active menu */ async setActiveMenu(name) { if (!this.menus.has(name)) { console.warn(`Menu '${name}' not found`); return this; } const menuConfig = this.menus.get(name); if (menuConfig.groupKind) { this.lastGroupMenu = menuConfig; if (!this.getApp().activeGroup) { this.showGroupSearch(); return; } } this._setActiveMenu(name); await this.render(); this.emit("menu-changed", { menuName: name, config: menuConfig, sidebar: this }); return this; } getGroupMenu(group) { if (!group) { console.warn("No group provided"); return null; } let targetMenu = this.lastGroupMenu; let anyGroupMenu = null; if (group._.kind) { for (const [menuName, menuConfig] of this.menus) { if (menuConfig.groupKind === group._.kind) { targetMenu = menuConfig; break; } else if (menuConfig.groupKind === "any") { anyGroupMenu = menuConfig; } } } if (!targetMenu) { return anyGroupMenu; } return targetMenu; } showMenuForGroup(group) { if (!group) { console.warn("No group provided"); return; } let targetMenu = this.getGroupMenu(group); if (!targetMenu) { console.warn(`No menu found for group kind: ${group.kind}`); return; } this._setActiveMenu(targetMenu.name); this.render(); this.emit("menu-changed", { menuName: targetMenu.name, config: targetMenu, sidebar: this }); return this; } /** * Get menu configuration */ getMenuConfig(name) { return this.menus.get(name) || null; } /** * Get current active menu configuration */ getCurrentMenuConfig() { return this.activeMenuName ? this.menus.get(this.activeMenuName) : null; } /** * Update menu configuration */ updateMenu(name, updates) { const menu = this.menus.get(name); if (!menu) { console.warn(`Menu '${name}' not found`); return this; } Object.assign(menu, updates); if (this.activeMenuName === name) { this.render(); } return this; } /** * Remove a menu */ removeMenu(name) { this.menus.delete(name); if (this.activeMenuName === name) { const remainingMenus = Array.from(this.menus.keys()); this.activeMenuName = remainingMenus.length > 0 ? remainingMenus[0] : null; this.render(); } return this; } /** * Get view data for template rendering */ async onBeforeRender() { const currentMenu = this.getCurrentMenuConfig(); if (!currentMenu) { return { currentMenu: null }; } let subData = { version: this.getApp().version || null, group: this.getApp().activeGroup || null, user: this.getApp.activeUser || null }; this.data = { currentMenu: { header: this.renderTemplateString(currentMenu.header || "", subData), footer: this.renderTemplateString(currentMenu.footer || "", subData), items: this.processNavItems(currentMenu.items, currentMenu.groupKind), data: currentMenu.data, showToggle: this.showToggle } }; } async onAfterRender() { if (this.isCollapsedState()) { setTimeout(() => this.initializeTooltips(), 50); } else { this.destroyTooltips(); } } setCustomView(view) { if (this.customView) { this.removeChild(this.customView.id); } this.customView = view; if (view) { view.containerId = "sidebar-custom-view-container"; this.addChild(view); } this.render(); return this; } clearCustomView() { if (this.customView) { this.removeChild(this.customView.id); this.customView = null; } this.render(); return this; } /** * Process navigation items - add IDs, active states, and proper hrefs */ processNavItems(items, groupKind) { const app = this.getApp(); const activeUser = app?.activeUser; const activeGroup = app?.activeGroup; const updateRouteWithGroup = (route) => { if (groupKind && activeGroup && activeGroup.id) { const separator = route.includes("?") ? "&" : "?"; return `${route}${separator}group=${activeGroup.id}`; } return route; }; return items.map((item, index2) => { if (item === "" || typeof item === "object" && item.divider) { return { isDivider: true, id: `divider-${index2}` }; } if (typeof item === "object" && item.spacer) { return { isSpacer: true, id: `spacer-${index2}` }; } const processedItem = { ...item }; if (processedItem.permissions) { if (!activeUser || !activeUser.hasPermission(processedItem.permissions)) { return null; } } if (processedItem.kind === "label") { processedItem.isLabel = true; if (!processedItem.id) { processedItem.id = `nav-label-${index2}`; } return processedItem; } if (!processedItem.id) { processedItem.id = `nav-${index2}`; } if (processedItem.route) { processedItem.href = updateRouteWithGroup(processedItem.route); } else if (processedItem.page) { const baseRoute = processedItem.page.startsWith("/") ? processedItem.page : `/${processedItem.page}`; processedItem.href = updateRouteWithGroup(baseRoute); processedItem.route = processedItem.href; } if (processedItem.children) { processedItem.children = processedItem.children.map((child) => { const processedChild = { ...child }; if (processedChild.permissions && activeUser) { if (!activeUser.hasPermission(processedChild.permissions)) { return null; } } if (processedChild.route) { processedChild.href = updateRouteWithGroup(processedChild.route); } else if (processedChild.page) { const baseRoute = processedChild.page.startsWith("/") ? processedChild.page : `/${processedChild.page}`; processedChild.href = updateRouteWithGroup(baseRoute); processedChild.route = processedChild.href; } return processedChild; }).filter((child) => child !== null); processedItem.hasChildren = !!(processedItem.children && processedItem.children.length > 0); } else { processedItem.hasChildren = false; } return processedItem; }).filter((item) => item !== null); } /** * Check if navigation item should be active (similar to TopNav) */ isItemActive(item) { if (!item.route || !this.currentRoute) { return false; } const normalizeRoute = (route) => { if (!route) return "/"; const decoded = decodeURIComponent(route); return decoded.startsWith("/") ? decoded : `/${decoded}`; }; const itemRoute = normalizeRoute(item.route); const currentRoute = normalizeRoute(this.currentRoute); if (itemRoute === "/" && currentRoute === "/") { return true; } if (itemRoute !== "/" && currentRoute !== "/") { return currentRoute.startsWith(itemRoute) || currentRoute === itemRoute; } return false; } /** * Update active item based on current route (like TopNav) */ async updateActiveItem(route) { this.currentRoute = route; this.clearAllActiveStates(); this.setActiveItemByRoute(route); await this.render(); return this; } /** * Action handler: Toggle submenu */ async handleActionToggleSubmenu(event, element) { const arrow = element.querySelector(".nav-arrow"); if (arrow) { arrow.classList.toggle("rotated"); } } /** * Action handler: Toggle sidebar collapsed/expanded state */ async handleActionToggleSidebar(event, element) { this.toggleSidebar(); } onActionShowGroupMenu(action, event, el) { this.setActiveMenu("group_default"); return false; } async onActionDefault(action, event, el) { const config = this.getCurrentMenuConfig(); if (!config) return; for (const item of config.items) { if (item.action == action && item.handler) { item.handler(action, event, el); return true; } } return false; } /** * Get all menu names */ getMenuNames() { return Array.from(this.menus.keys()); } /** * Check if menu exists */ hasMenu(name) { return this.menus.has(name); } /** * Clear all menus */ clearMenus() { this.menus.clear(); this.activeMenuName = null; this.render(); return this; } /** * Set data for current menu */ setMenuData(data) { const currentMenu = this.getCurrentMenuConfig(); if (currentMenu) { currentMenu.data = { ...currentMenu.data, ...data }; this.render(); } return this; } /** * Get data for current menu */ getMenuData() { const currentMenu = this.getCurrentMenuConfig(); return currentMenu ? currentMenu.data : {}; } /** * Setup listeners for route change events (like TopNav) */ setupRouteListeners() { const app = this.getApp(); if (app && app.events) { app.events.on(["page:show", "page:hide", "page:denied"], (data) => { this.onRouteChanged(data); }); app.events.on("group:changed", (data) => { this.showMenuForGroup(data.group); }); app.events.on("portal:user-changed", (data) => { this.render(); }); } } /** * Handle route changed event - auto-switch menu and update active item */ onRouteChanged(data) { if (data.page && data.page.route) { const route = data.page.route; if (this.activeMenuItem && this.routesMatch(route, this.activeMenuItem.route)) { return; } const switchedMenu = this.autoSwitchToMenuForRoute(route); if (!switchedMenu) { this.clearAllActiveStates(); this.setActiveItemByRoute(route); this.updateActiveItem(route); } if (switchedMenu) { console.log(`Route changed to '${route}', auto-switched menu`); } } } /** * Toggle sidebar between collapsed and expanded states */ toggleSidebar() { const portalContainer = document.querySelector(".portal-container"); if (!portalContainer) return; this.hideAllTooltips(); const isCurrentlyCollapsed = portalContainer.classList.contains("collapse-sidebar"); const isCurrentlyHidden = portalContainer.classList.contains("hide-sidebar"); if (isCurrentlyHidden) { portalContainer.classList.remove("hide-sidebar"); this.isCollapsed = false; this.destroyTooltips(); } else if (isCurrentlyCollapsed) { portalContainer.classList.remove("collapse-sidebar"); this.isCollapsed = false; this.destroyTooltips(); } else { portalContainer.classList.add("collapse-sidebar"); this.isCollapsed = true; setTimeout(() => this.initializeTooltips(), 150); } return this; } /** * Set sidebar state programmatically */ setSidebarState(state) { const portalContainer = document.querySelector(".portal-container"); if (!portalContainer) return this; portalContainer.classList.remove("collapse-sidebar", "hide-sidebar"); switch (state) { case "collapsed": portalContainer.classList.add("collapse-sidebar"); this.isCollapsed = true; break; case "hidden": portalContainer.classList.add("hide-sidebar"); this.isCollapsed = false; break; case "normal": default: this.isCollapsed = false; break; } if (this.isCollapsed) { this.hideAllTooltips(); setTimeout(() => this.initializeTooltips(), 100); } else { this.destroyTooltips(); } return this; } /** * Initialize tooltips for nav items when sidebar is collapsed */ initializeTooltips() { this.destroyTooltips(); if (!this.isCollapsedState()) { return this; } const navLinks = this.element.querySelectorAll(".sidebar-nav .nav-link"); navLinks.forEach((link) => { const navText = link.querySelector(".nav-text"); if (navText && navText.textContent.trim()) { const tooltipText = navText.textContent.trim(); link.setAttribute("data-bs-toggle", "tooltip"); link.setAttribute("data-bs-placement", "right"); link.setAttribute("data-bs-title", tooltipText); link.setAttribute("data-bs-container", "body"); if (window.bootstrap && window.bootstrap.Tooltip) { const tooltip = new window.bootstrap.Tooltip(link, { placement: "right", container: "body", trigger: "hover", delay: { show: 500, hide: 100 }, fallbackPlacements: ["top", "bottom", "left"] }); link._tooltipInstance = tooltip; link.addEventListener("click", () => { tooltip.hide(); }); link.addEventListener("blur", () => { tooltip.hide(); }); } } }); this.addTooltipHideListeners(); return this; } destroyTooltips() { this.removeTooltipHideListeners(); const navLinks = this.element.querySelectorAll('.sidebar-nav .nav-link[data-bs-toggle="tooltip"]'); navLinks.forEach((link) => { const tooltipInstance = link._tooltipInstance || window.bootstrap?.Tooltip?.getInstance(link); if (tooltipInstance) { tooltipInstance.hide(); tooltipInstance.dispose(); } delete link._tooltipInstance; link.removeAttribute("data-bs-toggle"); link.removeAttribute("data-bs-placement"); link.removeAttribute("data-bs-title"); link.removeAttribute("data-bs-container"); }); return this; } /** * Get current sidebar state */ getSidebarState() { const portalContainer = document.querySelector(".portal-container"); if (!portalContainer) return "normal"; if (portalContainer.classList.contains("hide-sidebar")) { return "hidden"; } else if (portalContainer.classList.contains("collapse-sidebar")) { return "collapsed"; } else { return "normal"; } } /** * Check if sidebar is collapsed */ isCollapsedState() { return this.getSidebarState() === "collapsed"; } /** * Enable/disable toggle button */ setToggleEnabled(enabled) { this.showToggle = enabled; this.render(); return this; } /** * Initialize menus from options */ initializeMenus(options) { if (options.menus) { for (const menu of options.menus) { this.addMenu(menu.name, menu); } } else if (options.menu) { options.menu.name = options.menu.name || "default"; this.addMenu(options.menu.name, options.menu); } } /** * Add global listeners to hide tooltips when needed */ addTooltipHideListeners() { this._tooltipScrollHandler = () => this.hideAllTooltips(); this.element.addEventListener("scroll", this._tooltipScrollHandler, { passive: true }); this._tooltipRouteHandler = () => this.hideAllTooltips(); this.getApp(); this._tooltipBlurHandler = () => this.hideAllTooltips(); window.addEventListener("blur", this._tooltipBlurHandler); this._tooltipEscapeHandler = (e4) => { if (e4.key === "Escape") { this.hideAllTooltips(); } }; document.addEventListener("keydown", this._tooltipEscapeHandler); } /** * Remove global tooltip hide listeners */ removeTooltipHideListeners() { if (this._tooltipScrollHandler) { this.element.removeEventListener("scroll", this._tooltipScrollHandler); delete this._tooltipScrollHandler; } if (this._tooltipBlurHandler) { window.removeEventListener("blur", this._tooltipBlurHandler); delete this._tooltipBlurHandler; } if (this._tooltipEscapeHandler) { document.removeEventListener("keydown", this._tooltipEscapeHandler); delete this._tooltipEscapeHandler; } } /** * Force hide all visible tooltips */ hideAllTooltips() { const navLinks = this.element.querySelectorAll('.sidebar-nav .nav-link[data-bs-toggle="tooltip"]'); navLinks.forEach((link) => { const tooltip = link._tooltipInstance || window.bootstrap?.Tooltip?.getInstance(link); if (tooltip) { tooltip.hide(); } }); const visibleTooltips = document.querySelectorAll(".tooltip.show"); visibleTooltips.forEach((tooltip) => { tooltip.remove(); }); } /** * Cleanup on destroy */ async onBeforeDestroy() { this.destroyTooltips(); await super.onBeforeDestroy(); } /** * Setup responsive behavior for mobile */ setupResponsiveBehavior() { const checkMobile = () => { const isMobile = window.innerWidth <= 768; const portalContainer = document.querySelector(".portal-container"); if (portalContainer) { if (isMobile) { portalContainer.classList.add("sidebar-mobile"); } else { portalContainer.classList.remove("sidebar-mobile", "sidebar-open"); } } }; checkMobile(); window.addEventListener("resize", checkMobile); } /** * Static method to create a sidebar with common configuration */ static createDefault(options = {}) { return new Sidebar({ theme: "sidebar-clean", showToggle: true, autoCollapseMobile: true, ...options }); } /** * Static method to create a minimal sidebar */ static createMinimal(options = {}) { return new Sidebar({ theme: "sidebar-clean", showToggle: false, autoCollapseMobile: false, ...options }); } /** * Set sidebar theme */ setSidebarTheme(theme) { this.removeClass("sidebar-light sidebar-dark sidebar-clean"); this.sidebarTheme = theme; this.addClass(theme); return this; } /** * Quick method to show/hide the sidebar */ show() { return this.setSidebarState("normal"); } hide() { return this.setSidebarState("hidden"); } collapse() { return this.setSidebarState("collapsed"); } expand() { return this.setSidebarState("normal"); } /** * Add pulse effect to toggle button */ pulseToggle() { const toggleButton = this.element.querySelector(".sidebar-toggle"); if (toggleButton) { toggleButton.classList.add("pulse"); const removePulse = () => { toggleButton.classList.remove("pulse"); toggleButton.removeEventListener("click", removePulse); }; toggleButton.addEventListener("click", removePulse, { once: true }); setTimeout(removePulse, 3e3); } return this; } /** * Utility method to quickly add a simple menu item */ addSimpleMenuItem(menuName, text, route, icon = "bi-circle") { const menu = this.menus.get(menuName); if (menu) { menu.items = menu.items || []; menu.items.push({ text, route, icon }); if (this.activeMenuName === menuName) { this.render(); } } return this; } /** * Utility method to quickly create and set a simple menu */ setSimpleMenu(name, header, items) { const menu = { name, header, items }; this.addMenu(name, menu); this.setActiveMenu(name); return this; } } class DeniedPage extends Page { constructor(options = {}) { super({ pageName: "Access Denied", route: "/denied", title: "Access Denied", pageIcon: "bi bi-shield-x", template: ` <div class="container mt-5"> <div class="row justify-content-center"> <div class="col-md-8 col-lg-6"> <div class="text-center mb-4"> <i class="bi bi-shield-x text-muted" style="font-size: 3rem;"></i> <h2 class="mt-3 mb-2">Access Denied</h2> <p class="text-muted">You don't have permission to access this page.</p> </div> {{#deniedPage}} <div class="card border-0 shadow-sm mb-4"> <div class="card-body"> <h6 class="card-subtitle mb-2 text-muted">Requested Page</h6> <h5 class="card-title"> <i class="{{pageIcon}} me-2"></i> {{displayName}} </h5> {{#route}} <p class="card-text text-muted small">{{route}}</p> {{/route}} {{#description}} <p class="card-text">{{description}}</p> {{/description}} {{#requiredPermissions}} <div class="mt-3"> <h6 class="mb-2">Required Permissions:</h6> {{#permissions}} <span class="badge bg-light text-dark me-1 mb-1">{{.}}</span> {{/permissions}} {{^permissions}} <span class="text-muted small">Authentication required</span> {{/permissions}} </div> {{/requiredPermissions}} </div> </div> {{/deniedPage}} <div class="d-grid gap-2 d-md-flex justify-content-md-center"> <button type="button" class="btn btn-primary" data-action="go-back"> <i class="bi bi-arrow-left me-1"></i> Go Back </button> <button type="button" class="btn btn-outline-secondary" dat