feat(ui): group dashboard navigation into sectioned routes and align view layouts with dcrouter
This commit is contained in:
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
## Pending
|
## Pending
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- group dashboard navigation and align view layout with dcrouter (ui)
|
||||||
|
- replace flat SipRouter dashboard tabs with grouped Overview, Telephony, Configuration, and System sections
|
||||||
|
- move dashboard URLs to grouped route paths such as `/telephony/calls` and `/configuration/providers`
|
||||||
|
- add consistent view headings and shared layout spacing across dashboard views
|
||||||
|
- group dashboard navigation into sectioned routes and align view layouts with dcrouter (ui)
|
||||||
|
- replace flat dashboard tabs with grouped Overview, Telephony, Configuration, and System navigation
|
||||||
|
- move routing to nested view/subview paths such as /telephony/calls and /configuration/providers
|
||||||
|
- add consistent headings and shared spacing across dashboard views
|
||||||
|
- update contact actions and app navigation syncing to use grouped routes
|
||||||
|
- switch restartBackground to the populate helper for loading local SmartData and SmartBucket config before restarting
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- load local SmartData/SmartBucket config before restarting the development background server (dev)
|
||||||
|
|
||||||
## 2026-05-22 - 1.27.1
|
## 2026-05-22 - 1.27.1
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:docker": "tsdocker build --verbose",
|
"build:docker": "tsdocker build --verbose",
|
||||||
"release:docker": "tsdocker push --verbose",
|
"release:docker": "tsdocker push --verbose",
|
||||||
"start": "tsx ts/sipproxy.ts",
|
"start": "tsx ts/sipproxy.ts",
|
||||||
"restartBackground": "pnpm run buildRust && pnpm run bundle; test -f .server.pid && kill $(cat .server.pid) 2>/dev/null; sleep 1; rm -f sip_trace.log proxy.out && nohup tsx ts/sipproxy.ts > proxy.out 2>&1 & echo $! > .server.pid; sleep 2; cat proxy.out"
|
"restartBackground": "tsx .nogit/populate.ts --restart-background"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@design.estate/dees-catalog": "^3.81.0",
|
"@design.estate/dees-catalog": "^3.81.0",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||||
import { deesCatalog } from '../plugins.js';
|
import { deesCatalog } from '../plugins.js';
|
||||||
|
import type { IView } from '@design.estate/dees-catalog';
|
||||||
import { NotificationManager } from '../state/notification-manager.js';
|
import { NotificationManager } from '../state/notification-manager.js';
|
||||||
import { appRouter } from '../router.js';
|
import { appRouter, type TSubviewSlug, type TViewSlug } from '../router.js';
|
||||||
import { SipproxyViewOverview } from './sipproxy-view-overview.js';
|
import { SipproxyViewOverview } from './sipproxy-view-overview.js';
|
||||||
import { SipproxyViewCalls } from './sipproxy-view-calls.js';
|
import { SipproxyViewCalls } from './sipproxy-view-calls.js';
|
||||||
import { SipproxyViewPhone } from './sipproxy-view-phone.js';
|
import { SipproxyViewPhone } from './sipproxy-view-phone.js';
|
||||||
@@ -12,25 +13,56 @@ import { SipproxyViewRoutes } from './sipproxy-view-routes.js';
|
|||||||
import { SipproxyViewVoicemail } from './sipproxy-view-voicemail.js';
|
import { SipproxyViewVoicemail } from './sipproxy-view-voicemail.js';
|
||||||
import { SipproxyViewIvr } from './sipproxy-view-ivr.js';
|
import { SipproxyViewIvr } from './sipproxy-view-ivr.js';
|
||||||
|
|
||||||
const VIEW_TABS = [
|
interface ITabbedView extends IView {
|
||||||
{ name: 'Overview', iconName: 'lucide:layoutDashboard', element: SipproxyViewOverview },
|
slug?: string;
|
||||||
{ name: 'Calls', iconName: 'lucide:phone', element: SipproxyViewCalls },
|
subViews?: ITabbedView[];
|
||||||
{ name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
|
}
|
||||||
{ name: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes },
|
|
||||||
{ name: 'Voicemail', iconName: 'lucide:voicemail', element: SipproxyViewVoicemail },
|
|
||||||
{ name: 'IVR', iconName: 'lucide:ListTree', element: SipproxyViewIvr },
|
|
||||||
{ name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
|
|
||||||
{ name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
|
|
||||||
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Map slug -> tab for routing.
|
const VIEW_TABS: ITabbedView[] = [
|
||||||
const SLUG_TO_TAB = new Map(VIEW_TABS.map((t) => [t.name.toLowerCase(), t]));
|
{
|
||||||
|
name: 'Overview',
|
||||||
|
slug: 'overview',
|
||||||
|
iconName: 'lucide:layoutDashboard',
|
||||||
|
subViews: [
|
||||||
|
{ name: 'Stats', slug: 'stats', iconName: 'lucide:activity', element: SipproxyViewOverview },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Telephony',
|
||||||
|
slug: 'telephony',
|
||||||
|
iconName: 'lucide:phoneCall',
|
||||||
|
subViews: [
|
||||||
|
{ name: 'Calls', slug: 'calls', iconName: 'lucide:phone', element: SipproxyViewCalls },
|
||||||
|
{ name: 'Phone', slug: 'phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
|
||||||
|
{ name: 'Routes', slug: 'routes', iconName: 'lucide:route', element: SipproxyViewRoutes },
|
||||||
|
{ name: 'Voicemail', slug: 'voicemail', iconName: 'lucide:voicemail', element: SipproxyViewVoicemail },
|
||||||
|
{ name: 'IVR', slug: 'ivr', iconName: 'lucide:ListTree', element: SipproxyViewIvr },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Configuration',
|
||||||
|
slug: 'configuration',
|
||||||
|
iconName: 'lucide:settings',
|
||||||
|
subViews: [
|
||||||
|
{ name: 'Contacts', slug: 'contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
|
||||||
|
{ name: 'Providers', slug: 'providers', iconName: 'lucide:server', element: SipproxyViewProviders },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'System',
|
||||||
|
slug: 'system',
|
||||||
|
iconName: 'lucide:serverCog',
|
||||||
|
subViews: [
|
||||||
|
{ name: 'Log', slug: 'log', iconName: 'lucide:scrollText', element: SipproxyViewLog },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@customElement('sipproxy-app')
|
@customElement('sipproxy-app')
|
||||||
export class SipproxyApp extends DeesElement {
|
export class SipproxyApp extends DeesElement {
|
||||||
private notificationManager = new NotificationManager();
|
private notificationManager = new NotificationManager();
|
||||||
private appdash: InstanceType<typeof deesCatalog.DeesSimpleAppDash> | null = null;
|
private appdash: InstanceType<typeof deesCatalog.DeesSimpleAppDash> | null = null;
|
||||||
|
private viewTabs = VIEW_TABS;
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
@@ -42,24 +74,49 @@ export class SipproxyApp extends DeesElement {
|
|||||||
|
|
||||||
private suppressViewSelectEvent = false;
|
private suppressViewSelectEvent = false;
|
||||||
|
|
||||||
|
private slugFor(view: ITabbedView): string {
|
||||||
|
return view.slug ?? view.name.toLowerCase().replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private findParent(view: ITabbedView): ITabbedView | undefined {
|
||||||
|
return this.viewTabs.find((tab) => tab.subViews?.includes(view));
|
||||||
|
}
|
||||||
|
|
||||||
|
private findViewBySlug(viewSlug: string, subviewSlug: string): ITabbedView | undefined {
|
||||||
|
const top = this.viewTabs.find((tab) => this.slugFor(tab) === viewSlug);
|
||||||
|
if (!top) return undefined;
|
||||||
|
return top.subViews?.find((subview) => this.slugFor(subview) === subviewSlug) ?? top;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get currentViewTab(): ITabbedView {
|
||||||
|
const currentRoute = appRouter.getCurrentRoute();
|
||||||
|
return this.findViewBySlug(currentRoute.view, currentRoute.subview) ?? this.viewTabs[0].subViews![0];
|
||||||
|
}
|
||||||
|
|
||||||
async firstUpdated() {
|
async firstUpdated() {
|
||||||
this.appdash = this.shadowRoot?.querySelector('dees-simple-appdash') as InstanceType<typeof deesCatalog.DeesSimpleAppDash>;
|
this.appdash = this.shadowRoot?.querySelector('dees-simple-appdash') as InstanceType<typeof deesCatalog.DeesSimpleAppDash>;
|
||||||
if (this.appdash) {
|
if (this.appdash) {
|
||||||
this.notificationManager.init(this.appdash);
|
this.notificationManager.init(this.appdash);
|
||||||
|
|
||||||
// Listen for user tab selections — sync URL.
|
// Listen for user tab selections and sync grouped URLs.
|
||||||
this.appdash.addEventListener('view-select', ((e: CustomEvent) => {
|
this.appdash.addEventListener('view-select', ((e: CustomEvent) => {
|
||||||
if (this.suppressViewSelectEvent) return;
|
if (this.suppressViewSelectEvent) return;
|
||||||
const viewName: string = e.detail?.view?.name || e.detail?.name || '';
|
const view = e.detail?.view as ITabbedView | undefined;
|
||||||
const slug = viewName.toLowerCase();
|
if (!view) return;
|
||||||
if (slug && slug !== appRouter.getCurrentView()) {
|
|
||||||
appRouter.navigateTo(slug as any, true);
|
const parent = this.findParent(view);
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
const parentSlug = this.slugFor(parent) as TViewSlug;
|
||||||
|
const subviewSlug = this.slugFor(view) as TSubviewSlug;
|
||||||
|
if (!appRouter.isCurrentRoute(parentSlug, subviewSlug)) {
|
||||||
|
appRouter.navigateToView(parentSlug, subviewSlug, true);
|
||||||
}
|
}
|
||||||
}) as EventListener);
|
}) as EventListener);
|
||||||
|
|
||||||
// Wire up router -> appdash (for browser back/forward).
|
// Wire up router -> appdash (for browser back/forward).
|
||||||
appRouter.setNavigateHandler((view) => {
|
appRouter.setNavigateHandler((view, subview) => {
|
||||||
const tab = SLUG_TO_TAB.get(view);
|
const tab = this.findViewBySlug(view, subview);
|
||||||
if (tab && this.appdash) {
|
if (tab && this.appdash) {
|
||||||
this.suppressViewSelectEvent = true;
|
this.suppressViewSelectEvent = true;
|
||||||
this.appdash.loadView(tab);
|
this.appdash.loadView(tab);
|
||||||
@@ -67,21 +124,17 @@ export class SipproxyApp extends DeesElement {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deep link: if URL isn't "overview", navigate to the right tab.
|
const initialTab = this.currentViewTab;
|
||||||
const initial = appRouter.getCurrentView();
|
if (initialTab) {
|
||||||
if (initial !== 'overview') {
|
this.suppressViewSelectEvent = true;
|
||||||
const tab = SLUG_TO_TAB.get(initial);
|
this.appdash.loadView(initialTab);
|
||||||
if (tab) {
|
this.suppressViewSelectEvent = false;
|
||||||
this.suppressViewSelectEvent = true;
|
|
||||||
this.appdash.loadView(tab);
|
|
||||||
this.suppressViewSelectEvent = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
public async disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
await super.disconnectedCallback();
|
||||||
this.notificationManager.destroy();
|
this.notificationManager.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +142,8 @@ export class SipproxyApp extends DeesElement {
|
|||||||
return html`
|
return html`
|
||||||
<dees-simple-appdash
|
<dees-simple-appdash
|
||||||
.name=${'SipRouter'}
|
.name=${'SipRouter'}
|
||||||
.viewTabs=${VIEW_TABS}
|
.viewTabs=${this.viewTabs}
|
||||||
|
.selectedView=${this.currentViewTab}
|
||||||
></dees-simple-appdash>
|
></dees-simple-appdash>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -942,6 +942,8 @@ export class SipproxyViewCalls extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
<dees-heading level="3">Calls</dees-heading>
|
||||||
|
|
||||||
<div class="view-section">
|
<div class="view-section">
|
||||||
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ export class SipproxyViewContacts extends DeesElement {
|
|||||||
type: ['inRow'] as any,
|
type: ['inRow'] as any,
|
||||||
actionFunc: async ({ item }: { item: IContact }) => {
|
actionFunc: async ({ item }: { item: IContact }) => {
|
||||||
appState.selectContact(item);
|
appState.selectContact(item);
|
||||||
appRouter.navigateTo('phone' as any);
|
appRouter.navigateToView('telephony', 'phone');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -343,6 +343,8 @@ export class SipproxyViewContacts extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
<dees-heading level="3">Contacts</dees-heading>
|
||||||
|
|
||||||
<div class="view-section">
|
<div class="view-section">
|
||||||
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -624,6 +624,8 @@ export class SipproxyViewIvr extends DeesElement {
|
|||||||
const menus = ivr.menus || [];
|
const menus = ivr.menus || [];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
<dees-heading level="3">IVR</dees-heading>
|
||||||
|
|
||||||
<div class="view-section">
|
<div class="view-section">
|
||||||
<dees-statsgrid
|
<dees-statsgrid
|
||||||
.tiles=${this.getStatsTiles()}
|
.tiles=${this.getStatsTiles()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||||
import { deesCatalog } from '../plugins.js';
|
|
||||||
import { appState, type IAppState } from '../state/appstate.js';
|
import { appState, type IAppState } from '../state/appstate.js';
|
||||||
|
import { viewHostCss } from './shared/index.js';
|
||||||
|
|
||||||
@customElement('sipproxy-view-log')
|
@customElement('sipproxy-view-log')
|
||||||
export class SipproxyViewLog extends DeesElement {
|
export class SipproxyViewLog extends DeesElement {
|
||||||
@@ -11,9 +11,9 @@ export class SipproxyViewLog extends DeesElement {
|
|||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
css`
|
css`
|
||||||
:host { display: block; padding: 1rem; height: 100%; }
|
dees-chart-log { height: calc(100vh - 140px); }
|
||||||
dees-chart-log { height: calc(100vh - 120px); }
|
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -58,6 +58,8 @@ export class SipproxyViewLog extends DeesElement {
|
|||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
|
<dees-heading level="3">Log</dees-heading>
|
||||||
|
|
||||||
<dees-chart-log
|
<dees-chart-log
|
||||||
label="SIP Trace Log"
|
label="SIP Trace Log"
|
||||||
mode="structured"
|
mode="structured"
|
||||||
|
|||||||
@@ -11,22 +11,8 @@ export class SipproxyViewOverview extends DeesElement {
|
|||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
viewHostCss,
|
viewHostCss,
|
||||||
css`
|
css`
|
||||||
:host {
|
dees-statsgrid {
|
||||||
display: block;
|
margin-bottom: 32px;
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-heading {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: #64748b;
|
|
||||||
margin: 32px 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-heading:first-of-type {
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
@@ -141,13 +127,15 @@ export class SipproxyViewOverview extends DeesElement {
|
|||||||
const onlineCount = allDevices.filter((d) => d.connected).length;
|
const onlineCount = allDevices.filter((d) => d.connected).length;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
<dees-heading level="3">Stats</dees-heading>
|
||||||
|
|
||||||
<dees-statsgrid
|
<dees-statsgrid
|
||||||
.tiles=${tiles}
|
.tiles=${tiles}
|
||||||
.minTileWidth=${220}
|
.minTileWidth=${220}
|
||||||
.gap=${16}
|
.gap=${16}
|
||||||
></dees-statsgrid>
|
></dees-statsgrid>
|
||||||
|
|
||||||
<div class="section-heading">Devices</div>
|
<dees-heading level="hr">Devices</dees-heading>
|
||||||
<dees-table
|
<dees-table
|
||||||
heading1="Devices"
|
heading1="Devices"
|
||||||
heading2="${onlineCount} of ${allDevices.length} online"
|
heading2="${onlineCount} of ${allDevices.length} online"
|
||||||
|
|||||||
@@ -617,6 +617,8 @@ export class SipproxyViewPhone extends DeesElement {
|
|||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
|
<dees-heading level="3">Phone</dees-heading>
|
||||||
|
|
||||||
<div class="phone-layout">
|
<div class="phone-layout">
|
||||||
${this.renderDialer()}
|
${this.renderDialer()}
|
||||||
${this.renderPhoneStatus()}
|
${this.renderPhoneStatus()}
|
||||||
|
|||||||
@@ -780,6 +780,8 @@ export class SipproxyViewProviders extends DeesElement {
|
|||||||
const providers = this.appData.providers || [];
|
const providers = this.appData.providers || [];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
<dees-heading level="3">Providers</dees-heading>
|
||||||
|
|
||||||
<div class="view-section">
|
<div class="view-section">
|
||||||
<dees-statsgrid
|
<dees-statsgrid
|
||||||
.tiles=${this.getStatsTiles()}
|
.tiles=${this.getStatsTiles()}
|
||||||
|
|||||||
@@ -339,6 +339,8 @@ export class SipproxyViewRoutes extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
<dees-heading level="3">Route Management</dees-heading>
|
||||||
|
|
||||||
<div class="view-section">
|
<div class="view-section">
|
||||||
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -458,6 +458,8 @@ export class SipproxyViewVoicemail extends DeesElement {
|
|||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
|
<dees-heading level="3">Voicemail</dees-heading>
|
||||||
|
|
||||||
<div class="view-section">
|
<div class="view-section">
|
||||||
<dees-table
|
<dees-table
|
||||||
heading1="Voiceboxes"
|
heading1="Voiceboxes"
|
||||||
|
|||||||
+86
-38
@@ -1,64 +1,112 @@
|
|||||||
/**
|
/**
|
||||||
* URL router for the SipRouter dashboard.
|
* URL router for the SipRouter dashboard.
|
||||||
* Maps URL paths to views in dees-simple-appdash.
|
* Maps grouped URL paths to views in dees-simple-appdash.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const VIEWS = ['overview', 'calls', 'phone', 'routes', 'voicemail', 'ivr', 'contacts', 'providers', 'log'] as const;
|
const SUBVIEW_MAP = {
|
||||||
type TViewSlug = (typeof VIEWS)[number];
|
overview: ['stats'],
|
||||||
|
telephony: ['calls', 'phone', 'routes', 'voicemail', 'ivr'],
|
||||||
|
configuration: ['contacts', 'providers'],
|
||||||
|
system: ['log'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const DEFAULT_SUBVIEW = {
|
||||||
|
overview: 'stats',
|
||||||
|
telephony: 'calls',
|
||||||
|
configuration: 'contacts',
|
||||||
|
system: 'log',
|
||||||
|
} as const satisfies { [key in TViewSlug]: TSubviewSlug };
|
||||||
|
|
||||||
|
const DEFAULT_ROUTE: IRouteState = {
|
||||||
|
view: 'overview',
|
||||||
|
subview: 'stats',
|
||||||
|
};
|
||||||
|
|
||||||
|
type TViewSlug = keyof typeof SUBVIEW_MAP;
|
||||||
|
type TSubviewSlug = (typeof SUBVIEW_MAP)[TViewSlug][number];
|
||||||
|
|
||||||
|
interface IRouteState {
|
||||||
|
view: TViewSlug;
|
||||||
|
subview: TSubviewSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidView(view: string): view is TViewSlug {
|
||||||
|
return Object.prototype.hasOwnProperty.call(SUBVIEW_MAP, view);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidSubview(view: TViewSlug, subview: string): subview is TSubviewSlug {
|
||||||
|
return (SUBVIEW_MAP[view] as readonly string[]).includes(subview);
|
||||||
|
}
|
||||||
|
|
||||||
class AppRouter {
|
class AppRouter {
|
||||||
private currentView: TViewSlug = 'overview';
|
private currentRoute: IRouteState = DEFAULT_ROUTE;
|
||||||
private onNavigate: ((view: TViewSlug) => void) | null = null;
|
private onNavigate: ((view: TViewSlug, subview: TSubviewSlug) => void) | null = null;
|
||||||
private suppressPush = false;
|
private initialized = false;
|
||||||
|
|
||||||
init(): void {
|
init(): void {
|
||||||
// Parse initial URL.
|
if (this.initialized) return;
|
||||||
const path = location.pathname.replace(/^\/+/, '').split('/')[0] || 'overview';
|
|
||||||
if (VIEWS.includes(path as TViewSlug)) {
|
this.applyRoute(this.parsePath(location.pathname) ?? DEFAULT_ROUTE, false, true);
|
||||||
this.currentView = path as TViewSlug;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle browser back/forward.
|
// Handle browser back/forward.
|
||||||
window.addEventListener('popstate', () => {
|
window.addEventListener('popstate', () => {
|
||||||
const p = location.pathname.replace(/^\/+/, '').split('/')[0] || 'overview';
|
this.applyRoute(this.parsePath(location.pathname) ?? DEFAULT_ROUTE, false, true);
|
||||||
if (VIEWS.includes(p as TViewSlug)) {
|
|
||||||
this.suppressPush = true;
|
|
||||||
this.navigateTo(p as TViewSlug);
|
|
||||||
this.suppressPush = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
setNavigateHandler(handler: (view: TViewSlug) => void): void {
|
setNavigateHandler(handler: (view: TViewSlug, subview: TSubviewSlug) => void): void {
|
||||||
this.onNavigate = handler;
|
this.onNavigate = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateTo(view: TViewSlug, skipCallback = false): void {
|
navigateToView(view: TViewSlug, subview?: TSubviewSlug, skipCallback = false): void {
|
||||||
this.currentView = view;
|
const targetSubview = subview && isValidSubview(view, subview) ? subview : DEFAULT_SUBVIEW[view];
|
||||||
if (!this.suppressPush) {
|
this.applyRoute({ view, subview: targetSubview }, skipCallback, false);
|
||||||
const url = `/${view}`;
|
}
|
||||||
if (location.pathname !== url) {
|
|
||||||
|
getCurrentRoute(): IRouteState {
|
||||||
|
return this.currentRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCurrentRoute(view: TViewSlug, subview: TSubviewSlug): boolean {
|
||||||
|
return this.currentRoute.view === view && this.currentRoute.subview === subview;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePath(pathname: string): IRouteState | null {
|
||||||
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
|
if (segments.length === 0) return DEFAULT_ROUTE;
|
||||||
|
if (segments.length > 2) return null;
|
||||||
|
|
||||||
|
const [viewSegment, subviewSegment] = segments;
|
||||||
|
if (!isValidView(viewSegment)) return null;
|
||||||
|
|
||||||
|
const targetSubview = subviewSegment ?? DEFAULT_SUBVIEW[viewSegment];
|
||||||
|
if (!isValidSubview(viewSegment, targetSubview)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
view: viewSegment,
|
||||||
|
subview: targetSubview,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyRoute(route: IRouteState, skipCallback: boolean, replace: boolean): void {
|
||||||
|
this.currentRoute = route;
|
||||||
|
|
||||||
|
const url = `/${route.view}/${route.subview}`;
|
||||||
|
if (location.pathname !== url) {
|
||||||
|
if (replace) {
|
||||||
|
history.replaceState(null, '', url);
|
||||||
|
} else {
|
||||||
history.pushState(null, '', url);
|
history.pushState(null, '', url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skipCallback) {
|
if (!skipCallback) {
|
||||||
this.onNavigate?.(view);
|
this.onNavigate?.(route.view, route.subview);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called when the user selects a tab in dees-simple-appdash. */
|
|
||||||
onViewSelect(viewName: string): void {
|
|
||||||
const slug = viewName.toLowerCase().replace(/\s+/g, '-');
|
|
||||||
const mapped = VIEWS.find((v) => v === slug || viewName.toLowerCase().startsWith(v));
|
|
||||||
if (mapped) {
|
|
||||||
this.navigateTo(mapped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentView(): TViewSlug {
|
|
||||||
return this.currentView;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appRouter = new AppRouter();
|
export const appRouter = new AppRouter();
|
||||||
export type { TViewSlug };
|
export type { IRouteState, TSubviewSlug, TViewSlug };
|
||||||
|
|||||||
Reference in New Issue
Block a user