9 Commits

Author SHA1 Message Date
Lala, Shahd e436dc871c fix: console errors, notif error 2026-05-14 20:16:36 +00:00
Lala, Shahd 3fa687c450 fix: timer now in sync with backend 2026-05-14 19:13:03 +00:00
Lala, Shahd 1e6cd34f61 fix: game created fixed 2026-05-14 18:36:34 +00:00
TeamCity 70a4debb40 ci: bump version to v0.2.3 2026-05-14 15:19:14 +00:00
shosho996 61000f8a22 fix: added missing challenge routes 2026-05-14 17:16:42 +02:00
TeamCity f98bcfd956 ci: bump version to v0.2.2 2026-05-12 22:04:12 +00:00
shosho996 6d1e06dfd6 fix: NCWF-1 401 (#6)
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Reviewed-on: #6
2026-05-13 00:01:26 +02:00
TeamCity ac4fe8b005 ci: bump version to v0.2.1 2026-05-12 21:13:35 +00:00
shosho996 f8f93efff4 fix: NCWF-1 401 (#5)
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Reviewed-on: #5
2026-05-12 23:11:19 +02:00
32 changed files with 1752 additions and 389 deletions
+15
View File
@@ -23,3 +23,18 @@
### Features
* NCS-69 Challenge request ([#3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/3)) ([bad7366](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bad7366bdbb048c20218257b30ac22efc9ecb6db))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.0...0.0.0) (2026-05-12)
### Bug Fixes
* NCWF-1 401 ([#5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/5)) ([f8f93ef](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f8f93efff48f1d7198023fed45b675c2e225df36))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.1...0.0.0) (2026-05-12)
### Bug Fixes
* NCWF-1 401 ([#6](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/6)) ([6d1e06d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/6d1e06dfd606b93d029e9c9b84eea6f8b3b6294e))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.2...0.0.0) (2026-05-14)
### Bug Fixes
* added missing challenge routes ([61000f8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/61000f8a22aff8b524664a756cc933834365f923))
+6
View File
@@ -9,6 +9,12 @@
"secure": false,
"changeOrigin": true
},
"/api/user/ws": {
"target": "http://localhost:8084",
"secure": false,
"changeOrigin": true,
"ws": true
},
"/api": {
"target": "http://localhost:8080",
"secure": false,
+2
View File
@@ -2,10 +2,12 @@ import { Routes } from '@angular/router';
import { GameComponent } from './pages/game/game.component';
import { WelcomeComponent } from './pages/welcome/welcome.component';
import { ProfileComponent } from './pages/profile/profile.component';
import { ChallengesComponent } from './pages/challenges/challenges.component';
export const routes: Routes = [
{ path: '', component: WelcomeComponent },
{ path: 'profile', component: ProfileComponent },
{ path: 'challenges', component: ChallengesComponent },
{ path: 'game/:gameId', component: GameComponent },
{ path: '**', redirectTo: '' }
];
@@ -15,7 +15,7 @@
<div class="form-group">
<label for="targetUsername">Opponent Username</label>
<input type="text" id="targetUsername" formControlName="targetUsername"
placeholder="Enter opponent's username" [disabled]="loading" required />
placeholder="Enter opponent's username" required />
<small *ngIf="form.get('targetUsername')?.hasError('required') && form.get('targetUsername')?.touched">
Username is required
</small>
@@ -24,7 +24,7 @@
<!-- Player Color Selection -->
<div class="form-group">
<label for="color">Play as</label>
<select id="color" formControlName="color" [disabled]="loading">
<select id="color" formControlName="color">
<option value="white">White</option>
<option value="black">Black</option>
<option value="random">Random</option>
@@ -34,7 +34,7 @@
<!-- Time Control Mode Selection -->
<div class="form-group">
<label for="timeMode">Time Control</label>
<select id="timeMode" formControlName="timeMode" [disabled]="loading">
<select id="timeMode" formControlName="timeMode">
<option value="blitz">Blitz</option>
<option value="rapid">Rapid</option>
<option value="classical">Classical</option>
@@ -58,13 +58,11 @@
<div class="form-row">
<div class="form-col">
<label for="limitMinutes">Time (minutes)</label>
<input type="number" id="limitMinutes" formControlName="limitMinutes" min="1" max="1000"
[disabled]="loading" />
<input type="number" id="limitMinutes" formControlName="limitMinutes" min="1" max="1000" />
</div>
<div class="form-col">
<label for="incrementSeconds">Increment (seconds)</label>
<input type="number" id="incrementSeconds" formControlName="incrementSeconds" min="0" max="300"
[disabled]="loading" />
<input type="number" id="incrementSeconds" formControlName="incrementSeconds" min="0" max="300" />
</div>
</div>
</div>
@@ -72,7 +70,7 @@
<!-- TTL (Time to Live) -->
<div class="form-group">
<label for="ttlSeconds">Challenge Expires In</label>
<select id="ttlSeconds" formControlName="ttlSeconds" [disabled]="loading">
<select id="ttlSeconds" formControlName="ttlSeconds">
<option *ngFor="let ttl of ttlOptions" [value]="ttl.seconds">
{{ ttl.label }}
</option>
@@ -124,11 +124,11 @@ export class ChallengeCreateDialogComponent implements OnInit, OnDestroy {
this.errorMessage = '';
this.loading = true;
this.form.disable();
const limitSeconds = Math.round((this.form.get('limitMinutes')?.value || 0) * 60);
const incrementSeconds = this.form.get('incrementSeconds')?.value || 0;
const ttlSeconds = this.form.get('ttlSeconds')?.value;
const color = (this.form.get('color')?.value || 'random') as PlayerColor;
const limitSeconds = Math.round((this.form.getRawValue().limitMinutes || 0) * 60);
const { incrementSeconds, ttlSeconds, color: rawColor } = this.form.getRawValue();
const color = (rawColor || 'random') as PlayerColor;
this.challengeService.sendChallenge(targetUsername, {
timeControl: {
@@ -138,7 +138,7 @@ export class ChallengeCreateDialogComponent implements OnInit, OnDestroy {
color,
ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined
})
.pipe(finalize(() => (this.loading = false)))
.pipe(finalize(() => { this.loading = false; this.form.enable(); }))
.subscribe({
next: (challenge) => {
// Challenge sent successfully - navigate to challenges page to view status
@@ -1,5 +1,6 @@
import { Component, Input, Output, EventEmitter, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Challenge } from '../../models/challenge.models';
import { ChallengeService } from '../../services/challenge.service';
import { finalize } from 'rxjs';
@@ -19,6 +20,7 @@ export class ChallengeNotificationComponent {
@Output() close = new EventEmitter<void>();
private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router);
acceptingChallenge = false;
decliningChallenge = false;
@@ -35,8 +37,13 @@ export class ChallengeNotificationComponent {
this.challengeService.acceptChallenge(this.challenge.id)
.pipe(finalize(() => (this.acceptingChallenge = false)))
.subscribe({
next: () => {
this.accept.emit(this.challenge);
next: (acceptedChallenge) => {
this.accept.emit(acceptedChallenge);
if (acceptedChallenge.gameId) {
void this.router.navigate(['/game', acceptedChallenge.gameId]);
} else {
this.errorMessage = 'Challenge accepted, but no game was created.';
}
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to accept challenge');
@@ -52,7 +59,7 @@ export class ChallengeNotificationComponent {
this.decliningChallenge = true;
this.errorMessage = '';
this.challengeService.declineChallenge(this.challenge.id, { reason: 'Not interested' })
this.challengeService.declineChallenge(this.challenge.id, { reason: 'generic' })
.pipe(finalize(() => (this.decliningChallenge = false)))
.subscribe({
next: () => {
@@ -4,15 +4,13 @@
<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"
[disabled]="isLoading" />
<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>
}
<label for="password" class="sr-only">Password</label>
<input id="password" type="password" class="dialog-input" formControlName="password" placeholder="Password"
[disabled]="isLoading" />
<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>
}
@@ -38,15 +38,18 @@ export class LoginDialogComponent {
this.isLoading = true;
this.errorMessage = null;
this.loginForm.disable();
const { username, password } = this.loginForm.value;
const { username, password } = this.loginForm.getRawValue();
this.authService.login(username, password).subscribe({
next: () => {
this.isLoading = false;
this.loginForm.enable();
this.onSuccess.emit();
},
error: (err) => {
this.isLoading = false;
this.loginForm.enable();
this.errorMessage = err.error?.message || 'Login failed. Please try again.';
}
});
@@ -3,26 +3,23 @@
<div class="dialog-title">CREATE ACCOUNT</div>
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
<input id="username" type="text" class="dialog-input" formControlName="username" placeholder="Username"
[disabled]="isLoading" />
<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>
}
<input id="email" type="email" class="dialog-input" formControlName="email" placeholder="Email"
[disabled]="isLoading" />
<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>
}
<input id="password" type="password" class="dialog-input" formControlName="password" placeholder="Password"
[disabled]="isLoading" />
<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" [disabled]="isLoading" />
placeholder="Confirm Password" />
@if (errorMessage) {
<div class="error-banner">{{ errorMessage }}</div>
@@ -46,15 +46,18 @@ export class RegisterDialogComponent {
this.isLoading = true;
this.errorMessage = null;
this.registerForm.disable();
const { username, email, password: pwd } = this.registerForm.value;
const { username, email, password: pwd } = this.registerForm.getRawValue();
this.authService.register(username, pwd, email).subscribe({
next: () => {
this.isLoading = false;
this.registerForm.enable();
this.onSuccess.emit();
},
error: (err) => {
this.isLoading = false;
this.registerForm.enable();
this.errorMessage =
err.error?.message || 'Registration failed. Please try again.';
}
+469 -66
View File
@@ -1,84 +1,487 @@
@import '../../button-template.css';
.navbar {
background: rgba(8, 6, 28, 0.85);
backdrop-filter: blur(8px);
box-shadow: 0 4px 20px rgba(0, 210, 255, 0.15);
border-bottom: 1px solid rgba(0, 210, 255, 0.2);
border-radius: 0;
padding: 0.75rem 1rem;
/* ============ THEME TOKENS ============ */
:host {
/* Light mode: warm sunset palette from background gradient */
--nc-accent: #ff6b3d;
--nc-accent-hover: rgba(255, 107, 61, 0.15);
--nc-accent-badge: rgba(255, 107, 61, 0.9);
--nc-badge-text: #1a0800;
--nc-surface: rgba(26, 24, 56, 0.97);
--nc-nav-bg: linear-gradient(180deg, rgba(26,24,56,0.88) 0%, rgba(46,32,80,0.6) 70%, rgba(74,41,98,0) 100%);
--nc-text: #fff;
--nc-text-muted: rgba(255,255,255,0.7);
--nc-text-dim: rgba(255,255,255,0.45);
--nc-border: rgba(255,255,255,0.1);
--nc-popover-glow: 0 20px 60px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,107,61,0.18);
--nc-unread-dot: #ff6b3d;
--nc-avatar-a: #d44d4a;
--nc-avatar-b: #8b3a6b;
--nc-danger: #ff7a7a;
}
.navbar-brand {
font-size: 1.5rem;
font-weight: bold;
color: var(--bb-title) !important;
font-family: 'Bebas Neue', sans-serif;
letter-spacing: 1px;
cursor: pointer;
:host-context(html[data-theme='dark']) {
/* Dark mode: blue neon palette */
--nc-accent: #00d5ff;
--nc-accent-hover: rgba(0, 213, 255, 0.12);
--nc-accent-badge: #00d5ff;
--nc-badge-text: #04000f;
--nc-surface: rgba(8, 6, 28, 0.97);
--nc-nav-bg: linear-gradient(180deg, rgba(8,5,20,0.88) 0%, rgba(8,5,20,0.58) 70%, rgba(8,5,20,0) 100%);
--nc-text: #fff;
--nc-text-muted: rgba(255,255,255,0.65);
--nc-text-dim: rgba(255,255,255,0.4);
--nc-border: rgba(255,255,255,0.08);
--nc-popover-glow: 0 20px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(0,213,255,0.15);
--nc-unread-dot: #00d5ff;
--nc-avatar-a: #00d5ff;
--nc-avatar-b: #1a5fa8;
--nc-danger: #ff7a7a;
}
.gap-2 {
gap: 0.5rem;
}
.user-section {
/* ============ NAV CONTAINER ============ */
.nc-nav {
position: fixed;
top: 0; left: 0; right: 0;
height: 56px;
z-index: 100;
display: flex;
align-items: center;
padding: 0 24px;
background: var(--nc-nav-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
font-family: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.me-btn {
background: rgba(0, 210, 255, 0.1);
color: var(--bb-title);
border: 1px solid var(--bb-border);
border-radius: 2px;
padding: 0.5rem 0.8rem;
font-family: 'Space Mono', monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
/* ============ LOGO ============ */
.nc-logo {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
user-select: none;
}
.nc-logo-mark {
width: 24px; height: 24px;
background: var(--nc-accent);
display: flex;
align-items: center;
justify-content: center;
outline: none;
font-weight: 800;
color: var(--nc-badge-text);
font-size: 14px;
}
.nc-logo-text {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--nc-text);
}
/* ============ CENTER LINKS ============ */
.nc-links {
flex: 1;
display: flex;
justify-content: center;
gap: 4px;
}
.nc-link {
background: transparent;
border: none;
color: var(--nc-text-muted);
padding: 8px 14px;
font-size: 12px;
font-family: inherit;
letter-spacing: 0.08em;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
position: relative;
transition: color 0.15s;
}
.nc-link:hover { color: var(--nc-text); }
.nc-link::after {
content: "";
position: absolute;
bottom: 2px; left: 14px; right: 14px;
height: 1px;
background: var(--nc-accent);
opacity: 0;
transition: opacity 0.15s;
}
.nc-link:hover::after { opacity: 1; }
/* ============ RIGHT CLUSTER ============ */
.nc-right {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
margin-left: auto;
}
/* ============ BELL ============ */
.nc-bell {
width: 36px; height: 36px;
border: 1px solid var(--nc-border);
background: transparent;
color: var(--nc-text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: background 0.15s, color 0.15s;
font-family: inherit;
}
.nc-bell:hover,
.nc-bell.is-open {
background: var(--nc-accent-hover);
color: var(--nc-text);
}
/* ============ BADGE ============ */
.nc-badge {
position: absolute;
top: 5px; right: 5px;
min-width: 14px; height: 14px;
border-radius: 7px;
background: var(--nc-accent-badge);
color: var(--nc-badge-text);
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
padding: 0 3px;
}
/* ============ PROFILE BUTTON ============ */
.nc-profile {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px 4px 4px;
height: 36px;
border: 1px solid var(--nc-border);
background: transparent;
cursor: pointer;
color: var(--nc-text-muted);
font-family: inherit;
transition: background 0.15s, color 0.15s;
}
.nc-profile:hover,
.nc-profile.is-open {
background: var(--nc-accent-hover);
color: var(--nc-text);
}
.nc-profile-name {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
}
.nc-chevron { opacity: 0.5; }
/* ============ AVATAR ============ */
.nc-avatar {
border-radius: 50%;
background: linear-gradient(135deg, var(--nc-avatar-a) 0%, var(--nc-avatar-b) 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.nc-avatar-sm { width: 26px; height: 26px; font-size: 11px; }
.nc-avatar-md { width: 40px; height: 40px; font-size: 17px; }
/* ============ DROPDOWN WRAPPER ============ */
.nc-dropdown-wrap { position: relative; }
/* ============ POPOVERS ============ */
.nc-popover {
position: absolute;
top: calc(100% + 10px);
right: 0;
background: var(--nc-surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--nc-border);
box-shadow: var(--nc-popover-glow);
z-index: 200;
overflow: hidden;
}
/* ============ NOTIFICATIONS PANEL ============ */
.nc-notif { width: 360px; }
.nc-notif-header {
padding: 14px 18px;
border-bottom: 1px solid rgba(255,255,255,0.06);
display: flex;
justify-content: space-between;
align-items: center;
}
.nc-notif-header-title {
font-size: 11px;
letter-spacing: 0.22em;
color: var(--nc-text-muted);
text-transform: uppercase;
font-weight: 600;
}
.nc-notif-list { max-height: 420px; overflow-y: auto; }
.nc-notif-empty {
padding: 24px 18px;
text-align: center;
font-size: 13px;
color: var(--nc-text-dim);
letter-spacing: 0.04em;
}
.nc-notif-row {
padding: 14px 18px;
border-bottom: 1px solid rgba(255,255,255,0.04);
position: relative;
display: flex;
gap: 12px;
align-items: flex-start;
}
.nc-notif-row.is-unread { background: rgba(255,255,255,0.03); }
.nc-notif-row.is-unread::before {
content: "";
position: absolute;
left: 6px; top: 22px;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--nc-unread-dot);
}
.nc-notif-icon {
width: 32px; height: 32px;
flex-shrink: 0;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.12);
display: flex;
align-items: center;
justify-content: center;
color: var(--nc-accent);
}
.nc-notif-body { flex: 1; min-width: 0; }
.nc-notif-text {
font-size: 13px;
color: var(--nc-text);
line-height: 1.35;
}
.nc-notif-text b { font-weight: 600; }
.nc-notif-meta {
font-size: 10px;
color: var(--nc-text-dim);
margin-top: 4px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.me-btn:hover {
background: rgba(0, 210, 255, 0.2);
border-color: var(--bb-tag);
box-shadow: 0 0 10px rgba(0, 210, 255, 0.4);
transform: scale(1.05);
}
.me-btn:active {
transform: scale(0.98);
}
/* Sunset Mode */
.sunset .navbar {
background: rgba(20, 5, 45, 0.85);
border-bottom-color: rgba(255, 64, 207, 0.2);
box-shadow: 0 4px 20px rgba(242, 106, 226, 0.15);
}
.sunset .me-btn {
background: rgba(242, 106, 226, 0.1);
border-color: var(--bb-border);
}
.sunset .me-btn:hover {
background: rgba(242, 106, 226, 0.2);
border-color: var(--bb-tag);
box-shadow: 0 0 10px rgba(242, 106, 226, 0.4);
}
.container-fluid {
.nc-notif-actions {
display: flex;
gap: 6px;
margin-top: 10px;
}
.nc-btn-accept,
.nc-btn-decline {
padding: 6px 12px;
font-size: 10px;
font-family: inherit;
letter-spacing: 0.18em;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
text-transform: uppercase;
font-weight: 600;
border: none;
transition: opacity 0.15s;
}
.nc-btn-accept {
background: var(--nc-accent);
color: var(--nc-badge-text);
font-weight: 700;
}
.nc-btn-decline {
background: transparent;
color: var(--nc-text-muted);
border: 1px solid rgba(255,255,255,0.15);
}
.nc-btn-accept:disabled,
.nc-btn-decline:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.nc-notif-footer {
padding: 10px 18px;
border-top: 1px solid rgba(255,255,255,0.06);
}
.nc-view-all {
width: 100%;
background: transparent;
border: none;
color: var(--nc-text-dim);
font-size: 11px;
font-family: inherit;
letter-spacing: 0.2em;
cursor: pointer;
text-transform: uppercase;
padding: 6px 0;
transition: color 0.15s;
}
.nc-view-all:hover { color: var(--nc-text-muted); }
/* ============ PROFILE MENU ============ */
.nc-menu { width: 250px; }
.nc-menu-header {
padding: 16px 16px 14px;
border-bottom: 1px solid rgba(255,255,255,0.06);
display: flex;
gap: 12px;
align-items: center;
}
.ms-auto {
margin-left: auto;
.nc-menu-user-name {
font-size: 14px;
color: var(--nc-text);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nc-menu-user-sub {
font-size: 11px;
color: var(--nc-text-dim);
margin-top: 2px;
letter-spacing: 0.06em;
}
.nc-menu-group { padding: 6px 0; }
.nc-menu-group + .nc-menu-group {
border-top: 1px solid rgba(255,255,255,0.06);
}
.nc-menu-item {
padding: 9px 16px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
color: var(--nc-text-muted);
font-size: 13px;
font-family: inherit;
background: transparent;
border: none;
width: 100%;
text-align: left;
transition: background 0.12s, color 0.12s;
}
.nc-menu-item:hover {
background: var(--nc-accent-hover);
color: var(--nc-accent);
}
.nc-menu-item.danger { color: var(--nc-danger); }
.nc-menu-item.danger:hover { background: rgba(255,122,122,0.08); color: var(--nc-danger); }
.nc-menu-icon { opacity: 0.85; display: inline-flex; }
.nc-menu-label { flex: 1; }
/* ============ DARK MODE TOGGLE PILL ============ */
.nc-toggle {
width: 28px; height: 16px;
border-radius: 8px;
background: rgba(255,255,255,0.15);
position: relative;
flex-shrink: 0;
transition: background 0.2s;
}
.nc-toggle.is-on { background: var(--nc-accent); }
.nc-toggle::after {
content: "";
position: absolute;
top: 2px; left: 2px;
width: 12px; height: 12px;
border-radius: 50%;
background: #fff;
transition: left 0.2s;
}
.nc-toggle.is-on::after { left: 14px; }
/* ============ AUTH BUTTONS (logged out) ============ */
.nc-auth-btn {
background: transparent;
border: 1px solid var(--nc-border);
color: var(--nc-text-muted);
padding: 7px 14px;
font-size: 11px;
font-family: inherit;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.nc-auth-btn:hover {
background: rgba(255,255,255,0.06);
color: var(--nc-text);
}
.nc-auth-btn--primary {
background: var(--nc-accent);
border-color: var(--nc-accent);
color: var(--nc-badge-text);
}
.nc-auth-btn--primary:hover {
filter: brightness(1.1);
color: var(--nc-badge-text);
}
/* ============ NOTIF SCROLLBAR ============ */
.nc-notif-list::-webkit-scrollbar { width: 6px; }
.nc-notif-list::-webkit-scrollbar-track { background: transparent; }
.nc-notif-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
+183 -23
View File
@@ -1,28 +1,188 @@
<nav class="navbar">
<div class="container-fluid">
<span class="navbar-brand">NowChess</span>
<div class="ms-auto">
<div class="d-flex align-items-center gap-2">
<button type="button" class="app-btn" (click)="toggleTheme()">
{{ isDarkMode ? 'Light mode' : 'Dark mode' }}
</button>
@if (currentUser; as user) {
<div class="d-flex align-items-center gap-2 user-section">
<button type="button" class="me-btn" (click)="goToProfile()">
👤 {{ user.username }}
</button>
<button type="button" class="app-btn" (click)="logout()">Logout</button>
</div>
} @else {
<button type="button" class="app-btn" (click)="openLoginDialog()">
Login
</button>
<button type="button" class="app-btn" (click)="openRegisterDialog()">
Register
</button>
<nav class="nc-nav">
<!-- Logo -->
<div class="nc-logo" (click)="goToHome()" role="button" tabindex="0">
<div class="nc-logo-mark"></div>
<span class="nc-logo-text">NowChess</span>
</div>
<!-- Center links — only when logged in -->
@if (currentUser) {
<div class="nc-links">
<button type="button" class="nc-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round">
<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>
Watch
</button>
<button type="button" class="nc-link">Leaderboard</button>
</div>
}
<!-- Right cluster -->
<div class="nc-right">
@if (currentUser; as user) {
<!-- Notifications bell -->
<div class="nc-dropdown-wrap" data-dropdown="notif">
<button type="button" class="nc-bell" [class.is-open]="notifOpen" (click)="toggleNotif($event)"
aria-label="Notifications">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
@if (incomingChallenges.length > 0) {
<span class="nc-badge">{{ incomingChallenges.length }}</span>
}
</button>
@if (notifOpen) {
<div class="nc-popover nc-notif" (click)="$event.stopPropagation()">
<div class="nc-notif-header">
<span class="nc-notif-header-title">Challenges</span>
</div>
<div class="nc-notif-list">
@if (incomingChallenges.length === 0) {
<div class="nc-notif-empty">No pending challenges</div>
}
@for (challenge of incomingChallenges; track challenge.id) {
<div class="nc-notif-row is-unread">
<div class="nc-notif-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" /><path d="M16 16l4 4" />
<path d="M19 21l2 -2" /><path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</div>
<div class="nc-notif-body">
<div class="nc-notif-text">
<b>{{ challenge.challenger.name }}</b> challenged you to a
{{ getTimeControlDisplay(challenge) }} game.
</div>
<div class="nc-notif-meta">
{{ challenge.challenger.rating }} · {{ challenge.timeControl.type ?? 'Custom' }} · {{ getExpirationInfo(challenge) }}
</div>
<div class="nc-notif-actions">
<button type="button" class="nc-btn-accept"
[disabled]="acceptingId === challenge.id || !!decliningId"
(click)="acceptChallenge($event, challenge)">
<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="20 6 9 17 4 12" />
</svg>
{{ acceptingId === challenge.id ? '...' : 'Accept' }}
</button>
<button type="button" class="nc-btn-decline"
[disabled]="!!acceptingId || decliningId === challenge.id"
(click)="declineChallenge($event, challenge)">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
{{ decliningId === challenge.id ? '...' : 'Decline' }}
</button>
</div>
</div>
</div>
}
</div>
<div class="nc-notif-footer">
<button type="button" class="nc-view-all" (click)="goToChallenges()">View all challenges</button>
</div>
</div>
}
</div>
<!-- Profile -->
<div class="nc-dropdown-wrap" data-dropdown="profile">
<button type="button" class="nc-profile" [class.is-open]="profileOpen" (click)="toggleProfile($event)">
<div class="nc-avatar nc-avatar-sm">{{ getInitial() }}</div>
<span class="nc-profile-name">{{ user.username }}</span>
<svg class="nc-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
@if (profileOpen) {
<div class="nc-popover nc-menu" (click)="$event.stopPropagation()">
<div class="nc-menu-header">
<div class="nc-avatar nc-avatar-md">{{ getInitial() }}</div>
<div>
<div class="nc-menu-user-name">{{ user.username }}</div>
<div class="nc-menu-user-sub">{{ user.rating }} · &#64;{{ user.username }}</div>
</div>
</div>
<div class="nc-menu-group">
<button type="button" class="nc-menu-item" (click)="goToProfile()">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<span class="nc-menu-label">My profile</span>
</button>
<button type="button" class="nc-menu-item" (click)="goToChallenges()">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" /><path d="M16 16l4 4" />
<path d="M19 21l2 -2" /><path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</span>
<span class="nc-menu-label">Challenges</span>
</button>
<button type="button" class="nc-menu-item" (click)="toggleTheme($event)">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
</span>
<span class="nc-menu-label">{{ isDarkMode ? 'Light mode' : 'Dark mode' }}</span>
<span class="nc-toggle" [class.is-on]="isDarkMode"></span>
</button>
</div>
<div class="nc-menu-group">
<button type="button" class="nc-menu-item danger" (click)="logout()">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</span>
<span class="nc-menu-label">Log out</span>
</button>
</div>
</div>
}
</div>
} @else {
<!-- Logged-out auth buttons -->
<button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button>
<button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()">Register</button>
}
</div>
</nav>
@@ -32,4 +192,4 @@
@if (showRegisterDialog) {
<app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" />
}
}
+142 -8
View File
@@ -1,4 +1,4 @@
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { Component, DestroyRef, HostListener, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -8,6 +8,10 @@ import { CurrentUser } from '../../models/auth.models';
import { LoginDialogComponent } from '../login-dialog/login-dialog.component';
import { RegisterDialogComponent } from '../register-dialog/register-dialog.component';
import { ThemeService } from '../../services/theme.service';
import { ChallengeEventService } from '../../services/challenge-event.service';
import { ChallengeService } from '../../services/challenge.service';
import { ChallengeWebSocketService } from '../../services/challenge-websocket.service';
import { Challenge } from '../../models/challenge.models';
@Component({
selector: 'app-toolbar',
@@ -21,35 +25,104 @@ export class ToolbarComponent implements OnInit {
private readonly authService = inject(AuthService);
private readonly authDialogService = inject(AuthDialogService);
private readonly themeService = inject(ThemeService);
private readonly challengeEventService = inject(ChallengeEventService);
private readonly challengeService = inject(ChallengeService);
private readonly challengeWs = inject(ChallengeWebSocketService);
private readonly router = inject(Router);
private pollHandle: ReturnType<typeof setInterval> | null = null;
currentUser: CurrentUser | null = null;
showLoginDialog = false;
showRegisterDialog = false;
isDarkMode = false;
profileOpen = false;
notifOpen = false;
incomingChallenges: Challenge[] = [];
acceptingId: string | null = null;
decliningId: string | null = null;
ngOnInit(): void {
this.destroyRef.onDestroy(() => this.stopPolling());
this.authService.currentUser$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
.subscribe(user => {
this.currentUser = user;
if (user) {
this.challengeWs.connect();
this.startPolling();
} else {
this.challengeWs.disconnect();
this.stopPolling();
this.challengeEventService.clear();
}
});
this.authDialogService.dialogState$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
.subscribe(state => {
this.showLoginDialog = state === 'login';
this.showRegisterDialog = state === 'register';
});
this.themeService.darkMode$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((isDarkMode) => {
this.isDarkMode = isDarkMode;
});
.subscribe(isDark => { this.isDarkMode = isDark; });
this.challengeEventService.getIncomingChallenges$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(challenges => { this.incomingChallenges = challenges; });
}
private startPolling(): void {
this.fetchIncoming();
this.pollHandle = setInterval(() => this.fetchIncoming(), 5000);
}
private stopPolling(): void {
if (this.pollHandle !== null) {
clearInterval(this.pollHandle);
this.pollHandle = null;
}
}
private fetchIncoming(): void {
this.challengeService.listChallenges().subscribe({
next: response => {
const incoming = response.in ?? response.incoming ?? [];
this.challengeEventService.setIncomingChallenges(incoming);
}
});
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (!(event.target as HTMLElement).closest('[data-dropdown]')) {
this.profileOpen = false;
this.notifOpen = false;
}
}
toggleProfile(event: MouseEvent): void {
event.stopPropagation();
const wasOpen = this.profileOpen;
this.profileOpen = false;
this.notifOpen = false;
this.profileOpen = !wasOpen;
}
toggleNotif(event: MouseEvent): void {
event.stopPropagation();
const wasOpen = this.notifOpen;
this.profileOpen = false;
this.notifOpen = false;
this.notifOpen = !wasOpen;
}
openLoginDialog(): void {
this.profileOpen = false;
this.notifOpen = false;
this.authDialogService.openLogin();
}
@@ -58,6 +131,8 @@ export class ToolbarComponent implements OnInit {
}
openRegisterDialog(): void {
this.profileOpen = false;
this.notifOpen = false;
this.authDialogService.openRegister();
}
@@ -66,15 +141,30 @@ export class ToolbarComponent implements OnInit {
}
logout(): void {
this.profileOpen = false;
this.notifOpen = false;
this.authService.logout();
}
toggleTheme(): void {
toggleTheme(event: MouseEvent): void {
event.stopPropagation();
this.themeService.toggleTheme();
}
goToHome(): void {
void this.router.navigate(['/']);
}
goToProfile(): void {
this.router.navigate(['/profile']);
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/profile']);
}
goToChallenges(): void {
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/challenges']);
}
onLoginSuccess(): void {
@@ -84,4 +174,48 @@ export class ToolbarComponent implements OnInit {
onRegisterSuccess(): void {
this.closeRegisterDialog();
}
getInitial(): string {
return this.currentUser?.username?.charAt(0).toUpperCase() ?? '?';
}
getTimeControlDisplay(challenge: Challenge): string {
const { limit, increment } = challenge.timeControl;
if (!limit || !increment) return 'Unlimited';
return `${Math.floor(limit / 60)}+${increment}`;
}
getExpirationInfo(challenge: Challenge): string {
const diff = new Date(challenge.expiresAt).getTime() - Date.now();
if (diff <= 0) return 'Expired';
const min = Math.floor(diff / 60000);
return min > 60 ? `${Math.floor(min / 60)}h` : `${min}m`;
}
acceptChallenge(event: MouseEvent, challenge: Challenge): void {
event.stopPropagation();
if (this.acceptingId || this.decliningId) return;
this.acceptingId = challenge.id;
this.challengeService.acceptChallenge(challenge.id).subscribe({
next: accepted => {
this.acceptingId = null;
this.challengeEventService.onChallengeAccepted(accepted);
if (accepted.gameId) void this.router.navigate(['/game', accepted.gameId]);
},
error: () => { this.acceptingId = null; }
});
}
declineChallenge(event: MouseEvent, challenge: Challenge): void {
event.stopPropagation();
if (this.acceptingId || this.decliningId) return;
this.decliningId = challenge.id;
this.challengeService.declineChallenge(challenge.id, { reason: 'generic' }).subscribe({
next: () => {
this.decliningId = null;
this.challengeEventService.removeChallenge(challenge.id);
},
error: () => { this.decliningId = null; }
});
}
}
+6
View File
@@ -1,5 +1,10 @@
export type GameTurn = 'white' | 'black';
export interface ClockState {
whiteRemainingMs: number;
blackRemainingMs: number;
}
export type GameStatus =
| 'started'
| 'check'
@@ -26,6 +31,7 @@ export interface GameState {
moves: string[];
undoAvailable: boolean;
redoAvailable: boolean;
clock: ClockState | null;
}
export interface GameFull {
@@ -104,7 +104,7 @@ export class ChallengesComponent implements OnInit, OnDestroy {
}
declineChallenge(challenge: Challenge): void {
this.challengeService.declineChallenge(challenge.id, { reason: 'Not interested' })
this.challengeService.declineChallenge(challenge.id, { reason: 'generic' })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
+5 -11
View File
@@ -20,30 +20,24 @@
</p>
</div>
}
@if (facade.isGameFinished && facade.gameCompletionMessage) {
<div class="game-completion-alert alert alert-success mb-3">
<h2 class="completion-title">{{ facade.gameCompletionMessage }}</h2>
<p class="completion-subtitle mb-0">
<a routerLink="/" class="completion-link">Start a new game</a>
</p>
</div>
}
<div class="container-fluid">
<div class="row g-3">
<!-- Left Sidebar - Dummy Timers -->
<!-- Left Sidebar - Timers -->
@if (hasTimer) {
<div class="col-lg-3 col-md-6 col-12 order-lg-1 order-2">
<section class="timer-card">
<h2>Timers</h2>
<div class="player-timer" [class.active-timer]="facade.state.turn === 'white'">
<p class="timer-label">White</p>
<p class="timer-value">{{ formatTimer(whiteTimerSeconds) }}</p>
<p class="timer-value">{{ formatTimer(whiteTimerMs) }}</p>
</div>
<div class="player-timer" [class.active-timer]="facade.state.turn === 'black'">
<p class="timer-label">Black</p>
<p class="timer-value">{{ formatTimer(blackTimerSeconds) }}</p>
<p class="timer-value">{{ formatTimer(blackTimerMs) }}</p>
</div>
</section>
</div>
}
<!-- Center - Chess Board -->
<div class="col-lg-6 col-md-12 col-12 order-lg-2 order-1">
+31 -122
View File
@@ -8,16 +8,8 @@ import { InputCardComponent } from '../../components/input-card/input-card.compo
import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component';
import { GameFacade } from './game.facade';
type TimerTurn = 'white' | 'black';
type BoardTheme = 'arabian' | 'classic';
interface TimerSnapshot {
whiteSeconds: number;
blackSeconds: number;
turn: TimerTurn;
savedAt: number;
}
@Component({
selector: 'app-game',
standalone: true,
@@ -27,26 +19,28 @@ interface TimerSnapshot {
styleUrl: './game.component.css'
})
export class GameComponent implements OnInit, OnDestroy {
private static readonly TIMER_START_SECONDS = 10 * 60;
private static readonly BOARD_THEME_STORAGE_KEY = 'nowchess.boardTheme';
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
readonly facade = inject(GameFacade);
whiteTimerSeconds = GameComponent.TIMER_START_SECONDS;
blackTimerSeconds = GameComponent.TIMER_START_SECONDS;
whiteTimerMs: number | null = null;
blackTimerMs: number | null = null;
exportType: 'fen' | 'pgn' = 'fen';
boardTheme: BoardTheme = 'arabian';
isDarkMode = false;
exportValue = '';
exportNotice = '';
private timerIntervalId: number | null = null;
private activeGameId = '';
get hasTimer(): boolean {
return this.facade.state?.clock != null;
}
ngOnInit(): void {
this.applyIncomingTheme();
this.syncThemeFromDocument();
this.boardTheme = this.resolveStoredBoardTheme();
this.startDummyTimers();
this.startClock();
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => {
const id = paramMap.get('gameId');
@@ -56,8 +50,6 @@ export class GameComponent implements OnInit, OnDestroy {
return;
}
this.activeGameId = id;
this.restoreTimers(id);
this.facade.setGameId(id);
this.syncExportValue();
});
@@ -67,8 +59,6 @@ export class GameComponent implements OnInit, OnDestroy {
if (this.timerIntervalId !== null) {
window.clearInterval(this.timerIntervalId);
}
this.persistTimers(this.resolveCurrentTurn());
}
private syncThemeFromDocument(): void {
@@ -122,40 +112,42 @@ export class GameComponent implements OnInit, OnDestroy {
});
}
formatTimer(totalSeconds: number): string {
const safeSeconds = Math.max(0, totalSeconds);
const minutes = Math.floor(safeSeconds / 60)
.toString()
.padStart(2, '0');
const seconds = (safeSeconds % 60).toString().padStart(2, '0');
formatTimer(ms: number | null): string {
if (ms === null) {
return '--:--';
}
if (ms < 0) {
return '';
}
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
return `${minutes}:${seconds}`;
}
private startDummyTimers(): void {
private startClock(): void {
if (this.timerIntervalId !== null) {
return;
}
this.timerIntervalId = window.setInterval(() => {
this.tickDummyTimers();
this.syncExportValue();
}, 1000);
this.timerIntervalId = window.setInterval(() => this.tickClock(), 200);
}
private tickDummyTimers(): void {
private tickClock(): void {
const state = this.facade.state;
if (!state || this.facade.loading || this.facade.isGameFinished) {
const clock = state?.clock;
if (!clock || this.facade.isGameFinished) {
this.whiteTimerMs = null;
this.blackTimerMs = null;
return;
}
if (state.turn === 'white') {
this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - 1);
this.persistTimers('white');
return;
}
this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - 1);
this.persistTimers('black');
const elapsed = Math.max(0, Date.now() - this.facade.clockSyncedAt);
const activeIsWhite = state!.turn === 'white';
this.whiteTimerMs =
clock.whiteRemainingMs < 0 ? -1 : Math.max(0, clock.whiteRemainingMs - (activeIsWhite ? elapsed : 0));
this.blackTimerMs =
clock.blackRemainingMs < 0 ? -1 : Math.max(0, clock.blackRemainingMs - (!activeIsWhite ? elapsed : 0));
this.syncExportValue();
}
private syncExportValue(): void {
@@ -168,89 +160,6 @@ export class GameComponent implements OnInit, OnDestroy {
this.exportValue = this.exportType === 'fen' ? state.fen : state.pgn;
}
private restoreTimers(gameId: string): void {
const fallbackTurn = this.resolveCurrentTurn();
const rawSnapshot = localStorage.getItem(this.getTimerStorageKey(gameId));
if (!rawSnapshot) {
this.resetTimers();
this.persistTimers(fallbackTurn);
return;
}
const snapshot = this.parseSnapshot(rawSnapshot);
if (!snapshot) {
this.resetTimers();
this.persistTimers(fallbackTurn);
return;
}
this.applySnapshot(snapshot);
this.persistTimers(snapshot.turn);
}
private parseSnapshot(rawSnapshot: string): TimerSnapshot | null {
try {
const parsed = JSON.parse(rawSnapshot) as Partial<TimerSnapshot>;
if (
typeof parsed.whiteSeconds !== 'number' ||
typeof parsed.blackSeconds !== 'number' ||
(parsed.turn !== 'white' && parsed.turn !== 'black') ||
typeof parsed.savedAt !== 'number'
) {
return null;
}
return {
whiteSeconds: Math.max(0, Math.floor(parsed.whiteSeconds)),
blackSeconds: Math.max(0, Math.floor(parsed.blackSeconds)),
turn: parsed.turn,
savedAt: parsed.savedAt
};
} catch {
return null;
}
}
private applySnapshot(snapshot: TimerSnapshot): void {
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - snapshot.savedAt) / 1000));
this.whiteTimerSeconds = snapshot.whiteSeconds;
this.blackTimerSeconds = snapshot.blackSeconds;
if (snapshot.turn === 'white') {
this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - elapsedSeconds);
return;
}
this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - elapsedSeconds);
}
private persistTimers(turn: TimerTurn): void {
if (!this.activeGameId) {
return;
}
const snapshot: TimerSnapshot = {
whiteSeconds: this.whiteTimerSeconds,
blackSeconds: this.blackTimerSeconds,
turn,
savedAt: Date.now()
};
localStorage.setItem(this.getTimerStorageKey(this.activeGameId), JSON.stringify(snapshot));
}
private resolveCurrentTurn(): TimerTurn {
return this.facade.state?.turn ?? 'white';
}
private resetTimers(): void {
this.whiteTimerSeconds = GameComponent.TIMER_START_SECONDS;
this.blackTimerSeconds = GameComponent.TIMER_START_SECONDS;
}
private getTimerStorageKey(gameId: string): string {
return `nowchess.timer.${gameId}`;
}
private resolveStoredBoardTheme(): BoardTheme {
const stored = localStorage.getItem(GameComponent.BOARD_THEME_STORAGE_KEY);
return stored === 'classic' ? 'classic' : 'arabian';
+5
View File
@@ -13,6 +13,7 @@ import { GameStreamService } from '../../services/game-stream.service';
export class GameFacade implements OnDestroy {
gameId = '';
game: GameFull | null = null;
clockSyncedAt = 0;
errorMessage = '';
moveInput = '';
fenInput = '';
@@ -119,6 +120,7 @@ export class GameFacade implements OnDestroy {
next: (state) => {
if (this.game) {
this.game = { ...this.game, state };
this.clockSyncedAt = Date.now();
this.updateGameCompletion();
}
this.moveInput = '';
@@ -207,6 +209,7 @@ export class GameFacade implements OnDestroy {
.subscribe({
next: (game) => {
this.game = game;
this.clockSyncedAt = Date.now();
this.loading = false;
this.updateGameCompletion();
this.startStreaming();
@@ -232,6 +235,7 @@ export class GameFacade implements OnDestroy {
private applyStreamEvent(event: GameStreamEvent): void {
if (event.type === 'gameFull') {
this.game = event.game;
this.clockSyncedAt = Date.now();
this.boardSelection = this.boardSelectionService.clearSelection();
this.updateGameCompletion();
this.tryMakeBotMove();
@@ -241,6 +245,7 @@ export class GameFacade implements OnDestroy {
if (event.type === 'gameState' && this.game) {
const moveCountBefore = this.game.state.moves.length;
this.game = { ...this.game, state: event.state };
this.clockSyncedAt = Date.now();
this.updateGameCompletion();
if (event.state.moves.length !== moveCountBefore) {
this.boardSelection = this.boardSelectionService.clearSelection();
+700
View File
@@ -0,0 +1,700 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NowChess Nav — Logged In</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&display=swap" rel="stylesheet">
<style>
/* ============ TOKENS ============ */
:root {
--neon: #ff45c8;
--neon-soft: rgba(255, 69, 200, 0.55);
--neon-glow: 0 0 6px var(--neon);
--bg: #06060d;
--text: #fff;
--text-muted: rgba(255, 255, 255, 0.65);
--text-dim: rgba(255, 255, 255, 0.45);
--border: rgba(255, 255, 255, 0.08);
--surface: rgba(10, 8, 22, 0.95);
--warning: #ffb13a;
--danger: #ff7a7a;
--sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: var(--bg);
font-family: var(--sans);
color: var(--text);
}
button { font-family: inherit; }
/* ============ NAV CONTAINER ============ */
.nc-nav {
position: fixed;
top: 0; left: 0; right: 0;
height: 56px;
z-index: 100;
display: flex;
align-items: center;
padding: 0 24px;
background: linear-gradient(180deg, rgba(8,5,20,0.85) 0%, rgba(8,5,20,0.55) 70%, rgba(8,5,20,0) 100%);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
/* ============ LOGO ============ */
.nc-logo {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.nc-logo-mark {
width: 22px; height: 22px;
background: var(--neon);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
color: #1a0014;
font-size: 13px;
}
.nc-logo-text {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
}
/* ============ NAV LINKS (center) ============ */
.nc-links {
flex: 1;
display: flex;
justify-content: center;
gap: 4px;
}
.nc-link {
background: transparent;
border: none;
color: var(--text-muted);
padding: 8px 14px;
font-size: 12px;
letter-spacing: 0.08em;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
position: relative;
transition: color 0.15s;
}
.nc-link:hover { color: var(--text); }
.nc-link::after {
content: "";
position: absolute;
bottom: 2px; left: 14px; right: 14px;
height: 1px;
background: var(--neon);
box-shadow: var(--neon-glow);
opacity: 0;
transition: opacity 0.15s;
}
.nc-link:hover::after { opacity: 1; }
/* ============ RIGHT CLUSTER ============ */
.nc-right {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
/* Bell button */
.nc-bell {
width: 36px; height: 36px;
border: 1px solid var(--border);
background: transparent;
color: rgba(255,255,255,0.85);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: background 0.15s;
}
.nc-bell:hover,
.nc-bell.is-open {
background: rgba(255, 69, 200, 0.12);
}
.nc-badge {
position: absolute;
top: 6px; right: 6px;
min-width: 14px; height: 14px;
border-radius: 7px;
background: var(--neon);
color: #1a0014;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
box-shadow: 0 0 6px rgba(255,69,200,0.6);
}
/* Profile button */
.nc-profile {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px 4px 4px;
height: 36px;
border: 1px solid var(--border);
background: transparent;
cursor: pointer;
color: rgba(255,255,255,0.85);
transition: background 0.15s;
}
.nc-profile:hover,
.nc-profile.is-open {
background: rgba(255, 69, 200, 0.12);
}
.nc-profile-name {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
}
.nc-profile .nc-chevron { opacity: 0.5; }
/* Avatar */
.nc-avatar {
border-radius: 50%;
background: linear-gradient(135deg, #ff45c8 0%, #7a2fd6 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.nc-avatar-sm { width: 26px; height: 26px; font-size: 11px; }
.nc-avatar-md { width: 40px; height: 40px; font-size: 17px; }
/* ============ POPOVERS ============ */
.nc-popover {
position: absolute;
top: calc(100% + 10px);
right: 0;
background: var(--surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border);
box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(255, 69, 200, 0.15);
z-index: 200;
overflow: hidden;
display: none;
}
.nc-popover.is-open { display: block; }
.nc-dropdown-wrap { position: relative; }
/* Notifications popover */
.nc-notif { width: 360px; }
.nc-notif-header {
padding: 14px 18px;
border-bottom: 1px solid rgba(255,255,255,0.06);
display: flex;
justify-content: space-between;
align-items: center;
}
.nc-notif-header-title {
font-size: 11px;
letter-spacing: 0.22em;
color: rgba(255,255,255,0.85);
text-transform: uppercase;
font-weight: 600;
}
.nc-mark-all {
background: transparent;
border: none;
color: var(--neon);
font-size: 10px;
letter-spacing: 0.18em;
cursor: pointer;
text-transform: uppercase;
padding: 0;
}
.nc-notif-list {
max-height: 420px;
overflow-y: auto;
}
.nc-notif-row {
padding: 14px 18px;
border-bottom: 1px solid rgba(255,255,255,0.04);
position: relative;
display: flex;
gap: 12px;
align-items: flex-start;
}
.nc-notif-row.is-unread { background: rgba(255, 69, 200, 0.04); }
.nc-notif-row.is-unread::before {
content: "";
position: absolute;
left: 8px; top: 22px;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--neon);
box-shadow: 0 0 6px var(--neon);
}
.nc-notif-row.kind-warning.is-unread::before {
background: var(--warning);
box-shadow: 0 0 6px var(--warning);
}
.nc-notif-icon {
width: 32px; height: 32px;
flex-shrink: 0;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255, 69, 200, 0.25);
display: flex;
align-items: center;
justify-content: center;
color: var(--neon);
}
.nc-notif-row.kind-warning .nc-notif-icon {
border-color: rgba(255, 177, 58, 0.25);
color: var(--warning);
}
.nc-notif-body { flex: 1; min-width: 0; }
.nc-notif-text {
font-size: 13px;
color: #fff;
line-height: 1.35;
}
.nc-notif-text b { font-weight: 600; }
.nc-notif-meta {
font-size: 10px;
color: var(--text-dim);
margin-top: 4px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.nc-notif-actions {
display: flex;
gap: 6px;
margin-top: 10px;
}
.nc-btn-accept,
.nc-btn-decline,
.nc-btn-resume {
padding: 6px 12px;
font-size: 10px;
letter-spacing: 0.18em;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
text-transform: uppercase;
font-weight: 600;
border: none;
}
.nc-btn-accept {
background: var(--neon);
color: #1a0014;
font-weight: 700;
}
.nc-btn-decline {
background: transparent;
color: rgba(255,255,255,0.7);
border: 1px solid rgba(255,255,255,0.15);
}
.nc-btn-resume {
background: rgba(255, 177, 58, 0.15);
color: var(--warning);
border: 1px solid rgba(255, 177, 58, 0.4);
}
.nc-notif-footer {
padding: 10px 18px;
border-top: 1px solid rgba(255,255,255,0.06);
}
.nc-view-all {
width: 100%;
background: transparent;
border: none;
color: rgba(255,255,255,0.55);
font-size: 11px;
letter-spacing: 0.2em;
cursor: pointer;
text-transform: uppercase;
padding: 6px 0;
}
/* Profile menu */
.nc-menu { width: 250px; }
.nc-menu-header {
padding: 16px 16px 14px;
border-bottom: 1px solid rgba(255,255,255,0.06);
display: flex;
gap: 12px;
align-items: center;
}
.nc-menu-user-name {
font-size: 14px;
color: #fff;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nc-menu-user-sub {
font-size: 11px;
color: var(--text-dim);
margin-top: 2px;
letter-spacing: 0.06em;
}
.nc-menu-group {
padding: 6px 0;
}
.nc-menu-group + .nc-menu-group {
border-top: 1px solid rgba(255,255,255,0.06);
}
.nc-menu-item {
padding: 9px 16px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
color: rgba(255,255,255,0.85);
font-size: 13px;
background: transparent;
border: none;
width: 100%;
text-align: left;
transition: background 0.12s, color 0.12s;
}
.nc-menu-item:hover {
background: rgba(255, 69, 200, 0.08);
color: var(--neon);
}
.nc-menu-item.danger { color: var(--danger); }
.nc-menu-item .nc-menu-icon { opacity: 0.85; display: inline-flex; }
.nc-menu-item .nc-menu-label { flex: 1; }
/* Dark mode toggle pill */
.nc-toggle {
width: 28px; height: 16px;
border-radius: 8px;
background: rgba(255,255,255,0.15);
position: relative;
transition: background 0.2s;
}
.nc-toggle.is-on { background: var(--neon); }
.nc-toggle::after {
content: "";
position: absolute;
top: 2px; left: 2px;
width: 12px; height: 12px;
border-radius: 50%;
background: #fff;
transition: left 0.2s;
}
.nc-toggle.is-on::after { left: 14px; }
/* Scrollbar */
::-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); }
/* Demo background so the transparent nav reads */
.demo-bg {
position: fixed; inset: 0;
background: linear-gradient(180deg, #1a1838 0%, #2e2050 25%, #4a2962 45%, #8b3a6b 65%, #d44d4a 85%, #ff6b3d 100%);
z-index: 0;
}
</style>
</head>
<body>
<!-- Demo background (remove when embedding) -->
<div class="demo-bg"></div>
<!-- ============================================================
NAV — LOGGED IN
============================================================ -->
<nav class="nc-nav">
<!-- Logo -->
<div class="nc-logo">
<div class="nc-logo-mark"></div>
<div class="nc-logo-text">NowChess</div>
</div>
<!-- Center links -->
<div class="nc-links">
<button class="nc-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<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>
Watch
</button>
<button class="nc-link">Leaderboard</button>
</div>
<!-- Right cluster -->
<div class="nc-right">
<!-- Notifications -->
<div class="nc-dropdown-wrap" data-dropdown="notif">
<button class="nc-bell" data-trigger="notif" aria-label="Notifications">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/>
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
</svg>
<span class="nc-badge" data-unread-count>3</span>
</button>
<div class="nc-popover nc-notif" data-panel="notif">
<div class="nc-notif-header">
<div class="nc-notif-header-title">Notifications</div>
<button class="nc-mark-all" data-action="mark-all">Mark all read</button>
</div>
<div class="nc-notif-list">
<!-- Challenge -->
<div class="nc-notif-row kind-challenge is-unread" data-notif-id="1">
<div class="nc-notif-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<line x1="14.5" y1="17.5" x2="3" y2="6"/>
<path d="M13 19l6 -6"/><path d="M16 16l4 4"/>
<path d="M19 21l2 -2"/><path d="M15 5l4 4"/>
<path d="M21 3l-3 1l-4 4"/>
</svg>
</div>
<div class="nc-notif-body">
<div class="nc-notif-text"><b>magnus_42</b> challenged you to a 10|0 rapid game.</div>
<div class="nc-notif-meta">1840 · Rapid · 2m</div>
<div class="nc-notif-actions">
<button class="nc-btn-accept" data-action="accept">
<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="20 6 9 17 4 12"/></svg>
Accept
</button>
<button class="nc-btn-decline" data-action="decline">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Decline
</button>
</div>
</div>
</div>
<!-- Time warning -->
<div class="nc-notif-row kind-warning is-unread" data-notif-id="2">
<div class="nc-notif-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9"/>
<polyline points="12 7 12 12 15 14"/>
</svg>
</div>
<div class="nc-notif-body">
<div class="nc-notif-text"><b>Your turn</b> in game vs. blitz_kai — 38s left on your clock.</div>
<div class="nc-notif-meta">Blitz · 3|2 · now</div>
<div class="nc-notif-actions">
<button class="nc-btn-resume">Resume game →</button>
</div>
</div>
</div>
<!-- Challenge 2 -->
<div class="nc-notif-row kind-challenge is-unread" data-notif-id="3">
<div class="nc-notif-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<line x1="14.5" y1="17.5" x2="3" y2="6"/>
<path d="M13 19l6 -6"/><path d="M16 16l4 4"/>
<path d="M19 21l2 -2"/><path d="M15 5l4 4"/>
<path d="M21 3l-3 1l-4 4"/>
</svg>
</div>
<div class="nc-notif-body">
<div class="nc-notif-text"><b>rookjumper</b> wants a rematch — 5|3 blitz.</div>
<div class="nc-notif-meta">1755 · Blitz · 12m</div>
<div class="nc-notif-actions">
<button class="nc-btn-accept" data-action="accept">Accept</button>
<button class="nc-btn-decline" data-action="decline">Decline</button>
</div>
</div>
</div>
<!-- Read notification -->
<div class="nc-notif-row kind-challenge" data-notif-id="4">
<div class="nc-notif-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<line x1="14.5" y1="17.5" x2="3" y2="6"/>
<path d="M13 19l6 -6"/><path d="M16 16l4 4"/>
<path d="M19 21l2 -2"/><path d="M15 5l4 4"/>
<path d="M21 3l-3 1l-4 4"/>
</svg>
</div>
<div class="nc-notif-body">
<div class="nc-notif-text"><b>queens_gambit</b> challenged you to a casual game.</div>
<div class="nc-notif-meta">Unrated · 1h</div>
</div>
</div>
</div>
<div class="nc-notif-footer">
<button class="nc-view-all">View all activity</button>
</div>
</div>
</div>
<!-- Profile -->
<div class="nc-dropdown-wrap" data-dropdown="profile">
<button class="nc-profile" data-trigger="profile">
<div class="nc-avatar nc-avatar-sm">S</div>
<span class="nc-profile-name">Sha</span>
<svg class="nc-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<div class="nc-popover nc-menu" data-panel="profile">
<div class="nc-menu-header">
<div class="nc-avatar nc-avatar-md">S</div>
<div>
<div class="nc-menu-user-name">Sha</div>
<div class="nc-menu-user-sub">1842 · @sha</div>
</div>
</div>
<div class="nc-menu-group">
<button class="nc-menu-item">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</span>
<span class="nc-menu-label">My profile</span>
</button>
<button class="nc-menu-item">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<line x1="14.5" y1="17.5" x2="3" y2="6"/>
<path d="M13 19l6 -6"/><path d="M16 16l4 4"/>
<path d="M19 21l2 -2"/><path d="M15 5l4 4"/>
<path d="M21 3l-3 1l-4 4"/>
</svg>
</span>
<span class="nc-menu-label">Game history</span>
</button>
<button class="nc-menu-item">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</span>
<span class="nc-menu-label">Settings</span>
</button>
<button class="nc-menu-item" data-action="toggle-dark">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>
</svg>
</span>
<span class="nc-menu-label" data-dark-label>Light mode</span>
<span class="nc-toggle is-on" data-dark-toggle></span>
</button>
</div>
<div class="nc-menu-group">
<button class="nc-menu-item danger">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</span>
<span class="nc-menu-label">Log out</span>
</button>
</div>
</div>
</div>
</div>
</nav>
<script>
/* ============ DROPDOWN BEHAVIOR ============ */
(() => {
const wraps = document.querySelectorAll('[data-dropdown]');
const triggers = document.querySelectorAll('[data-trigger]');
const closeAll = () => {
document.querySelectorAll('.nc-popover.is-open').forEach(p => p.classList.remove('is-open'));
document.querySelectorAll('[data-trigger].is-open').forEach(t => t.classList.remove('is-open'));
};
triggers.forEach(trigger => {
trigger.addEventListener('click', e => {
e.stopPropagation();
const name = trigger.dataset.trigger;
const panel = document.querySelector(`[data-panel="${name}"]`);
const isOpen = panel.classList.contains('is-open');
closeAll();
if (!isOpen) {
panel.classList.add('is-open');
trigger.classList.add('is-open');
}
});
});
document.addEventListener('click', e => {
if (!e.target.closest('[data-dropdown]')) closeAll();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeAll();
});
/* ============ NOTIFICATION ACTIONS ============ */
const updateBadge = () => {
const unread = document.querySelectorAll('.nc-notif-row.is-unread').length;
const badge = document.querySelector('[data-unread-count]');
if (unread === 0) { badge.style.display = 'none'; }
else { badge.style.display = ''; badge.textContent = unread; }
};
document.querySelector('[data-action="mark-all"]')?.addEventListener('click', () => {
document.querySelectorAll('.nc-notif-row.is-unread').forEach(r => r.classList.remove('is-unread'));
updateBadge();
});
document.querySelectorAll('.nc-notif-row [data-action="accept"], .nc-notif-row [data-action="decline"]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
btn.closest('.nc-notif-row')?.remove();
updateBadge();
});
});
/* ============ DARK MODE TOGGLE ============ */
document.querySelector('[data-action="toggle-dark"]')?.addEventListener('click', e => {
e.stopPropagation();
const toggle = document.querySelector('[data-dark-toggle]');
const label = document.querySelector('[data-dark-label]');
const on = toggle.classList.toggle('is-on');
label.textContent = on ? 'Light mode' : 'Dark mode';
});
})();
</script>
</body>
</html>
@@ -263,6 +263,14 @@
</div>
}
@if (showChallengeDialog) {
<div class="dialog-overlay" (click)="closeChallengeDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()">
<app-challenge-create-dialog (closeChallengeDialog)="closeChallengeDialog()"></app-challenge-create-dialog>
</div>
</div>
}
@if (errorMessage) {
<p class="error-banner">{{ errorMessage }}</p>
}
+13 -24
View File
@@ -67,6 +67,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
showOptionsDialog = false;
showJoinDialog = false;
showImportDialog = false;
showChallengeDialog = false;
gameIdInput = '';
importMode: ImportMode = 'fen';
@@ -223,11 +224,21 @@ export class WelcomeComponent implements OnInit, OnDestroy {
}
startOneVsOne(): void {
if (!this.requireAuth(() => this.performStartOneVsOne())) {
if (!this.requireAuth(() => this.openChallengeDialog())) {
return;
}
this.performStartOneVsOne();
this.openChallengeDialog();
}
openChallengeDialog(): void {
this.closeAllDialogs();
this.showChallengeDialog = true;
}
closeChallengeDialog(): void {
this.showChallengeDialog = false;
this.errorMessage = '';
}
startVsBot(difficulty: Difficulty): void {
@@ -352,28 +363,6 @@ export class WelcomeComponent implements OnInit, OnDestroy {
action();
}
private performStartOneVsOne(): void {
if (this.creating) {
return;
}
this.errorMessage = '';
this.creating = true;
this.gameApi
.createGame()
.pipe(finalize(() => (this.creating = false)))
.subscribe({
next: (game) => {
void this.router.navigate(['/game', game.gameId], {
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
});
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Unable to create a game.');
}
});
}
private performStartVsBot(difficulty: Difficulty): void {
if (this.creating) {
+1 -1
View File
@@ -3,11 +3,11 @@ import { HttpInterceptorFn } from '@angular/common/http';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = localStorage.getItem('token');
// Add token to protected endpoints only (not registration or login)
const isProtectedEndpoint =
req.url.includes('/api/account/me') ||
req.url.includes('/api/account/bots') ||
req.url.includes('/api/account/official-bots') ||
req.url.includes('/api/board/game') ||
req.url.includes('/api/challenge');
if (token && isProtectedEndpoint) {
@@ -61,4 +61,18 @@ export class ChallengeEventService {
removeChallenge(challengeId: string): void {
this.onChallengeRemoved(challengeId);
}
/**
* Replace the full incoming list (used by HTTP polling)
*/
setIncomingChallenges(challenges: Challenge[]): void {
this.incomingChallenges$.next(challenges);
}
/**
* Clear all incoming challenges (used on logout)
*/
clear(): void {
this.incomingChallenges$.next([]);
}
}
+68 -88
View File
@@ -1,135 +1,115 @@
import { Injectable, inject } from '@angular/core';
import { Subject } from 'rxjs';
import { Router } from '@angular/router';
import { environment } from '../../environments/environment';
import { ChallengeEventService } from './challenge-event.service';
import { Challenge } from '../models/challenge.models';
import { ChallengeService } from './challenge.service';
/**
* Service to handle WebSocket connections for challenge events
* Listens for incoming challenge notifications and emits them to ChallengeEventService
*/
@Injectable({ providedIn: 'root' })
export class ChallengeWebSocketService {
private readonly challengeEventService = inject(ChallengeEventService);
private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router);
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 3000;
private intentionalClose = false;
/**
* Initialize WebSocket connection for challenge events
*/
connect(): void {
if (this.ws) {
return; // Already connected
}
if (this.ws) return;
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `${wsProtocol}://${window.location.host}/ws/challenges`;
const token = localStorage.getItem('token');
if (!token) return;
const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`;
try {
this.ws = new WebSocket(wsUrl);
this.intentionalClose = false;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log('Challenge WebSocket connected');
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
this.handleMessage(event.data as string);
};
this.ws.onerror = (error) => {
console.error('Challenge WebSocket error:', error);
this.ws.onerror = () => {
// onclose fires right after, handles reconnect
};
this.ws.onclose = () => {
console.log('Challenge WebSocket disconnected');
this.ws = null;
this.attemptReconnect();
if (!this.intentionalClose) {
this.attemptReconnect();
}
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
} catch {
this.attemptReconnect();
}
}
/**
* Close the WebSocket connection
*/
disconnect(): void {
this.intentionalClose = true;
this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
/**
* Send a message through WebSocket
*/
send(message: any): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
/**
* Handle incoming WebSocket messages
*/
private handleMessage(data: string): void {
let message: Record<string, unknown>;
try {
const message = JSON.parse(data);
if (!message.type) {
return;
}
switch (message.type) {
case 'challenge.received':
if (message.challenge) {
this.challengeEventService.onChallengeReceived(message.challenge as Challenge);
}
break;
case 'challenge.accepted':
if (message.challenge) {
this.challengeEventService.onChallengeAccepted(message.challenge as Challenge);
}
break;
case 'challenge.declined':
if (message.challengeId) {
this.challengeEventService.removeChallenge(message.challengeId);
}
break;
case 'challenge.expired':
if (message.challengeId) {
this.challengeEventService.removeChallenge(message.challengeId);
}
break;
default:
console.debug('Unknown challenge message type:', message.type);
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
}
/**
* Attempt to reconnect to WebSocket
*/
private attemptReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max WebSocket reconnection attempts reached');
message = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
}
this.reconnectAttempts++;
console.log(`Attempting WebSocket reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
switch (message['type']) {
case 'CONNECTED':
break;
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
case 'challengeCreated': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({
next: challenge => this.challengeEventService.onChallengeReceived(challenge),
error: () => { /* challenge may have already expired */ }
});
}
break;
}
case 'challengeAccepted': {
const challengeId = message['challengeId'] as string | undefined;
const gameId = message['gameId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
if (gameId) {
void this.router.navigate(['/game', gameId]);
}
break;
}
case 'challengeDeclined':
case 'challengeExpired':
case 'challengeCancelled': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
break;
}
}
}
private attemptReconnect(): void {
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++;
setTimeout(() => { this.connect(); }, this.reconnectDelay);
}
}
+17 -7
View File
@@ -10,6 +10,7 @@ export class GameStreamService {
private readonly destroyRef = inject(DestroyRef);
private streamSubscription: Subscription | null = null;
private pollSubscription: Subscription | null = null;
private lastGameStateHash: string | null = null;
startStreaming(
gameId: string,
@@ -20,7 +21,10 @@ export class GameStreamService {
.streamGame(gameId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (event) => onEvent(event),
next: (event) => {
this.lastGameStateHash = JSON.stringify(event);
onEvent(event);
},
error: () => {
onStreamError();
this.startPolling(gameId, onEvent);
@@ -37,7 +41,7 @@ export class GameStreamService {
return;
}
this.pollSubscription = interval(1500)
this.pollSubscription = interval(5000)
.pipe(
startWith(0),
switchMap(() => this.gameApi.getGame(gameId)),
@@ -45,11 +49,16 @@ export class GameStreamService {
)
.subscribe({
next: (game) => {
const event: GameStreamEvent = {
type: 'gameFull',
game
};
onEvent(event);
// Only emit if game state changed to avoid unnecessary updates
const stateHash = JSON.stringify(game.state);
if (this.lastGameStateHash !== stateHash) {
this.lastGameStateHash = stateHash;
const event: GameStreamEvent = {
type: 'gameFull',
game
};
onEvent(event);
}
}
});
}
@@ -59,5 +68,6 @@ export class GameStreamService {
this.pollSubscription?.unsubscribe();
this.streamSubscription = null;
this.pollSubscription = null;
this.lastGameStateHash = null;
}
}
+20 -5
View File
@@ -84,8 +84,19 @@ export class StreamHandlerService {
}
};
// Set timeout to fallback if WebSocket doesn't connect quickly
const connectionTimeoutId = setTimeout(() => {
if (!connected && !fallbackActive) {
console.warn(`[StreamHandler] WebSocket timeout for ${gameId}, attempting NDJSON fallback`);
ws.close();
void startNdjsonFallback();
}
}, 3000);
ws.onopen = () => {
connected = true;
clearTimeout(connectionTimeoutId);
console.log(`[StreamHandler] WebSocket connected for ${gameId}`);
};
ws.onmessage = (message) => {
@@ -97,19 +108,23 @@ export class StreamHandlerService {
};
ws.onerror = (error) => {
console.warn(`[StreamHandler] WebSocket error for ${gameId}, attempting NDJSON fallback:`, error);
if (!connected) {
console.warn(`[StreamHandler] WebSocket error for ${gameId}:`, error);
clearTimeout(connectionTimeoutId);
if (!connected && !fallbackActive) {
void startNdjsonFallback();
}
};
ws.onclose = () => {
clearTimeout(connectionTimeoutId);
console.warn(`[StreamHandler] WebSocket closed for ${gameId}, connected=${connected}`);
if (!connected) {
if (connected) {
// Connection was established but closed, stream is complete
observer.complete();
} else if (!fallbackActive) {
// Connection never established, try fallback
console.log(`[StreamHandler] Starting NDJSON fallback for ${gameId}`);
void startNdjsonFallback();
} else {
observer.complete();
}
};
@@ -3,5 +3,6 @@ export const environment = {
apiBaseUrl: '',
accountServiceUrl: '',
wsBaseUrl: 'ws://localhost:8080',
userWsBaseUrl: 'ws://localhost:8084',
apiPath: '/api/board/game'
};
+1
View File
@@ -7,5 +7,6 @@ export const environment = {
apiBaseUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
accountServiceUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
wsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
userWsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
apiPath: '/api/board/game'
};
+1
View File
@@ -7,5 +7,6 @@ export const environment = {
apiBaseUrl: runtimeConfig.apiUrl || '',
accountServiceUrl: runtimeConfig.apiUrl || '',
wsBaseUrl: runtimeConfig.wsUrl,
userWsBaseUrl: runtimeConfig.wsUrl,
apiPath: '/api/board/game'
};
+3
View File
@@ -6,6 +6,9 @@
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<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&display=swap" rel="stylesheet">
<script src="/env.js" defer></script>
</head>
<body>
+4 -3
View File
@@ -5,11 +5,12 @@
box-sizing: border-box;
}
/* Light Mode (Default) */
/* Light Mode (Default) — sunset gradient palette */
html:not([data-theme='dark']),
html:not([data-theme='dark']) body {
background: linear-gradient(160deg, var(--color-primary-light), var(--color-secondary-mint));
color: var(--color-text-primary);
background: linear-gradient(180deg, #1a1838 0%, #2e2050 25%, #4a2962 45%, #8b3a6b 65%, #d44d4a 85%, #ff6b3d 100%);
background-attachment: fixed;
color: #fff;
}
html:not([data-theme='dark']) body::before {
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=2
PATCH=0
PATCH=3