Compare commits
2 Commits
0.6.3
..
aeca003191
| Author | SHA1 | Date | |
|---|---|---|---|
| aeca003191 | |||
| 8728817231 |
@@ -41,6 +41,3 @@ Thumbs.db
|
|||||||
# Claude Code
|
# Claude Code
|
||||||
/.claude/settings.local.json
|
/.claude/settings.local.json
|
||||||
/.claude/worktrees/
|
/.claude/worktrees/
|
||||||
|
|
||||||
# Local clone of the tournament server repo (not tracked)
|
|
||||||
/tournament-server/
|
|
||||||
|
|||||||
@@ -91,25 +91,3 @@
|
|||||||
### Features
|
### Features
|
||||||
|
|
||||||
* **tournaments:** remove external server add/remove UI ([412591d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/412591dfe0119dbec84c3783cd94590810884580))
|
* **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))
|
|
||||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.6.1...0.0.0) (2026-06-23)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* jwt token issue ([147d7f0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/147d7f0d2ca7a77bb80eb4b73b8d60b00ad2f708))
|
|
||||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.6.2...0.0.0) (2026-06-23)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* route tournament calls through backend gateway ([890c3fc](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/890c3fcecccec89e643180725e2a601f84fa5d99))
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<!-- Center links — only when logged in -->
|
<!-- Center links — only when logged in -->
|
||||||
@if (currentUser) {
|
@if (currentUser) {
|
||||||
<div class="nc-links">
|
<div class="nc-links">
|
||||||
<button type="button" class="nc-link" (click)="goToTournaments()">
|
<button type="button" class="nc-link">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
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" />
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
|
|||||||
@@ -51,20 +51,13 @@ export interface TournamentList {
|
|||||||
finished: Tournament[];
|
finished: Tournament[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TournamentMatch {
|
|
||||||
gameId: string;
|
|
||||||
whiteId: string;
|
|
||||||
winner?: 'white' | 'black' | 'draw' | null;
|
|
||||||
status?: GameStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TournamentPairing {
|
export interface TournamentPairing {
|
||||||
id?: string;
|
id: string;
|
||||||
round?: number;
|
round: number;
|
||||||
white: TournamentBotRef | null;
|
white: TournamentBotRef | null;
|
||||||
black: TournamentBotRef;
|
black: TournamentBotRef;
|
||||||
matches: TournamentMatch[];
|
gameId: string | null;
|
||||||
winner?: 'white' | 'black' | 'draw' | null;
|
winner: 'white' | 'black' | 'draw' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoundPairings {
|
export interface RoundPairings {
|
||||||
|
|||||||
@@ -158,10 +158,9 @@
|
|||||||
<div class="state-msg small"><span class="pulse"></span>Loading…</div>
|
<div class="state-msg small"><span class="pulse"></span>Loading…</div>
|
||||||
} @else if (pairings && pairings.pairings.length > 0) {
|
} @else if (pairings && pairings.pairings.length > 0) {
|
||||||
<div class="pairings-list">
|
<div class="pairings-list">
|
||||||
@for (p of pairings.pairings; track pairingKey(p)) {
|
@for (p of pairings.pairings; track p.id) {
|
||||||
@let gid = firstGameId(p);
|
<div class="pairing-row" [class.is-watchable]="!!p.gameId"
|
||||||
<div class="pairing-row" [class.is-watchable]="!!gid"
|
(click)="p.gameId && watchGame(p.gameId)">
|
||||||
(click)="gid && watchGame(gid)">
|
|
||||||
<span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span>
|
<span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span>
|
||||||
<span class="pairing-vs">vs</span>
|
<span class="pairing-vs">vs</span>
|
||||||
<span class="pairing-black">{{ p.black.name }}</span>
|
<span class="pairing-black">{{ p.black.name }}</span>
|
||||||
@@ -169,7 +168,7 @@
|
|||||||
<span class="pairing-result" [class]="'result-' + p.winner">
|
<span class="pairing-result" [class]="'result-' + p.winner">
|
||||||
{{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '1–0' : '0–1' }}
|
{{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '1–0' : '0–1' }}
|
||||||
</span>
|
</span>
|
||||||
} @else if (gid) {
|
} @else if (p.gameId) {
|
||||||
<span class="pairing-ongoing">
|
<span class="pairing-ongoing">
|
||||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<circle cx="12" cy="12" r="10"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { BotService } from '../../services/bot.service';
|
|||||||
import { OfficialBotService } from '../../services/official-bot.service';
|
import { OfficialBotService } from '../../services/official-bot.service';
|
||||||
import { TournamentServerService, ExternalTournamentServer } from '../../services/tournament-server.service';
|
import { TournamentServerService, ExternalTournamentServer } from '../../services/tournament-server.service';
|
||||||
import { Bot } from '../../models/bot.models';
|
import { Bot } from '../../models/bot.models';
|
||||||
import { Tournament, TournamentResult, RoundPairings, TournamentPairing } from '../../models/tournament.models';
|
import { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models';
|
||||||
import { CurrentUser } from '../../models/auth.models';
|
import { CurrentUser } from '../../models/auth.models';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
@@ -167,16 +167,6 @@ 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 {
|
watchGame(gameId: string): void {
|
||||||
const tid = this.selectedTournament?.id;
|
const tid = this.selectedTournament?.id;
|
||||||
if (!tid) return;
|
if (!tid) return;
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
|
|
||||||
interface RegisterResponse {
|
|
||||||
id: string;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class TournamentAuthService {
|
|
||||||
private readonly inflight = new Map<string, Promise<string>>();
|
|
||||||
|
|
||||||
async getToken(serverUrl: string): Promise<string> {
|
|
||||||
const key = this.cacheKey(serverUrl);
|
|
||||||
const cached = localStorage.getItem(key);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const existing = this.inflight.get(key);
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const promise = this.register(serverUrl)
|
|
||||||
.then(token => {
|
|
||||||
localStorage.setItem(key, token);
|
|
||||||
this.inflight.delete(key);
|
|
||||||
return token;
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
this.inflight.delete(key);
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.inflight.set(key, promise);
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearToken(serverUrl: string): void {
|
|
||||||
localStorage.removeItem(this.cacheKey(serverUrl));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async register(serverUrl: string): Promise<string> {
|
|
||||||
const base = serverUrl.replace(/\/+$/, '');
|
|
||||||
const localName = localStorage.getItem('username') ?? 'viewer';
|
|
||||||
const res = await fetch(`${base}/api/auth/register`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: `${localName}-watch`, isBot: false })
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`tournament-server register failed: ${res.status}`);
|
|
||||||
const body = (await res.json()) as RegisterResponse;
|
|
||||||
return body.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
private cacheKey(serverUrl: string): string {
|
|
||||||
return `tournament-token:${serverUrl.replace(/\/+$/, '')}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +1,36 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { GameStreamEvent, TournamentStreamEvent } from '../models/tournament.models';
|
import { GameStreamEvent, TournamentStreamEvent } from '../models/tournament.models';
|
||||||
import { TournamentAuthService } from './tournament-auth.service';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class TournamentStreamService {
|
export class TournamentStreamService {
|
||||||
private readonly auth = inject(TournamentAuthService);
|
|
||||||
|
|
||||||
streamTournament(serverUrl: string, tournamentId: string): Observable<TournamentStreamEvent> {
|
streamTournament(serverUrl: string, tournamentId: string): Observable<TournamentStreamEvent> {
|
||||||
return this.ndjson<TournamentStreamEvent>(
|
return this.ndjson<TournamentStreamEvent>(
|
||||||
serverUrl,
|
this.url(serverUrl, `/api/tournament/${tournamentId}/stream`)
|
||||||
`/api/tournament/${tournamentId}/stream`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
streamGame(serverUrl: string, tournamentId: string, gameId: string): Observable<GameStreamEvent> {
|
streamGame(serverUrl: string, tournamentId: string, gameId: string): Observable<GameStreamEvent> {
|
||||||
return this.ndjson<GameStreamEvent>(
|
return this.ndjson<GameStreamEvent>(
|
||||||
serverUrl,
|
this.url(serverUrl, `/api/tournament/${tournamentId}/game/${gameId}/stream`)
|
||||||
`/api/tournament/${tournamentId}/game/${gameId}/stream`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fullUrl(base: string, path: string): string {
|
private url(base: string, path: string): string {
|
||||||
if (!base) return path;
|
if (!base) return path;
|
||||||
return `${base.replace(/\/+$/, '')}${path}`;
|
return `${base.replace(/\/+$/, '')}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ndjson<T>(serverUrl: string, path: string): Observable<T> {
|
private ndjson<T>(url: string): Observable<T> {
|
||||||
return new Observable<T>(subscriber => {
|
return new Observable<T>(subscriber => {
|
||||||
const controller = new AbortController();
|
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 () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await this.openWithRetry(serverUrl, path, controller.signal);
|
const res = await fetch(url, { headers, signal: controller.signal });
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
subscriber.error(new Error(`Stream failed: ${res.status} ${res.statusText}`));
|
subscriber.error(new Error(`Stream failed: ${res.status} ${res.statusText}`));
|
||||||
return;
|
return;
|
||||||
@@ -65,20 +63,4 @@ export class TournamentStreamService {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openWithRetry(serverUrl: string, path: string, signal: AbortSignal): Promise<Response> {
|
|
||||||
const url = this.fullUrl(serverUrl, path);
|
|
||||||
const token = await this.auth.getToken(serverUrl);
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: { Accept: 'application/x-ndjson', Authorization: `Bearer ${token}` },
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (res.status !== 401) return res;
|
|
||||||
this.auth.clearToken(serverUrl);
|
|
||||||
const fresh = await this.auth.getToken(serverUrl);
|
|
||||||
return fetch(url, {
|
|
||||||
headers: { Accept: 'application/x-ndjson', Authorization: `Bearer ${fresh}` },
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Submodule
+1
Submodule tournament-server added at ffe36da943
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=6
|
MINOR=5
|
||||||
PATCH=3
|
PATCH=0
|
||||||
|
|||||||
Reference in New Issue
Block a user