From 1e6cd34f61688841c6b1324d4794602a94e6c1de Mon Sep 17 00:00:00 2001 From: "Lala, Shahd" Date: Thu, 14 May 2026 18:36:34 +0000 Subject: [PATCH 1/9] fix: game created fixed --- .../challenge-notification.component.ts | 11 +- src/app/pages/welcome/nav-export.html | 700 ++++++++++++++++++ 2 files changed, 709 insertions(+), 2 deletions(-) create mode 100644 src/app/pages/welcome/nav-export.html diff --git a/src/app/components/challenge-notification/challenge-notification.component.ts b/src/app/components/challenge-notification/challenge-notification.component.ts index 7c5ca57..7c4130d 100644 --- a/src/app/components/challenge-notification/challenge-notification.component.ts +++ b/src/app/components/challenge-notification/challenge-notification.component.ts @@ -1,5 +1,6 @@ import { Component, Input, Output, EventEmitter, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; import { Challenge } from '../../models/challenge.models'; import { ChallengeService } from '../../services/challenge.service'; import { finalize } from 'rxjs'; @@ -19,6 +20,7 @@ export class ChallengeNotificationComponent { @Output() close = new EventEmitter(); private readonly challengeService = inject(ChallengeService); + private readonly router = inject(Router); acceptingChallenge = false; decliningChallenge = false; @@ -35,8 +37,13 @@ export class ChallengeNotificationComponent { this.challengeService.acceptChallenge(this.challenge.id) .pipe(finalize(() => (this.acceptingChallenge = false))) .subscribe({ - next: () => { - this.accept.emit(this.challenge); + next: (acceptedChallenge) => { + this.accept.emit(acceptedChallenge); + if (acceptedChallenge.gameId) { + void this.router.navigate(['/game', acceptedChallenge.gameId]); + } else { + this.errorMessage = 'Challenge accepted, but no game was created.'; + } }, error: (error) => { this.errorMessage = getErrorMessage(error, 'Failed to accept challenge'); diff --git a/src/app/pages/welcome/nav-export.html b/src/app/pages/welcome/nav-export.html new file mode 100644 index 0000000..4bbe709 --- /dev/null +++ b/src/app/pages/welcome/nav-export.html @@ -0,0 +1,700 @@ + + + + + +NowChess Nav — Logged In + + + + + + + + + +
+ + + + + + + + -- 2.52.0 From 3fa687c450dd637b7cfd339c016d8af9f7ad2f4a Mon Sep 17 00:00:00 2001 From: "Lala, Shahd" Date: Thu, 14 May 2026 19:13:03 +0000 Subject: [PATCH 2/9] fix: timer now in sync with backend --- src/app/models/game.models.ts | 6 + src/app/pages/game/game.component.html | 16 +-- src/app/pages/game/game.component.ts | 153 +++++-------------------- src/app/pages/game/game.facade.ts | 5 + 4 files changed, 47 insertions(+), 133 deletions(-) diff --git a/src/app/models/game.models.ts b/src/app/models/game.models.ts index 8caaa84..a79face 100644 --- a/src/app/models/game.models.ts +++ b/src/app/models/game.models.ts @@ -1,5 +1,10 @@ export type GameTurn = 'white' | 'black'; +export interface ClockState { + whiteRemainingMs: number; + blackRemainingMs: number; +} + export type GameStatus = | 'started' | 'check' @@ -26,6 +31,7 @@ export interface GameState { moves: string[]; undoAvailable: boolean; redoAvailable: boolean; + clock: ClockState | null; } export interface GameFull { diff --git a/src/app/pages/game/game.component.html b/src/app/pages/game/game.component.html index dac651b..e1d8167 100644 --- a/src/app/pages/game/game.component.html +++ b/src/app/pages/game/game.component.html @@ -20,30 +20,24 @@

} - @if (facade.isGameFinished && facade.gameCompletionMessage) { -
-

{{ facade.gameCompletionMessage }}

-

- Start a new game -

-
- }
- + + @if (hasTimer) {

Timers

White

-

{{ formatTimer(whiteTimerSeconds) }}

+

{{ formatTimer(whiteTimerMs) }}

Black

-

{{ formatTimer(blackTimerSeconds) }}

+

{{ formatTimer(blackTimerMs) }}

+ }
diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts index a7c14fb..fa12690 100644 --- a/src/app/pages/game/game.component.ts +++ b/src/app/pages/game/game.component.ts @@ -8,16 +8,8 @@ import { InputCardComponent } from '../../components/input-card/input-card.compo import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component'; import { GameFacade } from './game.facade'; -type TimerTurn = 'white' | 'black'; type BoardTheme = 'arabian' | 'classic'; -interface TimerSnapshot { - whiteSeconds: number; - blackSeconds: number; - turn: TimerTurn; - savedAt: number; -} - @Component({ selector: 'app-game', standalone: true, @@ -27,26 +19,28 @@ interface TimerSnapshot { styleUrl: './game.component.css' }) export class GameComponent implements OnInit, OnDestroy { - private static readonly TIMER_START_SECONDS = 10 * 60; private static readonly BOARD_THEME_STORAGE_KEY = 'nowchess.boardTheme'; private readonly route = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); readonly facade = inject(GameFacade); - whiteTimerSeconds = GameComponent.TIMER_START_SECONDS; - blackTimerSeconds = GameComponent.TIMER_START_SECONDS; + whiteTimerMs: number | null = null; + blackTimerMs: number | null = null; exportType: 'fen' | 'pgn' = 'fen'; boardTheme: BoardTheme = 'arabian'; isDarkMode = false; exportValue = ''; exportNotice = ''; private timerIntervalId: number | null = null; - private activeGameId = ''; + + get hasTimer(): boolean { + return this.facade.state?.clock != null; + } ngOnInit(): void { this.applyIncomingTheme(); this.syncThemeFromDocument(); this.boardTheme = this.resolveStoredBoardTheme(); - this.startDummyTimers(); + this.startClock(); this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => { const id = paramMap.get('gameId'); @@ -56,8 +50,6 @@ export class GameComponent implements OnInit, OnDestroy { return; } - this.activeGameId = id; - this.restoreTimers(id); this.facade.setGameId(id); this.syncExportValue(); }); @@ -67,8 +59,6 @@ export class GameComponent implements OnInit, OnDestroy { if (this.timerIntervalId !== null) { window.clearInterval(this.timerIntervalId); } - - this.persistTimers(this.resolveCurrentTurn()); } private syncThemeFromDocument(): void { @@ -122,40 +112,42 @@ export class GameComponent implements OnInit, OnDestroy { }); } - formatTimer(totalSeconds: number): string { - const safeSeconds = Math.max(0, totalSeconds); - const minutes = Math.floor(safeSeconds / 60) - .toString() - .padStart(2, '0'); - const seconds = (safeSeconds % 60).toString().padStart(2, '0'); + formatTimer(ms: number | null): string { + if (ms === null) { + return '--:--'; + } + if (ms < 0) { + return '—'; + } + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0'); + const seconds = (totalSeconds % 60).toString().padStart(2, '0'); return `${minutes}:${seconds}`; } - private startDummyTimers(): void { + private startClock(): void { if (this.timerIntervalId !== null) { return; } - - this.timerIntervalId = window.setInterval(() => { - this.tickDummyTimers(); - this.syncExportValue(); - }, 1000); + this.timerIntervalId = window.setInterval(() => this.tickClock(), 200); } - private tickDummyTimers(): void { + private tickClock(): void { const state = this.facade.state; - if (!state || this.facade.loading || this.facade.isGameFinished) { + const clock = state?.clock; + if (!clock || this.facade.isGameFinished) { + this.whiteTimerMs = null; + this.blackTimerMs = null; return; } - if (state.turn === 'white') { - this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - 1); - this.persistTimers('white'); - return; - } - - this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - 1); - this.persistTimers('black'); + const elapsed = Math.max(0, Date.now() - this.facade.clockSyncedAt); + const activeIsWhite = state!.turn === 'white'; + this.whiteTimerMs = + clock.whiteRemainingMs < 0 ? -1 : Math.max(0, clock.whiteRemainingMs - (activeIsWhite ? elapsed : 0)); + this.blackTimerMs = + clock.blackRemainingMs < 0 ? -1 : Math.max(0, clock.blackRemainingMs - (!activeIsWhite ? elapsed : 0)); + this.syncExportValue(); } private syncExportValue(): void { @@ -168,89 +160,6 @@ export class GameComponent implements OnInit, OnDestroy { this.exportValue = this.exportType === 'fen' ? state.fen : state.pgn; } - private restoreTimers(gameId: string): void { - const fallbackTurn = this.resolveCurrentTurn(); - const rawSnapshot = localStorage.getItem(this.getTimerStorageKey(gameId)); - if (!rawSnapshot) { - this.resetTimers(); - this.persistTimers(fallbackTurn); - return; - } - - const snapshot = this.parseSnapshot(rawSnapshot); - if (!snapshot) { - this.resetTimers(); - this.persistTimers(fallbackTurn); - return; - } - - this.applySnapshot(snapshot); - this.persistTimers(snapshot.turn); - } - - private parseSnapshot(rawSnapshot: string): TimerSnapshot | null { - try { - const parsed = JSON.parse(rawSnapshot) as Partial; - if ( - typeof parsed.whiteSeconds !== 'number' || - typeof parsed.blackSeconds !== 'number' || - (parsed.turn !== 'white' && parsed.turn !== 'black') || - typeof parsed.savedAt !== 'number' - ) { - return null; - } - - return { - whiteSeconds: Math.max(0, Math.floor(parsed.whiteSeconds)), - blackSeconds: Math.max(0, Math.floor(parsed.blackSeconds)), - turn: parsed.turn, - savedAt: parsed.savedAt - }; - } catch { - return null; - } - } - - private applySnapshot(snapshot: TimerSnapshot): void { - const elapsedSeconds = Math.max(0, Math.floor((Date.now() - snapshot.savedAt) / 1000)); - this.whiteTimerSeconds = snapshot.whiteSeconds; - this.blackTimerSeconds = snapshot.blackSeconds; - - if (snapshot.turn === 'white') { - this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - elapsedSeconds); - return; - } - - this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - elapsedSeconds); - } - - private persistTimers(turn: TimerTurn): void { - if (!this.activeGameId) { - return; - } - - const snapshot: TimerSnapshot = { - whiteSeconds: this.whiteTimerSeconds, - blackSeconds: this.blackTimerSeconds, - turn, - savedAt: Date.now() - }; - localStorage.setItem(this.getTimerStorageKey(this.activeGameId), JSON.stringify(snapshot)); - } - - private resolveCurrentTurn(): TimerTurn { - return this.facade.state?.turn ?? 'white'; - } - - private resetTimers(): void { - this.whiteTimerSeconds = GameComponent.TIMER_START_SECONDS; - this.blackTimerSeconds = GameComponent.TIMER_START_SECONDS; - } - - private getTimerStorageKey(gameId: string): string { - return `nowchess.timer.${gameId}`; - } - private resolveStoredBoardTheme(): BoardTheme { const stored = localStorage.getItem(GameComponent.BOARD_THEME_STORAGE_KEY); return stored === 'classic' ? 'classic' : 'arabian'; diff --git a/src/app/pages/game/game.facade.ts b/src/app/pages/game/game.facade.ts index d31ee04..90f1ca8 100644 --- a/src/app/pages/game/game.facade.ts +++ b/src/app/pages/game/game.facade.ts @@ -13,6 +13,7 @@ import { GameStreamService } from '../../services/game-stream.service'; export class GameFacade implements OnDestroy { gameId = ''; game: GameFull | null = null; + clockSyncedAt = 0; errorMessage = ''; moveInput = ''; fenInput = ''; @@ -119,6 +120,7 @@ export class GameFacade implements OnDestroy { next: (state) => { if (this.game) { this.game = { ...this.game, state }; + this.clockSyncedAt = Date.now(); this.updateGameCompletion(); } this.moveInput = ''; @@ -207,6 +209,7 @@ export class GameFacade implements OnDestroy { .subscribe({ next: (game) => { this.game = game; + this.clockSyncedAt = Date.now(); this.loading = false; this.updateGameCompletion(); this.startStreaming(); @@ -232,6 +235,7 @@ export class GameFacade implements OnDestroy { private applyStreamEvent(event: GameStreamEvent): void { if (event.type === 'gameFull') { this.game = event.game; + this.clockSyncedAt = Date.now(); this.boardSelection = this.boardSelectionService.clearSelection(); this.updateGameCompletion(); this.tryMakeBotMove(); @@ -241,6 +245,7 @@ export class GameFacade implements OnDestroy { if (event.type === 'gameState' && this.game) { const moveCountBefore = this.game.state.moves.length; this.game = { ...this.game, state: event.state }; + this.clockSyncedAt = Date.now(); this.updateGameCompletion(); if (event.state.moves.length !== moveCountBefore) { this.boardSelection = this.boardSelectionService.clearSelection(); -- 2.52.0 From e436dc871c1a415725345979d6f8d7862cdaeb0e Mon Sep 17 00:00:00 2001 From: "Lala, Shahd" Date: Thu, 14 May 2026 20:16:36 +0000 Subject: [PATCH 3/9] fix: console errors, notif error --- proxy.conf.json | 6 + .../challenge-create-dialog.component.html | 14 +- .../challenge-create-dialog.component.ts | 10 +- .../challenge-notification.component.ts | 2 +- .../login-dialog/login-dialog.component.html | 6 +- .../login-dialog/login-dialog.component.ts | 5 +- .../register-dialog.component.html | 11 +- .../register-dialog.component.ts | 5 +- .../components/toolbar/toolbar.component.css | 535 +++++++++++++++--- .../components/toolbar/toolbar.component.html | 206 ++++++- .../components/toolbar/toolbar.component.ts | 150 ++++- .../pages/challenges/challenges.component.ts | 2 +- src/app/services/challenge-event.service.ts | 14 + .../services/challenge-websocket.service.ts | 156 +++-- src/environments/environment.development.ts | 1 + src/environments/environment.staging.ts | 1 + src/environments/environment.ts | 1 + src/index.html | 3 + src/styles.css | 7 +- 19 files changed, 919 insertions(+), 216 deletions(-) diff --git a/proxy.conf.json b/proxy.conf.json index 1f2ef6a..fa98e8c 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -9,6 +9,12 @@ "secure": false, "changeOrigin": true }, + "/api/user/ws": { + "target": "http://localhost:8084", + "secure": false, + "changeOrigin": true, + "ws": true + }, "/api": { "target": "http://localhost:8080", "secure": false, diff --git a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html index 0911de4..9536f0d 100644 --- a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html +++ b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html @@ -15,7 +15,7 @@
+ placeholder="Enter opponent's username" required /> Username is required @@ -24,7 +24,7 @@
- @@ -34,7 +34,7 @@
- @@ -58,13 +58,11 @@
- +
- +
@@ -72,7 +70,7 @@
- diff --git a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts index 7c04003..2dc9a1f 100644 --- a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts +++ b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts @@ -124,11 +124,11 @@ export class ChallengeCreateDialogComponent implements OnInit, OnDestroy { this.errorMessage = ''; this.loading = true; + this.form.disable(); - const limitSeconds = Math.round((this.form.get('limitMinutes')?.value || 0) * 60); - const incrementSeconds = this.form.get('incrementSeconds')?.value || 0; - const ttlSeconds = this.form.get('ttlSeconds')?.value; - const color = (this.form.get('color')?.value || 'random') as PlayerColor; + const limitSeconds = Math.round((this.form.getRawValue().limitMinutes || 0) * 60); + const { incrementSeconds, ttlSeconds, color: rawColor } = this.form.getRawValue(); + const color = (rawColor || 'random') as PlayerColor; this.challengeService.sendChallenge(targetUsername, { timeControl: { @@ -138,7 +138,7 @@ export class ChallengeCreateDialogComponent implements OnInit, OnDestroy { color, ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined }) - .pipe(finalize(() => (this.loading = false))) + .pipe(finalize(() => { this.loading = false; this.form.enable(); })) .subscribe({ next: (challenge) => { // Challenge sent successfully - navigate to challenges page to view status diff --git a/src/app/components/challenge-notification/challenge-notification.component.ts b/src/app/components/challenge-notification/challenge-notification.component.ts index 7c4130d..87d760f 100644 --- a/src/app/components/challenge-notification/challenge-notification.component.ts +++ b/src/app/components/challenge-notification/challenge-notification.component.ts @@ -59,7 +59,7 @@ export class ChallengeNotificationComponent { this.decliningChallenge = true; this.errorMessage = ''; - this.challengeService.declineChallenge(this.challenge.id, { reason: 'Not interested' }) + this.challengeService.declineChallenge(this.challenge.id, { reason: 'generic' }) .pipe(finalize(() => (this.decliningChallenge = false))) .subscribe({ next: () => { diff --git a/src/app/components/login-dialog/login-dialog.component.html b/src/app/components/login-dialog/login-dialog.component.html index 0f3d9e4..0be3592 100644 --- a/src/app/components/login-dialog/login-dialog.component.html +++ b/src/app/components/login-dialog/login-dialog.component.html @@ -4,15 +4,13 @@
- + @if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) { Username must be at least 3 characters } - + @if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) { Password must be at least 6 characters } diff --git a/src/app/components/login-dialog/login-dialog.component.ts b/src/app/components/login-dialog/login-dialog.component.ts index 25a74a2..dc025dd 100644 --- a/src/app/components/login-dialog/login-dialog.component.ts +++ b/src/app/components/login-dialog/login-dialog.component.ts @@ -38,15 +38,18 @@ export class LoginDialogComponent { this.isLoading = true; this.errorMessage = null; + this.loginForm.disable(); - const { username, password } = this.loginForm.value; + const { username, password } = this.loginForm.getRawValue(); this.authService.login(username, password).subscribe({ next: () => { this.isLoading = false; + this.loginForm.enable(); this.onSuccess.emit(); }, error: (err) => { this.isLoading = false; + this.loginForm.enable(); this.errorMessage = err.error?.message || 'Login failed. Please try again.'; } }); diff --git a/src/app/components/register-dialog/register-dialog.component.html b/src/app/components/register-dialog/register-dialog.component.html index 6562df8..4c75996 100644 --- a/src/app/components/register-dialog/register-dialog.component.html +++ b/src/app/components/register-dialog/register-dialog.component.html @@ -3,26 +3,23 @@
CREATE ACCOUNT
- + @if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) { Username must be at least 3 characters } - + @if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) { Please enter a valid email } - + @if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) { Password must be at least 6 characters } + placeholder="Confirm Password" /> @if (errorMessage) {
{{ errorMessage }}
diff --git a/src/app/components/register-dialog/register-dialog.component.ts b/src/app/components/register-dialog/register-dialog.component.ts index ef11842..ae3e9cc 100644 --- a/src/app/components/register-dialog/register-dialog.component.ts +++ b/src/app/components/register-dialog/register-dialog.component.ts @@ -46,15 +46,18 @@ export class RegisterDialogComponent { this.isLoading = true; this.errorMessage = null; + this.registerForm.disable(); - const { username, email, password: pwd } = this.registerForm.value; + const { username, email, password: pwd } = this.registerForm.getRawValue(); this.authService.register(username, pwd, email).subscribe({ next: () => { this.isLoading = false; + this.registerForm.enable(); this.onSuccess.emit(); }, error: (err) => { this.isLoading = false; + this.registerForm.enable(); this.errorMessage = err.error?.message || 'Registration failed. Please try again.'; } diff --git a/src/app/components/toolbar/toolbar.component.css b/src/app/components/toolbar/toolbar.component.css index be8f759..b84e9fb 100644 --- a/src/app/components/toolbar/toolbar.component.css +++ b/src/app/components/toolbar/toolbar.component.css @@ -1,84 +1,487 @@ -@import '../../button-template.css'; - -.navbar { - background: rgba(8, 6, 28, 0.85); - backdrop-filter: blur(8px); - box-shadow: 0 4px 20px rgba(0, 210, 255, 0.15); - border-bottom: 1px solid rgba(0, 210, 255, 0.2); - border-radius: 0; - padding: 0.75rem 1rem; +/* ============ THEME TOKENS ============ */ +:host { + /* Light mode: warm sunset palette from background gradient */ + --nc-accent: #ff6b3d; + --nc-accent-hover: rgba(255, 107, 61, 0.15); + --nc-accent-badge: rgba(255, 107, 61, 0.9); + --nc-badge-text: #1a0800; + --nc-surface: rgba(26, 24, 56, 0.97); + --nc-nav-bg: linear-gradient(180deg, rgba(26,24,56,0.88) 0%, rgba(46,32,80,0.6) 70%, rgba(74,41,98,0) 100%); + --nc-text: #fff; + --nc-text-muted: rgba(255,255,255,0.7); + --nc-text-dim: rgba(255,255,255,0.45); + --nc-border: rgba(255,255,255,0.1); + --nc-popover-glow: 0 20px 60px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,107,61,0.18); + --nc-unread-dot: #ff6b3d; + --nc-avatar-a: #d44d4a; + --nc-avatar-b: #8b3a6b; + --nc-danger: #ff7a7a; } -.navbar-brand { - font-size: 1.5rem; - font-weight: bold; - color: var(--bb-title) !important; - font-family: 'Bebas Neue', sans-serif; - letter-spacing: 1px; - cursor: pointer; +:host-context(html[data-theme='dark']) { + /* Dark mode: blue neon palette */ + --nc-accent: #00d5ff; + --nc-accent-hover: rgba(0, 213, 255, 0.12); + --nc-accent-badge: #00d5ff; + --nc-badge-text: #04000f; + --nc-surface: rgba(8, 6, 28, 0.97); + --nc-nav-bg: linear-gradient(180deg, rgba(8,5,20,0.88) 0%, rgba(8,5,20,0.58) 70%, rgba(8,5,20,0) 100%); + --nc-text: #fff; + --nc-text-muted: rgba(255,255,255,0.65); + --nc-text-dim: rgba(255,255,255,0.4); + --nc-border: rgba(255,255,255,0.08); + --nc-popover-glow: 0 20px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(0,213,255,0.15); + --nc-unread-dot: #00d5ff; + --nc-avatar-a: #00d5ff; + --nc-avatar-b: #1a5fa8; + --nc-danger: #ff7a7a; } -.gap-2 { - gap: 0.5rem; -} - -.user-section { +/* ============ NAV CONTAINER ============ */ +.nc-nav { + position: fixed; + top: 0; left: 0; right: 0; + height: 56px; + z-index: 100; + display: flex; align-items: center; + padding: 0 24px; + background: var(--nc-nav-bg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + font-family: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } -.me-btn { - background: rgba(0, 210, 255, 0.1); - color: var(--bb-title); - border: 1px solid var(--bb-border); - border-radius: 2px; - padding: 0.5rem 0.8rem; - font-family: 'Space Mono', monospace; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.5px; +/* ============ LOGO ============ */ +.nc-logo { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; cursor: pointer; - transition: all 0.2s ease; - display: inline-flex; + user-select: none; +} + +.nc-logo-mark { + width: 24px; height: 24px; + background: var(--nc-accent); + display: flex; align-items: center; justify-content: center; - outline: none; + font-weight: 800; + color: var(--nc-badge-text); + font-size: 14px; +} + +.nc-logo-text { + font-size: 15px; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--nc-text); +} + +/* ============ CENTER LINKS ============ */ +.nc-links { + flex: 1; + display: flex; + justify-content: center; + gap: 4px; +} + +.nc-link { + background: transparent; + border: none; + color: var(--nc-text-muted); + padding: 8px 14px; + font-size: 12px; + font-family: inherit; + letter-spacing: 0.08em; + font-weight: 500; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + position: relative; + transition: color 0.15s; +} + +.nc-link:hover { color: var(--nc-text); } + +.nc-link::after { + content: ""; + position: absolute; + bottom: 2px; left: 14px; right: 14px; + height: 1px; + background: var(--nc-accent); + opacity: 0; + transition: opacity 0.15s; +} + +.nc-link:hover::after { opacity: 1; } + +/* ============ RIGHT CLUSTER ============ */ +.nc-right { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + margin-left: auto; +} + +/* ============ BELL ============ */ +.nc-bell { + width: 36px; height: 36px; + border: 1px solid var(--nc-border); + background: transparent; + color: var(--nc-text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + position: relative; + transition: background 0.15s, color 0.15s; + font-family: inherit; +} + +.nc-bell:hover, +.nc-bell.is-open { + background: var(--nc-accent-hover); + color: var(--nc-text); +} + +/* ============ BADGE ============ */ +.nc-badge { + position: absolute; + top: 5px; right: 5px; + min-width: 14px; height: 14px; + border-radius: 7px; + background: var(--nc-accent-badge); + color: var(--nc-badge-text); + font-size: 9px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + padding: 0 3px; +} + +/* ============ PROFILE BUTTON ============ */ +.nc-profile { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px 4px 4px; + height: 36px; + border: 1px solid var(--nc-border); + background: transparent; + cursor: pointer; + color: var(--nc-text-muted); + font-family: inherit; + transition: background 0.15s, color 0.15s; +} + +.nc-profile:hover, +.nc-profile.is-open { + background: var(--nc-accent-hover); + color: var(--nc-text); +} + +.nc-profile-name { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; +} + +.nc-chevron { opacity: 0.5; } + +/* ============ AVATAR ============ */ +.nc-avatar { + border-radius: 50%; + background: linear-gradient(135deg, var(--nc-avatar-a) 0%, var(--nc-avatar-b) 100%); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + letter-spacing: 0.02em; + flex-shrink: 0; +} + +.nc-avatar-sm { width: 26px; height: 26px; font-size: 11px; } +.nc-avatar-md { width: 40px; height: 40px; font-size: 17px; } + +/* ============ DROPDOWN WRAPPER ============ */ +.nc-dropdown-wrap { position: relative; } + +/* ============ POPOVERS ============ */ +.nc-popover { + position: absolute; + top: calc(100% + 10px); + right: 0; + background: var(--nc-surface); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--nc-border); + box-shadow: var(--nc-popover-glow); + z-index: 200; + overflow: hidden; +} + +/* ============ NOTIFICATIONS PANEL ============ */ +.nc-notif { width: 360px; } + +.nc-notif-header { + padding: 14px 18px; + border-bottom: 1px solid rgba(255,255,255,0.06); + display: flex; + justify-content: space-between; + align-items: center; +} + +.nc-notif-header-title { + font-size: 11px; + letter-spacing: 0.22em; + color: var(--nc-text-muted); + text-transform: uppercase; + font-weight: 600; +} + +.nc-notif-list { max-height: 420px; overflow-y: auto; } + +.nc-notif-empty { + padding: 24px 18px; + text-align: center; + font-size: 13px; + color: var(--nc-text-dim); + letter-spacing: 0.04em; +} + +.nc-notif-row { + padding: 14px 18px; + border-bottom: 1px solid rgba(255,255,255,0.04); + position: relative; + display: flex; + gap: 12px; + align-items: flex-start; +} + +.nc-notif-row.is-unread { background: rgba(255,255,255,0.03); } + +.nc-notif-row.is-unread::before { + content: ""; + position: absolute; + left: 6px; top: 22px; + width: 4px; height: 4px; + border-radius: 50%; + background: var(--nc-unread-dot); +} + +.nc-notif-icon { + width: 32px; height: 32px; + flex-shrink: 0; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.12); + display: flex; + align-items: center; + justify-content: center; + color: var(--nc-accent); +} + +.nc-notif-body { flex: 1; min-width: 0; } + +.nc-notif-text { + font-size: 13px; + color: var(--nc-text); + line-height: 1.35; +} + +.nc-notif-text b { font-weight: 600; } + +.nc-notif-meta { + font-size: 10px; + color: var(--nc-text-dim); + margin-top: 4px; + letter-spacing: 0.08em; text-transform: uppercase; } -.me-btn:hover { - background: rgba(0, 210, 255, 0.2); - border-color: var(--bb-tag); - box-shadow: 0 0 10px rgba(0, 210, 255, 0.4); - transform: scale(1.05); -} - -.me-btn:active { - transform: scale(0.98); -} - -/* Sunset Mode */ -.sunset .navbar { - background: rgba(20, 5, 45, 0.85); - border-bottom-color: rgba(255, 64, 207, 0.2); - box-shadow: 0 4px 20px rgba(242, 106, 226, 0.15); -} - -.sunset .me-btn { - background: rgba(242, 106, 226, 0.1); - border-color: var(--bb-border); -} - -.sunset .me-btn:hover { - background: rgba(242, 106, 226, 0.2); - border-color: var(--bb-tag); - box-shadow: 0 0 10px rgba(242, 106, 226, 0.4); -} - -.container-fluid { +.nc-notif-actions { display: flex; + gap: 6px; + margin-top: 10px; +} + +.nc-btn-accept, +.nc-btn-decline { + padding: 6px 12px; + font-size: 10px; + font-family: inherit; + letter-spacing: 0.18em; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + text-transform: uppercase; + font-weight: 600; + border: none; + transition: opacity 0.15s; +} + +.nc-btn-accept { + background: var(--nc-accent); + color: var(--nc-badge-text); + font-weight: 700; +} + +.nc-btn-decline { + background: transparent; + color: var(--nc-text-muted); + border: 1px solid rgba(255,255,255,0.15); +} + +.nc-btn-accept:disabled, +.nc-btn-decline:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.nc-notif-footer { + padding: 10px 18px; + border-top: 1px solid rgba(255,255,255,0.06); +} + +.nc-view-all { + width: 100%; + background: transparent; + border: none; + color: var(--nc-text-dim); + font-size: 11px; + font-family: inherit; + letter-spacing: 0.2em; + cursor: pointer; + text-transform: uppercase; + padding: 6px 0; + transition: color 0.15s; +} + +.nc-view-all:hover { color: var(--nc-text-muted); } + +/* ============ PROFILE MENU ============ */ +.nc-menu { width: 250px; } + +.nc-menu-header { + padding: 16px 16px 14px; + border-bottom: 1px solid rgba(255,255,255,0.06); + display: flex; + gap: 12px; align-items: center; } -.ms-auto { - margin-left: auto; +.nc-menu-user-name { + font-size: 14px; + color: var(--nc-text); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } + +.nc-menu-user-sub { + font-size: 11px; + color: var(--nc-text-dim); + margin-top: 2px; + letter-spacing: 0.06em; +} + +.nc-menu-group { padding: 6px 0; } + +.nc-menu-group + .nc-menu-group { + border-top: 1px solid rgba(255,255,255,0.06); +} + +.nc-menu-item { + padding: 9px 16px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + color: var(--nc-text-muted); + font-size: 13px; + font-family: inherit; + background: transparent; + border: none; + width: 100%; + text-align: left; + transition: background 0.12s, color 0.12s; +} + +.nc-menu-item:hover { + background: var(--nc-accent-hover); + color: var(--nc-accent); +} + +.nc-menu-item.danger { color: var(--nc-danger); } +.nc-menu-item.danger:hover { background: rgba(255,122,122,0.08); color: var(--nc-danger); } + +.nc-menu-icon { opacity: 0.85; display: inline-flex; } +.nc-menu-label { flex: 1; } + +/* ============ DARK MODE TOGGLE PILL ============ */ +.nc-toggle { + width: 28px; height: 16px; + border-radius: 8px; + background: rgba(255,255,255,0.15); + position: relative; + flex-shrink: 0; + transition: background 0.2s; +} + +.nc-toggle.is-on { background: var(--nc-accent); } + +.nc-toggle::after { + content: ""; + position: absolute; + top: 2px; left: 2px; + width: 12px; height: 12px; + border-radius: 50%; + background: #fff; + transition: left 0.2s; +} + +.nc-toggle.is-on::after { left: 14px; } + +/* ============ AUTH BUTTONS (logged out) ============ */ +.nc-auth-btn { + background: transparent; + border: 1px solid var(--nc-border); + color: var(--nc-text-muted); + padding: 7px 14px; + font-size: 11px; + font-family: inherit; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.nc-auth-btn:hover { + background: rgba(255,255,255,0.06); + color: var(--nc-text); +} + +.nc-auth-btn--primary { + background: var(--nc-accent); + border-color: var(--nc-accent); + color: var(--nc-badge-text); +} + +.nc-auth-btn--primary:hover { + filter: brightness(1.1); + color: var(--nc-badge-text); +} + +/* ============ NOTIF SCROLLBAR ============ */ +.nc-notif-list::-webkit-scrollbar { width: 6px; } +.nc-notif-list::-webkit-scrollbar-track { background: transparent; } +.nc-notif-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; } diff --git a/src/app/components/toolbar/toolbar.component.html b/src/app/components/toolbar/toolbar.component.html index cba57a3..5526099 100644 --- a/src/app/components/toolbar/toolbar.component.html +++ b/src/app/components/toolbar/toolbar.component.html @@ -1,28 +1,188 @@ -
+
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)); + } +} -- 2.52.0 From 43b7f5ccd320a8a322c40186ea4eade3ec194f15 Mon Sep 17 00:00:00 2001 From: Shahd Lala Date: Fri, 15 May 2026 02:15:10 +0200 Subject: [PATCH 7/9] fix: cleanup --- src/app/pages/welcome/Game (1).html | 1065 --------------------------- 1 file changed, 1065 deletions(-) delete mode 100644 src/app/pages/welcome/Game (1).html diff --git a/src/app/pages/welcome/Game (1).html b/src/app/pages/welcome/Game (1).html deleted file mode 100644 index e9ba7a5..0000000 --- a/src/app/pages/welcome/Game (1).html +++ /dev/null @@ -1,1065 +0,0 @@ - - - - - -NowChess — Game - - - - - - - - - - - - -
- - - - - -
-
-

- Rapid · 10|0 - Rated -

-
- - ID bDc1rDUF - - - - Move 3 - - Started 4m ago -
-
-
- - -
-
- - -
- - -
- - -
-
M
-
-
- magnus_42 - 1840 -
-
- - -
-
-
09:42
-
- - -
-
- - Your turn — magnus_42 played h2h4 -
- YOU PLAY WHITE -
- - -
-
-
- - -
-
S
-
-
- Sha (you) - 1842 -
-
- -
-
-
09:38
-
- - -
- - - -
- -
- - - - -
-
- - -
Copied
- - - - - -- 2.52.0 From 770be4251327513daf4efffe50ee2155b749c4c5 Mon Sep 17 00:00:00 2001 From: Shahd Lala Date: Fri, 15 May 2026 02:15:22 +0200 Subject: [PATCH 8/9] fix: cleanup --- src/app/pages/welcome/nav-export.html | 700 -------------------------- 1 file changed, 700 deletions(-) delete mode 100644 src/app/pages/welcome/nav-export.html diff --git a/src/app/pages/welcome/nav-export.html b/src/app/pages/welcome/nav-export.html deleted file mode 100644 index 4bbe709..0000000 --- a/src/app/pages/welcome/nav-export.html +++ /dev/null @@ -1,700 +0,0 @@ - - - - - -NowChess Nav — Logged In - - - - - - - - - -
- - - - - - - - -- 2.52.0 From 68d702738326edf5ef84898f58b15eebd3921fce Mon Sep 17 00:00:00 2001 From: Shahd Lala Date: Fri, 15 May 2026 02:15:40 +0200 Subject: [PATCH 9/9] fix: cleanup --- src/app/pages/welcome/welcome.component.html | 277 ------------------- 1 file changed, 277 deletions(-) delete mode 100644 src/app/pages/welcome/welcome.component.html diff --git a/src/app/pages/welcome/welcome.component.html b/src/app/pages/welcome/welcome.component.html deleted file mode 100644 index e192bff..0000000 --- a/src/app/pages/welcome/welcome.component.html +++ /dev/null @@ -1,277 +0,0 @@ -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
JOIN
-
JOIN
GAME
- -
-
-
-
-
-
- - - @if (showMeatEmoji) { -
- 🍖 -
- } -
- -
-
-
-
-
-
-
-
-
- OPEN 24/7 -
-
-
BOT
-
PLAY WITH
A BOT
- -
-
-
-
-
-
-
- -
- Player One -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
WELCOME
-
WELCOME TO
NOWCHESS
-
Play your next move from the skyline.
- -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- MORE -
-
-
OPTIONS
-
MORE
OPTIONS
- -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
- - - @if (showSpeechBubble) { -
-
-
{{ bubbleMessage }}
-
-
-
- } - - - @if (isZoomedIn) { -
-
-
-
- Player 2 - @if (showSecondSpeechBubble) { -
-
Feed me! 🍖
-
-
- } - @if (showHappyBubble) { -
-
Happy meow! 😸
-
-
- } -
-
- - - @if (showMeatEmoji) { -
- 🍖 -
- } -
-
- } - -
-
-
- - @if (showDifficultyDialog) { -
-
-
SELECT DIFFICULTY
-
- - - -
-
-
- } - - @if (showOptionsDialog) { -
-
-
MORE OPTIONS
-
- -
-
-
- } - - @if (showJoinDialog) { -
-
-
JOIN GAME
- -
- - -
-
-
- } - - @if (showImportDialog) { -
-
-
IMPORT GAME
-
- - -
- -
- - -
-
-
- } - - @if (showChallengeDialog) { -
-
- -
-
- } - - @if (errorMessage) { -

{{ errorMessage }}

- } -
\ No newline at end of file -- 2.52.0