diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 59dd95e..db5cc47 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -3,10 +3,12 @@ import { GameComponent } from './pages/game/game.component'; import { WelcomeComponent } from './pages/welcome/welcome.component'; import { ProfileComponent } from './pages/profile/profile.component'; import { ChallengesComponent } from './pages/challenges/challenges.component'; +import { GamesComponent } from './pages/games/games.component'; export const routes: Routes = [ { path: '', component: WelcomeComponent }, { path: 'profile', component: ProfileComponent }, + { path: 'games', component: GamesComponent }, { path: 'challenges', component: ChallengesComponent }, { path: 'game/:gameId', component: GameComponent }, { path: '**', redirectTo: '' } diff --git a/src/app/components/toolbar/toolbar.component.css b/src/app/components/toolbar/toolbar.component.css index 6f5da3a..859c441 100644 --- a/src/app/components/toolbar/toolbar.component.css +++ b/src/app/components/toolbar/toolbar.component.css @@ -165,6 +165,30 @@ padding: 0 3px; } +/* ============ GAMES BUTTON ============ */ +.nc-games-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 14px; + border: 1px solid var(--nc-border); + background: transparent; + color: var(--nc-text-muted); + font-family: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.nc-games-btn:hover { + background: var(--nc-accent-hover); + color: var(--nc-text); +} + /* ============ PROFILE BUTTON ============ */ .nc-profile { display: flex; diff --git a/src/app/components/toolbar/toolbar.component.html b/src/app/components/toolbar/toolbar.component.html index 5526099..6bcfdfe 100644 --- a/src/app/components/toolbar/toolbar.component.html +++ b/src/app/components/toolbar/toolbar.component.html @@ -101,6 +101,18 @@ } + + +
+ +
+ + + + @if (loading) { +
+ + Loading games… +
+ } @else if (tab === 'active') { + + @if (activeGames.length === 0) { +
+
+ + + + +
+

No active games

+

Start a new game from the lobby to see it here.

+ Go to lobby +
+ } @else { +
+ @for (game of activeGames; track game.gameId) { +
+
+
+ {{ game.white.displayName }} + vs + {{ game.black.displayName }} +
+
+ + {{ statusLabel(game.state.status) }} + · + {{ game.state.moves.length }} moves + · + {{ game.gameId.slice(0, 8) }} +
+
+
+ + +
+
+ } +
+ } + + } @else { + + @if (finishedGames.length === 0) { +
+
+ + + + + + +
+

No game history yet

+

Completed games will appear here.

+
+ } @else { +
+ @for (game of finishedGames; track game.gameId) { +
+
+
+ {{ game.white.displayName }} + vs + {{ game.black.displayName }} +
+
+ + {{ statusLabel(game.state.status) }} + · + {{ game.state.moves.length }} moves + · + {{ game.gameId.slice(0, 8) }} +
+
+
+ + +
+
+ } +
+ } + + } + + + diff --git a/src/app/pages/games/games.component.ts b/src/app/pages/games/games.component.ts new file mode 100644 index 0000000..9a78042 --- /dev/null +++ b/src/app/pages/games/games.component.ts @@ -0,0 +1,100 @@ +import { Component, DestroyRef, OnInit, inject } from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AuthService } from '../../services/auth.service'; +import { GameApiService } from '../../services/game-api.service'; +import { GameHistoryService } from '../../services/game-history.service'; +import { GameFull, GameStatus } from '../../models/game.models'; + +type GamesTab = 'active' | 'history'; + +const FINISHED_STATUSES: GameStatus[] = [ + 'checkmate', 'stalemate', 'resign', 'draw', 'insufficientMaterial' +]; + +@Component({ + selector: 'app-games', + standalone: true, + imports: [RouterLink], + templateUrl: './games.component.html', + styleUrl: './games.component.css' +}) +export class GamesComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly router = inject(Router); + private readonly authService = inject(AuthService); + private readonly gameApi = inject(GameApiService); + private readonly gameHistory = inject(GameHistoryService); + + tab: GamesTab = 'active'; + loading = true; + activeGames: GameFull[] = []; + finishedGames: GameFull[] = []; + + ngOnInit(): void { + this.authService.currentUser$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((user) => { + if (!user) void this.router.navigate(['/']); + }); + + this.loadGames(); + } + + setTab(tab: GamesTab): void { + this.tab = tab; + } + + resumeGame(gameId: string): void { + void this.router.navigate(['/game', gameId]); + } + + removeGame(gameId: string): void { + this.gameHistory.removeGame(gameId); + this.activeGames = this.activeGames.filter((g) => g.gameId !== gameId); + this.finishedGames = this.finishedGames.filter((g) => g.gameId !== gameId); + } + + statusLabel(status: GameStatus): string { + const labels: Record = { + started: 'In Progress', + check: 'Check', + checkmate: 'Checkmate', + stalemate: 'Stalemate', + resign: 'Resigned', + draw: 'Draw', + drawOffered: 'Draw Offered', + fiftyMoveAvailable: 'In Progress', + promotionPending: 'In Progress', + insufficientMaterial: 'Draw' + }; + return labels[status] ?? status; + } + + isFinished(status: GameStatus): boolean { + return FINISHED_STATUSES.includes(status); + } + + private loadGames(): void { + const ids = this.gameHistory.getGameIds(); + if (ids.length === 0) { + this.loading = false; + return; + } + + const requests = ids.map((id) => + this.gameApi.getGame(id).pipe(catchError(() => of(null))) + ); + + forkJoin(requests) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((results) => { + const valid = results.filter((g): g is GameFull => g !== null); + this.activeGames = valid.filter((g) => !FINISHED_STATUSES.includes(g.state.status)); + this.finishedGames = valid.filter((g) => FINISHED_STATUSES.includes(g.state.status)); + this.loading = false; + }); + } +} diff --git a/src/app/services/game-history.service.ts b/src/app/services/game-history.service.ts new file mode 100644 index 0000000..a2189b1 --- /dev/null +++ b/src/app/services/game-history.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@angular/core'; + +const STORAGE_KEY = 'nowchess.games'; +const MAX_ENTRIES = 50; + +interface GameEntry { + id: string; + addedAt: number; +} + +@Injectable({ providedIn: 'root' }) +export class GameHistoryService { + recordGame(gameId: string): void { + const entries = this.load().filter((e) => e.id !== gameId); + entries.unshift({ id: gameId, addedAt: Date.now() }); + this.save(entries.slice(0, MAX_ENTRIES)); + } + + getGameIds(): string[] { + return this.load().map((e) => e.id); + } + + removeGame(gameId: string): void { + this.save(this.load().filter((e) => e.id !== gameId)); + } + + private load(): GameEntry[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as GameEntry[]) : []; + } catch { + return []; + } + } + + private save(entries: GameEntry[]): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + } +}