415c77efa0
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>
375 lines
16 KiB
HTML
375 lines
16 KiB
HTML
<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' ? '1–0' : '0–1' }}
|
||
</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>
|
||
}
|