feat: NCWF-5/6/7/8/9 chess analysis page and engine integration (#11)
Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com> Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,542 @@
|
||||
/* ============================================================
|
||||
DESIGN TOKENS — dark mode (default)
|
||||
============================================================ */
|
||||
:host {
|
||||
--nc-neon: #ff45c8;
|
||||
--nc-neon-soft: rgba(255, 69, 200, 0.55);
|
||||
--nc-bg: #06060d;
|
||||
--nc-surface: rgba(20, 17, 42, 0.6);
|
||||
--nc-surface-solid: rgba(10, 8, 22, 0.95);
|
||||
--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-warning: #ffb13a;
|
||||
--nc-warning-soft: rgba(255, 177, 58, 0.4);
|
||||
--nc-danger: #ff7a7a;
|
||||
--nc-danger-bg: rgba(255, 122, 122, 0.08);
|
||||
--nc-danger-soft: rgba(255, 122, 122, 0.3);
|
||||
--nc-success: #5ee5a1;
|
||||
--nc-clock-bg: rgba(0, 0, 0, 0.4);
|
||||
--nc-btn-bg: rgba(255, 255, 255, 0.03);
|
||||
--nc-btn-hover-bg: rgba(255, 255, 255, 0.07);
|
||||
--nc-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--nc-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
:host-context(html:not([data-theme='dark'])) {
|
||||
--nc-neon: #ff3dbb;
|
||||
--nc-neon-soft: rgba(255, 61, 187, 0.55);
|
||||
--nc-bg: transparent;
|
||||
--nc-surface: rgba(26, 24, 56, 0.72);
|
||||
--nc-surface-solid: rgba(26, 24, 56, 0.97);
|
||||
--nc-text: #fff;
|
||||
--nc-text-muted: rgba(255, 255, 255, 0.72);
|
||||
--nc-text-dim: rgba(255, 255, 255, 0.45);
|
||||
--nc-border: rgba(255, 255, 255, 0.1);
|
||||
--nc-border-strong: rgba(255, 255, 255, 0.18);
|
||||
--nc-btn-bg: rgba(255, 255, 255, 0.05);
|
||||
--nc-btn-hover-bg: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SHELL
|
||||
============================================================ */
|
||||
.analysis-shell {
|
||||
min-height: 100dvh;
|
||||
background: var(--nc-bg);
|
||||
font-family: var(--nc-sans);
|
||||
color: var(--nc-text);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.analysis-shell::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 20% 100%, rgba(74, 41, 98, 0.12), transparent 60%),
|
||||
radial-gradient(ellipse 60% 40% at 90% 0%, rgba(41, 74, 98, 0.18), transparent 60%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PAGE CONTAINER
|
||||
============================================================ */
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 32px 60px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BREADCRUMB
|
||||
============================================================ */
|
||||
.crumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
font-family: var(--nc-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.crumb-link {
|
||||
color: var(--nc-text-dim);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.crumb-link:hover {
|
||||
color: var(--nc-neon);
|
||||
}
|
||||
.crumb-sep {
|
||||
color: var(--nc-text-dim);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.crumb-current {
|
||||
color: var(--nc-text-muted);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PAGE HEADER
|
||||
============================================================ */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--nc-border);
|
||||
}
|
||||
|
||||
.page-title h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--nc-text);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 0;
|
||||
font-family: var(--nc-mono);
|
||||
font-size: 11px;
|
||||
color: var(--nc-text-dim);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ERROR
|
||||
============================================================ */
|
||||
.state-error {
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 20px;
|
||||
color: var(--nc-danger);
|
||||
background: var(--nc-danger-bg);
|
||||
border: 1px solid var(--nc-danger-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
INPUT SECTION
|
||||
============================================================ */
|
||||
.input-section {
|
||||
background: var(--nc-surface);
|
||||
border: 1px solid var(--nc-border);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
padding: 20px;
|
||||
margin-bottom: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--nc-border);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--nc-text-muted);
|
||||
font-family: var(--nc-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.mode-tab:hover {
|
||||
color: var(--nc-text);
|
||||
}
|
||||
|
||||
.mode-tab.active {
|
||||
color: var(--nc-neon);
|
||||
border-color: var(--nc-neon-soft);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
flex: 1;
|
||||
background: var(--nc-clock-bg);
|
||||
border: 1px solid var(--nc-border);
|
||||
color: var(--nc-text);
|
||||
font-family: var(--nc-mono);
|
||||
font-size: 12px;
|
||||
padding: 9px 12px;
|
||||
letter-spacing: 0.04em;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: var(--nc-neon-soft);
|
||||
}
|
||||
.text-input::placeholder {
|
||||
color: var(--nc-text-dim);
|
||||
}
|
||||
|
||||
.pgn-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
background: var(--nc-clock-bg);
|
||||
border: 1px solid var(--nc-border);
|
||||
color: var(--nc-text);
|
||||
font-family: var(--nc-mono);
|
||||
font-size: 12px;
|
||||
padding: 10px 12px;
|
||||
letter-spacing: 0.04em;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.text-area:focus {
|
||||
border-color: var(--nc-neon-soft);
|
||||
}
|
||||
.text-area::placeholder {
|
||||
color: var(--nc-text-dim);
|
||||
}
|
||||
|
||||
.depth-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--nc-border);
|
||||
}
|
||||
|
||||
.depth-label {
|
||||
font-family: var(--nc-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nc-text-dim);
|
||||
}
|
||||
|
||||
.depth-input {
|
||||
width: 56px;
|
||||
background: var(--nc-clock-bg);
|
||||
border: 1px solid var(--nc-border);
|
||||
color: var(--nc-text);
|
||||
font-family: var(--nc-mono);
|
||||
font-size: 13px;
|
||||
padding: 7px 10px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.depth-input:focus {
|
||||
border-color: var(--nc-neon-soft);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BUTTONS
|
||||
============================================================ */
|
||||
.btn {
|
||||
font-family: var(--nc-sans);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
padding: 9px 14px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--nc-border-strong);
|
||||
background: var(--nc-btn-bg);
|
||||
color: var(--nc-text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn:hover:not([disabled]) {
|
||||
background: var(--nc-btn-hover-bg);
|
||||
border-color: var(--nc-text-muted);
|
||||
}
|
||||
|
||||
.btn[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--nc-neon) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--nc-neon) !important;
|
||||
box-shadow: 0 0 14px rgba(255, 69, 200, 0.3);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not([disabled]) {
|
||||
box-shadow: 0 0 20px rgba(255, 69, 200, 0.5);
|
||||
}
|
||||
|
||||
.btn-analyse {
|
||||
margin-left: auto;
|
||||
background: rgba(255, 69, 200, 0.12);
|
||||
color: var(--nc-neon);
|
||||
border-color: var(--nc-neon-soft);
|
||||
}
|
||||
|
||||
.btn-analyse:hover:not([disabled]) {
|
||||
background: rgba(255, 69, 200, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
MAIN GRID
|
||||
============================================================ */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BOARD COLUMN
|
||||
============================================================ */
|
||||
.board-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
container-type: size;
|
||||
aspect-ratio: 1 / 1;
|
||||
padding: 10px;
|
||||
background: var(--nc-surface);
|
||||
border: 1px solid var(--nc-border);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
box-shadow:
|
||||
0 8px 40px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(255, 69, 200, 0.06);
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: var(--nc-btn-bg);
|
||||
border: 1px solid var(--nc-border);
|
||||
color: var(--nc-text-muted);
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--nc-neon);
|
||||
background: var(--nc-btn-hover-bg);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SIDE COLUMN
|
||||
============================================================ */
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.side-card {
|
||||
background: var(--nc-surface);
|
||||
border: 1px solid var(--nc-border);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.side-card-summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 13px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.side-card-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.side-card-title {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nc-text-muted);
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.side-card-meta {
|
||||
font-family: var(--nc-mono);
|
||||
font-size: 10px;
|
||||
color: var(--nc-text-dim);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.chev {
|
||||
color: var(--nc-text-dim);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.side-card[open] .chev {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.side-card[open] .side-card-summary {
|
||||
border-bottom: 1px solid var(--nc-border);
|
||||
}
|
||||
|
||||
.side-card-body {
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.timeline-body {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
EVAL GRID
|
||||
============================================================ */
|
||||
.eval-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eval-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.eval-row--col {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.eval-label {
|
||||
font-family: var(--nc-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nc-text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.eval-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--nc-text);
|
||||
}
|
||||
|
||||
.eval-value.mono {
|
||||
font-family: var(--nc-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.eval-value.positive {
|
||||
color: var(--nc-success);
|
||||
}
|
||||
.eval-value.negative {
|
||||
color: var(--nc-danger);
|
||||
}
|
||||
|
||||
.continuation {
|
||||
font-size: 11px;
|
||||
color: var(--nc-text-muted);
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
RESPONSIVE
|
||||
============================================================ */
|
||||
@media (max-width: 1100px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.board-col {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page {
|
||||
padding: 16px 16px 48px;
|
||||
}
|
||||
.page-title h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
<div class="analysis-shell">
|
||||
<div class="page">
|
||||
<!-- Breadcrumb -->
|
||||
<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">Analysis</span>
|
||||
</nav>
|
||||
|
||||
<!-- Page header -->
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<h1>Chess Analysis</h1>
|
||||
<p class="page-subtitle">Analyse positions, games or custom PGN with the engine</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Error -->
|
||||
@if (errorMessage) {
|
||||
<div class="state-error">{{ errorMessage }}</div>
|
||||
}
|
||||
|
||||
<!-- Input section -->
|
||||
<section class="input-section">
|
||||
<!-- Mode tabs -->
|
||||
<div class="mode-tabs" role="tablist" aria-label="Analysis input mode">
|
||||
<button
|
||||
class="mode-tab"
|
||||
[class.active]="inputMode === 'fen'"
|
||||
role="tab"
|
||||
(click)="setInputMode('fen')"
|
||||
>
|
||||
FEN
|
||||
</button>
|
||||
<button
|
||||
class="mode-tab"
|
||||
[class.active]="inputMode === 'pgn'"
|
||||
role="tab"
|
||||
(click)="setInputMode('pgn')"
|
||||
>
|
||||
PGN
|
||||
</button>
|
||||
<button
|
||||
class="mode-tab"
|
||||
[class.active]="inputMode === 'game'"
|
||||
role="tab"
|
||||
(click)="setInputMode('game')"
|
||||
>
|
||||
Game ID
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- FEN input -->
|
||||
@if (inputMode === 'fen') {
|
||||
<div class="input-row">
|
||||
<input
|
||||
id="fen-input"
|
||||
type="text"
|
||||
class="text-input"
|
||||
placeholder="Paste FEN string here…"
|
||||
autocomplete="off"
|
||||
[(ngModel)]="fenInput"
|
||||
(keydown.enter)="loadFen()"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
(click)="loadFen()"
|
||||
[disabled]="!fenInput.trim()"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PGN input -->
|
||||
@if (inputMode === 'pgn') {
|
||||
<div class="pgn-col">
|
||||
<textarea
|
||||
id="pgn-input"
|
||||
class="text-area"
|
||||
rows="5"
|
||||
placeholder="Paste PGN here…"
|
||||
[(ngModel)]="pgnInput"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
(click)="loadPgn()"
|
||||
[disabled]="!pgnInput.trim() || loading"
|
||||
>
|
||||
@if (loading) {
|
||||
Loading…
|
||||
} @else {
|
||||
Import PGN
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Game ID input -->
|
||||
@if (inputMode === 'game') {
|
||||
<div class="input-row">
|
||||
<input
|
||||
id="gameid-input"
|
||||
type="text"
|
||||
class="text-input"
|
||||
placeholder="Game ID"
|
||||
autocomplete="off"
|
||||
[(ngModel)]="fenInput"
|
||||
(keydown.enter)="loadGame(fenInput)"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
(click)="loadGame(fenInput)"
|
||||
[disabled]="!fenInput.trim() || loading"
|
||||
>
|
||||
@if (loading) {
|
||||
Loading…
|
||||
} @else {
|
||||
Load game
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Depth + analyse -->
|
||||
<div class="depth-row">
|
||||
<label class="depth-label" for="depth-input">Depth</label>
|
||||
<input
|
||||
id="depth-input"
|
||||
type="number"
|
||||
class="depth-input"
|
||||
min="1"
|
||||
max="18"
|
||||
[(ngModel)]="depth"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-analyse"
|
||||
type="button"
|
||||
(click)="runAnalysis()"
|
||||
[disabled]="analysing"
|
||||
>
|
||||
@if (analysing) {
|
||||
Analysing…
|
||||
} @else {
|
||||
Analyse
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Main layout: board + sidebar -->
|
||||
<div class="layout">
|
||||
<!-- Board column -->
|
||||
<div class="board-col">
|
||||
<div class="board-wrap">
|
||||
<app-chess-board
|
||||
[fen]="displayFen"
|
||||
[selectedSquare]="null"
|
||||
[highlightedSquares]="[]"
|
||||
boardTheme="arabian"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Navigation bar -->
|
||||
@if (fenHistory.length > 1) {
|
||||
<div class="nav-bar">
|
||||
<button class="icon-btn" title="First position" (click)="navigateHistory('first')">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="11 17 6 12 11 7" />
|
||||
<polyline points="18 17 13 12 18 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Previous" (click)="navigateHistory('prev')">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Next" (click)="navigateHistory('next')">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Last position" (click)="navigateHistory('last')">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="13 17 18 12 13 7" />
|
||||
<polyline points="6 17 11 12 6 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Side column -->
|
||||
<aside class="side">
|
||||
<!-- Single position analysis result -->
|
||||
@if (positionAnalysis && !hasAnnotations) {
|
||||
<details class="side-card" open>
|
||||
<summary class="side-card-summary">
|
||||
<span class="side-card-title">Position Analysis</span>
|
||||
<svg
|
||||
class="chev"
|
||||
width="12"
|
||||
height="12"
|
||||
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>
|
||||
</summary>
|
||||
<div class="side-card-body eval-grid">
|
||||
<div class="eval-row">
|
||||
<span class="eval-label">Evaluation</span>
|
||||
<span
|
||||
class="eval-value"
|
||||
[class.positive]="positionAnalysis.eval > 0"
|
||||
[class.negative]="positionAnalysis.eval < 0"
|
||||
>
|
||||
{{ positionAnalysis.eval > 0 ? '+' : '' }}{{ positionAnalysis.eval.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="eval-row">
|
||||
<span class="eval-label">Win chance</span>
|
||||
<span class="eval-value">{{ (positionAnalysis.winChance * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="eval-row">
|
||||
<span class="eval-label">Best move</span>
|
||||
<span class="eval-value mono">{{ positionAnalysis.bestMove }}</span>
|
||||
</div>
|
||||
<div class="eval-row">
|
||||
<span class="eval-label">Depth</span>
|
||||
<span class="eval-value mono">{{ positionAnalysis.depth }}</span>
|
||||
</div>
|
||||
@if (positionAnalysis.continuations.length > 0) {
|
||||
<div class="eval-row eval-row--col">
|
||||
<span class="eval-label">Continuation</span>
|
||||
<span class="eval-value mono continuation">{{
|
||||
positionAnalysis.continuations.join(' ')
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
|
||||
<!-- Evaluation timeline -->
|
||||
@if (hasAnnotations) {
|
||||
<details class="side-card" open>
|
||||
<summary class="side-card-summary">
|
||||
<span class="side-card-title">Evaluation</span>
|
||||
<svg
|
||||
class="chev"
|
||||
width="12"
|
||||
height="12"
|
||||
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>
|
||||
</summary>
|
||||
<div class="side-card-body timeline-body">
|
||||
<app-eval-timeline [moves]="annotatedMoves" [activePly]="activePly" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Annotated moves -->
|
||||
<details class="side-card" open>
|
||||
<summary class="side-card-summary">
|
||||
<span class="side-card-title">Moves</span>
|
||||
<span class="side-card-meta">{{ annotatedMoves.length }} plies</span>
|
||||
<svg
|
||||
class="chev"
|
||||
width="12"
|
||||
height="12"
|
||||
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>
|
||||
</summary>
|
||||
<app-annotated-move-list
|
||||
[moves]="annotatedMoves"
|
||||
[activePly]="activePly"
|
||||
(plySelected)="navigateToPly($event)"
|
||||
/>
|
||||
</details>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { switchMap, of } from 'rxjs';
|
||||
|
||||
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
|
||||
import { EvalTimelineComponent } from '../../components/eval-timeline/eval-timeline.component';
|
||||
import { AnnotatedMoveListComponent } from '../../components/annotated-move-list/annotated-move-list.component';
|
||||
|
||||
import { GameApiService } from '../../services/game-api.service';
|
||||
import { AnalysisService } from '../../services/analysis.service';
|
||||
|
||||
import { AnnotatedMove, AnalysisResponse } from '../../models/analysis.models';
|
||||
import { GameFull } from '../../models/game.models';
|
||||
import { getErrorMessage } from '../../core/http/error-message.util';
|
||||
|
||||
const ANALYSIS_DEPTH_DEFAULT = 14;
|
||||
const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
|
||||
|
||||
type InputMode = 'fen' | 'pgn' | 'game';
|
||||
|
||||
@Component({
|
||||
selector: 'app-analysis',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
FormsModule,
|
||||
ChessBoardComponent,
|
||||
EvalTimelineComponent,
|
||||
AnnotatedMoveListComponent,
|
||||
],
|
||||
templateUrl: './analysis.component.html',
|
||||
styleUrl: './analysis.component.css',
|
||||
})
|
||||
export class AnalysisComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly gameApi = inject(GameApiService);
|
||||
private readonly analysisService = inject(AnalysisService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
// ── State ─────────────────────────────────────────────────
|
||||
inputMode: InputMode = 'fen';
|
||||
fenInput = '';
|
||||
pgnInput = '';
|
||||
depth = ANALYSIS_DEPTH_DEFAULT;
|
||||
|
||||
loading = false;
|
||||
analysing = false;
|
||||
errorMessage = '';
|
||||
|
||||
currentFen = STARTING_FEN;
|
||||
game: GameFull | null = null;
|
||||
|
||||
/** FEN for each ply (index 0 = position before move 0 was played) */
|
||||
fenHistory: string[] = [STARTING_FEN];
|
||||
annotatedMoves: AnnotatedMove[] = [];
|
||||
activePly: number | null = null;
|
||||
|
||||
/** Single-position analysis result (for custom FEN/PGN input) */
|
||||
positionAnalysis: AnalysisResponse | null = null;
|
||||
|
||||
get displayFen(): string {
|
||||
if (this.activePly !== null && this.fenHistory[this.activePly + 1]) {
|
||||
return this.fenHistory[this.activePly + 1];
|
||||
}
|
||||
return this.currentFen;
|
||||
}
|
||||
|
||||
get hasAnnotations(): boolean {
|
||||
return this.annotatedMoves.length > 0;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────
|
||||
ngOnInit(): void {
|
||||
// Support /analysis?gameId=xxx deep-link
|
||||
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||
const gameId = params.get('gameId');
|
||||
if (gameId) {
|
||||
this.inputMode = 'game';
|
||||
this.loadGame(gameId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Input mode ─────────────────────────────────────────────
|
||||
setInputMode(mode: InputMode): void {
|
||||
this.inputMode = mode;
|
||||
this.errorMessage = '';
|
||||
}
|
||||
|
||||
// ── Load FEN ──────────────────────────────────────────────
|
||||
loadFen(): void {
|
||||
const fen = this.fenInput.trim();
|
||||
if (!fen) return;
|
||||
this.reset();
|
||||
this.currentFen = fen;
|
||||
this.fenHistory = [fen];
|
||||
this.analyseSinglePosition(fen);
|
||||
}
|
||||
|
||||
// ── Load PGN ──────────────────────────────────────────────
|
||||
loadPgn(): void {
|
||||
const pgn = this.pgnInput.trim();
|
||||
if (!pgn) return;
|
||||
this.reset();
|
||||
this.loading = true;
|
||||
this.gameApi
|
||||
.importPgn(pgn)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (game) => {
|
||||
this.loading = false;
|
||||
this.applyGame(game);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.errorMessage = getErrorMessage(err, 'Could not import PGN.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Load game by ID ───────────────────────────────────────
|
||||
loadGame(gameId: string): void {
|
||||
this.reset();
|
||||
this.loading = true;
|
||||
this.gameApi
|
||||
.getGame(gameId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (game) => {
|
||||
this.loading = false;
|
||||
this.applyGame(game);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.errorMessage = getErrorMessage(err, 'Could not load game.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Run full analysis ─────────────────────────────────────
|
||||
runAnalysis(): void {
|
||||
if (this.fenHistory.length < 2) {
|
||||
this.analyseSinglePosition(this.currentFen);
|
||||
return;
|
||||
}
|
||||
this.analysing = true;
|
||||
this.errorMessage = '';
|
||||
const sans =
|
||||
this.annotatedMoves.length > 0
|
||||
? this.annotatedMoves.map((m) => m.san)
|
||||
: (this.game?.state.moves ?? []);
|
||||
|
||||
this.analysisService
|
||||
.analyzeGame(sans, this.fenHistory, this.depth)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (annotated) => {
|
||||
this.annotatedMoves = annotated;
|
||||
this.analysing = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.errorMessage = getErrorMessage(err, 'Analysis failed.');
|
||||
this.analysing = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Board navigation ──────────────────────────────────────
|
||||
navigateToPly(ply: number): void {
|
||||
this.activePly = ply;
|
||||
}
|
||||
|
||||
navigateHistory(direction: 'first' | 'prev' | 'next' | 'last'): void {
|
||||
const total = this.fenHistory.length - 1;
|
||||
const current = this.activePly ?? total;
|
||||
let next: number;
|
||||
switch (direction) {
|
||||
case 'first':
|
||||
next = 0;
|
||||
break;
|
||||
case 'prev':
|
||||
next = Math.max(0, current - 1);
|
||||
break;
|
||||
case 'next':
|
||||
next = Math.min(total, current + 1);
|
||||
break;
|
||||
default:
|
||||
next = total;
|
||||
break;
|
||||
}
|
||||
this.activePly = next >= total ? null : next;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────
|
||||
private reset(): void {
|
||||
this.errorMessage = '';
|
||||
this.annotatedMoves = [];
|
||||
this.positionAnalysis = null;
|
||||
this.activePly = null;
|
||||
this.game = null;
|
||||
this.fenHistory = [STARTING_FEN];
|
||||
this.currentFen = STARTING_FEN;
|
||||
}
|
||||
|
||||
private applyGame(game: GameFull): void {
|
||||
this.game = game;
|
||||
this.currentFen = game.state.fen;
|
||||
// Build a flat FEN history from scratch using moves array
|
||||
// The server gives us the final FEN. We reconstruct history by
|
||||
// storing the final FEN; full per-ply history requires per-move API calls
|
||||
// which is out of scope here — we store what we have and allow analysis to proceed.
|
||||
this.fenHistory = [game.state.fen];
|
||||
// Seed annotated moves with san strings, no quality yet
|
||||
this.annotatedMoves = game.state.moves.map((san) => ({
|
||||
san,
|
||||
fen: game.state.fen,
|
||||
evalBefore: null,
|
||||
evalAfter: null,
|
||||
quality: null,
|
||||
bestMove: null,
|
||||
winChanceBefore: null,
|
||||
winChanceAfter: null,
|
||||
}));
|
||||
}
|
||||
|
||||
private analyseSinglePosition(fen: string): void {
|
||||
this.analysing = true;
|
||||
this.analysisService
|
||||
.analyzePosition(fen, this.depth)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.positionAnalysis = result;
|
||||
this.analysing = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.errorMessage = getErrorMessage(err, 'Analysis failed.');
|
||||
this.analysing = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,18 @@
|
||||
}
|
||||
.page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
|
||||
|
||||
.page-actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.btn-servers {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 14px; border-radius: 8px;
|
||||
border: 1px solid var(--nc-border-strong);
|
||||
background: transparent; color: var(--nc-text-muted);
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-servers:hover { color: var(--nc-text); border-color: var(--nc-text-muted); }
|
||||
|
||||
.btn-new {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 14px; border-radius: 8px; border: none;
|
||||
@@ -327,3 +339,74 @@
|
||||
color: var(--nc-success); letter-spacing: 0.06em; text-transform: uppercase;
|
||||
}
|
||||
.pairing-ongoing svg { animation: pulse 1.4s ease-in-out infinite; }
|
||||
|
||||
/* Official bot join section */
|
||||
.join-divider {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 20px 0 14px;
|
||||
}
|
||||
.join-divider::before, .join-divider::after {
|
||||
content: ''; flex: 1; height: 1px; background: var(--nc-border);
|
||||
}
|
||||
.join-divider-label {
|
||||
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; color: var(--nc-text-dim); white-space: nowrap;
|
||||
}
|
||||
|
||||
.official-bot-grid {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.official-bot-btn {
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 9px 4px; border-radius: 8px; border: 1px solid var(--nc-border);
|
||||
background: var(--nc-surface); font-size: 12px; font-weight: 700;
|
||||
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||
color: var(--nc-text-muted); text-transform: capitalize;
|
||||
}
|
||||
.official-bot-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.official-btn-easy:hover:not(:disabled) { border-color: var(--nc-success); color: var(--nc-success); background: rgba(94,229,161,0.07); }
|
||||
.official-btn-medium:hover:not(:disabled) { border-color: var(--nc-warn); color: var(--nc-warn); background: rgba(255,209,102,0.07); }
|
||||
.official-btn-hard:hover:not(:disabled) { border-color: var(--nc-neon); color: var(--nc-neon); background: rgba(255,69,200,0.07); }
|
||||
.official-btn-expert:hover:not(:disabled) { border-color: var(--nc-danger); color: var(--nc-danger); background: rgba(255,122,122,0.07); }
|
||||
|
||||
/* Servers dialog */
|
||||
.servers-dialog { max-width: 480px; }
|
||||
|
||||
.servers-list {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.server-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; border-radius: 8px;
|
||||
border: 1px solid var(--nc-border); background: var(--nc-surface);
|
||||
}
|
||||
.server-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.server-label { font-size: 13px; font-weight: 600; color: var(--nc-text); }
|
||||
.server-url {
|
||||
font-size: 11px; color: var(--nc-text-dim);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
font-family: monospace;
|
||||
}
|
||||
.server-remove-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--nc-text-dim); padding: 4px; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; transition: color 0.15s, background 0.15s;
|
||||
font-size: 13px;
|
||||
}
|
||||
.server-remove-btn:hover:not(:disabled) { color: var(--nc-danger); background: rgba(255,122,122,0.1); }
|
||||
.server-remove-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.server-add-form {
|
||||
border-top: 1px solid var(--nc-border);
|
||||
padding-top: 16px;
|
||||
display: flex; flex-direction: column; gap: 0;
|
||||
}
|
||||
.server-add-heading {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; color: var(--nc-text-muted);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
@@ -16,15 +16,25 @@
|
||||
<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 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')">
|
||||
@@ -218,6 +228,97 @@
|
||||
@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>
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CommonModule, TitleCasePipe } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { FormBuilder, FormGroup, FormsModule, 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 { OfficialBotService } from '../../services/official-bot.service';
|
||||
import { TournamentServerService, ExternalTournamentServer } from '../../services/tournament-server.service';
|
||||
import { Bot } from '../../models/bot.models';
|
||||
import { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models';
|
||||
import { CurrentUser } from '../../models/auth.models';
|
||||
@@ -15,7 +17,7 @@ type StatusTab = 'started' | 'created' | 'finished';
|
||||
@Component({
|
||||
selector: 'app-tournaments',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, ReactiveFormsModule],
|
||||
imports: [CommonModule, RouterLink, FormsModule, ReactiveFormsModule, TitleCasePipe],
|
||||
templateUrl: './tournaments.component.html',
|
||||
styleUrl: './tournaments.component.css'
|
||||
})
|
||||
@@ -25,6 +27,8 @@ export class TournamentsComponent implements OnInit {
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly botService = inject(BotService);
|
||||
private readonly officialBotService = inject(OfficialBotService);
|
||||
private readonly tournamentServerService = inject(TournamentServerService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
@@ -58,6 +62,19 @@ export class TournamentsComponent implements OnInit {
|
||||
joiningBotId: string | null = null;
|
||||
joinError: string | null = null;
|
||||
|
||||
readonly officialDifficulties = ['easy', 'medium', 'hard', 'expert'] as const;
|
||||
joiningOfficialDifficulty: string | null = null;
|
||||
officialJoinError: string | null = null;
|
||||
|
||||
showServersDialog = false;
|
||||
servers: ExternalTournamentServer[] = [];
|
||||
serversLoading = false;
|
||||
newServerLabel = '';
|
||||
newServerUrl = '';
|
||||
addingServer = false;
|
||||
addServerError: string | null = null;
|
||||
removingServerId: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.authService.currentUser$
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
@@ -170,6 +187,32 @@ export class TournamentsComponent implements OnInit {
|
||||
this.joinDialogTournamentId = null;
|
||||
this.joiningBotId = null;
|
||||
this.joinError = null;
|
||||
this.joiningOfficialDifficulty = null;
|
||||
this.officialJoinError = null;
|
||||
}
|
||||
|
||||
joinWithOfficialBot(difficulty: string): void {
|
||||
if (!this.joinDialogTournamentId || this.joiningOfficialDifficulty || this.joiningBotId) return;
|
||||
this.joiningOfficialDifficulty = difficulty;
|
||||
this.officialJoinError = null;
|
||||
const tid = this.joinDialogTournamentId;
|
||||
this.officialBotService.joinTournament(tid, difficulty).subscribe({
|
||||
next: () => {
|
||||
this.joiningOfficialDifficulty = null;
|
||||
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.joiningOfficialDifficulty = null;
|
||||
this.officialJoinError = err.error?.error ?? 'Failed to join with official bot.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
joinWithBot(bot: Bot): void {
|
||||
@@ -196,6 +239,58 @@ export class TournamentsComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
openServersDialog(): void {
|
||||
this.newServerLabel = '';
|
||||
this.newServerUrl = '';
|
||||
this.addServerError = null;
|
||||
this.showServersDialog = true;
|
||||
this.serversLoading = true;
|
||||
this.tournamentServerService.list()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: res => { this.servers = res.servers; this.serversLoading = false; },
|
||||
error: () => { this.serversLoading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
closeServersDialog(): void {
|
||||
this.showServersDialog = false;
|
||||
}
|
||||
|
||||
addServer(): void {
|
||||
const label = this.newServerLabel.trim();
|
||||
const url = this.newServerUrl.trim();
|
||||
if (!label || !url || this.addingServer) return;
|
||||
this.addingServer = true;
|
||||
this.addServerError = null;
|
||||
this.tournamentServerService.register(label, url).subscribe({
|
||||
next: server => {
|
||||
this.addingServer = false;
|
||||
this.servers = [...this.servers, server];
|
||||
this.newServerLabel = '';
|
||||
this.newServerUrl = '';
|
||||
this.loadTournaments();
|
||||
},
|
||||
error: err => {
|
||||
this.addingServer = false;
|
||||
this.addServerError = err.error?.error ?? 'Failed to add server.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeServer(id: string): void {
|
||||
if (this.removingServerId) return;
|
||||
this.removingServerId = id;
|
||||
this.tournamentServerService.remove(id).subscribe({
|
||||
next: () => {
|
||||
this.removingServerId = null;
|
||||
this.servers = this.servers.filter(s => s.id !== id);
|
||||
this.loadTournaments();
|
||||
},
|
||||
error: () => { this.removingServerId = null; }
|
||||
});
|
||||
}
|
||||
|
||||
private loadTournaments(): void {
|
||||
this.tournamentService.list()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
|
||||
Reference in New Issue
Block a user