feat(admin): build shared admin shell
Sidebar (Dashboard/Catalog group/Products/Categories/Orders/Transactions/ Reviews/Reports/Content/Media/Marketplace Builder/Users/Settings/ Monitoring/Analytics + Documentation/Help/Logout), sticky topbar (breadcrumbs, page title/description, search, notifications, tenant selector and quick-publish placeholders, current user), reserved right-rail slot, scrollable content area. Desktop 280px sidebar, tablet icon rail, mobile drawer with focus management and Escape-to-close. Nav items without a built page (Reviews, Reports, Settings, Docs, Help) render disabled with a coming-soon badge instead of dead links; Content and Marketplace Builder route to the existing project-editor pages (static-pages / general) rather than duplicating them. All 15 /backoffice/** routes now render through AdminLayoutComponent; the public storefront header/back-button/footer no longer render on admin routes (app.ts/app.html gate on a new isAdminRoute signal). Added the adminShell i18n namespace (ru/en/hy) for every new shell string so this doesn't add to the existing untranslated-admin-UI gap tracked in KNOWN-ISSUES.md. Colors/type sizes follow DESIGN.md tokens; the two rgba() modal-scrim values are a documented, intentional exception (neutral overlay, not a themed token). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
142
src/app/features/admin/shell/admin-layout.component.ts
Normal file
142
src/app/features/admin/shell/admin-layout.component.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, HostListener, computed, inject, signal, viewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../i18n/translate.service';
|
||||
import { LanguageService } from '../../../services/language.service';
|
||||
import { AdminAuthService } from '../../../core/admin-auth/admin-auth.service';
|
||||
import { ADMIN_NAV_BOTTOM, ADMIN_NAV_PRIMARY, AdminBreadcrumbEntry, AdminNavEntry } from './admin-nav.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-layout',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, TranslatePipe],
|
||||
templateUrl: './admin-layout.component.html',
|
||||
styleUrl: './admin-layout.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AdminLayoutComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly adminAuth = inject(AdminAuthService);
|
||||
|
||||
private readonly drawerEl = viewChild<ElementRef<HTMLElement>>('drawer');
|
||||
private readonly menuToggleEl = viewChild<ElementRef<HTMLElement>>('menuToggle');
|
||||
|
||||
readonly navPrimary: AdminNavEntry[] = ADMIN_NAV_PRIMARY;
|
||||
readonly navBottom: AdminNavEntry[] = ADMIN_NAV_BOTTOM;
|
||||
|
||||
readonly mobileDrawerOpen = signal(false);
|
||||
readonly notificationsOpen = signal(false);
|
||||
|
||||
readonly currentLang = this.languageService.currentLanguage;
|
||||
readonly displayName = this.adminAuth.displayName;
|
||||
|
||||
readonly initials = computed(() => {
|
||||
const name = this.displayName();
|
||||
if (!name) {
|
||||
return '?';
|
||||
}
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map(part => part[0]?.toUpperCase())
|
||||
.join('');
|
||||
});
|
||||
|
||||
private readonly breadcrumbSignal = signal<AdminBreadcrumbEntry[]>([]);
|
||||
private readonly titleKeySignal = signal<string>('adminShell.pages.dashboard.title');
|
||||
private readonly descriptionKeySignal = signal<string>('adminShell.pages.dashboard.description');
|
||||
|
||||
readonly breadcrumb = this.breadcrumbSignal.asReadonly();
|
||||
readonly pageTitleKey = this.titleKeySignal.asReadonly();
|
||||
readonly pageDescriptionKey = this.descriptionKeySignal.asReadonly();
|
||||
|
||||
constructor() {
|
||||
this.readRouteData();
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter(event => event instanceof NavigationEnd),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.readRouteData();
|
||||
this.closeMobileDrawer();
|
||||
this.notificationsOpen.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
if (this.mobileDrawerOpen()) {
|
||||
this.closeMobileDrawer();
|
||||
return;
|
||||
}
|
||||
if (this.notificationsOpen()) {
|
||||
this.notificationsOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
toggleMobileDrawer(): void {
|
||||
this.mobileDrawerOpen.update(open => !open);
|
||||
if (this.mobileDrawerOpen()) {
|
||||
queueMicrotask(() => {
|
||||
const drawer = this.drawerEl()?.nativeElement;
|
||||
drawer?.querySelector<HTMLElement>('a, button')?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
closeMobileDrawer(): void {
|
||||
if (!this.mobileDrawerOpen()) {
|
||||
return;
|
||||
}
|
||||
this.mobileDrawerOpen.set(false);
|
||||
this.menuToggleEl()?.nativeElement.focus();
|
||||
}
|
||||
|
||||
toggleNotifications(): void {
|
||||
this.notificationsOpen.update(open => !open);
|
||||
}
|
||||
|
||||
adminLinkFor(entry: Extract<AdminNavEntry, { type: 'link' }>): string[] {
|
||||
const lang = this.currentLang();
|
||||
if (entry.absolutePath) {
|
||||
return ['/', lang, ...entry.absolutePath];
|
||||
}
|
||||
return ['/', lang, 'backoffice', ...(entry.path ?? [])];
|
||||
}
|
||||
|
||||
breadcrumbLink(entry: AdminBreadcrumbEntry): string[] {
|
||||
return ['/', this.currentLang(), 'backoffice', ...(entry.path ?? [])];
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.adminAuth.logout();
|
||||
}
|
||||
|
||||
cardTitle(key: string): string {
|
||||
return this.translate.t(key);
|
||||
}
|
||||
|
||||
private readRouteData(): void {
|
||||
let leaf = this.route;
|
||||
while (leaf.firstChild) {
|
||||
leaf = leaf.firstChild;
|
||||
}
|
||||
const data = leaf.snapshot.data as {
|
||||
titleKey?: string;
|
||||
descriptionKey?: string;
|
||||
breadcrumb?: AdminBreadcrumbEntry[];
|
||||
};
|
||||
this.titleKeySignal.set(data.titleKey ?? 'adminShell.pages.dashboard.title');
|
||||
this.descriptionKeySignal.set(data.descriptionKey ?? 'adminShell.pages.dashboard.description');
|
||||
this.breadcrumbSignal.set(data.breadcrumb ?? []);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user