Angular Custom Theme / Layout Replacement — Plan
Replace the LeptonX application layout with a custom theme featuring a custom sidebar whose top element is an arena switcher.
Target:
new/angular. Replace the LeptonX application layout with our own theme featuring a custom sidebar whose top element is an arena switcher — a tenant (business) can own several arenas and must be able to manage one arena or all arenas together from one dropdown. Reference implementation studied:H:\wafi\SwiftAccessHub\angular(PrimeNG-based custom theme). This is a prerequisite (step 0) for.claude/plan/ANGULAR_MVP_PLAN.md— the MVP pages render inside this layout and filter by the selected arena.
How layout replacement works (verified in SwiftAccessHub)
- Build a standalone layout component (sidebar + topbar +
<router-outlet/>). - In
app.component.tsngOnInit, register it:replaceableComponents.add({ component: AmarArenaApplicationLayoutComponent, key: eThemeLeptonXComponents.ApplicationLayout, // from '@volosoft/abp.ng.theme.lepton-x' });<abp-dynamic-layout/>in the root template then renders OUR layout for every route withlayout: eLayoutType.application. - LeptonX stays installed: it still provides the Account layout (login/register pages) and
base styles. We only replace
ApplicationLayout(Account layout can be themed later the same way viaeThemeLeptonXComponents.AccountLayout). - The custom sidebar builds its menu from ABP’s
RoutesService(+PermissionServicefor visibility), so existing ABP menu registrations (Administration, Identity, Tenants, Settings) and ourroute.provider.tsentries appear automatically — no duplicate menu definition.
Deliberate deviation from the reference: SwiftAccessHub pulls in PrimeNG for its theme. We will NOT add PrimeNG — Bootstrap 5 (already shipped with ABP theme.shared) + our own SCSS is enough for a sidebar/topbar/dropdown, and keeps the dependency tree small. If we later want a component library, that’s a separate decision.
Folder structure (new theme/ folder)
src/app/theme/
├── application-layout/
│ ├── application-layout.component.ts|html|scss # shell: sidebar + topbar + content outlet
├── sidebar/
│ ├── sidebar.component.ts|html|scss # menu from RoutesService, collapse, mobile drawer
│ └── arena-switcher/
│ └── arena-switcher.component.ts|html|scss # THE dropdown (top of sidebar)
├── top-navbar/
│ └── top-navbar.component.ts|html|scss # page title/breadcrumb, user menu (profile, logout)
├── services/
│ ├── sidebar.service.ts # collapsed state (signal), mobile drawer, widths
│ └── arena-context.service.ts # selected arena state (see below)
└── theme.scss # design tokens: colors, sidebar width vars
Arena switcher (the core requirement)
ArenaContextService (singleton, providedIn: 'root'):
type ArenaSelection = { mode: 'all' } | { mode: 'single'; arenaId: string };
selection: Signal<ArenaSelection> // default 'all'
selectedArena: Signal<Arena | null> // resolved from ArenaService list
arenas: Signal<Arena[]> // loaded once from ArenaService (dummy for now)
select(selection: ArenaSelection): void // updates signal + persists
- Persisted to
localStorage(amararena.selectedArena) per user; restored on boot; falls back to ‘all’ if the stored arena no longer exists. - Single-arena tenants: if the tenant has exactly one arena, the switcher renders as a static label (no dropdown) and selection is forced to that arena.
- Pages don’t talk to the switcher — they inject
ArenaContextServiceand react to the signal (dashboard stats, schedule grid, bookings list all filter by it; ‘all’ = no filter, with an arena column/grouping shown where relevant). - When real APIs arrive: selection becomes a
arenaId?query param on BackOffice calls; service is unchanged. (Tenant resolution itself stays ABP’s — this is intra-tenant scoping only.)
Dropdown UI (top of sidebar, above the menu):
- Collapsed sidebar → shows arena initial/icon only; expanded → arena name + chevron.
- Items: “All arenas” (with count badge) + one item per active arena (name, city).
- Bottom action inside dropdown: “+ New arena” → routes to
/arenascreate flow (from MVP plan).
Dependency: needs Arena model + ArenaService abstract class + dummy implementation.
These are defined in ANGULAR_MVP_PLAN.md (shared layer) — that shared-layer step moves here
(built together with the theme), the MVP plan then consumes it.
Layout behavior
- Desktop: fixed left sidebar, 260px expanded / 72px collapsed (icons + tooltips), state in
SidebarService+ localStorage. Content area shifts via CSS variable. - Mobile (≤768px): sidebar becomes an overlay drawer, hamburger in the top navbar.
- Top navbar: hamburger (mobile), current page title from
RoutesService, right side = current user dropdown (ConfigStateService.getDeep('currentUser')): profile → ABP account manage, logout →AuthService.logout(). Language switcher omitted for MVP (English only). - Errors/toasts: keep ABP theme.shared defaults (no custom toaster — reference’s PrimeNG toaster is part of what we’re not copying).
Implementation order
theme/skeleton:theme.scsstokens,SidebarService, layout shell component with empty sidebar/topbar; register viaReplaceableComponentsService; verify home page renders in it.- Sidebar menu: render
RoutesServicetree (visible items only), active-route highlight, collapse/expand, mobile drawer. - Shared layer for arenas:
Arenamodel, abstractArenaService,DummyArenaServicewith seed data (3 Dhaka arenas), provider wiring. (Pulled forward from the MVP plan.) ArenaContextService+ arena-switcher component at the top of the sidebar; persistence + single-arena behavior.- Top navbar: title, user dropdown, logout.
ng buildgreen; manual smoke test (ng serve) — note: login against the backend still requires the PostgreSQL fix (known issue in CLAUDE.md); layout can be verified on the home route which doesn’t require auth.
Out of scope (this pass)
- Replacing the Account (login) layout — LeptonX default stays for now.
- Dark mode, language switcher, notifications bell.
- Permission-based menu filtering beyond what
RoutesServicealready provides (real permissions arrive with BackOffice policies).