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' };