web-mojo
Version:
WEB-MOJO - A lightweight JavaScript framework for building data-driven web applications
1,546 lines (1,533 loc) • 84.3 kB
JavaScript
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