fix: NCWF-4 Token Issues (#8)

Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-06-02 21:55:55 +02:00
parent 873bfe3bae
commit 95eff42dfe
37 changed files with 2522 additions and 573 deletions
@@ -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; }
@@ -0,0 +1,273 @@
<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>
@if (currentUser) {
<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 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>
</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>
}
@@ -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; }
});
}
}