fix: NCWF-2 bugs and desing fixes (#7)

Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-05-15 02:16:43 +02:00
parent 70a4debb40
commit c02414ea40
45 changed files with 3167 additions and 1277 deletions
@@ -0,0 +1,51 @@
.board-actions {
display: flex;
gap: 6px;
padding: 8px;
background: var(--nc-surface);
border: 1px solid var(--nc-border);
}
.board-actions.disabled {
opacity: 0.5;
pointer-events: none;
}
.btn {
flex: 1;
font-family: var(--nc-sans);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 600;
padding: 8px 10px;
cursor: pointer;
border: 1px solid var(--nc-border-strong);
background: var(--nc-btn-bg, rgba(255, 255, 255, 0.03));
color: var(--nc-text);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn:hover:not(:disabled) {
background: var(--nc-btn-hover-bg, rgba(255, 255, 255, 0.07));
border-color: var(--nc-text-muted);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-danger {
color: var(--nc-danger);
border-color: var(--nc-danger-soft, rgba(255, 122, 122, 0.3));
}
.btn-danger:hover:not(:disabled) {
background: var(--nc-danger-bg, rgba(255, 122, 122, 0.08));
border-color: var(--nc-danger);
}
@@ -0,0 +1,27 @@
<div class="board-actions" [class.disabled]="isGameFinished">
<button class="btn" type="button" [disabled]="isGameFinished" (click)="takeback.emit()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 7v6h-6"/>
<path d="M3 17a9 9 0 0 0 15.5-6"/>
<path d="M3 7a9 9 0 0 1 15.5 6"/>
</svg>
Takeback
</button>
<button class="btn" type="button" [disabled]="isGameFinished" (click)="offerDraw.emit()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"/>
<polyline points="12 5 19 12 12 19"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Offer Draw
</button>
<button class="btn btn-danger" type="button" [disabled]="isGameFinished" (click)="resign.emit()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4l16 16"/>
<path d="M4 20l16-16"/>
</svg>
Resign
</button>
</div>
@@ -0,0 +1,16 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
@Component({
selector: 'app-board-actions-bar',
standalone: true,
imports: [],
templateUrl: './board-actions-bar.component.html',
styleUrl: './board-actions-bar.component.css'
})
export class BoardActionsBarComponent {
@Input() undoAvailable = false;
@Input() isGameFinished = false;
@Output() takeback = new EventEmitter<void>();
@Output() offerDraw = new EventEmitter<void>();
@Output() resign = new EventEmitter<void>();
}
@@ -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: () => {
@@ -0,0 +1,132 @@
.card {
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.disclose summary {
list-style: none;
cursor: pointer;
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
.disclose summary::-webkit-details-marker {
display: none;
}
.panel-title {
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--nc-text-muted);
font-weight: 600;
}
.chev {
color: var(--nc-text-dim);
display: flex;
transition: transform 0.2s;
}
.disclose[open] .chev {
transform: rotate(180deg);
}
.disclose[open] summary {
border-bottom: 1px solid var(--nc-border);
}
.panel-body {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* Segmented control */
.seg {
display: flex;
border: 1px solid var(--nc-border);
padding: 2px;
background: var(--nc-seg-bg, rgba(0, 0, 0, 0.2));
}
.seg-btn {
flex: 1;
background: transparent;
border: none;
color: var(--nc-text-muted);
padding: 7px 10px;
font-size: 11px;
font-family: var(--nc-sans);
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.seg-btn.active {
background: var(--nc-neon);
color: #fff;
font-weight: 700;
}
.export-out {
width: 100%;
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
font-family: var(--nc-mono);
font-size: 11px;
padding: 10px;
resize: vertical;
min-height: 70px;
line-height: 1.5;
}
.export-out:focus {
outline: none;
border-color: var(--nc-neon-soft);
}
.export-row {
display: flex;
gap: 6px;
}
/* Buttons */
.btn {
flex: 1;
font-family: var(--nc-sans);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 600;
padding: 8px 10px;
cursor: pointer;
border: 1px solid var(--nc-border-strong);
background: var(--nc-btn-bg, rgba(255, 255, 255, 0.03));
color: var(--nc-text);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.15s, border-color 0.15s;
}
.btn:hover {
background: var(--nc-btn-hover-bg, rgba(255, 255, 255, 0.07));
border-color: var(--nc-text-muted);
}
.copy-notice {
margin: 0;
font-size: 11px;
color: var(--nc-success);
font-family: var(--nc-mono);
letter-spacing: 0.04em;
}
@@ -0,0 +1,51 @@
<details class="card disclose">
<summary>
<span class="panel-title">Export Position</span>
<span class="chev" aria-hidden="true">
<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="6 9 12 15 18 9"/>
</svg>
</span>
</summary>
<div class="panel-body">
<div class="seg" role="tablist" aria-label="Export format">
<button
class="seg-btn"
[class.active]="exportKind === 'fen'"
role="tab"
[attr.aria-selected]="exportKind === 'fen'"
(click)="setKind('fen')">FEN</button>
<button
class="seg-btn"
[class.active]="exportKind === 'pgn'"
role="tab"
[attr.aria-selected]="exportKind === 'pgn'"
(click)="setKind('pgn')">PGN</button>
</div>
<textarea class="export-out" [value]="exportValue" [placeholder]="exportPlaceholder" rows="4" readonly></textarea>
<div class="export-row">
<button class="btn" type="button" (click)="copy()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
Copy
</button>
<button class="btn" type="button" (click)="download()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download
</button>
</div>
@if (copyNotice) {
<p class="copy-notice">{{ copyNotice }}</p>
}
</div>
</details>
@@ -0,0 +1,79 @@
import { Component, Input, OnChanges } from '@angular/core';
import { FormsModule } from '@angular/forms';
type ExportKind = 'fen' | 'pgn';
@Component({
selector: 'app-export-panel',
standalone: true,
imports: [FormsModule],
templateUrl: './export-panel.component.html',
styleUrl: './export-panel.component.css'
})
export class ExportPanelComponent implements OnChanges {
@Input() fen = '';
@Input() pgn = '';
exportKind: ExportKind = 'fen';
exportValue = '';
copyNotice = '';
private copyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
get exportPlaceholder(): string {
return this.exportKind === 'fen' ? 'FEN will appear here' : 'PGN will appear here';
}
ngOnChanges(): void {
this.syncValue();
}
setKind(kind: ExportKind): void {
this.exportKind = kind;
this.syncValue();
}
copy(): void {
if (!this.exportValue.trim()) {
return;
}
if (!navigator.clipboard?.writeText) {
this.showNotice('Ready in the text box.');
return;
}
void navigator.clipboard
.writeText(this.exportValue)
.then(() => this.showNotice('Copied!'))
.catch(() => this.showNotice('Ready in the text box.'));
}
download(): void {
if (!this.exportValue.trim()) {
return;
}
const ext = this.exportKind === 'pgn' ? 'pgn' : 'txt';
const blob = new Blob([this.exportValue], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `game.${ext}`;
a.click();
URL.revokeObjectURL(url);
}
private syncValue(): void {
this.exportValue = this.exportKind === 'fen' ? this.fen : this.pgn;
}
private showNotice(msg: string): void {
this.copyNotice = msg;
if (this.copyNoticeTimer !== null) {
clearTimeout(this.copyNoticeTimer);
}
this.copyNoticeTimer = setTimeout(() => {
this.copyNotice = '';
}, 1800);
}
}
@@ -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.';
}
});
@@ -0,0 +1,94 @@
.moves {
display: grid;
grid-template-columns: 38px 1fr 1fr;
font-family: var(--nc-mono);
font-size: 12px;
max-height: 260px;
overflow-y: auto;
}
.moves-empty {
grid-column: 1 / -1;
padding: 16px;
color: var(--nc-text-dim);
font-size: 12px;
text-align: center;
}
.mv-num {
padding: 6px 12px 6px 10px;
color: var(--nc-text-dim);
text-align: right;
border-right: 1px solid var(--nc-border);
}
.mv {
padding: 6px 10px;
color: var(--nc-text);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.mv:hover {
background: rgba(255, 69, 200, 0.06);
color: var(--nc-neon);
}
.mv.current {
background: rgba(255, 69, 200, 0.10);
color: var(--nc-neon);
}
.mv.mv-empty {
color: var(--nc-text-dim);
cursor: default;
}
.mv.mv-empty:hover {
background: transparent;
color: var(--nc-text-dim);
}
.moves-foot {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-top: 1px solid var(--nc-border);
}
.moves-nav {
display: flex;
gap: 2px;
}
.icon-btn {
background: transparent;
border: 1px solid transparent;
color: var(--nc-text-muted);
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.icon-btn:hover {
color: var(--nc-neon);
border-color: var(--nc-border);
}
.live-label {
font-family: var(--nc-mono);
font-size: 10px;
color: var(--nc-neon);
letter-spacing: 0.14em;
opacity: 0.8;
}
.moves::-webkit-scrollbar { width: 6px; }
.moves::-webkit-scrollbar-track { background: transparent; }
.moves::-webkit-scrollbar-thumb { background: var(--nc-border-strong); border-radius: 3px; }
.moves::-webkit-scrollbar-thumb:hover { background: var(--nc-neon-soft); }
@@ -0,0 +1,43 @@
<div class="moves" role="list">
@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">
{{ pair.white }}
</div>
<div class="mv" [class.current]="currentBlackIndex === $index" [class.mv-empty]="!pair.black" role="listitem">
{{ pair.black ?? '…' }}
</div>
}
}
</div>
<div class="moves-foot">
<div class="moves-nav" role="group" aria-label="Move navigation">
<button class="icon-btn" title="First move" (click)="navigate.emit('first')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/>
</svg>
</button>
<button class="icon-btn" title="Previous move" (click)="navigate.emit('prev')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<button class="icon-btn" title="Next move" (click)="navigate.emit('next')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
<button class="icon-btn" title="Last move" (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>
}
</div>
@@ -0,0 +1,52 @@
import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
export type MoveNavDirection = 'first' | 'prev' | 'next' | 'last';
interface MovePair {
white: string;
black: string | null;
}
@Component({
selector: 'app-move-history',
standalone: true,
imports: [],
templateUrl: './move-history.component.html',
styleUrl: './move-history.component.css'
})
export class MoveHistoryComponent implements OnChanges {
@Input({ required: true }) moves: string[] = [];
@Output() navigate = new EventEmitter<MoveNavDirection>();
movePairs: MovePair[] = [];
get plyCount(): number {
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;
}
ngOnChanges(): void {
this.movePairs = this.buildPairs(this.moves);
}
private buildPairs(moves: string[]): MovePair[] {
const pairs: MovePair[] = [];
for (let i = 0; i < moves.length; i += 2) {
pairs.push({ white: moves[i], black: moves[i + 1] ?? null });
}
return pairs;
}
}
@@ -0,0 +1,90 @@
.player {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: border-color 0.2s, box-shadow 0.2s;
}
.player.is-turn {
border-color: var(--nc-neon-soft);
box-shadow: 0 0 0 1px rgba(255, 69, 200, 0.2), 0 0 20px rgba(255, 69, 200, 0.1);
}
.player-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
font-weight: 700;
color: #fff;
}
.avatar-black {
background: linear-gradient(135deg, #2a2a40 0%, #0a0a14 100%);
border: 1px solid var(--nc-border-strong);
}
.avatar-white {
background: linear-gradient(135deg, var(--nc-neon) 0%, #7a2fd6 100%);
}
.player-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.player-name {
font-size: 14px;
font-weight: 600;
color: var(--nc-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.captured {
display: flex;
align-items: center;
gap: 2px;
font-size: 13px;
color: var(--nc-text-muted);
line-height: 1;
}
.clock {
font-family: var(--nc-mono);
font-size: 22px;
font-weight: 600;
padding: 8px 14px;
min-width: 92px;
text-align: center;
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
letter-spacing: 0.02em;
transition: color 0.2s, border-color 0.2s, background 0.2s, text-shadow 0.2s;
}
.clock.clock-active {
color: var(--nc-neon);
border-color: var(--nc-neon-soft);
background: var(--nc-neon-clock-bg, rgba(255, 69, 200, 0.08));
text-shadow: 0 0 8px rgba(255, 69, 200, 0.4);
}
.clock.clock-low {
color: var(--nc-warning);
border-color: var(--nc-warning-soft, rgba(255, 177, 58, 0.4));
}
@@ -0,0 +1,22 @@
<div class="player" [class.is-turn]="isActive">
<div class="player-avatar" [class.avatar-black]="color === 'black'" [class.avatar-white]="color === 'white'">
{{ initial }}
</div>
<div class="player-info">
<div class="player-name">{{ name }}</div>
@if (capturedPieces.length > 0) {
<div class="captured">
@for (pc of capturedPieces; track $index) {
<span class="pc">{{ pc }}</span>
}
</div>
}
</div>
@if (clockDisplay !== '--:--') {
<div class="clock" [class.clock-active]="isActive" [class.clock-low]="isLowTime && !isActive">
{{ clockDisplay }}
</div>
}
</div>
@@ -0,0 +1,18 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-player-card',
standalone: true,
imports: [],
templateUrl: './player-card.component.html',
styleUrl: './player-card.component.css'
})
export class PlayerCardComponent {
@Input({ required: true }) name = '';
@Input({ required: true }) initial = '';
@Input({ required: true }) color: 'white' | 'black' = 'white';
@Input() isActive = false;
@Input() clockDisplay = '--:--';
@Input() isLowTime = false;
@Input() capturedPieces: string[] = [];
}
@@ -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.';
}
+493 -66
View File
@@ -1,84 +1,511 @@
@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: #ff3dbb;
--nc-accent-hover: rgba(255, 107, 61, 0.15);
--nc-accent-badge: rgba(223, 61, 255, 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;
}
/* ============ GAMES BUTTON ============ */
.nc-games-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--nc-border);
background: transparent;
color: var(--nc-text-muted);
font-family: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.nc-games-btn:hover {
background: var(--nc-accent-hover);
color: var(--nc-text);
}
/* ============ 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; }
+195 -23
View File
@@ -1,28 +1,200 @@
<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>
<!-- Games quick-access -->
<button type="button" class="nc-games-btn" (click)="goToGames()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 17.5L3 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 1-4 4"/>
</svg>
Games
</button>
<!-- 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 +204,4 @@
@if (showRegisterDialog) {
<app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" />
}
}
+148 -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,36 @@ 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']);
}
goToGames(): void {
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/games']);
}
goToChallenges(): void {
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/challenges']);
}
onLoginSuccess(): void {
@@ -84,4 +180,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; }
});
}
}