- @if (facade.loading) {
-
Loading game state...
- } @else if (facade.state) {
- @if (facade.isGameFinished && facade.gameCompletionMessage) {
-
- }
- @if (facade.isGameFinished && facade.gameCompletionMessage) {
-
- }
-
-
-
-
-
- Timers
-
-
White
-
{{ formatTimer(whiteTimerSeconds) }}
-
-
-
Black
-
{{ formatTimer(blackTimerSeconds) }}
-
-
-
+
+
-
-
-
-
-
-
- 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 a7c14fb..18b68c4 100644
--- a/src/app/pages/game/game.component.ts
+++ b/src/app/pages/game/game.component.ts
@@ -1,52 +1,115 @@
-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 TimerTurn = 'white' | 'black';
type BoardTheme = 'arabian' | 'classic';
-interface TimerSnapshot {
- whiteSeconds: number;
- blackSeconds: number;
- turn: TimerTurn;
- savedAt: number;
-}
+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 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;
- exportType: 'fen' | 'pgn' = 'fen';
- boardTheme: BoardTheme = 'arabian';
- isDarkMode = false;
- exportValue = '';
- exportNotice = '';
- private timerIntervalId: number | null = null;
- private activeGameId = '';
+ whiteTimerMs: number | null = null;
+ blackTimerMs: number | null = null;
+ boardTheme: BoardTheme = 'arabian';
+ flipped = false;
+ toastMessage = '';
+
+ 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.startDummyTimers();
+ this.startClock();
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => {
const id = paramMap.get('gameId');
@@ -55,11 +118,7 @@ export class GameComponent implements OnInit, OnDestroy {
this.facade.loading = false;
return;
}
-
- this.activeGameId = id;
- this.restoreTimers(id);
this.facade.setGameId(id);
- this.syncExportValue();
});
}
@@ -67,192 +126,91 @@ export class GameComponent implements OnInit, OnDestroy {
if (this.timerIntervalId !== null) {
window.clearInterval(this.timerIntervalId);
}
-
- this.persistTimers(this.resolveCurrentTurn());
- }
-
- 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(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');
+ // ββ 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 startDummyTimers(): void {
- if (this.timerIntervalId !== null) {
- return;
- }
-
- this.timerIntervalId = window.setInterval(() => {
- this.tickDummyTimers();
- this.syncExportValue();
- }, 1000);
+ // ββ Private βββββββββββββββββββββββββββββββββββββββββββββββββββ
+ private startClock(): void {
+ if (this.timerIntervalId !== null) return;
+ 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));
}
- private syncExportValue(): void {
- const state = this.facade.state;
- if (!state) {
- this.exportValue = '';
- return;
- }
-
- 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 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 d31ee04..ae5d4b0 100644
--- a/src/app/pages/game/game.facade.ts
+++ b/src/app/pages/game/game.facade.ts
@@ -8,11 +8,13 @@ import { GameCompletionService } from '../../services/game-completion.service';
import { GameImportService } from '../../services/game-import.service';
import { BoardSelectionService, BoardSelection } from '../../services/board-selection.service';
import { GameStreamService } from '../../services/game-stream.service';
+import { GameHistoryService } from '../../services/game-history.service';
@Injectable()
export class GameFacade implements OnDestroy {
gameId = '';
game: GameFull | null = null;
+ clockSyncedAt = 0;
errorMessage = '';
moveInput = '';
fenInput = '';
@@ -36,6 +38,7 @@ export class GameFacade implements OnDestroy {
private readonly importService = inject(GameImportService);
private readonly boardSelectionService = inject(BoardSelectionService);
private readonly streamService = inject(GameStreamService);
+ private readonly gameHistory = inject(GameHistoryService);
get state(): GameState | null {
return this.game?.state ?? null;
@@ -119,6 +122,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,8 +211,10 @@ export class GameFacade implements OnDestroy {
.subscribe({
next: (game) => {
this.game = game;
+ this.clockSyncedAt = Date.now();
this.loading = false;
this.updateGameCompletion();
+ this.gameHistory.recordGame(this.gameId);
this.startStreaming();
this.tryMakeBotMove();
},
@@ -223,15 +229,14 @@ 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 */ }
);
}
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 +246,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();
diff --git a/src/app/pages/games/games.component.css b/src/app/pages/games/games.component.css
new file mode 100644
index 0000000..ca11e68
--- /dev/null
+++ b/src/app/pages/games/games.component.css
@@ -0,0 +1,366 @@
+:host {
+ --nc-neon: #ff45c8;
+ --nc-bg: #06060d;
+ --nc-surface: rgba(20, 17, 42, 0.6);
+ --nc-text: #fff;
+ --nc-text-muted: rgba(255, 255, 255, 0.65);
+ --nc-text-dim: rgba(255, 255, 255, 0.45);
+ --nc-border: rgba(255, 255, 255, 0.08);
+ --nc-border-strong: rgba(255, 255, 255, 0.15);
+ --nc-success: #5ee5a1;
+ --nc-danger: #ff7a7a;
+ --nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ --nc-mono: "JetBrains Mono", "Fira Code", monospace;
+
+ display: block;
+ min-height: 100vh;
+ background: var(--nc-bg);
+ font-family: var(--nc-sans);
+ color: var(--nc-text);
+}
+
+:host-context(html:not([data-theme='dark'])) {
+ --nc-neon: #c026d3;
+ --nc-bg: #f5f0fc;
+ --nc-surface: rgba(255, 255, 255, 0.88);
+ --nc-text: #0f0022;
+ --nc-text-muted: rgba(15, 0, 34, 0.65);
+ --nc-text-dim: rgba(15, 0, 34, 0.4);
+ --nc-border: rgba(15, 0, 34, 0.1);
+ --nc-border-strong: rgba(15, 0, 34, 0.2);
+ --nc-success: #16a34a;
+ --nc-danger: #dc2626;
+}
+
+.games-shell {
+ padding-top: 72px;
+ min-height: 100vh;
+}
+
+.page {
+ max-width: 760px;
+ margin: 0 auto;
+ padding: 32px 20px 64px;
+}
+
+/* ββ Breadcrumb βββββββββββββββββββββββββββ */
+.crumb {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 28px;
+ font-size: 11px;
+ color: var(--nc-text-dim);
+ letter-spacing: 0.06em;
+}
+
+.crumb-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--nc-text-dim);
+ text-decoration: none;
+ transition: color 0.15s;
+}
+
+.crumb-link:hover { color: var(--nc-neon); }
+
+.crumb-sep { opacity: 0.4; }
+
+.crumb-current {
+ color: var(--nc-text-muted);
+ font-weight: 500;
+}
+
+/* ββ Header βββββββββββββββββββββββββββββββ */
+.page-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 28px;
+ flex-wrap: wrap;
+}
+
+.page-title {
+ margin: 0;
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ color: var(--nc-text);
+}
+
+/* ββ Tabs βββββββββββββββββββββββββββββββββ */
+.tabs {
+ display: flex;
+ gap: 2px;
+ background: var(--nc-surface);
+ border: 1px solid var(--nc-border);
+ padding: 3px;
+}
+
+.tab-btn {
+ background: transparent;
+ border: none;
+ color: var(--nc-text-muted);
+ font-family: var(--nc-sans);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ padding: 6px 16px;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ transition: background 0.15s, color 0.15s;
+}
+
+.tab-btn:hover { color: var(--nc-text); }
+
+.tab-btn.active {
+ background: var(--nc-neon);
+ color: #1a0014;
+}
+
+:host-context(html:not([data-theme='dark'])) .tab-btn.active { color: #fff; }
+
+.tab-badge {
+ background: rgba(255, 255, 255, 0.25);
+ color: inherit;
+ font-size: 9px;
+ font-weight: 700;
+ padding: 1px 5px;
+ border-radius: 8px;
+ line-height: 1.4;
+}
+
+/* ββ State messages βββββββββββββββββββββββ */
+.state-msg {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--nc-text-dim);
+ font-size: 13px;
+ padding: 32px 0;
+}
+
+.pulse {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--nc-neon);
+ animation: pulse-ring 1.4s ease-in-out infinite;
+}
+
+@keyframes pulse-ring {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.4; transform: scale(0.6); }
+}
+
+/* ββ Empty state ββββββββββββββββββββββββββ */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ padding: 64px 20px;
+ text-align: center;
+}
+
+.empty-icon {
+ width: 56px;
+ height: 56px;
+ border: 1px solid var(--nc-border);
+ background: var(--nc-surface);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--nc-text-dim);
+ margin-bottom: 8px;
+}
+
+.empty-title {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--nc-text);
+}
+
+.empty-sub {
+ margin: 0;
+ font-size: 13px;
+ color: var(--nc-text-dim);
+}
+
+.btn-primary {
+ margin-top: 12px;
+ background: var(--nc-neon);
+ color: #1a0014;
+ border: none;
+ padding: 9px 22px;
+ font-family: var(--nc-sans);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ text-decoration: none;
+ display: inline-flex;
+ cursor: pointer;
+ transition: filter 0.15s;
+}
+
+:host-context(html:not([data-theme='dark'])) .btn-primary { color: #fff; }
+
+.btn-primary:hover { filter: brightness(1.1); }
+
+/* ββ Game list ββββββββββββββββββββββββββββ */
+.game-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ border: 1px solid var(--nc-border);
+ background: var(--nc-surface);
+ overflow: hidden;
+}
+
+.game-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--nc-border);
+ transition: background 0.12s;
+}
+
+.game-row:last-child { border-bottom: none; }
+
+.game-row:hover { background: rgba(255, 255, 255, 0.03); }
+
+:host-context(html:not([data-theme='dark'])) .game-row:hover {
+ background: rgba(192, 38, 211, 0.04);
+}
+
+.game-row-main {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ min-width: 0;
+}
+
+.game-players {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.player { color: var(--nc-text); }
+
+.vs-sep {
+ font-size: 10px;
+ font-weight: 500;
+ color: var(--nc-text-dim);
+ letter-spacing: 0.1em;
+}
+
+.game-meta {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ color: var(--nc-text-dim);
+ font-family: var(--nc-mono);
+}
+
+.status-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.active-dot {
+ background: var(--nc-success);
+ box-shadow: 0 0 6px var(--nc-success);
+}
+
+.finished-dot {
+ background: var(--nc-text-dim);
+}
+
+.status-text { color: var(--nc-text-muted); }
+
+.meta-sep { opacity: 0.4; }
+
+.meta-item { color: var(--nc-text-dim); }
+
+.game-id-label {
+ font-size: 10px;
+ color: var(--nc-text-dim);
+ opacity: 0.7;
+}
+
+/* ββ Row actions ββββββββββββββββββββββββββ */
+.game-row-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.btn-resume,
+.btn-view {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 14px;
+ border: none;
+ font-family: var(--nc-sans);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ cursor: pointer;
+ transition: filter 0.15s;
+}
+
+.btn-resume {
+ background: var(--nc-neon);
+ color: #1a0014;
+}
+
+:host-context(html:not([data-theme='dark'])) .btn-resume { color: #fff; }
+
+.btn-resume:hover { filter: brightness(1.1); }
+
+.btn-view {
+ background: transparent;
+ color: var(--nc-text-muted);
+ border: 1px solid var(--nc-border-strong);
+}
+
+.btn-view:hover {
+ color: var(--nc-neon);
+ border-color: var(--nc-neon);
+}
+
+.btn-remove {
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--nc-text-dim);
+ width: 28px;
+ height: 28px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: color 0.15s, border-color 0.15s;
+}
+
+.btn-remove:hover {
+ color: var(--nc-danger);
+ border-color: var(--nc-border);
+}
diff --git a/src/app/pages/games/games.component.html b/src/app/pages/games/games.component.html
new file mode 100644
index 0000000..fee4887
--- /dev/null
+++ b/src/app/pages/games/games.component.html
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+
+
+
+ @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/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
-
-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
WELCOME
-
WELCOME TO
NOWCHESS
-
Play your next move from the skyline.
-
-
-
-
-
-
-
-
-
-
-
-
-
- MORE
-
-
-
OPTIONS
-
MORE
OPTIONS
-
-
-
-
-
-
-
-
-
-
-
- @if (showSpeechBubble) {
-
-
-
{{ bubbleMessage }}
-
-
-
- }
-
-
- @if (isZoomedIn) {
-
-
-
-
-

- @if (showSecondSpeechBubble) {
-
- }
- @if (showHappyBubble) {
-
- }
-
-
-
-
- @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
diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts
index 178b315..cf34768 100644
--- a/src/app/services/auth.service.ts
+++ b/src/app/services/auth.service.ts
@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable } from 'rxjs';
-import { map, tap } from 'rxjs/operators';
+import { map, switchMap, tap } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import { LoginRequest, RegisterRequest, RegisterResponse, LoginResponse, CurrentUser } from '../models/auth.models';
@@ -42,16 +42,9 @@ export class AuthService {
email
})
.pipe(
- tap((response) => {
- localStorage.setItem('username', response.username);
- localStorage.setItem('userId', response.id);
- this.currentUserSubject.next({
- id: response.id,
- username: response.username,
- rating: response.rating,
- createdAt: response.createdAt
- });
- })
+ switchMap((response) =>
+ this.login(username, password).pipe(map(() => response))
+ )
);
}
diff --git a/src/app/services/challenge-event.service.ts b/src/app/services/challenge-event.service.ts
index fca0c7d..33caae3 100644
--- a/src/app/services/challenge-event.service.ts
+++ b/src/app/services/challenge-event.service.ts
@@ -61,4 +61,18 @@ export class ChallengeEventService {
removeChallenge(challengeId: string): void {
this.onChallengeRemoved(challengeId);
}
+
+ /**
+ * Replace the full incoming list (used by HTTP polling)
+ */
+ setIncomingChallenges(challenges: Challenge[]): void {
+ this.incomingChallenges$.next(challenges);
+ }
+
+ /**
+ * Clear all incoming challenges (used on logout)
+ */
+ clear(): void {
+ this.incomingChallenges$.next([]);
+ }
}
diff --git a/src/app/services/challenge-websocket.service.ts b/src/app/services/challenge-websocket.service.ts
index d13131f..1a03907 100644
--- a/src/app/services/challenge-websocket.service.ts
+++ b/src/app/services/challenge-websocket.service.ts
@@ -1,135 +1,115 @@
import { Injectable, inject } from '@angular/core';
-import { Subject } from 'rxjs';
+import { Router } from '@angular/router';
+import { environment } from '../../environments/environment';
import { ChallengeEventService } from './challenge-event.service';
-import { Challenge } from '../models/challenge.models';
+import { ChallengeService } from './challenge.service';
-/**
- * Service to handle WebSocket connections for challenge events
- * Listens for incoming challenge notifications and emits them to ChallengeEventService
- */
@Injectable({ providedIn: 'root' })
export class ChallengeWebSocketService {
private readonly challengeEventService = inject(ChallengeEventService);
+ private readonly challengeService = inject(ChallengeService);
+ private readonly router = inject(Router);
+
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 3000;
+ private intentionalClose = false;
- /**
- * Initialize WebSocket connection for challenge events
- */
connect(): void {
- if (this.ws) {
- return; // Already connected
- }
+ if (this.ws) return;
- const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
- const wsUrl = `${wsProtocol}://${window.location.host}/ws/challenges`;
+ const token = localStorage.getItem('token');
+ if (!token) return;
+
+ const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`;
try {
- this.ws = new WebSocket(wsUrl);
+ this.intentionalClose = false;
+ this.ws = new WebSocket(url);
this.ws.onopen = () => {
- console.log('Challenge WebSocket connected');
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
- this.handleMessage(event.data);
+ this.handleMessage(event.data as string);
};
- this.ws.onerror = (error) => {
- console.error('Challenge WebSocket error:', error);
+ this.ws.onerror = () => {
+ // onclose fires right after, handles reconnect
};
this.ws.onclose = () => {
- console.log('Challenge WebSocket disconnected');
this.ws = null;
- this.attemptReconnect();
+ if (!this.intentionalClose) {
+ this.attemptReconnect();
+ }
};
- } catch (error) {
- console.error('Failed to create WebSocket:', error);
+ } catch {
this.attemptReconnect();
}
}
- /**
- * Close the WebSocket connection
- */
disconnect(): void {
+ this.intentionalClose = true;
+ this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
- /**
- * Send a message through WebSocket
- */
- send(message: any): void {
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
- this.ws.send(JSON.stringify(message));
- }
- }
-
- /**
- * Handle incoming WebSocket messages
- */
private handleMessage(data: string): void {
+ let message: Record;
try {
- const message = JSON.parse(data);
-
- if (!message.type) {
- return;
- }
-
- switch (message.type) {
- case 'challenge.received':
- if (message.challenge) {
- this.challengeEventService.onChallengeReceived(message.challenge as Challenge);
- }
- break;
-
- case 'challenge.accepted':
- if (message.challenge) {
- this.challengeEventService.onChallengeAccepted(message.challenge as Challenge);
- }
- break;
-
- case 'challenge.declined':
- if (message.challengeId) {
- this.challengeEventService.removeChallenge(message.challengeId);
- }
- break;
-
- case 'challenge.expired':
- if (message.challengeId) {
- this.challengeEventService.removeChallenge(message.challengeId);
- }
- break;
-
- default:
- console.debug('Unknown challenge message type:', message.type);
- }
- } catch (error) {
- console.error('Failed to parse WebSocket message:', error);
- }
- }
-
- /**
- * Attempt to reconnect to WebSocket
- */
- private attemptReconnect(): void {
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
- console.error('Max WebSocket reconnection attempts reached');
+ message = JSON.parse(data) as Record;
+ } catch {
return;
}
- this.reconnectAttempts++;
- console.log(`Attempting WebSocket reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
+ switch (message['type']) {
+ case 'CONNECTED':
+ break;
- setTimeout(() => {
- this.connect();
- }, this.reconnectDelay);
+ case 'challengeCreated': {
+ const challengeId = message['challengeId'] as string | undefined;
+ if (challengeId) {
+ this.challengeService.getChallenge(challengeId).subscribe({
+ next: challenge => this.challengeEventService.onChallengeReceived(challenge),
+ error: () => { /* challenge may have already expired */ }
+ });
+ }
+ break;
+ }
+
+ case 'challengeAccepted': {
+ const challengeId = message['challengeId'] as string | undefined;
+ const gameId = message['gameId'] as string | undefined;
+ if (challengeId) {
+ this.challengeEventService.removeChallenge(challengeId);
+ }
+ if (gameId) {
+ void this.router.navigate(['/game', gameId]);
+ }
+ break;
+ }
+
+ case 'challengeDeclined':
+ case 'challengeExpired':
+ case 'challengeCancelled': {
+ const challengeId = message['challengeId'] as string | undefined;
+ if (challengeId) {
+ this.challengeEventService.removeChallenge(challengeId);
+ }
+ break;
+ }
+ }
+ }
+
+ private attemptReconnect(): void {
+ if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
+ this.reconnectAttempts++;
+ setTimeout(() => { this.connect(); }, this.reconnectDelay);
}
}
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/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));
+ }
+}
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 9ad1e10..fa74ddd 100644
--- a/src/environments/environment.development.ts
+++ b/src/environments/environment.development.ts
@@ -2,6 +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/environments/environment.staging.ts b/src/environments/environment.staging.ts
index a2f73b6..208ecbe 100644
--- a/src/environments/environment.staging.ts
+++ b/src/environments/environment.staging.ts
@@ -7,5 +7,6 @@ export const environment = {
apiBaseUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
accountServiceUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
wsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
+ userWsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
apiPath: '/api/board/game'
};
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index 2478e38..58d8445 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -7,5 +7,6 @@ export const environment = {
apiBaseUrl: runtimeConfig.apiUrl || '',
accountServiceUrl: runtimeConfig.apiUrl || '',
wsBaseUrl: runtimeConfig.wsUrl,
+ userWsBaseUrl: runtimeConfig.wsUrl,
apiPath: '/api/board/game'
};
diff --git a/src/index.html b/src/index.html
index 1d5f35e..53e217f 100644
--- a/src/index.html
+++ b/src/index.html
@@ -6,6 +6,9 @@
+
+
+
diff --git a/src/styles.css b/src/styles.css
index 520eacd..f9eb90b 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -5,11 +5,12 @@
box-sizing: border-box;
}
-/* Light Mode (Default) */
+/* Light Mode (Default) β sunset gradient palette */
html:not([data-theme='dark']),
html:not([data-theme='dark']) body {
- background: linear-gradient(160deg, var(--color-primary-light), var(--color-secondary-mint));
- color: var(--color-text-primary);
+ background: linear-gradient(180deg, #1a1838 0%, #2e2050 25%, #4a2962 45%, #8b3a6b 65%, #d44d4a 85%, #ff6b3d 100%);
+ background-attachment: fixed;
+ color: #fff;
}
html:not([data-theme='dark']) body::before {