diff --git a/src/client/src/app/app.config.ts b/src/client/src/app/app.config.ts index 62e266b..7032bef 100644 --- a/src/client/src/app/app.config.ts +++ b/src/client/src/app/app.config.ts @@ -24,6 +24,7 @@ import { routes } from './app.routes'; import { environment } from './environments/environment'; import { AuthInterceptor } from './services/auth.interceptor'; import { AuthService } from './services/auth.service'; +import { ThemeService } from './services/theme.service'; import { WebPushService } from './services/web-push.service'; export const appConfig: ApplicationConfig = { @@ -58,11 +59,13 @@ export const appConfig: ApplicationConfig = { provide: APP_INITIALIZER, multi: true, useFactory: () => { + // Inject services that need to be initialized + inject(WebPushService); + inject(ThemeService); + const authService = inject(AuthService); - const webPushService = inject(WebPushService); return async () => { await authService.init(); - webPushService.init(); }; }, }, diff --git a/src/client/src/app/components/app/menu/menu.component.html b/src/client/src/app/components/app/menu/menu.component.html index bbf0966..df6b169 100644 --- a/src/client/src/app/components/app/menu/menu.component.html +++ b/src/client/src/app/components/app/menu/menu.component.html @@ -1,12 +1,28 @@ @if (isAdmin()) { - + -
- @if (title()) { - {{ title() }} - {{ translations.title() }} - } @else { - {{ translations.title() }} +
+
+ @if (title()) { + {{ title() }} + {{ translations.title() }} + } @else { + {{ translations.title() }} + } +
+ @if (!isServerConnected()) { + + + {{ translations.nav_offline() }} + }
@@ -14,11 +30,36 @@ } @else {
- @for (item of menuItems(); track index; let index = $index) { - + +
+ @if (!isServerConnected()) { + + + {{ translations.nav_offline() }} + } +
} @@ -39,45 +80,3 @@
}
- - - @if (item.visible !== false) { - @if (item.separator) { - @if (item.state?.['grow']) { -
- } @else { -
- } - } @else if (item.items) { - @if (item.state?.['expand'] === true) { - @for (subItem of item.items; track index; let index = $index) { - - } - } @else { -
- - -
- } - } @else { - - } - } -
diff --git a/src/client/src/app/components/app/menu/menu.component.ts b/src/client/src/app/components/app/menu/menu.component.ts index ebf27d3..13bd204 100644 --- a/src/client/src/app/components/app/menu/menu.component.ts +++ b/src/client/src/app/components/app/menu/menu.component.ts @@ -8,6 +8,7 @@ import { MenuItem } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; import { MenuModule } from 'primeng/menu'; import { MenubarModule } from 'primeng/menubar'; +import { OverlayPanelModule } from 'primeng/overlaypanel'; import { TooltipModule } from 'primeng/tooltip'; import { filter, fromEvent, map, merge } from 'rxjs'; @@ -20,7 +21,14 @@ import { chainSignals } from '../../../utils/signal.utils'; @Component({ selector: 'app-menu', standalone: true, - imports: [ButtonModule, CommonModule, MenubarModule, MenuModule, TooltipModule], + imports: [ + ButtonModule, + CommonModule, + MenubarModule, + MenuModule, + TooltipModule, + OverlayPanelModule, + ], templateUrl: './menu.component.html', changeDetection: ChangeDetectionStrategy.OnPush, animations: [ @@ -56,13 +64,13 @@ export class MenuComponent { ), { initialValue: false } ); + protected readonly isServerConnected = this._realtimeEventsService.isConnected; - protected readonly menuItems = computed(() => [ + protected readonly adminMenuItems = computed(() => [ { label: this.translations.nav_home(), icon: 'i-[mdi--home]', routerLink: '/home', - visible: this.isLoggedIn(), }, { label: this.translations.nav_manage(), @@ -84,18 +92,11 @@ export class MenuComponent { routerLink: '/manage/events', }, ], - visible: this.isAdmin(), - }, - { - separator: true, - visible: !this.isAdmin(), - state: { grow: true }, }, { label: this.translations.nav_settings(), icon: 'i-[mdi--cog]', routerLink: '/user-settings', - visible: this.isLoggedIn(), }, ]); diff --git a/src/client/src/app/components/events/event-details/event-details.component.html b/src/client/src/app/components/events/event-details/event-details.component.html index 5bd12c5..b8125a1 100644 --- a/src/client/src/app/components/events/event-details/event-details.component.html +++ b/src/client/src/app/components/events/event-details/event-details.component.html @@ -39,14 +39,25 @@ {{ event.registrationDeadline | date: 'medium' : undefined : locale() }} @if (canStart()) { - +
+ + @if (!hasInstances()) { + {{ + translations.events_warning_notStartableNoInstances() + }} + } @else if (!allTimeslotsHaveMaps()) { + {{ + translations.events_warning_notStartableMissingMap() + }} + } +
} @if (canCommit()) { this.timeslots().some(x => x.instances.length > 0) ); + protected readonly allTimeslotsHaveMaps = computed( + () => + !this.event() + ?.timeslots.filter(x => x.instances.length > 0) + .some(x => x.mapId === null || x.mapId === undefined) + ); protected readonly canBuildInstances = computed(() => ifTruthy(this.event(), event => event.registrationDeadline.getTime() < this.now(), false) ); protected readonly canStart = computed( - () => - this.canBuildInstances() && this.event() && !this.event()?.startedAt && this.hasInstances() + () => this.canBuildInstances() && this.event() && !this.event()?.startedAt ); protected readonly canCommit = computed( @@ -103,10 +108,7 @@ export class EventDetailsComponent { ); protected readonly allowToStart = computed( - () => - !this.event() - ?.timeslots.filter(x => x.instances.length > 0) - .some(x => x.mapId === null || x.mapId === undefined) + () => this.hasInstances() && this.allTimeslotsHaveMaps() ); constructor() { diff --git a/src/client/src/app/i18n/de.json b/src/client/src/app/i18n/de.json index 6d3803a..8e4bb7e 100644 --- a/src/client/src/app/i18n/de.json +++ b/src/client/src/app/i18n/de.json @@ -24,7 +24,8 @@ "eventTimeslot": "Zeitslot", "maps": "Bahnen", "users": "Benutzer", - "settings": "Einstellungen" + "settings": "Einstellungen", + "offline": "Die Verbindung zum Server wurde unterbrochen. Die Verbindung wird automatisch wiederhergestellt, sobald sie wieder verfügbar ist." }, "settings": { "general": { @@ -130,6 +131,10 @@ "title": "Veranstaltung freigeben", "text": "Möchtest du die Veranstlaung {{date}} wirklich freigeben? Die Zeitfenster können nicht mehr angepasst werden und die Spieler werden über ein neues Event benachrichtigt" }, + "warning": { + "notStartableNoInstances": "Die Veranstaltung kann nicht gestartet werden, da die Gruppen noch nicht gebildet wurden.", + "notStartableMissingMap": "Die Veranstaltung kann nicht gestartet werden, da nicht für alle gespielten Zeitslots eine Bahn festgelegt wurde." + }, "error": { "load": "Fehler beim Laden der Veranstaltungen.", "loadOne": "Fehler beim Laden der Veranstaltung.", diff --git a/src/client/src/app/i18n/en.json b/src/client/src/app/i18n/en.json index 70177b0..2410699 100644 --- a/src/client/src/app/i18n/en.json +++ b/src/client/src/app/i18n/en.json @@ -24,7 +24,8 @@ "eventTimeslot": "Timeslot", "maps": "Maps", "users": "Users", - "settings": "Settings" + "settings": "Settings", + "offline": "The connection to the server has been lost. The connection will be restored automatically once it is available again." }, "settings": { "general": { @@ -130,6 +131,10 @@ "title": "Commit event", "text": "Do you really want to release the event {{date}}? The time slots can no longer be adjusted and players will be notified of a new event" }, + "warning": { + "notStartableNoInstances": "The event cannot be started because the groups have not been built yet.", + "notStartableMissingMap": "The event cannot be started because not all played timeslots have a map assigned." + }, "error": { "load": "Failed to load events.", "loadOne": "Failed to load event.", diff --git a/src/client/src/app/services/auth.interceptor.ts b/src/client/src/app/services/auth.interceptor.ts index 06e231d..6a3a7ab 100644 --- a/src/client/src/app/services/auth.interceptor.ts +++ b/src/client/src/app/services/auth.interceptor.ts @@ -1,6 +1,6 @@ import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; +import { from, Observable, switchMap } from 'rxjs'; import { AuthService } from './auth.service'; @@ -9,14 +9,22 @@ export class AuthInterceptor implements HttpInterceptor { private readonly _authService = inject(AuthService); public intercept(req: HttpRequest, next: HttpHandler): Observable> { - const token = this._authService.token()?.token; - if (token && !req.headers.has('Authorization')) { - req = req.clone({ - setHeaders: { - Authorization: `Bearer ${token}`, - }, - }); + if (req.url.includes('api/auth/token')) { + return next.handle(req); } - return next.handle(req); + + return from(this._authService.ensureTokenNotExpired()).pipe( + switchMap(() => { + const token = this._authService.token()?.token; + if (token && !req.headers.has('Authorization')) { + req = req.clone({ + setHeaders: { + Authorization: `Bearer ${token}`, + }, + }); + } + return next.handle(req); + }) + ); } } diff --git a/src/client/src/app/services/auth.service.ts b/src/client/src/app/services/auth.service.ts index c4d09af..8ef70c7 100644 --- a/src/client/src/app/services/auth.service.ts +++ b/src/client/src/app/services/auth.service.ts @@ -7,7 +7,9 @@ import { inject, signal, } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; +import { fromEvent } from 'rxjs'; import { AuthTokenInfo, @@ -47,6 +49,14 @@ export class AuthService implements OnDestroy { this.clearTokenRefreshTimeout(); } }); + + fromEvent(document, 'visibilitychange') + .pipe(takeUntilDestroyed()) + .subscribe(() => { + if (!document.hidden) { + this.ensureTokenNotExpired(); + } + }); } public async init() { @@ -82,6 +92,15 @@ export class AuthService implements OnDestroy { this._token.set(null); } + public async ensureTokenNotExpired() { + const expiration = this._token()?.expiresAt; + if (!expiration || expiration.getTime() < Date.now() + 60 * 1000) { + await this.refreshToken(); + } else if (expiration) { + this.updateTokenRefreshTimeout(expiration); + } + } + public async signIn(loginToken: string): Promise { if (!environment.authenticationRequired) return 'success'; @@ -121,19 +140,25 @@ export class AuthService implements OnDestroy { } private updateTokenRefreshTimeout(expiration: Date) { + if (this._tokenRefreshTimeout) clearTimeout(this._tokenRefreshTimeout); this._tokenRefreshTimeout = setTimeout( () => { - const loginToken = getLoginToken(); - if (loginToken) { - this.signIn(loginToken); - } else { - this.signOut(); - } + this.refreshToken(); }, Math.max(10000, expiration.getTime() - Date.now() - 1000 * 60) ); } + private async refreshToken() { + console.log('Refreshing token'); + const loginToken = getLoginToken(); + if (loginToken) { + await this.signIn(loginToken); + } else { + await this.signOut(); + } + } + private clearTokenRefreshTimeout() { if (this._tokenRefreshTimeout) clearTimeout(this._tokenRefreshTimeout); this._tokenRefreshTimeout = undefined; diff --git a/src/client/src/app/services/web-push.service.ts b/src/client/src/app/services/web-push.service.ts index fd8c670..467af07 100644 --- a/src/client/src/app/services/web-push.service.ts +++ b/src/client/src/app/services/web-push.service.ts @@ -1,12 +1,4 @@ -import { - computed, - DestroyRef, - EventEmitter, - inject, - Injectable, - Injector, - signal, -} from '@angular/core'; +import { computed, EventEmitter, inject, Injectable, signal } from '@angular/core'; import { toObservable, takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { SwPush } from '@angular/service-worker'; import { combineLatest, filter, first, startWith, pairwise, firstValueFrom, map } from 'rxjs'; @@ -28,8 +20,6 @@ export class WebPushService { private readonly _swPush = inject(SwPush); private readonly _notificationsService = inject(NotificationsService); private readonly _wellKnownService = inject(WellKnownService); - private readonly _destroyRef = inject(DestroyRef); - private readonly _injector = inject(Injector); private readonly _subscription = toSignal(this._swPush.subscription); @@ -43,14 +33,11 @@ export class WebPushService { ) : signal(false); - public init(): void { + constructor() { if (!this.notificationsSupported) return; if (!getHasConfiguredPush()) { - combineLatest([ - toObservable(this._authService.isAuthorized, { injector: this._injector }), - this._swPush.subscription, - ]) + combineLatest([toObservable(this._authService.isAuthorized), this._swPush.subscription]) .pipe( filter( ([isLoggedIn, subscription]) => @@ -60,21 +47,17 @@ export class WebPushService { !getHasConfiguredPush() ), first(), - takeUntilDestroyed(this._destroyRef) + takeUntilDestroyed() ) .subscribe(() => this.onPromptNotification.emit()); } combineLatest([ - toObservable(this._authService.user, { injector: this._injector }), - toObservable(this._translateService.language, { injector: this._injector }), + toObservable(this._authService.user), + toObservable(this._translateService.language), this._swPush.subscription, ]) - .pipe( - startWith([undefined, undefined, undefined]), - pairwise(), - takeUntilDestroyed(this._destroyRef) - ) + .pipe(startWith([undefined, undefined, undefined]), pairwise(), takeUntilDestroyed()) .subscribe(([[_oldUser, _oldLang, oldSub], [newUser, newLang, newSub]]) => { if (newSub && newLang && newUser && newUser.id !== 'admin') { this.registerSubscription(newLang, newSub); @@ -84,7 +67,7 @@ export class WebPushService { }); this._authService.onBeforeSignOut - .pipe(takeUntilDestroyed(this._destroyRef)) + .pipe(takeUntilDestroyed()) .subscribe(() => this.disable(true)); } diff --git a/src/client/src/dark-theme.scss b/src/client/src/dark-theme.scss index 62a8d3c..dc9d9aa 100644 --- a/src/client/src/dark-theme.scss +++ b/src/client/src/dark-theme.scss @@ -15,5 +15,6 @@ sans-serif; --font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - --danger-color: #fca5a5; + --danger-color: #f87171; + --warning-color: #fb923c; } diff --git a/src/client/src/light-theme.scss b/src/client/src/light-theme.scss index 94dd4f5..d7b724d 100644 --- a/src/client/src/light-theme.scss +++ b/src/client/src/light-theme.scss @@ -16,4 +16,5 @@ --font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; --danger-color: #ef4444; + --warning-color: #f97316; } diff --git a/src/client/tailwind.config.js b/src/client/tailwind.config.js index 64afc83..6603af3 100644 --- a/src/client/tailwind.config.js +++ b/src/client/tailwind.config.js @@ -29,6 +29,7 @@ module.exports = { 'text-color-secondary': 'var(--text-color-secondary)', primary: 'var(--primary-color)', danger: 'var(--danger-color)', + warning: 'var(--warning-color)', 'primary-text': 'var(--primary-color-text)', 'surface-0': 'var(--surface-0)', 'surface-50': 'var(--surface-50)',