import { ChangeDetectionStrategy, Component, DestroyRef, OnDestroy, OnInit, computed, inject, signal, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; import { filter } from 'rxjs/operators'; import { BootstrapService } from '../../core/services/bootstrap.service'; import { AuthService } from '../../core/services/auth.service'; import { EventStatusStore } from '../../core/services/event-status.store'; import { AuthenticatedEventSourceService } from '../../core/services/authenticated-event-source.service'; import { UserStore } from '../../core/services/user.store'; import { ChallengesStore } from '../challenges/challenges.store'; import { ShellHeaderComponent } from '../shell/header/shell-header.component'; import { ChangePasswordModalComponent, ChangePasswordSubmitPayload, } from '../shell/change-password/change-password-modal.component'; import { ChangePasswordFailure, ChangePasswordResult } from '../../core/services/auth.service'; import { QuickTabsComponent, ShellTab } from '../shell/tabs/quick-tabs.component'; import { deriveActiveSectionFromUrl, shouldShowAdminNav } from './home.shell'; import { formatChangePasswordError } from '../shell/change-password/password-feedback'; const TABS: ShellTab[] = [ { id: 'challenges', label: 'Challenges' }, { id: 'scoreboard', label: 'Scoreboard' }, { id: 'blog', label: 'Blog' }, ]; @Component({ selector: 'app-home', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, RouterOutlet, ShellHeaderComponent, ChangePasswordModalComponent, QuickTabsComponent, ], template: `
`, }) export class HomeComponent implements OnInit, OnDestroy { readonly TABS = TABS; readonly bootstrap = inject(BootstrapService); readonly auth = inject(AuthService); readonly eventStatus = inject(EventStatusStore); private readonly authenticatedEventSource = inject(AuthenticatedEventSourceService); readonly userStore = inject(UserStore); private readonly challengesStore = inject(ChallengesStore); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); readonly userMenuOpen = signal(false); readonly changePasswordOpen = signal(false); readonly changePasswordSubmitting = signal(false); readonly changePasswordError = signal(null); private readonly currentUrl = signal(this.router.url || '/'); readonly activeSection = computed(() => deriveActiveSectionFromUrl(this.currentUrl())); readonly activeTabId = computed(() => { const url = this.currentUrl(); if (url.startsWith('/scoreboard')) return 'scoreboard'; if (url.startsWith('/blog')) return 'blog'; if (url.startsWith('/admin')) return 'admin'; return 'challenges'; }); readonly canAccessAdmin = computed(() => shouldShowAdminNav({ isAuthenticated: this.auth.isAuthenticated(), role: this.auth.currentUser()?.role, }), ); constructor() { this.router.events .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) .subscribe((e) => this.currentUrl.set(e.urlAfterRedirects || e.url)); this.destroyRef.onDestroy(() => { this.eventStatus.stop(); }); } ngOnInit(): void { this.userStore.hydrateFromAuth(); void this.userStore.loadMe(); this.eventStatus.start( () => this.authenticatedEventSource.open('/api/v1/events/status'), () => this.handleSessionInvalidated('sse-unauthorized'), ); this.peerInvalidationUnsubscribe = this.auth.onPeerInvalidation((reason) => this.handleSessionInvalidated(reason), ); } ngOnDestroy(): void { this.eventStatus.stop(); if (this.peerInvalidationUnsubscribe) { this.peerInvalidationUnsubscribe(); this.peerInvalidationUnsubscribe = null; } } goHome(): void { void this.router.navigateByUrl('/challenges'); this.userMenuOpen.set(false); } goAdmin(): void { void this.router.navigateByUrl('/admin'); this.userMenuOpen.set(false); } toggleUserMenu(): void { this.userMenuOpen.update((v) => !v); } navigateToSection(id: string): void { if (id === 'admin') { void this.router.navigateByUrl('/admin'); } else { void this.router.navigateByUrl('/' + id); } this.userMenuOpen.set(false); } openChangePassword(): void { this.changePasswordError.set(null); this.changePasswordOpen.set(true); this.userMenuOpen.set(false); } closeChangePassword(): void { if (this.changePasswordSubmitting()) return; this.changePasswordOpen.set(false); this.changePasswordError.set(null); } async submitChangePassword(payload: ChangePasswordSubmitPayload): Promise { this.changePasswordSubmitting.set(true); this.changePasswordError.set(null); const result: ChangePasswordResult = await this.auth.changePassword(payload); this.changePasswordSubmitting.set(false); if (result.ok === true) { this.changePasswordOpen.set(false); return; } const failure = result as ChangePasswordFailure; this.changePasswordError.set( formatChangePasswordError({ code: failure.code, message: failure.message }), ); } async onLogout(): Promise { await this.auth.logout(); this.userMenuOpen.set(false); this.userStore.reset(); this.challengesStore.reset(); await this.router.navigateByUrl('/login'); } private peerInvalidationUnsubscribe: (() => void) | null = null; private navigatingToLogin = false; private async handleSessionInvalidated( _reason: 'peer-logout' | 'sse-unauthorized', ): Promise { this.userStore.reset(); this.challengesStore.reset(); this.changePasswordOpen.set(false); this.userMenuOpen.set(false); if (this.navigatingToLogin) return; if (this.router.url.startsWith('/login')) return; this.navigatingToLogin = true; try { await this.router.navigateByUrl('/login'); } finally { this.navigatingToLogin = false; } } }