Files

408 lines
11 KiB
TypeScript
Raw Permalink Normal View History

import {
customElement,
DeesElement,
property,
html,
cssManager,
unsafeCSS,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
import * as states from '../../states/accountstate.js';
import { IdpState } from '../../states/idp.state.js';
import { accountDesignTokens } from './sharedstyles.js';
import { CreateOrgModal } from './create-org-modal.js';
import { OrgSelectModal } from './org-select-modal.js';
import { commitinfo } from '../../../ts/00_commitinfo_data.js';
declare global {
interface HTMLElementTagNameMap {
'lele-accountnavigation': LeleAccountNavigation;
}
}
@customElement('lele-accountnavigation')
export class LeleAccountNavigation extends DeesElement {
@state()
accessor isGlobalAdmin: boolean = false;
@state()
accessor currentPath: string = window.location.pathname;
constructor() {
super();
}
private async navigateTo(path: string) {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl(path);
// Update state after navigation to trigger re-render
this.currentPath = window.location.pathname;
}
private async navigateToOrgPage(page: string) {
const currentState = states.accountState.getState();
if (currentState.selectedOrg) {
const path = page ? `/org/${currentState.selectedOrg.data.slug}/${page}` : `/org/${currentState.selectedOrg.data.slug}`;
await this.navigateTo(path);
} else {
const targetPath = page ? `/org/:orgName/${page}` : '/org/:orgName';
const description = page ? `Choose an organization to view its ${page}.` : 'Choose an organization to view its overview.';
const result = await OrgSelectModal.show({
targetPath,
title: 'Select Organization',
description,
});
if (result) {
await this.navigateTo(result.path.replace('/dash', ''));
}
}
}
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
css`
:host {
display: flex;
flex-direction: column;
background: var(--card);
border-right: 1px solid var(--border);
height: 100%;
}
:host([hidden]) {
display: none;
}
.logoArea {
padding: 20px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
2024-10-07 15:14:44 +02:00
.logo {
font-family: 'Cal Sans', 'Geist Sans', sans-serif;
letter-spacing: -0.02em;
font-size: 20px;
font-weight: 600;
color: var(--foreground);
cursor: pointer;
transition: opacity 0.15s ease;
display: flex;
align-items: center;
gap: 8px;
2024-10-07 15:14:44 +02:00
}
.logo:hover {
opacity: 0.8;
}
.logo idp-icon {
opacity: 0.9;
}
.navContent {
flex: 1;
overflow-y: auto;
padding-bottom: 16px;
2024-10-07 15:14:44 +02:00
}
.commitinfo {
flex-shrink: 0;
text-align: center;
font-family: 'Geist Mono', monospace;
font-size: 10px;
padding: 12px 16px;
border-top: 1px solid var(--border);
color: var(--muted-foreground);
opacity: 0.6;
background: var(--card);
}
.navigationGroupLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
padding: 20px 16px 8px;
opacity: 0.7;
}
.navigationGroupLabel:first-of-type {
padding-top: 16px;
}
.navigationOption {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
margin: 2px 8px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--muted-foreground);
transition: all 0.15s ease;
cursor: pointer;
}
.navigationOption:hover {
background: var(--muted);
color: var(--foreground);
}
.navigationOption idp-icon {
opacity: 0.7;
flex-shrink: 0;
}
.navigationOption:hover idp-icon {
opacity: 1;
}
.navigationOption.active {
background: var(--muted);
color: var(--foreground);
}
.navigationOption.active idp-icon {
opacity: 1;
}
.divider {
height: 1px;
background: var(--border);
margin: 8px 16px;
}
idp-select {
margin: 8px;
}
`,
];
2024-10-07 15:14:44 +02:00
public async getAccountRouter() {
const host = (this.getRootNode() as any).host;
return (host as any).subrouter;
}
public render(): TemplateResult {
return html`
<div class="logoArea">
<div class="logo">
<idp-icon name="fingerprint" size="22"></idp-icon>
idp.global
</div>
</div>
<div class="navContent">
<div class="navigationGroupLabel">Account</div>
<div
class="navigationOption ${this.isActive('') ? 'active' : ''}"
@click=${() => this.navigateTo('')}
>
<idp-icon name="home" size="16"></idp-icon>
Overview
</div>
<div
class="navigationOption"
@click=${async () => {
}}
>
<idp-icon name="shield" size="16"></idp-icon>
Manage Roles
</div>
<div
class="navigationOption"
@click=${async () => {
const idpState = await IdpState.getSingletonInstance();
idpState.domtools.router.pushUrl('/logout');
}}
>
<idp-icon name="power" size="16"></idp-icon>
Log Out
</div>
<div class="divider"></div>
<div class="navigationGroupLabel">Organization</div>
<idp-select
label="Select organization"
@idp-select=${async (eventArg: CustomEvent<plugins.idpCatalog.IIdpSelectEventDetail>) => {
// Handle "Create new..." option
if (eventArg.detail.key === '__create_new__') {
const org = await CreateOrgModal.show();
if (org) {
await this.navigateTo(`/org/${org.data.slug}/settings`);
}
return;
}
const currentState = states.accountState.getState();
const newOrg = currentState.organizations.find((org) => org.data.slug === eventArg.detail.payload);
states.accountState.dispatchAction(states.setSelectedOrg, newOrg);
// Auto-navigate to new org's current page type (reactivity)
const currentPath = window.location.pathname;
if (currentPath.includes('/org/') && newOrg) {
// Extract the page type (apps, settings, etc.) and navigate to new org
const pathParts = currentPath.split('/');
const pageType = pathParts[4]; // /dash/org/:orgName/:pageType
if (pageType) {
await this.navigateTo(`/org/${newOrg.data.slug}/${pageType}`);
} else {
await this.navigateTo(`/org/${newOrg.data.slug}`);
}
}
}}
></idp-select>
<div
class="navigationOption ${this.isActive('org-overview') ? 'active' : ''}"
@click=${() => this.navigateToOrgPage('')}
>
<idp-icon name="home" size="16"></idp-icon>
Overview
</div>
<div
class="navigationOption ${this.isActive('apps') ? 'active' : ''}"
@click=${() => this.navigateToOrgPage('apps')}
>
<idp-icon name="box" size="16"></idp-icon>
Apps
</div>
<div
2025-12-04 17:45:40 +00:00
class="navigationOption ${this.isActive('users') ? 'active' : ''}"
@click=${() => this.navigateToOrgPage('users')}
>
<idp-icon name="users" size="16"></idp-icon>
Users
</div>
<div
class="navigationOption"
@click=${async () => {}}
>
<idp-icon name="activity" size="16"></idp-icon>
Activity
</div>
<div
class="navigationOption ${this.isActive('settings') ? 'active' : ''}"
@click=${() => this.navigateToOrgPage('settings')}
>
<idp-icon name="settings" size="16"></idp-icon>
Settings
</div>
${this.renderAdminLink()}
</div>
<div class="commitinfo">v${commitinfo.version}</div>
`;
}
private renderAdminLink(): TemplateResult | null {
if (!this.isGlobalAdmin) {
return null;
}
return html`
<div class="divider"></div>
<div class="navigationGroupLabel">Platform</div>
<div
class="navigationOption ${this.isActive('admin') ? 'active' : ''}"
@click=${() => this.navigateTo('/admin')}
>
<idp-icon name="shield" size="16"></idp-icon>
Global Admin
</div>
`;
}
private isActive(page: string): boolean {
const path = this.currentPath;
if (page === '') {
// Account overview - exact match
return path === '/dash' || path === '/dash/';
}
if (page === 'org-overview') {
// Org overview - /dash/org/:slug without trailing page type
return /^\/dash\/org\/[^\/]+\/?$/.test(path);
}
// For other pages, check if the path contains the page segment
return path.includes(`/${page}`);
}
public async firstUpdated() {
// Listen for popstate (browser back/forward)
window.addEventListener('popstate', () => {
this.currentPath = window.location.pathname;
});
// Watch for URL changes from external navigation (e.g., modals)
let lastPath = this.currentPath;
const checkPath = () => {
if (window.location.pathname !== lastPath) {
lastPath = window.location.pathname;
this.currentPath = lastPath;
}
requestAnimationFrame(checkPath);
};
requestAnimationFrame(checkPath);
const orgSelect = this.shadowRoot.querySelector('idp-select') as plugins.idpCatalog.IdpSelect | null;
const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization): plugins.idpCatalog.IIdpSelectOption | null => {
if (!orgArg) {
return null;
}
return {
option: orgArg.data.name,
key: orgArg.data.slug,
payload: orgArg.data.slug,
};
};
// "Create new..." option to add at the end
const createNewOption = {
option: '+ Create new...',
key: '__create_new__',
payload: '__create_new__',
};
states.accountState
.select((stateArg) => stateArg.organizations)
.pipe(
plugins.deesDomtools.plugins.smartrx.rxjs.ops.map((orgArrayArg) => {
const orgEntries = orgArrayArg
.map(orgToMenuEntry)
.filter((entryArg): entryArg is plugins.idpCatalog.IIdpSelectOption => Boolean(entryArg));
// Add "Create new..." at the end
return [...orgEntries, createNewOption];
})
)
.subscribe((menuEntries) => {
if (orgSelect) {
orgSelect.options = menuEntries;
}
});
states.accountState
.select((stateArg) => stateArg.selectedOrg)
.pipe(plugins.deesDomtools.plugins.smartrx.rxjs.ops.map(orgToMenuEntry))
.subscribe((selectedOrgArg) => {
if (orgSelect) {
orgSelect.selectedOption = selectedOrgArg;
}
});
// Check if user is global admin
states.accountState
.select((stateArg) => stateArg.user)
.subscribe((user) => {
this.isGlobalAdmin = user?.data?.isGlobalAdmin ?? false;
});
}
}