- @if (facade.loading) {
-
Loading game state...
- } @else if (facade.state) {
- @if (facade.isGameFinished && facade.gameCompletionMessage) {
-
- }
-
-
-
- @if (hasTimer) {
-
-
- Timers
-
-
White
-
{{ formatTimer(whiteTimerMs) }}
-
-
-
Black
-
{{ formatTimer(blackTimerMs) }}
-
-
-
- }
+
+
-
-
-
-
-
-
- Move History
-
- @if (facade.state.moves.length === 0) {
- No moves yet.
- } @else {
-
- @for (move of facade.state.moves; track $index) {
- -
- {{ $index + 1 }}.
- {{ move }}
-
- }
-
- }
-
-
-
+
+
+
+
+
+
+
+ @if (facade.loading) {
+
+
+ Loading game…
+
+ } @else if (facade.state) {
+ @if (facade.errorMessage) {
+
{{ facade.errorMessage }}
+ }
+
+
+ @if (facade.isGameFinished && facade.gameCompletionMessage) {
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ facade.state.turn === 'white' ? 'WHITE' : 'BLACK' }} TO MOVE
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
}
- @if (facade.errorMessage) {
-
{{ facade.errorMessage }}
- }
-
-
\ No newline at end of file
+
+
+
+
+@if (toastMessage) {
+
{{ toastMessage }}
+}
diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts
index fa12690..18b68c4 100644
--- a/src/app/pages/game/game.component.ts
+++ b/src/app/pages/game/game.component.ts
@@ -1,44 +1,113 @@
-import { CommonModule } from '@angular/common';
import { Component, DestroyRef, OnDestroy, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
-import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
+import { BoardActionsBarComponent } from '../../components/board-actions-bar/board-actions-bar.component';
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
-import { InputCardComponent } from '../../components/input-card/input-card.component';
+import { ExportPanelComponent } from '../../components/export-panel/export-panel.component';
+import { MoveHistoryComponent, MoveNavDirection } from '../../components/move-history/move-history.component';
+import { PlayerCardComponent } from '../../components/player-card/player-card.component';
import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component';
import { GameFacade } from './game.facade';
type BoardTheme = 'arabian' | 'classic';
+const LOW_TIME_THRESHOLD_MS = 60_000;
+const BOARD_THEME_KEY = 'nowchess.boardTheme';
+
@Component({
selector: 'app-game',
standalone: true,
- imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent, InputCardComponent, PromotionDialogComponent],
+ imports: [
+ RouterLink,
+ ChessBoardComponent,
+ PromotionDialogComponent,
+ PlayerCardComponent,
+ MoveHistoryComponent,
+ ExportPanelComponent,
+ BoardActionsBarComponent,
+ ],
providers: [GameFacade],
templateUrl: './game.component.html',
styleUrl: './game.component.css'
})
export class GameComponent implements OnInit, OnDestroy {
- private static readonly BOARD_THEME_STORAGE_KEY = 'nowchess.boardTheme';
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
readonly facade = inject(GameFacade);
+
whiteTimerMs: number | null = null;
blackTimerMs: number | null = null;
- exportType: 'fen' | 'pgn' = 'fen';
boardTheme: BoardTheme = 'arabian';
- isDarkMode = false;
- exportValue = '';
- exportNotice = '';
- private timerIntervalId: number | null = null;
+ flipped = false;
+ toastMessage = '';
- get hasTimer(): boolean {
- return this.facade.state?.clock != null;
+ private timerIntervalId: number | null = null;
+ private toastTimer: ReturnType
| null = null;
+
+ // ── Player display ──────────────────────────────────────────
+ get whitePlayerName(): string {
+ return this.facade.game?.white.displayName ?? 'White';
}
+ get blackPlayerName(): string {
+ return this.facade.game?.black.displayName ?? 'Black';
+ }
+
+ get whitePlayerInitial(): string {
+ return this.whitePlayerName.charAt(0).toUpperCase();
+ }
+
+ get blackPlayerInitial(): string {
+ return this.blackPlayerName.charAt(0).toUpperCase();
+ }
+
+ // ── Clocks ──────────────────────────────────────────────────
+ get whiteClock(): string {
+ return this.formatTimer(this.whiteTimerMs);
+ }
+
+ get blackClock(): string {
+ return this.formatTimer(this.blackTimerMs);
+ }
+
+ get isLowTimeWhite(): boolean {
+ return this.whiteTimerMs !== null && this.whiteTimerMs < LOW_TIME_THRESHOLD_MS;
+ }
+
+ get isLowTimeBlack(): boolean {
+ return this.blackTimerMs !== null && this.blackTimerMs < LOW_TIME_THRESHOLD_MS;
+ }
+
+ // ── Status message ───────────────────────────────────────────
+ get statusMessage(): string {
+ const state = this.facade.state;
+ if (!state) return '';
+
+ if (state.status === 'check') {
+ const who = state.turn === 'white' ? 'White' : 'Black';
+ return `${who} is in check`;
+ }
+
+ if (state.status === 'drawOffered') {
+ return 'Draw offer pending';
+ }
+
+ const last = state.moves.length > 0 ? state.moves[state.moves.length - 1] : null;
+ if (last) {
+ const mover = state.turn === 'white' ? this.blackPlayerName : this.whitePlayerName;
+ return `${mover} played ${last}`;
+ }
+
+ return 'Game started';
+ }
+
+ // ── Move number ──────────────────────────────────────────────
+ get moveNumber(): number {
+ return Math.ceil((this.facade.state?.moves.length ?? 0) / 2);
+ }
+
+ // ── Lifecycle ────────────────────────────────────────────────
ngOnInit(): void {
- this.applyIncomingTheme();
- this.syncThemeFromDocument();
this.boardTheme = this.resolveStoredBoardTheme();
this.startClock();
@@ -49,9 +118,7 @@ export class GameComponent implements OnInit, OnDestroy {
this.facade.loading = false;
return;
}
-
this.facade.setGameId(id);
- this.syncExportValue();
});
}
@@ -61,74 +128,57 @@ export class GameComponent implements OnInit, OnDestroy {
}
}
- private syncThemeFromDocument(): void {
- this.isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark';
- }
-
- private applyIncomingTheme(): void {
- const incomingTheme = window.history.state?.theme;
- if (incomingTheme === 'dark') {
- document.documentElement.setAttribute('data-theme', 'dark');
- localStorage.setItem('theme', 'dark');
- return;
- }
-
- if (incomingTheme === 'light') {
- document.documentElement.removeAttribute('data-theme');
- localStorage.removeItem('theme');
- }
- }
-
- setExportType(type: 'fen' | 'pgn'): void {
- this.exportType = type;
- this.exportNotice = '';
- this.syncExportValue();
- }
-
+ // ── Board theme ──────────────────────────────────────────────
setBoardTheme(theme: BoardTheme): void {
this.boardTheme = theme;
- localStorage.setItem(GameComponent.BOARD_THEME_STORAGE_KEY, theme);
+ localStorage.setItem(BOARD_THEME_KEY, theme);
}
- completeExport(): void {
- this.syncExportValue();
- if (!this.exportValue.trim()) {
- this.exportNotice = 'Nothing to export yet.';
- return;
- }
-
- if (!navigator.clipboard?.writeText) {
- this.exportNotice = 'Export is ready in the text box.';
- return;
- }
-
- void navigator.clipboard
- .writeText(this.exportValue)
- .then(() => {
- this.exportNotice = 'Copied to clipboard.';
- })
- .catch(() => {
- this.exportNotice = 'Export is ready in the text box.';
- });
+ // ── Board flip ───────────────────────────────────────────────
+ flipBoard(): void {
+ this.flipped = !this.flipped;
}
- formatTimer(ms: number | null): string {
- if (ms === null) {
- return '--:--';
- }
- if (ms < 0) {
- return '—';
- }
+ // ── Copy helpers ─────────────────────────────────────────────
+ copyGameId(): void {
+ void navigator.clipboard?.writeText(this.facade.gameId).then(() => this.showToast('Game ID copied'));
+ }
+
+ copyUrl(): void {
+ void navigator.clipboard?.writeText(window.location.href).then(() => this.showToast('Link copied'));
+ }
+
+ // ── Board actions ─────────────────────────────────────────────
+ onTakeback(): void {
+ this.showToast('Takeback requested');
+ }
+
+ onOfferDraw(): void {
+ this.showToast('Draw offered');
+ }
+
+ onResign(): void {
+ this.showToast('Resigned');
+ }
+
+ // ── Move history navigation ───────────────────────────────────
+ onMoveNavigate(_direction: MoveNavDirection): void {
+ // Visual-only for now; board always reflects live position.
+ }
+
+ // ── Timer helpers ─────────────────────────────────────────────
+ private 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 ───────────────────────────────────────────────────
private startClock(): void {
- if (this.timerIntervalId !== null) {
- return;
- }
+ if (this.timerIntervalId !== null) return;
this.timerIntervalId = window.setInterval(() => this.tickClock(), 200);
}
@@ -143,25 +193,24 @@ export class GameComponent implements OnInit, OnDestroy {
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();
+ 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));
}
- private syncExportValue(): void {
- const state = this.facade.state;
- if (!state) {
- this.exportValue = '';
- return;
- }
-
- this.exportValue = this.exportType === 'fen' ? state.fen : state.pgn;
+ private showToast(msg: string): void {
+ this.toastMessage = msg;
+ if (this.toastTimer !== null) clearTimeout(this.toastTimer);
+ this.toastTimer = setTimeout(() => {
+ this.toastMessage = '';
+ }, 1800);
}
private resolveStoredBoardTheme(): BoardTheme {
- const stored = localStorage.getItem(GameComponent.BOARD_THEME_STORAGE_KEY);
+ const stored = localStorage.getItem(BOARD_THEME_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 90f1ca8..24bbfed 100644
--- a/src/app/pages/game/game.facade.ts
+++ b/src/app/pages/game/game.facade.ts
@@ -226,9 +226,7 @@ export class GameFacade implements OnDestroy {
this.streamService.startStreaming(
this.gameId,
(event) => this.applyStreamEvent(event),
- () => {
- this.errorMessage = 'Live stream disconnected. Falling back to polling.';
- }
+ () => { /* polling fallback — not an error */ }
);
}
diff --git a/src/app/pages/welcome/Game (1).html b/src/app/pages/welcome/Game (1).html
new file mode 100644
index 0000000..e9ba7a5
--- /dev/null
+++ b/src/app/pages/welcome/Game (1).html
@@ -0,0 +1,1065 @@
+
+
+
+
+
+NowChess — Game
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
M
+
+
+ magnus_42
+ 1840
+
+
+
+ ♙
+
+
+
09:42
+
+
+
+
+
+
+ Your turn — magnus_42 played h2h4
+
+
YOU PLAY WHITE
+
+
+
+
+
+
+
+
S
+
+
+ Sha (you)
+ 1842
+
+
+ ♟
+
+
+
09:38
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Copied
+
+
+
+
+
diff --git a/src/app/services/game-api.service.ts b/src/app/services/game-api.service.ts
index c362f58..833b747 100644
--- a/src/app/services/game-api.service.ts
+++ b/src/app/services/game-api.service.ts
@@ -77,8 +77,7 @@ export class GameApiService {
}
streamGame(gameId: string): Observable {
- const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/stream`;
- const fallbackUrl = `${this.apiBase}${this.apiPath}/${gameId}/stream`;
- return this.streamHandler.createGameStream(wsUrl, fallbackUrl, gameId);
+ const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
+ return this.streamHandler.createGameStream(wsUrl, gameId);
}
}
diff --git a/src/app/services/stream-handler.service.ts b/src/app/services/stream-handler.service.ts
index c41bde6..520a7fe 100644
--- a/src/app/services/stream-handler.service.ts
+++ b/src/app/services/stream-handler.service.ts
@@ -2,26 +2,14 @@ import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { GameStreamEvent, ErrorEvent } from '../models/game.models';
+const WS_CONNECT_TIMEOUT_MS = 3000;
+
@Injectable({ providedIn: 'root' })
export class StreamHandlerService {
- createGameStream(wsUrl: string, fallbackUrl: string, gameId: string): Observable {
+ createGameStream(wsUrl: string, gameId: string): Observable {
return new Observable((observer) => {
const ws = new WebSocket(wsUrl);
- const abortController = new AbortController();
let connected = false;
- let fallbackActive = false;
-
- const parseEvent = (raw: string): GameStreamEvent | null => {
- if (!raw.trim()) {
- return null;
- }
-
- try {
- return JSON.parse(raw) as GameStreamEvent;
- } catch {
- return null;
- }
- };
const emitErrorEvent = (message: string): void => {
const errorEvent: ErrorEvent = {
@@ -31,67 +19,18 @@ export class StreamHandlerService {
observer.next(errorEvent);
};
- const startNdjsonFallback = async (): Promise => {
- if (fallbackActive) {
- return;
- }
-
- fallbackActive = true;
- console.log(`[StreamHandler] NDJSON fallback started for ${gameId}, URL:`, fallbackUrl);
-
- try {
- const response = await fetch(fallbackUrl, {
- headers: { Accept: 'application/x-ndjson' },
- signal: abortController.signal
- });
-
- if (!response.ok || !response.body) {
- console.error(`[StreamHandler] NDJSON fetch failed: HTTP ${response.status}`);
- emitErrorEvent(`Unable to open stream: HTTP ${response.status}`);
- observer.complete();
- return;
- }
-
- console.log(`[StreamHandler] NDJSON stream connected for ${gameId}`);
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
-
- while (true) {
- const { value, done } = await reader.read();
- if (done) {
- break;
- }
-
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split('\n');
- buffer = lines.pop() ?? '';
-
- for (const line of lines) {
- const event = parseEvent(line);
- if (event) {
- observer.next(event);
- }
- }
- }
-
- observer.complete();
- } catch (error) {
- if ((error as Error).name !== 'AbortError') {
- emitErrorEvent((error as Error).message);
- observer.error(error);
- }
- }
+ const failAndComplete = (reason: string): void => {
+ console.warn(`[StreamHandler] WebSocket failed for ${gameId}: ${reason}`);
+ emitErrorEvent(reason);
+ observer.complete();
};
- // Set timeout to fallback if WebSocket doesn't connect quickly
const connectionTimeoutId = setTimeout(() => {
- if (!connected && !fallbackActive) {
- console.warn(`[StreamHandler] WebSocket timeout for ${gameId}, attempting NDJSON fallback`);
+ if (!connected) {
ws.close();
- void startNdjsonFallback();
+ failAndComplete('WebSocket connection timed out — falling back to polling');
}
- }, 3000);
+ }, WS_CONNECT_TIMEOUT_MS);
ws.onopen = () => {
connected = true;
@@ -101,35 +40,30 @@ export class StreamHandlerService {
ws.onmessage = (message) => {
const payload = typeof message.data === 'string' ? message.data : '';
- const event = parseEvent(payload);
- if (event) {
+ if (!payload.trim()) return;
+ try {
+ const event = JSON.parse(payload) as GameStreamEvent;
observer.next(event);
+ } catch {
+ // ignore malformed frames
}
};
- ws.onerror = (error) => {
- console.warn(`[StreamHandler] WebSocket error for ${gameId}:`, error);
+ ws.onerror = () => {
clearTimeout(connectionTimeoutId);
- if (!connected && !fallbackActive) {
- void startNdjsonFallback();
+ if (!connected) {
+ failAndComplete('WebSocket connection error — falling back to polling');
}
};
ws.onclose = () => {
clearTimeout(connectionTimeoutId);
- console.warn(`[StreamHandler] WebSocket closed for ${gameId}, connected=${connected}`);
if (connected) {
- // Connection was established but closed, stream is complete
observer.complete();
- } else if (!fallbackActive) {
- // Connection never established, try fallback
- console.log(`[StreamHandler] Starting NDJSON fallback for ${gameId}`);
- void startNdjsonFallback();
}
};
return () => {
- abortController.abort();
ws.close();
};
});
diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts
index 758863f..fa74ddd 100644
--- a/src/environments/environment.development.ts
+++ b/src/environments/environment.development.ts
@@ -2,7 +2,7 @@ export const environment = {
production: false,
apiBaseUrl: '',
accountServiceUrl: '',
- wsBaseUrl: 'ws://localhost:8080',
+ wsBaseUrl: '',
userWsBaseUrl: 'ws://localhost:8084',
apiPath: '/api/board/game'
};
diff --git a/src/index.html b/src/index.html
index 25cc5c8..53e217f 100644
--- a/src/index.html
+++ b/src/index.html
@@ -8,7 +8,7 @@
-
+