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/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/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/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..4f1a966 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); + 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' };