From 5951257c991887f82558355e99dcea25053fc38d Mon Sep 17 00:00:00 2001 From: shahdlala66 Date: Wed, 22 Apr 2026 13:05:09 +0200 Subject: [PATCH] feat: move history, export import fixed, timer added --- src/app/pages/game/game.component.css | 124 +++++++++ src/app/pages/game/game.component.html | 89 +++++-- src/app/pages/game/game.component.ts | 91 ++++++- src/app/pages/welcome/welcome.component.css | 191 +------------- src/app/pages/welcome/welcome.component.html | 48 ++++ src/app/pages/welcome/welcome.component.ts | 48 ++++ src/styles.css | 251 +++++++++++++++++++ 7 files changed, 628 insertions(+), 214 deletions(-) diff --git a/src/app/pages/game/game.component.css b/src/app/pages/game/game.component.css index 042bcef..b9ca9ea 100644 --- a/src/app/pages/game/game.component.css +++ b/src/app/pages/game/game.component.css @@ -70,6 +70,130 @@ h2 { container-type: size; } +.timer-card, +.history-card, +.export-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: grid; + gap: var(--size-md); +} + +.timer-card h2, +.history-card h2, +.export-card h2 { + margin: 0; + font-size: 1.1rem; + color: var(--color-text-primary); +} + +.history-list { + margin: 0; + padding-left: 1.1rem; + display: grid; + gap: var(--size-xs); + max-height: 180px; + overflow: auto; +} + +.history-list li { + color: var(--color-text-primary); + display: flex; + gap: var(--size-sm); + align-items: baseline; +} + +.history-number { + font-weight: 700; + min-width: 1.8rem; +} + +.history-move { + font-family: monospace; +} + +.history-empty { + margin: 0; + color: var(--color-text-primary); +} + +.player-timer { + background: var(--color-bg-input); + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-md); + padding: var(--size-md-padding); +} + +.active-timer { + box-shadow: 0 0 0 3px rgba(185, 218, 209, 0.25); +} + +.timer-label { + margin: 0; + color: var(--color-text-primary); + font-weight: 600; +} + +.timer-value { + margin: var(--size-xs) 0 0; + color: var(--color-text-primary); + font-size: 1.35rem; + font-weight: 700; +} + +.export-mode-group { + display: flex; + gap: var(--size-lg); + flex-wrap: wrap; +} + +.export-mode-option { + display: inline-flex; + align-items: center; + gap: var(--size-sm); + color: var(--color-text-primary); + font-weight: 600; +} + +.export-mode-option input { + accent-color: var(--color-primary); +} + +.export-text { + width: 100%; + min-height: 140px; + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-md); + background: var(--color-bg-input); + color: var(--color-text-primary); + padding: var(--size-md-padding); + resize: vertical; +} + +.export-button { + width: fit-content; + border: var(--button-border); + border-radius: var(--button-radius); + background: var(--color-bg-button); + color: var(--color-text-primary); + font-weight: 700; + padding: var(--button-padding); + cursor: pointer; +} + +.export-button:hover { + background: var(--color-bg-button-hover); + color: var(--color-text-button-hover); +} + +.export-note { + margin: 0; + color: var(--color-text-primary); + font-weight: 600; +} + .alert { border-radius: var(--border-radius-sm); border: var(--border-width) solid var(--color-border); diff --git a/src/app/pages/game/game.component.html b/src/app/pages/game/game.component.html index 04c93b2..e0df059 100644 --- a/src/app/pages/game/game.component.html +++ b/src/app/pages/game/game.component.html @@ -25,18 +25,19 @@ }
- +
- +
+

Timers

+
+

White

+

{{ formatTimer(whiteTimerSeconds) }}

+
+
+

Black

+

{{ formatTimer(blackTimerSeconds) }}

+
+
@@ -67,18 +68,62 @@
- +
- +
+

Move History

+ + @if (facade.state.moves.length === 0) { +

No moves yet.

+ } @else { +
    + @for (move of facade.state.moves; track $index) { +
  1. + {{ $index + 1 }}. + {{ move }} +
  2. + } +
+ } +
+ +
+

Export

+
+ + +
+ + + + + + @if (exportNotice) { +

{{ exportNotice }}

+ } +
diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts index 307aed4..a51e0cd 100644 --- a/src/app/pages/game/game.component.ts +++ b/src/app/pages/game/game.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, DestroyRef, OnInit, inject } from '@angular/core'; +import { Component, DestroyRef, OnDestroy, OnInit, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, RouterLink } from '@angular/router'; @@ -16,12 +16,20 @@ import { GameFacade } from './game.facade'; templateUrl: './game.component.html', styleUrl: './game.component.css' }) -export class GameComponent implements OnInit { +export class GameComponent implements OnInit, OnDestroy { private readonly route = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); readonly facade = inject(GameFacade); + whiteTimerSeconds = 10 * 60; + blackTimerSeconds = 10 * 60; + exportType: 'fen' | 'pgn' = 'fen'; + exportValue = ''; + exportNotice = ''; + private timerIntervalId: number | null = null; ngOnInit(): void { + this.startDummyTimers(); + this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => { const id = paramMap.get('gameId'); if (!id) { @@ -31,6 +39,85 @@ export class GameComponent implements OnInit { } this.facade.setGameId(id); + this.syncExportValue(); }); } + + ngOnDestroy(): void { + if (this.timerIntervalId !== null) { + window.clearInterval(this.timerIntervalId); + } + } + + setExportType(type: 'fen' | 'pgn'): void { + this.exportType = type; + this.exportNotice = ''; + this.syncExportValue(); + } + + completeExport(): void { + this.syncExportValue(); + if (!this.exportValue.trim()) { + this.exportNotice = 'Nothing to export yet.'; + return; + } + + if (!navigator.clipboard?.writeText) { + this.exportNotice = 'Export is ready in the text box.'; + return; + } + + void navigator.clipboard + .writeText(this.exportValue) + .then(() => { + this.exportNotice = 'Copied to clipboard.'; + }) + .catch(() => { + this.exportNotice = 'Export is ready in the text box.'; + }); + } + + formatTimer(totalSeconds: number): string { + const safeSeconds = Math.max(0, totalSeconds); + const minutes = Math.floor(safeSeconds / 60) + .toString() + .padStart(2, '0'); + const seconds = (safeSeconds % 60).toString().padStart(2, '0'); + return `${minutes}:${seconds}`; + } + + private startDummyTimers(): void { + if (this.timerIntervalId !== null) { + return; + } + + this.timerIntervalId = window.setInterval(() => { + this.tickDummyTimers(); + this.syncExportValue(); + }, 1000); + } + + private tickDummyTimers(): void { + const state = this.facade.state; + if (!state || this.facade.loading || this.facade.isGameFinished) { + return; + } + + if (state.turn === 'white') { + this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - 1); + return; + } + + this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - 1); + } + + private syncExportValue(): void { + const state = this.facade.state; + if (!state) { + this.exportValue = ''; + return; + } + + this.exportValue = this.exportType === 'fen' ? state.fen : state.pgn; + } } diff --git a/src/app/pages/welcome/welcome.component.css b/src/app/pages/welcome/welcome.component.css index e11a5ea..e919f9b 100644 --- a/src/app/pages/welcome/welcome.component.css +++ b/src/app/pages/welcome/welcome.component.css @@ -8,180 +8,6 @@ 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; @@ -370,21 +196,6 @@ p { } @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: var(--size-lg); } @@ -589,7 +400,7 @@ p { padding: var(--size-md-padding); border: var(--border-width) solid var(--color-border); border-radius: var(--border-radius-md); - background: white; + background: var(--color-bg-input); color: var(--color-text-primary); font-family: inherit; font-size: 1rem; diff --git a/src/app/pages/welcome/welcome.component.html b/src/app/pages/welcome/welcome.component.html index d5b67d8..254d5c7 100644 --- a/src/app/pages/welcome/welcome.component.html +++ b/src/app/pages/welcome/welcome.component.html @@ -58,6 +58,11 @@ Join Game {{ joiningGame ? 'Joining...' : 'Enter game ID' }} + + @if (showJoinGameForm) { @@ -92,6 +97,49 @@ } + @if (showImportGameForm) { +
+

Import game

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

{{ errorMessage }}

} diff --git a/src/app/pages/welcome/welcome.component.ts b/src/app/pages/welcome/welcome.component.ts index 3f3c149..a948317 100644 --- a/src/app/pages/welcome/welcome.component.ts +++ b/src/app/pages/welcome/welcome.component.ts @@ -18,8 +18,12 @@ export class WelcomeComponent { errorMessage = ''; showDifficultySelector = false; showJoinGameForm = false; + showImportGameForm = false; gameIdInput = ''; joiningGame = false; + importing = false; + importMode: 'fen' | 'pgn' = 'fen'; + importText = ''; constructor( private readonly router: Router, @@ -81,16 +85,60 @@ export class WelcomeComponent { toggleDifficultySelector(): void { this.showDifficultySelector = !this.showDifficultySelector; this.showJoinGameForm = false; + this.showImportGameForm = false; this.errorMessage = ''; } toggleJoinGameForm(): void { this.showJoinGameForm = !this.showJoinGameForm; this.showDifficultySelector = false; + this.showImportGameForm = false; this.errorMessage = ''; this.gameIdInput = ''; } + toggleImportGameForm(): void { + this.showImportGameForm = !this.showImportGameForm; + this.showDifficultySelector = false; + this.showJoinGameForm = false; + this.errorMessage = ''; + + if (!this.showImportGameForm) { + this.importText = ''; + this.importMode = 'fen'; + } + } + + setImportMode(mode: 'fen' | 'pgn'): void { + this.importMode = mode; + this.errorMessage = ''; + } + + submitImportedGame(): void { + const trimmedImport = this.importText.trim(); + if (this.importing || !trimmedImport) { + return; + } + + this.errorMessage = ''; + this.importing = true; + + const importRequest = + this.importMode === 'fen' ? this.gameApi.importFen(trimmedImport) : this.gameApi.importPgn(trimmedImport); + + importRequest.pipe(finalize(() => (this.importing = false))).subscribe({ + next: (game) => { + this.importText = ''; + this.showImportGameForm = false; + void this.router.navigate(['/game', game.gameId]); + }, + error: (error) => { + const defaultMessage = this.importMode === 'fen' ? 'Unable to import FEN.' : 'Unable to import PGN.'; + this.errorMessage = getErrorMessage(error, defaultMessage); + } + }); + } + joinGame(): void { if (this.joiningGame || !this.gameIdInput.trim()) { return; diff --git a/src/styles.css b/src/styles.css index fa5a81b..520eacd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -62,3 +62,254 @@ button, input { font: inherit; } + +.welcome-shell .import-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; + display: grid; + gap: var(--size-md); +} + +.welcome-shell .import-game-form p { + margin: 0; + font-weight: 600; + color: var(--color-text-primary); +} + +.welcome-shell .import-mode-group { + display: flex; + gap: var(--size-lg); + flex-wrap: wrap; +} + +.welcome-shell .import-mode-option { + display: inline-flex; + align-items: center; + gap: var(--size-sm); + font-weight: 600; + color: var(--color-text-primary); +} + +.welcome-shell .import-mode-option input { + accent-color: var(--color-primary); +} + +.welcome-shell .import-game-text { + width: 100%; + resize: vertical; + min-height: 110px; + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-md); + background: var(--color-bg-input); + color: var(--color-text-primary); + padding: var(--size-md-padding); +} + +.welcome-shell .import-game-text:focus { + outline: none; + border-color: var(--color-secondary-mint); + box-shadow: 0 0 0 3px rgba(185, 218, 209, 0.2); +} + +.welcome-shell .theme-toggle-container { + position: absolute; + top: 20px; + right: 20px; + z-index: 100; +} + +.welcome-shell .switch { + display: inline-block; + position: relative; +} + +.welcome-shell .switch__input { + clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(50%); + height: 1px; + width: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; +} + +.welcome-shell .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); +} + +.welcome-shell .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); +} + +.welcome-shell .switch__indicator::before, +.welcome-shell .switch__indicator::after { + position: absolute; + content: ''; + display: block; + background-color: #FFFFFF; + border-radius: 9999px; + transition: all 0.4s cubic-bezier(.46,.03,.52,.96); +} + +.welcome-shell .switch__indicator::before { + top: 7px; + left: 7px; + width: 9px; + height: 9px; + opacity: 0.6; +} + +.welcome-shell .switch__indicator::after { + bottom: 8px; + right: 6px; + width: 14px; + height: 14px; + opacity: 0.8; +} + +.welcome-shell .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); +} + +.welcome-shell .switch__decoration::before, +.welcome-shell .switch__decoration::after { + position: absolute; + display: block; + content: ''; + width: 5px; + height: 5px; + background-color: #FFFFFF; + border-radius: 9999px; +} + +.welcome-shell .switch__decoration::before { + top: -20px; + left: 10px; + opacity: 1; + animation: twinkle-stars 0.6s infinite; +} + +.welcome-shell .switch__decoration::after { + top: -7px; + left: 30px; + animation: twinkle-stars 0.6s infinite -0.2s; +} + +@keyframes twinkle-stars { + 50% { + opacity: 0.2; + } +} + +.welcome-shell .switch__input:checked + .switch__label { + background-color: #8FB5F5; + border-color: #347CF8; +} + +.welcome-shell .switch__input:checked + .switch__label .switch__indicator { + background-color: #ECD21F; + box-shadow: none; + transform: translate(-50%, -50%) translateX(72%); +} + +.welcome-shell .switch__input:checked + .switch__label .switch__indicator::before, +.welcome-shell .switch__input:checked + .switch__label .switch__indicator::after { + display: none; +} + +.welcome-shell .switch__input:checked + .switch__label .switch__decoration { + top: 50%; + transform: translate(0%, -50%); + animation: cloud 8s linear infinite; + width: 20px; + height: 20px; +} + +.welcome-shell .switch__input:checked + .switch__label .switch__decoration::before { + width: 10px; + height: 10px; + top: auto; + bottom: 0; + left: -8px; + animation: none; +} + +.welcome-shell .switch__input:checked + .switch__label .switch__decoration::after { + width: 15px; + height: 15px; + top: auto; + bottom: 0; + left: 16px; + animation: none; +} + +.welcome-shell .switch__input:checked + .switch__label .switch__decoration, +.welcome-shell .switch__input:checked + .switch__label .switch__decoration::before, +.welcome-shell .switch__input:checked + .switch__label .switch__decoration::after { + border-radius: 9999px 9999px 0 0; +} + +.welcome-shell .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%); + } +} + +@media (max-width: 768px) { + .welcome-shell .theme-toggle-container { + top: 10px; + right: 10px; + } + + .welcome-shell .switch__label { + width: 100px; + height: 50px; + } + + .welcome-shell .switch__indicator { + width: 33px; + height: 33px; + } +}