From 873bfe3bae61d4d865b80ac9369c30766df8da16 Mon Sep 17 00:00:00 2001 From: TeamCity Date: Mon, 1 Jun 2026 21:58:57 +0000 Subject: [PATCH 1/7] ci: bump version to v0.2.5 --- CHANGELOG.md | 1 + versions.env | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76241b4..c1eabea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,3 +44,4 @@ * build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080)) * NCWF-2 bugs and desing fixes ([#7](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/7)) ([c02414e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c02414ea40177b05a5e62dcf68dcb44efa6d3740)) +## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.4...0.0.0) (2026-06-01) diff --git a/versions.env b/versions.env index a146044..04725b4 100644 --- a/versions.env +++ b/versions.env @@ -1,3 +1,3 @@ MAJOR=0 MINOR=2 -PATCH=4 +PATCH=5 From 95eff42dfe6d9c23ede08c7297614369a1b00d9f Mon Sep 17 00:00:00 2001 From: Shahd Lala Date: Tue, 2 Jun 2026 21:55:55 +0200 Subject: [PATCH 2/7] fix: NCWF-4 Token Issues (#8) Co-authored-by: Lala, Shahd Co-authored-by: shahdlala66 Reviewed-on: https://git.janis-eccarius.de/NowChess/NowChess-Frontend/pulls/8 --- proxy.conf.json | 5 + src/app/app.routes.ts | 4 + .../login-dialog/login-dialog.component.css | 316 +++++++++++++++-- .../login-dialog/login-dialog.component.html | 51 ++- .../move-history/move-history.component.css | 5 + .../move-history/move-history.component.html | 13 +- .../move-history/move-history.component.ts | 39 ++- .../register-dialog.component.css | 321 +++++++++++++++-- .../register-dialog.component.html | 71 ++-- .../components/toolbar/toolbar.component.html | 3 +- .../components/toolbar/toolbar.component.ts | 30 +- src/app/core/config.loader.ts | 4 +- src/app/models/auth.models.ts | 3 +- src/app/models/bot.models.ts | 10 + src/app/models/tournament.models.ts | 66 ++++ src/app/pages/bots/bots.component.css | 163 +++++++++ src/app/pages/bots/bots.component.html | 125 +++++++ src/app/pages/bots/bots.component.ts | 111 ++++++ src/app/pages/game/game.component.css | 156 +++++++-- src/app/pages/game/game.component.html | 46 ++- src/app/pages/game/game.component.ts | 14 +- src/app/pages/game/game.facade.ts | 88 ++++- src/app/pages/games/games.component.ts | 9 +- .../tournaments/tournaments.component.css | 329 ++++++++++++++++++ .../tournaments/tournaments.component.html | 273 +++++++++++++++ .../tournaments/tournaments.component.ts | 232 ++++++++++++ src/app/pages/welcome/Auth Dialog.html | 89 +++++ src/app/pages/welcome/welcome.component.css | 234 ------------- src/app/pages/welcome/welcome.component.html | 51 --- src/app/pages/welcome/welcome.component.ts | 106 ------ src/app/services/auth.interceptor.ts | 5 +- src/app/services/auth.service.ts | 5 +- src/app/services/bot.service.ts | 27 ++ src/app/services/game-api.service.ts | 14 +- src/app/services/game-completion.service.ts | 23 +- src/app/services/tournament.service.ts | 52 +++ src/environments/environment.development.ts | 2 +- 37 files changed, 2522 insertions(+), 573 deletions(-) create mode 100644 src/app/models/bot.models.ts create mode 100644 src/app/models/tournament.models.ts create mode 100644 src/app/pages/bots/bots.component.css create mode 100644 src/app/pages/bots/bots.component.html create mode 100644 src/app/pages/bots/bots.component.ts create mode 100644 src/app/pages/tournaments/tournaments.component.css create mode 100644 src/app/pages/tournaments/tournaments.component.html create mode 100644 src/app/pages/tournaments/tournaments.component.ts create mode 100644 src/app/pages/welcome/Auth Dialog.html create mode 100644 src/app/services/bot.service.ts create mode 100644 src/app/services/tournament.service.ts diff --git a/proxy.conf.json b/proxy.conf.json index fa98e8c..f243619 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -1,4 +1,9 @@ { + "/api/tournament": { + "target": "http://localhost:8089", + "secure": false, + "changeOrigin": true + }, "/api/account": { "target": "http://localhost:8083", "secure": false, diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index db5cc47..54f7663 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -4,12 +4,16 @@ import { WelcomeComponent } from './pages/welcome/welcome.component'; import { ProfileComponent } from './pages/profile/profile.component'; import { ChallengesComponent } from './pages/challenges/challenges.component'; import { GamesComponent } from './pages/games/games.component'; +import { TournamentsComponent } from './pages/tournaments/tournaments.component'; +import { BotsComponent } from './pages/bots/bots.component'; export const routes: Routes = [ { path: '', component: WelcomeComponent }, { path: 'profile', component: ProfileComponent }, { path: 'games', component: GamesComponent }, { path: 'challenges', component: ChallengesComponent }, + { path: 'tournaments', component: TournamentsComponent }, + { path: 'bots', component: BotsComponent }, { path: 'game/:gameId', component: GameComponent }, { path: '**', redirectTo: '' } ]; diff --git a/src/app/components/login-dialog/login-dialog.component.css b/src/app/components/login-dialog/login-dialog.component.css index acea1e8..813b554 100644 --- a/src/app/components/login-dialog/login-dialog.component.css +++ b/src/app/components/login-dialog/login-dialog.component.css @@ -1,58 +1,293 @@ -@import '../../button-template.css'; +:host { + --auth-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --auth-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace; + --auth-neon: #ff45c8; + --auth-neon-soft: rgba(255, 69, 200, 0.55); + --auth-bg: #06060d; +} .dialog-overlay { position: fixed; inset: 0; - background: rgba(2, 2, 10, 0.58); + background: rgba(4, 2, 12, 0.72); + backdrop-filter: blur(6px); display: grid; place-items: center; z-index: 350; padding: 1rem; + animation: backdrop-in 180ms ease-out both; } .dialog-card { - width: min(460px, 100%); - background: var(--dlg-bg); - border: 1.5px solid var(--dlg-border); - box-shadow: var(--bb-glow); - border-radius: 4px; - padding: 1rem; + position: relative; + width: min(420px, 100%); + background: + radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.10), transparent 60%), + linear-gradient(180deg, #0a0612 0%, #06060d 100%); + border: 1px solid var(--auth-neon-soft); + border-radius: 14px; + padding: 28px 26px 22px; display: grid; - gap: 0.7rem; + gap: 14px; + font-family: var(--auth-sans); + color: #fff; + box-shadow: + 0 0 0 1px rgba(255, 69, 200, 0.06) inset, + 0 0 30px rgba(255, 69, 200, 0.18), + 0 30px 60px rgba(0, 0, 0, 0.55); + overflow: hidden; + animation: dialog-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +.dialog-card::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient( + 180deg, + transparent 0%, + rgba(255, 69, 200, 0.06) 50%, + transparent 100% + ); + height: 35%; + animation: scanline 4.5s linear infinite; + mix-blend-mode: screen; +} + +.dialog-card::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: + repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.012) 0px, + rgba(255, 255, 255, 0.012) 1px, + transparent 1px, + transparent 3px + ); + border-radius: inherit; +} + +.dialog-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + position: relative; + z-index: 1; +} + +.brand-tag { + font-family: var(--auth-mono); + font-size: 10px; + letter-spacing: 2.5px; + color: var(--auth-neon); + text-transform: uppercase; + opacity: 0.85; + animation: pulse-glow 2.4s ease-in-out infinite; +} + +.brand-tag::before { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--auth-neon); + margin-right: 8px; + vertical-align: 1px; + box-shadow: 0 0 8px var(--auth-neon); +} + +.close-btn { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.65); + width: 28px; + height: 28px; + border-radius: 8px; + cursor: pointer; + font-size: 16px; + line-height: 1; + display: grid; + place-items: center; + transition: all 0.18s ease; +} + +.close-btn:hover { + color: var(--auth-neon); + border-color: var(--auth-neon-soft); + box-shadow: 0 0 10px rgba(255, 69, 200, 0.35); } .dialog-title { - font-family: 'Bebas Neue', sans-serif; - font-size: 22px; - letter-spacing: 2px; - color: var(--bb-title); - text-align: center; + font-family: var(--auth-sans); + font-weight: 700; + font-size: 26px; + letter-spacing: 0.5px; + color: #fff; + margin: 4px 0 0; + position: relative; + z-index: 1; +} + +.dialog-subtitle { + font-family: var(--auth-mono); + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + letter-spacing: 0.6px; + margin-bottom: 6px; + position: relative; + z-index: 1; +} + +form { + display: grid; + gap: 12px; + position: relative; + z-index: 1; +} + +.field { + display: grid; + gap: 6px; +} + +.field-label { + font-family: var(--auth-mono); + font-size: 10px; + letter-spacing: 1.5px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.55); } .dialog-input { width: 100%; - background: rgba(4, 4, 20, 0.62); - border: 1px solid var(--bb-border); - color: var(--bb-title); - border-radius: 2px; - padding: 0.6rem 0.7rem; - font-family: 'Space Mono', monospace; + background: rgba(8, 5, 20, 0.6); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #fff; + border-radius: 10px; + padding: 12px 14px; + font-family: var(--auth-mono); font-size: 13px; + letter-spacing: 0.3px; + transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; +} + +.dialog-input::placeholder { + color: rgba(255, 255, 255, 0.28); } .dialog-input:focus { outline: none; - box-shadow: 0 0 0 2px rgba(0, 213, 255, 0.25); -} - -.dialog-textarea { - min-height: 120px; - resize: vertical; + border-color: var(--auth-neon); + background: rgba(20, 6, 26, 0.7); + box-shadow: 0 0 0 3px rgba(255, 69, 200, 0.15), 0 0 18px rgba(255, 69, 200, 0.18); } .text-danger { - color: #dc3545; - font-size: 0.875rem; + color: #ff6ea0; + font-family: var(--auth-mono); + font-size: 11px; + letter-spacing: 0.3px; +} + +.error-banner { + font-family: var(--auth-mono); + font-size: 12px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(255, 69, 110, 0.08); + border: 1px solid rgba(255, 69, 110, 0.35); + color: #ff9bb4; + animation: shake 0.35s ease-out; +} + +.dialog-actions { + display: flex; + gap: 10px; + margin-top: 6px; + position: relative; + z-index: 1; +} + +.btn { + flex: 1; + border-radius: 10px; + padding: 12px 14px; + font-family: var(--auth-sans); + font-weight: 600; + font-size: 13px; + letter-spacing: 0.4px; + cursor: pointer; + transition: all 0.18s ease; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.btn-ghost { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); +} + +.btn-ghost:hover:not(:disabled) { + color: #fff; + border-color: rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.03); +} + +.btn-primary { + background: var(--auth-neon); + border: 1px solid var(--auth-neon); + color: #1a0210; + box-shadow: 0 0 18px rgba(255, 69, 200, 0.45); +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 0 26px rgba(255, 69, 200, 0.7); +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; +} + +.alt-line { + margin-top: 4px; + text-align: center; + font-family: var(--auth-mono); + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + position: relative; + z-index: 1; +} + +.alt-line a { + color: var(--auth-neon); + cursor: pointer; + text-decoration: none; + margin-left: 4px; +} + +.alt-line a:hover { + text-shadow: 0 0 8px var(--auth-neon-soft); +} + +.spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(26, 2, 16, 0.35); + border-top-color: #1a0210; + border-radius: 50%; + animation: spin 0.7s linear infinite; } .sr-only { @@ -66,3 +301,28 @@ white-space: nowrap !important; border: 0 !important; } + +@keyframes scanline { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(300%); } +} +@keyframes pulse-glow { + 0%, 100% { opacity: 0.85; } + 50% { opacity: 1; } +} +@keyframes dialog-in { + from { opacity: 0; transform: translateY(8px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +@keyframes backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20%, 60% { transform: translateX(-4px); } + 40%, 80% { transform: translateX(4px); } +} +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/src/app/components/login-dialog/login-dialog.component.html b/src/app/components/login-dialog/login-dialog.component.html index 0be3592..e6ff05d 100644 --- a/src/app/components/login-dialog/login-dialog.component.html +++ b/src/app/components/login-dialog/login-dialog.component.html @@ -1,34 +1,49 @@
-
LOGIN
+
+ NowChess // Auth + +
+ +

Welcome back

+
Sign in to continue your match
- - - @if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) { - Username must be at least 3 characters - } +
+ + + @if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) { + Username must be at least 3 characters + } +
- - - @if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) { - Password must be at least 6 characters - } +
+ + + @if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) { + Password must be at least 6 characters + } +
@if (errorMessage) { -
{{ errorMessage }}
+
{{ errorMessage }}
}
- - - +
+ +
+ New here?Create an account +
-
\ No newline at end of file + diff --git a/src/app/components/move-history/move-history.component.css b/src/app/components/move-history/move-history.component.css index 8498110..1727b12 100644 --- a/src/app/components/move-history/move-history.component.css +++ b/src/app/components/move-history/move-history.component.css @@ -88,6 +88,11 @@ opacity: 0.8; } +.live-label.reviewing { + color: var(--nc-warning); + opacity: 1; +} + .moves::-webkit-scrollbar { width: 6px; } .moves::-webkit-scrollbar-track { background: transparent; } .moves::-webkit-scrollbar-thumb { background: var(--nc-border-strong); border-radius: 3px; } diff --git a/src/app/components/move-history/move-history.component.html b/src/app/components/move-history/move-history.component.html index 8bae41c..681bc79 100644 --- a/src/app/components/move-history/move-history.component.html +++ b/src/app/components/move-history/move-history.component.html @@ -1,13 +1,16 @@ -
+
@if (movePairs.length === 0) {
No moves yet.
} @else { @for (pair of movePairs; track $index) { -
+
{{ pair.white }}
-
+
{{ pair.black ?? '…' }}
} @@ -31,13 +34,13 @@ -
@if (plyCount > 0) { - LIVE + {{ isLive ? 'LIVE' : 'REVIEWING' }} }
diff --git a/src/app/components/move-history/move-history.component.ts b/src/app/components/move-history/move-history.component.ts index 0221780..0cc6ec0 100644 --- a/src/app/components/move-history/move-history.component.ts +++ b/src/app/components/move-history/move-history.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'; export type MoveNavDirection = 'first' | 'prev' | 'next' | 'last'; @@ -16,7 +16,11 @@ interface MovePair { }) export class MoveHistoryComponent implements OnChanges { @Input({ required: true }) moves: string[] = []; + @Input() viewingPly: number | null = null; @Output() navigate = new EventEmitter(); + @Output() navigateToPly = new EventEmitter(); + + @ViewChild('movesEl') movesEl?: ElementRef; movePairs: MovePair[] = []; @@ -24,24 +28,33 @@ export class MoveHistoryComponent implements OnChanges { return this.moves.length; } - get currentWhiteIndex(): number { - const lastPairIndex = this.movePairs.length - 1; - if (lastPairIndex < 0) return -1; - const lastMove = this.moves.length - 1; - return lastMove % 2 === 0 ? lastPairIndex : -1; - } - - get currentBlackIndex(): number { - const lastPairIndex = this.movePairs.length - 1; - if (lastPairIndex < 0) return -1; - const lastMove = this.moves.length - 1; - return lastMove % 2 === 1 ? lastPairIndex : -1; + get isLive(): boolean { + return this.viewingPly === null || this.viewingPly >= this.moves.length - 1; } ngOnChanges(): void { this.movePairs = this.buildPairs(this.moves); } + isWhiteViewing(pairIndex: number): boolean { + const ply = this.viewingPly ?? this.moves.length - 1; + return ply === pairIndex * 2; + } + + isBlackViewing(pairIndex: number): boolean { + const ply = this.viewingPly ?? this.moves.length - 1; + return ply === pairIndex * 2 + 1; + } + + clickWhite(pairIndex: number): void { + this.navigateToPly.emit(pairIndex * 2); + } + + clickBlack(pairIndex: number, black: string | null): void { + if (!black) return; + this.navigateToPly.emit(pairIndex * 2 + 1); + } + private buildPairs(moves: string[]): MovePair[] { const pairs: MovePair[] = []; for (let i = 0; i < moves.length; i += 2) { diff --git a/src/app/components/register-dialog/register-dialog.component.css b/src/app/components/register-dialog/register-dialog.component.css index acea1e8..dbc3953 100644 --- a/src/app/components/register-dialog/register-dialog.component.css +++ b/src/app/components/register-dialog/register-dialog.component.css @@ -1,58 +1,298 @@ -@import '../../button-template.css'; +:host { + --auth-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --auth-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace; + --auth-neon: #ff45c8; + --auth-neon-soft: rgba(255, 69, 200, 0.55); + --auth-bg: #06060d; +} .dialog-overlay { position: fixed; inset: 0; - background: rgba(2, 2, 10, 0.58); + background: rgba(4, 2, 12, 0.72); + backdrop-filter: blur(6px); display: grid; place-items: center; z-index: 350; padding: 1rem; + animation: backdrop-in 180ms ease-out both; } .dialog-card { - width: min(460px, 100%); - background: var(--dlg-bg); - border: 1.5px solid var(--dlg-border); - box-shadow: var(--bb-glow); - border-radius: 4px; - padding: 1rem; + position: relative; + width: min(440px, 100%); + background: + radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.10), transparent 60%), + linear-gradient(180deg, #0a0612 0%, #06060d 100%); + border: 1px solid var(--auth-neon-soft); + border-radius: 14px; + padding: 28px 26px 22px; display: grid; - gap: 0.7rem; + gap: 14px; + font-family: var(--auth-sans); + color: #fff; + box-shadow: + 0 0 0 1px rgba(255, 69, 200, 0.06) inset, + 0 0 30px rgba(255, 69, 200, 0.18), + 0 30px 60px rgba(0, 0, 0, 0.55); + overflow: hidden; + animation: dialog-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +.dialog-card::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient( + 180deg, + transparent 0%, + rgba(255, 69, 200, 0.06) 50%, + transparent 100% + ); + height: 35%; + animation: scanline 4.5s linear infinite; + mix-blend-mode: screen; +} + +.dialog-card::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.012) 0px, + rgba(255, 255, 255, 0.012) 1px, + transparent 1px, + transparent 3px + ); + border-radius: inherit; +} + +.dialog-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + position: relative; + z-index: 1; +} + +.brand-tag { + font-family: var(--auth-mono); + font-size: 10px; + letter-spacing: 2.5px; + color: var(--auth-neon); + text-transform: uppercase; + opacity: 0.85; + animation: pulse-glow 2.4s ease-in-out infinite; +} + +.brand-tag::before { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--auth-neon); + margin-right: 8px; + vertical-align: 1px; + box-shadow: 0 0 8px var(--auth-neon); +} + +.close-btn { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.65); + width: 28px; + height: 28px; + border-radius: 8px; + cursor: pointer; + font-size: 16px; + line-height: 1; + display: grid; + place-items: center; + transition: all 0.18s ease; +} + +.close-btn:hover { + color: var(--auth-neon); + border-color: var(--auth-neon-soft); + box-shadow: 0 0 10px rgba(255, 69, 200, 0.35); } .dialog-title { - font-family: 'Bebas Neue', sans-serif; - font-size: 22px; - letter-spacing: 2px; - color: var(--bb-title); - text-align: center; + font-family: var(--auth-sans); + font-weight: 700; + font-size: 26px; + letter-spacing: 0.5px; + color: #fff; + margin: 4px 0 0; + position: relative; + z-index: 1; +} + +.dialog-subtitle { + font-family: var(--auth-mono); + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + letter-spacing: 0.6px; + margin-bottom: 6px; + position: relative; + z-index: 1; +} + +form { + display: grid; + gap: 12px; + position: relative; + z-index: 1; +} + +.field { + display: grid; + gap: 6px; +} + +.field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.field-label { + font-family: var(--auth-mono); + font-size: 10px; + letter-spacing: 1.5px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.55); } .dialog-input { width: 100%; - background: rgba(4, 4, 20, 0.62); - border: 1px solid var(--bb-border); - color: var(--bb-title); - border-radius: 2px; - padding: 0.6rem 0.7rem; - font-family: 'Space Mono', monospace; + background: rgba(8, 5, 20, 0.6); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #fff; + border-radius: 10px; + padding: 12px 14px; + font-family: var(--auth-mono); font-size: 13px; + letter-spacing: 0.3px; + transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; +} + +.dialog-input::placeholder { + color: rgba(255, 255, 255, 0.28); } .dialog-input:focus { outline: none; - box-shadow: 0 0 0 2px rgba(0, 213, 255, 0.25); -} - -.dialog-textarea { - min-height: 120px; - resize: vertical; + border-color: var(--auth-neon); + background: rgba(20, 6, 26, 0.7); + box-shadow: 0 0 0 3px rgba(255, 69, 200, 0.15), 0 0 18px rgba(255, 69, 200, 0.18); } .text-danger { - color: #dc3545; - font-size: 0.875rem; + color: #ff6ea0; + font-family: var(--auth-mono); + font-size: 11px; + letter-spacing: 0.3px; +} + +.error-banner { + font-family: var(--auth-mono); + font-size: 12px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(255, 69, 110, 0.08); + border: 1px solid rgba(255, 69, 110, 0.35); + color: #ff9bb4; + animation: shake 0.35s ease-out; +} + +.dialog-actions { + display: flex; + gap: 10px; + margin-top: 6px; + position: relative; + z-index: 1; +} + +.btn { + flex: 1; + border-radius: 10px; + padding: 12px 14px; + font-family: var(--auth-sans); + font-weight: 600; + font-size: 13px; + letter-spacing: 0.4px; + cursor: pointer; + transition: all 0.18s ease; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.btn-ghost { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); +} + +.btn-ghost:hover:not(:disabled) { + color: #fff; + border-color: rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.03); +} + +.btn-primary { + background: var(--auth-neon); + border: 1px solid var(--auth-neon); + color: #1a0210; + box-shadow: 0 0 18px rgba(255, 69, 200, 0.45); +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 0 26px rgba(255, 69, 200, 0.7); +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; +} + +.alt-line { + margin-top: 4px; + text-align: center; + font-family: var(--auth-mono); + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + position: relative; + z-index: 1; +} + +.alt-line a { + color: var(--auth-neon); + cursor: pointer; + text-decoration: none; + margin-left: 4px; +} + +.alt-line a:hover { + text-shadow: 0 0 8px var(--auth-neon-soft); +} + +.spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(26, 2, 16, 0.35); + border-top-color: #1a0210; + border-radius: 50%; + animation: spin 0.7s linear infinite; } .sr-only { @@ -66,3 +306,28 @@ white-space: nowrap !important; border: 0 !important; } + +@keyframes scanline { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(300%); } +} +@keyframes pulse-glow { + 0%, 100% { opacity: 0.85; } + 50% { opacity: 1; } +} +@keyframes dialog-in { + from { opacity: 0; transform: translateY(8px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +@keyframes backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20%, 60% { transform: translateX(-4px); } + 40%, 80% { transform: translateX(4px); } +} +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/src/app/components/register-dialog/register-dialog.component.html b/src/app/components/register-dialog/register-dialog.component.html index 4c75996..123ad58 100644 --- a/src/app/components/register-dialog/register-dialog.component.html +++ b/src/app/components/register-dialog/register-dialog.component.html @@ -1,40 +1,65 @@
-
CREATE ACCOUNT
+
+ NowChess // Register + +
+ +

Create account

+
Join the board and start playing
- - @if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) { - Username must be at least 3 characters - } +
+ + + @if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) { + Username must be at least 3 characters + } +
- - @if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) { - Please enter a valid email - } +
+ + + @if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) { + Please enter a valid email + } +
- - @if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) { - Password must be at least 6 characters - } - - +
+
+ + + @if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) { + Min 6 characters + } +
+
+ + +
+
@if (errorMessage) { -
{{ errorMessage }}
+
{{ errorMessage }}
}
- - - +
+ +
+ Already have an account?Sign in +
-
\ No newline at end of file +
diff --git a/src/app/components/toolbar/toolbar.component.html b/src/app/components/toolbar/toolbar.component.html index 6bcfdfe..8522bd5 100644 --- a/src/app/components/toolbar/toolbar.component.html +++ b/src/app/components/toolbar/toolbar.component.html @@ -17,7 +17,8 @@ Watch - + +
} diff --git a/src/app/components/toolbar/toolbar.component.ts b/src/app/components/toolbar/toolbar.component.ts index 515aad1..2aa57a8 100644 --- a/src/app/components/toolbar/toolbar.component.ts +++ b/src/app/components/toolbar/toolbar.component.ts @@ -31,6 +31,7 @@ export class ToolbarComponent implements OnInit { private readonly router = inject(Router); private pollHandle: ReturnType | null = null; + private readonly navigatedChallengeIds = new Set(); currentUser: CurrentUser | null = null; showLoginDialog = false; @@ -55,6 +56,7 @@ export class ToolbarComponent implements OnInit { } else { this.challengeWs.disconnect(); this.stopPolling(); + this.navigatedChallengeIds.clear(); this.challengeEventService.clear(); } }); @@ -76,8 +78,8 @@ export class ToolbarComponent implements OnInit { } private startPolling(): void { - this.fetchIncoming(); - this.pollHandle = setInterval(() => this.fetchIncoming(), 5000); + this.fetchChallenges(); + this.pollHandle = setInterval(() => this.fetchChallenges(), 10_000); } private stopPolling(): void { @@ -87,11 +89,21 @@ export class ToolbarComponent implements OnInit { } } - private fetchIncoming(): void { + private fetchChallenges(): void { this.challengeService.listChallenges().subscribe({ next: response => { const incoming = response.in ?? response.incoming ?? []; this.challengeEventService.setIncomingChallenges(incoming); + + const outgoing = response.out ?? response.outgoing ?? []; + for (const c of outgoing) { + if (c.status === 'accepted' && c.gameId && !this.navigatedChallengeIds.has(c.id)) { + this.navigatedChallengeIds.add(c.id); + if (!this.router.url.includes(`/game/${c.gameId}`)) { + void this.router.navigate(['/game', c.gameId]); + } + } + } } }); } @@ -167,12 +179,24 @@ export class ToolbarComponent implements OnInit { void this.router.navigate(['/games']); } + goToTournaments(): void { + this.profileOpen = false; + this.notifOpen = false; + void this.router.navigate(['/tournaments']); + } + goToChallenges(): void { this.profileOpen = false; this.notifOpen = false; void this.router.navigate(['/challenges']); } + goToBots(): void { + this.profileOpen = false; + this.notifOpen = false; + void this.router.navigate(['/bots']); + } + onLoginSuccess(): void { this.closeLoginDialog(); } diff --git a/src/app/core/config.loader.ts b/src/app/core/config.loader.ts index 2d10fd5..9361f5b 100644 --- a/src/app/core/config.loader.ts +++ b/src/app/core/config.loader.ts @@ -4,8 +4,10 @@ */ export function loadRuntimeConfig() { const config = (window as any).__RUNTIME_CONFIG__ || {}; + const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const derivedWsUrl = `${wsProtocol}://${window.location.host}`; return { apiUrl: config.API_URL || '', - wsUrl: config.WEBSOCKET_URL || 'ws://localhost:8080' + wsUrl: config.WEBSOCKET_URL || derivedWsUrl }; } diff --git a/src/app/models/auth.models.ts b/src/app/models/auth.models.ts index 0af910d..9d9af03 100644 --- a/src/app/models/auth.models.ts +++ b/src/app/models/auth.models.ts @@ -17,7 +17,8 @@ export interface RegisterResponse { } export interface LoginResponse { - token: string; + accessToken: string; + refreshToken: string; } export interface CurrentUser { diff --git a/src/app/models/bot.models.ts b/src/app/models/bot.models.ts new file mode 100644 index 0000000..7385545 --- /dev/null +++ b/src/app/models/bot.models.ts @@ -0,0 +1,10 @@ +export interface Bot { + id: string; + name: string; + rating: number; + createdAt: string; +} + +export interface BotWithToken extends Bot { + token: string; +} diff --git a/src/app/models/tournament.models.ts b/src/app/models/tournament.models.ts new file mode 100644 index 0000000..7dc854c --- /dev/null +++ b/src/app/models/tournament.models.ts @@ -0,0 +1,66 @@ +export interface TournamentClock { + limit: number; + increment: number; +} + +export interface TournamentVariant { + key: string; + name: string; +} + +export interface TournamentBotRef { + id: string; + name: string; +} + +export interface TournamentResult { + rank: number; + points: number; + tieBreak: number; + bot: TournamentBotRef; + nbGames: number; + wins: number; + draws: number; + losses: number; +} + +export interface TournamentStanding { + page: number; + players: TournamentResult[]; +} + +export interface Tournament { + id: string; + fullName: string; + clock: TournamentClock; + variant: TournamentVariant; + rated: boolean; + nbPlayers: number; + nbRounds: number; + createdBy: string; + startsAt: string | null; + status: 'created' | 'started' | 'finished'; + round: number; + standing: TournamentStanding; + winner: TournamentBotRef | null; +} + +export interface TournamentList { + created: Tournament[]; + started: Tournament[]; + finished: Tournament[]; +} + +export interface TournamentPairing { + id: string; + round: number; + white: TournamentBotRef | null; + black: TournamentBotRef; + gameId: string | null; + winner: 'white' | 'black' | 'draw' | null; +} + +export interface RoundPairings { + round: number; + pairings: TournamentPairing[]; +} diff --git a/src/app/pages/bots/bots.component.css b/src/app/pages/bots/bots.component.css new file mode 100644 index 0000000..5fc7a1a --- /dev/null +++ b/src/app/pages/bots/bots.component.css @@ -0,0 +1,163 @@ +:host { + --nc-neon: #ff45c8; + --nc-bg: #06060d; + --nc-surface: rgba(20, 17, 42, 0.6); + --nc-text: #fff; + --nc-text-muted: rgba(255, 255, 255, 0.65); + --nc-text-dim: rgba(255, 255, 255, 0.45); + --nc-border: rgba(255, 255, 255, 0.08); + --nc-border-strong: rgba(255, 255, 255, 0.15); + --nc-success: #5ee5a1; + --nc-danger: #ff7a7a; + --nc-warn: #ffd166; + --nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + + display: block; + min-height: 100vh; + background: var(--nc-bg); + font-family: var(--nc-sans); + color: var(--nc-text); +} + +:host-context(html:not([data-theme='dark'])) { + --nc-neon: #c026d3; + --nc-bg: #f5f0fc; + --nc-surface: rgba(255, 255, 255, 0.88); + --nc-text: #0f0022; + --nc-text-muted: rgba(15, 0, 34, 0.65); + --nc-text-dim: rgba(15, 0, 34, 0.4); + --nc-border: rgba(15, 0, 34, 0.1); + --nc-border-strong: rgba(15, 0, 34, 0.2); + --nc-success: #16a34a; + --nc-danger: #dc2626; + --nc-warn: #b45309; +} + +.b-shell { padding-top: 72px; min-height: 100vh; } +.page { max-width: 680px; margin: 0 auto; padding: 32px 20px 64px; } + +.crumb { display: flex; align-items: center; gap: 8px; margin-bottom: 28px; + font-size: 11px; color: var(--nc-text-dim); letter-spacing: 0.06em; } +.crumb-link { display: inline-flex; align-items: center; gap: 4px; + color: var(--nc-text-dim); text-decoration: none; transition: color 0.15s; } +.crumb-link:hover { color: var(--nc-neon); } +.crumb-sep { opacity: 0.35; } +.crumb-current { color: var(--nc-text-muted); } + +.page-header { margin-bottom: 24px; } +.title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; } +.page-sub { font-size: 13px; color: var(--nc-text-muted); margin: 0; line-height: 1.5; } + +.btn-new { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 14px; border-radius: 8px; border: none; + background: var(--nc-neon); color: #fff; + font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity 0.15s; +} +.btn-new:hover { opacity: 0.85; } + +/* Create panel */ +.create-panel { + border: 1px solid var(--nc-border-strong); border-radius: 12px; + background: var(--nc-surface); padding: 16px; margin-bottom: 20px; +} +.create-inner { display: flex; flex-direction: column; gap: 10px; } +.field-label { font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--nc-text-muted); } +.create-row { display: flex; gap: 8px; align-items: center; } +.text-input { + flex: 1; padding: 8px 12px; border-radius: 8px; + border: 1px solid var(--nc-border-strong); + background: rgba(255,255,255,0.04); color: var(--nc-text); font-size: 14px; +} +.text-input:focus { outline: 2px solid var(--nc-neon); outline-offset: 1px; border-color: transparent; } +.text-input:disabled { opacity: 0.5; } +.error-text { font-size: 12px; color: var(--nc-danger); margin: 0; } + +/* Buttons */ +.btn-primary { + padding: 8px 16px; border-radius: 8px; border: none; + background: var(--nc-neon); color: #fff; font-size: 13px; font-weight: 600; + cursor: pointer; white-space: nowrap; transition: opacity 0.15s; +} +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-ghost { + padding: 8px 14px; border-radius: 8px; border: 1px solid var(--nc-border-strong); + background: transparent; color: var(--nc-text-muted); font-size: 13px; cursor: pointer; +} +.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; } + +/* States */ +.state-msg { display: flex; align-items: center; gap: 10px; + padding: 24px 0; color: var(--nc-text-muted); font-size: 13px; } +.pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--nc-neon); + flex-shrink: 0; animation: pulse 1.4s ease-in-out infinite; } +@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } } + +.empty-state { display: flex; flex-direction: column; align-items: center; + gap: 8px; padding: 64px 0; text-align: center; } +.empty-icon { color: var(--nc-text-dim); margin-bottom: 4px; } +.empty-title { font-size: 15px; font-weight: 600; margin: 0; } +.empty-sub { font-size: 13px; color: var(--nc-text-muted); margin: 0; } + +/* Bot list */ +.bot-list { display: flex; flex-direction: column; gap: 8px; } +.bot-card { + border: 1px solid var(--nc-border); border-radius: 12px; + background: var(--nc-surface); overflow: hidden; +} +.bot-main { + display: flex; align-items: center; gap: 12px; + padding: 14px 16px; +} +.bot-avatar { + width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0; + background: var(--nc-neon); color: #fff; + display: flex; align-items: center; justify-content: center; + font-size: 16px; font-weight: 700; +} +.bot-info { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; } +.bot-name { font-size: 14px; font-weight: 600; } +.bot-meta { font-size: 11px; color: var(--nc-text-muted); } +.bot-actions { display: flex; gap: 8px; flex-shrink: 0; } + +.btn-token { + display: inline-flex; align-items: center; gap: 5px; + padding: 6px 12px; border-radius: 7px; border: 1px solid var(--nc-border-strong); + background: transparent; color: var(--nc-text-muted); font-size: 12px; cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.btn-token:hover, .btn-token.active { background: rgba(255,69,200,0.1); color: var(--nc-neon); border-color: var(--nc-neon); } +.btn-token:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-danger { + padding: 6px 12px; border-radius: 7px; border: 1px solid rgba(255,122,122,0.3); + background: transparent; color: var(--nc-danger); font-size: 12px; cursor: pointer; + transition: background 0.15s; +} +.btn-danger:hover { background: rgba(255,122,122,0.1); } +.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; } + +/* Token panel */ +.token-panel { + border-top: 1px solid var(--nc-border); padding: 12px 16px; + display: flex; flex-direction: column; gap: 10px; +} +.token-warning { + display: flex; align-items: flex-start; gap: 8px; + font-size: 12px; color: var(--nc-warn); +} +.token-row { display: flex; align-items: center; gap: 8px; } +.token-value { + flex: 1; font-family: monospace; font-size: 11px; + background: rgba(0,0,0,0.2); border-radius: 6px; + padding: 8px 10px; word-break: break-all; + color: var(--nc-text-muted); border: 1px solid var(--nc-border); +} +.btn-copy { + padding: 6px 12px; border-radius: 7px; border: 1px solid var(--nc-border-strong); + background: transparent; color: var(--nc-text-muted); font-size: 12px; + cursor: pointer; white-space: nowrap; transition: color 0.15s; + flex-shrink: 0; +} +.btn-copy:hover { color: var(--nc-success); } diff --git a/src/app/pages/bots/bots.component.html b/src/app/pages/bots/bots.component.html new file mode 100644 index 0000000..2cee025 --- /dev/null +++ b/src/app/pages/bots/bots.component.html @@ -0,0 +1,125 @@ +
+
+ + + + + + @if (showCreate) { +
+
+ +
+ + + +
+ @if (createError) { +

{{ createError }}

+ } +
+
+ } + + @if (loading) { +
Loading bots…
+ } @else if (bots.length === 0) { +
+ + + + +

No bots yet

+

Create a bot to join tournaments and play automated games.

+
+ } @else { +
+ @for (bot of bots; track bot.id) { +
+
+
{{ bot.name.charAt(0).toUpperCase() }}
+
+ {{ bot.name }} + Rating {{ bot.rating }} · Created {{ bot.createdAt | date:'MMM d, yyyy' }} +
+
+ + +
+
+ + @if (revealedTokens[bot.id]) { +
+
+ + + + + Token was just regenerated — the old one is now invalid. Keep this secret. +
+
+ {{ revealedTokens[bot.id] }} + +
+
+ } +
+ } +
+ } + +
+
diff --git a/src/app/pages/bots/bots.component.ts b/src/app/pages/bots/bots.component.ts new file mode 100644 index 0000000..a4915fe --- /dev/null +++ b/src/app/pages/bots/bots.component.ts @@ -0,0 +1,111 @@ +import { Component, DestroyRef, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { BotService } from '../../services/bot.service'; +import { Bot, BotWithToken } from '../../models/bot.models'; + +@Component({ + selector: 'app-bots', + standalone: true, + imports: [CommonModule, RouterLink, FormsModule], + templateUrl: './bots.component.html', + styleUrl: './bots.component.css' +}) +export class BotsComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly botService = inject(BotService); + + bots: Bot[] = []; + loading = true; + + showCreate = false; + newBotName = ''; + creating = false; + createError: string | null = null; + + revealedTokens: Record = {}; + revealingId: string | null = null; + copiedId: string | null = null; + deletingId: string | null = null; + + ngOnInit(): void { + this.loadBots(); + } + + loadBots(): void { + this.loading = true; + this.botService.list() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: bots => { this.bots = bots; this.loading = false; }, + error: () => { this.loading = false; } + }); + } + + openCreate(): void { + this.newBotName = ''; + this.createError = null; + this.showCreate = true; + } + + cancelCreate(): void { + this.showCreate = false; + } + + submitCreate(): void { + const name = this.newBotName.trim(); + if (!name) return; + this.creating = true; + this.createError = null; + this.botService.create(name).subscribe({ + next: (bot: BotWithToken) => { + this.creating = false; + this.showCreate = false; + this.bots = [bot, ...this.bots]; + this.revealedTokens[bot.id] = bot.token; + }, + error: err => { + this.creating = false; + this.createError = err.error?.message ?? err.error?.error ?? 'Failed to create bot.'; + } + }); + } + + revealToken(botId: string): void { + if (this.revealedTokens[botId]) { + delete this.revealedTokens[botId]; + return; + } + this.revealingId = botId; + this.botService.rotateToken(botId).subscribe({ + next: token => { + this.revealingId = null; + this.revealedTokens[botId] = token; + }, + error: () => { this.revealingId = null; } + }); + } + + copyToken(botId: string): void { + const token = this.revealedTokens[botId]; + if (!token) return; + navigator.clipboard.writeText(token).then(() => { + this.copiedId = botId; + setTimeout(() => { this.copiedId = null; }, 2000); + }); + } + + deleteBot(botId: string): void { + this.deletingId = botId; + this.botService.delete(botId).subscribe({ + next: () => { + this.deletingId = null; + this.bots = this.bots.filter(b => b.id !== botId); + delete this.revealedTokens[botId]; + }, + error: () => { this.deletingId = null; } + }); + } +} diff --git a/src/app/pages/game/game.component.css b/src/app/pages/game/game.component.css index 68642a8..3885907 100644 --- a/src/app/pages/game/game.component.css +++ b/src/app/pages/game/game.component.css @@ -28,30 +28,30 @@ } /* ============================================================ - LIGHT MODE TOKEN OVERRIDES + LIGHT MODE TOKEN OVERRIDES (sunset-gradient palette) ============================================================ */ :host-context(html:not([data-theme='dark'])) { - --nc-neon: #c026d3; - --nc-neon-soft: rgba(192, 38, 211, 0.45); - --nc-neon-clock-bg: rgba(192, 38, 211, 0.07); - --nc-bg: #f5f0fc; - --nc-surface: rgba(255, 255, 255, 0.88); - --nc-surface-solid: rgba(255, 255, 255, 0.98); - --nc-text: #0f0022; - --nc-text-muted: rgba(15, 0, 34, 0.65); - --nc-text-dim: rgba(15, 0, 34, 0.40); - --nc-border: rgba(15, 0, 34, 0.10); - --nc-border-strong: rgba(15, 0, 34, 0.20); - --nc-warning: #d97706; - --nc-warning-soft: rgba(217, 119, 6, 0.35); - --nc-danger: #dc2626; - --nc-danger-soft: rgba(220, 38, 38, 0.25); - --nc-danger-bg: rgba(220, 38, 38, 0.06); - --nc-success: #059669; - --nc-clock-bg: rgba(0, 0, 0, 0.04); - --nc-btn-bg: rgba(0, 0, 0, 0.03); - --nc-btn-hover-bg: rgba(0, 0, 0, 0.06); - --nc-seg-bg: rgba(0, 0, 0, 0.06); + --nc-neon: #ff3dbb; + --nc-neon-soft: rgba(255, 61, 187, 0.55); + --nc-neon-clock-bg: rgba(255, 61, 187, 0.08); + --nc-bg: transparent; + --nc-surface: rgba(26, 24, 56, 0.72); + --nc-surface-solid: rgba(26, 24, 56, 0.97); + --nc-text: #fff; + --nc-text-muted: rgba(255, 255, 255, 0.72); + --nc-text-dim: rgba(255, 255, 255, 0.45); + --nc-border: rgba(255, 255, 255, 0.10); + --nc-border-strong: rgba(255, 255, 255, 0.18); + --nc-warning: #ffb13a; + --nc-warning-soft: rgba(255, 177, 58, 0.40); + --nc-danger: #ff7a7a; + --nc-danger-soft: rgba(255, 122, 122, 0.30); + --nc-danger-bg: rgba(255, 122, 122, 0.08); + --nc-success: #5ee5a1; + --nc-clock-bg: rgba(0, 0, 0, 0.30); + --nc-btn-bg: rgba(255, 255, 255, 0.05); + --nc-btn-hover-bg: rgba(255, 255, 255, 0.10); + --nc-seg-bg: rgba(0, 0, 0, 0.28); } /* ============================================================ @@ -78,8 +78,8 @@ :host-context(html:not([data-theme='dark'])) .game-shell::before { background: - radial-gradient(ellipse 80% 50% at 20% 100%, rgba(192, 38, 211, 0.06), transparent 60%), - radial-gradient(ellipse 60% 40% at 90% 0%, rgba(120, 40, 180, 0.08), transparent 60%); + radial-gradient(ellipse 80% 50% at 20% 100%, rgba(212, 77, 74, 0.10), transparent 60%), + radial-gradient(ellipse 60% 40% at 90% 0%, rgba(74, 41, 98, 0.22), transparent 60%); } /* ============================================================ @@ -290,12 +290,53 @@ animation: slideIn 0.35s ease-out; } +.completion-banner--timeout { + background: rgba(255, 177, 58, 0.06); + border-color: var(--nc-warning-soft); +} + +.completion-banner--timeout .completion-title { + color: var(--nc-warning); +} + +.completion-left { + display: flex; + align-items: center; + gap: 14px; +} + +.completion-icon { + font-size: 22px; + opacity: 0.7; +} + +.completion-actions { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.completion-new { + font-size: 11px !important; + padding: 8px 14px !important; + text-decoration: none; +} + @keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } -.completion-title { font-size: 16px; font-weight: 600; color: var(--nc-neon); } +.completion-title { font-size: 15px; font-weight: 700; color: var(--nc-neon); } + +.completion-sub { + font-family: var(--nc-mono); + font-size: 10px; + color: var(--nc-text-dim); + letter-spacing: 0.08em; + margin-top: 2px; +} .completion-link { font-family: var(--nc-mono); @@ -345,8 +386,8 @@ } :host-context(html:not([data-theme='dark'])) .status-strip { - background: rgba(192, 38, 211, 0.04); - border-color: rgba(192, 38, 211, 0.18); + background: rgba(255, 61, 187, 0.06); + border-color: rgba(255, 61, 187, 0.20); } .status-left { display: inline-flex; align-items: center; gap: 10px; } @@ -388,7 +429,12 @@ } :host-context(html:not([data-theme='dark'])) .board-wrap { - box-shadow: 0 8px 40px rgba(0, 0, 0, 0.1); + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 61, 187, 0.08); +} + +.board-wrap.reviewing { + border-color: var(--nc-warning-soft); + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 177, 58, 0.18); } /* ============================================================ @@ -490,6 +536,60 @@ .seg-btn.active { background: var(--nc-neon); color: #fff; font-weight: 700; } +/* ============================================================ + RESIGN CONFIRM OVERLAY +============================================================ */ +.confirm-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 600; +} + +.confirm-box { + background: var(--nc-surface-solid); + border: 1px solid var(--nc-danger-soft); + padding: 28px 32px; + min-width: 300px; + display: flex; + flex-direction: column; + gap: 8px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.confirm-title { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--nc-text); +} + +.confirm-sub { + margin: 0 0 12px; + font-size: 13px; + color: var(--nc-text-muted); +} + +.confirm-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.btn-danger-solid { + background: var(--nc-danger) !important; + color: #fff !important; + border-color: var(--nc-danger) !important; + font-weight: 700; +} + +.btn-danger-solid:hover { opacity: 0.88; } + /* ============================================================ TOAST ============================================================ */ diff --git a/src/app/pages/game/game.component.html b/src/app/pages/game/game.component.html index 07c4593..f8587ac 100644 --- a/src/app/pages/game/game.component.html +++ b/src/app/pages/game/game.component.html @@ -72,8 +72,24 @@ @if (facade.isGameFinished && facade.gameCompletionMessage) {
- {{ facade.gameCompletionMessage }} - Start new game +
+ +
+
{{ facade.gameCompletionMessage }}
+
Game #{{ facade.gameId }}
+
+
+ +
+ } + + @if (!facade.isGameFinished && ((whiteTimerMs !== null && whiteTimerMs <= 0) || (blackTimerMs !== null && blackTimerMs <= 0))) { +
+ Time's up! + Waiting for server to confirm result…
} @@ -104,11 +120,11 @@ -
+
@@ -146,7 +162,9 @@ + [viewingPly]="facade.viewingPly" + (navigate)="facade.navigateHistory($event)" + (navigateToPly)="facade.navigateToPly($event)" /> @@ -200,6 +218,20 @@
+ +@if (facade.resignConfirmPending) { + +} + @if (toastMessage) {
{{ toastMessage }}
diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts index 18b68c4..7bfaf36 100644 --- a/src/app/pages/game/game.component.ts +++ b/src/app/pages/game/game.component.ts @@ -4,7 +4,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router'; import { BoardActionsBarComponent } from '../../components/board-actions-bar/board-actions-bar.component'; import { ChessBoardComponent } from '../../components/chess-board/chess-board.component'; import { ExportPanelComponent } from '../../components/export-panel/export-panel.component'; -import { MoveHistoryComponent, MoveNavDirection } from '../../components/move-history/move-history.component'; +import { MoveHistoryComponent } from '../../components/move-history/move-history.component'; import { PlayerCardComponent } from '../../components/player-card/player-card.component'; import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component'; import { GameFacade } from './game.facade'; @@ -158,12 +158,7 @@ export class GameComponent implements OnInit, OnDestroy { } onResign(): void { - this.showToast('Resigned'); - } - - // ── Move history navigation ─────────────────────────────────── - onMoveNavigate(_direction: MoveNavDirection): void { - // Visual-only for now; board always reflects live position. + this.facade.requestResign(); } // ── Timer helpers ───────────────────────────────────────────── @@ -199,6 +194,11 @@ export class GameComponent implements OnInit, OnDestroy { this.blackTimerMs = clock.blackRemainingMs < 0 ? -1 : Math.max(0, clock.blackRemainingMs - (!activeIsWhite ? elapsed : 0)); + + if ((this.whiteTimerMs !== null && this.whiteTimerMs <= 0 && clock.whiteRemainingMs > 0) || + (this.blackTimerMs !== null && this.blackTimerMs <= 0 && clock.blackRemainingMs > 0)) { + this.facade.errorMessage = ''; + } } private showToast(msg: string): void { diff --git a/src/app/pages/game/game.facade.ts b/src/app/pages/game/game.facade.ts index ae5d4b0..390e735 100644 --- a/src/app/pages/game/game.facade.ts +++ b/src/app/pages/game/game.facade.ts @@ -23,6 +23,11 @@ export class GameFacade implements OnDestroy { gameCompletionMessage = ''; isGameFinished = false; isPromotionDialogOpen = false; + resignConfirmPending = false; + + private fenHistory: string[] = []; + private sessionStartPly = 0; + viewingPly: number | null = null; private boardSelection: BoardSelection = { selectedSquare: null, @@ -52,6 +57,46 @@ export class GameFacade implements OnDestroy { return this.boardSelection.highlightedSquares; } + get displayFen(): string { + if (this.viewingPly !== null) { + const historyIndex = this.viewingPly - this.sessionStartPly; + return this.fenHistory[historyIndex] ?? this.game?.state.fen ?? ''; + } + return this.game?.state.fen ?? ''; + } + + get isReviewing(): boolean { + return this.viewingPly !== null; + } + + navigateToPly(ply: number): void { + const historyIndex = ply - this.sessionStartPly; + if (historyIndex < 0 || historyIndex >= this.fenHistory.length) return; + this.viewingPly = ply; + this.boardSelection = this.boardSelectionService.clearSelection(); + } + + navigateHistory(direction: 'first' | 'prev' | 'next' | 'last'): void { + const totalPly = this.sessionStartPly + this.fenHistory.length - 1; + const current = this.viewingPly ?? totalPly; + + let next: number; + switch (direction) { + case 'first': next = this.sessionStartPly; break; + case 'prev': next = Math.max(this.sessionStartPly, current - 1); break; + case 'next': next = Math.min(totalPly, current + 1); break; + case 'last': + default: next = totalPly; break; + } + + if (next === totalPly) { + this.viewingPly = null; + } else { + this.viewingPly = next; + } + this.boardSelection = this.boardSelectionService.clearSelection(); + } + ngOnDestroy(): void { this.streamService.cleanup(); this.botMoveService.cleanup(); @@ -63,7 +108,7 @@ export class GameFacade implements OnDestroy { } onBoardSquareSelected(square: string): void { - if (!this.state) { + if (!this.state || this.viewingPly !== null) { return; } @@ -123,6 +168,8 @@ export class GameFacade implements OnDestroy { if (this.game) { this.game = { ...this.game, state }; this.clockSyncedAt = Date.now(); + this.pushFen(state.fen); + this.viewingPly = null; this.updateGameCompletion(); } this.moveInput = ''; @@ -171,6 +218,26 @@ export class GameFacade implements OnDestroy { this.pendingPromotionMoves = []; } + requestResign(): void { + this.resignConfirmPending = true; + } + + cancelResign(): void { + this.resignConfirmPending = false; + } + + confirmResign(): void { + this.resignConfirmPending = false; + this.gameApi + .resignGame(this.gameId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Could not resign.'); + } + }); + } + importFen(): void { this.errorMessage = ''; this.importService.importFen( @@ -204,6 +271,8 @@ export class GameFacade implements OnDestroy { this.errorMessage = ''; this.boardSelection = this.boardSelectionService.clearSelection(); this.streamService.cleanup(); + this.fenHistory = []; + this.viewingPly = null; this.gameApi .getGame(this.gameId) @@ -213,6 +282,8 @@ export class GameFacade implements OnDestroy { this.game = game; this.clockSyncedAt = Date.now(); this.loading = false; + this.sessionStartPly = game.state.moves.length; + this.fenHistory = [game.state.fen]; this.updateGameCompletion(); this.gameHistory.recordGame(this.gameId); this.startStreaming(); @@ -237,7 +308,10 @@ export class GameFacade implements OnDestroy { if (event.type === 'gameFull') { this.game = event.game; this.clockSyncedAt = Date.now(); - this.boardSelection = this.boardSelectionService.clearSelection(); + this.pushFen(event.game.state.fen); + if (this.viewingPly === null) { + this.boardSelection = this.boardSelectionService.clearSelection(); + } this.updateGameCompletion(); this.tryMakeBotMove(); return; @@ -247,8 +321,9 @@ export class GameFacade implements OnDestroy { const moveCountBefore = this.game.state.moves.length; this.game = { ...this.game, state: event.state }; this.clockSyncedAt = Date.now(); + this.pushFen(event.state.fen); this.updateGameCompletion(); - if (event.state.moves.length !== moveCountBefore) { + if (event.state.moves.length !== moveCountBefore && this.viewingPly === null) { this.boardSelection = this.boardSelectionService.clearSelection(); this.tryMakeBotMove(); } @@ -260,6 +335,13 @@ export class GameFacade implements OnDestroy { } } + private pushFen(fen: string): void { + const last = this.fenHistory[this.fenHistory.length - 1]; + if (last !== fen) { + this.fenHistory.push(fen); + } + } + private tryMakeBotMove(): void { this.botMoveService.tryMakeBotMove( this.gameId, diff --git a/src/app/pages/games/games.component.ts b/src/app/pages/games/games.component.ts index 9a78042..dbbfb0f 100644 --- a/src/app/pages/games/games.component.ts +++ b/src/app/pages/games/games.component.ts @@ -85,7 +85,14 @@ export class GamesComponent implements OnInit { } const requests = ids.map((id) => - this.gameApi.getGame(id).pipe(catchError(() => of(null))) + this.gameApi.getGame(id).pipe( + catchError((err: unknown) => { + if (typeof err === 'object' && err !== null && (err as { status: number }).status === 404) { + this.gameHistory.removeGame(id); + } + return of(null); + }) + ) ); forkJoin(requests) diff --git a/src/app/pages/tournaments/tournaments.component.css b/src/app/pages/tournaments/tournaments.component.css new file mode 100644 index 0000000..6e40a72 --- /dev/null +++ b/src/app/pages/tournaments/tournaments.component.css @@ -0,0 +1,329 @@ +:host { + --nc-neon: #ff45c8; + --nc-bg: #06060d; + --nc-surface: rgba(20, 17, 42, 0.6); + --nc-text: #fff; + --nc-text-muted: rgba(255, 255, 255, 0.65); + --nc-text-dim: rgba(255, 255, 255, 0.45); + --nc-border: rgba(255, 255, 255, 0.08); + --nc-border-strong: rgba(255, 255, 255, 0.15); + --nc-success: #5ee5a1; + --nc-danger: #ff7a7a; + --nc-warn: #ffd166; + --nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + + display: block; + min-height: 100vh; + background: var(--nc-bg); + font-family: var(--nc-sans); + color: var(--nc-text); +} + +:host-context(html:not([data-theme='dark'])) { + --nc-neon: #c026d3; + --nc-bg: #f5f0fc; + --nc-surface: rgba(255, 255, 255, 0.88); + --nc-text: #0f0022; + --nc-text-muted: rgba(15, 0, 34, 0.65); + --nc-text-dim: rgba(15, 0, 34, 0.4); + --nc-border: rgba(15, 0, 34, 0.1); + --nc-border-strong: rgba(15, 0, 34, 0.2); + --nc-success: #16a34a; + --nc-danger: #dc2626; + --nc-warn: #b45309; +} + +.t-shell { padding-top: 72px; min-height: 100vh; } + +.page { + max-width: 760px; + margin: 0 auto; + padding: 32px 20px 64px; +} + +/* Breadcrumb */ +.crumb { + display: flex; align-items: center; gap: 8px; + margin-bottom: 28px; font-size: 11px; + color: var(--nc-text-dim); letter-spacing: 0.06em; +} +.crumb-link { + display: inline-flex; align-items: center; gap: 4px; + color: var(--nc-text-dim); text-decoration: none; + transition: color 0.15s; +} +.crumb-link:hover { color: var(--nc-neon); } +.crumb-sep { opacity: 0.35; } +.crumb-current { color: var(--nc-text-muted); } + +/* Header */ +.page-header { margin-bottom: 28px; } +.page-title-row { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 16px; +} +.page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; } + +.btn-new { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 14px; border-radius: 8px; border: none; + background: var(--nc-neon); color: #fff; + font-size: 13px; font-weight: 600; cursor: pointer; + transition: opacity 0.15s; +} +.btn-new:hover { opacity: 0.85; } + +/* Create dialog */ +.dialog-overlay { + position: fixed; inset: 0; z-index: 200; + background: rgba(0,0,0,0.55); backdrop-filter: blur(4px); + display: flex; align-items: center; justify-content: center; + padding: 20px; +} +.dialog-card { + background: var(--nc-bg); border: 1px solid var(--nc-border-strong); + border-radius: 16px; padding: 24px; width: 100%; max-width: 420px; +} +.dialog-head { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 20px; +} +.dialog-brand { font-size: 14px; font-weight: 700; color: var(--nc-neon); } +.dialog-close { + background: none; border: none; cursor: pointer; + font-size: 20px; line-height: 1; color: var(--nc-text-muted); + padding: 0 4px; +} +.dialog-close:hover { color: var(--nc-text); } +.dialog-field { display: flex; flex-direction: column; gap: 6px; flex: 1; } +.dialog-label { font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--nc-text-muted); } +.dialog-input { + width: 100%; padding: 8px 10px; border-radius: 8px; + border: 1px solid var(--nc-border-strong); + background: rgba(255,255,255,0.04); color: var(--nc-text); + font-size: 14px; box-sizing: border-box; +} +.dialog-input:focus { outline: 2px solid var(--nc-neon); outline-offset: 1px; border-color: transparent; } +.dialog-row { display: flex; gap: 12px; margin-bottom: 16px; } +.dialog-field:not(.dialog-row .dialog-field) { margin-bottom: 16px; } +.dialog-toggle { + display: flex; align-items: center; gap: 10px; cursor: pointer; + margin-bottom: 20px; user-select: none; +} +.dialog-toggle input[type=checkbox] { display: none; } +.toggle-track { + width: 36px; height: 20px; border-radius: 10px; + background: var(--nc-border-strong); flex-shrink: 0; + transition: background 0.2s; position: relative; +} +.toggle-track::after { + content: ''; position: absolute; top: 3px; left: 3px; + width: 14px; height: 14px; border-radius: 50%; + background: var(--nc-text-muted); transition: transform 0.2s, background 0.2s; +} +.dialog-toggle input:checked ~ .toggle-track { background: var(--nc-neon); } +.dialog-toggle input:checked ~ .toggle-track::after { transform: translateX(16px); background: #fff; } +.toggle-label { font-size: 14px; color: var(--nc-text); } +.dialog-error { + font-size: 13px; color: var(--nc-danger); + background: rgba(255,122,122,0.1); border-radius: 8px; + padding: 10px 12px; margin-bottom: 16px; +} +.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; } +.btn-ghost { + padding: 8px 16px; border-radius: 8px; border: 1px solid var(--nc-border-strong); + background: transparent; color: var(--nc-text-muted); font-size: 13px; cursor: pointer; +} +.btn-ghost:hover { color: var(--nc-text); border-color: var(--nc-text-muted); } +.btn-primary { + padding: 8px 18px; border-radius: 8px; border: none; + background: var(--nc-neon); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; + transition: opacity 0.15s; +} +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } + +/* Tabs */ +.tabs { display: flex; gap: 4px; } +.tab-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 14px; border-radius: 8px; border: none; + background: transparent; color: var(--nc-text-muted); + font-size: 13px; font-weight: 500; cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.tab-btn:hover { background: var(--nc-border); color: var(--nc-text); } +.tab-btn.active { background: var(--nc-surface); color: var(--nc-text); border: 1px solid var(--nc-border-strong); } +.tab-badge { + display: inline-flex; align-items: center; justify-content: center; + min-width: 18px; height: 18px; padding: 0 5px; + border-radius: 9px; background: var(--nc-border-strong); + font-size: 10px; font-weight: 700; color: var(--nc-text-muted); +} +.live-badge { background: rgba(94, 229, 161, 0.2); color: var(--nc-success); } + +/* States */ +.state-msg { + display: flex; align-items: center; gap: 10px; + padding: 24px 0; color: var(--nc-text-muted); font-size: 13px; +} +.state-msg.small { padding: 12px 0; } +.pulse { + width: 8px; height: 8px; border-radius: 50%; + background: var(--nc-neon); flex-shrink: 0; + animation: pulse 1.4s ease-in-out infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.85); } +} + +.empty-state { + display: flex; flex-direction: column; align-items: center; + gap: 8px; padding: 64px 0; text-align: center; +} +.empty-icon { color: var(--nc-text-dim); margin-bottom: 4px; } +.empty-title { font-size: 15px; font-weight: 600; margin: 0; } +.empty-sub { font-size: 13px; color: var(--nc-text-muted); margin: 0; } + +/* Tournament list */ +.t-list { display: flex; flex-direction: column; gap: 8px; } + +.t-card { + border: 1px solid var(--nc-border); + border-radius: 12px; + background: var(--nc-surface); + overflow: hidden; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.t-card:hover, .t-card.expanded { + border-color: var(--nc-border-strong); + background: rgba(255, 255, 255, 0.04); +} +.t-card:focus-visible { outline: 2px solid var(--nc-neon); outline-offset: 2px; } + +.t-action-btn { + padding: 5px 12px; border-radius: 7px; font-size: 12px; font-weight: 600; + cursor: pointer; border: none; transition: opacity 0.15s; white-space: nowrap; +} +.t-action-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.t-btn-start { background: var(--nc-success); color: #0f0022; } +.t-btn-start:hover:not(:disabled) { opacity: 0.85; } +.t-btn-join { background: var(--nc-neon); color: #fff; } +.t-btn-join:hover:not(:disabled) { opacity: 0.85; } + +/* Join dialog extras */ +.join-hint { font-size: 13px; color: var(--nc-text-muted); margin: 0 0 16px; line-height: 1.5; } +.join-empty { font-size: 13px; color: var(--nc-text-muted); margin: 0 0 8px; } +.dialog-loading { display: flex; align-items: center; gap: 8px; + font-size: 13px; color: var(--nc-text-muted); padding: 12px 0; } +.bot-pick-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; } +.bot-pick-row { + display: flex; align-items: center; gap: 10px; + padding: 10px 12px; border-radius: 8px; + border: 1px solid var(--nc-border); background: var(--nc-surface); + cursor: pointer; text-align: left; width: 100%; + transition: border-color 0.15s, background 0.15s; +} +.bot-pick-row:hover:not(:disabled) { border-color: var(--nc-neon); background: rgba(255,69,200,0.06); } +.bot-pick-row:disabled { opacity: 0.5; cursor: not-allowed; } +.bot-pick-avatar { + width: 30px; height: 30px; border-radius: 50%; background: var(--nc-neon); + color: #fff; display: flex; align-items: center; justify-content: center; + font-size: 13px; font-weight: 700; flex-shrink: 0; +} +.bot-pick-name { flex: 1; font-size: 14px; font-weight: 600; color: var(--nc-text); } +.bot-pick-rating { font-size: 12px; color: var(--nc-text-muted); } +.bot-pick-spinner { font-size: 13px; color: var(--nc-neon); } + +.t-card-main { + display: flex; justify-content: space-between; align-items: center; + padding: 14px 16px; gap: 12px; +} +.t-card-left { display: flex; align-items: center; gap: 12px; min-width: 0; } +.t-card-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } + +.t-status-dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; +} +.dot-started { background: var(--nc-success); box-shadow: 0 0 6px var(--nc-success); } +.dot-created { background: var(--nc-warn); } +.dot-finished { background: var(--nc-text-dim); } + +.t-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.t-name { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.t-meta { font-size: 11px; color: var(--nc-text-muted); } + +.winner-badge { + font-size: 11px; font-weight: 600; color: var(--nc-warn); + padding: 3px 8px; border-radius: 6px; + background: rgba(255, 209, 102, 0.12); +} + +.chevron { color: var(--nc-text-dim); transition: transform 0.2s; } +.chevron.open { transform: rotate(180deg); } + +/* Detail panel */ +.t-detail { + border-top: 1px solid var(--nc-border); + padding: 16px; + display: flex; flex-direction: column; gap: 20px; +} + +.detail-section { display: flex; flex-direction: column; gap: 10px; } +.detail-heading { + font-size: 11px; font-weight: 700; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--nc-text-muted); margin: 0; +} + +.no-standings { font-size: 12px; color: var(--nc-text-dim); margin: 0; } + +/* Standings table */ +.standings-table { + width: 100%; border-collapse: collapse; font-size: 13px; +} +.standings-table th { + text-align: left; padding: 6px 8px; + font-size: 10px; font-weight: 700; letter-spacing: 0.06em; + text-transform: uppercase; color: var(--nc-text-dim); + border-bottom: 1px solid var(--nc-border); +} +.standings-table td { padding: 8px 8px; border-bottom: 1px solid var(--nc-border); } +.standings-table tr:last-child td { border-bottom: none; } +.top-row td { color: var(--nc-text); } +.standings-table tr:not(.top-row) td { color: var(--nc-text-muted); } + +.col-rank { width: 40px; font-size: 14px; } +.col-pts { width: 48px; font-weight: 700; color: var(--nc-neon) !important; } +.col-tb { width: 52px; color: var(--nc-text-dim) !important; font-size: 12px; } +.col-games { width: 64px; } + +.wdl { font-size: 12px; font-variant-numeric: tabular-nums; } +.w { color: var(--nc-success); } +.d { color: var(--nc-text-muted); } +.l { color: var(--nc-danger); } + +/* Pairings */ +.pairings-list { display: flex; flex-direction: column; gap: 6px; } +.pairing-row { + display: flex; align-items: center; gap: 8px; + padding: 8px 10px; border-radius: 8px; + background: rgba(255,255,255,0.025); + font-size: 13px; transition: background 0.15s; +} +.pairing-row.is-watchable { cursor: pointer; } +.pairing-row.is-watchable:hover { background: rgba(255,255,255,0.06); } +.pairing-white { font-weight: 600; flex: 1; } +.pairing-vs { color: var(--nc-text-dim); font-size: 11px; flex-shrink: 0; } +.pairing-black { flex: 1; } +.pairing-result { font-weight: 700; font-size: 12px; margin-left: auto; } +.result-white { color: var(--nc-success); } +.result-black { color: var(--nc-danger); } +.result-draw { color: var(--nc-text-muted); } +.pairing-ongoing { + display: inline-flex; align-items: center; gap: 5px; + margin-left: auto; font-size: 10px; font-weight: 700; + color: var(--nc-success); letter-spacing: 0.06em; text-transform: uppercase; +} +.pairing-ongoing svg { animation: pulse 1.4s ease-in-out infinite; } diff --git a/src/app/pages/tournaments/tournaments.component.html b/src/app/pages/tournaments/tournaments.component.html new file mode 100644 index 0000000..6ce1fe6 --- /dev/null +++ b/src/app/pages/tournaments/tournaments.component.html @@ -0,0 +1,273 @@ +
+
+ + + + + + @if (loading) { +
Loading tournaments…
+ } @else if (activeList.length === 0) { +
+
+ + + + + + +
+

No tournaments here

+

Check back later or look in another tab.

+
+ } @else { +
+ @for (t of activeList; track t.id) { +
+ +
+
+ +
+ {{ t.fullName }} + + {{ clockDisplay(t) }} · {{ t.nbRounds }} rounds · + @if (t.status === 'started') { Round {{ t.round }}/{{ t.nbRounds }} · } + {{ t.nbPlayers }} player{{ t.nbPlayers === 1 ? '' : 's' }} + @if (t.rated) { · Rated } + +
+
+
+ @if (t.status === 'finished' && t.winner) { + 🏆 {{ t.winner.name }} + } + @if (currentUser && t.status === 'created') { + @if (t.createdBy === currentUser.id) { + + } + + } + + + +
+
+ + @if (selectedTournament?.id === t.id) { +
+ + + @if (t.standing.players.length > 0) { +
+

Leaderboard

+ + + + + + + + + + + + @for (r of t.standing.players; track r.bot.id) { + + + + + + + + } + +
#BotPtsBkhW/D/L
{{ rankMedal(r.rank) }}{{ r.bot.name }}{{ scoreDisplay(r) }}{{ r.tieBreak }} + + {{ r.wins }}/{{ r.draws }}/{{ r.losses }} + +
+
+ } @else { +

No standings yet — waiting for games to complete.

+ } + + + @if (t.round > 0) { +
+

Round {{ t.round }} pairings

+ @if (pairingsLoading) { +
Loading…
+ } @else if (pairings && pairings.pairings.length > 0) { +
+ @for (p of pairings.pairings; track p.id) { +
+ {{ p.white?.name ?? 'Bye' }} + vs + {{ p.black.name }} + @if (p.winner) { + + {{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '1–0' : '0–1' }} + + } @else if (p.gameId) { + + + + + Watch + + } +
+ } +
+ } @else { +

No pairings recorded yet.

+ } +
+ } + +
+ } +
+ } +
+ } + +
+
+ +@if (joinDialogTournamentId) { +
+
+
+ Join with a bot + +
+

Select one of your bots to enter this tournament. Its token will be rotated to authenticate the join.

+ + @if (botsLoading) { +
Loading bots…
+ } @else if (userBots.length === 0) { +

You have no bots yet. Go to Bots in the nav to create one first.

+ } @else { +
+ @for (bot of userBots; track bot.id) { + + } +
+ } + + @if (joinError) { +
{{ joinError }}
+ } +
+
+} + +@if (showCreateDialog) { +
+
+
+ New tournament + +
+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + @if (createError) { +
{{ createError }}
+ } + +
+ + +
+
+
+
+} diff --git a/src/app/pages/tournaments/tournaments.component.ts b/src/app/pages/tournaments/tournaments.component.ts new file mode 100644 index 0000000..adfbd83 --- /dev/null +++ b/src/app/pages/tournaments/tournaments.component.ts @@ -0,0 +1,232 @@ +import { Component, DestroyRef, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router, RouterLink } from '@angular/router'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { TournamentService } from '../../services/tournament.service'; +import { AuthService } from '../../services/auth.service'; +import { BotService } from '../../services/bot.service'; +import { Bot } from '../../models/bot.models'; +import { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models'; +import { CurrentUser } from '../../models/auth.models'; + +type StatusTab = 'started' | 'created' | 'finished'; + +@Component({ + selector: 'app-tournaments', + standalone: true, + imports: [CommonModule, RouterLink, ReactiveFormsModule], + templateUrl: './tournaments.component.html', + styleUrl: './tournaments.component.css' +}) +export class TournamentsComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly tournamentService = inject(TournamentService); + private readonly authService = inject(AuthService); + private readonly fb = inject(FormBuilder); + private readonly botService = inject(BotService); + private readonly router = inject(Router); + + loading = true; + tab: StatusTab = 'started'; + currentUser: CurrentUser | null = null; + + started: Tournament[] = []; + created: Tournament[] = []; + finished: Tournament[] = []; + + selectedTournament: Tournament | null = null; + pairings: RoundPairings | null = null; + pairingsLoading = false; + + showCreateDialog = false; + createForm: FormGroup = this.fb.group({ + name: ['', [Validators.required, Validators.minLength(3)]], + nbRounds: [4, [Validators.required, Validators.min(1), Validators.max(20)]], + clockLimitMinutes: [3, [Validators.required, Validators.min(1), Validators.max(60)]], + clockIncrement: [0, [Validators.required, Validators.min(0), Validators.max(60)]], + rated: [false] + }); + createLoading = false; + createError: string | null = null; + + startingId: string | null = null; + + joinDialogTournamentId: string | null = null; + userBots: Bot[] = []; + botsLoading = false; + joiningBotId: string | null = null; + joinError: string | null = null; + + ngOnInit(): void { + this.authService.currentUser$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(u => { this.currentUser = u; }); + this.loadTournaments(); + } + + openCreateDialog(): void { + this.createForm.reset({ name: '', nbRounds: 4, clockLimitMinutes: 3, clockIncrement: 0, rated: false }); + this.createError = null; + this.showCreateDialog = true; + } + + closeCreateDialog(): void { + this.showCreateDialog = false; + } + + submitCreate(): void { + if (this.createForm.invalid) return; + this.createLoading = true; + this.createError = null; + this.tournamentService.create(this.createForm.value).subscribe({ + next: t => { + this.createLoading = false; + this.showCreateDialog = false; + this.created = [t, ...this.created]; + this.tab = 'created'; + this.selectedTournament = null; + }, + error: err => { + this.createLoading = false; + this.createError = err.error?.message ?? err.error?.error ?? 'Failed to create tournament.'; + } + }); + } + + setTab(tab: StatusTab): void { + this.tab = tab; + this.selectedTournament = null; + this.pairings = null; + } + + selectTournament(t: Tournament): void { + if (this.selectedTournament?.id === t.id) { + this.selectedTournament = null; + this.pairings = null; + return; + } + this.selectedTournament = t; + this.pairings = null; + if (t.round > 0) { + this.loadPairings(t.id, t.round); + } + } + + get activeList(): Tournament[] { + return this[this.tab]; + } + + clockDisplay(t: Tournament): string { + const min = Math.floor(t.clock.limit / 60); + return `${min}+${t.clock.increment}`; + } + + rankMedal(rank: number): string { + if (rank === 1) return '🥇'; + if (rank === 2) return '🥈'; + if (rank === 3) return '🥉'; + return `${rank}.`; + } + + scoreDisplay(r: TournamentResult): string { + return r.points % 1 === 0 ? `${r.points}` : `${r.points}`; + } + + startTournament(event: MouseEvent, t: Tournament): void { + event.stopPropagation(); + this.startingId = t.id; + this.tournamentService.start(t.id).subscribe({ + next: updated => { + this.startingId = null; + const list = this.created.map(x => x.id === t.id ? updated : x); + this.created = list.filter(x => x.status === 'created'); + if (!this.started.find(x => x.id === updated.id)) this.started = [updated, ...this.started]; + this.selectedTournament = updated; + this.tab = 'started'; + }, + error: () => { this.startingId = null; } + }); + } + + watchGame(gameId: string): void { + void this.router.navigate(['/game', gameId]); + } + + openJoinDialog(event: MouseEvent, tournamentId: string): void { + event.stopPropagation(); + this.joinDialogTournamentId = tournamentId; + this.joinError = null; + this.botsLoading = true; + this.botService.list() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: bots => { this.userBots = bots; this.botsLoading = false; }, + error: () => { this.botsLoading = false; } + }); + } + + closeJoinDialog(): void { + this.joinDialogTournamentId = null; + this.joiningBotId = null; + this.joinError = null; + } + + joinWithBot(bot: Bot): void { + if (!this.joinDialogTournamentId || this.joiningBotId) return; + this.joiningBotId = bot.id; + this.joinError = null; + this.botService.rotateToken(bot.id).subscribe({ + next: token => { + this.tournamentService.joinWithBotToken(this.joinDialogTournamentId!, token).subscribe({ + next: () => { + this.joiningBotId = null; + const tid = this.joinDialogTournamentId!; + this.closeJoinDialog(); + this.tournamentService.get(tid) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(updated => { + this.created = this.created.map(x => x.id === tid ? updated : x); + this.started = this.started.map(x => x.id === tid ? updated : x); + if (this.selectedTournament?.id === tid) this.selectedTournament = updated; + }); + }, + error: err => { + this.joiningBotId = null; + this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.'; + } + }); + }, + error: () => { + this.joiningBotId = null; + this.joinError = 'Failed to get bot token.'; + } + }); + } + + private loadTournaments(): void { + this.tournamentService.list() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: list => { + this.started = list.started; + this.created = list.created; + this.finished = list.finished; + this.loading = false; + if (this.started.length === 0 && this.created.length > 0) this.tab = 'created'; + else if (this.started.length === 0 && this.finished.length > 0) this.tab = 'finished'; + }, + error: () => { this.loading = false; } + }); + } + + private loadPairings(id: string, round: number): void { + this.pairingsLoading = true; + this.tournamentService.roundPairings(id, round) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: p => { this.pairings = p; this.pairingsLoading = false; }, + error: () => { this.pairingsLoading = false; } + }); + } +} diff --git a/src/app/pages/welcome/Auth Dialog.html b/src/app/pages/welcome/Auth Dialog.html new file mode 100644 index 0000000..32dfb08 --- /dev/null +++ b/src/app/pages/welcome/Auth Dialog.html @@ -0,0 +1,89 @@ + + + + + + NowChess — Auth Dialog + + + + + + +
+ + + + + + + + + diff --git a/src/app/pages/welcome/welcome.component.css b/src/app/pages/welcome/welcome.component.css index 7317008..e7162bc 100644 --- a/src/app/pages/welcome/welcome.component.css +++ b/src/app/pages/welcome/welcome.component.css @@ -652,240 +652,6 @@ font-weight: 600; } -/* Speech Bubble Styles */ -.speech-bubble-container { - position: fixed; - top: 35%; - left: 55%; - transform: translate(-50%, -50%); - z-index: 500; - cursor: pointer; - animation: slideInBubble 0.8s cubic-bezier(0.34, 1.56, 0.64, 1); -} - -@keyframes slideInBubble { - 0% { - opacity: 0; - transform: translate(-50%, -50%) scale(0.5); - } - 100% { - opacity: 1; - transform: translate(-50%, -50%) scale(1); - } -} - -.speech-bubble { - background: linear-gradient(135deg, #B9DAD1 0%, #B9C2DA 100%); - border: 2px solid #8b1270; - border-radius: 20px; - padding: 16px 24px; - font-family: 'Comic Sans MS', 'Comic Sans', cursive; - font-size: 18px; - font-weight: bold; - color: #5A2C28; - white-space: nowrap; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2), - inset 0 1px 3px rgba(255, 255, 255, 0.3); - position: relative; - transition: all 0.3s ease; -} - -.speech-bubble:hover { - transform: scale(1.05); - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3), - inset 0 1px 3px rgba(255, 255, 255, 0.5); -} - -.bubble-text { - margin: 0; -} - -.bubble-tail { - position: absolute; - bottom: -12px; - left: 20px; - width: 0; - height: 0; - border-left: 10px solid transparent; - border-right: 0px solid transparent; - border-top: 12px solid #B9DAD1; -} - -/* Zoom Overlay and Window */ -.zoom-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.7); - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - animation: fadeIn 0.3s ease; - cursor: pointer; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -.zoom-window-wrapper { - cursor: auto; - animation: zoomInWindow 1.2s cubic-bezier(0.34, 1.56, 0.64, 1); -} - -@keyframes zoomInWindow { - 0% { - transform: scale(0.1); - opacity: 0; - } - 100% { - transform: scale(1); - opacity: 1; - } -} - -.zoom-window-frame { - background: #13072a; - border: 8px solid #f26ae2; - border-radius: 16px; - padding: 40px 20px 20px 20px; - box-shadow: 0 0 40px rgba(242, 106, 226, 0.6), - inset 0 0 20px rgba(242, 106, 226, 0.2); - max-width: 90vw; - max-height: 90vh; - position: relative; -} - -.zoom-player-2 { - position: relative; - display: flex; - align-items: center; - justify-content: center; -} - -.player-2-gif { - max-width: 100%; - max-height: 70vh; - width: auto; - height: auto; - display: block; - border-radius: 12px; - cursor: pointer; - transition: transform 0.2s ease; -} - -.player-2-gif:hover { - transform: scale(1.02); -} - -.second-speech-bubble { - position: absolute; - top: -60px; - left: 50%; - transform: translateX(-50%); - background: linear-gradient(135deg, #C19EF5 0%, #E1EAA9 100%); - border: 2px solid #BA6D4B; - border-radius: 20px; - padding: 12px 18px; - font-family: 'Comic Sans MS', 'Comic Sans', cursive; - font-size: 16px; - font-weight: bold; - color: #5A2C28; - white-space: nowrap; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2), - inset 0 1px 3px rgba(255, 255, 255, 0.3); - animation: popInBubble 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); - z-index: 10; -} - -@keyframes popInBubble { - 0% { - opacity: 0; - transform: translateX(-50%) scale(0.3); - } - 100% { - opacity: 1; - transform: translateX(-50%) scale(1); - } -} - -.second-speech-bubble .bubble-tail { - top: 100%; - bottom: auto; - left: 50%; - transform: translateX(-50%); - border-top: 12px solid #C19EF5; -} - -/* Happy Meow Bubble */ -.happy-speech-bubble { - position: absolute; - top: -60px; - left: 50%; - transform: translateX(-50%); - background: linear-gradient(135deg, #F3C8A0 0%, #BA6D4B 100%); - border: 2px solid #5A2C28; - border-radius: 20px; - padding: 12px 18px; - font-family: 'Comic Sans MS', 'Comic Sans', cursive; - font-size: 16px; - font-weight: bold; - color: #fff; - white-space: nowrap; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), - inset 0 1px 3px rgba(255, 255, 255, 0.4), - 0 0 20px rgba(243, 200, 160, 0.5); - animation: popInBubble 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); - z-index: 10; -} - -.happy-speech-bubble .bubble-tail { - top: 100%; - bottom: auto; - left: 50%; - transform: translateX(-50%); - border-top: 12px solid #F3C8A0; -} - -/* Meat Emoji */ -.meat-emoji { - position: fixed; - font-size: 48px; - cursor: grab; - user-select: none; - z-index: 1001; - display: flex; - align-items: center; - justify-content: center; - width: 60px; - height: 60px; - animation: meatAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); - filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3)); - transition: transform 0.1s ease; -} - -.meat-emoji:active { - cursor: grabbing; - transform: scale(1.1); - filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5)); -} - -@keyframes meatAppear { - 0% { - opacity: 0; - transform: scale(0); - } - 100% { - opacity: 1; - transform: scale(1); - } -} - @media (max-width: 900px) { .bwrap { transform: scale(0.9); diff --git a/src/app/pages/welcome/welcome.component.html b/src/app/pages/welcome/welcome.component.html index e192bff..73cbf94 100644 --- a/src/app/pages/welcome/welcome.component.html +++ b/src/app/pages/welcome/welcome.component.html @@ -46,13 +46,6 @@
- - - @if (showMeatEmoji) { -
- 🍖 -
- }
@@ -145,50 +138,6 @@
- - @if (showSpeechBubble) { -
-
-
{{ bubbleMessage }}
-
-
-
- } - - - @if (isZoomedIn) { -
-
-
-
- Player 2 - @if (showSecondSpeechBubble) { -
-
Feed me! 🍖
-
-
- } - @if (showHappyBubble) { -
-
Happy meow! 😸
-
-
- } -
-
- - - @if (showMeatEmoji) { -
- 🍖 -
- } -
-
- } -
diff --git a/src/app/pages/welcome/welcome.component.ts b/src/app/pages/welcome/welcome.component.ts index 9a3bbaa..5126331 100644 --- a/src/app/pages/welcome/welcome.component.ts +++ b/src/app/pages/welcome/welcome.component.ts @@ -79,28 +79,11 @@ export class WelcomeComponent implements OnInit, OnDestroy { private authDialogState: 'login' | 'register' | null = null; private pendingAction: (() => void) | null = null; - // Speech bubble and zoom features - showSpeechBubble = false; - isZoomedIn = false; - showSecondSpeechBubble = false; - showHappyBubble = false; - showMeatEmoji = false; - bubbleMessage = 'meow'; - - // Meat emoji drag state - meatX = 0; - meatY = 0; - isDraggingMeat = false; - meatDragOffsetX = 0; - meatDragOffsetY = 0; - stars: Star[] = []; bgBuildings: BackgroundBuilding[] = []; windows: Record = {}; private flickerIntervalId: ReturnType | undefined; - private speechBubbleTimeoutId: ReturnType | undefined; - private zoomTimeoutId: ReturnType | undefined; private coolColors = ['#7de8ff', '#00d5ff', '#5bc0de', '#31b0d5', '#4fc3f7', '#29b6f6']; private coolGlowColors = ['#00d5ff', '#00d5ff', '#31b0d5', '#31b0d5', '#03a9f4', '#0288d1']; @@ -140,21 +123,10 @@ export class WelcomeComponent implements OnInit, OnDestroy { this.generateBackgroundBuildings(); this.generateWindowsForAllBuildings(); this.startWindowFlicker(); - - // Show speech bubble after 5 seconds - this.speechBubbleTimeoutId = setTimeout(() => { - this.showSpeechBubble = true; - }, 5000); } ngOnDestroy(): void { this.stopWindowFlicker(); - if (this.speechBubbleTimeoutId) { - clearTimeout(this.speechBubbleTimeoutId); - } - if (this.zoomTimeoutId) { - clearTimeout(this.zoomTimeoutId); - } } openDifficultyDialog(): void { @@ -265,84 +237,6 @@ export class WelcomeComponent implements OnInit, OnDestroy { this.performSubmitImportGame(); } - onSpeechBubbleClick(): void { - this.showSpeechBubble = false; - this.isZoomedIn = true; - this.bubbleMessage = 'meow'; - this.showMeatEmoji = true; - this.showHappyBubble = false; - this.showSecondSpeechBubble = true; - - // Reset meat position - this.meatX = window.innerWidth / 2 - 100; - this.meatY = window.innerHeight / 2 + 150; - } - - onZoomedViewClick(): void { - this.isZoomedIn = false; - this.showSecondSpeechBubble = false; - this.showHappyBubble = false; - this.showMeatEmoji = false; - this.bubbleMessage = 'meow'; - - if (this.zoomTimeoutId) { - clearTimeout(this.zoomTimeoutId); - } - } - - onMeatMouseDown(event: MouseEvent): void { - this.isDraggingMeat = true; - const rect = (event.target as HTMLElement).getBoundingClientRect(); - this.meatDragOffsetX = event.clientX - rect.left; - this.meatDragOffsetY = event.clientY - rect.top; - } - - onMouseMove(event: MouseEvent): void { - if (!this.isDraggingMeat) { - return; - } - - this.meatX = event.clientX - this.meatDragOffsetX; - this.meatY = event.clientY - this.meatDragOffsetY; - - const gifElement = document.querySelector('.player-2-gif') as HTMLElement; - if (!gifElement) { - return; - } - - const gifRect = gifElement.getBoundingClientRect(); - const gifCenterX = gifRect.left + gifRect.width / 2; - const gifCenterY = gifRect.top + gifRect.height / 2; - - const meatElement = document.querySelector('.meat-emoji') as HTMLElement; - if (!meatElement) { - return; - } - - const meatRect = meatElement.getBoundingClientRect(); - const meatCenterX = meatRect.left + meatRect.width / 2; - const meatCenterY = meatRect.top + meatRect.height / 2; - - const distance = Math.sqrt( - Math.pow(meatCenterX - gifCenterX, 2) + Math.pow(meatCenterY - gifCenterY, 2) - ); - - if (distance < 50) { - this.onMeatFed(); - } - } - - onMouseUp(): void { - this.isDraggingMeat = false; - } - - onMeatFed(): void { - this.showMeatEmoji = false; - this.showSecondSpeechBubble = false; - this.showHappyBubble = true; - this.isDraggingMeat = false; - } - private requireAuth(action: () => void): boolean { if (this.authService.isLoggedIn()) { return true; diff --git a/src/app/services/auth.interceptor.ts b/src/app/services/auth.interceptor.ts index 673722e..4759842 100644 --- a/src/app/services/auth.interceptor.ts +++ b/src/app/services/auth.interceptor.ts @@ -8,9 +8,10 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { req.url.includes('/api/account/bots') || req.url.includes('/api/account/official-bots') || req.url.includes('/api/board/game') || - req.url.includes('/api/challenge'); + req.url.includes('/api/challenge') || + req.url.includes('/api/tournament'); - if (token && isProtectedEndpoint) { + if (token && isProtectedEndpoint && !req.headers.has('Authorization')) { req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index cf34768..cdce9c6 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -26,9 +26,9 @@ export class AuthService { }) .pipe( tap((response) => { - localStorage.setItem('token', response.token); + localStorage.setItem('token', response.accessToken); //GRRRRRRRRRR + localStorage.setItem('refreshToken', response.refreshToken); localStorage.setItem('username', username); - // After login, fetch current user info this.getCurrentUser().subscribe(); }) ); @@ -60,6 +60,7 @@ export class AuthService { logout(): void { localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); localStorage.removeItem('username'); localStorage.removeItem('userId'); this.currentUserSubject.next(null); diff --git a/src/app/services/bot.service.ts b/src/app/services/bot.service.ts new file mode 100644 index 0000000..fa77c29 --- /dev/null +++ b/src/app/services/bot.service.ts @@ -0,0 +1,27 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, map } from 'rxjs'; +import { Bot, BotWithToken } from '../models/bot.models'; + +@Injectable({ providedIn: 'root' }) +export class BotService { + private readonly http = inject(HttpClient); + private readonly base = '/api/account/bots'; + + list(): Observable { + return this.http.get(this.base); + } + + create(name: string): Observable { + return this.http.post(this.base, { name }); + } + + rotateToken(botId: string): Observable { + return this.http.post<{ token: string }>(`${this.base}/${botId}/rotate-token`, null) + .pipe(map(r => r.token)); + } + + delete(botId: string): Observable { + return this.http.delete(`${this.base}/${botId}`); + } +} diff --git a/src/app/services/game-api.service.ts b/src/app/services/game-api.service.ts index 833b747..726fd64 100644 --- a/src/app/services/game-api.service.ts +++ b/src/app/services/game-api.service.ts @@ -67,6 +67,14 @@ export class GameApiService { return this.http.post(`${this.apiBase}${this.apiPath}/import/pgn`, { pgn }); } + resignGame(gameId: string): Observable { + return this.http.post(`${this.apiBase}${this.apiPath}/${gameId}/resign`, {}); + } + + offerDraw(gameId: string): Observable { + return this.http.post(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {}); + } + private resolveWsBase(): string { if (this.wsBase) { return this.wsBase; @@ -77,7 +85,11 @@ export class GameApiService { } streamGame(gameId: string): Observable { - const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`; + const token = localStorage.getItem('token'); + let wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`; + if (token) { + wsUrl += `?token=${encodeURIComponent(token)}`; + } return this.streamHandler.createGameStream(wsUrl, gameId); } } diff --git a/src/app/services/game-completion.service.ts b/src/app/services/game-completion.service.ts index 6025b08..c0c692d 100644 --- a/src/app/services/game-completion.service.ts +++ b/src/app/services/game-completion.service.ts @@ -24,23 +24,28 @@ export class GameCompletionService { return { isFinished: true, message }; } + isTimeOut(state: GameState | null): boolean { + if (!state?.clock) return false; + return state.clock.whiteRemainingMs <= 0 || state.clock.blackRemainingMs <= 0; + } + private buildCompletionMessage(status: GameStatus, state: GameState, game: GameFull): string { + const winner = state.winner === 'white' ? game.white.displayName : state.winner === 'black' ? game.black.displayName : null; + const loser = state.winner === 'white' ? game.black.displayName : state.winner === 'black' ? game.white.displayName : null; + switch (status) { case 'checkmate': - const winner = state.winner === 'white' ? game.white.displayName : game.black.displayName; - return `Checkmate! ${winner} wins!`; + return winner ? `Checkmate — ${winner} wins!` : 'Checkmate!'; case 'stalemate': - return 'Stalemate! The game is a draw.'; + return 'Stalemate — 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!`; + return loser && winner ? `${loser} resigned — ${winner} wins!` : 'Resigned.'; case 'draw': - return 'Draw! The game ended in a draw.'; + return 'Draw by agreement.'; case 'insufficientMaterial': - return 'Insufficient material! The game is a draw.'; + return 'Draw — insufficient material.'; default: - return 'Game ended!'; + return 'Game over.'; } } } diff --git a/src/app/services/tournament.service.ts b/src/app/services/tournament.service.ts new file mode 100644 index 0000000..493d400 --- /dev/null +++ b/src/app/services/tournament.service.ts @@ -0,0 +1,52 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Tournament, TournamentList, RoundPairings } from '../models/tournament.models'; + +export interface CreateTournamentForm { + name: string; + nbRounds: number; + clockLimitMinutes: number; + clockIncrement: number; + rated: boolean; +} + +@Injectable({ providedIn: 'root' }) +export class TournamentService { + private readonly http = inject(HttpClient); + private readonly base = '/api/tournament'; + + list(): Observable { + return this.http.get(this.base); + } + + get(id: string): Observable { + return this.http.get(`${this.base}/${id}`); + } + + create(form: CreateTournamentForm): Observable { + const body = new URLSearchParams(); + body.set('name', form.name); + body.set('nbRounds', String(form.nbRounds)); + body.set('clockLimit', String(form.clockLimitMinutes * 60)); + body.set('clockIncrement', String(form.clockIncrement)); + body.set('rated', String(form.rated)); + return this.http.post(this.base, body.toString(), { + headers: new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' }) + }); + } + + start(id: string): Observable { + return this.http.post(`${this.base}/${id}/start`, null); + } + + joinWithBotToken(id: string, botToken: string): Observable { + return this.http.post(`${this.base}/${id}/join`, null, { + headers: new HttpHeaders({ Authorization: `Bearer ${botToken}` }) + }); + } + + roundPairings(id: string, round: number): Observable { + return this.http.get(`${this.base}/${id}/round/${round}`); + } +} diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index fa74ddd..0bb31eb 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -2,7 +2,7 @@ export const environment = { production: false, apiBaseUrl: '', accountServiceUrl: '', - wsBaseUrl: '', + wsBaseUrl: 'ws://localhost:8084', userWsBaseUrl: 'ws://localhost:8084', apiPath: '/api/board/game' }; From 85f1b8e42ce6892c66fac96146de5409b67cf637 Mon Sep 17 00:00:00 2001 From: TeamCity Date: Tue, 2 Jun 2026 19:59:36 +0000 Subject: [PATCH 3/7] ci: bump version to v0.2.6 --- CHANGELOG.md | 5 +++++ versions.env | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1eabea..679275f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,3 +45,8 @@ * build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080)) * NCWF-2 bugs and desing fixes ([#7](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/7)) ([c02414e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c02414ea40177b05a5e62dcf68dcb44efa6d3740)) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.4...0.0.0) (2026-06-01) +## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.5...0.0.0) (2026-06-02) + +### Bug Fixes + +* NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f)) diff --git a/versions.env b/versions.env index 04725b4..60fb3b7 100644 --- a/versions.env +++ b/versions.env @@ -1,3 +1,3 @@ MAJOR=0 MINOR=2 -PATCH=5 +PATCH=6 From a65d748f99394890753747305f7a2e4ebdbed0d6 Mon Sep 17 00:00:00 2001 From: Janis Eccarius Date: Sat, 6 Jun 2026 14:32:45 +0200 Subject: [PATCH 4/7] chore(claude): add YouTrack command suite Port create-defect, create-story, estimate-issues, fix-defect, implement-feature and split-story commands, adapted for the Angular frontend (npm build/test, prettier gates, NCWF routing). implement-feature now walks subtasks, respects blocked-by, and reports cross-project (NCS/NCI) tasks. Ignore .claude/worktrees. Co-Authored-By: Claude Opus 4.8 --- .claude/commands/create-defect.md | 97 ++++++++++++++++ .claude/commands/create-story.md | 114 ++++++++++++++++++ .claude/commands/estimate-issues.md | 92 +++++++++++++++ .claude/commands/fix-defect.md | 143 +++++++++++++++++++++++ .claude/commands/implement-feature.md | 159 ++++++++++++++++++++++++++ .claude/commands/split-story.md | 111 ++++++++++++++++++ .gitignore | 4 + 7 files changed, 720 insertions(+) create mode 100644 .claude/commands/create-defect.md create mode 100644 .claude/commands/create-story.md create mode 100644 .claude/commands/estimate-issues.md create mode 100644 .claude/commands/fix-defect.md create mode 100644 .claude/commands/implement-feature.md create mode 100644 .claude/commands/split-story.md diff --git a/.claude/commands/create-defect.md b/.claude/commands/create-defect.md new file mode 100644 index 0000000..82e19e9 --- /dev/null +++ b/.claude/commands/create-defect.md @@ -0,0 +1,97 @@ +# Create Defect in YouTrack + +Automated defect creation workflow. Topic/hint: `$ARGUMENTS` + +This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Defects raised +here usually belong to project `NCWF`, but routing in Step 5 still applies. + +## Step 1 — Gather Context + +Use `AskUserQuestion` tool to ask the user (max 4 questions at once): + +1. **Component** — Where does the bug occur? (e.g. a component, service, route/page, model, styling, API integration) +2. **What breaks** — What is the actual (broken) behavior? +3. **Expected** — What should happen instead? +4. **Reproducibility** — Is it always reproducible? Any known trigger conditions? (route, viewport, browser) + +If `$ARGUMENTS` already answers some of these, skip those questions. + +## Step 2 — Research (if needed) + +If the bug involves component logic, a service, or routing: +- Search repo for relevant code (`Grep`/`Bash`) under `src/app/{components,services,models,core,pages}`. +- Check `.spec.ts` files for existing coverage of the broken area. +- Do NOT guess at root cause. Surface findings before drafting. + +## Step 3 — Draft Defect + +Compose the full defect report using this template: + +``` +Summary + +[One-sentence description of what is broken.] + + +Steps to Reproduce + +1. Step one +2. Step two +3. Step three + + +Expected Behavior + +[What should happen.] + + +Actual Behavior + +[What actually happens.] + + +Environment / Notes + +[Any relevant context: route/URL, viewport, browser, console errors, API payloads — only if applicable.] +``` + +Rules: +- Steps must be minimal and reproducible. +- Expected vs actual: concrete and unambiguous. +- Omit "Environment / Notes" section if not relevant. + +## Step 4 — Clarify + +Show the draft to the user. +**Use `AskUserQuestion` tool to ask:** +- Are steps to reproduce complete and accurate? +- Severity: Blocker / Critical / Major / Minor / Trivial? +- Any related tickets or recent changes to link? + +Incorporate feedback. Repeat until user approves. + +## Step 5 — Determine Project + +> **Project routing rules (always apply these):** +> - Frontend code (UI, UX, web app, components, services) → `NCWF` +> - Backend code (game engine, bots, API, services, coordinator) → `NCS` +> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI` + +- Frontend / UI / UX → project: `NCWF` (default for this repo) +- Backend / coordinator / systems / bot / engine → project: `NCS` +- Kubernetes, pipelines, CI/CD, infrastructure → project: `NCI` + +If ambiguous, ask the user. + +## Step 6 — Create Issue + +Call `mcp__youtrack__create_issue` with: +- `project`: determined in Step 5 +- `summary`: concise title describing what is broken (≤72 chars, sentence case) +- `description`: full formatted defect report from Step 3 (Markdown) +- `type`: `Bug` + +## Step 7 — Report + +Display the created issue ID and URL. +Ask if a linked investigation or fix task is needed. diff --git a/.claude/commands/create-story.md b/.claude/commands/create-story.md new file mode 100644 index 0000000..25edb03 --- /dev/null +++ b/.claude/commands/create-story.md @@ -0,0 +1,114 @@ +# Create User Story in YouTrack + +Automated user-story creation workflow. Topic/hint: `$ARGUMENTS` + +This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Stories raised +here usually belong to project `NCWF`, but routing in Step 5 still applies. + +## Step 1 — Gather Context + +Use `AskUserQuestion` tool to ask the user (max 4 questions at once): + +1. **Domain** — Is this frontend (UI/UX) work, or does it touch backend/infrastructure? +2. **User type** — Who is the actor? (e.g. player, spectator, admin, anonymous visitor) +3. **Action** — What should the user be able to do? +4. **Goal/value** — Why? What outcome does it enable? + +If `$ARGUMENTS` already answers some of these, skip those questions. + +## Step 2 — Research (if needed) + +If the topic involves unfamiliar UI flows, component structure, or technical constraints: +- Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`). +- Use `WebSearch` if the topic involves Angular APIs, external standards or protocols. +- Do NOT guess. Surface findings before drafting. + +## Step 3 — Draft Story + +Compose the full story using this template: + +``` +As a [type of user] +I want to [perform an action] +So that [achieve a goal or value] + + +Description + +[Additional context or business logic for this story.] + + +Acceptance Criteria + +[List the specific, measurable criteria that define when this story is done:] + +- Criterion 1 +- Criterion 2 +- Criterion 3 + + +Implementation Notes + +[Technical notes, component/service references, design refs, or constraints.] +``` + +Rules: +- User story line: plain English, present tense, from user's perspective. +- Acceptance criteria: testable, unambiguous, one condition each. +- Implementation notes: optional — only include if there are known constraints, related tickets, or design refs. + +## Step 4 — Clarify Acceptance Criteria + +Show the draft to the user. +**Use `AskUserQuestion` tool to ask:** +- Are the acceptance criteria complete and correct? +- Any implementation constraints to add? +- Priority (if known)? + +Incorporate feedback. Repeat until user approves. + +## Step 5 — Determine Project + +> **Project routing rules (always apply these):** +> - Frontend code (UI, UX, web app, components, services) → `NCWF` +> - Backend code (game engine, bots, API, services, coordinator) → `NCS` +> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI` + +- Frontend / UI / UX → project: `NCWF` (default for this repo) +- Backend / coordinator / systems / bot / engine → project: `NCS` +- Kubernetes, pipelines, CI/CD, infrastructure → project: `NCI` + +If still ambiguous, ask the user. + +## Step 6 — Create Issue + +Call `mcp__youtrack__create_issue` with: +- `project`: determined in Step 5 +- `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case) +- `description`: full formatted story from Step 3 (Markdown) +- `type`: `Feature` (or `Task` if purely technical with no user-facing value) + +## Step 7 — Link Issues + +After creation, ask the user (use `AskUserQuestion` if interactive, otherwise infer from context): + +> Are there related issues to link? (skip if none) + +Collect any issue IDs the user mentions. For each, determine the correct relation and call `mcp__youtrack__link_issues`: + +| Situation | Relation to use | +|-----------|----------------| +| This story must be done before another | `blocks` | +| Another story must be done before this | `is blocked by` | +| Stories share domain or are related | `relates to` | +| This is a child of an epic/story | `subtask of` | +| This is a parent grouping subtasks | `parent for` | +| This depends on another ticket's output | `depends on` | + +If the user mentions an issue in the story description or implementation notes (e.g. "see NCS-42", "after NCWF-12 is done"), auto-detect and suggest linking it — confirm before creating the link. + +## Step 8 — Report + +Display the created issue ID and URL. +List any links created (relation type + linked issue ID). +Ask if a linked sub-task or implementation ticket is needed. diff --git a/.claude/commands/estimate-issues.md b/.claude/commands/estimate-issues.md new file mode 100644 index 0000000..cc8ed37 --- /dev/null +++ b/.claude/commands/estimate-issues.md @@ -0,0 +1,92 @@ +# Estimate Issue Time in YouTrack + +Sprint planning time estimator. Issue ID or empty for full current sprint: `$ARGUMENTS` + +This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`. + +## Step 1 — Determine Scope + +**Single-issue mode** (`$ARGUMENTS` set): +- Call `mcp__youtrack__get_issue` on `$ARGUMENTS`. +- Proceed with that issue only. + +**Sprint mode** (`$ARGUMENTS` empty): +- Call `mcp__youtrack__search_issues` with query `project: NCWF Sprints: {current sprint} #Unresolved`. +- If query returns 0 results, use `AskUserQuestion` to ask for the sprint name, then retry with `project: NCWF Sprints: {name}`. +- Collect all returned issues. + +## Step 2 — Build Issue Tree + +For each top-level issue from Step 1: +1. Fetch full details via `mcp__youtrack__get_issue`: summary, description, acceptance criteria, Type, existing `Zeitschätzung`, linked issues. +2. Identify subtasks from links with relation `subtask of` (i.e. issues where the fetched issue is the parent). +3. Recursively fetch subtasks until all leaves are known. +4. Group into tree: Epic → Story → Task/Subtask. + +**Leaf node** = issue with no subtask children. +**Parent node** = issue that has at least one subtask child. + +## Step 3 — Estimate Leaf Nodes + +For each leaf node: +1. Read: summary, description, acceptance criteria, implementation notes. +2. If scope is unclear, search codebase (`Grep`/`Bash`) under `src/app/` for related files to gauge complexity. +3. Assign estimate using this scale: + +| Size | Criteria | Estimate | +|------|----------|----------| +| Trivial | Style tweak, copy change, 1-file tweak | 30m | +| Small | 1–3 files, single component/service, no unknowns | 1h–2h | +| Medium | 3–6 files, new component + service wiring, some design | 3h–5h | +| Large | 6+ files, cross-feature, non-trivial state/routing | 1d–2d | +| XL | New feature area, major refactor, research spike | 3d–5d | + +4. Record: estimate + one-line reasoning. +5. Skip leaf if it already has `Zeitschätzung` set — note it as pre-estimated. + +## Step 4 — Roll Up for Display + +YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**. + +Compute display-only rolled-up totals: +- Parent total = sum of all descendant leaf estimates (including pre-estimated ones). +- Flag any branch where some leaves are missing estimates (partial roll-up). + +## Step 5 — Show Summary + Confirm + +Display full tree with estimates. Format: + +``` +Epic NCWF-10: Board UI overhaul [4h 30m] ← rolled up + Story NCWF-11: Drag-and-drop pieces [2h 30m] ← rolled up + Task NCWF-12: Add drag directive 1h 30m ← leaf (new) + Task NCWF-13: Add component specs 1h ← leaf (new) + Story NCWF-14: Move-list panel [2h] ← rolled up + Task NCWF-15: Render SAN move list 2h ← leaf (pre-set, skipped) +``` + +Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack. + +If sprint mode, show grand total at bottom: +``` +Sprint total: Xd Yh Zm (N issues, M leaves to update) +``` + +**Use `AskUserQuestion` tool:** +- Does the breakdown look right? +- Any estimates to adjust before writing to YouTrack? + +Incorporate all feedback before proceeding. + +## Step 6 — Write Estimates + +On user approval, write estimates **only to leaf nodes** (bottom-up order): +- For each leaf with a new estimate, call `mcp__youtrack__update_issue` with field `Zeitschätzung` = approved estimate. +- YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`. +- Skip leaves already pre-estimated. + +## Step 7 — Report + +List all updated issues with set estimates. +Show final rolled-up totals per Epic/Story (read back from YouTrack via `mcp__youtrack__get_issue` if needed). +In sprint mode, show total sprint estimate. diff --git a/.claude/commands/fix-defect.md b/.claude/commands/fix-defect.md new file mode 100644 index 0000000..e46441c --- /dev/null +++ b/.claude/commands/fix-defect.md @@ -0,0 +1,143 @@ +# Fix Defect from YouTrack + +Automated defect-fix workflow. Ticket ID: `$ARGUMENTS` + +This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates: +- Build: `npm run build` +- Test: `npm test -- --watch=false --browsers=ChromeHeadless` +- Format: `npx prettier --write .` (check with `npx prettier --check .`) + +## Step 1 — Fetch Ticket + +Call `mcp__youtrack__get_issue` with ID `$ARGUMENTS`. +Extract and display: summary, description, steps to reproduce, Priority, Subsystem. + +## Step 2 — Create Worktree + +Derive branch name from ticket: +- `type` from YouTrack issue type: `bug` → `fix`, `feature`/`task` → `feat`, `refactor` → `refactor`, else `chore` +- `scope` from affected area (kebab-case, omit if unclear) +- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles + +Branch format: `/-` +Example: `fix/NCWF-123-board-flip-resets-selection` + +Call `EnterWorktree` with that branch name. +All subsequent file work happens inside this worktree. + +## Step 3 — Identify Root Cause (read-only) + +1. Run `npm run build` — capture all errors and warnings. +2. Run `npm test -- --watch=false --browsers=ChromeHeadless` — capture all failures. +3. Spawn `cavecrew-investigator` with: ticket description + build/test output → locate root cause (files, line numbers, what's wrong). +4. **If anything is ambiguous (reproduction unclear, scope uncertain, conflicting signals), use `AskUserQuestion` tool to ask — max 4 questions at once.** +5. **Report findings to user. No file writes yet. Wait for acknowledgement before continuing.** + +## Step 3b — Complexity Assessment + Subtasks + +After root cause confirmed, assess scope: + +**Simple** (1–2 files, single concern, < 1 hour estimated): proceed directly to Step 4. + +**Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding. + +To create subtasks: +1. Break fix into discrete, independently-completable tasks (e.g. "Fix selection state in BoardComponent", "Add spec for flip behaviour", "Update GameService move stream"). +2. For each subtask call `mcp__youtrack__create_issue` with: + - `project`: based on subtask content — do **not** inherit from parent. Frontend/UI → `NCWF`; backend code → `NCS`; Kubernetes/pipelines/CI-CD/infrastructure → `NCI`. If ambiguous, ask user. + - `summary`: concise action-oriented title + - `type`: `Task` + - `description`: what to do and why +3. Call `mcp__youtrack__link_issues` to link each subtask to `$ARGUMENTS` with relation `subtask of`. +4. Check if the ticket description or comments mention other issue IDs. For each mentioned ID, suggest a link and confirm with user: + - Fix depends on another fix finishing first → `is blocked by` + - This fix blocks another ticket → `blocks` + - Logically related but independent → `relates to` +5. List created subtask IDs and any additional links to user. + +Then proceed to Step 4, implementing subtasks in order. + +## Step 4 — Fix + +1. Implement fix (use the `general-purpose` agent for non-trivial multi-file changes; inline edits for small ones). +2. Run `npm run build` — must be green. +3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green (add new specs for new behaviour; do not modify existing specs unless requirements changed). +4. Run `npx prettier --write .` — **blocking, foreground only**. Wait for completion before continuing. +5. Run `npx prettier --check .` — **blocking, foreground only** (never `run_in_background`). Wait for exit code 0. Must be green. + - If it fails, fix all issues and re-run until exit code 0. + - **Do NOT proceed to Step 5 until the build, tests and format check all pass.** +If any step fails, iterate until all pass. + +## Step 5 — Review + +Spawn `cavecrew-reviewer` on the full diff. +Display findings grouped by severity. + +## Step 5b — Apply Review Findings + +If the review produced any findings (any severity): +1. Implement all agreed fixes. +2. Run `npm run build` — must be green. +3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green. +4. Run `npx prettier --write .` then `npx prettier --check .` — **blocking, foreground only**. Wait for exit code 0. +5. Re-spawn `cavecrew-reviewer` on the updated diff to confirm all findings are resolved. + +Repeat until review is clean or user explicitly accepts remaining findings. + +## Step 6 — Confirm + Push + +Show summary: ticket, branch, files changed, review findings. +**Use `AskUserQuestion` tool to ask for explicit approval before pushing.** Include any open questions about commit message scope or body if unclear. + +On approval, commit following Conventional Commits: + +``` +(): + + + +Closes $ARGUMENTS +https://knockoutwhist.youtrack.cloud/issue/$ARGUMENTS +``` + +- `type`: same as branch type (`fix`, `feat`, `refactor`, `chore`, etc.) +- `scope`: affected area (`ui`, `components`, `services`, `models`, `core`, `routing`, `pages`, `styles`) +- Subject: imperative mood, no period, lowercase +- Footer `Closes $ARGUMENTS` and ticket URL always present + +Push branch to remote. + +## Step 7 — Comment on Ticket + +After successful push, call `mcp__youtrack__add_issue_comment` on `$ARGUMENTS` with: + +``` +Branch `` pushed. + + + +Files changed: +- +- +``` + +## Step 7b — Additional Links + +After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked: + +| Situation | Relation | +|-----------|---------| +| This fix blocks another open ticket | `blocks` | +| Another ticket must ship first | `is blocked by` | +| Related defect or story | `relates to` | +| Duplicate of another defect | `duplicates` | + +Scan the ticket description and comments for any issue IDs that were mentioned but not yet linked. Suggest those automatically. + +Call `mcp__youtrack__link_issues` for each confirmed link. + +## Step 8 — Cleanup + +Call `ExitWorktree` with `discard_changes: true` to delete the worktree. +(Branch was pushed in step 6 — commits are safe on remote; `discard_changes: true` bypasses the local-ahead guard.) +Report: branch pushed, ticket commented, links created, worktree deleted, done. diff --git a/.claude/commands/implement-feature.md b/.claude/commands/implement-feature.md new file mode 100644 index 0000000..fa2edb1 --- /dev/null +++ b/.claude/commands/implement-feature.md @@ -0,0 +1,159 @@ +# Implement Feature from YouTrack + +Automated feature-implementation workflow. Ticket ID: `$ARGUMENTS` + +This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project = +`NCWF`. Gates: +- Build: `npm run build` +- Test: `npm test -- --watch=false --browsers=ChromeHeadless` +- Format: `npx prettier --write .` (check with `npx prettier --check .`) + +This workflow implements the given ticket **and all of its subtasks**, while +respecting `blocked by` dependencies. Tasks that live in other projects +(`NCS`, `NCI`, or any project other than `NCWF`) are never edited here — they +are collected and reported at the end with a ready-to-run prompt. + +## Step 1 — Fetch Ticket + Build Task Tree + +1. Call `mcp__youtrack__get_issue` with ID `$ARGUMENTS`. +2. Extract and display: summary, description, acceptance criteria, Priority, Subsystem, and the **issue links**. +3. From the links, build the task tree: + - **Subtasks** = issues linked as `subtask of` / `parent for` (children of `$ARGUMENTS`). Recurse: fetch each subtask with `get_issue` and collect its subtasks too. + - **Blocked-by** = for every task in the tree, record issues linked as `is blocked by`. +4. Classify each task by project (the prefix before the dash in the issue ID): + - **In-project** = `NCWF`. + - **Out-of-project** = `NCS`, `NCI`, or any other prefix. These are **never implemented here**. +5. Display the full tree: root, subtasks (nested), and for each its blockers + project tag. + +## Step 2 — Resolve Implementation Order + +1. Filter to **in-project (`NCWF`), not-yet-resolved** tasks only (root + subtasks). Out-of-project tasks are excluded from implementation. +2. Topologically sort by `blocked by`: a task is only implementable once all its in-project blockers are resolved. +3. A task is **blocked** (cannot start) if any blocker is: + - an in-project task that is not yet resolved in this run, **or** + - an out-of-project task (`NCS`/`NCI`/etc.) — these can't be resolved here. +4. Produce two lists: + - **Implementable order** — `NCWF` tasks, dependency-sorted. + - **Blocked tasks** — with the blocker(s) that stop them. +5. **Report both lists to the user.** Wait for acknowledgement before continuing. + +## Step 3 — Create Worktree + +Derive branch name from the root ticket `$ARGUMENTS`: +- `type` from YouTrack issue type: `feature`/`task` → `feat`, `refactor` → `refactor`, `bug` → `fix`, else `chore` +- `scope` from affected area (kebab-case, omit if unclear) +- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles + +Branch format: `/-` +Example: `feat/NCWF-456-add-board-theme-selector` + +Call `EnterWorktree` with that branch name. +All subsequent file work happens inside this worktree. All implementable +tasks (root + subtasks) are implemented on this one branch. + +## Step 4 — Understand Requirements (read-only) + +1. Run `npm run build` — confirm baseline is green. +2. Run `npm test -- --watch=false --browsers=ChromeHeadless` — confirm baseline is green. +3. For the root + each implementable subtask, spawn `cavecrew-investigator` with: that task's description + acceptance criteria → locate affected files under `src/app/`, relevant components/services/models, routes, integration touch-points. +4. **If anything is ambiguous (scope unclear, acceptance criteria missing, design decisions needed), use `AskUserQuestion` tool to ask — max 4 questions at once.** +5. **Report plan to user: per task — what will be added/changed, which files, which areas. No file writes yet. Wait for acknowledgement before continuing.** + +## Step 5 — Implement (per task, in dependency order) + +For each task in the implementable order from Step 2, do the following before moving to the next: + +1. Implement task (use the `general-purpose` agent for non-trivial multi-file changes; inline edits for small ones). +2. Run `npm run build` — must be green. +3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green (add new specs for new behaviour; do not modify existing specs unless requirements changed). +4. Run `npx prettier --write .` — **blocking, foreground only**. Wait for completion before continuing. +5. Run `npx prettier --check .` — **blocking, foreground only** (never `run_in_background`). Wait for exit code 0. Must be green. + - If it fails, fix all issues and re-run until exit code 0. + - **Do NOT proceed to the next task until build, tests and format check all pass.** + +If any step fails, iterate until all pass. Once a task is fully green it counts +as **resolved** for the purpose of unblocking later tasks — re-check Step 2's +blocked list: any task whose blockers are now all resolved becomes implementable. + +## Step 6 — Review + +Spawn `cavecrew-reviewer` on the full diff (covering all implemented tasks). +Display findings grouped by severity. + +## Step 6b — Apply Review Findings + +If the review produced any findings (any severity): +1. Implement all agreed fixes. +2. Run `npm run build` — must be green. +3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green. +4. Run `npx prettier --write .` then `npx prettier --check .` — **blocking, foreground only**. Wait for exit code 0. +5. Re-spawn `cavecrew-reviewer` on the updated diff to confirm all findings are resolved. + +Repeat until review is clean or user explicitly accepts remaining findings. + +## Step 7 — Confirm + Push + +Show summary: root ticket, implemented subtasks, branch, files changed, review findings. +**Use `AskUserQuestion` tool to ask for explicit approval before pushing.** Include any open questions about commit message scope or body if unclear. + +On approval, commit following Conventional Commits: + +``` +(): + + + +Closes $ARGUMENTS + for each implemented subtask> +https://knockoutwhist.youtrack.cloud/issue/$ARGUMENTS +``` + +- `type`: same as branch type (`feat`, `refactor`, `chore`, etc.) +- `scope`: affected area (`ui`, `components`, `services`, `models`, `core`, `routing`, `pages`, `styles`) +- Subject: imperative mood, no period, lowercase +- Footer `Closes ` for the root and every resolved subtask, plus the root ticket URL. + +Push branch to remote. + +## Step 8 — Comment on Tickets + +After successful push, call `mcp__youtrack__add_issue_comment` on `$ARGUMENTS` **and on each implemented subtask** with: + +``` +Branch `` pushed. + + + +Files changed: +- +- +``` + +## Step 9 — Cleanup + +Call `ExitWorktree` with `discard_changes: true` to delete the worktree. +(Branch was pushed in step 7 — commits are safe on remote; `discard_changes: true` bypasses the local-ahead guard.) + +## Step 10 — Report Blocked + Cross-Project Tasks + +Final report to the user, in two sections: + +### Blocked in-project tasks +List any `NCWF` tasks that could **not** be implemented, with the blocker(s) +that stopped them. (These can be re-run with this command once blockers clear.) + +### Cross-project tasks (NCS / NCI / other) +For every out-of-project task discovered in the tree (whether it was a subtask +or a blocker), output one entry: + +``` +- []: + Prompt: /implement-feature +``` + +Where `Prompt` is a short, copy-pasteable instruction to implement that task in +its own project — e.g. the ticket ID plus a one-line description of what the +other project needs to do so this project's blocked tasks can proceed. + +End with: branch pushed, tickets commented, worktree deleted, plus the counts of +implemented / blocked / cross-project tasks. diff --git a/.claude/commands/split-story.md b/.claude/commands/split-story.md new file mode 100644 index 0000000..40bcf1e --- /dev/null +++ b/.claude/commands/split-story.md @@ -0,0 +1,111 @@ +# Split Story into Subtasks in YouTrack + +Split a user story into smaller, implementable subtasks. Story ID: `$ARGUMENTS` + +This is the **NowChess-Frontend** repo. Frontend subtasks route to `NCWF`. + +## Step 1 — Fetch Story + +Call `mcp__youtrack__get_issue` with ID `$ARGUMENTS`. +Extract and display: summary, description, acceptance criteria, implementation notes. + +## Step 2 — Research (if needed) + +If the story involves unfamiliar UI flows or technical constraints: +- Search repo for relevant code under `src/app/` (`Grep`/`Bash`). +- Use `WebSearch` for Angular APIs, external standards or protocols. +- Do NOT guess. Surface findings before proposing splits. + +## Step 3 — Propose Split + +Analyse the story and propose a set of subtasks. Rules: +- Each subtask = one unit of work, completable independently or in sequence. +- No subtask should exceed ~2 days of work. +- Name subtasks in imperative mood (e.g. "Add board theme service", "Render theme picker component"). +- Cover the full scope of the parent story — no gaps. + +Show proposed subtask list to user (titles only) and ask: +**Use `AskUserQuestion` tool:** +- Does the split look right? +- Any subtasks to add, remove, or merge? +- Should any subtask be assigned to a specific person? + +Incorporate feedback. Repeat until user approves the list. + +## Step 4 — Draft Each Subtask + +For each approved subtask, compose description using this template: + +``` +[Brief description of what needs to be done for this subtask.] + + +Steps / Tasks + +- Task 1 +- Task 2 +- Task 3 + + +Definition of Done + +What must be true for this subtask to be considered complete: + +- Code implemented +- Specs passing +- Reviewed and merged +``` + +Rules: +- Steps/Tasks: concrete, ordered where order matters. +- Definition of Done: adjust per subtask — not all subtasks need the same criteria (e.g. a research spike has different DoD than an implementation task). +- Keep description short — one paragraph max. + +## Step 5 — Determine Project per Subtask + +Assign each subtask's project based on its content — do **not** inherit blindly from parent: + +- Frontend code (UI, UX, web app, components, services) → `NCWF` +- Backend code (game engine, bots, API, services, coordinator) → `NCS` +- Kubernetes, pipelines, CI/CD, DB setup, infrastructure → `NCI` + +If a subtask's project is ambiguous, ask the user before creating it. + +## Step 6 — Create Subtasks + +For each subtask call `mcp__youtrack__create_issue` with: +- `project`: from Step 5 +- `summary`: subtask title (≤72 chars, sentence case) +- `description`: full formatted description from Step 4 (Markdown) +- `type`: `Task` + +Then call `mcp__youtrack__link_issues` to link each created subtask to `$ARGUMENTS` with relation `subtask of`. + +## Step 6b — Inter-Subtask Links + +If subtasks must be done in sequence (one depends on output of another), add ordering links: +- For each dependency pair call `mcp__youtrack__link_issues` with relation `is blocked by` (subtask B is blocked by subtask A). + +Ask the user to confirm sequencing before adding these links: + +> Do any subtasks have ordering dependencies? (e.g. "Add theme service must come before Render theme picker") + +## Step 6c — External Links + +Scan `$ARGUMENTS` description and implementation notes for any referenced issue IDs not already linked. For each: + +| Situation | Relation | +|-----------|---------| +| Parent story blocks another epic/story | `blocks` | +| Story depends on another epic completing | `is blocked by` | +| Related story in same domain | `relates to` | +| This story duplicates or supersedes | `duplicates` | + +Suggest links to the user and call `mcp__youtrack__link_issues` on confirmation. + +## Step 7 — Report + +List all created subtask IDs and summaries. +List all links created (subtask-of, blocking chains, external). +Display parent story link. +Ask if any subtask needs further splitting. diff --git a/.gitignore b/.gitignore index 0313feb..45e2f1f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ __screenshots__/ # System files .DS_Store Thumbs.db + +# Claude Code +/.claude/settings.local.json +/.claude/worktrees/ From 16079dd63af02cea7e11ef69b3b1610d22ce7fec Mon Sep 17 00:00:00 2001 From: TeamCity Date: Sat, 6 Jun 2026 12:36:12 +0000 Subject: [PATCH 5/7] ci: bump version to v0.2.7 --- CHANGELOG.md | 1 + versions.env | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 679275f..26a4351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,3 +50,4 @@ ### Bug Fixes * NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f)) +## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.6...0.0.0) (2026-06-06) diff --git a/versions.env b/versions.env index 60fb3b7..6c04775 100644 --- a/versions.env +++ b/versions.env @@ -1,3 +1,3 @@ MAJOR=0 MINOR=2 -PATCH=6 +PATCH=7 From a62073511f2ac912ceb0f6b4730bef37545dd8ea Mon Sep 17 00:00:00 2001 From: LQ63 Date: Wed, 10 Jun 2026 11:13:37 +0200 Subject: [PATCH 6/7] fix: route play-vs-bot to /vs-bot endpoint POST /api/board/game is @InternalOnly and returns 401 for browser clients. Switch to /api/board/game/vs-bot which is @PermitAll and notifies the official-bots service to play the bot side automatically. Co-Authored-By: Claude Sonnet 4.6 --- src/app/services/game-api.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/services/game-api.service.ts b/src/app/services/game-api.service.ts index 726fd64..cf7aef6 100644 --- a/src/app/services/game-api.service.ts +++ b/src/app/services/game-api.service.ts @@ -40,7 +40,7 @@ export class GameApiService { ? { white: playerInfo, black: botInfo } : { white: botInfo, black: playerInfo }; - return this.http.post(`${this.apiBase}${this.apiPath}`, payload); + return this.http.post(`${this.apiBase}${this.apiPath}/vs-bot`, payload); } getGame(gameId: string): Observable { From ae952d70b07f6085b6fecee69da073f40b598747 Mon Sep 17 00:00:00 2001 From: TeamCity Date: Wed, 10 Jun 2026 09:18:51 +0000 Subject: [PATCH 7/7] ci: bump version to v0.2.8 --- CHANGELOG.md | 5 +++++ versions.env | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26a4351..643029b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,3 +51,8 @@ * NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f)) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.6...0.0.0) (2026-06-06) +## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.7...0.0.0) (2026-06-10) + +### Bug Fixes + +* route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea)) diff --git a/versions.env b/versions.env index 6c04775..4c39cf4 100644 --- a/versions.env +++ b/versions.env @@ -1,3 +1,3 @@ MAJOR=0 MINOR=2 -PATCH=7 +PATCH=8