fix: NCWF-4 Token Issues (#8)
Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de> Co-authored-by: shahdlala66 <shahd.lala66@gmail.com> Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"/api/tournament": {
|
||||
"target": "http://localhost:8089",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/api/account": {
|
||||
"target": "http://localhost:8083",
|
||||
"secure": false,
|
||||
|
||||
@@ -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: '' }
|
||||
];
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -1,34 +1,49 @@
|
||||
<div class="dialog-overlay" (click)="closeDialog()">
|
||||
<div class="dialog-card" (click)="$event.stopPropagation()">
|
||||
<div class="dialog-title">LOGIN</div>
|
||||
<div class="dialog-head">
|
||||
<span class="brand-tag">NowChess // Auth</span>
|
||||
<button type="button" class="close-btn" (click)="closeDialog()" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<h2 class="dialog-title">Welcome back</h2>
|
||||
<div class="dialog-subtitle">Sign in to continue your match</div>
|
||||
|
||||
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
|
||||
<label for="username" class="sr-only">Username</label>
|
||||
<input id="username" type="text" class="dialog-input" formControlName="username" placeholder="Username" />
|
||||
@if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) {
|
||||
<small class="text-danger">Username must be at least 3 characters</small>
|
||||
}
|
||||
<div class="field">
|
||||
<label for="username" class="field-label">Username</label>
|
||||
<input id="username" type="text" class="dialog-input" formControlName="username"
|
||||
placeholder="your_handle" autocomplete="username" />
|
||||
@if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) {
|
||||
<small class="text-danger">Username must be at least 3 characters</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<label for="password" class="sr-only">Password</label>
|
||||
<input id="password" type="password" class="dialog-input" formControlName="password" placeholder="Password" />
|
||||
@if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) {
|
||||
<small class="text-danger">Password must be at least 6 characters</small>
|
||||
}
|
||||
<div class="field">
|
||||
<label for="password" class="field-label">Password</label>
|
||||
<input id="password" type="password" class="dialog-input" formControlName="password"
|
||||
placeholder="••••••••" autocomplete="current-password" />
|
||||
@if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) {
|
||||
<small class="text-danger">Password must be at least 6 characters</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="error-banner">{{ errorMessage }}</div>
|
||||
<div class="error-banner">{{ errorMessage }}</div>
|
||||
}
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="app-btn" (click)="openRegister()">Create account</button>
|
||||
<button type="button" class="app-btn" (click)="closeDialog()">Cancel</button>
|
||||
<button type="submit" class="app-btn" [disabled]="isLoading || loginForm.invalid">
|
||||
<button type="button" class="btn btn-ghost" (click)="closeDialog()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="isLoading || loginForm.invalid">
|
||||
@if (isLoading) {
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
}
|
||||
Login
|
||||
{{ isLoading ? 'Signing in…' : 'Sign in' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="alt-line">
|
||||
New here?<a (click)="openRegister()">Create an account</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<div class="moves" role="list">
|
||||
<div class="moves" role="list" #movesEl>
|
||||
@if (movePairs.length === 0) {
|
||||
<div class="moves-empty">No moves yet.</div>
|
||||
} @else {
|
||||
@for (pair of movePairs; track $index) {
|
||||
<div class="mv-num" role="presentation">{{ $index + 1 }}</div>
|
||||
<div class="mv" [class.current]="currentWhiteIndex === $index" role="listitem">
|
||||
<div class="mv" [class.current]="isWhiteViewing($index)" role="listitem"
|
||||
tabindex="0" (click)="clickWhite($index)" (keydown.enter)="clickWhite($index)">
|
||||
{{ pair.white }}
|
||||
</div>
|
||||
<div class="mv" [class.current]="currentBlackIndex === $index" [class.mv-empty]="!pair.black" role="listitem">
|
||||
<div class="mv" [class.current]="isBlackViewing($index)" [class.mv-empty]="!pair.black" role="listitem"
|
||||
[attr.tabindex]="pair.black ? 0 : null"
|
||||
(click)="clickBlack($index, pair.black)" (keydown.enter)="clickBlack($index, pair.black)">
|
||||
{{ pair.black ?? '…' }}
|
||||
</div>
|
||||
}
|
||||
@@ -31,13 +34,13 @@
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Last move" (click)="navigate.emit('last')">
|
||||
<button class="icon-btn" title="Last move / return to live" (click)="navigate.emit('last')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@if (plyCount > 0) {
|
||||
<span class="live-label">LIVE</span>
|
||||
<span class="live-label" [class.reviewing]="!isLive">{{ isLive ? 'LIVE' : 'REVIEWING' }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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<MoveNavDirection>();
|
||||
@Output() navigateToPly = new EventEmitter<number>();
|
||||
|
||||
@ViewChild('movesEl') movesEl?: ElementRef<HTMLElement>;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -1,40 +1,65 @@
|
||||
<div class="dialog-overlay" (click)="closeDialog()">
|
||||
<div class="dialog-card" (click)="$event.stopPropagation()">
|
||||
<div class="dialog-title">CREATE ACCOUNT</div>
|
||||
<div class="dialog-head">
|
||||
<span class="brand-tag">NowChess // Register</span>
|
||||
<button type="button" class="close-btn" (click)="closeDialog()" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<h2 class="dialog-title">Create account</h2>
|
||||
<div class="dialog-subtitle">Join the board and start playing</div>
|
||||
|
||||
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
|
||||
<input id="username" type="text" class="dialog-input" formControlName="username" placeholder="Username" />
|
||||
@if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) {
|
||||
<small class="text-danger">Username must be at least 3 characters</small>
|
||||
}
|
||||
<div class="field">
|
||||
<label for="username" class="field-label">Username</label>
|
||||
<input id="username" type="text" class="dialog-input" formControlName="username"
|
||||
placeholder="your_handle" autocomplete="username" />
|
||||
@if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) {
|
||||
<small class="text-danger">Username must be at least 3 characters</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<input id="email" type="email" class="dialog-input" formControlName="email" placeholder="Email" />
|
||||
@if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) {
|
||||
<small class="text-danger">Please enter a valid email</small>
|
||||
}
|
||||
<div class="field">
|
||||
<label for="email" class="field-label">Email</label>
|
||||
<input id="email" type="email" class="dialog-input" formControlName="email"
|
||||
placeholder="you@domain.com" autocomplete="email" />
|
||||
@if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) {
|
||||
<small class="text-danger">Please enter a valid email</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<input id="password" type="password" class="dialog-input" formControlName="password" placeholder="Password" />
|
||||
@if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) {
|
||||
<small class="text-danger">Password must be at least 6 characters</small>
|
||||
}
|
||||
|
||||
<input id="confirmPassword" type="password" class="dialog-input" formControlName="confirmPassword"
|
||||
placeholder="Confirm Password" />
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="password" class="field-label">Password</label>
|
||||
<input id="password" type="password" class="dialog-input" formControlName="password"
|
||||
placeholder="••••••••" autocomplete="new-password" />
|
||||
@if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) {
|
||||
<small class="text-danger">Min 6 characters</small>
|
||||
}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="confirmPassword" class="field-label">Confirm</label>
|
||||
<input id="confirmPassword" type="password" class="dialog-input" formControlName="confirmPassword"
|
||||
placeholder="••••••••" autocomplete="new-password" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="error-banner">{{ errorMessage }}</div>
|
||||
<div class="error-banner">{{ errorMessage }}</div>
|
||||
}
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="app-btn" (click)="openLogin()">Already have an account?</button>
|
||||
<button type="button" class="app-btn" (click)="closeDialog()">Cancel</button>
|
||||
<button type="submit" class="app-btn" [disabled]="isLoading || registerForm.invalid">
|
||||
<button type="button" class="btn btn-ghost" (click)="closeDialog()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="isLoading || registerForm.invalid">
|
||||
@if (isLoading) {
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
}
|
||||
Register
|
||||
{{ isLoading ? 'Creating…' : 'Create account' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="alt-line">
|
||||
Already have an account?<a (click)="openLogin()">Sign in</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
</svg>
|
||||
Watch
|
||||
</button>
|
||||
<button type="button" class="nc-link">Leaderboard</button>
|
||||
<button type="button" class="nc-link" (click)="goToTournaments()">Tournaments</button>
|
||||
<button type="button" class="nc-link" (click)="goToBots()">Bots</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export class ToolbarComponent implements OnInit {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
private pollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly navigatedChallengeIds = new Set<string>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ export interface RegisterResponse {
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface Bot {
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BotWithToken extends Bot {
|
||||
token: string;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -0,0 +1,125 @@
|
||||
<div class="b-shell">
|
||||
<div class="page">
|
||||
|
||||
<nav class="crumb">
|
||||
<a routerLink="/" class="crumb-link">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
Back to lobby
|
||||
</a>
|
||||
<span class="crumb-sep">/</span>
|
||||
<span class="crumb-current">My Bots</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div class="title-row">
|
||||
<h1 class="page-title">My Bots</h1>
|
||||
<button type="button" class="btn-new" (click)="openCreate()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
New bot
|
||||
</button>
|
||||
</div>
|
||||
<p class="page-sub">Bots are automated players owned by your account. Each has a token used to join tournaments and make moves.</p>
|
||||
</header>
|
||||
|
||||
@if (showCreate) {
|
||||
<div class="create-panel">
|
||||
<div class="create-inner">
|
||||
<label class="field-label">Bot name</label>
|
||||
<div class="create-row">
|
||||
<input type="text" class="text-input" [(ngModel)]="newBotName"
|
||||
placeholder="e.g. AlphaBot" (keydown.enter)="submitCreate()"
|
||||
[disabled]="creating" maxlength="40" />
|
||||
<button type="button" class="btn-primary" (click)="submitCreate()"
|
||||
[disabled]="creating || !newBotName.trim()">
|
||||
{{ creating ? 'Creating…' : 'Create' }}
|
||||
</button>
|
||||
<button type="button" class="btn-ghost" (click)="cancelCreate()" [disabled]="creating">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@if (createError) {
|
||||
<p class="error-text">{{ createError }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="state-msg"><span class="pulse"></span>Loading bots…</div>
|
||||
} @else if (bots.length === 0) {
|
||||
<div class="empty-state">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" class="empty-icon">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
<p class="empty-title">No bots yet</p>
|
||||
<p class="empty-sub">Create a bot to join tournaments and play automated games.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="bot-list">
|
||||
@for (bot of bots; track bot.id) {
|
||||
<div class="bot-card">
|
||||
<div class="bot-main">
|
||||
<div class="bot-avatar">{{ bot.name.charAt(0).toUpperCase() }}</div>
|
||||
<div class="bot-info">
|
||||
<span class="bot-name">{{ bot.name }}</span>
|
||||
<span class="bot-meta">Rating {{ bot.rating }} · Created {{ bot.createdAt | date:'MMM d, yyyy' }}</span>
|
||||
</div>
|
||||
<div class="bot-actions">
|
||||
<button type="button" class="btn-token"
|
||||
[class.active]="!!revealedTokens[bot.id]"
|
||||
[disabled]="revealingId === bot.id"
|
||||
(click)="revealToken(bot.id)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@if (revealedTokens[bot.id]) {
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
} @else {
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
}
|
||||
</svg>
|
||||
{{ revealingId === bot.id ? '…' : (revealedTokens[bot.id] ? 'Hide' : 'Token') }}
|
||||
</button>
|
||||
<button type="button" class="btn-danger"
|
||||
[disabled]="deletingId === bot.id"
|
||||
(click)="deleteBot(bot.id)">
|
||||
{{ deletingId === bot.id ? '…' : 'Delete' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (revealedTokens[bot.id]) {
|
||||
<div class="token-panel">
|
||||
<div class="token-warning">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
Token was just regenerated — the old one is now invalid. Keep this secret.
|
||||
</div>
|
||||
<div class="token-row">
|
||||
<code class="token-value">{{ revealedTokens[bot.id] }}</code>
|
||||
<button type="button" class="btn-copy" (click)="copyToken(bot.id)">
|
||||
{{ copiedId === bot.id ? '✓ Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<string, string> = {};
|
||||
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; }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
============================================================ */
|
||||
|
||||
@@ -72,8 +72,24 @@
|
||||
<!-- Game completed banner -->
|
||||
@if (facade.isGameFinished && facade.gameCompletionMessage) {
|
||||
<div class="completion-banner">
|
||||
<span class="completion-title">{{ facade.gameCompletionMessage }}</span>
|
||||
<a routerLink="/" class="completion-link">Start new game</a>
|
||||
<div class="completion-left">
|
||||
<span class="completion-icon">♟</span>
|
||||
<div>
|
||||
<div class="completion-title">{{ facade.gameCompletionMessage }}</div>
|
||||
<div class="completion-sub">Game #{{ facade.gameId }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="completion-actions">
|
||||
<a routerLink="/games" class="completion-link">My games</a>
|
||||
<a routerLink="/" class="btn btn-primary completion-new">New game</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!facade.isGameFinished && ((whiteTimerMs !== null && whiteTimerMs <= 0) || (blackTimerMs !== null && blackTimerMs <= 0))) {
|
||||
<div class="completion-banner completion-banner--timeout">
|
||||
<span class="completion-title">Time's up!</span>
|
||||
<span class="completion-sub">Waiting for server to confirm result…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -104,11 +120,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Board -->
|
||||
<div class="board-wrap">
|
||||
<div class="board-wrap" [class.reviewing]="facade.isReviewing">
|
||||
<app-chess-board
|
||||
[fen]="facade.state.fen"
|
||||
[selectedSquare]="facade.selectedSquare"
|
||||
[highlightedSquares]="facade.highlightedSquares"
|
||||
[fen]="facade.displayFen"
|
||||
[selectedSquare]="facade.isReviewing ? null : facade.selectedSquare"
|
||||
[highlightedSquares]="facade.isReviewing ? [] : facade.highlightedSquares"
|
||||
[boardTheme]="boardTheme"
|
||||
(squareSelected)="facade.onBoardSquareSelected($event)" />
|
||||
</div>
|
||||
@@ -146,7 +162,9 @@
|
||||
</summary>
|
||||
<app-move-history
|
||||
[moves]="facade.state.moves"
|
||||
(navigate)="onMoveNavigate($event)" />
|
||||
[viewingPly]="facade.viewingPly"
|
||||
(navigate)="facade.navigateHistory($event)"
|
||||
(navigateToPly)="facade.navigateToPly($event)" />
|
||||
</details>
|
||||
|
||||
<!-- Play move (collapsible) -->
|
||||
@@ -200,6 +218,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resign confirmation dialog -->
|
||||
@if (facade.resignConfirmPending) {
|
||||
<div class="confirm-overlay" role="dialog" aria-modal="true" aria-label="Confirm resign">
|
||||
<div class="confirm-box">
|
||||
<p class="confirm-title">Resign this game?</p>
|
||||
<p class="confirm-sub">Your opponent will be declared the winner.</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn" type="button" (click)="facade.cancelResign()">Cancel</button>
|
||||
<button class="btn btn-danger-solid" type="button" (click)="facade.confirmResign()">Yes, resign</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Toast notification -->
|
||||
@if (toastMessage) {
|
||||
<div class="toast show">{{ toastMessage }}</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
:host {
|
||||
--nc-neon: #ff45c8;
|
||||
--nc-bg: #06060d;
|
||||
--nc-surface: rgba(20, 17, 42, 0.6);
|
||||
--nc-text: #fff;
|
||||
--nc-text-muted: rgba(255, 255, 255, 0.65);
|
||||
--nc-text-dim: rgba(255, 255, 255, 0.45);
|
||||
--nc-border: rgba(255, 255, 255, 0.08);
|
||||
--nc-border-strong: rgba(255, 255, 255, 0.15);
|
||||
--nc-success: #5ee5a1;
|
||||
--nc-danger: #ff7a7a;
|
||||
--nc-warn: #ffd166;
|
||||
--nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
background: var(--nc-bg);
|
||||
font-family: var(--nc-sans);
|
||||
color: var(--nc-text);
|
||||
}
|
||||
|
||||
:host-context(html:not([data-theme='dark'])) {
|
||||
--nc-neon: #c026d3;
|
||||
--nc-bg: #f5f0fc;
|
||||
--nc-surface: rgba(255, 255, 255, 0.88);
|
||||
--nc-text: #0f0022;
|
||||
--nc-text-muted: rgba(15, 0, 34, 0.65);
|
||||
--nc-text-dim: rgba(15, 0, 34, 0.4);
|
||||
--nc-border: rgba(15, 0, 34, 0.1);
|
||||
--nc-border-strong: rgba(15, 0, 34, 0.2);
|
||||
--nc-success: #16a34a;
|
||||
--nc-danger: #dc2626;
|
||||
--nc-warn: #b45309;
|
||||
}
|
||||
|
||||
.t-shell { padding-top: 72px; min-height: 100vh; }
|
||||
|
||||
.page {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 64px;
|
||||
}
|
||||
|
||||
/* Breadcrumb */
|
||||
.crumb {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-bottom: 28px; font-size: 11px;
|
||||
color: var(--nc-text-dim); letter-spacing: 0.06em;
|
||||
}
|
||||
.crumb-link {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
color: var(--nc-text-dim); text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.crumb-link:hover { color: var(--nc-neon); }
|
||||
.crumb-sep { opacity: 0.35; }
|
||||
.crumb-current { color: var(--nc-text-muted); }
|
||||
|
||||
/* Header */
|
||||
.page-header { margin-bottom: 28px; }
|
||||
.page-title-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
|
||||
|
||||
.btn-new {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 14px; border-radius: 8px; border: none;
|
||||
background: var(--nc-neon); color: #fff;
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-new:hover { opacity: 0.85; }
|
||||
|
||||
/* Create dialog */
|
||||
.dialog-overlay {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
background: rgba(0,0,0,0.55); backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.dialog-card {
|
||||
background: var(--nc-bg); border: 1px solid var(--nc-border-strong);
|
||||
border-radius: 16px; padding: 24px; width: 100%; max-width: 420px;
|
||||
}
|
||||
.dialog-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.dialog-brand { font-size: 14px; font-weight: 700; color: var(--nc-neon); }
|
||||
.dialog-close {
|
||||
background: none; border: none; cursor: pointer;
|
||||
font-size: 20px; line-height: 1; color: var(--nc-text-muted);
|
||||
padding: 0 4px;
|
||||
}
|
||||
.dialog-close:hover { color: var(--nc-text); }
|
||||
.dialog-field { display: flex; flex-direction: column; gap: 6px; flex: 1; }
|
||||
.dialog-label { font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--nc-text-muted); }
|
||||
.dialog-input {
|
||||
width: 100%; padding: 8px 10px; border-radius: 8px;
|
||||
border: 1px solid var(--nc-border-strong);
|
||||
background: rgba(255,255,255,0.04); color: var(--nc-text);
|
||||
font-size: 14px; box-sizing: border-box;
|
||||
}
|
||||
.dialog-input:focus { outline: 2px solid var(--nc-neon); outline-offset: 1px; border-color: transparent; }
|
||||
.dialog-row { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.dialog-field:not(.dialog-row .dialog-field) { margin-bottom: 16px; }
|
||||
.dialog-toggle {
|
||||
display: flex; align-items: center; gap: 10px; cursor: pointer;
|
||||
margin-bottom: 20px; user-select: none;
|
||||
}
|
||||
.dialog-toggle input[type=checkbox] { display: none; }
|
||||
.toggle-track {
|
||||
width: 36px; height: 20px; border-radius: 10px;
|
||||
background: var(--nc-border-strong); flex-shrink: 0;
|
||||
transition: background 0.2s; position: relative;
|
||||
}
|
||||
.toggle-track::after {
|
||||
content: ''; position: absolute; top: 3px; left: 3px;
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: var(--nc-text-muted); transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
.dialog-toggle input:checked ~ .toggle-track { background: var(--nc-neon); }
|
||||
.dialog-toggle input:checked ~ .toggle-track::after { transform: translateX(16px); background: #fff; }
|
||||
.toggle-label { font-size: 14px; color: var(--nc-text); }
|
||||
.dialog-error {
|
||||
font-size: 13px; color: var(--nc-danger);
|
||||
background: rgba(255,122,122,0.1); border-radius: 8px;
|
||||
padding: 10px 12px; margin-bottom: 16px;
|
||||
}
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; }
|
||||
.btn-ghost {
|
||||
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--nc-border-strong);
|
||||
background: transparent; color: var(--nc-text-muted); font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.btn-ghost:hover { color: var(--nc-text); border-color: var(--nc-text-muted); }
|
||||
.btn-primary {
|
||||
padding: 8px 18px; border-radius: 8px; border: none;
|
||||
background: var(--nc-neon); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs { display: flex; gap: 4px; }
|
||||
.tab-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 14px; border-radius: 8px; border: none;
|
||||
background: transparent; color: var(--nc-text-muted);
|
||||
font-size: 13px; font-weight: 500; cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.tab-btn:hover { background: var(--nc-border); color: var(--nc-text); }
|
||||
.tab-btn.active { background: var(--nc-surface); color: var(--nc-text); border: 1px solid var(--nc-border-strong); }
|
||||
.tab-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 18px; height: 18px; padding: 0 5px;
|
||||
border-radius: 9px; background: var(--nc-border-strong);
|
||||
font-size: 10px; font-weight: 700; color: var(--nc-text-muted);
|
||||
}
|
||||
.live-badge { background: rgba(94, 229, 161, 0.2); color: var(--nc-success); }
|
||||
|
||||
/* States */
|
||||
.state-msg {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 24px 0; color: var(--nc-text-muted); font-size: 13px;
|
||||
}
|
||||
.state-msg.small { padding: 12px 0; }
|
||||
.pulse {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--nc-neon); flex-shrink: 0;
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.85); }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 8px; padding: 64px 0; text-align: center;
|
||||
}
|
||||
.empty-icon { color: var(--nc-text-dim); margin-bottom: 4px; }
|
||||
.empty-title { font-size: 15px; font-weight: 600; margin: 0; }
|
||||
.empty-sub { font-size: 13px; color: var(--nc-text-muted); margin: 0; }
|
||||
|
||||
/* Tournament list */
|
||||
.t-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.t-card {
|
||||
border: 1px solid var(--nc-border);
|
||||
border-radius: 12px;
|
||||
background: var(--nc-surface);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.t-card:hover, .t-card.expanded {
|
||||
border-color: var(--nc-border-strong);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.t-card:focus-visible { outline: 2px solid var(--nc-neon); outline-offset: 2px; }
|
||||
|
||||
.t-action-btn {
|
||||
padding: 5px 12px; border-radius: 7px; font-size: 12px; font-weight: 600;
|
||||
cursor: pointer; border: none; transition: opacity 0.15s; white-space: nowrap;
|
||||
}
|
||||
.t-action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.t-btn-start { background: var(--nc-success); color: #0f0022; }
|
||||
.t-btn-start:hover:not(:disabled) { opacity: 0.85; }
|
||||
.t-btn-join { background: var(--nc-neon); color: #fff; }
|
||||
.t-btn-join:hover:not(:disabled) { opacity: 0.85; }
|
||||
|
||||
/* Join dialog extras */
|
||||
.join-hint { font-size: 13px; color: var(--nc-text-muted); margin: 0 0 16px; line-height: 1.5; }
|
||||
.join-empty { font-size: 13px; color: var(--nc-text-muted); margin: 0 0 8px; }
|
||||
.dialog-loading { display: flex; align-items: center; gap: 8px;
|
||||
font-size: 13px; color: var(--nc-text-muted); padding: 12px 0; }
|
||||
.bot-pick-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
|
||||
.bot-pick-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; border-radius: 8px;
|
||||
border: 1px solid var(--nc-border); background: var(--nc-surface);
|
||||
cursor: pointer; text-align: left; width: 100%;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.bot-pick-row:hover:not(:disabled) { border-color: var(--nc-neon); background: rgba(255,69,200,0.06); }
|
||||
.bot-pick-row:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.bot-pick-avatar {
|
||||
width: 30px; height: 30px; border-radius: 50%; background: var(--nc-neon);
|
||||
color: #fff; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.bot-pick-name { flex: 1; font-size: 14px; font-weight: 600; color: var(--nc-text); }
|
||||
.bot-pick-rating { font-size: 12px; color: var(--nc-text-muted); }
|
||||
.bot-pick-spinner { font-size: 13px; color: var(--nc-neon); }
|
||||
|
||||
.t-card-main {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 14px 16px; gap: 12px;
|
||||
}
|
||||
.t-card-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.t-card-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||
|
||||
.t-status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.dot-started { background: var(--nc-success); box-shadow: 0 0 6px var(--nc-success); }
|
||||
.dot-created { background: var(--nc-warn); }
|
||||
.dot-finished { background: var(--nc-text-dim); }
|
||||
|
||||
.t-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.t-name { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.t-meta { font-size: 11px; color: var(--nc-text-muted); }
|
||||
|
||||
.winner-badge {
|
||||
font-size: 11px; font-weight: 600; color: var(--nc-warn);
|
||||
padding: 3px 8px; border-radius: 6px;
|
||||
background: rgba(255, 209, 102, 0.12);
|
||||
}
|
||||
|
||||
.chevron { color: var(--nc-text-dim); transition: transform 0.2s; }
|
||||
.chevron.open { transform: rotate(180deg); }
|
||||
|
||||
/* Detail panel */
|
||||
.t-detail {
|
||||
border-top: 1px solid var(--nc-border);
|
||||
padding: 16px;
|
||||
display: flex; flex-direction: column; gap: 20px;
|
||||
}
|
||||
|
||||
.detail-section { display: flex; flex-direction: column; gap: 10px; }
|
||||
.detail-heading {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; color: var(--nc-text-muted); margin: 0;
|
||||
}
|
||||
|
||||
.no-standings { font-size: 12px; color: var(--nc-text-dim); margin: 0; }
|
||||
|
||||
/* Standings table */
|
||||
.standings-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||
}
|
||||
.standings-table th {
|
||||
text-align: left; padding: 6px 8px;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.06em;
|
||||
text-transform: uppercase; color: var(--nc-text-dim);
|
||||
border-bottom: 1px solid var(--nc-border);
|
||||
}
|
||||
.standings-table td { padding: 8px 8px; border-bottom: 1px solid var(--nc-border); }
|
||||
.standings-table tr:last-child td { border-bottom: none; }
|
||||
.top-row td { color: var(--nc-text); }
|
||||
.standings-table tr:not(.top-row) td { color: var(--nc-text-muted); }
|
||||
|
||||
.col-rank { width: 40px; font-size: 14px; }
|
||||
.col-pts { width: 48px; font-weight: 700; color: var(--nc-neon) !important; }
|
||||
.col-tb { width: 52px; color: var(--nc-text-dim) !important; font-size: 12px; }
|
||||
.col-games { width: 64px; }
|
||||
|
||||
.wdl { font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.w { color: var(--nc-success); }
|
||||
.d { color: var(--nc-text-muted); }
|
||||
.l { color: var(--nc-danger); }
|
||||
|
||||
/* Pairings */
|
||||
.pairings-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.pairing-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 10px; border-radius: 8px;
|
||||
background: rgba(255,255,255,0.025);
|
||||
font-size: 13px; transition: background 0.15s;
|
||||
}
|
||||
.pairing-row.is-watchable { cursor: pointer; }
|
||||
.pairing-row.is-watchable:hover { background: rgba(255,255,255,0.06); }
|
||||
.pairing-white { font-weight: 600; flex: 1; }
|
||||
.pairing-vs { color: var(--nc-text-dim); font-size: 11px; flex-shrink: 0; }
|
||||
.pairing-black { flex: 1; }
|
||||
.pairing-result { font-weight: 700; font-size: 12px; margin-left: auto; }
|
||||
.result-white { color: var(--nc-success); }
|
||||
.result-black { color: var(--nc-danger); }
|
||||
.result-draw { color: var(--nc-text-muted); }
|
||||
.pairing-ongoing {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
margin-left: auto; font-size: 10px; font-weight: 700;
|
||||
color: var(--nc-success); letter-spacing: 0.06em; text-transform: uppercase;
|
||||
}
|
||||
.pairing-ongoing svg { animation: pulse 1.4s ease-in-out infinite; }
|
||||
@@ -0,0 +1,273 @@
|
||||
<div class="t-shell">
|
||||
<div class="page">
|
||||
|
||||
<nav class="crumb" aria-label="Breadcrumb">
|
||||
<a routerLink="/" class="crumb-link">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
Back to lobby
|
||||
</a>
|
||||
<span class="crumb-sep">/</span>
|
||||
<span class="crumb-current">Tournaments</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div class="page-title-row">
|
||||
<h1 class="page-title">Tournaments</h1>
|
||||
@if (currentUser) {
|
||||
<button type="button" class="btn-new" (click)="openCreateDialog()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
New tournament
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="tabs" role="tablist">
|
||||
<button type="button" class="tab-btn" [class.active]="tab === 'started'" (click)="setTab('started')">
|
||||
Live
|
||||
@if (started.length > 0) { <span class="tab-badge live-badge">{{ started.length }}</span> }
|
||||
</button>
|
||||
<button type="button" class="tab-btn" [class.active]="tab === 'created'" (click)="setTab('created')">
|
||||
Upcoming
|
||||
@if (created.length > 0) { <span class="tab-badge">{{ created.length }}</span> }
|
||||
</button>
|
||||
<button type="button" class="tab-btn" [class.active]="tab === 'finished'" (click)="setTab('finished')">
|
||||
Finished
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading) {
|
||||
<div class="state-msg"><span class="pulse"></span>Loading tournaments…</div>
|
||||
} @else if (activeList.length === 0) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/>
|
||||
<path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/>
|
||||
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/>
|
||||
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="empty-title">No tournaments here</p>
|
||||
<p class="empty-sub">Check back later or look in another tab.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="t-list">
|
||||
@for (t of activeList; track t.id) {
|
||||
<div class="t-card" [class.expanded]="selectedTournament?.id === t.id"
|
||||
(click)="selectTournament(t)" role="button" tabindex="0"
|
||||
(keydown.enter)="selectTournament(t)">
|
||||
|
||||
<div class="t-card-main">
|
||||
<div class="t-card-left">
|
||||
<span class="t-status-dot" [class]="'dot-' + t.status"></span>
|
||||
<div class="t-info">
|
||||
<span class="t-name">{{ t.fullName }}</span>
|
||||
<span class="t-meta">
|
||||
{{ clockDisplay(t) }} · {{ t.nbRounds }} rounds ·
|
||||
@if (t.status === 'started') { Round {{ t.round }}/{{ t.nbRounds }} · }
|
||||
{{ t.nbPlayers }} player{{ t.nbPlayers === 1 ? '' : 's' }}
|
||||
@if (t.rated) { · Rated }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-card-right">
|
||||
@if (t.status === 'finished' && t.winner) {
|
||||
<span class="winner-badge">🏆 {{ t.winner.name }}</span>
|
||||
}
|
||||
@if (currentUser && t.status === 'created') {
|
||||
@if (t.createdBy === currentUser.id) {
|
||||
<button type="button" class="t-action-btn t-btn-start"
|
||||
[disabled]="startingId === t.id"
|
||||
(click)="startTournament($event, t)">
|
||||
{{ startingId === t.id ? '…' : 'Start' }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="t-action-btn t-btn-join"
|
||||
(click)="openJoinDialog($event, t.id)">
|
||||
Join with bot
|
||||
</button>
|
||||
}
|
||||
<svg class="chevron" [class.open]="selectedTournament?.id === t.id"
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (selectedTournament?.id === t.id) {
|
||||
<div class="t-detail" (click)="$event.stopPropagation()">
|
||||
|
||||
<!-- Leaderboard -->
|
||||
@if (t.standing.players.length > 0) {
|
||||
<section class="detail-section">
|
||||
<h3 class="detail-heading">Leaderboard</h3>
|
||||
<table class="standings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-rank">#</th>
|
||||
<th class="col-name">Bot</th>
|
||||
<th class="col-pts">Pts</th>
|
||||
<th class="col-tb">Bkh</th>
|
||||
<th class="col-games">W/D/L</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of t.standing.players; track r.bot.id) {
|
||||
<tr [class.top-row]="r.rank <= 3">
|
||||
<td class="col-rank">{{ rankMedal(r.rank) }}</td>
|
||||
<td class="col-name">{{ r.bot.name }}</td>
|
||||
<td class="col-pts">{{ scoreDisplay(r) }}</td>
|
||||
<td class="col-tb">{{ r.tieBreak }}</td>
|
||||
<td class="col-games">
|
||||
<span class="wdl">
|
||||
<span class="w">{{ r.wins }}</span>/<span class="d">{{ r.draws }}</span>/<span class="l">{{ r.losses }}</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
} @else {
|
||||
<p class="no-standings">No standings yet — waiting for games to complete.</p>
|
||||
}
|
||||
|
||||
<!-- Current round pairings -->
|
||||
@if (t.round > 0) {
|
||||
<section class="detail-section">
|
||||
<h3 class="detail-heading">Round {{ t.round }} pairings</h3>
|
||||
@if (pairingsLoading) {
|
||||
<div class="state-msg small"><span class="pulse"></span>Loading…</div>
|
||||
} @else if (pairings && pairings.pairings.length > 0) {
|
||||
<div class="pairings-list">
|
||||
@for (p of pairings.pairings; track p.id) {
|
||||
<div class="pairing-row" [class.is-watchable]="!!p.gameId"
|
||||
(click)="p.gameId && watchGame(p.gameId)">
|
||||
<span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span>
|
||||
<span class="pairing-vs">vs</span>
|
||||
<span class="pairing-black">{{ p.black.name }}</span>
|
||||
@if (p.winner) {
|
||||
<span class="pairing-result" [class]="'result-' + p.winner">
|
||||
{{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '1–0' : '0–1' }}
|
||||
</span>
|
||||
} @else if (p.gameId) {
|
||||
<span class="pairing-ongoing">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
</svg>
|
||||
Watch
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="no-standings">No pairings recorded yet.</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (joinDialogTournamentId) {
|
||||
<div class="dialog-overlay" (click)="closeJoinDialog()">
|
||||
<div class="dialog-card" (click)="$event.stopPropagation()">
|
||||
<div class="dialog-head">
|
||||
<span class="dialog-brand">Join with a bot</span>
|
||||
<button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button>
|
||||
</div>
|
||||
<p class="join-hint">Select one of your bots to enter this tournament. Its token will be rotated to authenticate the join.</p>
|
||||
|
||||
@if (botsLoading) {
|
||||
<div class="dialog-loading"><span class="pulse"></span>Loading bots…</div>
|
||||
} @else if (userBots.length === 0) {
|
||||
<p class="join-empty">You have no bots yet. Go to <strong>Bots</strong> in the nav to create one first.</p>
|
||||
} @else {
|
||||
<div class="bot-pick-list">
|
||||
@for (bot of userBots; track bot.id) {
|
||||
<button type="button" class="bot-pick-row"
|
||||
[disabled]="!!joiningBotId"
|
||||
(click)="joinWithBot(bot)">
|
||||
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
|
||||
<span class="bot-pick-name">{{ bot.name }}</span>
|
||||
<span class="bot-pick-rating">{{ bot.rating }}</span>
|
||||
@if (joiningBotId === bot.id) {
|
||||
<span class="bot-pick-spinner">…</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (joinError) {
|
||||
<div class="dialog-error">{{ joinError }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showCreateDialog) {
|
||||
<div class="dialog-overlay" (click)="closeCreateDialog()">
|
||||
<div class="dialog-card" (click)="$event.stopPropagation()">
|
||||
<div class="dialog-head">
|
||||
<span class="dialog-brand">New tournament</span>
|
||||
<button type="button" class="dialog-close" (click)="closeCreateDialog()">×</button>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="createForm" (ngSubmit)="submitCreate()">
|
||||
<div class="dialog-field">
|
||||
<label class="dialog-label">Name</label>
|
||||
<input type="text" class="dialog-input" formControlName="name" placeholder="e.g. Friday Blitz Open" />
|
||||
</div>
|
||||
|
||||
<div class="dialog-row">
|
||||
<div class="dialog-field">
|
||||
<label class="dialog-label">Rounds</label>
|
||||
<input type="number" class="dialog-input" formControlName="nbRounds" min="1" max="20" />
|
||||
</div>
|
||||
<div class="dialog-field">
|
||||
<label class="dialog-label">Clock (min)</label>
|
||||
<input type="number" class="dialog-input" formControlName="clockLimitMinutes" min="1" max="60" />
|
||||
</div>
|
||||
<div class="dialog-field">
|
||||
<label class="dialog-label">Increment (s)</label>
|
||||
<input type="number" class="dialog-input" formControlName="clockIncrement" min="0" max="60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="dialog-toggle">
|
||||
<input type="checkbox" formControlName="rated" />
|
||||
<span class="toggle-track"></span>
|
||||
<span class="toggle-label">Rated</span>
|
||||
</label>
|
||||
|
||||
@if (createError) {
|
||||
<div class="dialog-error">{{ createError }}</div>
|
||||
}
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn-ghost" (click)="closeCreateDialog()">Cancel</button>
|
||||
<button type="submit" class="btn-primary" [disabled]="createLoading || createForm.invalid">
|
||||
{{ createLoading ? 'Creating…' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { TournamentService } from '../../services/tournament.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { BotService } from '../../services/bot.service';
|
||||
import { Bot } from '../../models/bot.models';
|
||||
import { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models';
|
||||
import { CurrentUser } from '../../models/auth.models';
|
||||
|
||||
type StatusTab = 'started' | 'created' | 'finished';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tournaments',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, ReactiveFormsModule],
|
||||
templateUrl: './tournaments.component.html',
|
||||
styleUrl: './tournaments.component.css'
|
||||
})
|
||||
export class TournamentsComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly tournamentService = inject(TournamentService);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly botService = inject(BotService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
tab: StatusTab = 'started';
|
||||
currentUser: CurrentUser | null = null;
|
||||
|
||||
started: Tournament[] = [];
|
||||
created: Tournament[] = [];
|
||||
finished: Tournament[] = [];
|
||||
|
||||
selectedTournament: Tournament | null = null;
|
||||
pairings: RoundPairings | null = null;
|
||||
pairingsLoading = false;
|
||||
|
||||
showCreateDialog = false;
|
||||
createForm: FormGroup = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.minLength(3)]],
|
||||
nbRounds: [4, [Validators.required, Validators.min(1), Validators.max(20)]],
|
||||
clockLimitMinutes: [3, [Validators.required, Validators.min(1), Validators.max(60)]],
|
||||
clockIncrement: [0, [Validators.required, Validators.min(0), Validators.max(60)]],
|
||||
rated: [false]
|
||||
});
|
||||
createLoading = false;
|
||||
createError: string | null = null;
|
||||
|
||||
startingId: string | null = null;
|
||||
|
||||
joinDialogTournamentId: string | null = null;
|
||||
userBots: Bot[] = [];
|
||||
botsLoading = false;
|
||||
joiningBotId: string | null = null;
|
||||
joinError: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.authService.currentUser$
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(u => { this.currentUser = u; });
|
||||
this.loadTournaments();
|
||||
}
|
||||
|
||||
openCreateDialog(): void {
|
||||
this.createForm.reset({ name: '', nbRounds: 4, clockLimitMinutes: 3, clockIncrement: 0, rated: false });
|
||||
this.createError = null;
|
||||
this.showCreateDialog = true;
|
||||
}
|
||||
|
||||
closeCreateDialog(): void {
|
||||
this.showCreateDialog = false;
|
||||
}
|
||||
|
||||
submitCreate(): void {
|
||||
if (this.createForm.invalid) return;
|
||||
this.createLoading = true;
|
||||
this.createError = null;
|
||||
this.tournamentService.create(this.createForm.value).subscribe({
|
||||
next: t => {
|
||||
this.createLoading = false;
|
||||
this.showCreateDialog = false;
|
||||
this.created = [t, ...this.created];
|
||||
this.tab = 'created';
|
||||
this.selectedTournament = null;
|
||||
},
|
||||
error: err => {
|
||||
this.createLoading = false;
|
||||
this.createError = err.error?.message ?? err.error?.error ?? 'Failed to create tournament.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setTab(tab: StatusTab): void {
|
||||
this.tab = tab;
|
||||
this.selectedTournament = null;
|
||||
this.pairings = null;
|
||||
}
|
||||
|
||||
selectTournament(t: Tournament): void {
|
||||
if (this.selectedTournament?.id === t.id) {
|
||||
this.selectedTournament = null;
|
||||
this.pairings = null;
|
||||
return;
|
||||
}
|
||||
this.selectedTournament = t;
|
||||
this.pairings = null;
|
||||
if (t.round > 0) {
|
||||
this.loadPairings(t.id, t.round);
|
||||
}
|
||||
}
|
||||
|
||||
get activeList(): Tournament[] {
|
||||
return this[this.tab];
|
||||
}
|
||||
|
||||
clockDisplay(t: Tournament): string {
|
||||
const min = Math.floor(t.clock.limit / 60);
|
||||
return `${min}+${t.clock.increment}`;
|
||||
}
|
||||
|
||||
rankMedal(rank: number): string {
|
||||
if (rank === 1) return '🥇';
|
||||
if (rank === 2) return '🥈';
|
||||
if (rank === 3) return '🥉';
|
||||
return `${rank}.`;
|
||||
}
|
||||
|
||||
scoreDisplay(r: TournamentResult): string {
|
||||
return r.points % 1 === 0 ? `${r.points}` : `${r.points}`;
|
||||
}
|
||||
|
||||
startTournament(event: MouseEvent, t: Tournament): void {
|
||||
event.stopPropagation();
|
||||
this.startingId = t.id;
|
||||
this.tournamentService.start(t.id).subscribe({
|
||||
next: updated => {
|
||||
this.startingId = null;
|
||||
const list = this.created.map(x => x.id === t.id ? updated : x);
|
||||
this.created = list.filter(x => x.status === 'created');
|
||||
if (!this.started.find(x => x.id === updated.id)) this.started = [updated, ...this.started];
|
||||
this.selectedTournament = updated;
|
||||
this.tab = 'started';
|
||||
},
|
||||
error: () => { this.startingId = null; }
|
||||
});
|
||||
}
|
||||
|
||||
watchGame(gameId: string): void {
|
||||
void this.router.navigate(['/game', gameId]);
|
||||
}
|
||||
|
||||
openJoinDialog(event: MouseEvent, tournamentId: string): void {
|
||||
event.stopPropagation();
|
||||
this.joinDialogTournamentId = tournamentId;
|
||||
this.joinError = null;
|
||||
this.botsLoading = true;
|
||||
this.botService.list()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: bots => { this.userBots = bots; this.botsLoading = false; },
|
||||
error: () => { this.botsLoading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
closeJoinDialog(): void {
|
||||
this.joinDialogTournamentId = null;
|
||||
this.joiningBotId = null;
|
||||
this.joinError = null;
|
||||
}
|
||||
|
||||
joinWithBot(bot: Bot): void {
|
||||
if (!this.joinDialogTournamentId || this.joiningBotId) return;
|
||||
this.joiningBotId = bot.id;
|
||||
this.joinError = null;
|
||||
this.botService.rotateToken(bot.id).subscribe({
|
||||
next: token => {
|
||||
this.tournamentService.joinWithBotToken(this.joinDialogTournamentId!, token).subscribe({
|
||||
next: () => {
|
||||
this.joiningBotId = null;
|
||||
const tid = this.joinDialogTournamentId!;
|
||||
this.closeJoinDialog();
|
||||
this.tournamentService.get(tid)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(updated => {
|
||||
this.created = this.created.map(x => x.id === tid ? updated : x);
|
||||
this.started = this.started.map(x => x.id === tid ? updated : x);
|
||||
if (this.selectedTournament?.id === tid) this.selectedTournament = updated;
|
||||
});
|
||||
},
|
||||
error: err => {
|
||||
this.joiningBotId = null;
|
||||
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
|
||||
}
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.joiningBotId = null;
|
||||
this.joinError = 'Failed to get bot token.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadTournaments(): void {
|
||||
this.tournamentService.list()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: list => {
|
||||
this.started = list.started;
|
||||
this.created = list.created;
|
||||
this.finished = list.finished;
|
||||
this.loading = false;
|
||||
if (this.started.length === 0 && this.created.length > 0) this.tab = 'created';
|
||||
else if (this.started.length === 0 && this.finished.length > 0) this.tab = 'finished';
|
||||
},
|
||||
error: () => { this.loading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
private loadPairings(id: string, round: number): void {
|
||||
this.pairingsLoading = true;
|
||||
this.tournamentService.roundPairings(id, round)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: p => { this.pairings = p; this.pairingsLoading = false; },
|
||||
error: () => { this.pairingsLoading = false; }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NowChess — Auth Dialog</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
|
||||
--neon: #ff45c8;
|
||||
--neon-soft: rgba(255, 69, 200, 0.55);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #06060d;
|
||||
font-family: var(--sans);
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
button, input { font-family: var(--sans); }
|
||||
input { color: #fff; }
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: #fff;
|
||||
-webkit-box-shadow: 0 0 0px 1000px rgba(8,5,20,0) inset;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,69,200,0.3); }
|
||||
|
||||
@keyframes scanline {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100%); }
|
||||
}
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { opacity: 0.85; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@keyframes dialog-in {
|
||||
from { opacity: 0; transform: translate(-50%, -48%) scale(0.96); }
|
||||
to { opacity: 1; transform: translate(-50%, -50%) 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); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script
|
||||
src="https://unpkg.com/react@18.3.1/umd/react.development.js"
|
||||
integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script
|
||||
src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"
|
||||
integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script
|
||||
src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"
|
||||
integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
|
||||
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||
<script type="text/babel" src="auth-dialog.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
|
||||
@@ -46,13 +46,6 @@
|
||||
<div class="w" *ngFor="let win of windows['wA3']" [ngStyle]="win.style"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Draggable Meat Emoji -->
|
||||
@if (showMeatEmoji) {
|
||||
<div class="meat-emoji" [style.left.px]="meatX" [style.top.px]="meatY" (mousedown)="onMeatMouseDown($event)">
|
||||
🍖
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="bwrap" style="left:21%;width:15%;">
|
||||
@@ -145,50 +138,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speech Bubble -->
|
||||
@if (showSpeechBubble) {
|
||||
<div class="speech-bubble-container" (click)="onSpeechBubbleClick()">
|
||||
<div class="speech-bubble">
|
||||
<div class="bubble-text">{{ bubbleMessage }}</div>
|
||||
<div class="bubble-tail"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Zoomed Window View -->
|
||||
@if (isZoomedIn) {
|
||||
<div class="zoom-overlay" (click)="onZoomedViewClick()" (mousemove)="onMouseMove($event)" (mouseup)="onMouseUp()"
|
||||
(mouseleave)="onMouseUp()">
|
||||
<div class="zoom-window-wrapper" (click)="$event.stopPropagation()">
|
||||
<div class="zoom-window-frame">
|
||||
<div class="zoom-player-2">
|
||||
<img src="/assets/arabian-chess/player-two.gif" alt="Player 2" class="player-2-gif"
|
||||
(click)="$event.stopPropagation()" />
|
||||
@if (showSecondSpeechBubble) {
|
||||
<div class="second-speech-bubble">
|
||||
<div class="bubble-text">Feed me! 🍖</div>
|
||||
<div class="bubble-tail"></div>
|
||||
</div>
|
||||
}
|
||||
@if (showHappyBubble) {
|
||||
<div class="happy-speech-bubble">
|
||||
<div class="bubble-text">Happy meow! 😸</div>
|
||||
<div class="bubble-tail"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Draggable Meat Emoji -->
|
||||
@if (showMeatEmoji) {
|
||||
<div class="meat-emoji" [style.left.px]="meatX" [style.top.px]="meatY" (mousedown)="onMeatMouseDown($event)">
|
||||
🍖
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="haze"></div>
|
||||
<div class="ground"></div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, WindowCell[]> = {};
|
||||
|
||||
private flickerIntervalId: ReturnType<typeof setInterval> | undefined;
|
||||
private speechBubbleTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
private zoomTimeoutId: ReturnType<typeof setTimeout> | 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;
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Bot[]> {
|
||||
return this.http.get<Bot[]>(this.base);
|
||||
}
|
||||
|
||||
create(name: string): Observable<BotWithToken> {
|
||||
return this.http.post<BotWithToken>(this.base, { name });
|
||||
}
|
||||
|
||||
rotateToken(botId: string): Observable<string> {
|
||||
return this.http.post<{ token: string }>(`${this.base}/${botId}/rotate-token`, null)
|
||||
.pipe(map(r => r.token));
|
||||
}
|
||||
|
||||
delete(botId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/${botId}`);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,14 @@ export class GameApiService {
|
||||
return this.http.post<GameFull>(`${this.apiBase}${this.apiPath}/import/pgn`, { pgn });
|
||||
}
|
||||
|
||||
resignGame(gameId: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.apiBase}${this.apiPath}/${gameId}/resign`, {});
|
||||
}
|
||||
|
||||
offerDraw(gameId: string): Observable<void> {
|
||||
return this.http.post<void>(`${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<GameStreamEvent> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TournamentList> {
|
||||
return this.http.get<TournamentList>(this.base);
|
||||
}
|
||||
|
||||
get(id: string): Observable<Tournament> {
|
||||
return this.http.get<Tournament>(`${this.base}/${id}`);
|
||||
}
|
||||
|
||||
create(form: CreateTournamentForm): Observable<Tournament> {
|
||||
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<Tournament>(this.base, body.toString(), {
|
||||
headers: new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' })
|
||||
});
|
||||
}
|
||||
|
||||
start(id: string): Observable<Tournament> {
|
||||
return this.http.post<Tournament>(`${this.base}/${id}/start`, null);
|
||||
}
|
||||
|
||||
joinWithBotToken(id: string, botToken: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.base}/${id}/join`, null, {
|
||||
headers: new HttpHeaders({ Authorization: `Bearer ${botToken}` })
|
||||
});
|
||||
}
|
||||
|
||||
roundPairings(id: string, round: number): Observable<RoundPairings> {
|
||||
return this.http.get<RoundPairings>(`${this.base}/${id}/round/${round}`);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ export const environment = {
|
||||
production: false,
|
||||
apiBaseUrl: '',
|
||||
accountServiceUrl: '',
|
||||
wsBaseUrl: '',
|
||||
wsBaseUrl: 'ws://localhost:8084',
|
||||
userWsBaseUrl: 'ws://localhost:8084',
|
||||
apiPath: '/api/board/game'
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user