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:
@@ -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' ? '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>
|
||||
</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; }
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user