Files
NowChess-Frontend/src/app/pages/tournaments/tournaments.component.html
T
Janis Eccarius 415c77efa0 feat(tournaments): external server management UI and official-bot join
Add TournamentServerService (GET/POST/DELETE /api/tournament/servers).
Add OfficialBotService (POST /api/bots/official/join-tournament).
Tournaments page gains a Servers button that opens a dialog to register,
list, and remove external tournament servers. Join dialog gains four
difficulty buttons (Easy/Medium/Hard/Expert) for spawning official bots
into a tournament at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 08:40:25 +02:00

375 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<div class="t-shell">
<div class="page">
<nav class="crumb" aria-label="Breadcrumb">
<a routerLink="/" class="crumb-link">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to lobby
</a>
<span class="crumb-sep">/</span>
<span class="crumb-current">Tournaments</span>
</nav>
<header class="page-header">
<div class="page-title-row">
<h1 class="page-title">Tournaments</h1>
<div class="page-actions">
@if (currentUser) {
<button type="button" class="btn-servers" (click)="openServersDialog()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
Servers
</button>
<button type="button" class="btn-new" (click)="openCreateDialog()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New tournament
</button>
}
</div>
</div>
<div class="tabs" role="tablist">
<button type="button" class="tab-btn" [class.active]="tab === 'started'" (click)="setTab('started')">
Live
@if (started.length > 0) { <span class="tab-badge live-badge">{{ started.length }}</span> }
</button>
<button type="button" class="tab-btn" [class.active]="tab === 'created'" (click)="setTab('created')">
Upcoming
@if (created.length > 0) { <span class="tab-badge">{{ created.length }}</span> }
</button>
<button type="button" class="tab-btn" [class.active]="tab === 'finished'" (click)="setTab('finished')">
Finished
</button>
</div>
</header>
@if (loading) {
<div class="state-msg"><span class="pulse"></span>Loading tournaments…</div>
} @else if (activeList.length === 0) {
<div class="empty-state">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/>
<path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/>
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/>
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>
</svg>
</div>
<p class="empty-title">No tournaments here</p>
<p class="empty-sub">Check back later or look in another tab.</p>
</div>
} @else {
<div class="t-list">
@for (t of activeList; track t.id) {
<div class="t-card" [class.expanded]="selectedTournament?.id === t.id"
(click)="selectTournament(t)" role="button" tabindex="0"
(keydown.enter)="selectTournament(t)">
<div class="t-card-main">
<div class="t-card-left">
<span class="t-status-dot" [class]="'dot-' + t.status"></span>
<div class="t-info">
<span class="t-name">{{ t.fullName }}</span>
<span class="t-meta">
{{ 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 }
</span>
</div>
</div>
<div class="t-card-right">
@if (t.status === 'finished' && t.winner) {
<span class="winner-badge">🏆 {{ t.winner.name }}</span>
}
@if (currentUser && t.status === 'created') {
@if (t.createdBy === currentUser.id) {
<button type="button" class="t-action-btn t-btn-start"
[disabled]="startingId === t.id"
(click)="startTournament($event, t)">
{{ startingId === t.id ? '…' : 'Start' }}
</button>
}
<button type="button" class="t-action-btn t-btn-join"
(click)="openJoinDialog($event, t.id)">
Join with bot
</button>
}
<svg class="chevron" [class.open]="selectedTournament?.id === t.id"
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
</div>
@if (selectedTournament?.id === t.id) {
<div class="t-detail" (click)="$event.stopPropagation()">
<!-- Leaderboard -->
@if (t.standing.players.length > 0) {
<section class="detail-section">
<h3 class="detail-heading">Leaderboard</h3>
<table class="standings-table">
<thead>
<tr>
<th class="col-rank">#</th>
<th class="col-name">Bot</th>
<th class="col-pts">Pts</th>
<th class="col-tb">Bkh</th>
<th class="col-games">W/D/L</th>
</tr>
</thead>
<tbody>
@for (r of t.standing.players; track r.bot.id) {
<tr [class.top-row]="r.rank <= 3">
<td class="col-rank">{{ rankMedal(r.rank) }}</td>
<td class="col-name">{{ r.bot.name }}</td>
<td class="col-pts">{{ scoreDisplay(r) }}</td>
<td class="col-tb">{{ r.tieBreak }}</td>
<td class="col-games">
<span class="wdl">
<span class="w">{{ r.wins }}</span>/<span class="d">{{ r.draws }}</span>/<span class="l">{{ r.losses }}</span>
</span>
</td>
</tr>
}
</tbody>
</table>
</section>
} @else {
<p class="no-standings">No standings yet — waiting for games to complete.</p>
}
<!-- Current round pairings -->
@if (t.round > 0) {
<section class="detail-section">
<h3 class="detail-heading">Round {{ t.round }} pairings</h3>
@if (pairingsLoading) {
<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)">
<span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span>
<span class="pairing-vs">vs</span>
<span class="pairing-black">{{ p.black.name }}</span>
@if (p.winner) {
<span class="pairing-result" [class]="'result-' + p.winner">
{{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '10' : '01' }}
</span>
} @else if (p.gameId) {
<span class="pairing-ongoing">
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10"/>
</svg>
Watch
</span>
}
</div>
}
</div>
} @else {
<p class="no-standings">No pairings recorded yet.</p>
}
</section>
}
</div>
}
</div>
}
</div>
}
</div>
</div>
@if (joinDialogTournamentId) {
<div class="dialog-overlay" (click)="closeJoinDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-head">
<span class="dialog-brand">Join with a bot</span>
<button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button>
</div>
<p class="join-hint">Select one of your bots to enter this tournament. Its token will be rotated to authenticate the join.</p>
@if (botsLoading) {
<div class="dialog-loading"><span class="pulse"></span>Loading bots…</div>
} @else if (userBots.length === 0) {
<p class="join-empty">You have no bots yet. Go to <strong>Bots</strong> in the nav to create one first.</p>
} @else {
<div class="bot-pick-list">
@for (bot of userBots; track bot.id) {
<button type="button" class="bot-pick-row"
[disabled]="!!joiningBotId"
(click)="joinWithBot(bot)">
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
<span class="bot-pick-name">{{ bot.name }}</span>
<span class="bot-pick-rating">{{ bot.rating }}</span>
@if (joiningBotId === bot.id) {
<span class="bot-pick-spinner"></span>
}
</button>
}
</div>
}
@if (joinError) {
<div class="dialog-error">{{ joinError }}</div>
}
<div class="join-divider">
<span class="join-divider-label">or join with an official bot</span>
</div>
<div class="official-bot-grid">
@for (d of officialDifficulties; track d) {
<button type="button" class="official-bot-btn"
[class]="'official-btn-' + d"
[disabled]="!!joiningOfficialDifficulty || !!joiningBotId"
(click)="joinWithOfficialBot(d)">
@if (joiningOfficialDifficulty === d) {
<span class="pulse"></span>
}
{{ d | titlecase }}
</button>
}
</div>
@if (officialJoinError) {
<div class="dialog-error">{{ officialJoinError }}</div>
}
</div>
</div>
}
@if (showServersDialog) {
<div class="dialog-overlay" (click)="closeServersDialog()">
<div class="dialog-card servers-dialog" (click)="$event.stopPropagation()">
<div class="dialog-head">
<span class="dialog-brand">Tournament servers</span>
<button type="button" class="dialog-close" (click)="closeServersDialog()">×</button>
</div>
<p class="join-hint">External tournament servers aggregated into this view. Tournaments from all servers appear in the list.</p>
@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>
} @else {
<div class="servers-list">
@for (s of servers; track s.id) {
<div class="server-row">
<div class="server-info">
<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>
</div>
</div>
}
@if (showCreateDialog) {
<div class="dialog-overlay" (click)="closeCreateDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-head">
<span class="dialog-brand">New tournament</span>
<button type="button" class="dialog-close" (click)="closeCreateDialog()">×</button>
</div>
<form [formGroup]="createForm" (ngSubmit)="submitCreate()">
<div class="dialog-field">
<label class="dialog-label">Name</label>
<input type="text" class="dialog-input" formControlName="name" placeholder="e.g. Friday Blitz Open" />
</div>
<div class="dialog-row">
<div class="dialog-field">
<label class="dialog-label">Rounds</label>
<input type="number" class="dialog-input" formControlName="nbRounds" min="1" max="20" />
</div>
<div class="dialog-field">
<label class="dialog-label">Clock (min)</label>
<input type="number" class="dialog-input" formControlName="clockLimitMinutes" min="1" max="60" />
</div>
<div class="dialog-field">
<label class="dialog-label">Increment (s)</label>
<input type="number" class="dialog-input" formControlName="clockIncrement" min="0" max="60" />
</div>
</div>
<label class="dialog-toggle">
<input type="checkbox" formControlName="rated" />
<span class="toggle-track"></span>
<span class="toggle-label">Rated</span>
</label>
@if (createError) {
<div class="dialog-error">{{ createError }}</div>
}
<div class="dialog-actions">
<button type="button" class="btn-ghost" (click)="closeCreateDialog()">Cancel</button>
<button type="submit" class="btn-primary" [disabled]="createLoading || createForm.invalid">
{{ createLoading ? 'Creating…' : 'Create' }}
</button>
</div>
</form>
</div>
</div>
}