diff --git a/proxy.conf.json b/proxy.conf.json
index fa98e8c..f243619 100644
--- a/proxy.conf.json
+++ b/proxy.conf.json
@@ -1,4 +1,9 @@
{
+ "/api/tournament": {
+ "target": "http://localhost:8089",
+ "secure": false,
+ "changeOrigin": true
+ },
"/api/account": {
"target": "http://localhost:8083",
"secure": false,
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index db5cc47..54f7663 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -4,12 +4,16 @@ import { WelcomeComponent } from './pages/welcome/welcome.component';
import { ProfileComponent } from './pages/profile/profile.component';
import { ChallengesComponent } from './pages/challenges/challenges.component';
import { GamesComponent } from './pages/games/games.component';
+import { TournamentsComponent } from './pages/tournaments/tournaments.component';
+import { BotsComponent } from './pages/bots/bots.component';
export const routes: Routes = [
{ path: '', component: WelcomeComponent },
{ path: 'profile', component: ProfileComponent },
{ path: 'games', component: GamesComponent },
{ path: 'challenges', component: ChallengesComponent },
+ { path: 'tournaments', component: TournamentsComponent },
+ { path: 'bots', component: BotsComponent },
{ path: 'game/:gameId', component: GameComponent },
{ path: '**', redirectTo: '' }
];
diff --git a/src/app/components/login-dialog/login-dialog.component.css b/src/app/components/login-dialog/login-dialog.component.css
index acea1e8..813b554 100644
--- a/src/app/components/login-dialog/login-dialog.component.css
+++ b/src/app/components/login-dialog/login-dialog.component.css
@@ -1,58 +1,293 @@
-@import '../../button-template.css';
+:host {
+ --auth-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --auth-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
+ --auth-neon: #ff45c8;
+ --auth-neon-soft: rgba(255, 69, 200, 0.55);
+ --auth-bg: #06060d;
+}
.dialog-overlay {
position: fixed;
inset: 0;
- background: rgba(2, 2, 10, 0.58);
+ background: rgba(4, 2, 12, 0.72);
+ backdrop-filter: blur(6px);
display: grid;
place-items: center;
z-index: 350;
padding: 1rem;
+ animation: backdrop-in 180ms ease-out both;
}
.dialog-card {
- width: min(460px, 100%);
- background: var(--dlg-bg);
- border: 1.5px solid var(--dlg-border);
- box-shadow: var(--bb-glow);
- border-radius: 4px;
- padding: 1rem;
+ position: relative;
+ width: min(420px, 100%);
+ background:
+ radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.10), transparent 60%),
+ linear-gradient(180deg, #0a0612 0%, #06060d 100%);
+ border: 1px solid var(--auth-neon-soft);
+ border-radius: 14px;
+ padding: 28px 26px 22px;
display: grid;
- gap: 0.7rem;
+ gap: 14px;
+ font-family: var(--auth-sans);
+ color: #fff;
+ box-shadow:
+ 0 0 0 1px rgba(255, 69, 200, 0.06) inset,
+ 0 0 30px rgba(255, 69, 200, 0.18),
+ 0 30px 60px rgba(0, 0, 0, 0.55);
+ overflow: hidden;
+ animation: dialog-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
+}
+
+.dialog-card::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: linear-gradient(
+ 180deg,
+ transparent 0%,
+ rgba(255, 69, 200, 0.06) 50%,
+ transparent 100%
+ );
+ height: 35%;
+ animation: scanline 4.5s linear infinite;
+ mix-blend-mode: screen;
+}
+
+.dialog-card::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background-image:
+ repeating-linear-gradient(
+ 0deg,
+ rgba(255, 255, 255, 0.012) 0px,
+ rgba(255, 255, 255, 0.012) 1px,
+ transparent 1px,
+ transparent 3px
+ );
+ border-radius: inherit;
+}
+
+.dialog-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ position: relative;
+ z-index: 1;
+}
+
+.brand-tag {
+ font-family: var(--auth-mono);
+ font-size: 10px;
+ letter-spacing: 2.5px;
+ color: var(--auth-neon);
+ text-transform: uppercase;
+ opacity: 0.85;
+ animation: pulse-glow 2.4s ease-in-out infinite;
+}
+
+.brand-tag::before {
+ content: '';
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--auth-neon);
+ margin-right: 8px;
+ vertical-align: 1px;
+ box-shadow: 0 0 8px var(--auth-neon);
+}
+
+.close-btn {
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ color: rgba(255, 255, 255, 0.65);
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ display: grid;
+ place-items: center;
+ transition: all 0.18s ease;
+}
+
+.close-btn:hover {
+ color: var(--auth-neon);
+ border-color: var(--auth-neon-soft);
+ box-shadow: 0 0 10px rgba(255, 69, 200, 0.35);
}
.dialog-title {
- font-family: 'Bebas Neue', sans-serif;
- font-size: 22px;
- letter-spacing: 2px;
- color: var(--bb-title);
- text-align: center;
+ font-family: var(--auth-sans);
+ font-weight: 700;
+ font-size: 26px;
+ letter-spacing: 0.5px;
+ color: #fff;
+ margin: 4px 0 0;
+ position: relative;
+ z-index: 1;
+}
+
+.dialog-subtitle {
+ font-family: var(--auth-mono);
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.45);
+ letter-spacing: 0.6px;
+ margin-bottom: 6px;
+ position: relative;
+ z-index: 1;
+}
+
+form {
+ display: grid;
+ gap: 12px;
+ position: relative;
+ z-index: 1;
+}
+
+.field {
+ display: grid;
+ gap: 6px;
+}
+
+.field-label {
+ font-family: var(--auth-mono);
+ font-size: 10px;
+ letter-spacing: 1.5px;
+ text-transform: uppercase;
+ color: rgba(255, 255, 255, 0.55);
}
.dialog-input {
width: 100%;
- background: rgba(4, 4, 20, 0.62);
- border: 1px solid var(--bb-border);
- color: var(--bb-title);
- border-radius: 2px;
- padding: 0.6rem 0.7rem;
- font-family: 'Space Mono', monospace;
+ background: rgba(8, 5, 20, 0.6);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ color: #fff;
+ border-radius: 10px;
+ padding: 12px 14px;
+ font-family: var(--auth-mono);
font-size: 13px;
+ letter-spacing: 0.3px;
+ transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
+}
+
+.dialog-input::placeholder {
+ color: rgba(255, 255, 255, 0.28);
}
.dialog-input:focus {
outline: none;
- box-shadow: 0 0 0 2px rgba(0, 213, 255, 0.25);
-}
-
-.dialog-textarea {
- min-height: 120px;
- resize: vertical;
+ border-color: var(--auth-neon);
+ background: rgba(20, 6, 26, 0.7);
+ box-shadow: 0 0 0 3px rgba(255, 69, 200, 0.15), 0 0 18px rgba(255, 69, 200, 0.18);
}
.text-danger {
- color: #dc3545;
- font-size: 0.875rem;
+ color: #ff6ea0;
+ font-family: var(--auth-mono);
+ font-size: 11px;
+ letter-spacing: 0.3px;
+}
+
+.error-banner {
+ font-family: var(--auth-mono);
+ font-size: 12px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ background: rgba(255, 69, 110, 0.08);
+ border: 1px solid rgba(255, 69, 110, 0.35);
+ color: #ff9bb4;
+ animation: shake 0.35s ease-out;
+}
+
+.dialog-actions {
+ display: flex;
+ gap: 10px;
+ margin-top: 6px;
+ position: relative;
+ z-index: 1;
+}
+
+.btn {
+ flex: 1;
+ border-radius: 10px;
+ padding: 12px 14px;
+ font-family: var(--auth-sans);
+ font-weight: 600;
+ font-size: 13px;
+ letter-spacing: 0.4px;
+ cursor: pointer;
+ transition: all 0.18s ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.btn-ghost {
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.btn-ghost:hover:not(:disabled) {
+ color: #fff;
+ border-color: rgba(255, 255, 255, 0.25);
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.btn-primary {
+ background: var(--auth-neon);
+ border: 1px solid var(--auth-neon);
+ color: #1a0210;
+ box-shadow: 0 0 18px rgba(255, 69, 200, 0.45);
+}
+
+.btn-primary:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 0 26px rgba(255, 69, 200, 0.7);
+}
+
+.btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.alt-line {
+ margin-top: 4px;
+ text-align: center;
+ font-family: var(--auth-mono);
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.45);
+ position: relative;
+ z-index: 1;
+}
+
+.alt-line a {
+ color: var(--auth-neon);
+ cursor: pointer;
+ text-decoration: none;
+ margin-left: 4px;
+}
+
+.alt-line a:hover {
+ text-shadow: 0 0 8px var(--auth-neon-soft);
+}
+
+.spinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(26, 2, 16, 0.35);
+ border-top-color: #1a0210;
+ border-radius: 50%;
+ animation: spin 0.7s linear infinite;
}
.sr-only {
@@ -66,3 +301,28 @@
white-space: nowrap !important;
border: 0 !important;
}
+
+@keyframes scanline {
+ 0% { transform: translateY(-100%); }
+ 100% { transform: translateY(300%); }
+}
+@keyframes pulse-glow {
+ 0%, 100% { opacity: 0.85; }
+ 50% { opacity: 1; }
+}
+@keyframes dialog-in {
+ from { opacity: 0; transform: translateY(8px) scale(0.96); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+@keyframes backdrop-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+@keyframes shake {
+ 0%, 100% { transform: translateX(0); }
+ 20%, 60% { transform: translateX(-4px); }
+ 40%, 80% { transform: translateX(4px); }
+}
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
diff --git a/src/app/components/login-dialog/login-dialog.component.html b/src/app/components/login-dialog/login-dialog.component.html
index 0be3592..e6ff05d 100644
--- a/src/app/components/login-dialog/login-dialog.component.html
+++ b/src/app/components/login-dialog/login-dialog.component.html
@@ -1,34 +1,49 @@
-
LOGIN
+
+ NowChess // Auth
+
+
+
+
Welcome back
+
Sign in to continue your match
-
\ No newline at end of file
+
diff --git a/src/app/components/move-history/move-history.component.css b/src/app/components/move-history/move-history.component.css
index 8498110..1727b12 100644
--- a/src/app/components/move-history/move-history.component.css
+++ b/src/app/components/move-history/move-history.component.css
@@ -88,6 +88,11 @@
opacity: 0.8;
}
+.live-label.reviewing {
+ color: var(--nc-warning);
+ opacity: 1;
+}
+
.moves::-webkit-scrollbar { width: 6px; }
.moves::-webkit-scrollbar-track { background: transparent; }
.moves::-webkit-scrollbar-thumb { background: var(--nc-border-strong); border-radius: 3px; }
diff --git a/src/app/components/move-history/move-history.component.html b/src/app/components/move-history/move-history.component.html
index 8bae41c..681bc79 100644
--- a/src/app/components/move-history/move-history.component.html
+++ b/src/app/components/move-history/move-history.component.html
@@ -1,13 +1,16 @@
-
+
@if (movePairs.length === 0) {
No moves yet.
} @else {
@for (pair of movePairs; track $index) {
{{ $index + 1 }}
-
+
{{ pair.white }}
-
+
{{ pair.black ?? '…' }}
}
@@ -31,13 +34,13 @@
-
@if (plyCount > 0) {
-
LIVE
+
{{ isLive ? 'LIVE' : 'REVIEWING' }}
}
diff --git a/src/app/components/move-history/move-history.component.ts b/src/app/components/move-history/move-history.component.ts
index 0221780..0cc6ec0 100644
--- a/src/app/components/move-history/move-history.component.ts
+++ b/src/app/components/move-history/move-history.component.ts
@@ -1,4 +1,4 @@
-import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
+import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core';
export type MoveNavDirection = 'first' | 'prev' | 'next' | 'last';
@@ -16,7 +16,11 @@ interface MovePair {
})
export class MoveHistoryComponent implements OnChanges {
@Input({ required: true }) moves: string[] = [];
+ @Input() viewingPly: number | null = null;
@Output() navigate = new EventEmitter
();
+ @Output() navigateToPly = new EventEmitter();
+
+ @ViewChild('movesEl') movesEl?: ElementRef;
movePairs: MovePair[] = [];
@@ -24,24 +28,33 @@ export class MoveHistoryComponent implements OnChanges {
return this.moves.length;
}
- get currentWhiteIndex(): number {
- const lastPairIndex = this.movePairs.length - 1;
- if (lastPairIndex < 0) return -1;
- const lastMove = this.moves.length - 1;
- return lastMove % 2 === 0 ? lastPairIndex : -1;
- }
-
- get currentBlackIndex(): number {
- const lastPairIndex = this.movePairs.length - 1;
- if (lastPairIndex < 0) return -1;
- const lastMove = this.moves.length - 1;
- return lastMove % 2 === 1 ? lastPairIndex : -1;
+ get isLive(): boolean {
+ return this.viewingPly === null || this.viewingPly >= this.moves.length - 1;
}
ngOnChanges(): void {
this.movePairs = this.buildPairs(this.moves);
}
+ isWhiteViewing(pairIndex: number): boolean {
+ const ply = this.viewingPly ?? this.moves.length - 1;
+ return ply === pairIndex * 2;
+ }
+
+ isBlackViewing(pairIndex: number): boolean {
+ const ply = this.viewingPly ?? this.moves.length - 1;
+ return ply === pairIndex * 2 + 1;
+ }
+
+ clickWhite(pairIndex: number): void {
+ this.navigateToPly.emit(pairIndex * 2);
+ }
+
+ clickBlack(pairIndex: number, black: string | null): void {
+ if (!black) return;
+ this.navigateToPly.emit(pairIndex * 2 + 1);
+ }
+
private buildPairs(moves: string[]): MovePair[] {
const pairs: MovePair[] = [];
for (let i = 0; i < moves.length; i += 2) {
diff --git a/src/app/components/register-dialog/register-dialog.component.css b/src/app/components/register-dialog/register-dialog.component.css
index acea1e8..dbc3953 100644
--- a/src/app/components/register-dialog/register-dialog.component.css
+++ b/src/app/components/register-dialog/register-dialog.component.css
@@ -1,58 +1,298 @@
-@import '../../button-template.css';
+:host {
+ --auth-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --auth-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
+ --auth-neon: #ff45c8;
+ --auth-neon-soft: rgba(255, 69, 200, 0.55);
+ --auth-bg: #06060d;
+}
.dialog-overlay {
position: fixed;
inset: 0;
- background: rgba(2, 2, 10, 0.58);
+ background: rgba(4, 2, 12, 0.72);
+ backdrop-filter: blur(6px);
display: grid;
place-items: center;
z-index: 350;
padding: 1rem;
+ animation: backdrop-in 180ms ease-out both;
}
.dialog-card {
- width: min(460px, 100%);
- background: var(--dlg-bg);
- border: 1.5px solid var(--dlg-border);
- box-shadow: var(--bb-glow);
- border-radius: 4px;
- padding: 1rem;
+ position: relative;
+ width: min(440px, 100%);
+ background:
+ radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.10), transparent 60%),
+ linear-gradient(180deg, #0a0612 0%, #06060d 100%);
+ border: 1px solid var(--auth-neon-soft);
+ border-radius: 14px;
+ padding: 28px 26px 22px;
display: grid;
- gap: 0.7rem;
+ gap: 14px;
+ font-family: var(--auth-sans);
+ color: #fff;
+ box-shadow:
+ 0 0 0 1px rgba(255, 69, 200, 0.06) inset,
+ 0 0 30px rgba(255, 69, 200, 0.18),
+ 0 30px 60px rgba(0, 0, 0, 0.55);
+ overflow: hidden;
+ animation: dialog-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
+}
+
+.dialog-card::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: linear-gradient(
+ 180deg,
+ transparent 0%,
+ rgba(255, 69, 200, 0.06) 50%,
+ transparent 100%
+ );
+ height: 35%;
+ animation: scanline 4.5s linear infinite;
+ mix-blend-mode: screen;
+}
+
+.dialog-card::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background-image: repeating-linear-gradient(
+ 0deg,
+ rgba(255, 255, 255, 0.012) 0px,
+ rgba(255, 255, 255, 0.012) 1px,
+ transparent 1px,
+ transparent 3px
+ );
+ border-radius: inherit;
+}
+
+.dialog-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ position: relative;
+ z-index: 1;
+}
+
+.brand-tag {
+ font-family: var(--auth-mono);
+ font-size: 10px;
+ letter-spacing: 2.5px;
+ color: var(--auth-neon);
+ text-transform: uppercase;
+ opacity: 0.85;
+ animation: pulse-glow 2.4s ease-in-out infinite;
+}
+
+.brand-tag::before {
+ content: '';
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--auth-neon);
+ margin-right: 8px;
+ vertical-align: 1px;
+ box-shadow: 0 0 8px var(--auth-neon);
+}
+
+.close-btn {
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ color: rgba(255, 255, 255, 0.65);
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ display: grid;
+ place-items: center;
+ transition: all 0.18s ease;
+}
+
+.close-btn:hover {
+ color: var(--auth-neon);
+ border-color: var(--auth-neon-soft);
+ box-shadow: 0 0 10px rgba(255, 69, 200, 0.35);
}
.dialog-title {
- font-family: 'Bebas Neue', sans-serif;
- font-size: 22px;
- letter-spacing: 2px;
- color: var(--bb-title);
- text-align: center;
+ font-family: var(--auth-sans);
+ font-weight: 700;
+ font-size: 26px;
+ letter-spacing: 0.5px;
+ color: #fff;
+ margin: 4px 0 0;
+ position: relative;
+ z-index: 1;
+}
+
+.dialog-subtitle {
+ font-family: var(--auth-mono);
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.45);
+ letter-spacing: 0.6px;
+ margin-bottom: 6px;
+ position: relative;
+ z-index: 1;
+}
+
+form {
+ display: grid;
+ gap: 12px;
+ position: relative;
+ z-index: 1;
+}
+
+.field {
+ display: grid;
+ gap: 6px;
+}
+
+.field-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+
+.field-label {
+ font-family: var(--auth-mono);
+ font-size: 10px;
+ letter-spacing: 1.5px;
+ text-transform: uppercase;
+ color: rgba(255, 255, 255, 0.55);
}
.dialog-input {
width: 100%;
- background: rgba(4, 4, 20, 0.62);
- border: 1px solid var(--bb-border);
- color: var(--bb-title);
- border-radius: 2px;
- padding: 0.6rem 0.7rem;
- font-family: 'Space Mono', monospace;
+ background: rgba(8, 5, 20, 0.6);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ color: #fff;
+ border-radius: 10px;
+ padding: 12px 14px;
+ font-family: var(--auth-mono);
font-size: 13px;
+ letter-spacing: 0.3px;
+ transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
+}
+
+.dialog-input::placeholder {
+ color: rgba(255, 255, 255, 0.28);
}
.dialog-input:focus {
outline: none;
- box-shadow: 0 0 0 2px rgba(0, 213, 255, 0.25);
-}
-
-.dialog-textarea {
- min-height: 120px;
- resize: vertical;
+ border-color: var(--auth-neon);
+ background: rgba(20, 6, 26, 0.7);
+ box-shadow: 0 0 0 3px rgba(255, 69, 200, 0.15), 0 0 18px rgba(255, 69, 200, 0.18);
}
.text-danger {
- color: #dc3545;
- font-size: 0.875rem;
+ color: #ff6ea0;
+ font-family: var(--auth-mono);
+ font-size: 11px;
+ letter-spacing: 0.3px;
+}
+
+.error-banner {
+ font-family: var(--auth-mono);
+ font-size: 12px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ background: rgba(255, 69, 110, 0.08);
+ border: 1px solid rgba(255, 69, 110, 0.35);
+ color: #ff9bb4;
+ animation: shake 0.35s ease-out;
+}
+
+.dialog-actions {
+ display: flex;
+ gap: 10px;
+ margin-top: 6px;
+ position: relative;
+ z-index: 1;
+}
+
+.btn {
+ flex: 1;
+ border-radius: 10px;
+ padding: 12px 14px;
+ font-family: var(--auth-sans);
+ font-weight: 600;
+ font-size: 13px;
+ letter-spacing: 0.4px;
+ cursor: pointer;
+ transition: all 0.18s ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.btn-ghost {
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.btn-ghost:hover:not(:disabled) {
+ color: #fff;
+ border-color: rgba(255, 255, 255, 0.25);
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.btn-primary {
+ background: var(--auth-neon);
+ border: 1px solid var(--auth-neon);
+ color: #1a0210;
+ box-shadow: 0 0 18px rgba(255, 69, 200, 0.45);
+}
+
+.btn-primary:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 0 26px rgba(255, 69, 200, 0.7);
+}
+
+.btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.alt-line {
+ margin-top: 4px;
+ text-align: center;
+ font-family: var(--auth-mono);
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.45);
+ position: relative;
+ z-index: 1;
+}
+
+.alt-line a {
+ color: var(--auth-neon);
+ cursor: pointer;
+ text-decoration: none;
+ margin-left: 4px;
+}
+
+.alt-line a:hover {
+ text-shadow: 0 0 8px var(--auth-neon-soft);
+}
+
+.spinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(26, 2, 16, 0.35);
+ border-top-color: #1a0210;
+ border-radius: 50%;
+ animation: spin 0.7s linear infinite;
}
.sr-only {
@@ -66,3 +306,28 @@
white-space: nowrap !important;
border: 0 !important;
}
+
+@keyframes scanline {
+ 0% { transform: translateY(-100%); }
+ 100% { transform: translateY(300%); }
+}
+@keyframes pulse-glow {
+ 0%, 100% { opacity: 0.85; }
+ 50% { opacity: 1; }
+}
+@keyframes dialog-in {
+ from { opacity: 0; transform: translateY(8px) scale(0.96); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+@keyframes backdrop-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+@keyframes shake {
+ 0%, 100% { transform: translateX(0); }
+ 20%, 60% { transform: translateX(-4px); }
+ 40%, 80% { transform: translateX(4px); }
+}
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
diff --git a/src/app/components/register-dialog/register-dialog.component.html b/src/app/components/register-dialog/register-dialog.component.html
index 4c75996..123ad58 100644
--- a/src/app/components/register-dialog/register-dialog.component.html
+++ b/src/app/components/register-dialog/register-dialog.component.html
@@ -1,40 +1,65 @@
-
CREATE ACCOUNT
+
+ NowChess // Register
+ ×
+
+
+
Create account
+
Join the board and start playing
-
\ No newline at end of file
+
diff --git a/src/app/components/toolbar/toolbar.component.html b/src/app/components/toolbar/toolbar.component.html
index 6bcfdfe..8522bd5 100644
--- a/src/app/components/toolbar/toolbar.component.html
+++ b/src/app/components/toolbar/toolbar.component.html
@@ -17,7 +17,8 @@
Watch
-
Leaderboard
+
Tournaments
+
Bots
}
diff --git a/src/app/components/toolbar/toolbar.component.ts b/src/app/components/toolbar/toolbar.component.ts
index 515aad1..2aa57a8 100644
--- a/src/app/components/toolbar/toolbar.component.ts
+++ b/src/app/components/toolbar/toolbar.component.ts
@@ -31,6 +31,7 @@ export class ToolbarComponent implements OnInit {
private readonly router = inject(Router);
private pollHandle: ReturnType | null = null;
+ private readonly navigatedChallengeIds = new Set();
currentUser: CurrentUser | null = null;
showLoginDialog = false;
@@ -55,6 +56,7 @@ export class ToolbarComponent implements OnInit {
} else {
this.challengeWs.disconnect();
this.stopPolling();
+ this.navigatedChallengeIds.clear();
this.challengeEventService.clear();
}
});
@@ -76,8 +78,8 @@ export class ToolbarComponent implements OnInit {
}
private startPolling(): void {
- this.fetchIncoming();
- this.pollHandle = setInterval(() => this.fetchIncoming(), 5000);
+ this.fetchChallenges();
+ this.pollHandle = setInterval(() => this.fetchChallenges(), 10_000);
}
private stopPolling(): void {
@@ -87,11 +89,21 @@ export class ToolbarComponent implements OnInit {
}
}
- private fetchIncoming(): void {
+ private fetchChallenges(): void {
this.challengeService.listChallenges().subscribe({
next: response => {
const incoming = response.in ?? response.incoming ?? [];
this.challengeEventService.setIncomingChallenges(incoming);
+
+ const outgoing = response.out ?? response.outgoing ?? [];
+ for (const c of outgoing) {
+ if (c.status === 'accepted' && c.gameId && !this.navigatedChallengeIds.has(c.id)) {
+ this.navigatedChallengeIds.add(c.id);
+ if (!this.router.url.includes(`/game/${c.gameId}`)) {
+ void this.router.navigate(['/game', c.gameId]);
+ }
+ }
+ }
}
});
}
@@ -167,12 +179,24 @@ export class ToolbarComponent implements OnInit {
void this.router.navigate(['/games']);
}
+ goToTournaments(): void {
+ this.profileOpen = false;
+ this.notifOpen = false;
+ void this.router.navigate(['/tournaments']);
+ }
+
goToChallenges(): void {
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/challenges']);
}
+ goToBots(): void {
+ this.profileOpen = false;
+ this.notifOpen = false;
+ void this.router.navigate(['/bots']);
+ }
+
onLoginSuccess(): void {
this.closeLoginDialog();
}
diff --git a/src/app/core/config.loader.ts b/src/app/core/config.loader.ts
index 2d10fd5..9361f5b 100644
--- a/src/app/core/config.loader.ts
+++ b/src/app/core/config.loader.ts
@@ -4,8 +4,10 @@
*/
export function loadRuntimeConfig() {
const config = (window as any).__RUNTIME_CONFIG__ || {};
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
+ const derivedWsUrl = `${wsProtocol}://${window.location.host}`;
return {
apiUrl: config.API_URL || '',
- wsUrl: config.WEBSOCKET_URL || 'ws://localhost:8080'
+ wsUrl: config.WEBSOCKET_URL || derivedWsUrl
};
}
diff --git a/src/app/models/auth.models.ts b/src/app/models/auth.models.ts
index 0af910d..9d9af03 100644
--- a/src/app/models/auth.models.ts
+++ b/src/app/models/auth.models.ts
@@ -17,7 +17,8 @@ export interface RegisterResponse {
}
export interface LoginResponse {
- token: string;
+ accessToken: string;
+ refreshToken: string;
}
export interface CurrentUser {
diff --git a/src/app/models/bot.models.ts b/src/app/models/bot.models.ts
new file mode 100644
index 0000000..7385545
--- /dev/null
+++ b/src/app/models/bot.models.ts
@@ -0,0 +1,10 @@
+export interface Bot {
+ id: string;
+ name: string;
+ rating: number;
+ createdAt: string;
+}
+
+export interface BotWithToken extends Bot {
+ token: string;
+}
diff --git a/src/app/models/tournament.models.ts b/src/app/models/tournament.models.ts
new file mode 100644
index 0000000..7dc854c
--- /dev/null
+++ b/src/app/models/tournament.models.ts
@@ -0,0 +1,66 @@
+export interface TournamentClock {
+ limit: number;
+ increment: number;
+}
+
+export interface TournamentVariant {
+ key: string;
+ name: string;
+}
+
+export interface TournamentBotRef {
+ id: string;
+ name: string;
+}
+
+export interface TournamentResult {
+ rank: number;
+ points: number;
+ tieBreak: number;
+ bot: TournamentBotRef;
+ nbGames: number;
+ wins: number;
+ draws: number;
+ losses: number;
+}
+
+export interface TournamentStanding {
+ page: number;
+ players: TournamentResult[];
+}
+
+export interface Tournament {
+ id: string;
+ fullName: string;
+ clock: TournamentClock;
+ variant: TournamentVariant;
+ rated: boolean;
+ nbPlayers: number;
+ nbRounds: number;
+ createdBy: string;
+ startsAt: string | null;
+ status: 'created' | 'started' | 'finished';
+ round: number;
+ standing: TournamentStanding;
+ winner: TournamentBotRef | null;
+}
+
+export interface TournamentList {
+ created: Tournament[];
+ started: Tournament[];
+ finished: Tournament[];
+}
+
+export interface TournamentPairing {
+ id: string;
+ round: number;
+ white: TournamentBotRef | null;
+ black: TournamentBotRef;
+ gameId: string | null;
+ winner: 'white' | 'black' | 'draw' | null;
+}
+
+export interface RoundPairings {
+ round: number;
+ pairings: TournamentPairing[];
+}
diff --git a/src/app/pages/bots/bots.component.css b/src/app/pages/bots/bots.component.css
new file mode 100644
index 0000000..5fc7a1a
--- /dev/null
+++ b/src/app/pages/bots/bots.component.css
@@ -0,0 +1,163 @@
+: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;
+}
+
+.b-shell { padding-top: 72px; min-height: 100vh; }
+.page { max-width: 680px; margin: 0 auto; padding: 32px 20px 64px; }
+
+.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); }
+
+.page-header { margin-bottom: 24px; }
+.title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
+.page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
+.page-sub { font-size: 13px; color: var(--nc-text-muted); margin: 0; line-height: 1.5; }
+
+.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 panel */
+.create-panel {
+ border: 1px solid var(--nc-border-strong); border-radius: 12px;
+ background: var(--nc-surface); padding: 16px; margin-bottom: 20px;
+}
+.create-inner { display: flex; flex-direction: column; gap: 10px; }
+.field-label { font-size: 11px; font-weight: 600; text-transform: uppercase;
+ letter-spacing: 0.06em; color: var(--nc-text-muted); }
+.create-row { display: flex; gap: 8px; align-items: center; }
+.text-input {
+ flex: 1; padding: 8px 12px; border-radius: 8px;
+ border: 1px solid var(--nc-border-strong);
+ background: rgba(255,255,255,0.04); color: var(--nc-text); font-size: 14px;
+}
+.text-input:focus { outline: 2px solid var(--nc-neon); outline-offset: 1px; border-color: transparent; }
+.text-input:disabled { opacity: 0.5; }
+.error-text { font-size: 12px; color: var(--nc-danger); margin: 0; }
+
+/* Buttons */
+.btn-primary {
+ padding: 8px 16px; border-radius: 8px; border: none;
+ background: var(--nc-neon); color: #fff; font-size: 13px; font-weight: 600;
+ cursor: pointer; white-space: nowrap; transition: opacity 0.15s;
+}
+.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
+.btn-ghost {
+ padding: 8px 14px; border-radius: 8px; border: 1px solid var(--nc-border-strong);
+ background: transparent; color: var(--nc-text-muted); font-size: 13px; cursor: pointer;
+}
+.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
+
+/* States */
+.state-msg { display: flex; align-items: center; gap: 10px;
+ padding: 24px 0; color: var(--nc-text-muted); font-size: 13px; }
+.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; }
+
+/* Bot list */
+.bot-list { display: flex; flex-direction: column; gap: 8px; }
+.bot-card {
+ border: 1px solid var(--nc-border); border-radius: 12px;
+ background: var(--nc-surface); overflow: hidden;
+}
+.bot-main {
+ display: flex; align-items: center; gap: 12px;
+ padding: 14px 16px;
+}
+.bot-avatar {
+ width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0;
+ background: var(--nc-neon); color: #fff;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 16px; font-weight: 700;
+}
+.bot-info { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; }
+.bot-name { font-size: 14px; font-weight: 600; }
+.bot-meta { font-size: 11px; color: var(--nc-text-muted); }
+.bot-actions { display: flex; gap: 8px; flex-shrink: 0; }
+
+.btn-token {
+ display: inline-flex; align-items: center; gap: 5px;
+ padding: 6px 12px; border-radius: 7px; border: 1px solid var(--nc-border-strong);
+ background: transparent; color: var(--nc-text-muted); font-size: 12px; cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+}
+.btn-token:hover, .btn-token.active { background: rgba(255,69,200,0.1); color: var(--nc-neon); border-color: var(--nc-neon); }
+.btn-token:disabled { opacity: 0.5; cursor: not-allowed; }
+.btn-danger {
+ padding: 6px 12px; border-radius: 7px; border: 1px solid rgba(255,122,122,0.3);
+ background: transparent; color: var(--nc-danger); font-size: 12px; cursor: pointer;
+ transition: background 0.15s;
+}
+.btn-danger:hover { background: rgba(255,122,122,0.1); }
+.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
+
+/* Token panel */
+.token-panel {
+ border-top: 1px solid var(--nc-border); padding: 12px 16px;
+ display: flex; flex-direction: column; gap: 10px;
+}
+.token-warning {
+ display: flex; align-items: flex-start; gap: 8px;
+ font-size: 12px; color: var(--nc-warn);
+}
+.token-row { display: flex; align-items: center; gap: 8px; }
+.token-value {
+ flex: 1; font-family: monospace; font-size: 11px;
+ background: rgba(0,0,0,0.2); border-radius: 6px;
+ padding: 8px 10px; word-break: break-all;
+ color: var(--nc-text-muted); border: 1px solid var(--nc-border);
+}
+.btn-copy {
+ padding: 6px 12px; border-radius: 7px; border: 1px solid var(--nc-border-strong);
+ background: transparent; color: var(--nc-text-muted); font-size: 12px;
+ cursor: pointer; white-space: nowrap; transition: color 0.15s;
+ flex-shrink: 0;
+}
+.btn-copy:hover { color: var(--nc-success); }
diff --git a/src/app/pages/bots/bots.component.html b/src/app/pages/bots/bots.component.html
new file mode 100644
index 0000000..2cee025
--- /dev/null
+++ b/src/app/pages/bots/bots.component.html
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+ @if (showCreate) {
+
+
+
+
+
+
+ {{ creating ? 'Creating…' : 'Create' }}
+
+
+ Cancel
+
+
+ @if (createError) {
+
{{ createError }}
+ }
+
+
+ }
+
+ @if (loading) {
+
Loading bots…
+ } @else if (bots.length === 0) {
+
+
+
No bots yet
+
Create a bot to join tournaments and play automated games.
+
+ } @else {
+
+ @for (bot of bots; track bot.id) {
+
+
+
{{ bot.name.charAt(0).toUpperCase() }}
+
+ {{ bot.name }}
+ Rating {{ bot.rating }} · Created {{ bot.createdAt | date:'MMM d, yyyy' }}
+
+
+
+
+ {{ revealingId === bot.id ? '…' : (revealedTokens[bot.id] ? 'Hide' : 'Token') }}
+
+
+ {{ deletingId === bot.id ? '…' : 'Delete' }}
+
+
+
+
+ @if (revealedTokens[bot.id]) {
+
+
+
+ Token was just regenerated — the old one is now invalid. Keep this secret.
+
+
+ {{ revealedTokens[bot.id] }}
+
+ {{ copiedId === bot.id ? '✓ Copied' : 'Copy' }}
+
+
+
+ }
+
+ }
+
+ }
+
+
+
diff --git a/src/app/pages/bots/bots.component.ts b/src/app/pages/bots/bots.component.ts
new file mode 100644
index 0000000..a4915fe
--- /dev/null
+++ b/src/app/pages/bots/bots.component.ts
@@ -0,0 +1,111 @@
+import { Component, DestroyRef, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { RouterLink } from '@angular/router';
+import { FormsModule } from '@angular/forms';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { BotService } from '../../services/bot.service';
+import { Bot, BotWithToken } from '../../models/bot.models';
+
+@Component({
+ selector: 'app-bots',
+ standalone: true,
+ imports: [CommonModule, RouterLink, FormsModule],
+ templateUrl: './bots.component.html',
+ styleUrl: './bots.component.css'
+})
+export class BotsComponent implements OnInit {
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly botService = inject(BotService);
+
+ bots: Bot[] = [];
+ loading = true;
+
+ showCreate = false;
+ newBotName = '';
+ creating = false;
+ createError: string | null = null;
+
+ revealedTokens: Record = {};
+ revealingId: string | null = null;
+ copiedId: string | null = null;
+ deletingId: string | null = null;
+
+ ngOnInit(): void {
+ this.loadBots();
+ }
+
+ loadBots(): void {
+ this.loading = true;
+ this.botService.list()
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe({
+ next: bots => { this.bots = bots; this.loading = false; },
+ error: () => { this.loading = false; }
+ });
+ }
+
+ openCreate(): void {
+ this.newBotName = '';
+ this.createError = null;
+ this.showCreate = true;
+ }
+
+ cancelCreate(): void {
+ this.showCreate = false;
+ }
+
+ submitCreate(): void {
+ const name = this.newBotName.trim();
+ if (!name) return;
+ this.creating = true;
+ this.createError = null;
+ this.botService.create(name).subscribe({
+ next: (bot: BotWithToken) => {
+ this.creating = false;
+ this.showCreate = false;
+ this.bots = [bot, ...this.bots];
+ this.revealedTokens[bot.id] = bot.token;
+ },
+ error: err => {
+ this.creating = false;
+ this.createError = err.error?.message ?? err.error?.error ?? 'Failed to create bot.';
+ }
+ });
+ }
+
+ revealToken(botId: string): void {
+ if (this.revealedTokens[botId]) {
+ delete this.revealedTokens[botId];
+ return;
+ }
+ this.revealingId = botId;
+ this.botService.rotateToken(botId).subscribe({
+ next: token => {
+ this.revealingId = null;
+ this.revealedTokens[botId] = token;
+ },
+ error: () => { this.revealingId = null; }
+ });
+ }
+
+ copyToken(botId: string): void {
+ const token = this.revealedTokens[botId];
+ if (!token) return;
+ navigator.clipboard.writeText(token).then(() => {
+ this.copiedId = botId;
+ setTimeout(() => { this.copiedId = null; }, 2000);
+ });
+ }
+
+ deleteBot(botId: string): void {
+ this.deletingId = botId;
+ this.botService.delete(botId).subscribe({
+ next: () => {
+ this.deletingId = null;
+ this.bots = this.bots.filter(b => b.id !== botId);
+ delete this.revealedTokens[botId];
+ },
+ error: () => { this.deletingId = null; }
+ });
+ }
+}
diff --git a/src/app/pages/game/game.component.css b/src/app/pages/game/game.component.css
index 68642a8..3885907 100644
--- a/src/app/pages/game/game.component.css
+++ b/src/app/pages/game/game.component.css
@@ -28,30 +28,30 @@
}
/* ============================================================
- LIGHT MODE TOKEN OVERRIDES
+ LIGHT MODE TOKEN OVERRIDES (sunset-gradient palette)
============================================================ */
:host-context(html:not([data-theme='dark'])) {
- --nc-neon: #c026d3;
- --nc-neon-soft: rgba(192, 38, 211, 0.45);
- --nc-neon-clock-bg: rgba(192, 38, 211, 0.07);
- --nc-bg: #f5f0fc;
- --nc-surface: rgba(255, 255, 255, 0.88);
- --nc-surface-solid: rgba(255, 255, 255, 0.98);
- --nc-text: #0f0022;
- --nc-text-muted: rgba(15, 0, 34, 0.65);
- --nc-text-dim: rgba(15, 0, 34, 0.40);
- --nc-border: rgba(15, 0, 34, 0.10);
- --nc-border-strong: rgba(15, 0, 34, 0.20);
- --nc-warning: #d97706;
- --nc-warning-soft: rgba(217, 119, 6, 0.35);
- --nc-danger: #dc2626;
- --nc-danger-soft: rgba(220, 38, 38, 0.25);
- --nc-danger-bg: rgba(220, 38, 38, 0.06);
- --nc-success: #059669;
- --nc-clock-bg: rgba(0, 0, 0, 0.04);
- --nc-btn-bg: rgba(0, 0, 0, 0.03);
- --nc-btn-hover-bg: rgba(0, 0, 0, 0.06);
- --nc-seg-bg: rgba(0, 0, 0, 0.06);
+ --nc-neon: #ff3dbb;
+ --nc-neon-soft: rgba(255, 61, 187, 0.55);
+ --nc-neon-clock-bg: rgba(255, 61, 187, 0.08);
+ --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.10);
+ --nc-border-strong: rgba(255, 255, 255, 0.18);
+ --nc-warning: #ffb13a;
+ --nc-warning-soft: rgba(255, 177, 58, 0.40);
+ --nc-danger: #ff7a7a;
+ --nc-danger-soft: rgba(255, 122, 122, 0.30);
+ --nc-danger-bg: rgba(255, 122, 122, 0.08);
+ --nc-success: #5ee5a1;
+ --nc-clock-bg: rgba(0, 0, 0, 0.30);
+ --nc-btn-bg: rgba(255, 255, 255, 0.05);
+ --nc-btn-hover-bg: rgba(255, 255, 255, 0.10);
+ --nc-seg-bg: rgba(0, 0, 0, 0.28);
}
/* ============================================================
@@ -78,8 +78,8 @@
:host-context(html:not([data-theme='dark'])) .game-shell::before {
background:
- radial-gradient(ellipse 80% 50% at 20% 100%, rgba(192, 38, 211, 0.06), transparent 60%),
- radial-gradient(ellipse 60% 40% at 90% 0%, rgba(120, 40, 180, 0.08), transparent 60%);
+ radial-gradient(ellipse 80% 50% at 20% 100%, rgba(212, 77, 74, 0.10), transparent 60%),
+ radial-gradient(ellipse 60% 40% at 90% 0%, rgba(74, 41, 98, 0.22), transparent 60%);
}
/* ============================================================
@@ -290,12 +290,53 @@
animation: slideIn 0.35s ease-out;
}
+.completion-banner--timeout {
+ background: rgba(255, 177, 58, 0.06);
+ border-color: var(--nc-warning-soft);
+}
+
+.completion-banner--timeout .completion-title {
+ color: var(--nc-warning);
+}
+
+.completion-left {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.completion-icon {
+ font-size: 22px;
+ opacity: 0.7;
+}
+
+.completion-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-shrink: 0;
+}
+
+.completion-new {
+ font-size: 11px !important;
+ padding: 8px 14px !important;
+ text-decoration: none;
+}
+
@keyframes slideIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
-.completion-title { font-size: 16px; font-weight: 600; color: var(--nc-neon); }
+.completion-title { font-size: 15px; font-weight: 700; color: var(--nc-neon); }
+
+.completion-sub {
+ font-family: var(--nc-mono);
+ font-size: 10px;
+ color: var(--nc-text-dim);
+ letter-spacing: 0.08em;
+ margin-top: 2px;
+}
.completion-link {
font-family: var(--nc-mono);
@@ -345,8 +386,8 @@
}
:host-context(html:not([data-theme='dark'])) .status-strip {
- background: rgba(192, 38, 211, 0.04);
- border-color: rgba(192, 38, 211, 0.18);
+ background: rgba(255, 61, 187, 0.06);
+ border-color: rgba(255, 61, 187, 0.20);
}
.status-left { display: inline-flex; align-items: center; gap: 10px; }
@@ -388,7 +429,12 @@
}
:host-context(html:not([data-theme='dark'])) .board-wrap {
- box-shadow: 0 8px 40px rgba(0, 0, 0, 0.1);
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 61, 187, 0.08);
+}
+
+.board-wrap.reviewing {
+ border-color: var(--nc-warning-soft);
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 177, 58, 0.18);
}
/* ============================================================
@@ -490,6 +536,60 @@
.seg-btn.active { background: var(--nc-neon); color: #fff; font-weight: 700; }
+/* ============================================================
+ RESIGN CONFIRM OVERLAY
+============================================================ */
+.confirm-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 600;
+}
+
+.confirm-box {
+ background: var(--nc-surface-solid);
+ border: 1px solid var(--nc-danger-soft);
+ padding: 28px 32px;
+ min-width: 300px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+}
+
+.confirm-title {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--nc-text);
+}
+
+.confirm-sub {
+ margin: 0 0 12px;
+ font-size: 13px;
+ color: var(--nc-text-muted);
+}
+
+.confirm-actions {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+}
+
+.btn-danger-solid {
+ background: var(--nc-danger) !important;
+ color: #fff !important;
+ border-color: var(--nc-danger) !important;
+ font-weight: 700;
+}
+
+.btn-danger-solid:hover { opacity: 0.88; }
+
/* ============================================================
TOAST
============================================================ */
diff --git a/src/app/pages/game/game.component.html b/src/app/pages/game/game.component.html
index 07c4593..f8587ac 100644
--- a/src/app/pages/game/game.component.html
+++ b/src/app/pages/game/game.component.html
@@ -72,8 +72,24 @@
@if (facade.isGameFinished && facade.gameCompletionMessage) {
-
{{ facade.gameCompletionMessage }}
-
Start new game
+
+
♟
+
+
{{ facade.gameCompletionMessage }}
+
Game #{{ facade.gameId }}
+
+
+
+
+ }
+
+ @if (!facade.isGameFinished && ((whiteTimerMs !== null && whiteTimerMs <= 0) || (blackTimerMs !== null && blackTimerMs <= 0))) {
+
+ Time's up!
+ Waiting for server to confirm result…
}
@@ -104,11 +120,11 @@
-
+
@@ -146,7 +162,9 @@
+ [viewingPly]="facade.viewingPly"
+ (navigate)="facade.navigateHistory($event)"
+ (navigateToPly)="facade.navigateToPly($event)" />
@@ -200,6 +218,20 @@
+
+@if (facade.resignConfirmPending) {
+
+
+
Resign this game?
+
Your opponent will be declared the winner.
+
+ Cancel
+ Yes, resign
+
+
+
+}
+
@if (toastMessage) {
{{ toastMessage }}
diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts
index 18b68c4..7bfaf36 100644
--- a/src/app/pages/game/game.component.ts
+++ b/src/app/pages/game/game.component.ts
@@ -4,7 +4,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
import { BoardActionsBarComponent } from '../../components/board-actions-bar/board-actions-bar.component';
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
import { ExportPanelComponent } from '../../components/export-panel/export-panel.component';
-import { MoveHistoryComponent, MoveNavDirection } from '../../components/move-history/move-history.component';
+import { MoveHistoryComponent } from '../../components/move-history/move-history.component';
import { PlayerCardComponent } from '../../components/player-card/player-card.component';
import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component';
import { GameFacade } from './game.facade';
@@ -158,12 +158,7 @@ export class GameComponent implements OnInit, OnDestroy {
}
onResign(): void {
- this.showToast('Resigned');
- }
-
- // ── Move history navigation ───────────────────────────────────
- onMoveNavigate(_direction: MoveNavDirection): void {
- // Visual-only for now; board always reflects live position.
+ this.facade.requestResign();
}
// ── Timer helpers ─────────────────────────────────────────────
@@ -199,6 +194,11 @@ export class GameComponent implements OnInit, OnDestroy {
this.blackTimerMs = clock.blackRemainingMs < 0
? -1
: Math.max(0, clock.blackRemainingMs - (!activeIsWhite ? elapsed : 0));
+
+ if ((this.whiteTimerMs !== null && this.whiteTimerMs <= 0 && clock.whiteRemainingMs > 0) ||
+ (this.blackTimerMs !== null && this.blackTimerMs <= 0 && clock.blackRemainingMs > 0)) {
+ this.facade.errorMessage = '';
+ }
}
private showToast(msg: string): void {
diff --git a/src/app/pages/game/game.facade.ts b/src/app/pages/game/game.facade.ts
index ae5d4b0..390e735 100644
--- a/src/app/pages/game/game.facade.ts
+++ b/src/app/pages/game/game.facade.ts
@@ -23,6 +23,11 @@ export class GameFacade implements OnDestroy {
gameCompletionMessage = '';
isGameFinished = false;
isPromotionDialogOpen = false;
+ resignConfirmPending = false;
+
+ private fenHistory: string[] = [];
+ private sessionStartPly = 0;
+ viewingPly: number | null = null;
private boardSelection: BoardSelection = {
selectedSquare: null,
@@ -52,6 +57,46 @@ export class GameFacade implements OnDestroy {
return this.boardSelection.highlightedSquares;
}
+ get displayFen(): string {
+ if (this.viewingPly !== null) {
+ const historyIndex = this.viewingPly - this.sessionStartPly;
+ return this.fenHistory[historyIndex] ?? this.game?.state.fen ?? '';
+ }
+ return this.game?.state.fen ?? '';
+ }
+
+ get isReviewing(): boolean {
+ return this.viewingPly !== null;
+ }
+
+ navigateToPly(ply: number): void {
+ const historyIndex = ply - this.sessionStartPly;
+ if (historyIndex < 0 || historyIndex >= this.fenHistory.length) return;
+ this.viewingPly = ply;
+ this.boardSelection = this.boardSelectionService.clearSelection();
+ }
+
+ navigateHistory(direction: 'first' | 'prev' | 'next' | 'last'): void {
+ const totalPly = this.sessionStartPly + this.fenHistory.length - 1;
+ const current = this.viewingPly ?? totalPly;
+
+ let next: number;
+ switch (direction) {
+ case 'first': next = this.sessionStartPly; break;
+ case 'prev': next = Math.max(this.sessionStartPly, current - 1); break;
+ case 'next': next = Math.min(totalPly, current + 1); break;
+ case 'last':
+ default: next = totalPly; break;
+ }
+
+ if (next === totalPly) {
+ this.viewingPly = null;
+ } else {
+ this.viewingPly = next;
+ }
+ this.boardSelection = this.boardSelectionService.clearSelection();
+ }
+
ngOnDestroy(): void {
this.streamService.cleanup();
this.botMoveService.cleanup();
@@ -63,7 +108,7 @@ export class GameFacade implements OnDestroy {
}
onBoardSquareSelected(square: string): void {
- if (!this.state) {
+ if (!this.state || this.viewingPly !== null) {
return;
}
@@ -123,6 +168,8 @@ export class GameFacade implements OnDestroy {
if (this.game) {
this.game = { ...this.game, state };
this.clockSyncedAt = Date.now();
+ this.pushFen(state.fen);
+ this.viewingPly = null;
this.updateGameCompletion();
}
this.moveInput = '';
@@ -171,6 +218,26 @@ export class GameFacade implements OnDestroy {
this.pendingPromotionMoves = [];
}
+ requestResign(): void {
+ this.resignConfirmPending = true;
+ }
+
+ cancelResign(): void {
+ this.resignConfirmPending = false;
+ }
+
+ confirmResign(): void {
+ this.resignConfirmPending = false;
+ this.gameApi
+ .resignGame(this.gameId)
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe({
+ error: (error) => {
+ this.errorMessage = getErrorMessage(error, 'Could not resign.');
+ }
+ });
+ }
+
importFen(): void {
this.errorMessage = '';
this.importService.importFen(
@@ -204,6 +271,8 @@ export class GameFacade implements OnDestroy {
this.errorMessage = '';
this.boardSelection = this.boardSelectionService.clearSelection();
this.streamService.cleanup();
+ this.fenHistory = [];
+ this.viewingPly = null;
this.gameApi
.getGame(this.gameId)
@@ -213,6 +282,8 @@ export class GameFacade implements OnDestroy {
this.game = game;
this.clockSyncedAt = Date.now();
this.loading = false;
+ this.sessionStartPly = game.state.moves.length;
+ this.fenHistory = [game.state.fen];
this.updateGameCompletion();
this.gameHistory.recordGame(this.gameId);
this.startStreaming();
@@ -237,7 +308,10 @@ export class GameFacade implements OnDestroy {
if (event.type === 'gameFull') {
this.game = event.game;
this.clockSyncedAt = Date.now();
- this.boardSelection = this.boardSelectionService.clearSelection();
+ this.pushFen(event.game.state.fen);
+ if (this.viewingPly === null) {
+ this.boardSelection = this.boardSelectionService.clearSelection();
+ }
this.updateGameCompletion();
this.tryMakeBotMove();
return;
@@ -247,8 +321,9 @@ export class GameFacade implements OnDestroy {
const moveCountBefore = this.game.state.moves.length;
this.game = { ...this.game, state: event.state };
this.clockSyncedAt = Date.now();
+ this.pushFen(event.state.fen);
this.updateGameCompletion();
- if (event.state.moves.length !== moveCountBefore) {
+ if (event.state.moves.length !== moveCountBefore && this.viewingPly === null) {
this.boardSelection = this.boardSelectionService.clearSelection();
this.tryMakeBotMove();
}
@@ -260,6 +335,13 @@ export class GameFacade implements OnDestroy {
}
}
+ private pushFen(fen: string): void {
+ const last = this.fenHistory[this.fenHistory.length - 1];
+ if (last !== fen) {
+ this.fenHistory.push(fen);
+ }
+ }
+
private tryMakeBotMove(): void {
this.botMoveService.tryMakeBotMove(
this.gameId,
diff --git a/src/app/pages/games/games.component.ts b/src/app/pages/games/games.component.ts
index 9a78042..dbbfb0f 100644
--- a/src/app/pages/games/games.component.ts
+++ b/src/app/pages/games/games.component.ts
@@ -85,7 +85,14 @@ export class GamesComponent implements OnInit {
}
const requests = ids.map((id) =>
- this.gameApi.getGame(id).pipe(catchError(() => of(null)))
+ this.gameApi.getGame(id).pipe(
+ catchError((err: unknown) => {
+ if (typeof err === 'object' && err !== null && (err as { status: number }).status === 404) {
+ this.gameHistory.removeGame(id);
+ }
+ return of(null);
+ })
+ )
);
forkJoin(requests)
diff --git a/src/app/pages/tournaments/tournaments.component.css b/src/app/pages/tournaments/tournaments.component.css
new file mode 100644
index 0000000..6e40a72
--- /dev/null
+++ b/src/app/pages/tournaments/tournaments.component.css
@@ -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; }
diff --git a/src/app/pages/tournaments/tournaments.component.html b/src/app/pages/tournaments/tournaments.component.html
new file mode 100644
index 0000000..6ce1fe6
--- /dev/null
+++ b/src/app/pages/tournaments/tournaments.component.html
@@ -0,0 +1,273 @@
+
+
+
+
+
+
+
+ @if (loading) {
+
Loading tournaments…
+ } @else if (activeList.length === 0) {
+
+
+
No tournaments here
+
Check back later or look in another tab.
+
+ } @else {
+
+ @for (t of activeList; track t.id) {
+
+
+
+
+
+
+ {{ t.fullName }}
+
+ {{ 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 }
+
+
+
+
+ @if (t.status === 'finished' && t.winner) {
+
🏆 {{ t.winner.name }}
+ }
+ @if (currentUser && t.status === 'created') {
+ @if (t.createdBy === currentUser.id) {
+
+ {{ startingId === t.id ? '…' : 'Start' }}
+
+ }
+
+ Join with bot
+
+ }
+
+
+
+
+ @if (selectedTournament?.id === t.id) {
+
+
+
+ @if (t.standing.players.length > 0) {
+
+ Leaderboard
+
+
+
+ | # |
+ Bot |
+ Pts |
+ Bkh |
+ W/D/L |
+
+
+
+ @for (r of t.standing.players; track r.bot.id) {
+
+ | {{ rankMedal(r.rank) }} |
+ {{ r.bot.name }} |
+ {{ scoreDisplay(r) }} |
+ {{ r.tieBreak }} |
+
+
+ {{ r.wins }}/{{ r.draws }}/{{ r.losses }}
+
+ |
+
+ }
+
+
+
+ } @else {
+
No standings yet — waiting for games to complete.
+ }
+
+
+ @if (t.round > 0) {
+
+ Round {{ t.round }} pairings
+ @if (pairingsLoading) {
+ Loading…
+ } @else if (pairings && pairings.pairings.length > 0) {
+
+ @for (p of pairings.pairings; track p.id) {
+
+ {{ p.white?.name ?? 'Bye' }}
+ vs
+ {{ p.black.name }}
+ @if (p.winner) {
+
+ {{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '1–0' : '0–1' }}
+
+ } @else if (p.gameId) {
+
+
+ Watch
+
+ }
+
+ }
+
+ } @else {
+ No pairings recorded yet.
+ }
+
+ }
+
+
+ }
+
+ }
+
+ }
+
+
+
+
+@if (joinDialogTournamentId) {
+
+
+
+ Join with a bot
+ ×
+
+
Select one of your bots to enter this tournament. Its token will be rotated to authenticate the join.
+
+ @if (botsLoading) {
+
Loading bots…
+ } @else if (userBots.length === 0) {
+
You have no bots yet. Go to Bots in the nav to create one first.
+ } @else {
+
+ @for (bot of userBots; track bot.id) {
+
+ {{ bot.name.charAt(0).toUpperCase() }}
+ {{ bot.name }}
+ {{ bot.rating }}
+ @if (joiningBotId === bot.id) {
+ …
+ }
+
+ }
+
+ }
+
+ @if (joinError) {
+
{{ joinError }}
+ }
+
+
+}
+
+@if (showCreateDialog) {
+
+
+
+ New tournament
+ ×
+
+
+
+
+
+}
diff --git a/src/app/pages/tournaments/tournaments.component.ts b/src/app/pages/tournaments/tournaments.component.ts
new file mode 100644
index 0000000..adfbd83
--- /dev/null
+++ b/src/app/pages/tournaments/tournaments.component.ts
@@ -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; }
+ });
+ }
+}
diff --git a/src/app/pages/welcome/Auth Dialog.html b/src/app/pages/welcome/Auth Dialog.html
new file mode 100644
index 0000000..32dfb08
--- /dev/null
+++ b/src/app/pages/welcome/Auth Dialog.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+ NowChess — Auth Dialog
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/pages/welcome/welcome.component.css b/src/app/pages/welcome/welcome.component.css
index 7317008..e7162bc 100644
--- a/src/app/pages/welcome/welcome.component.css
+++ b/src/app/pages/welcome/welcome.component.css
@@ -652,240 +652,6 @@
font-weight: 600;
}
-/* Speech Bubble Styles */
-.speech-bubble-container {
- position: fixed;
- top: 35%;
- left: 55%;
- transform: translate(-50%, -50%);
- z-index: 500;
- cursor: pointer;
- animation: slideInBubble 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
-}
-
-@keyframes slideInBubble {
- 0% {
- opacity: 0;
- transform: translate(-50%, -50%) scale(0.5);
- }
- 100% {
- opacity: 1;
- transform: translate(-50%, -50%) scale(1);
- }
-}
-
-.speech-bubble {
- background: linear-gradient(135deg, #B9DAD1 0%, #B9C2DA 100%);
- border: 2px solid #8b1270;
- border-radius: 20px;
- padding: 16px 24px;
- font-family: 'Comic Sans MS', 'Comic Sans', cursive;
- font-size: 18px;
- font-weight: bold;
- color: #5A2C28;
- white-space: nowrap;
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2),
- inset 0 1px 3px rgba(255, 255, 255, 0.3);
- position: relative;
- transition: all 0.3s ease;
-}
-
-.speech-bubble:hover {
- transform: scale(1.05);
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3),
- inset 0 1px 3px rgba(255, 255, 255, 0.5);
-}
-
-.bubble-text {
- margin: 0;
-}
-
-.bubble-tail {
- position: absolute;
- bottom: -12px;
- left: 20px;
- width: 0;
- height: 0;
- border-left: 10px solid transparent;
- border-right: 0px solid transparent;
- border-top: 12px solid #B9DAD1;
-}
-
-/* Zoom Overlay and Window */
-.zoom-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.7);
- z-index: 1000;
- display: flex;
- align-items: center;
- justify-content: center;
- animation: fadeIn 0.3s ease;
- cursor: pointer;
-}
-
-@keyframes fadeIn {
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
-}
-
-.zoom-window-wrapper {
- cursor: auto;
- animation: zoomInWindow 1.2s cubic-bezier(0.34, 1.56, 0.64, 1);
-}
-
-@keyframes zoomInWindow {
- 0% {
- transform: scale(0.1);
- opacity: 0;
- }
- 100% {
- transform: scale(1);
- opacity: 1;
- }
-}
-
-.zoom-window-frame {
- background: #13072a;
- border: 8px solid #f26ae2;
- border-radius: 16px;
- padding: 40px 20px 20px 20px;
- box-shadow: 0 0 40px rgba(242, 106, 226, 0.6),
- inset 0 0 20px rgba(242, 106, 226, 0.2);
- max-width: 90vw;
- max-height: 90vh;
- position: relative;
-}
-
-.zoom-player-2 {
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.player-2-gif {
- max-width: 100%;
- max-height: 70vh;
- width: auto;
- height: auto;
- display: block;
- border-radius: 12px;
- cursor: pointer;
- transition: transform 0.2s ease;
-}
-
-.player-2-gif:hover {
- transform: scale(1.02);
-}
-
-.second-speech-bubble {
- position: absolute;
- top: -60px;
- left: 50%;
- transform: translateX(-50%);
- background: linear-gradient(135deg, #C19EF5 0%, #E1EAA9 100%);
- border: 2px solid #BA6D4B;
- border-radius: 20px;
- padding: 12px 18px;
- font-family: 'Comic Sans MS', 'Comic Sans', cursive;
- font-size: 16px;
- font-weight: bold;
- color: #5A2C28;
- white-space: nowrap;
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2),
- inset 0 1px 3px rgba(255, 255, 255, 0.3);
- animation: popInBubble 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
- z-index: 10;
-}
-
-@keyframes popInBubble {
- 0% {
- opacity: 0;
- transform: translateX(-50%) scale(0.3);
- }
- 100% {
- opacity: 1;
- transform: translateX(-50%) scale(1);
- }
-}
-
-.second-speech-bubble .bubble-tail {
- top: 100%;
- bottom: auto;
- left: 50%;
- transform: translateX(-50%);
- border-top: 12px solid #C19EF5;
-}
-
-/* Happy Meow Bubble */
-.happy-speech-bubble {
- position: absolute;
- top: -60px;
- left: 50%;
- transform: translateX(-50%);
- background: linear-gradient(135deg, #F3C8A0 0%, #BA6D4B 100%);
- border: 2px solid #5A2C28;
- border-radius: 20px;
- padding: 12px 18px;
- font-family: 'Comic Sans MS', 'Comic Sans', cursive;
- font-size: 16px;
- font-weight: bold;
- color: #fff;
- white-space: nowrap;
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3),
- inset 0 1px 3px rgba(255, 255, 255, 0.4),
- 0 0 20px rgba(243, 200, 160, 0.5);
- animation: popInBubble 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
- z-index: 10;
-}
-
-.happy-speech-bubble .bubble-tail {
- top: 100%;
- bottom: auto;
- left: 50%;
- transform: translateX(-50%);
- border-top: 12px solid #F3C8A0;
-}
-
-/* Meat Emoji */
-.meat-emoji {
- position: fixed;
- font-size: 48px;
- cursor: grab;
- user-select: none;
- z-index: 1001;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 60px;
- height: 60px;
- animation: meatAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
- filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
- transition: transform 0.1s ease;
-}
-
-.meat-emoji:active {
- cursor: grabbing;
- transform: scale(1.1);
- filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
-}
-
-@keyframes meatAppear {
- 0% {
- opacity: 0;
- transform: scale(0);
- }
- 100% {
- opacity: 1;
- transform: scale(1);
- }
-}
-
@media (max-width: 900px) {
.bwrap {
transform: scale(0.9);
diff --git a/src/app/pages/welcome/welcome.component.html b/src/app/pages/welcome/welcome.component.html
index e192bff..73cbf94 100644
--- a/src/app/pages/welcome/welcome.component.html
+++ b/src/app/pages/welcome/welcome.component.html
@@ -46,13 +46,6 @@
-
-
- @if (showMeatEmoji) {
-
- 🍖
-
- }
@@ -145,50 +138,6 @@
-
- @if (showSpeechBubble) {
-
-
-
{{ bubbleMessage }}
-
-
-
- }
-
-
- @if (isZoomedIn) {
-
-
-
-
-

- @if (showSecondSpeechBubble) {
-
- }
- @if (showHappyBubble) {
-
- }
-
-
-
-
- @if (showMeatEmoji) {
-
- 🍖
-
- }
-
-
- }
-
diff --git a/src/app/pages/welcome/welcome.component.ts b/src/app/pages/welcome/welcome.component.ts
index 9a3bbaa..5126331 100644
--- a/src/app/pages/welcome/welcome.component.ts
+++ b/src/app/pages/welcome/welcome.component.ts
@@ -79,28 +79,11 @@ export class WelcomeComponent implements OnInit, OnDestroy {
private authDialogState: 'login' | 'register' | null = null;
private pendingAction: (() => void) | null = null;
- // Speech bubble and zoom features
- showSpeechBubble = false;
- isZoomedIn = false;
- showSecondSpeechBubble = false;
- showHappyBubble = false;
- showMeatEmoji = false;
- bubbleMessage = 'meow';
-
- // Meat emoji drag state
- meatX = 0;
- meatY = 0;
- isDraggingMeat = false;
- meatDragOffsetX = 0;
- meatDragOffsetY = 0;
-
stars: Star[] = [];
bgBuildings: BackgroundBuilding[] = [];
windows: Record = {};
private flickerIntervalId: ReturnType | undefined;
- private speechBubbleTimeoutId: ReturnType | undefined;
- private zoomTimeoutId: ReturnType | undefined;
private coolColors = ['#7de8ff', '#00d5ff', '#5bc0de', '#31b0d5', '#4fc3f7', '#29b6f6'];
private coolGlowColors = ['#00d5ff', '#00d5ff', '#31b0d5', '#31b0d5', '#03a9f4', '#0288d1'];
@@ -140,21 +123,10 @@ export class WelcomeComponent implements OnInit, OnDestroy {
this.generateBackgroundBuildings();
this.generateWindowsForAllBuildings();
this.startWindowFlicker();
-
- // Show speech bubble after 5 seconds
- this.speechBubbleTimeoutId = setTimeout(() => {
- this.showSpeechBubble = true;
- }, 5000);
}
ngOnDestroy(): void {
this.stopWindowFlicker();
- if (this.speechBubbleTimeoutId) {
- clearTimeout(this.speechBubbleTimeoutId);
- }
- if (this.zoomTimeoutId) {
- clearTimeout(this.zoomTimeoutId);
- }
}
openDifficultyDialog(): void {
@@ -265,84 +237,6 @@ export class WelcomeComponent implements OnInit, OnDestroy {
this.performSubmitImportGame();
}
- onSpeechBubbleClick(): void {
- this.showSpeechBubble = false;
- this.isZoomedIn = true;
- this.bubbleMessage = 'meow';
- this.showMeatEmoji = true;
- this.showHappyBubble = false;
- this.showSecondSpeechBubble = true;
-
- // Reset meat position
- this.meatX = window.innerWidth / 2 - 100;
- this.meatY = window.innerHeight / 2 + 150;
- }
-
- onZoomedViewClick(): void {
- this.isZoomedIn = false;
- this.showSecondSpeechBubble = false;
- this.showHappyBubble = false;
- this.showMeatEmoji = false;
- this.bubbleMessage = 'meow';
-
- if (this.zoomTimeoutId) {
- clearTimeout(this.zoomTimeoutId);
- }
- }
-
- onMeatMouseDown(event: MouseEvent): void {
- this.isDraggingMeat = true;
- const rect = (event.target as HTMLElement).getBoundingClientRect();
- this.meatDragOffsetX = event.clientX - rect.left;
- this.meatDragOffsetY = event.clientY - rect.top;
- }
-
- onMouseMove(event: MouseEvent): void {
- if (!this.isDraggingMeat) {
- return;
- }
-
- this.meatX = event.clientX - this.meatDragOffsetX;
- this.meatY = event.clientY - this.meatDragOffsetY;
-
- const gifElement = document.querySelector('.player-2-gif') as HTMLElement;
- if (!gifElement) {
- return;
- }
-
- const gifRect = gifElement.getBoundingClientRect();
- const gifCenterX = gifRect.left + gifRect.width / 2;
- const gifCenterY = gifRect.top + gifRect.height / 2;
-
- const meatElement = document.querySelector('.meat-emoji') as HTMLElement;
- if (!meatElement) {
- return;
- }
-
- const meatRect = meatElement.getBoundingClientRect();
- const meatCenterX = meatRect.left + meatRect.width / 2;
- const meatCenterY = meatRect.top + meatRect.height / 2;
-
- const distance = Math.sqrt(
- Math.pow(meatCenterX - gifCenterX, 2) + Math.pow(meatCenterY - gifCenterY, 2)
- );
-
- if (distance < 50) {
- this.onMeatFed();
- }
- }
-
- onMouseUp(): void {
- this.isDraggingMeat = false;
- }
-
- onMeatFed(): void {
- this.showMeatEmoji = false;
- this.showSecondSpeechBubble = false;
- this.showHappyBubble = true;
- this.isDraggingMeat = false;
- }
-
private requireAuth(action: () => void): boolean {
if (this.authService.isLoggedIn()) {
return true;
diff --git a/src/app/services/auth.interceptor.ts b/src/app/services/auth.interceptor.ts
index 673722e..4759842 100644
--- a/src/app/services/auth.interceptor.ts
+++ b/src/app/services/auth.interceptor.ts
@@ -8,9 +8,10 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
req.url.includes('/api/account/bots') ||
req.url.includes('/api/account/official-bots') ||
req.url.includes('/api/board/game') ||
- req.url.includes('/api/challenge');
+ req.url.includes('/api/challenge') ||
+ req.url.includes('/api/tournament');
- if (token && isProtectedEndpoint) {
+ if (token && isProtectedEndpoint && !req.headers.has('Authorization')) {
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts
index cf34768..cdce9c6 100644
--- a/src/app/services/auth.service.ts
+++ b/src/app/services/auth.service.ts
@@ -26,9 +26,9 @@ export class AuthService {
})
.pipe(
tap((response) => {
- localStorage.setItem('token', response.token);
+ localStorage.setItem('token', response.accessToken); //GRRRRRRRRRR
+ localStorage.setItem('refreshToken', response.refreshToken);
localStorage.setItem('username', username);
- // After login, fetch current user info
this.getCurrentUser().subscribe();
})
);
@@ -60,6 +60,7 @@ export class AuthService {
logout(): void {
localStorage.removeItem('token');
+ localStorage.removeItem('refreshToken');
localStorage.removeItem('username');
localStorage.removeItem('userId');
this.currentUserSubject.next(null);
diff --git a/src/app/services/bot.service.ts b/src/app/services/bot.service.ts
new file mode 100644
index 0000000..fa77c29
--- /dev/null
+++ b/src/app/services/bot.service.ts
@@ -0,0 +1,27 @@
+import { Injectable, inject } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { Observable, map } from 'rxjs';
+import { Bot, BotWithToken } from '../models/bot.models';
+
+@Injectable({ providedIn: 'root' })
+export class BotService {
+ private readonly http = inject(HttpClient);
+ private readonly base = '/api/account/bots';
+
+ list(): Observable {
+ return this.http.get(this.base);
+ }
+
+ create(name: string): Observable {
+ return this.http.post(this.base, { name });
+ }
+
+ rotateToken(botId: string): Observable {
+ return this.http.post<{ token: string }>(`${this.base}/${botId}/rotate-token`, null)
+ .pipe(map(r => r.token));
+ }
+
+ delete(botId: string): Observable {
+ return this.http.delete(`${this.base}/${botId}`);
+ }
+}
diff --git a/src/app/services/game-api.service.ts b/src/app/services/game-api.service.ts
index 833b747..726fd64 100644
--- a/src/app/services/game-api.service.ts
+++ b/src/app/services/game-api.service.ts
@@ -67,6 +67,14 @@ export class GameApiService {
return this.http.post(`${this.apiBase}${this.apiPath}/import/pgn`, { pgn });
}
+ resignGame(gameId: string): Observable {
+ return this.http.post(`${this.apiBase}${this.apiPath}/${gameId}/resign`, {});
+ }
+
+ offerDraw(gameId: string): Observable {
+ return this.http.post(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {});
+ }
+
private resolveWsBase(): string {
if (this.wsBase) {
return this.wsBase;
@@ -77,7 +85,11 @@ export class GameApiService {
}
streamGame(gameId: string): Observable {
- const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
+ const token = localStorage.getItem('token');
+ let wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
+ if (token) {
+ wsUrl += `?token=${encodeURIComponent(token)}`;
+ }
return this.streamHandler.createGameStream(wsUrl, gameId);
}
}
diff --git a/src/app/services/game-completion.service.ts b/src/app/services/game-completion.service.ts
index 6025b08..c0c692d 100644
--- a/src/app/services/game-completion.service.ts
+++ b/src/app/services/game-completion.service.ts
@@ -24,23 +24,28 @@ export class GameCompletionService {
return { isFinished: true, message };
}
+ isTimeOut(state: GameState | null): boolean {
+ if (!state?.clock) return false;
+ return state.clock.whiteRemainingMs <= 0 || state.clock.blackRemainingMs <= 0;
+ }
+
private buildCompletionMessage(status: GameStatus, state: GameState, game: GameFull): string {
+ const winner = state.winner === 'white' ? game.white.displayName : state.winner === 'black' ? game.black.displayName : null;
+ const loser = state.winner === 'white' ? game.black.displayName : state.winner === 'black' ? game.white.displayName : null;
+
switch (status) {
case 'checkmate':
- const winner = state.winner === 'white' ? game.white.displayName : game.black.displayName;
- return `Checkmate! ${winner} wins!`;
+ return winner ? `Checkmate — ${winner} wins!` : 'Checkmate!';
case 'stalemate':
- return 'Stalemate! The game is a draw.';
+ return 'Stalemate — draw!';
case 'resign':
- const resignedPlayer = state.winner === 'white' ? game.black.displayName : game.white.displayName;
- const resignedWinner = state.winner === 'white' ? game.white.displayName : game.black.displayName;
- return `${resignedPlayer} resigned. ${resignedWinner} wins!`;
+ return loser && winner ? `${loser} resigned — ${winner} wins!` : 'Resigned.';
case 'draw':
- return 'Draw! The game ended in a draw.';
+ return 'Draw by agreement.';
case 'insufficientMaterial':
- return 'Insufficient material! The game is a draw.';
+ return 'Draw — insufficient material.';
default:
- return 'Game ended!';
+ return 'Game over.';
}
}
}
diff --git a/src/app/services/tournament.service.ts b/src/app/services/tournament.service.ts
new file mode 100644
index 0000000..493d400
--- /dev/null
+++ b/src/app/services/tournament.service.ts
@@ -0,0 +1,52 @@
+import { Injectable, inject } from '@angular/core';
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+import { Observable } from 'rxjs';
+import { Tournament, TournamentList, RoundPairings } from '../models/tournament.models';
+
+export interface CreateTournamentForm {
+ name: string;
+ nbRounds: number;
+ clockLimitMinutes: number;
+ clockIncrement: number;
+ rated: boolean;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TournamentService {
+ private readonly http = inject(HttpClient);
+ private readonly base = '/api/tournament';
+
+ list(): Observable {
+ return this.http.get(this.base);
+ }
+
+ get(id: string): Observable {
+ return this.http.get(`${this.base}/${id}`);
+ }
+
+ create(form: CreateTournamentForm): Observable {
+ const body = new URLSearchParams();
+ body.set('name', form.name);
+ body.set('nbRounds', String(form.nbRounds));
+ body.set('clockLimit', String(form.clockLimitMinutes * 60));
+ body.set('clockIncrement', String(form.clockIncrement));
+ body.set('rated', String(form.rated));
+ return this.http.post(this.base, body.toString(), {
+ headers: new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' })
+ });
+ }
+
+ start(id: string): Observable {
+ return this.http.post(`${this.base}/${id}/start`, null);
+ }
+
+ joinWithBotToken(id: string, botToken: string): Observable {
+ return this.http.post(`${this.base}/${id}/join`, null, {
+ headers: new HttpHeaders({ Authorization: `Bearer ${botToken}` })
+ });
+ }
+
+ roundPairings(id: string, round: number): Observable {
+ return this.http.get(`${this.base}/${id}/round/${round}`);
+ }
+}
diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts
index fa74ddd..0bb31eb 100644
--- a/src/environments/environment.development.ts
+++ b/src/environments/environment.development.ts
@@ -2,7 +2,7 @@ export const environment = {
production: false,
apiBaseUrl: '',
accountServiceUrl: '',
- wsBaseUrl: '',
+ wsBaseUrl: 'ws://localhost:8084',
userWsBaseUrl: 'ws://localhost:8084',
apiPath: '/api/board/game'
};