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 # Visual Studio Code
.vscode/ .vscode/
.history/* .history/*
.angualar/ .angular/
# Miscellaneous # Miscellaneous
/.angular/cache /.angular/cache
.sass-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 ```bash
npm install npm install
npm start 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 ```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": "**/*", "glob": "**/*",
"input": "ARABIAN CHESS", "input": "arabian-chess",
"output": "/arabian-chess" "output": "/arabian-chess"
} }
], ],
@@ -90,7 +90,7 @@
}, },
{ {
"glob": "**/*", "glob": "**/*",
"input": "ARABIAN CHESS", "input": "arabian-chess",
"output": "/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/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0", "@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0", "@angular/router": "^20.3.0",
"bootstrap": "^5.3.8",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
@@ -2969,6 +2970,17 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
@@ -3820,6 +3832,25 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/brace-expansion": {
"version": "1.1.14", "version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "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/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0", "@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0", "@angular/router": "^20.3.0",
"bootstrap": "^5.3.8",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
@@ -45,4 +46,4 @@
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.2" "typescript": "~5.9.2"
} }
} }
@@ -1,7 +1,28 @@
.piece { .piece {
width: clamp(40px, 9cqh, 130px); width: clamp(50px, 11cqh, 160px);
height: clamp(40px, 9cqh, 130px); height: clamp(50px, 11cqh, 160px);
display: block; display: block;
object-fit: contain; object-fit: contain;
pointer-events: none; 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 { .game-shell {
height: 100dvh; min-height: 100dvh;
padding: clamp(0.75rem, 2vw, 1.5rem); padding: clamp(0.75rem, 2vw, 1.5rem);
overflow: hidden;
} }
.game-card { .game-card {
max-width: 1100px; max-width: 1400px;
height: 100%;
margin: 0 auto; margin: 0 auto;
background: #F3C8A0; background: #F3C8A0;
border: 2px solid #5A2C28; border: 2px solid #5A2C28;
border-radius: 12px; 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); box-shadow: 0 8px 24px rgba(90, 44, 40, 0.2);
display: flex;
flex-direction: column;
min-height: 0;
} }
header { header {
margin-bottom: 1rem; margin-bottom: 1.5rem;
} }
h1, h1,
h2 { h2 {
color: #5A2C28; color: #5A2C28;
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
font-size: clamp(1.5rem, 4vw, 2rem);
} }
.meta { .meta {
margin: 0; color: #5A2C28;
font-size: 0.95rem;
} }
.back-link { .back-link {
display: inline-block; display: inline-block;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #5A2C28; color: #5A2C28;
text-decoration: none;
font-weight: 600;
}
.back-link:hover {
text-decoration: underline;
} }
.top-section { .top-section {
display: grid; background: #F3C8A0;
gap: 0.5rem; padding: 0.75rem;
margin-bottom: 0.5rem; border-radius: 8px;
flex: 0 0 auto; border: 1px solid #5A2C28;
}
.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;
} }
.move-form { .move-form {
display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.75rem; }
.move-form label {
color: #5A2C28;
font-weight: 600;
white-space: nowrap;
} }
.board-hint { .board-hint {
margin: 0;
color: #5A2C28; color: #5A2C28;
margin: 0;
}
.center-column {
width: 100%;
} }
.board-section { .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; 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 { .import-card {
background: #E1EAA9; background: #E1EAA9;
border: 2px solid #5A2C28; border: 2px solid #5A2C28;
border-radius: 10px; border-radius: 10px;
padding: 0.65rem; padding: 1rem;
display: grid; display: flex;
align-self: start; flex-direction: column;
align-content: start; gap: 0.75rem;
gap: 0.5rem;
} }
.import-card label { .import-card label {
color: #5A2C28; color: #5A2C28;
font-weight: 700; font-weight: 700;
margin-bottom: 0.25rem;
} }
input { .import-card textarea {
border: 2px solid #5A2C28; border: 2px solid #5A2C28;
border-radius: 10px; border-radius: 8px;
background: #B9DAD1;
padding: 0.6rem 0.75rem;
min-width: 180px;
}
textarea {
border: 2px solid #5A2C28;
border-radius: 10px;
background: #B9DAD1; background: #B9DAD1;
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
resize: vertical; resize: vertical;
min-height: 80px; min-height: 100px;
font-family: inherit;
} }
button { .import-card input {
border: 2px solid #5A2C28; 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; background: #C19EF5;
color: #5A2C28; color: #5A2C28;
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
cursor: pointer; cursor: pointer;
font-weight: 600;
transition: background-color 0.2s, color 0.2s;
} }
button:hover { .btn:hover {
background: #BA6D4B; background: #BA6D4B;
color: #F3C8A0; color: #F3C8A0;
} }
.error { .alert {
margin-top: 0.5rem; border-radius: 8px;
color: #5A2C28; border: 2px solid #5A2C28;
font-weight: 700;
} }
@media (max-width: 920px) { @media (max-width: 991px) {
.content-layout { .game-card {
grid-template-columns: 1fr; padding: clamp(0.75rem, 1.5vw, 1rem);
grid-template-areas:
"center"
"fen"
"pgn";
} }
.center-column { .board-section {
grid-area: center; min-height: 350px;
} }
.fen-card { h1,
grid-area: fen; h2 {
} font-size: clamp(1.25rem, 3vw, 1.75rem);
}
.pgn-card { }
grid-area: pgn;
@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"> <main class="game-shell">
<section class="game-card"> <section class="game-card">
<header> <header class="mb-3">
<a routerLink="/" class="back-link">Back</a> <a routerLink="/" class="back-link">Back</a>
<h1>1 vs 1 Game</h1> <h1 class="mb-2">1 vs 1 Game</h1>
<p class="meta">Game ID: <strong>{{ gameId }}</strong></p> <p class="meta mb-0">Game ID: <strong>{{ facade.gameId }}</strong></p>
</header> </header>
@if (loading) { @if (facade.loading) {
<p>Loading game state...</p> <p>Loading game state...</p>
} @else if (state) { } @else if (facade.state) {
<section class="content-layout"> <div class="container-fluid">
<aside class="import-card fen-card"> <div class="row g-3">
<label for="fenImport">Import FEN</label> <!-- Left Sidebar - FEN Import -->
<textarea <div class="col-lg-3 col-md-6 col-12 order-lg-1 order-2">
id="fenImport" <aside class="import-card fen-card h-100">
name="fenImport" <label for="fenImport">Import FEN</label>
[(ngModel)]="fenInput" <textarea
rows="4" id="fenImport"
placeholder="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" name="fenImport"
></textarea> [(ngModel)]="facade.fenInput"
<button type="button" (click)="importFen()">Load FEN</button> rows="4"
</aside> 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"> <!-- Center - Chess Board -->
<section class="top-section"> <div class="col-lg-6 col-md-12 col-12 order-lg-2 order-1">
<form class="move-form" (ngSubmit)="submitMove()"> <section class="center-column d-flex flex-column h-100">
<label for="uciMove">Play move (UCI)</label> <div class="top-section mb-3">
<input id="uciMove" name="uciMove" [(ngModel)]="moveInput" placeholder="e2e4" /> <form class="move-form d-flex flex-wrap gap-2 mb-2" (ngSubmit)="facade.submitMove()">
<button type="submit">Send Move</button> <label for="uciMove" class="form-label mb-0">Play move (UCI)</label>
</form> <input
<p class="board-hint">Click your piece to highlight legal targets.</p> id="uciMove"
</section> 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"> <div class="board-section flex-grow-1 d-flex align-items-center justify-content-center">
<app-chess-board <app-chess-board
[fen]="state.fen" [fen]="facade.state.fen"
[selectedSquare]="selectedSquare" [selectedSquare]="facade.selectedSquare"
[highlightedSquares]="highlightedSquares" [highlightedSquares]="facade.highlightedSquares"
(squareSelected)="onBoardSquareSelected($event)" (squareSelected)="facade.onBoardSquareSelected($event)"
/> />
</section> </div>
</section> </section>
</div>
<aside class="import-card pgn-card"> <!-- Right Sidebar - PGN Import -->
<label for="pgnImport">Import PGN</label> <div class="col-lg-3 col-md-6 col-12 order-lg-3 order-3">
<textarea <aside class="import-card pgn-card h-100">
id="pgnImport" <label for="pgnImport">Import PGN</label>
name="pgnImport" <textarea
[(ngModel)]="pgnInput" id="pgnImport"
rows="4" name="pgnImport"
placeholder="1. e4 e5 2. Nf3 Nc6 *" [(ngModel)]="facade.pgnInput"
></textarea> rows="4"
<button type="button" (click)="importPgn()">Load PGN</button> placeholder="1. e4 e5 2. Nf3 Nc6 *"
</aside> ></textarea>
</section> <button type="button" class="btn w-100" (click)="facade.importPgn()">Load PGN</button>
</aside>
</div>
</div>
</div>
} }
@if (errorMessage) { @if (facade.errorMessage) {
<p class="error">{{ errorMessage }}</p> <p class="alert alert-danger mt-3 mb-0">{{ facade.errorMessage }}</p>
} }
</section> </section>
</main> </main>
+13 -251
View File
@@ -1,272 +1,34 @@
import { CommonModule } from '@angular/common'; 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 { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { interval, startWith, Subscription, switchMap } from 'rxjs';
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component'; import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models'; import { GameFacade } from './game.facade';
import { GameApiService } from '../../services/game-api.service';
@Component({ @Component({
selector: 'app-game', selector: 'app-game',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent], imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent],
providers: [GameFacade],
templateUrl: './game.component.html', templateUrl: './game.component.html',
styleUrl: './game.component.css' styleUrl: './game.component.css'
}) })
export class GameComponent implements OnInit, OnDestroy { export class GameComponent implements OnInit {
gameId = ''; private readonly route = inject(ActivatedRoute);
game: GameFull | null = null; private readonly destroyRef = inject(DestroyRef);
errorMessage = ''; readonly facade = inject(GameFacade);
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;
}
ngOnInit(): void { ngOnInit(): void {
this.routeSubscription = this.route.paramMap.subscribe((paramMap) => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => {
const id = paramMap.get('gameId'); const id = paramMap.get('gameId');
if (!id) { if (!id) {
this.errorMessage = 'Missing gameId in route.'; this.facade.errorMessage = 'Missing gameId in route.';
this.loading = false; this.facade.loading = false;
return; return;
} }
this.gameId = id; this.facade.setGameId(id);
this.loadGame();
}); });
} }
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; font-weight: 700;
margin-top: 1rem; 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 { Component } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { finalize } from 'rxjs'; import { finalize } from 'rxjs';
import { getErrorMessage } from '../../core/http/error-message.util';
import { GameApiService } from '../../services/game-api.service'; import { GameApiService } from '../../services/game-api.service';
@Component({ @Component({
@@ -35,8 +36,8 @@ export class WelcomeComponent {
next: (game) => { next: (game) => {
void this.router.navigate(['/game', game.gameId]); void this.router.navigate(['/game', game.gameId]);
}, },
error: (error: { error?: { message?: string } }) => { error: (error) => {
this.errorMessage = error.error?.message ?? 'Unable to create a game.'; this.errorMessage = getErrorMessage(error, 'Unable to create a game.');
} }
}); });
} }
@@ -1,4 +1,5 @@
export const environment = { export const environment = {
production: false,
apiBaseUrl: 'http://localhost:8080', apiBaseUrl: 'http://localhost:8080',
wsBaseUrl: 'ws://localhost:8080' wsBaseUrl: 'ws://localhost:8080'
}; };
+1
View File
@@ -1,4 +1,5 @@
export const environment = { export const environment = {
production: true,
apiBaseUrl: 'http://localhost:8080', apiBaseUrl: 'http://localhost:8080',
wsBaseUrl: 'ws://localhost:8080' wsBaseUrl: 'ws://localhost:8080'
}; };
+2
View File
@@ -1,3 +1,5 @@
@import 'bootstrap/dist/css/bootstrap.min.css';
:root { :root {
--warm-primary: #BA6D4B; --warm-primary: #BA6D4B;
--warm-dark: #5A2C28; --warm-dark: #5A2C28;