Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f1339809f | |||
| 0621968c3c | |||
| bd6d023513 | |||
| 2229cfd00a | |||
| 00b51b57b4 | |||
| 1dabd88c62 | |||
| ab2c641130 | |||
| 412591dfe0 |
@@ -41,3 +41,6 @@ Thumbs.db
|
||||
# Claude Code
|
||||
/.claude/settings.local.json
|
||||
/.claude/worktrees/
|
||||
|
||||
# Local clone of the tournament server repo (not tracked)
|
||||
/tournament-server/
|
||||
|
||||
@@ -86,3 +86,20 @@
|
||||
### Bug Fixes
|
||||
|
||||
* **tournaments:** load both user bots and official bots in join dialog ([5b5fd6f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/5b5fd6f027b4aedb951a802725fcd929d514c359))
|
||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.4...0.0.0) (2026-06-21)
|
||||
|
||||
### Features
|
||||
|
||||
* **tournaments:** remove external server add/remove UI ([412591d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/412591dfe0119dbec84c3783cd94590810884580))
|
||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.5.0...0.0.0) (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* NCWF-10 streaming endpoint ([#14](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/14)) ([1dabd88](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1dabd88c6286a7b01d6fe8527aec864b24e21cca))
|
||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.6.0...0.0.0) (2026-06-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* api streaming issues ([0621968](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/0621968c3ceddebe01e9c363bda345b5dcccfbbf))
|
||||
* api url ([2229cfd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2229cfd00a7d16daa6a9544c8940e792c4362dfb))
|
||||
* streaming issues ([bd6d023](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd6d02351336ed6adf66244979c6d959f47e318b))
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { TournamentWatchComponent } from './pages/tournament-watch/tournament-watch.component';
|
||||
import { BotsComponent } from './pages/bots/bots.component';
|
||||
import { AnalysisComponent } from './pages/analysis/analysis.component';
|
||||
|
||||
@@ -14,6 +15,7 @@ export const routes: Routes = [
|
||||
{ path: 'games', component: GamesComponent },
|
||||
{ path: 'challenges', component: ChallengesComponent },
|
||||
{ path: 'tournaments', component: TournamentsComponent },
|
||||
{ path: 'tournament/:id/game/:gameId', component: TournamentWatchComponent },
|
||||
{ path: 'bots', component: BotsComponent },
|
||||
{ path: 'analysis', component: AnalysisComponent },
|
||||
{ path: 'game/:gameId', component: GameComponent },
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<!-- Center links — only when logged in -->
|
||||
@if (currentUser) {
|
||||
<div class="nc-links">
|
||||
<button type="button" class="nc-link">
|
||||
<button type="button" class="nc-link" (click)="goToTournaments()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
|
||||
@@ -51,16 +51,60 @@ export interface TournamentList {
|
||||
finished: Tournament[];
|
||||
}
|
||||
|
||||
export interface TournamentMatch {
|
||||
gameId: string;
|
||||
whiteId: string;
|
||||
winner?: 'white' | 'black' | 'draw' | null;
|
||||
status?: GameStatus;
|
||||
}
|
||||
|
||||
export interface TournamentPairing {
|
||||
id: string;
|
||||
round: number;
|
||||
id?: string;
|
||||
round?: number;
|
||||
white: TournamentBotRef | null;
|
||||
black: TournamentBotRef;
|
||||
gameId: string | null;
|
||||
winner: 'white' | 'black' | 'draw' | null;
|
||||
matches: TournamentMatch[];
|
||||
winner?: 'white' | 'black' | 'draw' | null;
|
||||
}
|
||||
|
||||
export interface RoundPairings {
|
||||
round: number;
|
||||
pairings: TournamentPairing[];
|
||||
}
|
||||
|
||||
export type TournamentStreamEvent =
|
||||
| { type: 'tournamentStarted' }
|
||||
| { type: 'roundStarted'; round: number }
|
||||
| { type: 'gameStart'; round: number; gameId: string; color: 'white' | 'black' }
|
||||
| { type: 'roundFinished'; round: number }
|
||||
| { type: 'tournamentFinished'; winner: TournamentBotRef | null }
|
||||
| { type: 'heartbeat' };
|
||||
|
||||
export interface GameClock {
|
||||
whiteTime: number;
|
||||
blackTime: number;
|
||||
increment: number;
|
||||
}
|
||||
|
||||
export type GameStatus = 'pending' | 'ongoing' | 'checkmate' | 'stalemate' | 'draw' | 'resigned' | 'timeout';
|
||||
|
||||
export interface GameStateSnapshot {
|
||||
id?: string;
|
||||
tournamentId?: string;
|
||||
round?: number;
|
||||
white?: TournamentBotRef;
|
||||
black?: TournamentBotRef;
|
||||
moves: string;
|
||||
fen: string;
|
||||
status: GameStatus;
|
||||
turn: 'white' | 'black';
|
||||
winner: 'white' | 'black' | null;
|
||||
clock?: GameClock;
|
||||
startPosition?: string;
|
||||
}
|
||||
|
||||
export type GameStreamEvent =
|
||||
| ({ type: 'gameState' } & GameStateSnapshot)
|
||||
| { type: 'move'; uci: string; fen: string; turn: 'white' | 'black'; clock?: GameClock }
|
||||
| { type: 'gameEnd'; winner: 'white' | 'black' | null; status: GameStatus }
|
||||
| { type: 'heartbeat' };
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
.watch-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
color: var(--text-primary, #e6e6e6);
|
||||
}
|
||||
|
||||
.watch-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--text-secondary, #9aa3b2);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
.back-link:hover { color: var(--accent, #d4b572); }
|
||||
|
||||
.watch-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vs { color: var(--text-secondary, #9aa3b2); font-weight: 400; }
|
||||
.round-tag {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(212, 181, 114, 0.15);
|
||||
color: var(--accent, #d4b572);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.watch-error {
|
||||
padding: 12px 16px;
|
||||
background: rgba(220, 70, 70, 0.12);
|
||||
border: 1px solid rgba(220, 70, 70, 0.3);
|
||||
color: #ff8a8a;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.watch-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary, #9aa3b2);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pulse {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--accent, #d4b572);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.watch-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 24px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.watch-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.board-wrap { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.clock {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface, #1b1f27);
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.clock.active {
|
||||
border-color: var(--accent, #d4b572);
|
||||
box-shadow: 0 0 0 1px rgba(212, 181, 114, 0.3);
|
||||
}
|
||||
.clock-label { font-weight: 500; }
|
||||
.clock-time { font-size: 20px; font-weight: 600; }
|
||||
|
||||
.watch-side { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.status-card {
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface, #1b1f27);
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
}
|
||||
.status-line { font-weight: 600; }
|
||||
.status-sub { font-size: 12px; color: var(--text-secondary, #9aa3b2); margin-top: 4px; }
|
||||
|
||||
.moves-card {
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface, #1b1f27);
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.moves-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary, #9aa3b2);
|
||||
}
|
||||
.ply-count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #d4b572);
|
||||
}
|
||||
.moves-empty { color: var(--text-secondary, #9aa3b2); font-size: 13px; }
|
||||
.moves-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
.moves-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 1fr;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.moves-list li:nth-child(odd) { background: rgba(255, 255, 255, 0.02); }
|
||||
.move-no { color: var(--text-secondary, #9aa3b2); }
|
||||
@@ -0,0 +1,63 @@
|
||||
<div class="watch-page">
|
||||
<header class="watch-head">
|
||||
<a class="back-link" routerLink="/tournaments">← Tournaments</a>
|
||||
<div class="watch-title">
|
||||
@if (snapshot?.white && snapshot?.black) {
|
||||
<span class="player">{{ snapshot!.white!.name }}</span>
|
||||
<span class="vs">vs</span>
|
||||
<span class="player">{{ snapshot!.black!.name }}</span>
|
||||
@if (snapshot?.round) {
|
||||
<span class="round-tag">Round {{ snapshot!.round }}</span>
|
||||
}
|
||||
} @else {
|
||||
<span class="player">Game {{ gameId }}</span>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error) {
|
||||
<div class="watch-error">{{ error }}</div>
|
||||
} @else if (connecting) {
|
||||
<div class="watch-loading"><span class="pulse"></span>Connecting to stream…</div>
|
||||
}
|
||||
|
||||
<div class="watch-layout">
|
||||
<div class="board-wrap">
|
||||
<div class="clock clock-top" [class.active]="status === 'ongoing' && turn === 'black'">
|
||||
<span class="clock-label">{{ snapshot?.black?.name ?? 'Black' }}</span>
|
||||
<span class="clock-time">{{ formatTime(clock?.blackTime) }}</span>
|
||||
</div>
|
||||
<app-chess-board [fen]="fen"></app-chess-board>
|
||||
<div class="clock clock-bot" [class.active]="status === 'ongoing' && turn === 'white'">
|
||||
<span class="clock-label">{{ snapshot?.white?.name ?? 'White' }}</span>
|
||||
<span class="clock-time">{{ formatTime(clock?.whiteTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="watch-side">
|
||||
<div class="status-card" [attr.data-status]="status">
|
||||
<div class="status-line">{{ statusLabel() }}</div>
|
||||
@if (clock?.increment) {
|
||||
<div class="status-sub">+{{ clock!.increment }}s increment</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="moves-card">
|
||||
<h3 class="moves-heading">Moves <span class="ply-count">{{ moves.length }}</span></h3>
|
||||
@if (moves.length === 0) {
|
||||
<p class="moves-empty">No moves yet.</p>
|
||||
} @else {
|
||||
<ol class="moves-list">
|
||||
@for (p of movePairs(); track p.n) {
|
||||
<li>
|
||||
<span class="move-no">{{ p.n }}.</span>
|
||||
<span class="move-uci">{{ p.white }}</span>
|
||||
<span class="move-uci">{{ p.black ?? '' }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
|
||||
import { TournamentStreamService } from '../../services/tournament-stream.service';
|
||||
import { GameClock, GameStateSnapshot, GameStreamEvent, GameStatus } from '../../models/tournament.models';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
const INITIAL_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tournament-watch',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, ChessBoardComponent],
|
||||
templateUrl: './tournament-watch.component.html',
|
||||
styleUrl: './tournament-watch.component.css'
|
||||
})
|
||||
export class TournamentWatchComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly stream = inject(TournamentStreamService);
|
||||
|
||||
tournamentId = '';
|
||||
gameId = '';
|
||||
serverUrl = '';
|
||||
|
||||
fen = INITIAL_FEN;
|
||||
turn: 'white' | 'black' = 'white';
|
||||
status: GameStatus = 'pending';
|
||||
winner: 'white' | 'black' | null = null;
|
||||
clock: GameClock | null = null;
|
||||
moves: string[] = [];
|
||||
snapshot: GameStateSnapshot | null = null;
|
||||
|
||||
connecting = true;
|
||||
error: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.tournamentId = this.route.snapshot.paramMap.get('id') ?? '';
|
||||
this.gameId = this.route.snapshot.paramMap.get('gameId') ?? '';
|
||||
this.serverUrl = this.route.snapshot.queryParamMap.get('server') ?? environment.tournamentServerUrl ?? '';
|
||||
if (!this.tournamentId || !this.gameId) {
|
||||
this.error = 'Missing tournament or game id.';
|
||||
this.connecting = false;
|
||||
return;
|
||||
}
|
||||
if (!this.serverUrl) {
|
||||
this.error = 'Missing tournament server URL.';
|
||||
this.connecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.stream.streamGame(this.serverUrl, this.tournamentId, this.gameId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: ev => this.apply(ev),
|
||||
error: err => {
|
||||
this.connecting = false;
|
||||
this.error = (err as Error).message ?? 'Stream failed.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
movePairs(): { n: number; white: string; black: string | null }[] {
|
||||
const pairs: { n: number; white: string; black: string | null }[] = [];
|
||||
for (let i = 0; i < this.moves.length; i += 2) {
|
||||
pairs.push({ n: i / 2 + 1, white: this.moves[i], black: this.moves[i + 1] ?? null });
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
formatTime(seconds: number | undefined): string {
|
||||
if (seconds === undefined || seconds === null) return '—';
|
||||
const s = Math.max(0, Math.floor(seconds));
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${r.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
statusLabel(): string {
|
||||
switch (this.status) {
|
||||
case 'pending': return 'Waiting to start';
|
||||
case 'ongoing': return `${this.turn === 'white' ? 'White' : 'Black'} to move`;
|
||||
case 'checkmate': return `Checkmate — ${this.winner ?? '—'} wins`;
|
||||
case 'stalemate': return 'Stalemate';
|
||||
case 'draw': return 'Draw';
|
||||
case 'resigned': return `Resigned — ${this.winner ?? '—'} wins`;
|
||||
case 'timeout': return `Timeout — ${this.winner ?? '—'} wins`;
|
||||
}
|
||||
}
|
||||
|
||||
private apply(ev: GameStreamEvent): void {
|
||||
this.connecting = false;
|
||||
switch (ev.type) {
|
||||
case 'gameState':
|
||||
this.snapshot = ev;
|
||||
this.fen = ev.fen;
|
||||
this.turn = ev.turn;
|
||||
this.status = ev.status;
|
||||
this.winner = ev.winner;
|
||||
this.clock = ev.clock ?? null;
|
||||
this.moves = ev.moves ? ev.moves.split(/\s+/).filter(Boolean) : [];
|
||||
return;
|
||||
case 'move':
|
||||
this.fen = ev.fen;
|
||||
this.turn = ev.turn;
|
||||
if (ev.clock) this.clock = ev.clock;
|
||||
this.moves = [...this.moves, ev.uci];
|
||||
return;
|
||||
case 'gameEnd':
|
||||
this.status = ev.status;
|
||||
this.winner = ev.winner;
|
||||
return;
|
||||
case 'heartbeat':
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,9 +158,10 @@
|
||||
<div class="state-msg small"><span class="pulse"></span>Loading…</div>
|
||||
} @else if (pairings && pairings.pairings.length > 0) {
|
||||
<div class="pairings-list">
|
||||
@for (p of pairings.pairings; track p.id) {
|
||||
<div class="pairing-row" [class.is-watchable]="!!p.gameId"
|
||||
(click)="p.gameId && watchGame(p.gameId)">
|
||||
@for (p of pairings.pairings; track pairingKey(p)) {
|
||||
@let gid = firstGameId(p);
|
||||
<div class="pairing-row" [class.is-watchable]="!!gid"
|
||||
(click)="gid && watchGame(gid)">
|
||||
<span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span>
|
||||
<span class="pairing-vs">vs</span>
|
||||
<span class="pairing-black">{{ p.black.name }}</span>
|
||||
@@ -168,7 +169,7 @@
|
||||
<span class="pairing-result" [class]="'result-' + p.winner">
|
||||
{{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '1–0' : '0–1' }}
|
||||
</span>
|
||||
} @else if (p.gameId) {
|
||||
} @else if (gid) {
|
||||
<span class="pairing-ongoing">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
@@ -271,7 +272,7 @@
|
||||
@if (serversLoading) {
|
||||
<div class="dialog-loading"><span class="pulse"></span>Loading servers…</div>
|
||||
} @else if (servers.length === 0) {
|
||||
<p class="join-empty">No external servers registered yet.</p>
|
||||
<p class="join-empty">No external servers registered.</p>
|
||||
} @else {
|
||||
<div class="servers-list">
|
||||
@for (s of servers; track s.id) {
|
||||
@@ -280,49 +281,14 @@
|
||||
<span class="server-label">{{ s.label }}</span>
|
||||
<span class="server-url">{{ s.url }}</span>
|
||||
</div>
|
||||
<button type="button" class="server-remove-btn"
|
||||
[disabled]="removingServerId === s.id"
|
||||
(click)="removeServer(s.id)"
|
||||
title="Remove server">
|
||||
@if (removingServerId === s.id) { … } @else {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
<path d="M10 11v6"/><path d="M14 11v6"/>
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="server-add-form">
|
||||
<h4 class="server-add-heading">Add server</h4>
|
||||
<div class="dialog-field">
|
||||
<label class="dialog-label">Label</label>
|
||||
<input type="text" class="dialog-input" [(ngModel)]="newServerLabel"
|
||||
placeholder="e.g. Local Dev Server" />
|
||||
</div>
|
||||
<div class="dialog-field">
|
||||
<label class="dialog-label">URL</label>
|
||||
<input type="url" class="dialog-input" [(ngModel)]="newServerUrl"
|
||||
placeholder="http://host:8089" />
|
||||
</div>
|
||||
@if (addServerError) {
|
||||
<div class="dialog-error">{{ addServerError }}</div>
|
||||
}
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn-ghost" (click)="closeServersDialog()">Close</button>
|
||||
<button type="button" class="btn-primary"
|
||||
[disabled]="addingServer || !newServerLabel.trim() || !newServerUrl.trim()"
|
||||
(click)="addServer()">
|
||||
{{ addingServer ? 'Adding…' : 'Add' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn-ghost" (click)="closeServersDialog()">Close</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ import { BotService } from '../../services/bot.service';
|
||||
import { OfficialBotService } from '../../services/official-bot.service';
|
||||
import { TournamentServerService, ExternalTournamentServer } from '../../services/tournament-server.service';
|
||||
import { Bot } from '../../models/bot.models';
|
||||
import { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models';
|
||||
import { Tournament, TournamentResult, RoundPairings, TournamentPairing } from '../../models/tournament.models';
|
||||
import { CurrentUser } from '../../models/auth.models';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
type StatusTab = 'started' | 'created' | 'finished';
|
||||
|
||||
@@ -71,17 +72,15 @@ export class TournamentsComponent implements OnInit {
|
||||
showServersDialog = false;
|
||||
servers: ExternalTournamentServer[] = [];
|
||||
serversLoading = false;
|
||||
newServerLabel = '';
|
||||
newServerUrl = '';
|
||||
addingServer = false;
|
||||
addServerError: string | null = null;
|
||||
removingServerId: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.authService.currentUser$
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(u => { this.currentUser = u; });
|
||||
this.loadTournaments();
|
||||
this.tournamentServerService.list()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({ next: res => { this.servers = res.servers; }, error: () => {} });
|
||||
}
|
||||
|
||||
openCreateDialog(): void {
|
||||
@@ -168,8 +167,27 @@ export class TournamentsComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
firstGameId(p: TournamentPairing): string | null {
|
||||
return p.matches?.[0]?.gameId ?? null;
|
||||
}
|
||||
|
||||
pairingKey(p: TournamentPairing): string {
|
||||
const w = p.white?.id ?? 'bye';
|
||||
const b = p.black?.id ?? 'bye';
|
||||
return `${w}-${b}-${this.firstGameId(p) ?? ''}`;
|
||||
}
|
||||
|
||||
watchGame(gameId: string): void {
|
||||
void this.router.navigate(['/game', gameId]);
|
||||
const tid = this.selectedTournament?.id;
|
||||
if (!tid) return;
|
||||
const server = this.servers[0]?.url || environment.tournamentServerUrl;
|
||||
if (!server) {
|
||||
this.joinError = 'No tournament server configured. Cannot open stream.';
|
||||
return;
|
||||
}
|
||||
void this.router.navigate(['/tournament', tid, 'game', gameId], {
|
||||
queryParams: { server }
|
||||
});
|
||||
}
|
||||
|
||||
openJoinDialog(event: MouseEvent, tournamentId: string): void {
|
||||
@@ -248,9 +266,6 @@ export class TournamentsComponent implements OnInit {
|
||||
}
|
||||
|
||||
openServersDialog(): void {
|
||||
this.newServerLabel = '';
|
||||
this.newServerUrl = '';
|
||||
this.addServerError = null;
|
||||
this.showServersDialog = true;
|
||||
this.serversLoading = true;
|
||||
this.tournamentServerService.list()
|
||||
@@ -265,40 +280,6 @@ export class TournamentsComponent implements OnInit {
|
||||
this.showServersDialog = false;
|
||||
}
|
||||
|
||||
addServer(): void {
|
||||
const label = this.newServerLabel.trim();
|
||||
const url = this.newServerUrl.trim();
|
||||
if (!label || !url || this.addingServer) return;
|
||||
this.addingServer = true;
|
||||
this.addServerError = null;
|
||||
this.tournamentServerService.register(label, url).subscribe({
|
||||
next: server => {
|
||||
this.addingServer = false;
|
||||
this.servers = [...this.servers, server];
|
||||
this.newServerLabel = '';
|
||||
this.newServerUrl = '';
|
||||
this.loadTournaments();
|
||||
},
|
||||
error: err => {
|
||||
this.addingServer = false;
|
||||
this.addServerError = err.error?.error ?? 'Failed to add server.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeServer(id: string): void {
|
||||
if (this.removingServerId) return;
|
||||
this.removingServerId = id;
|
||||
this.tournamentServerService.remove(id).subscribe({
|
||||
next: () => {
|
||||
this.removingServerId = null;
|
||||
this.servers = this.servers.filter(s => s.id !== id);
|
||||
this.loadTournaments();
|
||||
},
|
||||
error: () => { this.removingServerId = null; }
|
||||
});
|
||||
}
|
||||
|
||||
private loadTournaments(): void {
|
||||
this.tournamentService.list()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
|
||||
@@ -20,12 +20,4 @@ export class TournamentServerService {
|
||||
list(): Observable<ExternalTournamentServerList> {
|
||||
return this.http.get<ExternalTournamentServerList>(this.base);
|
||||
}
|
||||
|
||||
register(label: string, url: string): Observable<ExternalTournamentServer> {
|
||||
return this.http.post<ExternalTournamentServer>(this.base, { label, url });
|
||||
}
|
||||
|
||||
remove(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { GameStreamEvent, TournamentStreamEvent } from '../models/tournament.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TournamentStreamService {
|
||||
streamTournament(serverUrl: string, tournamentId: string): Observable<TournamentStreamEvent> {
|
||||
return this.ndjson<TournamentStreamEvent>(
|
||||
this.url(serverUrl, `/api/tournament/${tournamentId}/stream`)
|
||||
);
|
||||
}
|
||||
|
||||
streamGame(serverUrl: string, tournamentId: string, gameId: string): Observable<GameStreamEvent> {
|
||||
return this.ndjson<GameStreamEvent>(
|
||||
this.url(serverUrl, `/api/tournament/${tournamentId}/game/${gameId}/stream`)
|
||||
);
|
||||
}
|
||||
|
||||
private url(base: string, path: string): string {
|
||||
if (!base) return path;
|
||||
return `${base.replace(/\/+$/, '')}${path}`;
|
||||
}
|
||||
|
||||
private ndjson<T>(url: string): Observable<T> {
|
||||
return new Observable<T>(subscriber => {
|
||||
const controller = new AbortController();
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = { Accept: 'application/x-ndjson' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url, { headers, signal: controller.signal });
|
||||
if (!res.ok || !res.body) {
|
||||
subscriber.error(new Error(`Stream failed: ${res.status} ${res.statusText}`));
|
||||
return;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, nl).trim();
|
||||
buffer = buffer.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
subscriber.next(JSON.parse(line) as T);
|
||||
} catch {
|
||||
// Drop malformed lines silently — server may emit partial keep-alives.
|
||||
}
|
||||
}
|
||||
}
|
||||
subscriber.complete();
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'AbortError') subscriber.error(err);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export interface CreateTournamentForm {
|
||||
name: string;
|
||||
@@ -14,7 +15,7 @@ export interface CreateTournamentForm {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TournamentService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly base = '/api/tournament';
|
||||
private readonly base = `${(environment.tournamentServerUrl ?? '').replace(/\/+$/, '')}/api/tournament`;
|
||||
|
||||
list(): Observable<TournamentList> {
|
||||
return this.http.get<TournamentList>(this.base);
|
||||
|
||||
@@ -4,5 +4,6 @@ export const environment = {
|
||||
accountServiceUrl: '',
|
||||
wsBaseUrl: 'ws://localhost:8084',
|
||||
userWsBaseUrl: 'ws://localhost:8084',
|
||||
apiPath: '/api/board/game'
|
||||
apiPath: '/api/board/game',
|
||||
tournamentServerUrl: 'http://141.37.123.132:8086'
|
||||
};
|
||||
|
||||
@@ -8,5 +8,6 @@ export const environment = {
|
||||
accountServiceUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
|
||||
wsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
|
||||
userWsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
|
||||
apiPath: '/api/board/game'
|
||||
apiPath: '/api/board/game',
|
||||
tournamentServerUrl: 'http://141.37.123.132:8086'
|
||||
};
|
||||
|
||||
@@ -8,5 +8,6 @@ export const environment = {
|
||||
accountServiceUrl: runtimeConfig.apiUrl || '',
|
||||
wsBaseUrl: runtimeConfig.wsUrl,
|
||||
userWsBaseUrl: runtimeConfig.wsUrl,
|
||||
apiPath: '/api/board/game'
|
||||
apiPath: '/api/board/game',
|
||||
tournamentServerUrl: 'http://141.37.123.132:8086'
|
||||
};
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=4
|
||||
PATCH=4
|
||||
MINOR=6
|
||||
PATCH=1
|
||||
|
||||
Reference in New Issue
Block a user