4 Commits

Author SHA1 Message Date
shahdlala66 e83ec814d9 style: removed comments 2026-04-18 23:45:08 +02:00
shahdlala66 0d7bb0343c style: bootstrap added 2026-04-18 23:21:25 +02:00
shahdlala66 8b090e4d96 fix: cleaner components seperation 2026-04-18 21:39:16 +02:00
shahdlala66 3e8c7c4057 fix: structure 2026-04-18 11:36:14 +02:00
57 changed files with 705 additions and 436 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ yarn-error.log
# Visual Studio Code
.vscode/
.history/*
.angualar/
.angular/
# Miscellaneous
/.angular/cache
.sass-cache/
+29 -48
View File
@@ -1,60 +1,41 @@
# NowchessFrontend
# NowChess Frontend
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.24.
Angular 20 frontend for the NowChess board UI.
## Development server
## Tech stack
To start a local development server, run:
- Angular standalone components and route-based pages
- HTTP and streaming integration for live game updates
- Asset sprites loaded from `arabian-chess/`
## Project structure
- `src/app/pages` page-level containers (`welcome`, `game`)
- `src/app/components` reusable UI pieces (`chess-board`, `chess-piece`)
- `src/app/services` API and stream integration (`GameApiService`)
- `src/app/models` shared API/domain types
- `src/app/core/chess` chess domain utilities (FEN parsing and square lookup)
- `src/environments` environment-specific API base URLs
## Run locally
```bash
npm install
npm start
npm install
npm start
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
Open `http://localhost:4200`.
## Code scaffolding
Development environment defaults to:
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
- API: `http://localhost:8080`
- WebSocket: `ws://localhost:8080`
`src/environments/environment.ts` is production-oriented (`production: true`) and `src/environments/environment.development.ts` is development-oriented (`production: false`).
## Build and test
```bash
ng generate component component-name
npm run build
npm test
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+2 -2
View File
@@ -28,7 +28,7 @@
},
{
"glob": "**/*",
"input": "ARABIAN CHESS",
"input": "arabian-chess",
"output": "/arabian-chess"
}
],
@@ -90,7 +90,7 @@
},
{
"glob": "**/*",
"input": "ARABIAN CHESS",
"input": "arabian-chess",
"output": "/arabian-chess"
}
],

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 907 B

After

Width:  |  Height:  |  Size: 907 B

Before

Width:  |  Height:  |  Size: 919 B

After

Width:  |  Height:  |  Size: 919 B

Before

Width:  |  Height:  |  Size: 818 B

After

Width:  |  Height:  |  Size: 818 B

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 161 B

After

Width:  |  Height:  |  Size: 161 B

Before

Width:  |  Height:  |  Size: 188 B

After

Width:  |  Height:  |  Size: 188 B

Before

Width:  |  Height:  |  Size: 188 B

After

Width:  |  Height:  |  Size: 188 B

Before

Width:  |  Height:  |  Size: 237 B

After

Width:  |  Height:  |  Size: 237 B

Before

Width:  |  Height:  |  Size: 243 B

After

Width:  |  Height:  |  Size: 243 B

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 264 B

Before

Width:  |  Height:  |  Size: 244 B

After

Width:  |  Height:  |  Size: 244 B

Before

Width:  |  Height:  |  Size: 240 B

After

Width:  |  Height:  |  Size: 240 B

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 232 B

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 287 B

Before

Width:  |  Height:  |  Size: 211 B

After

Width:  |  Height:  |  Size: 211 B

Before

Width:  |  Height:  |  Size: 238 B

After

Width:  |  Height:  |  Size: 238 B

Before

Width:  |  Height:  |  Size: 227 B

After

Width:  |  Height:  |  Size: 227 B

Before

Width:  |  Height:  |  Size: 267 B

After

Width:  |  Height:  |  Size: 267 B

Before

Width:  |  Height:  |  Size: 300 B

After

Width:  |  Height:  |  Size: 300 B

Before

Width:  |  Height:  |  Size: 218 B

After

Width:  |  Height:  |  Size: 218 B

Before

Width:  |  Height:  |  Size: 244 B

After

Width:  |  Height:  |  Size: 244 B

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 245 B

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 229 B

Before

Width:  |  Height:  |  Size: 286 B

After

Width:  |  Height:  |  Size: 286 B

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 245 B

Before

Width:  |  Height:  |  Size: 266 B

After

Width:  |  Height:  |  Size: 266 B

Before

Width:  |  Height:  |  Size: 297 B

After

Width:  |  Height:  |  Size: 297 B

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 258 B

Before

Width:  |  Height:  |  Size: 263 B

After

Width:  |  Height:  |  Size: 263 B

Before

Width:  |  Height:  |  Size: 313 B

After

Width:  |  Height:  |  Size: 313 B

Before

Width:  |  Height:  |  Size: 251 B

After

Width:  |  Height:  |  Size: 251 B

Before

Width:  |  Height:  |  Size: 275 B

After

Width:  |  Height:  |  Size: 275 B

Before

Width:  |  Height:  |  Size: 305 B

After

Width:  |  Height:  |  Size: 305 B

Before

Width:  |  Height:  |  Size: 281 B

After

Width:  |  Height:  |  Size: 281 B

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 280 B

+31
View File
@@ -14,6 +14,7 @@
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0",
"bootstrap": "^5.3.8",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
@@ -2969,6 +2970,17 @@
"license": "MIT",
"optional": true
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
@@ -3820,6 +3832,25 @@
"dev": true,
"license": "ISC"
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+2 -1
View File
@@ -28,6 +28,7 @@
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0",
"bootstrap": "^5.3.8",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
@@ -45,4 +46,4 @@
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.2"
}
}
}
@@ -1,7 +1,28 @@
.piece {
width: clamp(40px, 9cqh, 130px);
height: clamp(40px, 9cqh, 130px);
width: clamp(50px, 11cqh, 160px);
height: clamp(50px, 11cqh, 160px);
display: block;
object-fit: contain;
pointer-events: none;
}
@media (max-width: 991px) {
.piece {
width: clamp(40px, 9cqh, 130px);
height: clamp(40px, 9cqh, 130px);
}
}
@media (max-width: 768px) {
.piece {
width: clamp(32px, 8cqh, 100px);
height: clamp(32px, 8cqh, 100px);
}
}
@media (max-width: 480px) {
.piece {
width: clamp(24px, 6cqh, 75px);
height: clamp(24px, 6cqh, 75px);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { GameTurn } from '../../models/game.models';
export function getPieceAtSquare(fen: string, targetSquare: string): string | null {
const placement = fen.split(' ')[0] ?? '';
const rows = placement.split('/');
if (rows.length !== 8 || targetSquare.length !== 2) {
return null;
}
const file = targetSquare.charCodeAt(0) - 97;
const rank = Number(targetSquare[1]);
const rowIndex = 8 - rank;
if (Number.isNaN(rank) || file < 0 || file > 7 || rowIndex < 0 || rowIndex > 7) {
return null;
}
let column = 0;
for (const symbol of rows[rowIndex]) {
if (symbol >= '1' && symbol <= '8') {
column += Number(symbol);
continue;
}
if (column === file) {
return symbol;
}
column += 1;
}
return null;
}
export function isPieceColor(pieceCode: string, turn: GameTurn): boolean {
const isWhitePiece = pieceCode === pieceCode.toUpperCase();
return (turn === 'white' && isWhitePiece) || (turn === 'black' && !isWhitePiece);
}
+8
View File
@@ -0,0 +1,8 @@
export function getErrorMessage(error: unknown, fallback: string): string {
if (!error || typeof error !== 'object') {
return fallback;
}
const httpError = error as { error?: { message?: unknown } };
return typeof httpError.error?.message === 'string' ? httpError.error.message : fallback;
}
+172 -80
View File
@@ -1,161 +1,253 @@
.game-shell {
height: 100dvh;
min-height: 100dvh;
padding: clamp(0.75rem, 2vw, 1.5rem);
overflow: hidden;
}
.game-card {
max-width: 1100px;
height: 100%;
max-width: 1400px;
margin: 0 auto;
background: #F3C8A0;
border: 2px solid #5A2C28;
border-radius: 12px;
padding: clamp(0.75rem, 1.5vw, 1.25rem);
padding: clamp(1rem, 2vw, 1.5rem);
box-shadow: 0 8px 24px rgba(90, 44, 40, 0.2);
display: flex;
flex-direction: column;
min-height: 0;
}
header {
margin-bottom: 1rem;
margin-bottom: 1.5rem;
}
h1,
h2 {
color: #5A2C28;
margin: 0 0 0.5rem;
font-size: clamp(1.5rem, 4vw, 2rem);
}
.meta {
margin: 0;
color: #5A2C28;
font-size: 0.95rem;
}
.back-link {
display: inline-block;
margin-bottom: 0.5rem;
color: #5A2C28;
text-decoration: none;
font-weight: 600;
}
.back-link:hover {
text-decoration: underline;
}
.top-section {
display: grid;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex: 0 0 auto;
}
.content-layout {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: minmax(220px, 0.95fr) minmax(0, 2.4fr) minmax(220px, 0.95fr);
gap: 0.75rem;
}
.center-column {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: #F3C8A0;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid #5A2C28;
}
.move-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.move-form label {
color: #5A2C28;
font-weight: 600;
white-space: nowrap;
}
.board-hint {
margin: 0;
color: #5A2C28;
margin: 0;
}
.center-column {
width: 100%;
}
.board-section {
flex: 1 1 auto;
min-height: 180px;
display: grid;
place-items: center;
container-type: size;
padding: clamp(0.35rem, 1vw, 0.75rem);
border-radius: 10px;
border: 2px solid #5A2C28;
background: #B9DAD1;
overflow: hidden;
border: 2px solid #5A2C28;
border-radius: 10px;
padding: clamp(0.5rem, 1vw, 1rem);
min-height: 400px;
container-type: size;
}
.import-card {
background: #E1EAA9;
border: 2px solid #5A2C28;
border-radius: 10px;
padding: 0.65rem;
display: grid;
align-self: start;
align-content: start;
gap: 0.5rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.import-card label {
color: #5A2C28;
font-weight: 700;
margin-bottom: 0.25rem;
}
input {
.import-card textarea {
border: 2px solid #5A2C28;
border-radius: 10px;
background: #B9DAD1;
padding: 0.6rem 0.75rem;
min-width: 180px;
}
textarea {
border: 2px solid #5A2C28;
border-radius: 10px;
border-radius: 8px;
background: #B9DAD1;
padding: 0.6rem 0.75rem;
resize: vertical;
min-height: 80px;
min-height: 100px;
font-family: inherit;
}
button {
.import-card input {
border: 2px solid #5A2C28;
border-radius: 10px;
border-radius: 8px;
background: #B9DAD1;
padding: 0.6rem 0.75rem;
font-family: inherit;
}
.btn {
border: 2px solid #5A2C28;
border-radius: 8px;
background: #C19EF5;
color: #5A2C28;
padding: 0.6rem 1rem;
cursor: pointer;
font-weight: 600;
transition: background-color 0.2s, color 0.2s;
}
button:hover {
.btn:hover {
background: #BA6D4B;
color: #F3C8A0;
}
.error {
margin-top: 0.5rem;
color: #5A2C28;
font-weight: 700;
.alert {
border-radius: 8px;
border: 2px solid #5A2C28;
}
@media (max-width: 920px) {
.content-layout {
grid-template-columns: 1fr;
grid-template-areas:
"center"
"fen"
"pgn";
@media (max-width: 991px) {
.game-card {
padding: clamp(0.75rem, 1.5vw, 1rem);
}
.center-column {
grid-area: center;
.board-section {
min-height: 350px;
}
.fen-card {
grid-area: fen;
}
.pgn-card {
grid-area: pgn;
h1,
h2 {
font-size: clamp(1.25rem, 3vw, 1.75rem);
}
}
@media (max-width: 768px) {
.game-shell {
padding: clamp(0.5rem, 1.5vw, 1rem);
}
.game-card {
padding: clamp(0.5rem, 1vw, 0.75rem);
}
header {
margin-bottom: 1rem;
}
h1,
h2 {
font-size: 1.25rem;
}
.meta {
font-size: 0.85rem;
}
.top-section {
padding: 0.5rem;
margin-bottom: 0.75rem;
}
.move-form {
flex-wrap: wrap;
}
.move-form label {
flex-basis: 100%;
margin-bottom: 0.5rem;
}
.board-section {
min-height: 300px;
}
.import-card {
padding: 0.75rem;
gap: 0.5rem;
}
.import-card label {
font-size: 0.9rem;
}
.import-card textarea,
.import-card input {
min-height: 70px;
font-size: 0.9rem;
padding: 0.5rem;
}
.btn {
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
}
}
@media (max-width: 480px) {
.game-shell {
padding: 0.5rem;
}
.game-card {
padding: 0.5rem;
border-radius: 8px;
}
header {
margin-bottom: 0.75rem;
}
h1 {
font-size: 1.1rem;
}
.meta {
font-size: 0.75rem;
}
.top-section {
padding: 0.4rem;
margin-bottom: 0.5rem;
}
.board-section {
min-height: 250px;
}
.import-card {
padding: 0.5rem;
gap: 0.35rem;
}
.import-card textarea,
.import-card input {
min-height: 50px;
font-size: 0.8rem;
padding: 0.4rem;
}
}
+66 -49
View File
@@ -1,63 +1,80 @@
<main class="game-shell">
<section class="game-card">
<header>
<header class="mb-3">
<a routerLink="/" class="back-link">Back</a>
<h1>1 vs 1 Game</h1>
<p class="meta">Game ID: <strong>{{ gameId }}</strong></p>
<h1 class="mb-2">1 vs 1 Game</h1>
<p class="meta mb-0">Game ID: <strong>{{ facade.gameId }}</strong></p>
</header>
@if (loading) {
@if (facade.loading) {
<p>Loading game state...</p>
} @else if (state) {
<section class="content-layout">
<aside class="import-card fen-card">
<label for="fenImport">Import FEN</label>
<textarea
id="fenImport"
name="fenImport"
[(ngModel)]="fenInput"
rows="4"
placeholder="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
></textarea>
<button type="button" (click)="importFen()">Load FEN</button>
</aside>
} @else if (facade.state) {
<div class="container-fluid">
<div class="row g-3">
<!-- Left Sidebar - FEN Import -->
<div class="col-lg-3 col-md-6 col-12 order-lg-1 order-2">
<aside class="import-card fen-card h-100">
<label for="fenImport">Import FEN</label>
<textarea
id="fenImport"
name="fenImport"
[(ngModel)]="facade.fenInput"
rows="4"
placeholder="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
></textarea>
<button type="button" class="btn w-100" (click)="facade.importFen()">Load FEN</button>
</aside>
</div>
<section class="center-column">
<section class="top-section">
<form class="move-form" (ngSubmit)="submitMove()">
<label for="uciMove">Play move (UCI)</label>
<input id="uciMove" name="uciMove" [(ngModel)]="moveInput" placeholder="e2e4" />
<button type="submit">Send Move</button>
</form>
<p class="board-hint">Click your piece to highlight legal targets.</p>
</section>
<!-- Center - Chess Board -->
<div class="col-lg-6 col-md-12 col-12 order-lg-2 order-1">
<section class="center-column d-flex flex-column h-100">
<div class="top-section mb-3">
<form class="move-form d-flex flex-wrap gap-2 mb-2" (ngSubmit)="facade.submitMove()">
<label for="uciMove" class="form-label mb-0">Play move (UCI)</label>
<input
id="uciMove"
name="uciMove"
class="form-control flex-grow-1"
[(ngModel)]="facade.moveInput"
placeholder="e2e4"
/>
<button type="submit" class="btn">Send Move</button>
</form>
<p class="board-hint small mb-0">Click your piece to highlight legal targets.</p>
</div>
<section class="board-section">
<app-chess-board
[fen]="state.fen"
[selectedSquare]="selectedSquare"
[highlightedSquares]="highlightedSquares"
(squareSelected)="onBoardSquareSelected($event)"
/>
</section>
</section>
<div class="board-section flex-grow-1 d-flex align-items-center justify-content-center">
<app-chess-board
[fen]="facade.state.fen"
[selectedSquare]="facade.selectedSquare"
[highlightedSquares]="facade.highlightedSquares"
(squareSelected)="facade.onBoardSquareSelected($event)"
/>
</div>
</section>
</div>
<aside class="import-card pgn-card">
<label for="pgnImport">Import PGN</label>
<textarea
id="pgnImport"
name="pgnImport"
[(ngModel)]="pgnInput"
rows="4"
placeholder="1. e4 e5 2. Nf3 Nc6 *"
></textarea>
<button type="button" (click)="importPgn()">Load PGN</button>
</aside>
</section>
<!-- Right Sidebar - PGN Import -->
<div class="col-lg-3 col-md-6 col-12 order-lg-3 order-3">
<aside class="import-card pgn-card h-100">
<label for="pgnImport">Import PGN</label>
<textarea
id="pgnImport"
name="pgnImport"
[(ngModel)]="facade.pgnInput"
rows="4"
placeholder="1. e4 e5 2. Nf3 Nc6 *"
></textarea>
<button type="button" class="btn w-100" (click)="facade.importPgn()">Load PGN</button>
</aside>
</div>
</div>
</div>
}
@if (errorMessage) {
<p class="error">{{ errorMessage }}</p>
@if (facade.errorMessage) {
<p class="alert alert-danger mt-3 mb-0">{{ facade.errorMessage }}</p>
}
</section>
</main>
+13 -251
View File
@@ -1,272 +1,34 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { interval, startWith, Subscription, switchMap } from 'rxjs';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models';
import { GameApiService } from '../../services/game-api.service';
import { GameFacade } from './game.facade';
@Component({
selector: 'app-game',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent],
providers: [GameFacade],
templateUrl: './game.component.html',
styleUrl: './game.component.css'
})
export class GameComponent implements OnInit, OnDestroy {
gameId = '';
game: GameFull | null = null;
errorMessage = '';
moveInput = '';
fenInput = '';
pgnInput = '';
loading = true;
selectedSquare: string | null = null;
highlightedSquares: string[] = [];
private selectedSquareMoves: LegalMove[] = [];
private streamSubscription: Subscription | null = null;
private pollSubscription: Subscription | null = null;
private routeSubscription: Subscription | null = null;
constructor(
private readonly route: ActivatedRoute,
private readonly router: Router,
private readonly gameApi: GameApiService
) {}
get state(): GameState | null {
return this.game?.state ?? null;
}
export class GameComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
readonly facade = inject(GameFacade);
ngOnInit(): void {
this.routeSubscription = this.route.paramMap.subscribe((paramMap) => {
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => {
const id = paramMap.get('gameId');
if (!id) {
this.errorMessage = 'Missing gameId in route.';
this.loading = false;
this.facade.errorMessage = 'Missing gameId in route.';
this.facade.loading = false;
return;
}
this.gameId = id;
this.loadGame();
this.facade.setGameId(id);
});
}
ngOnDestroy(): void {
this.routeSubscription?.unsubscribe();
this.streamSubscription?.unsubscribe();
this.pollSubscription?.unsubscribe();
}
onBoardSquareSelected(square: string): void {
if (!this.state) {
return;
}
if (this.selectedSquare && this.highlightedSquares.includes(square)) {
const selectedMove = this.selectedSquareMoves.find((move) => move.to === square);
if (selectedMove) {
this.moveInput = selectedMove.uci;
this.submitMove();
}
return;
}
const piece = this.getPieceAtSquare(this.state.fen, square);
if (!piece || !this.isCurrentTurnPiece(piece)) {
this.clearSelection();
return;
}
this.errorMessage = '';
this.gameApi.getLegalMoves(this.gameId, square).subscribe({
next: (response) => {
this.selectedSquare = square;
this.selectedSquareMoves = response.moves;
this.highlightedSquares = response.moves.map((move) => move.to);
},
error: () => {
this.clearSelection();
this.errorMessage = 'Could not load legal moves for selected square.';
}
});
}
submitMove(): void {
const uci = this.moveInput.trim();
if (!uci) {
return;
}
this.errorMessage = '';
this.gameApi.makeMove(this.gameId, uci).subscribe({
next: (state) => {
if (this.game) {
this.game = { ...this.game, state };
}
this.moveInput = '';
this.clearSelection();
},
error: (error: { error?: { message?: string } }) => {
this.errorMessage = error.error?.message ?? 'Move rejected.';
}
});
}
importFen(): void {
const fen = this.fenInput.trim();
if (!fen) {
this.errorMessage = 'Please provide a FEN string.';
return;
}
this.errorMessage = '';
this.gameApi.importFen(fen).subscribe({
next: (game) => {
this.fenInput = '';
this.pgnInput = '';
void this.router.navigate(['/game', game.gameId]);
},
error: (error: { error?: { message?: string } }) => {
this.errorMessage = error.error?.message ?? 'FEN import failed.';
}
});
}
importPgn(): void {
const pgn = this.pgnInput.trim();
if (!pgn) {
this.errorMessage = 'Please provide a PGN string.';
return;
}
this.errorMessage = '';
this.gameApi.importPgn(pgn).subscribe({
next: (game) => {
this.pgnInput = '';
this.fenInput = '';
void this.router.navigate(['/game', game.gameId]);
},
error: (error: { error?: { message?: string } }) => {
this.errorMessage = error.error?.message ?? 'PGN import failed.';
}
});
}
private loadGame(): void {
this.loading = true;
this.errorMessage = '';
this.clearSelection();
this.streamSubscription?.unsubscribe();
this.pollSubscription?.unsubscribe();
this.gameApi.getGame(this.gameId).subscribe({
next: (game) => {
this.game = game;
this.loading = false;
this.startStream();
this.startPolling();
},
error: (error: { error?: { message?: string } }) => {
this.errorMessage = error.error?.message ?? `Could not load game ${this.gameId}.`;
this.loading = false;
}
});
}
private startStream(): void {
this.streamSubscription = this.gameApi.streamGame(this.gameId).subscribe({
next: (event) => this.applyStreamEvent(event),
error: () => {
this.errorMessage = 'Live stream disconnected.';
}
});
}
private startPolling(): void {
this.pollSubscription = interval(1500)
.pipe(
startWith(0),
switchMap(() => this.gameApi.getGame(this.gameId))
)
.subscribe({
next: (game) => {
const previousMoves = this.game?.state.moves.join(',') ?? '';
this.game = game;
if (previousMoves !== game.state.moves.join(',')) {
this.clearSelection();
}
}
});
}
private applyStreamEvent(event: GameStreamEvent): void {
if (event.type === 'gameFull') {
this.game = event.game;
this.clearSelection();
return;
}
if (event.type === 'gameState' && this.game) {
const moveCountBefore = this.game.state.moves.length;
this.game = { ...this.game, state: event.state };
if (event.state.moves.length !== moveCountBefore) {
this.clearSelection();
}
return;
}
if (event.type === 'error') {
this.errorMessage = event.error.message;
}
}
private clearSelection(): void {
this.selectedSquare = null;
this.selectedSquareMoves = [];
this.highlightedSquares = [];
}
private isCurrentTurnPiece(pieceCode: string): boolean {
if (!this.state) {
return false;
}
const isWhitePiece = pieceCode === pieceCode.toUpperCase();
return (this.state.turn === 'white' && isWhitePiece) || (this.state.turn === 'black' && !isWhitePiece);
}
private getPieceAtSquare(fen: string, targetSquare: string): string | null {
const placement = fen.split(' ')[0] ?? '';
const rows = placement.split('/');
if (rows.length !== 8 || targetSquare.length !== 2) {
return null;
}
const file = targetSquare.charCodeAt(0) - 97;
const rank = Number(targetSquare[1]);
const rowIndex = 8 - rank;
if (Number.isNaN(rank) || file < 0 || file > 7 || rowIndex < 0 || rowIndex > 7) {
return null;
}
let column = 0;
for (const symbol of rows[rowIndex]) {
if (symbol >= '1' && symbol <= '8') {
column += Number(symbol);
continue;
}
if (column === file) {
return symbol;
}
column += 1;
}
return null;
}
}
+239
View File
@@ -0,0 +1,239 @@
import { DestroyRef, Injectable, OnDestroy, inject } from '@angular/core';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval, startWith, Subscription, switchMap } from 'rxjs';
import { getPieceAtSquare, isPieceColor } from '../../core/chess/fen.utils';
import { getErrorMessage } from '../../core/http/error-message.util';
import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models';
import { GameApiService } from '../../services/game-api.service';
@Injectable()
export class GameFacade implements OnDestroy {
gameId = '';
game: GameFull | null = null;
errorMessage = '';
moveInput = '';
fenInput = '';
pgnInput = '';
loading = true;
selectedSquare: string | null = null;
highlightedSquares: string[] = [];
private selectedSquareMoves: LegalMove[] = [];
private readonly router = inject(Router);
private readonly gameApi = inject(GameApiService);
private readonly destroyRef = inject(DestroyRef);
private streamSubscription: Subscription | null = null;
private pollSubscription: Subscription | null = null;
ngOnDestroy(): void {
this.streamSubscription?.unsubscribe();
this.pollSubscription?.unsubscribe();
}
get state(): GameState | null {
return this.game?.state ?? null;
}
setGameId(gameId: string): void {
this.gameId = gameId;
this.loadGame();
}
onBoardSquareSelected(square: string): void {
if (!this.state) {
return;
}
if (this.selectedSquare && this.highlightedSquares.includes(square)) {
const selectedMove = this.selectedSquareMoves.find((move) => move.to === square);
if (selectedMove) {
this.moveInput = selectedMove.uci;
this.submitMove();
}
return;
}
const piece = getPieceAtSquare(this.state.fen, square);
if (!piece || !isPieceColor(piece, this.state.turn)) {
this.clearSelection();
return;
}
this.errorMessage = '';
this.gameApi
.getLegalMoves(this.gameId, square)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
this.selectedSquare = square;
this.selectedSquareMoves = response.moves;
this.highlightedSquares = response.moves.map((move) => move.to);
},
error: () => {
this.clearSelection();
this.errorMessage = 'Could not load legal moves for selected square.';
}
});
}
submitMove(): void {
const uci = this.moveInput.trim();
if (!uci) {
return;
}
this.errorMessage = '';
this.gameApi
.makeMove(this.gameId, uci)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (state) => {
if (this.game) {
this.game = { ...this.game, state };
}
this.moveInput = '';
this.clearSelection();
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Move rejected.');
}
});
}
importFen(): void {
const fen = this.fenInput.trim();
if (!fen) {
this.errorMessage = 'Please provide a FEN string.';
return;
}
this.errorMessage = '';
this.gameApi
.importFen(fen)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (game) => {
this.fenInput = '';
this.pgnInput = '';
void this.router.navigate(['/game', game.gameId]);
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'FEN import failed.');
}
});
}
importPgn(): void {
const pgn = this.pgnInput.trim();
if (!pgn) {
this.errorMessage = 'Please provide a PGN string.';
return;
}
this.errorMessage = '';
this.gameApi
.importPgn(pgn)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (game) => {
this.pgnInput = '';
this.fenInput = '';
void this.router.navigate(['/game', game.gameId]);
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'PGN import failed.');
}
});
}
private loadGame(): void {
this.loading = true;
this.errorMessage = '';
this.clearSelection();
this.streamSubscription?.unsubscribe();
this.pollSubscription?.unsubscribe();
this.pollSubscription = null;
this.gameApi
.getGame(this.gameId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (game) => {
this.game = game;
this.loading = false;
this.startStream();
},
error: (error) => {
this.errorMessage = getErrorMessage(error, `Could not load game ${this.gameId}.`);
this.loading = false;
}
});
}
private startStream(): void {
this.streamSubscription = this.gameApi
.streamGame(this.gameId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (event) => this.applyStreamEvent(event),
error: () => {
this.errorMessage = 'Live stream disconnected. Falling back to polling.';
this.startPolling();
},
complete: () => {
this.errorMessage = 'Live stream ended. Falling back to polling.';
this.startPolling();
}
});
}
private startPolling(): void {
if (this.pollSubscription) {
return;
}
this.pollSubscription = interval(1500)
.pipe(
startWith(0),
switchMap(() => this.gameApi.getGame(this.gameId)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: (game) => {
const previousMoves = this.game?.state.moves.join(',') ?? '';
this.game = game;
if (previousMoves !== game.state.moves.join(',')) {
this.clearSelection();
}
}
});
}
private applyStreamEvent(event: GameStreamEvent): void {
if (event.type === 'gameFull') {
this.game = event.game;
this.clearSelection();
return;
}
if (event.type === 'gameState' && this.game) {
const moveCountBefore = this.game.state.moves.length;
this.game = { ...this.game, state: event.state };
if (event.state.moves.length !== moveCountBefore) {
this.clearSelection();
}
return;
}
if (event.type === 'error') {
this.errorMessage = event.error.message;
}
}
private clearSelection(): void {
this.selectedSquare = null;
this.selectedSquareMoves = [];
this.highlightedSquares = [];
}
}
@@ -67,3 +67,77 @@ p {
font-weight: 700;
margin-top: 1rem;
}
@media (max-width: 768px) {
.welcome-shell {
padding: 1rem;
}
.welcome-card {
padding: 1.5rem;
}
h1 {
font-size: 1.5rem;
}
p {
font-size: 0.875rem;
margin: 0 0 1rem;
}
.mode-grid {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.mode {
padding: 0.75rem;
gap: 0.15rem;
}
.mode span {
font-size: 1rem;
}
.mode small {
font-size: 0.7rem;
}
.error {
font-size: 0.75rem;
}
}
@media (max-width: 480px) {
.welcome-shell {
padding: 0.5rem;
}
.welcome-card {
padding: 1rem;
border-radius: 8px;
}
h1 {
font-size: 1.25rem;
margin: 0 0 0.15rem;
}
p {
font-size: 0.8rem;
margin: 0 0 0.75rem;
}
.mode-grid {
grid-template-columns: 1fr;
}
.mode {
padding: 0.65rem;
}
.mode span {
font-size: 0.95rem;
}
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { finalize } from 'rxjs';
import { getErrorMessage } from '../../core/http/error-message.util';
import { GameApiService } from '../../services/game-api.service';
@Component({
@@ -35,8 +36,8 @@ export class WelcomeComponent {
next: (game) => {
void this.router.navigate(['/game', game.gameId]);
},
error: (error: { error?: { message?: string } }) => {
this.errorMessage = error.error?.message ?? 'Unable to create a game.';
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Unable to create a game.');
}
});
}
@@ -1,4 +1,5 @@
export const environment = {
production: false,
apiBaseUrl: 'http://localhost:8080',
wsBaseUrl: 'ws://localhost:8080'
};
+1
View File
@@ -1,4 +1,5 @@
export const environment = {
production: true,
apiBaseUrl: 'http://localhost:8080',
wsBaseUrl: 'ws://localhost:8080'
};
+2
View File
@@ -1,3 +1,5 @@
@import 'bootstrap/dist/css/bootstrap.min.css';
:root {
--warm-primary: #BA6D4B;
--warm-dark: #5A2C28;