diff --git a/changelog.md b/changelog.md index 4f0605b..9d36807 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,10 @@ ## Pending +### Features + +- group Onebox sidebar navigation into Apps, Network, and Registry sections (web) + - add parent/subview routes for grouped app, network, and registry pages ## 2026-05-21 - 1.26.3 diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 441e6ee..8c39ba7 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -64,6 +64,7 @@ export interface IAppStoreState { export interface IUiState { activeView: string; + activeSubview: string | null; autoRefresh: boolean; refreshInterval: number; pendingAppTemplate?: any; @@ -161,6 +162,7 @@ export const uiStatePart = await appState.getStatePart( 'ui', { activeView: 'dashboard', + activeSubview: null, autoRefresh: true, refreshInterval: 30000, }, @@ -1016,10 +1018,17 @@ export const setBackupPasswordAction = settingsStatePart.createAction<{ password // UI Actions // ============================================================================ -export const setActiveViewAction = uiStatePart.createAction<{ view: string }>( +export const setActiveViewAction = uiStatePart.createAction<{ view: string; subview?: string | null }>( async (statePartArg, dataArg) => { const normalizedView = dataArg.view.toLowerCase().replace(/\s+/g, '-'); - return { ...statePartArg.getState(), activeView: normalizedView }; + const normalizedSubview = dataArg.subview + ? dataArg.subview.toLowerCase().replace(/\s+/g, '-') + : null; + return { + ...statePartArg.getState(), + activeView: normalizedView, + activeSubview: normalizedSubview, + }; }, ); diff --git a/ts_web/elements/ob-app-shell.ts b/ts_web/elements/ob-app-shell.ts index 811b199..5686437 100644 --- a/ts_web/elements/ob-app-shell.ts +++ b/ts_web/elements/ob-app-shell.ts @@ -12,14 +12,21 @@ import { type TemplateResult, } from '@design.estate/dees-element'; -import type { ObViewDashboard } from './ob-view-dashboard.js'; -import type { ObViewServices } from './ob-view-services.js'; -import type { ObViewDomains } from './ob-view-domains.js'; -import type { ObViewDnsRecords } from './ob-view-dns-records.js'; -import type { ObViewNetwork } from './ob-view-network.js'; -import type { ObViewRegistries } from './ob-view-registries.js'; -import type { ObViewTokens } from './ob-view-tokens.js'; -import type { ObViewSettings } from './ob-view-settings.js'; +interface IUnresolvedView { + slug?: string; + name: string; + iconName?: string; + element?: Promise; + subViews?: IUnresolvedView[]; +} + +interface IResolvedView { + slug?: string; + name: string; + iconName?: string; + element?: any; + subViews?: IResolvedView[]; +} @customElement('ob-app-shell') export class ObAppShell extends DeesElement { @@ -29,6 +36,7 @@ export class ObAppShell extends DeesElement { @state() accessor uiState: appstate.IUiState = { activeView: 'dashboard', + activeSubview: null, autoRefresh: true, refreshInterval: 30000, }; @@ -39,27 +47,93 @@ export class ObAppShell extends DeesElement { @state() accessor loginError: string = ''; - private viewTabs = [ - { name: 'Dashboard', iconName: 'lucide:layoutDashboard', element: (async () => (await import('./ob-view-dashboard.js')).ObViewDashboard)() }, - { name: 'App Store', iconName: 'lucide:store', element: (async () => (await import('./ob-view-appstore.js')).ObViewAppStore)() }, - { name: 'Services', iconName: 'lucide:boxes', element: (async () => (await import('./ob-view-services.js')).ObViewServices)() }, - { name: 'Domains', iconName: 'lucide:globe', element: (async () => (await import('./ob-view-domains.js')).ObViewDomains)() }, - { name: 'DNS Records', iconName: 'lucide:listTree', element: (async () => (await import('./ob-view-dns-records.js')).ObViewDnsRecords)() }, - { name: 'Network', iconName: 'lucide:network', element: (async () => (await import('./ob-view-network.js')).ObViewNetwork)() }, - { name: 'Registries', iconName: 'lucide:package', element: (async () => (await import('./ob-view-registries.js')).ObViewRegistries)() }, - { name: 'Tokens', iconName: 'lucide:key', element: (async () => (await import('./ob-view-tokens.js')).ObViewTokens)() }, - { name: 'Settings', iconName: 'lucide:settings', element: (async () => (await import('./ob-view-settings.js')).ObViewSettings)() }, + private viewTabs: IUnresolvedView[] = [ + { + slug: 'dashboard', + name: 'Dashboard', + iconName: 'lucide:layoutDashboard', + element: (async () => (await import('./ob-view-dashboard.js')).ObViewDashboard)(), + }, + { + slug: 'apps', + name: 'Apps', + iconName: 'lucide:store', + subViews: [ + { + slug: 'app-store', + name: 'App Store', + iconName: 'lucide:store', + element: (async () => (await import('./ob-view-appstore.js')).ObViewAppStore)(), + }, + { + slug: 'services', + name: 'Services', + iconName: 'lucide:boxes', + element: (async () => (await import('./ob-view-services.js')).ObViewServices)(), + }, + ], + }, + { + slug: 'network', + name: 'Network', + iconName: 'lucide:network', + subViews: [ + { + slug: 'proxy', + name: 'Proxy', + iconName: 'lucide:route', + element: (async () => (await import('./ob-view-network.js')).ObViewNetwork)(), + }, + { + slug: 'domains', + name: 'Domains', + iconName: 'lucide:globe', + element: (async () => (await import('./ob-view-domains.js')).ObViewDomains)(), + }, + { + slug: 'dns-records', + name: 'DNS Records', + iconName: 'lucide:listTree', + element: (async () => (await import('./ob-view-dns-records.js')).ObViewDnsRecords)(), + }, + ], + }, + { + slug: 'registry', + name: 'Registry', + iconName: 'lucide:package', + subViews: [ + { + slug: 'registries', + name: 'Registries', + iconName: 'lucide:package', + element: (async () => (await import('./ob-view-registries.js')).ObViewRegistries)(), + }, + { + slug: 'tokens', + name: 'Tokens', + iconName: 'lucide:key', + element: (async () => (await import('./ob-view-tokens.js')).ObViewTokens)(), + }, + ], + }, + { + slug: 'settings', + name: 'Settings', + iconName: 'lucide:settings', + element: (async () => (await import('./ob-view-settings.js')).ObViewSettings)(), + }, ]; - private resolvedViewTabs: Array<{ name: string; iconName?: string; element: any }> = []; + private resolvedViewTabs: IResolvedView[] = []; constructor() { super(); document.title = 'Onebox'; const loginSubscription = appstate.loginStatePart - .select((stateArg) => stateArg) - .subscribe((loginState) => { + .select((stateArg: appstate.ILoginState) => stateArg) + .subscribe((loginState: appstate.ILoginState) => { this.loginState = loginState; if (loginState.isLoggedIn) { appstate.systemStatePart.dispatchAction(appstate.fetchSystemStatusAction, null); @@ -68,15 +142,56 @@ export class ObAppShell extends DeesElement { this.rxSubscriptions.push(loginSubscription); const uiSubscription = appstate.uiStatePart - .select((stateArg) => stateArg) - .subscribe((uiState) => { + .select((stateArg: appstate.IUiState) => stateArg) + .subscribe((uiState: appstate.IUiState) => { this.uiState = uiState; - this.syncAppdashView(uiState.activeView); + this.syncAppdashView(uiState.activeView, uiState.activeSubview); }); this.rxSubscriptions.push(uiSubscription); } - public static styles = [ + private async resolveViewTabs(tabs: IUnresolvedView[]): Promise { + return Promise.all( + tabs.map(async (tab) => { + const resolvedTab: IResolvedView = { + slug: tab.slug, + name: tab.name, + iconName: tab.iconName, + }; + if (tab.element) { + resolvedTab.element = await tab.element; + } + if (tab.subViews) { + resolvedTab.subViews = await this.resolveViewTabs(tab.subViews); + } + return resolvedTab; + }), + ); + } + + private slugFor(view: IResolvedView): string { + return view.slug ?? view.name.toLowerCase().replace(/\s+/g, '-'); + } + + private findParent(view: IResolvedView): IResolvedView | undefined { + return this.resolvedViewTabs.find((viewTab) => viewTab.subViews?.includes(view)); + } + + private findViewBySlug(viewSlug: string, subviewSlug: string | null): IResolvedView | undefined { + const topLevelView = this.resolvedViewTabs.find((view) => this.slugFor(view) === viewSlug); + if (!topLevelView) return undefined; + if (subviewSlug && topLevelView.subViews) { + return topLevelView.subViews.find((subview) => this.slugFor(subview) === subviewSlug) ?? topLevelView; + } + return topLevelView; + } + + private get currentViewTab(): IResolvedView | undefined { + if (this.resolvedViewTabs.length === 0) return undefined; + return this.findViewBySlug(this.uiState.activeView, this.uiState.activeSubview) ?? this.resolvedViewTabs[0]; + } + + public static override styles = [ cssManager.defaultStyles, css` :host { @@ -91,16 +206,14 @@ export class ObAppShell extends DeesElement { `, ]; - public render(): TemplateResult { + public override render(): TemplateResult { return html`
t.name.toLowerCase().replace(/\s+/g, '-') === this.uiState.activeView - ) || this.resolvedViewTabs[0]} + .selectedView=${this.currentViewTab} > @@ -108,15 +221,8 @@ export class ObAppShell extends DeesElement { `; } - public async firstUpdated() { - // Resolve async view tab imports - this.resolvedViewTabs = await Promise.all( - this.viewTabs.map(async (tab) => ({ - name: tab.name, - iconName: tab.iconName, - element: await tab.element, - })), - ); + public override async firstUpdated() { + this.resolvedViewTabs = await this.resolveViewTabs(this.viewTabs); this.requestUpdate(); await this.updateComplete; @@ -130,34 +236,44 @@ export class ObAppShell extends DeesElement { const appDash = this.shadowRoot!.querySelector('dees-simple-appdash') as any; if (appDash) { appDash.addEventListener('view-select', (e: CustomEvent) => { - const viewName = e.detail.view.name.toLowerCase().replace(/\s+/g, '-'); - appRouter.navigateToView(viewName); + const view = e.detail.view as IResolvedView; + const parent = this.findParent(view); + const currentState = appstate.uiStatePart.getState(); + if (parent) { + const parentSlug = this.slugFor(parent); + const subviewSlug = this.slugFor(view); + if (currentState.activeView === parentSlug && currentState.activeSubview === subviewSlug) { + return; + } + appRouter.navigateToView(parentSlug, subviewSlug); + } else { + const slug = this.slugFor(view); + if (currentState.activeView === slug && !currentState.activeSubview) { + return; + } + appRouter.navigateToView(slug); + } }); appDash.addEventListener('logout', async () => { await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); }); } - // Load the initial view on the appdash now that tabs are resolved - // Read activeView directly from state (not this.uiState which may be stale) if (appDash && this.resolvedViewTabs.length > 0) { - const currentActiveView = appstate.uiStatePart.getState().activeView; - const initialView = this.resolvedViewTabs.find( - (t) => t.name.toLowerCase().replace(/\s+/g, '-') === currentActiveView, - ) || this.resolvedViewTabs[0]; + const currentUiState = appstate.uiStatePart.getState(); + const initialView = + this.findViewBySlug(currentUiState.activeView, currentUiState.activeSubview) || + this.resolvedViewTabs[0]; await appDash.loadView(initialView); } - // Check for stored session (persistent login state) const loginState = appstate.loginStatePart.getState(); if (loginState.identity?.jwt) { if (loginState.identity.expiresAt > Date.now()) { - // Switch to dashboard immediately (no flash of login form) this.loginState = loginState; if (simpleLogin) { await simpleLogin.switchToSlottedContent(); } - // Validate token with server in the background try { const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_GetSystemStatus @@ -165,11 +281,9 @@ export class ObAppShell extends DeesElement { const response = await typedRequest.fire({ identity: loginState.identity }); appstate.systemStatePart.setState({ status: response.status }); } catch (err) { - // Token rejected by server - switch back to login console.warn('Stored session invalid, returning to login:', err); await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); if (simpleLogin) { - // Force page reload to show login properly window.location.reload(); } } @@ -210,14 +324,13 @@ export class ObAppShell extends DeesElement { } } - private syncAppdashView(viewName: string): void { + private syncAppdashView(viewName: string, subviewName: string | null): void { const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any; if (!appDash || this.resolvedViewTabs.length === 0) return; - // Match kebab-case view name (e.g., 'app-store') to tab name (e.g., 'App Store') - const targetTab = this.resolvedViewTabs.find( - (t) => t.name.toLowerCase().replace(/\s+/g, '-') === viewName - ); - if (!targetTab) return; + + const targetTab = this.findViewBySlug(viewName, subviewName); + if (!targetTab || appDash.selectedView === targetTab) return; + appDash.loadView(targetTab); } } diff --git a/ts_web/router.ts b/ts_web/router.ts index b78f66b..d63fc42 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -3,12 +3,40 @@ import * as appstate from './appstate.js'; const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter; -export const validViews = [ - 'dashboard', 'app-store', 'services', 'domains', 'dns-records', 'network', - 'registries', 'tokens', 'settings', -] as const; +const flatViews = ['dashboard', 'settings'] as const; -export type TValidView = typeof validViews[number]; +const subviewMap: Record = { + apps: ['app-store', 'services'] as const, + network: ['proxy', 'domains', 'dns-records'] as const, + registry: ['registries', 'tokens'] as const, +}; + +const defaultSubview: Record = { + apps: 'app-store', + network: 'proxy', + registry: 'registries', +}; + +const legacySubviewTargetMap: Record = { + 'app-store': { view: 'apps', subview: 'app-store' }, + services: { view: 'apps', subview: 'services' }, + proxy: { view: 'network', subview: 'proxy' }, + domains: { view: 'network', subview: 'domains' }, + 'dns-records': { view: 'network', subview: 'dns-records' }, + registries: { view: 'registry', subview: 'registries' }, + tokens: { view: 'registry', subview: 'tokens' }, +}; + +export const validTopLevelViews = [...flatViews, ...Object.keys(subviewMap)] as const; +export type TValidView = typeof validTopLevelViews[number]; + +export function isValidView(view: string): boolean { + return (validTopLevelViews as readonly string[]).includes(view); +} + +export function isValidSubview(view: string, subview: string): boolean { + return subviewMap[view]?.includes(subview) ?? false; +} class AppRouter { private router: InstanceType; @@ -28,24 +56,37 @@ class AppRouter { } private setupRoutes(): void { - for (const view of validViews) { + for (const view of flatViews) { this.router.on(`/${view}`, async () => { - this.updateViewState(view); + this.updateViewState(view, null); }); } - // Root redirect + for (const view of Object.keys(subviewMap)) { + this.router.on(`/${view}`, async () => { + this.navigateTo(`/${view}/${defaultSubview[view]}`); + }); + + for (const subview of subviewMap[view]) { + this.router.on(`/${view}/${subview}`, async () => { + this.updateViewState(view, subview); + }); + } + } + this.router.on('/', async () => { this.navigateTo('/dashboard'); }); } private setupStateSync(): void { - appstate.uiStatePart.select((s) => s.activeView).subscribe((activeView) => { + appstate.uiStatePart.select().subscribe((uiState: appstate.IUiState) => { if (this.suppressStateUpdate) return; const currentPath = window.location.pathname; - const expectedPath = `/${activeView}`; + const expectedPath = uiState.activeSubview + ? `/${uiState.activeView}/${uiState.activeSubview}` + : `/${uiState.activeView}`; if (currentPath !== expectedPath) { this.suppressStateUpdate = true; @@ -60,25 +101,37 @@ class AppRouter { if (!path || path === '/') { this.router.pushUrl('/dashboard'); - } else { - const segments = path.split('/').filter(Boolean); - const view = segments[0]; + return; + } - if (validViews.includes(view as TValidView)) { - this.updateViewState(view as TValidView); + const segments = path.split('/').filter(Boolean); + const view = segments[0]; + const subview = segments[1]; + + if (!isValidView(view)) { + this.router.pushUrl('/dashboard'); + return; + } + + if (subviewMap[view]) { + if (subview && isValidSubview(view, subview)) { + this.updateViewState(view, subview); } else { - this.router.pushUrl('/dashboard'); + this.router.pushUrl(`/${view}/${defaultSubview[view]}`); } + } else { + this.updateViewState(view, null); } } - private updateViewState(view: string): void { + private updateViewState(view: string, subview: string | null): void { this.suppressStateUpdate = true; const currentState = appstate.uiStatePart.getState(); - if (currentState.activeView !== view) { + if (currentState.activeView !== view || currentState.activeSubview !== subview) { appstate.uiStatePart.setState({ ...currentState, activeView: view, + activeSubview: subview, }); } this.suppressStateUpdate = false; @@ -88,17 +141,34 @@ class AppRouter { this.router.pushUrl(path); } - public navigateToView(view: string): void { - const normalized = view.toLowerCase().replace(/\s+/g, '-'); - if (validViews.includes(normalized as TValidView)) { - this.navigateTo(`/${normalized}`); - } else { + public navigateToView(view: string, subview?: string): void { + const normalizedView = view.toLowerCase().replace(/\s+/g, '-'); + const normalizedSubview = subview?.toLowerCase().replace(/\s+/g, '-'); + + if (!isValidView(normalizedView)) { + const legacyTarget = legacySubviewTargetMap[normalizedView]; + if (legacyTarget) { + this.navigateToView(legacyTarget.view, legacyTarget.subview); + return; + } this.navigateTo('/dashboard'); + return; + } + + if (normalizedSubview && isValidSubview(normalizedView, normalizedSubview)) { + this.navigateTo(`/${normalizedView}/${normalizedSubview}`); + } else if (subviewMap[normalizedView]) { + this.navigateTo(`/${normalizedView}/${defaultSubview[normalizedView]}`); + } else { + this.navigateTo(`/${normalizedView}`); } } public getCurrentView(): string { - return appstate.uiStatePart.getState().activeView; + const uiState = appstate.uiStatePart.getState(); + return uiState.activeSubview + ? `${uiState.activeView}/${uiState.activeSubview}` + : uiState.activeView; } public destroy(): void {