diff --git a/angular.json b/angular.json index 72e5f83..fd1f73a 100644 --- a/angular.json +++ b/angular.json @@ -53,6 +53,12 @@ "outputHashing": "all" }, "development": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ], "optimization": false, "extractLicenses": false, "sourceMap": true @@ -62,6 +68,9 @@ }, "serve": { "builder": "@angular/build:dev-server", + "options": { + "proxyConfig": "proxy.conf.json" + }, "configurations": { "production": { "buildTarget": "nowchess-frontend:build:production" diff --git a/arabian-chess/plane.png b/arabian-chess/plane.png new file mode 100644 index 0000000..aebea01 Binary files /dev/null and b/arabian-chess/plane.png differ diff --git a/arabian-chess/player-one.gif b/arabian-chess/player-one.gif new file mode 100644 index 0000000..3263628 Binary files /dev/null and b/arabian-chess/player-one.gif differ diff --git a/arabian-chess/player-two.gif b/arabian-chess/player-two.gif new file mode 100644 index 0000000..a35e524 Binary files /dev/null and b/arabian-chess/player-two.gif differ diff --git a/arabian-chess/raf.gif b/arabian-chess/raf.gif new file mode 100644 index 0000000..7ae5436 Binary files /dev/null and b/arabian-chess/raf.gif differ diff --git a/arabian-chess/xav.png b/arabian-chess/xav.png new file mode 100644 index 0000000..f352994 Binary files /dev/null and b/arabian-chess/xav.png differ diff --git a/arabian-chess/zayne.png b/arabian-chess/zayne.png new file mode 100644 index 0000000..e360f1b Binary files /dev/null and b/arabian-chess/zayne.png differ diff --git a/docs/api-spec.yaml b/docs/board-api-spec.yaml similarity index 98% rename from docs/api-spec.yaml rename to docs/board-api-spec.yaml index 8b20333..61bf241 100644 --- a/docs/api-spec.yaml +++ b/docs/board-api-spec.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - title: NowChess API + title: NowChess Board API description: | REST API for the NowChess application. Designed to feel familiar to users of the [lichess API](https://lichess.org/api). @@ -186,11 +186,8 @@ paths: currently to move. For promotion moves include the target piece as the fifth character: - `e7e8q`, `a2a1r`, etc. - - If the move results in a pawn reaching the back rank and no promotion - character is supplied, the game enters `promotionPending` status and - the move is not yet applied — resubmit with the promotion character. + `e7e8q`, `a2a1r`, etc. Promotion moves without the fifth character + are rejected with `400 INVALID_MOVE`. security: - bearerAuth: [] parameters: @@ -630,7 +627,6 @@ components: | `draw` | Draw agreed or claimed — game over | | `drawOffered` | Waiting for the opponent to accept or decline a draw offer | | `fiftyMoveAvailable` | Fifty-move rule threshold reached; active player may claim draw | - | `promotionPending` | A pawn reached the back rank; awaiting promotion piece selection | | `insufficientMaterial` | Neither side has enough pieces to deliver checkmate — game over (draw) | enum: - started @@ -641,7 +637,6 @@ components: - draw - drawOffered - fiftyMoveAvailable - - promotionPending - insufficientMaterial # ------------------------------------------------------------------------- diff --git a/proxy.conf.json b/proxy.conf.json new file mode 100644 index 0000000..b1b7ec5 --- /dev/null +++ b/proxy.conf.json @@ -0,0 +1,8 @@ +{ + "/api": { + "target": "http://localhost:8080", + "secure": false, + "changeOrigin": true, + "ws": true + } +} diff --git a/src/app/app.ts b/src/app/app.ts index 957de88..6aba061 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { RouterOutlet } from '@angular/router'; @Component({ @@ -7,5 +7,15 @@ import { RouterOutlet } from '@angular/router'; templateUrl: './app.html', styleUrl: './app.css' }) -export class App { +export class App implements OnInit { + ngOnInit(): void { + this.initTheme(); + } + + private initTheme(): void { + const savedTheme = localStorage.getItem('theme'); + if (savedTheme === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark'); + } + } } diff --git a/src/app/components/chess-board/chess-board.component.css b/src/app/components/chess-board/chess-board.component.css index 30bf393..a86b1b1 100644 --- a/src/app/components/chess-board/chess-board.component.css +++ b/src/app/components/chess-board/chess-board.component.css @@ -7,10 +7,10 @@ .board-grid { display: grid; grid-template-columns: repeat(8, 1fr); - border: 2px solid #5A2C28; - border-radius: 10px 10px 0 0; + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-md) var(--border-radius-md) 0 0; overflow: hidden; - background: #5A2C28; + background: var(--color-text-primary); } .square { @@ -41,12 +41,13 @@ width: 28%; height: 28%; border-radius: 50%; - background: rgba(193, 158, 245, 0.75); - border: 2px solid #5A2C28; + background: var(--color-secondary-purple); + opacity: 0.75; + border: var(--border-width) solid var(--color-border); } .square.selected { - outline: 3px solid #BA6D4B; + outline: 3px solid var(--color-primary); outline-offset: -3px; } @@ -55,7 +56,7 @@ width: 100%; display: block; object-fit: fill; - border: 2px solid #5A2C28; + border: var(--border-width) solid var(--color-border); border-top: 0; - border-radius: 0 0 10px 10px; + border-radius: 0 0 var(--border-radius-md) var(--border-radius-md); } diff --git a/src/app/components/chess-piece/chess-piece.component.css b/src/app/components/chess-piece/chess-piece.component.css index 2655455..4a364f3 100644 --- a/src/app/components/chess-piece/chess-piece.component.css +++ b/src/app/components/chess-piece/chess-piece.component.css @@ -1,6 +1,6 @@ .piece { width: clamp(50px, 11cqh, 160px); - height: clamp(50px, 11cqh, 160px); + height: clamp(40px, 8cqh, 120px); display: block; object-fit: contain; pointer-events: none; diff --git a/src/app/components/input-card/input-card.component.css b/src/app/components/input-card/input-card.component.css new file mode 100644 index 0000000..b4cb7a4 --- /dev/null +++ b/src/app/components/input-card/input-card.component.css @@ -0,0 +1,63 @@ +.input-card { + background: var(--color-bg-card); + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-md); + padding: var(--size-lg-padding); + display: flex; + flex-direction: column; + gap: var(--size-lg-gap); +} + +.move-card { + padding: var(--size-sm-padding); + gap: var(--size-sm-gap); +} + +.move-card input { + min-height: 45px; + padding: var(--size-md-padding); +} + +.input-card label { + color: var(--color-text-primary); + font-weight: 700; + margin-bottom: var(--size-xs); +} + +.input-card textarea { + resize: vertical; + height: 40px; + background-color:lightcyan; + border: 3px solid var(--color-border); + border-radius: var(--border-radius-sm); +} + +.input-card input { + min-width: unset; + background-color:lightcyan; + border: 3px solid var(--color-border); + border-radius: var(--border-radius-sm); + +} + +.input-card .btn { + border: var(--button-border); + border-radius: var(--border-radius-sm); + background: var(--color-bg-button); + color: var(--color-text-primary); + padding: var(--button-padding); + cursor: pointer; + font-weight: 600; + transition: background-color 0.2s, color 0.2s; +} + +.input-card .btn:hover { + background: var(--color-bg-button-hover); + color: var(--color-text-button-hover); +} + +.hint-text { + margin: 0; + color: var(--color-text-primary); + font-size: 0.9rem; +} diff --git a/src/app/components/input-card/input-card.component.html b/src/app/components/input-card/input-card.component.html new file mode 100644 index 0000000..34b9d1a --- /dev/null +++ b/src/app/components/input-card/input-card.component.html @@ -0,0 +1,31 @@ +
+ + + @if (inputType === 'textarea') { + + } @else { + + } + + + + @if (hintText) { +

{{ hintText }}

+ } +
diff --git a/src/app/components/input-card/input-card.component.ts b/src/app/components/input-card/input-card.component.ts new file mode 100644 index 0000000..45bf28b --- /dev/null +++ b/src/app/components/input-card/input-card.component.ts @@ -0,0 +1,33 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-input-card', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './input-card.component.html', + styleUrl: './input-card.component.css', +}) +export class InputCardComponent { + @Input() label: string = ''; + @Input() placeholder: string = ''; + @Input() buttonLabel: string = 'Send'; + @Input() inputType: 'input' | 'textarea' = 'input'; + @Input() value: string = ''; + @Input() cardClass: string = ''; + @Input() hintText: string = ''; + @Input() rows: number = 4; + + @Output() valueChange = new EventEmitter(); + @Output() buttonClick = new EventEmitter(); + + onValueChange(newValue: string): void { + this.value = newValue; + this.valueChange.emit(newValue); + } + + onButtonClick(): void { + this.buttonClick.emit(); + } +} diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.css b/src/app/components/promotion-dialog/promotion-dialog.component.css new file mode 100644 index 0000000..578f349 --- /dev/null +++ b/src/app/components/promotion-dialog/promotion-dialog.component.css @@ -0,0 +1,131 @@ +.promotion-dialog-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s, visibility 0.2s; + z-index: 1000; + + &.open { + opacity: 1; + visibility: visible; + } +} + +.promotion-dialog { + background: white; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + max-width: 400px; + width: 90%; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.promotion-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid #e0e0e0; + + h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #333; + } + + .close-btn { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #999; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s; + + &:hover { + color: #333; + } + } +} + +.promotion-body { + padding: 20px; +} + +.promotion-prompt { + margin: 0 0 20px 0; + text-align: center; + color: #666; + font-size: 14px; +} + +.promotion-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.promotion-button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px; + background: #f5f5f5; + border: 2px solid #ddd; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; + + &:hover { + background: #e8e8e8; + border-color: #999; + transform: translateY(-2px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + &:active { + transform: translateY(0); + box-shadow: none; + } + + .piece-symbol { + font-size: 32px; + line-height: 1; + } + + .piece-label { + font-size: 12px; + font-weight: 500; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; + } +} diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.html b/src/app/components/promotion-dialog/promotion-dialog.component.html new file mode 100644 index 0000000..7cdb899 --- /dev/null +++ b/src/app/components/promotion-dialog/promotion-dialog.component.html @@ -0,0 +1,26 @@ +
+
+
+

Pawn Promotion

+ +
+ +
+

Choose a piece to promote your pawn to:

+ +
+ @for (piece of promotionPieces; track piece.type) { + + } +
+
+
+
diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.ts b/src/app/components/promotion-dialog/promotion-dialog.component.ts new file mode 100644 index 0000000..279339a --- /dev/null +++ b/src/app/components/promotion-dialog/promotion-dialog.component.ts @@ -0,0 +1,39 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type PromotionPieceType = 'queen' | 'rook' | 'bishop' | 'knight'; +interface PromotionPieceOption { + type: PromotionPieceType; + label: string; + symbol: string; +} + +@Component({ + selector: 'app-promotion-dialog', + standalone: true, + imports: [CommonModule], + templateUrl: './promotion-dialog.component.html', + styleUrl: './promotion-dialog.component.css' +}) +export class PromotionDialogComponent { + @Input() isOpen = false; + @Output() promotionSelected = new EventEmitter(); + @Output() closed = new EventEmitter(); + + promotionPieces: PromotionPieceOption[] = [ + { type: 'queen', label: 'Queen', symbol: '♕' }, + { type: 'rook', label: 'Rook', symbol: '♖' }, + { type: 'bishop', label: 'Bishop', symbol: '♗' }, + { type: 'knight', label: 'Knight', symbol: '♘' } + ]; + + selectPromotion(type: PromotionPieceType): void { + this.promotionSelected.emit(type); + this.isOpen = false; + } + + close(): void { + this.closed.emit(); + this.isOpen = false; + } +} diff --git a/src/app/pages/game/game.component.css b/src/app/pages/game/game.component.css index edf4e18..042bcef 100644 --- a/src/app/pages/game/game.component.css +++ b/src/app/pages/game/game.component.css @@ -1,38 +1,38 @@ .game-shell { min-height: 100dvh; - padding: clamp(0.75rem, 2vw, 1.5rem); + padding: clamp(var(--size-md), 2vw, var(--size-xl)); } .game-card { max-width: 1400px; margin: 0 auto; - background: #F3C8A0; - border: 2px solid #5A2C28; - border-radius: 12px; - padding: clamp(1rem, 2vw, 1.5rem); - box-shadow: 0 8px 24px rgba(90, 44, 40, 0.2); + background: var(--color-bg-main); + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-lg); + padding: clamp(var(--size-lg), 2vw, var(--size-xl)); + box-shadow: var(--shadow-md); } header { - margin-bottom: 1.5rem; + margin-bottom: var(--size-xl); } h1, h2 { - color: #5A2C28; - margin: 0 0 0.5rem; - font-size: clamp(1.5rem, 4vw, 2rem); + color: var(--color-text-primary); + margin: 0 0 var(--size-md); + font-size: var(--heading-h1); } .meta { - color: #5A2C28; + color: var(--color-text-primary); font-size: 0.95rem; } .back-link { display: inline-block; - margin-bottom: 0.5rem; - color: #5A2C28; + margin-bottom: var(--size-sm); + color: var(--color-text-primary); text-decoration: none; font-weight: 600; } @@ -42,25 +42,19 @@ h2 { } .top-section { - background: #F3C8A0; - padding: 0.75rem; - border-radius: 8px; - border: 1px solid #5A2C28; + display: grid; + gap: var(--size-md); + margin-top: var(--size-sm); + flex: 0 0 auto; } -.move-form { - align-items: center; +.move-card { + padding: var(--size-lg-padding); } -.move-form label { - color: #5A2C28; - font-weight: 600; - white-space: nowrap; -} - -.board-hint { - color: #5A2C28; - margin: 0; +.move-card .btn { + align-self: flex-start; + width: auto; } .center-column { @@ -68,72 +62,70 @@ h2 { } .board-section { - background: #B9DAD1; - border: 2px solid #5A2C28; - border-radius: 10px; - padding: clamp(0.5rem, 1vw, 1rem); + background: var(--color-bg-board); + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-md); + padding: clamp(var(--size-sm), 1vw, var(--size-lg)); min-height: 400px; container-type: size; } -.import-card { - background: #E1EAA9; - border: 2px solid #5A2C28; - border-radius: 10px; - padding: 1rem; - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.import-card label { - color: #5A2C28; - font-weight: 700; - margin-bottom: 0.25rem; -} - -.import-card textarea { - border: 2px solid #5A2C28; - border-radius: 8px; - background: #B9DAD1; - padding: 0.6rem 0.75rem; - resize: vertical; - min-height: 100px; - font-family: inherit; -} - -.import-card input { - border: 2px solid #5A2C28; - 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; -} - -.btn:hover { - background: #BA6D4B; - color: #F3C8A0; -} - .alert { - border-radius: 8px; - border: 2px solid #5A2C28; + border-radius: var(--border-radius-sm); + border: var(--border-width) solid var(--color-border); +} + +.game-completion-alert { + background: linear-gradient(135deg, var(--color-secondary-mint, #B9DAD1) 0%, var(--color-secondary-blue, #B9C2DA) 100%); + border: 2px solid var(--color-secondary-mint, #B9DAD1) !important; + border-radius: var(--border-radius-lg) !important; + padding: var(--size-xl-padding) !important; + box-shadow: 0 8px 16px rgba(185, 218, 209, 0.3); + animation: slideIn 0.4s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.completion-title { + color: var(--color-text-primary); + font-size: 1.75rem; + margin: 0 0 var(--size-md) 0; + font-weight: 700; + text-align: center; +} + +.completion-subtitle { + text-align: center; + color: var(--color-text-primary); + font-size: 1rem; +} + +.completion-link { + color: var(--color-text-primary); + text-decoration: none; + font-weight: 600; + border-bottom: 2px solid var(--color-text-primary); + transition: all 0.3s ease; + padding-bottom: 2px; +} + +.completion-link:hover { + color: var(--color-secondary-blue); + border-bottom-color: var(--color-secondary-blue); } @media (max-width: 991px) { .game-card { - padding: clamp(0.75rem, 1.5vw, 1rem); + padding: clamp(var(--size-md), 1.5vw, var(--size-lg)); } .board-section { @@ -142,26 +134,26 @@ h2 { h1, h2 { - font-size: clamp(1.25rem, 3vw, 1.75rem); + font-size: var(--heading-h1-tablet); } } @media (max-width: 768px) { .game-shell { - padding: clamp(0.5rem, 1.5vw, 1rem); + padding: clamp(var(--size-sm), 1.5vw, var(--size-lg)); } .game-card { - padding: clamp(0.5rem, 1vw, 0.75rem); + padding: clamp(var(--size-sm), 1vw, var(--size-md)); } header { - margin-bottom: 1rem; + margin-bottom: var(--size-lg); } h1, h2 { - font-size: 1.25rem; + font-size: var(--heading-h1-mobile); } .meta { @@ -169,61 +161,31 @@ h2 { } .top-section { - padding: 0.5rem; - margin-bottom: 0.75rem; - } - - .move-form { - flex-wrap: wrap; - } - - .move-form label { - flex-basis: 100%; - margin-bottom: 0.5rem; + gap: var(--size-xs); + margin-bottom: var(--size-xs); } .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; + padding: var(--size-sm); } .game-card { - padding: 0.5rem; - border-radius: 8px; + padding: var(--size-sm); + border-radius: var(--border-radius-md); } header { - margin-bottom: 0.75rem; + margin-bottom: var(--size-md); } h1 { - font-size: 1.1rem; + font-size: var(--heading-h1-small); } .meta { @@ -231,23 +193,11 @@ h2 { } .top-section { - padding: 0.4rem; - margin-bottom: 0.5rem; + gap: var(--size-xs-gap); + margin-bottom: var(--size-xs); } .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; - } } diff --git a/src/app/pages/game/game.component.html b/src/app/pages/game/game.component.html index 41a3bc3..04c93b2 100644 --- a/src/app/pages/game/game.component.html +++ b/src/app/pages/game/game.component.html @@ -1,4 +1,10 @@
+ +
Back @@ -9,41 +15,33 @@ @if (facade.loading) {

Loading game state...

} @else if (facade.state) { + @if (facade.isGameFinished && facade.gameCompletionMessage) { +
+

{{ facade.gameCompletionMessage }}

+

+ Start a new game +

+
+ }
- +
-
-
- - - -
-

Click your piece to highlight legal targets.

-
-
+ +
+ +
- +
diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts index d269243..307aed4 100644 --- a/src/app/pages/game/game.component.ts +++ b/src/app/pages/game/game.component.ts @@ -4,12 +4,14 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { ChessBoardComponent } from '../../components/chess-board/chess-board.component'; +import { InputCardComponent } from '../../components/input-card/input-card.component'; +import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component'; import { GameFacade } from './game.facade'; @Component({ selector: 'app-game', standalone: true, - imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent], + imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent, InputCardComponent, PromotionDialogComponent], providers: [GameFacade], templateUrl: './game.component.html', styleUrl: './game.component.css' diff --git a/src/app/pages/game/game.facade.ts b/src/app/pages/game/game.facade.ts index 03f9619..d31ee04 100644 --- a/src/app/pages/game/game.facade.ts +++ b/src/app/pages/game/game.facade.ts @@ -1,11 +1,13 @@ 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'; +import { BotMoveService } from '../../services/bot-move.service'; +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'; @Injectable() export class GameFacade implements OnDestroy { @@ -16,25 +18,42 @@ export class GameFacade implements OnDestroy { fenInput = ''; pgnInput = ''; loading = true; - selectedSquare: string | null = null; - highlightedSquares: string[] = []; + gameCompletionMessage = ''; + isGameFinished = false; + isPromotionDialogOpen = false; + + private boardSelection: BoardSelection = { + selectedSquare: null, + highlightedSquares: [], + selectedSquareMoves: [] + }; + private pendingPromotionMoves: LegalMove[] = []; - 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(); - } + private readonly botMoveService = inject(BotMoveService); + private readonly completionService = inject(GameCompletionService); + private readonly importService = inject(GameImportService); + private readonly boardSelectionService = inject(BoardSelectionService); + private readonly streamService = inject(GameStreamService); get state(): GameState | null { return this.game?.state ?? null; } + get selectedSquare(): string | null { + return this.boardSelection.selectedSquare; + } + + get highlightedSquares(): string[] { + return this.boardSelection.highlightedSquares; + } + + ngOnDestroy(): void { + this.streamService.cleanup(); + this.botMoveService.cleanup(); + } + setGameId(gameId: string): void { this.gameId = gameId; this.loadGame(); @@ -45,36 +64,45 @@ export class GameFacade implements OnDestroy { return; } - if (this.selectedSquare && this.highlightedSquares.includes(square)) { - const selectedMove = this.selectedSquareMoves.find((move) => move.to === square); + // Handle move selection + if (this.boardSelection.selectedSquare && this.boardSelection.highlightedSquares.includes(square)) { + const selectedMove = this.boardSelection.selectedSquareMoves.find((move) => move.to === square); if (selectedMove) { + // If multiple promotion outcomes exist for the target, ask player to choose one. + const promotionMoves = this.boardSelection.selectedSquareMoves.filter( + (move) => move.to === square && !!move.promotion + ); + if (promotionMoves.length > 0) { + this.pendingPromotionMoves = promotionMoves; + this.isPromotionDialogOpen = true; + return; + } this.moveInput = selectedMove.uci; this.submitMove(); } return; } - const piece = getPieceAtSquare(this.state.fen, square); - if (!piece || !isPieceColor(piece, this.state.turn)) { - this.clearSelection(); - return; - } - + // Load moves for selected square 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.'; - } - }); + const newSelection = this.boardSelectionService.handleSquareSelection( + square, + this.gameId, + this.state, + this.boardSelection, + (moves) => { + this.boardSelection = { + selectedSquare: square, + highlightedSquares: moves.map((move) => move.to), + selectedSquareMoves: moves + }; + }, + (error) => { + this.errorMessage = error; + this.boardSelection = this.boardSelectionService.clearSelection(); + } + ); + this.boardSelection = newSelection; } submitMove(): void { @@ -91,9 +119,25 @@ export class GameFacade implements OnDestroy { next: (state) => { if (this.game) { this.game = { ...this.game, state }; + this.updateGameCompletion(); } this.moveInput = ''; - this.clearSelection(); + this.boardSelection = this.boardSelectionService.clearSelection(); + this.botMoveService.tryMakeBotMove( + this.gameId, + this.game, + this.state, + (updatedState) => { + if (this.game) { + this.game = { ...this.game, state: updatedState }; + this.updateGameCompletion(); + } + this.boardSelection = this.boardSelectionService.clearSelection(); + }, + (error) => { + this.errorMessage = error; + } + ); }, error: (error) => { this.errorMessage = getErrorMessage(error, 'Move rejected.'); @@ -101,59 +145,61 @@ export class GameFacade implements OnDestroy { }); } - importFen(): void { - const fen = this.fenInput.trim(); - if (!fen) { - this.errorMessage = 'Please provide a FEN string.'; + onPromotionSelected(promotionPiece: 'queen' | 'rook' | 'bishop' | 'knight'): void { + const selectedPromotionMove = this.pendingPromotionMoves.find((move) => move.promotion === promotionPiece); + if (!selectedPromotionMove) { + this.errorMessage = 'Selected promotion move is unavailable.'; + this.isPromotionDialogOpen = false; + this.pendingPromotionMoves = []; return; } + this.moveInput = selectedPromotionMove.uci; + this.isPromotionDialogOpen = false; + this.boardSelection = this.boardSelectionService.clearSelection(); + this.pendingPromotionMoves = []; + this.submitMove(); + } + + onPromotionClosed(): void { + this.isPromotionDialogOpen = false; + this.boardSelection = this.boardSelectionService.clearSelection(); + this.pendingPromotionMoves = []; + } + + importFen(): void { 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.'); - } - }); + this.importService.importFen( + this.fenInput, + () => { + this.fenInput = ''; + this.pgnInput = ''; + }, + (error) => { + this.errorMessage = error; + } + ); } 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.'); - } - }); + this.importService.importPgn( + this.pgnInput, + () => { + this.pgnInput = ''; + this.fenInput = ''; + }, + (error) => { + this.errorMessage = error; + } + ); } private loadGame(): void { this.loading = true; this.errorMessage = ''; - this.clearSelection(); - this.streamSubscription?.unsubscribe(); - this.pollSubscription?.unsubscribe(); - this.pollSubscription = null; + this.boardSelection = this.boardSelectionService.clearSelection(); + this.streamService.cleanup(); this.gameApi .getGame(this.gameId) @@ -162,7 +208,9 @@ export class GameFacade implements OnDestroy { next: (game) => { this.game = game; this.loading = false; - this.startStream(); + this.updateGameCompletion(); + this.startStreaming(); + this.tryMakeBotMove(); }, error: (error) => { this.errorMessage = getErrorMessage(error, `Could not load game ${this.gameId}.`); @@ -171,57 +219,32 @@ export class GameFacade implements OnDestroy { }); } - 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 startStreaming(): void { + this.streamService.startStreaming( + this.gameId, + (event) => this.applyStreamEvent(event), + () => { + this.errorMessage = 'Live stream disconnected. Falling back to polling.'; + } + ); } private applyStreamEvent(event: GameStreamEvent): void { if (event.type === 'gameFull') { this.game = event.game; - this.clearSelection(); + this.boardSelection = this.boardSelectionService.clearSelection(); + this.updateGameCompletion(); + this.tryMakeBotMove(); return; } if (event.type === 'gameState' && this.game) { const moveCountBefore = this.game.state.moves.length; this.game = { ...this.game, state: event.state }; + this.updateGameCompletion(); if (event.state.moves.length !== moveCountBefore) { - this.clearSelection(); + this.boardSelection = this.boardSelectionService.clearSelection(); + this.tryMakeBotMove(); } return; } @@ -231,9 +254,27 @@ export class GameFacade implements OnDestroy { } } - private clearSelection(): void { - this.selectedSquare = null; - this.selectedSquareMoves = []; - this.highlightedSquares = []; + private tryMakeBotMove(): void { + this.botMoveService.tryMakeBotMove( + this.gameId, + this.game, + this.state, + (updatedState) => { + if (this.game) { + this.game = { ...this.game, state: updatedState }; + this.updateGameCompletion(); + } + this.boardSelection = this.boardSelectionService.clearSelection(); + }, + (error) => { + this.errorMessage = error; + } + ); + } + + private updateGameCompletion(): void { + const completion = this.completionService.getGameCompletion(this.game, this.state); + this.gameCompletionMessage = completion.message; + this.isGameFinished = completion.isFinished; } } diff --git a/src/app/pages/welcome/welcome.component.css b/src/app/pages/welcome/welcome.component.css index 1407a16..e11a5ea 100644 --- a/src/app/pages/welcome/welcome.component.css +++ b/src/app/pages/welcome/welcome.component.css @@ -1,99 +1,459 @@ .welcome-shell { min-height: 100vh; - display: grid; - place-items: center; - padding: 2rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--size-xl); + position: relative; +} + +.theme-toggle-container { + position: absolute; + top: 20px; + right: 20px; + z-index: 100; +} + +.switch { + display: inline-block; + position: relative; +} + +.switch__input { + clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(50%); + height: 1px; + width: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; +} + +.switch__label { + position: relative; + display: inline-block; + width: 120px; + height: 60px; + background-color: #2B2B2B; + border: 5px solid #5B5B5B; + border-radius: 9999px; + cursor: pointer; + transition: all 0.4s cubic-bezier(.46,.03,.52,.96); +} + +.switch__indicator { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) translateX(-72%); + display: block; + width: 40px; + height: 40px; + background-color: #7B7B7B; + border-radius: 9999px; + box-shadow: 10px 0px 0 0 rgba(0, 0, 0, 0.2) inset; + transition: all 0.4s cubic-bezier(.46,.03,.52,.96); +} + +.switch__indicator::before, +.switch__indicator::after { + position: absolute; + content: ''; + display: block; + background-color: #FFFFFF; + border-radius: 9999px; + transition: all 0.4s cubic-bezier(.46,.03,.52,.96); +} + +.switch__indicator::before { + top: 7px; + left: 7px; + width: 9px; + height: 9px; + opacity: 0.6; +} + +.switch__indicator::after { + bottom: 8px; + right: 6px; + width: 14px; + height: 14px; + opacity: 0.8; +} + +.switch__decoration { + position: absolute; + top: 65%; + left: 50%; + display: block; + width: 5px; + height: 5px; + background-color: #FFFFFF; + border-radius: 9999px; + animation: twinkle-stars 0.8s infinite -0.6s; + transition: all 0.4s cubic-bezier(.46,.03,.52,.96); +} + +.switch__decoration::before, +.switch__decoration::after { + position: absolute; + display: block; + content: ''; + width: 5px; + height: 5px; + background-color: #FFFFFF; + border-radius: 9999px; +} + +.switch__decoration::before { + top: -20px; + left: 10px; + opacity: 1; + animation: twinkle-stars 0.6s infinite; +} + +.switch__decoration::after { + top: -7px; + left: 30px; + animation: twinkle-stars 0.6s infinite -0.2s; +} + +@keyframes twinkle-stars { + 50% { opacity: 0.2; } +} + +.switch__input:checked + .switch__label { + background-color: #8FB5F5; + border-color: #347CF8; +} + +.switch__input:checked + .switch__label .switch__indicator { + background-color: #ECD21F; + box-shadow: none; + transform: translate(-50%, -50%) translateX(72%); +} + +.switch__input:checked + .switch__label .switch__indicator::before, +.switch__input:checked + .switch__label .switch__indicator::after { + display: none; +} + +.switch__input:checked + .switch__label .switch__decoration { + top: 50%; + transform: translate(0%, -50%); + animation: cloud 8s linear infinite; + width: 20px; + height: 20px; +} + +.switch__input:checked + .switch__label .switch__decoration::before { + width: 10px; + height: 10px; + top: auto; + bottom: 0; + left: -8px; + animation: none; +} + +.switch__input:checked + .switch__label .switch__decoration::after { + width: 15px; + height: 15px; + top: auto; + bottom: 0; + left: 16px; + animation: none; +} + +.switch__input:checked + .switch__label .switch__decoration, +.switch__input:checked + .switch__label .switch__decoration::before, +.switch__input:checked + .switch__label .switch__decoration::after { + border-radius: 9999px 9999px 0 0; +} + +.switch__input:checked + .switch__label .switch__decoration::after { + border-bottom-right-radius: 9999px; +} + +@keyframes cloud { + 0% { transform: translate(0%, -50%); } + 50% { transform: translate(-50%, -50%); } + 100% { transform: translate(0%, -50%); } +} + +.clouds-container { + display: flex; + justify-content: center; + align-items: flex-start; + gap: 60px; + width: 100%; + max-width: 900px; + margin-bottom: var(--size-xl); + position: relative; + z-index: 1; +} + +.plane { + position: relative; + width: 320px; + height: 140px; + display: flex; + align-items: center; + justify-content: center; + filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.12)); +} + +.plane-body { + width: 240%; + height: 240%; + object-fit: contain; + position: absolute; +} + +.plane-gif { + width: 70px; + height: 70px; + object-fit: contain; + z-index: 10; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15)); + position: relative; + transform: translateX(-25px) translateY(15px); +} + +.cloud { + position: relative; + width: 220px; + height: 90px; + display: flex; + align-items: center; + justify-content: center; + filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.12)); +} + +.cloud::before { + content: ''; + position: absolute; + width: 90px; + height: 90px; + background: #ffffff; + border-radius: 50%; + top: 0; + left: 0; + box-shadow: 55px 0 0 9px #ffffff, 110px 0 0 5px #ffffff, 27px -18px 0 13px #ffffff, 82px -13px 0 11px #ffffff; +} + +.cloud::after { + content: ''; + position: absolute; + width: 220px; + height: 45px; + background: #ffffff; + border-radius: 50px; + bottom: 0; + left: 0; +} + +.cloud-gif { + width: 150px; + height: 100px; + object-fit: contain; + z-index: 10; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15)); + position: relative; +} + +.gif-with-halo { + position: relative; + display: inline-block; +} + +.gif-with-halo::before { + content: ''; + position: absolute; + top: -10px; + left: 50%; + transform: translateX(-50%); + width: 75px; + height: 15px; + border: 2px solid rgba(255, 215, 0, 0.7); + border-radius: 50%; + box-shadow: 0 0 8px rgba(255, 215, 0, 0.5); + z-index: 5; } .welcome-card { width: min(900px, 100%); - border-radius: 12px; - border: 2px solid #5A2C28; - background: #F3C8A0; - padding: 2rem; - box-shadow: 0 8px 24px rgba(90, 44, 40, 0.2); + border-radius: var(--border-radius-lg); + border: var(--border-width) solid var(--color-border); + background: var(--color-bg-main); + padding: var(--size-xl-padding); + box-shadow: var(--shadow-md); } h1 { - margin: 0 0 0.25rem; - color: #5A2C28; + margin: 0 0 var(--size-xs); + color: var(--color-text-primary); + font-size: var(--heading-h1); } p { - margin: 0 0 1.25rem; + margin: 0 0 var(--size-lg); } .mode-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 1rem; + gap: var(--size-xl-gap); } .mode { - border: 2px solid #5A2C28; - border-radius: 10px; - padding: 1rem; + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-md); + padding: var(--size-lg-padding); text-align: left; display: grid; - gap: 0.25rem; + gap: var(--size-xs); } .mode span { font-size: 1.15rem; - color: #5A2C28; + color: var(--color-text-primary); } .mode small { - color: #5A2C28; + color: var(--color-text-primary); opacity: 0.9; } .mode-active { - background: #B9DAD1; + background: var(--color-secondary-mint); cursor: pointer; } .mode-active:hover:enabled { - background: #B9C2DA; + background: var(--color-secondary-blue); } .mode-disabled { - background: #E1EAA9; + background: var(--color-bg-card); opacity: 0.75; } .error { - color: #5A2C28; + color: var(--color-text-primary); font-weight: 700; - margin-top: 1rem; + margin-top: var(--size-xl); +} + +.plane-left { + animation: float 3s ease-in-out infinite; +} + +.cloud-left { + animation: float 3s ease-in-out infinite; + animation-delay: 0.25s; +} + +.cloud-right { + animation: float 3s ease-in-out infinite; + animation-delay: 0.5s; +} + +@keyframes float { + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-15px); + } } @media (max-width: 768px) { + .theme-toggle-container { + top: 10px; + right: 10px; + } + + .switch__label { + width: 100px; + height: 50px; + } + + .switch__indicator { + width: 33px; + height: 33px; + } + .welcome-shell { - padding: 1rem; + padding: var(--size-lg); + } + + .clouds-container { + gap: 30px; + margin-bottom: var(--size-lg); + } + + .plane { + width: 240px; + height: 110px; + } + + .plane-gif { + width: 55px; + height: 55px; + transform: translateX(-20px) translateY(12px); + } + + .cloud { + width: 170px; + height: 75px; + } + + .cloud::before { + width: 65px; + height: 65px; + box-shadow: 40px 0 0 7px #ffffff, 80px 0 0 3px #ffffff, 20px -13px 0 10px #ffffff, 60px -10px 0 8px #ffffff; + } + + .cloud::after { + width: 170px; + height: 38px; + } + + .cloud-gif { + width: 140px; + height: 140px; + } + + .gif-with-halo::before { + top: -8px; + width: 80px; + height: 12px; + border: 1.5px solid rgba(255, 215, 0, 0.7); } .welcome-card { - padding: 1.5rem; + padding: var(--size-xl); } h1 { - font-size: 1.5rem; + font-size: var(--heading-h1-mobile); } p { font-size: 0.875rem; - margin: 0 0 1rem; + margin: 0 0 var(--size-lg); } .mode-grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 0.75rem; + gap: var(--size-md-gap); } .mode { - padding: 0.75rem; - gap: 0.15rem; + padding: var(--size-md-padding); + gap: var(--size-xs-gap); } .mode span { @@ -111,22 +471,22 @@ p { @media (max-width: 480px) { .welcome-shell { - padding: 0.5rem; + padding: var(--size-sm); } .welcome-card { - padding: 1rem; - border-radius: 8px; + padding: var(--size-lg-padding); + border-radius: var(--border-radius-md); } h1 { - font-size: 1.25rem; - margin: 0 0 0.15rem; + font-size: var(--heading-h1-small); + margin: 0 0 var(--size-xs); } p { font-size: 0.8rem; - margin: 0 0 0.75rem; + margin: 0 0 var(--size-md); } .mode-grid { @@ -134,10 +494,163 @@ p { } .mode { - padding: 0.65rem; + padding: var(--size-md-padding); } .mode span { font-size: 0.95rem; } } + +.difficulty-selector { + grid-column: 1 / -1; + background: var(--color-bg-card); + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-md); + padding: var(--size-lg-padding); + margin: var(--size-md) 0; +} + +.difficulty-selector p { + margin: 0 0 var(--size-md); + font-weight: 600; +} + +.difficulty-buttons { + display: flex; + gap: var(--size-md-gap); + flex-wrap: wrap; +} + +.difficulty-btn { + flex: 1; + min-width: 80px; + padding: var(--size-md-padding); + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-md); + cursor: pointer; + font-weight: 600; + transition: all 0.3s ease; +} + +.difficulty-btn.easy { + background: var(--color-success-light, #d4edda); + color: var(--color-text-primary); +} + +.difficulty-btn.easy:hover:enabled { + background: var(--color-success, #28a745); + color: white; +} + +.difficulty-btn.medium { + background: var(--color-warning-light, #fff3cd); + color: var(--color-text-primary); +} + +.difficulty-btn.medium:hover:enabled { + background: var(--color-warning, #ffc107); + color: var(--color-text-primary); +} + +.difficulty-btn.hard { + background: var(--color-danger-light, #f8d7da); + color: var(--color-text-primary); +} + +.difficulty-btn.hard:hover:enabled { + background: var(--color-danger, #dc3545); + color: white; +} + +.join-game-form { + grid-column: 1 / -1; + background: var(--color-bg-card); + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-md); + padding: var(--size-lg-padding); + margin: var(--size-md) 0; +} + +.join-game-form p { + margin: 0 0 var(--size-md); + font-weight: 600; +} + +.join-game-input-group { + display: flex; + gap: var(--size-md-gap); + flex-wrap: wrap; +} + +.join-game-input { + flex: 1; + min-width: 150px; + padding: var(--size-md-padding); + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-md); + background: white; + color: var(--color-text-primary); + font-family: inherit; + font-size: 1rem; + transition: border-color 0.3s ease; +} + +.join-game-input:focus { + outline: none; + border-color: var(--color-secondary-mint); + box-shadow: 0 0 0 3px rgba(185, 218, 209, 0.2); +} + +.join-game-input:disabled { + background: var(--color-bg-card); + opacity: 0.6; +} + +.join-game-btn { + padding: var(--size-md-padding) var(--size-lg); + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-md); + cursor: pointer; + font-weight: 600; + font-size: 0.95rem; + transition: all 0.3s ease; +} + +.join-game-btn.join { + background: var(--color-secondary-mint); + color: var(--color-text-primary); +} + +.join-game-btn.join:hover:enabled { + background: var(--color-secondary-blue); +} + +.join-game-btn.join:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.join-game-btn.cancel { + background: var(--color-bg-card); + color: var(--color-text-primary); +} + +.join-game-btn.cancel:hover:enabled { + background: var(--color-border); +} + +.join-game-btn.cancel:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.difficulty-btn.hard:hover:enabled { + background: var(--color-danger, #dc3545); + color: white; +} + +.difficulty-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/src/app/pages/welcome/welcome.component.html b/src/app/pages/welcome/welcome.component.html index 392bf2e..d5b67d8 100644 --- a/src/app/pages/welcome/welcome.component.html +++ b/src/app/pages/welcome/welcome.component.html @@ -1,25 +1,97 @@
+
+
+ + +
+
+
+
+ Player One +
+
+ Plane + Raf +
+
+
+ Player Two +
+
+

Welcome to NowChess

Pick a mode to begin.

- + @if (showDifficultySelector) { +
+

Select difficulty:

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

Enter the game ID:

+
+ + + +
+
+ } + @if (errorMessage) {

{{ errorMessage }}

} diff --git a/src/app/pages/welcome/welcome.component.ts b/src/app/pages/welcome/welcome.component.ts index 0b64679..3f3c149 100644 --- a/src/app/pages/welcome/welcome.component.ts +++ b/src/app/pages/welcome/welcome.component.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { finalize } from 'rxjs'; import { getErrorMessage } from '../../core/http/error-message.util'; @@ -8,18 +9,31 @@ import { GameApiService } from '../../services/game-api.service'; @Component({ selector: 'app-welcome', standalone: true, - imports: [CommonModule], + imports: [CommonModule, FormsModule], templateUrl: './welcome.component.html', styleUrl: './welcome.component.css' }) export class WelcomeComponent { creating = false; errorMessage = ''; + showDifficultySelector = false; + showJoinGameForm = false; + gameIdInput = ''; + joiningGame = false; constructor( private readonly router: Router, private readonly gameApi: GameApiService - ) {} + ) { + this.initTheme(); + } + + private initTheme(): void { + const savedTheme = localStorage.getItem('theme'); + if (savedTheme === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark'); + } + } startOneVsOne(): void { if (this.creating) { @@ -41,4 +55,83 @@ export class WelcomeComponent { } }); } + + startVsBot(difficulty: 'easy' | 'medium' | 'hard'): void { + if (this.creating) { + return; + } + + this.errorMessage = ''; + this.creating = true; + this.showDifficultySelector = false; + + this.gameApi + .createGameVsBot(difficulty) + .pipe(finalize(() => (this.creating = false))) + .subscribe({ + next: (game) => { + void this.router.navigate(['/game', game.gameId]); + }, + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Unable to create a game against bot.'); + } + }); + } + + toggleDifficultySelector(): void { + this.showDifficultySelector = !this.showDifficultySelector; + this.showJoinGameForm = false; + this.errorMessage = ''; + } + + toggleJoinGameForm(): void { + this.showJoinGameForm = !this.showJoinGameForm; + this.showDifficultySelector = false; + this.errorMessage = ''; + this.gameIdInput = ''; + } + + joinGame(): void { + if (this.joiningGame || !this.gameIdInput.trim()) { + return; + } + + this.errorMessage = ''; + this.joiningGame = true; + + this.gameApi + .getGame(this.gameIdInput.trim()) + .pipe(finalize(() => (this.joiningGame = false))) + .subscribe({ + next: (game) => { + void this.router.navigate(['/game', game.gameId]); + }, + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Unable to find or join the game.'); + } + }); + } + + clearJoinGameForm(): void { + this.showJoinGameForm = false; + this.gameIdInput = ''; + this.errorMessage = ''; + } + + toggleDarkMode(): void { + const htmlElement = document.documentElement; + const isDarkMode = htmlElement.getAttribute('data-theme') === 'dark'; + + if (isDarkMode) { + htmlElement.removeAttribute('data-theme'); + localStorage.removeItem('theme'); + } else { + htmlElement.setAttribute('data-theme', 'dark'); + localStorage.setItem('theme', 'dark'); + } + } + + isDarkMode(): boolean { + return document.documentElement.getAttribute('data-theme') === 'dark'; + } } diff --git a/src/app/services/board-selection.service.ts b/src/app/services/board-selection.service.ts new file mode 100644 index 0000000..54de571 --- /dev/null +++ b/src/app/services/board-selection.service.ts @@ -0,0 +1,64 @@ +import { Injectable, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { GameApiService } from './game-api.service'; +import { getPieceAtSquare, isPieceColor } from '../core/chess/fen.utils'; +import { GameState, LegalMove } from '../models/game.models'; + +export interface BoardSelection { + selectedSquare: string | null; + highlightedSquares: string[]; + selectedSquareMoves: LegalMove[]; +} + +@Injectable({ providedIn: 'root' }) +export class BoardSelectionService { + private readonly gameApi = inject(GameApiService); + private readonly destroyRef = inject(DestroyRef); + + handleSquareSelection( + square: string, + gameId: string, + state: GameState | null, + currentSelection: BoardSelection, + onMovesLoaded: (moves: LegalMove[]) => void, + onError: (error: string) => void + ): BoardSelection { + if (!state) { + return currentSelection; + } + + // If clicking on a highlighted square, it's a move + if (currentSelection.selectedSquare && currentSelection.highlightedSquares.includes(square)) { + const selectedMove = currentSelection.selectedSquareMoves.find((move) => move.to === square); + if (selectedMove) { + return { selectedSquare: null, highlightedSquares: [], selectedSquareMoves: [] }; + } + return currentSelection; + } + + // Check if square has a piece of the correct color + const piece = getPieceAtSquare(state.fen, square); + if (!piece || !isPieceColor(piece, state.turn)) { + return { selectedSquare: null, highlightedSquares: [], selectedSquareMoves: [] }; + } + + // Load legal moves for this square + this.gameApi + .getLegalMoves(gameId, square) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (response) => { + onMovesLoaded(response.moves); + }, + error: () => { + onError('Could not load legal moves for selected square.'); + } + }); + + return { selectedSquare: square, highlightedSquares: [], selectedSquareMoves: [] }; + } + + clearSelection(): BoardSelection { + return { selectedSquare: null, highlightedSquares: [], selectedSquareMoves: [] }; + } +} diff --git a/src/app/services/bot-move.service.ts b/src/app/services/bot-move.service.ts new file mode 100644 index 0000000..e625d39 --- /dev/null +++ b/src/app/services/bot-move.service.ts @@ -0,0 +1,78 @@ +import { Injectable, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { delay, Subscription } from 'rxjs'; +import { GameApiService } from './game-api.service'; +import { getErrorMessage } from '../core/http/error-message.util'; +import { GameFull, GameState } from '../models/game.models'; + +@Injectable({ providedIn: 'root' }) +export class BotMoveService { + private readonly gameApi = inject(GameApiService); + private readonly destroyRef = inject(DestroyRef); + private botMoveSubscription: Subscription | null = null; + + private isBotPlayer(playerId: string): boolean { + return playerId.startsWith('bot-'); + } + + isPlayingAgainstBot(game: GameFull | null): boolean { + if (!game) { + return false; + } + return this.isBotPlayer(game.white.id) || this.isBotPlayer(game.black.id); + } + + isCurrentPlayerBot(game: GameFull | null, state: GameState | null): boolean { + if (!game || !state) { + return false; + } + const currentPlayer = state.turn === 'white' ? game.white : game.black; + return this.isBotPlayer(currentPlayer.id); + } + + tryMakeBotMove( + gameId: string, + game: GameFull | null, + state: GameState | null, + onSuccess: (updatedState: GameState) => void, + onError: (error: string) => void + ): void { + if (!this.isPlayingAgainstBot(game) || !this.isCurrentPlayerBot(game, state) || !state) { + return; + } + + this.botMoveSubscription?.unsubscribe(); + this.botMoveSubscription = this.gameApi + .getLegalMoves(gameId) + .pipe( + delay(1000), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: (response) => { + if (response.moves.length === 0) { + return; + } + const botMove = response.moves[Math.floor(Math.random() * response.moves.length)]; + this.gameApi + .makeMove(gameId, botMove.uci) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (updatedState) => { + onSuccess(updatedState); + }, + error: (error) => { + onError(getErrorMessage(error, 'Bot move failed.')); + } + }); + }, + error: () => { + onError('Could not get legal moves for bot move.'); + } + }); + } + + cleanup(): void { + this.botMoveSubscription?.unsubscribe(); + } +} diff --git a/src/app/services/game-api.service.ts b/src/app/services/game-api.service.ts index 5508da3..c362f58 100644 --- a/src/app/services/game-api.service.ts +++ b/src/app/services/game-api.service.ts @@ -1,32 +1,54 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; import { - ErrorEvent, GameFull, GameState, GameStreamEvent, - LegalMovesResponse + LegalMovesResponse, + PlayerInfo } from '../models/game.models'; +import { StreamHandlerService } from './stream-handler.service'; @Injectable({ providedIn: 'root' }) export class GameApiService { private readonly apiBase = environment.apiBaseUrl; private readonly wsBase = environment.wsBaseUrl; + private readonly apiPath = environment.apiPath; + private readonly streamHandler = inject(StreamHandlerService); constructor(private readonly http: HttpClient) {} createGame(): Observable { - return this.http.post(`${this.apiBase}/api/board/game`, {}); + return this.http.post(`${this.apiBase}${this.apiPath}`, {}); + } + + createGameVsBot(difficulty: 'easy' | 'medium' | 'hard' = 'medium'): Observable { + const playerColor = Math.random() > 0.5 ? 'white' : 'black'; + const playerInfo: PlayerInfo = { + id: `player-${Date.now()}`, + displayName: 'You' + }; + const botInfo: PlayerInfo = { + id: `bot-${difficulty}`, + displayName: `Bot (${difficulty})` + }; + + const payload = + playerColor === 'white' + ? { white: playerInfo, black: botInfo } + : { white: botInfo, black: playerInfo }; + + return this.http.post(`${this.apiBase}${this.apiPath}`, payload); } getGame(gameId: string): Observable { - return this.http.get(`${this.apiBase}/api/board/game/${gameId}`); + return this.http.get(`${this.apiBase}${this.apiPath}/${gameId}`); } makeMove(gameId: string, uci: string): Observable { - return this.http.post(`${this.apiBase}/api/board/game/${gameId}/move/${uci}`, {}); + return this.http.post(`${this.apiBase}${this.apiPath}/${gameId}/move/${uci}`, {}); } getLegalMoves(gameId: string, square?: string): Observable { @@ -34,127 +56,29 @@ export class GameApiService { if (square) { params = params.set('square', square); } - return this.http.get(`${this.apiBase}/api/board/game/${gameId}/moves`, { params }); + return this.http.get(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { params }); } importFen(fen: string): Observable { - return this.http.post(`${this.apiBase}/api/board/game/import/fen`, { fen }); + return this.http.post(`${this.apiBase}${this.apiPath}/import/fen`, { fen }); } importPgn(pgn: string): Observable { - return this.http.post(`${this.apiBase}/api/board/game/import/pgn`, { pgn }); + return this.http.post(`${this.apiBase}${this.apiPath}/import/pgn`, { pgn }); + } + + private resolveWsBase(): string { + if (this.wsBase) { + return this.wsBase; + } + + const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + return `${wsProtocol}://${window.location.host}`; } streamGame(gameId: string): Observable { - return new Observable((observer) => { - const wsUrl = `${this.wsBase}/api/board/game/${gameId}/stream`; - const streamUrl = `${this.apiBase}/api/board/game/${gameId}/stream`; - 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 { - const parsed = JSON.parse(raw) as GameStreamEvent; - return parsed; - } catch { - return null; - } - }; - - const emitErrorEvent = (message: string): void => { - const errorEvent: ErrorEvent = { - type: 'error', - error: { code: 'STREAM_ERROR', message } - }; - observer.next(errorEvent); - }; - - const startNdjsonFallback = async (): Promise => { - if (fallbackActive) { - return; - } - - fallbackActive = true; - - try { - const response = await fetch(streamUrl, { - headers: { Accept: 'application/x-ndjson' }, - signal: abortController.signal - }); - - if (!response.ok || !response.body) { - emitErrorEvent(`Unable to open stream: HTTP ${response.status}`); - observer.complete(); - return; - } - - 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); - } - } - }; - - ws.onopen = () => { - connected = true; - }; - - ws.onmessage = (message) => { - const payload = typeof message.data === 'string' ? message.data : ''; - const event = parseEvent(payload); - if (event) { - observer.next(event); - } - }; - - ws.onerror = () => { - if (!connected) { - void startNdjsonFallback(); - } - }; - - ws.onclose = () => { - if (!connected) { - void startNdjsonFallback(); - } else { - observer.complete(); - } - }; - - return () => { - abortController.abort(); - ws.close(); - }; - }); + const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/stream`; + const fallbackUrl = `${this.apiBase}${this.apiPath}/${gameId}/stream`; + return this.streamHandler.createGameStream(wsUrl, fallbackUrl, gameId); } } diff --git a/src/app/services/game-completion.service.ts b/src/app/services/game-completion.service.ts new file mode 100644 index 0000000..6025b08 --- /dev/null +++ b/src/app/services/game-completion.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; +import { GameFull, GameState, GameStatus } from '../models/game.models'; + +export interface GameCompletion { + isFinished: boolean; + message: string; +} + +@Injectable({ providedIn: 'root' }) +export class GameCompletionService { + getGameCompletion(game: GameFull | null, state: GameState | null): GameCompletion { + if (!game || !state) { + return { isFinished: false, message: '' }; + } + + const status = state.status; + const gameEndingStatuses: GameStatus[] = ['checkmate', 'stalemate', 'resign', 'draw', 'insufficientMaterial']; + + if (!gameEndingStatuses.includes(status)) { + return { isFinished: false, message: '' }; + } + + const message = this.buildCompletionMessage(status, state, game); + return { isFinished: true, message }; + } + + private buildCompletionMessage(status: GameStatus, state: GameState, game: GameFull): string { + switch (status) { + case 'checkmate': + const winner = state.winner === 'white' ? game.white.displayName : game.black.displayName; + return `Checkmate! ${winner} wins!`; + case 'stalemate': + return 'Stalemate! The game is a draw.'; + case 'resign': + const resignedPlayer = state.winner === 'white' ? game.black.displayName : game.white.displayName; + const resignedWinner = state.winner === 'white' ? game.white.displayName : game.black.displayName; + return `${resignedPlayer} resigned. ${resignedWinner} wins!`; + case 'draw': + return 'Draw! The game ended in a draw.'; + case 'insufficientMaterial': + return 'Insufficient material! The game is a draw.'; + default: + return 'Game ended!'; + } + } +} diff --git a/src/app/services/game-import.service.ts b/src/app/services/game-import.service.ts new file mode 100644 index 0000000..a46c5f4 --- /dev/null +++ b/src/app/services/game-import.service.ts @@ -0,0 +1,62 @@ +import { Injectable, DestroyRef, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { GameApiService } from './game-api.service'; +import { getErrorMessage } from '../core/http/error-message.util'; + +@Injectable({ providedIn: 'root' }) +export class GameImportService { + private readonly gameApi = inject(GameApiService); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + importFen( + fen: string, + onSuccess: () => void, + onError: (error: string) => void + ): void { + const trimmedFen = fen.trim(); + if (!trimmedFen) { + onError('Please provide a FEN string.'); + return; + } + + this.gameApi + .importFen(trimmedFen) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (game) => { + onSuccess(); + void this.router.navigate(['/game', game.gameId]); + }, + error: (error) => { + onError(getErrorMessage(error, 'FEN import failed.')); + } + }); + } + + importPgn( + pgn: string, + onSuccess: () => void, + onError: (error: string) => void + ): void { + const trimmedPgn = pgn.trim(); + if (!trimmedPgn) { + onError('Please provide a PGN string.'); + return; + } + + this.gameApi + .importPgn(trimmedPgn) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (game) => { + onSuccess(); + void this.router.navigate(['/game', game.gameId]); + }, + error: (error) => { + onError(getErrorMessage(error, 'PGN import failed.')); + } + }); + } +} diff --git a/src/app/services/game-stream.service.ts b/src/app/services/game-stream.service.ts new file mode 100644 index 0000000..9ed823b --- /dev/null +++ b/src/app/services/game-stream.service.ts @@ -0,0 +1,63 @@ +import { Injectable, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { interval, startWith, Subscription, switchMap } from 'rxjs'; +import { GameApiService } from './game-api.service'; +import { GameStreamEvent } from '../models/game.models'; + +@Injectable({ providedIn: 'root' }) +export class GameStreamService { + private readonly gameApi = inject(GameApiService); + private readonly destroyRef = inject(DestroyRef); + private streamSubscription: Subscription | null = null; + private pollSubscription: Subscription | null = null; + + startStreaming( + gameId: string, + onEvent: (event: GameStreamEvent) => void, + onStreamError: () => void + ): void { + this.streamSubscription = this.gameApi + .streamGame(gameId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (event) => onEvent(event), + error: () => { + onStreamError(); + this.startPolling(gameId, onEvent); + }, + complete: () => { + onStreamError(); + this.startPolling(gameId, onEvent); + } + }); + } + + startPolling(gameId: string, onEvent: (event: GameStreamEvent) => void): void { + if (this.pollSubscription) { + return; + } + + this.pollSubscription = interval(1500) + .pipe( + startWith(0), + switchMap(() => this.gameApi.getGame(gameId)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: (game) => { + const event: GameStreamEvent = { + type: 'gameFull', + game + }; + onEvent(event); + } + }); + } + + cleanup(): void { + this.streamSubscription?.unsubscribe(); + this.pollSubscription?.unsubscribe(); + this.streamSubscription = null; + this.pollSubscription = null; + } +} diff --git a/src/app/services/stream-handler.service.ts b/src/app/services/stream-handler.service.ts new file mode 100644 index 0000000..7a9db6d --- /dev/null +++ b/src/app/services/stream-handler.service.ts @@ -0,0 +1,122 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { GameStreamEvent, ErrorEvent } from '../models/game.models'; + +@Injectable({ providedIn: 'root' }) +export class StreamHandlerService { + createGameStream(wsUrl: string, fallbackUrl: 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 = { + type: 'error', + error: { code: 'STREAM_ERROR', message } + }; + 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); + } + } + }; + + ws.onopen = () => { + connected = true; + }; + + ws.onmessage = (message) => { + const payload = typeof message.data === 'string' ? message.data : ''; + const event = parseEvent(payload); + if (event) { + observer.next(event); + } + }; + + ws.onerror = (error) => { + console.warn(`[StreamHandler] WebSocket error for ${gameId}, attempting NDJSON fallback:`, error); + if (!connected) { + void startNdjsonFallback(); + } + }; + + ws.onclose = () => { + console.warn(`[StreamHandler] WebSocket closed for ${gameId}, connected=${connected}`); + if (!connected) { + console.log(`[StreamHandler] Starting NDJSON fallback for ${gameId}`); + void startNdjsonFallback(); + } else { + observer.complete(); + } + }; + + return () => { + abortController.abort(); + ws.close(); + }; + }); + } +} diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 5ad9ba5..e55757c 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -1,5 +1,6 @@ export const environment = { production: false, - apiBaseUrl: 'http://localhost:8080', - wsBaseUrl: 'ws://localhost:8080' + apiBaseUrl: '', + wsBaseUrl: 'ws://localhost:8080', + apiPath: '/api/board/game' }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index ac31fb7..42a2794 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,5 +1,6 @@ export const environment = { production: true, - apiBaseUrl: 'http://localhost:8080', - wsBaseUrl: 'ws://localhost:8080' + apiBaseUrl: '', + wsBaseUrl: 'ws://localhost:8080', + apiPath: '/api/board/game' }; diff --git a/src/styles-variables.css b/src/styles-variables.css new file mode 100644 index 0000000..32b9dc3 --- /dev/null +++ b/src/styles-variables.css @@ -0,0 +1,125 @@ +/* ======================================== + COLOR VARIABLES - Semantic Naming + ======================================== */ + +/* Light Mode Colors (Default) */ +:root:not([data-theme='dark']) { + /* Primary Colors - Light Mode */ + --color-primary: #BA6D4B; + --color-primary-dark: #5A2C28; + --color-primary-light: #F3C8A0; + + /* Secondary Colors - Light Mode */ + --color-secondary-mint: #B9DAD1; + --color-secondary-blue: #B9C2DA; + --color-secondary-purple: #C19EF5; + --color-secondary-lime: #E1EAA9; + + /* Functional Colors - Light Mode */ + --color-bg-main: #F3C8A0; + --color-bg-board: #B9DAD1; + --color-bg-card: #E1EAA9; + --color-bg-input: #B9DAD1; + --color-bg-input-focus: #B9C2DA; + --color-bg-button: #C19EF5; + --color-bg-button-hover: #BA6D4B; + + --color-text-primary: #5A2C28; + --color-text-button-hover: #F3C8A0; + --color-border: #5A2C28; +} + +/* Dark Mode Colors */ +:root[data-theme='dark'] { + /* Primary Colors - Dark Mode */ + --color-primary: #1a3a52; + --color-primary-dark: #0f1f2e; + --color-primary-light: #2d5a7b; + + /* Secondary Colors - Dark Mode */ + --color-secondary-mint: #4a7c7c; + --color-secondary-blue: #5a6fa5; + --color-secondary-purple: #7d5fa8; + --color-secondary-lime: #6b8e23; + + /* Functional Colors - Dark Mode */ + --color-bg-main: #1a2f47; + --color-bg-board: #2d5a7b; + --color-bg-card: #3d4e63; + --color-bg-input: #2d4a6f; + --color-bg-input-focus: #3d5a8f; + --color-bg-button: #5a7da5; + --color-bg-button-hover: #7a9dc5; + + --color-text-primary: #e8f0f8; + --color-text-button-hover: #ffffff; + --color-border: #4a7c9c; +} + +:root { + + /* ======================================== + TYPOGRAPHY SIZES + ======================================== */ + + /* Header Sizes */ + --heading-h1: clamp(1.5rem, 4vw, 2rem); + --heading-h1-tablet: clamp(1.25rem, 3vw, 1.75rem); + --heading-h1-mobile: 1.25rem; + --heading-h1-small: 1.1rem; + + --heading-h2: clamp(1.5rem, 4vw, 2rem); + + /* ======================================== + SPACING SIZES (XS, SM, MD, LG, XL) + ======================================== */ + + /* Extra Small - Minimal spacing */ + --size-xs: 0.25rem; + --size-xs-padding: 0.4rem; + --size-xs-gap: 0.15rem; + + /* Small - Compact spacing */ + --size-sm: 0.5rem; + --size-sm-padding: 0.5rem; + --size-sm-gap: 0.35rem; + + /* Medium - Default spacing */ + --size-md: 0.75rem; + --size-md-padding: 0.6rem 0.75rem; + --size-md-gap: 0.5rem; + + /* Large - Generous spacing */ + --size-lg: 1rem; + --size-lg-padding: 1rem; + --size-lg-gap: 0.75rem; + + /* Extra Large - Maximum spacing */ + --size-xl: 1.5rem; + --size-xl-padding: 1.5rem; + --size-xl-gap: 1rem; + + /* ======================================== + BORDER & RADIUS + ======================================== */ + --border-width: 2px; + --border-radius-sm: 8px; + --border-radius-md: 10px; + --border-radius-lg: 12px; + + /* ======================================== + FORM ELEMENTS + ======================================== */ + --form-input-padding: 0.6rem 0.75rem; + --form-input-border: var(--border-width) solid var(--color-border); + --form-input-radius: var(--border-radius-md); + + --button-padding: 0.6rem 1rem; + --button-border: var(--border-width) solid var(--color-border); + --button-radius: var(--border-radius-sm); + + /* ======================================== + SHADOWS + ======================================== */ + --shadow-md: 0 8px 24px rgba(90, 44, 40, 0.2); +} \ No newline at end of file diff --git a/src/styles.css b/src/styles.css index 628021c..fa5a81b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,26 +1,61 @@ +@import 'styles-variables.css'; @import 'bootstrap/dist/css/bootstrap.min.css'; -:root { - --warm-primary: #BA6D4B; - --warm-dark: #5A2C28; - --warm-light: #F3C8A0; - --cool-mint: #B9DAD1; - --cool-blue: #B9C2DA; - --cool-purple: #C19EF5; - --cool-lime: #E1EAA9; -} - * { box-sizing: border-box; } +/* Light Mode (Default) */ +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); +} + +html:not([data-theme='dark']) body::before { + display: none; +} + +/* Dark Mode */ +html[data-theme='dark'], +html[data-theme='dark'] body { + background: #0f1f2e; + background-image: + radial-gradient(2px 2px at 20px 30px, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0)), + radial-gradient(2px 2px at 60px 70px, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0)), + radial-gradient(1px 1px at 50px 50px, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0)), + radial-gradient(1px 1px at 130px 80px, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0)), + radial-gradient(2px 2px at 90px 10px, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0)), + radial-gradient(1px 1px at 130px 120px, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0)), + radial-gradient(2px 2px at 10px 90px, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0)), + radial-gradient(1px 1px at 40px 120px, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0)); + background-repeat: repeat; + background-size: 150px 150px; + background-attachment: fixed; + color: var(--color-text-primary); +} + +html[data-theme='dark'] body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: + radial-gradient(circle at 20% 80%, rgba(45, 90, 123, 0.1) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(90, 111, 165, 0.1) 0%, transparent 50%), + radial-gradient(circle at 50% 50%, rgba(29, 53, 82, 0.05) 0%, transparent 50%); + pointer-events: none; + z-index: -1; +} + html, body { margin: 0; min-height: 100%; font-family: "Comic Sans MS", "Comic Sans", cursive; - background: linear-gradient(160deg, var(--warm-light), var(--cool-mint)); - color: var(--warm-dark); + position: relative; } button,