feat(ui): group dashboard navigation into sectioned routes and align view layouts with dcrouter

This commit is contained in:
2026-05-22 13:45:21 +00:00
parent 60fbb4be2b
commit b8dccac68d
13 changed files with 215 additions and 93 deletions
+86 -38
View File
@@ -1,64 +1,112 @@
/**
* 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;
type TViewSlug = (typeof VIEWS)[number];
const SUBVIEW_MAP = {
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 {
private currentView: TViewSlug = 'overview';
private onNavigate: ((view: TViewSlug) => void) | null = null;
private suppressPush = false;
private currentRoute: IRouteState = DEFAULT_ROUTE;
private onNavigate: ((view: TViewSlug, subview: TSubviewSlug) => void) | null = null;
private initialized = false;
init(): void {
// Parse initial URL.
const path = location.pathname.replace(/^\/+/, '').split('/')[0] || 'overview';
if (VIEWS.includes(path as TViewSlug)) {
this.currentView = path as TViewSlug;
}
if (this.initialized) return;
this.applyRoute(this.parsePath(location.pathname) ?? DEFAULT_ROUTE, false, true);
// Handle browser back/forward.
window.addEventListener('popstate', () => {
const p = location.pathname.replace(/^\/+/, '').split('/')[0] || 'overview';
if (VIEWS.includes(p as TViewSlug)) {
this.suppressPush = true;
this.navigateTo(p as TViewSlug);
this.suppressPush = false;
}
this.applyRoute(this.parsePath(location.pathname) ?? DEFAULT_ROUTE, false, true);
});
this.initialized = true;
}
setNavigateHandler(handler: (view: TViewSlug) => void): void {
setNavigateHandler(handler: (view: TViewSlug, subview: TSubviewSlug) => void): void {
this.onNavigate = handler;
}
navigateTo(view: TViewSlug, skipCallback = false): void {
this.currentView = view;
if (!this.suppressPush) {
const url = `/${view}`;
if (location.pathname !== url) {
navigateToView(view: TViewSlug, subview?: TSubviewSlug, skipCallback = false): void {
const targetSubview = subview && isValidSubview(view, subview) ? subview : DEFAULT_SUBVIEW[view];
this.applyRoute({ view, subview: targetSubview }, skipCallback, false);
}
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);
}
}
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 type { TViewSlug };
export type { IRouteState, TSubviewSlug, TViewSlug };