11 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
TeamCity 9ad23b01cc ci: bump version to v0.2.0 2026-05-12 20:41:26 +00:00
shosho996 bad7366bdb feat: NCS-69 Challenge request (#3)
- create challange window
- challanges view page
- decline and accept
- notif tab (wip)
- active game window (wip)

---------

Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Reviewed-on: #3
2026-05-12 22:38:57 +02:00
40 changed files with 3336 additions and 286 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
tmp
*.log
.git
.gitignore
Dockerfile
docker-compose*.yml
^\.env$
.idea
vscode
coverage
+20
View File
@@ -18,3 +18,23 @@
* removed cache files ([7ee59c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/7ee59c434bf137a08fd560bbc02ceefbcfd90f04))
* size of pieces and removed text view of the game state ([c60d00f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c60d00f9d25247504845654615065fbccd7fe448))
* structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.1.0...0.0.0) (2026-05-12)
### 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: '' }
];
@@ -0,0 +1,261 @@
@import '../../button-template.css';
.challenge-create-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(3px);
&.loading {
pointer-events: none;
opacity: 0.7;
}
}
.challenge-create-dialog {
background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%);
border: 2px solid #00d5ff;
border-radius: 8px;
box-shadow: 0 0 20px rgba(0, 213, 255, 0.3), inset 0 0 10px rgba(0, 213, 255, 0.05);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
color: #e0e0e0;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid rgba(0, 213, 255, 0.2);
h2 {
margin: 0;
color: #00d5ff;
font-size: 24px;
font-weight: 600;
letter-spacing: 1px;
}
}
.close-btn {
background: none;
border: none;
font-size: 28px;
color: #00d5ff;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover:not(:disabled) {
color: #ffffff;
transform: scale(1.1);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.dialog-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
label {
color: #b0b0d0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input,
select {
background-color: rgba(15, 20, 50, 0.8);
border: 1px solid rgba(0, 213, 255, 0.3);
border-radius: 4px;
color: #e0e0e0;
padding: 10px 12px;
font-size: 14px;
font-family: 'Space Mono', monospace;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: #00d5ff;
box-shadow: 0 0 10px rgba(0, 213, 255, 0.3);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
option {
background-color: #0a0e27;
color: #e0e0e0;
}
}
small {
color: #ff6b6b;
font-size: 12px;
margin-top: 4px;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-col {
display: flex;
flex-direction: column;
gap: 8px;
label {
color: #b0b0d0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input {
background-color: rgba(15, 20, 50, 0.8);
border: 1px solid rgba(0, 213, 255, 0.3);
border-radius: 4px;
color: #e0e0e0;
padding: 10px 12px;
font-size: 14px;
font-family: 'Space Mono', monospace;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: #00d5ff;
box-shadow: 0 0 10px rgba(0, 213, 255, 0.3);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.preset-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preset-btn {
padding: 8px 16px;
background-color: rgba(0, 213, 255, 0.1);
border: 1px solid #00d5ff;
border-radius: 4px;
color: #00d5ff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background-color: rgba(0, 213, 255, 0.2);
box-shadow: 0 0 10px rgba(0, 213, 255, 0.3);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.error-message {
background-color: rgba(255, 107, 107, 0.1);
border: 1px solid #ff6b6b;
border-radius: 4px;
padding: 12px;
color: #ff9999;
font-size: 13px;
margin-bottom: 8px;
}
.dialog-buttons {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid rgba(0, 213, 255, 0.2);
button {
padding: 10px 24px;
font-size: 13px;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
&.btn-secondary {
background-color: transparent;
border: 1px solid rgba(0, 213, 255, 0.3);
color: #00d5ff;
&:hover:not(:disabled) {
background-color: rgba(0, 213, 255, 0.1);
border-color: #00d5ff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&.btn-primary {
background-color: #00d5ff;
border: none;
color: #0a0e27;
&:hover:not(:disabled) {
box-shadow: 0 0 20px rgba(0, 213, 255, 0.6);
transform: translateY(-2px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
}
@@ -0,0 +1,91 @@
<div class="challenge-create-dialog-overlay" (click)="cancel()" [class.loading]="loading">
<div class="challenge-create-dialog" (click)="$event.stopPropagation()">
<div class="dialog-header">
<h2>Create Challenge</h2>
<button type="button" class="close-btn" (click)="cancel()" [disabled]="loading">×</button>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="dialog-form">
<!-- Error Message -->
<div *ngIf="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<!-- Target Username -->
<div class="form-group">
<label for="targetUsername">Opponent Username</label>
<input type="text" id="targetUsername" formControlName="targetUsername"
placeholder="Enter opponent's username" required />
<small *ngIf="form.get('targetUsername')?.hasError('required') && form.get('targetUsername')?.touched">
Username is required
</small>
</div>
<!-- Player Color Selection -->
<div class="form-group">
<label for="color">Play as</label>
<select id="color" formControlName="color">
<option value="white">White</option>
<option value="black">Black</option>
<option value="random">Random</option>
</select>
</div>
<!-- Time Control Mode Selection -->
<div class="form-group">
<label for="timeMode">Time Control</label>
<select id="timeMode" formControlName="timeMode">
<option value="blitz">Blitz</option>
<option value="rapid">Rapid</option>
<option value="classical">Classical</option>
<option value="unlimited">Unlimited</option>
</select>
</div>
<!-- Time Presets -->
<div class="form-group" *ngIf="selectedTimeMode !== 'unlimited'">
<label>Presets</label>
<div class="preset-buttons">
<button type="button" *ngFor="let preset of getAvailablePresets()" class="preset-btn"
(click)="selectPreset(preset)" [disabled]="loading">
{{ preset.label }}
</button>
</div>
</div>
<!-- Custom Time Control -->
<div class="form-group" *ngIf="selectedTimeMode !== 'unlimited'">
<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" />
</div>
<div class="form-col">
<label for="incrementSeconds">Increment (seconds)</label>
<input type="number" id="incrementSeconds" formControlName="incrementSeconds" min="0" max="300" />
</div>
</div>
</div>
<!-- TTL (Time to Live) -->
<div class="form-group">
<label for="ttlSeconds">Challenge Expires In</label>
<select id="ttlSeconds" formControlName="ttlSeconds">
<option *ngFor="let ttl of ttlOptions" [value]="ttl.seconds">
{{ ttl.label }}
</option>
</select>
</div>
<!-- Buttons -->
<div class="dialog-buttons">
<button type="button" class="btn btn-secondary" (click)="cancel()" [disabled]="loading">
Cancel
</button>
<button type="submit" class="btn btn-primary" [disabled]="form.invalid || loading">
{{ loading ? 'Sending...' : 'Send Challenge' }}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,159 @@
import { Component, inject, OnInit, OnDestroy, DestroyRef, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { finalize } from 'rxjs';
import { ChallengeService } from '../../services/challenge.service';
import { Router } from '@angular/router';
import { getErrorMessage } from '../../core/http/error-message.util';
import { PlayerColor } from '../../models/challenge.models';
type TimeMode = 'blitz' | 'rapid' | 'classical' | 'unlimited';
interface TimePreset {
label: string;
limitSeconds: number;
incrementSeconds: number;
}
@Component({
selector: 'app-challenge-create-dialog',
standalone: true,
imports: [CommonModule, FormsModule, ReactiveFormsModule],
templateUrl: './challenge-create-dialog.component.html',
styleUrls: ['./challenge-create-dialog.component.css']
})
export class ChallengeCreateDialogComponent implements OnInit, OnDestroy {
private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router);
private readonly fb = inject(FormBuilder);
private readonly destroyRef = inject(DestroyRef);
@Output() closeChallengeDialog = new EventEmitter<void>();
form!: FormGroup;
loading = false;
errorMessage = '';
selectedTimeMode: TimeMode = 'rapid';
timePresets: Record<TimeMode, TimePreset[]> = {
blitz: [
{ label: '1+0', limitSeconds: 60, incrementSeconds: 0 },
{ label: '2+1', limitSeconds: 120, incrementSeconds: 1 },
{ label: '3+0', limitSeconds: 180, incrementSeconds: 0 },
{ label: '3+2', limitSeconds: 180, incrementSeconds: 2 },
{ label: '5+0', limitSeconds: 300, incrementSeconds: 0 }
],
rapid: [
{ label: '10+0', limitSeconds: 600, incrementSeconds: 0 },
{ label: '10+5', limitSeconds: 600, incrementSeconds: 5 },
{ label: '15+10', limitSeconds: 900, incrementSeconds: 10 },
{ label: '25+10', limitSeconds: 1500, incrementSeconds: 10 }
],
classical: [
{ label: '30+0', limitSeconds: 1800, incrementSeconds: 0 },
{ label: '30+20', limitSeconds: 1800, incrementSeconds: 20 },
{ label: '60+30', limitSeconds: 3600, incrementSeconds: 30 },
{ label: '90+30', limitSeconds: 5400, incrementSeconds: 30 }
],
unlimited: []
};
ttlOptions = [
{ label: '5 minutes', seconds: 300 },
{ label: '1 hour', seconds: 3600 },
{ label: '1 day', seconds: 86400 },
{ label: 'No expiry', seconds: 0 }
];
ngOnInit(): void {
this.initializeForm();
}
ngOnDestroy(): void {
}
private initializeForm(): void {
this.form = this.fb.group({
targetUsername: ['', [Validators.required, Validators.minLength(1)]],
color: ['random', Validators.required],
timeMode: ['rapid'],
limitMinutes: [10, [Validators.required, Validators.min(1), Validators.max(1000)]],
incrementSeconds: [5, [Validators.required, Validators.min(0), Validators.max(300)]],
ttlSeconds: [3600, Validators.required]
});
this.form.get('timeMode')?.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((mode: unknown) => {
const timeMode = mode as TimeMode;
this.selectedTimeMode = timeMode;
if (timeMode !== 'unlimited') {
const firstPreset = this.timePresets[timeMode][0];
if (firstPreset) {
this.form.patchValue({
limitMinutes: firstPreset.limitSeconds / 60,
incrementSeconds: firstPreset.incrementSeconds
});
}
}
});
}
selectPreset(preset: TimePreset): void {
this.form.patchValue({
limitMinutes: preset.limitSeconds / 60,
incrementSeconds: preset.incrementSeconds
});
}
getAvailablePresets(): TimePreset[] {
return this.timePresets[this.selectedTimeMode] || [];
}
submit(): void {
if (this.form.invalid || this.loading) {
return;
}
const targetUsername = this.form.get('targetUsername')?.value?.trim();
if (!targetUsername) {
this.errorMessage = 'Please enter a valid username';
return;
}
this.errorMessage = '';
this.loading = true;
this.form.disable();
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: {
limitSeconds,
incrementSeconds
},
color,
ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined
})
.pipe(finalize(() => { this.loading = false; this.form.enable(); }))
.subscribe({
next: (challenge) => {
// Challenge sent successfully - navigate to challenges page to view status
this.form.reset();
this.closeChallengeDialog.emit();
void this.router.navigate(['/challenges']);
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to send challenge. Please try again.');
}
});
}
cancel(): void {
this.form.reset();
this.closeChallengeDialog.emit();
}
}
@@ -0,0 +1,194 @@
@import '../../button-template.css';
.challenge-notification {
position: fixed;
top: 20px;
right: 20px;
max-width: 400px;
background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%);
border: 2px solid #00d5ff;
border-radius: 8px;
box-shadow: 0 0 20px rgba(0, 213, 255, 0.4), inset 0 0 10px rgba(0, 213, 255, 0.05);
padding: 16px;
color: #e0e0e0;
z-index: 2000;
animation: slideInRight 0.3s ease-out;
&.error {
border-color: #ff6b6b;
box-shadow: 0 0 20px rgba(255, 107, 107, 0.4), inset 0 0 10px rgba(255, 107, 107, 0.05);
}
@media (max-width: 768px) {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
}
}
@keyframes slideInRight {
from {
transform: translateX(450px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(0, 213, 255, 0.2);
}
.notification-title {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
.badge {
background-color: rgba(0, 213, 255, 0.2);
border: 1px solid #00d5ff;
border-radius: 3px;
padding: 4px 8px;
font-size: 10px;
font-weight: 700;
color: #00d5ff;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.title {
font-size: 14px;
font-weight: 600;
color: #d4f4ff;
}
}
.close-btn {
background: none;
border: none;
font-size: 24px;
color: #00d5ff;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover:not(:disabled) {
color: #ffffff;
transform: scale(1.15);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.notification-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.time-control {
display: flex;
gap: 8px;
align-items: center;
font-size: 13px;
.label {
color: #b0b0d0;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.value {
background-color: rgba(0, 213, 255, 0.1);
border: 1px solid rgba(0, 213, 255, 0.3);
border-radius: 3px;
padding: 4px 12px;
color: #00d5ff;
font-family: 'Space Mono', monospace;
font-weight: 600;
}
}
.expiration {
font-size: 12px;
color: #b0b0d0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.error-message {
background-color: rgba(255, 107, 107, 0.1);
border: 1px solid #ff6b6b;
border-radius: 3px;
padding: 8px 10px;
color: #ff9999;
font-size: 12px;
}
.notification-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
button {
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
border: none;
&.btn-decline {
background-color: transparent;
border: 1px solid rgba(0, 213, 255, 0.3);
color: #00d5ff;
&:hover:not(:disabled) {
background-color: rgba(0, 213, 255, 0.1);
border-color: #00d5ff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&.btn-accept {
background-color: #00d5ff;
color: #0a0e27;
&:hover:not(:disabled) {
box-shadow: 0 0 15px rgba(0, 213, 255, 0.6);
transform: translateY(-1px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
}
@@ -0,0 +1,38 @@
<div class="challenge-notification" [class.error]="!!errorMessage">
<div class="notification-header">
<div class="notification-title">
<span class="badge">CHALLENGE</span>
<span class="title">{{ getCreatedByDisplay() }} challenged you!</span>
</div>
<button type="button" class="close-btn" (click)="onClose()"
[disabled]="acceptingChallenge || decliningChallenge">
×
</button>
</div>
<div class="notification-content">
<div class="time-control">
<span class="label">Time Control:</span>
<span class="value">{{ getTimeControlDisplay() }}</span>
</div>
<div class="expiration">
<span class="label">{{ getExpirationInfo() }}</span>
</div>
<div *ngIf="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div class="notification-actions">
<button type="button" class="btn btn-decline" (click)="onDecline()"
[disabled]="acceptingChallenge || decliningChallenge">
{{ decliningChallenge ? 'Declining...' : 'Decline' }}
</button>
<button type="button" class="btn btn-accept" (click)="onAccept()"
[disabled]="acceptingChallenge || decliningChallenge">
{{ acceptingChallenge ? 'Accepting...' : 'Accept' }}
</button>
</div>
</div>
</div>
@@ -0,0 +1,108 @@
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';
import { getErrorMessage } from '../../core/http/error-message.util';
@Component({
selector: 'app-challenge-notification',
standalone: true,
imports: [CommonModule],
templateUrl: './challenge-notification.component.html',
styleUrls: ['./challenge-notification.component.css']
})
export class ChallengeNotificationComponent {
@Input() challenge!: Challenge;
@Output() accept = new EventEmitter<Challenge>();
@Output() decline = new EventEmitter<Challenge>();
@Output() close = new EventEmitter<void>();
private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router);
acceptingChallenge = false;
decliningChallenge = false;
errorMessage = '';
onAccept(): void {
if (this.acceptingChallenge || this.decliningChallenge) {
return;
}
this.acceptingChallenge = true;
this.errorMessage = '';
this.challengeService.acceptChallenge(this.challenge.id)
.pipe(finalize(() => (this.acceptingChallenge = false)))
.subscribe({
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');
}
});
}
onDecline(): void {
if (this.acceptingChallenge || this.decliningChallenge) {
return;
}
this.decliningChallenge = true;
this.errorMessage = '';
this.challengeService.declineChallenge(this.challenge.id, { reason: 'generic' })
.pipe(finalize(() => (this.decliningChallenge = false)))
.subscribe({
next: () => {
this.decline.emit(this.challenge);
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to decline challenge');
}
});
}
onClose(): void {
this.close.emit();
}
getTimeControlDisplay(): string {
const { limit, increment } = this.challenge.timeControl;
if (!limit || !increment) {
return 'Unlimited';
}
const minutes = Math.floor(limit / 60);
return `${minutes}+${increment}`;
}
getCreatedByDisplay(): string {
return this.challenge.challenger.name;
}
getExpirationInfo(): string {
const expiresAt = new Date(this.challenge.expiresAt);
const now = new Date();
const diffMs = expiresAt.getTime() - now.getTime();
if (diffMs <= 0) {
return 'Expired';
}
const minutes = Math.floor(diffMs / 60000);
if (minutes > 60) {
const hours = Math.floor(minutes / 60);
return `Expires in ${hours}h`;
}
return `Expires in ${minutes}m`;
}
}
@@ -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; }
});
}
}
+49
View File
@@ -0,0 +1,49 @@
export type ChallengeStatus = 'created' | 'pending' | 'accepted' | 'declined' | 'cancelled' | 'expired';
export type PlayerColor = 'white' | 'black' | 'random';
export interface Player {
id: string;
name: string;
rating: number;
}
export interface TimeControl {
type: string | null;
limit: number | null;
increment: number | null;
}
export interface Challenge {
id: string;
challenger: Player;
destUser: Player;
variant: string;
color: PlayerColor;
timeControl: TimeControl;
status: ChallengeStatus;
declineReason: string | null;
gameId: string | null;
expiresAt: string;
createdAt: string;
}
export interface SendChallengeRequest {
timeControl: {
limitSeconds: number;
incrementSeconds: number;
};
color?: PlayerColor;
ttlSeconds?: number;
}
export interface ListChallengesResponse {
'in'?: Challenge[];
'out'?: Challenge[];
incoming?: Challenge[];
outgoing?: Challenge[];
}
export interface DeclineChallengeRequest {
reason?: string;
}
+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 {
@@ -0,0 +1,222 @@
@import '../../button-template.css';
.challenges-container {
min-height: 100vh;
background: linear-gradient(135deg, #04000f 0%, #0e0235 25%, #2d0860 50%, #0e0235 75%, #04000f 100%);
color: #e0e0e0;
padding: 20px;
font-family: 'Space Mono', 'Courier New', monospace;
}
.challenges-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid rgba(0, 213, 255, 0.3);
h1 {
margin: 0;
color: #00d5ff;
font-size: 32px;
letter-spacing: 2px;
text-transform: uppercase;
}
}
.back-btn {
background: transparent;
border: 1px solid rgba(0, 213, 255, 0.3);
color: #00d5ff;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s ease;
&:hover {
background-color: rgba(0, 213, 255, 0.1);
border-color: #00d5ff;
}
}
.error-banner {
background-color: rgba(255, 107, 107, 0.1);
border: 1px solid #ff6b6b;
border-radius: 4px;
padding: 15px;
color: #ff9999;
margin-bottom: 20px;
}
.challenges-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
}
.challenges-section {
h2 {
color: #00d5ff;
font-size: 20px;
margin: 0 0 20px 0;
padding-bottom: 10px;
border-bottom: 1px solid rgba(0, 213, 255, 0.2);
text-transform: uppercase;
letter-spacing: 1px;
}
}
.loading-spinner {
text-align: center;
padding: 40px;
color: #00d5ff;
font-size: 16px;
}
.empty-state {
text-align: center;
padding: 40px;
color: #b0b0d0;
font-size: 14px;
background-color: rgba(0, 213, 255, 0.05);
border: 1px dashed rgba(0, 213, 255, 0.2);
border-radius: 4px;
}
.challenge-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.challenge-card {
background: linear-gradient(135deg, rgba(10, 14, 39, 0.8) 0%, rgba(26, 26, 62, 0.8) 100%);
border: 1px solid rgba(0, 213, 255, 0.3);
border-radius: 8px;
padding: 16px;
transition: all 0.2s ease;
&:hover {
border-color: rgba(0, 213, 255, 0.6);
box-shadow: 0 0 15px rgba(0, 213, 255, 0.2);
}
}
.challenge-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(0, 213, 255, 0.1);
.challenger-name {
color: #d4f4ff;
font-weight: 600;
font-size: 14px;
}
.time-control {
background-color: rgba(0, 213, 255, 0.15);
border: 1px solid rgba(0, 213, 255, 0.3);
color: #00d5ff;
padding: 4px 12px;
border-radius: 3px;
font-weight: 600;
font-size: 12px;
}
}
.challenge-details {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
.detail {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
.label {
color: #b0b0d0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.value {
color: #00d5ff;
font-weight: 600;
&.status-pending {
color: #ffcc30;
}
&.status-accepted {
color: #4ade80;
}
&.status-declined {
color: #ff6b6b;
}
&.status-expired {
color: #b0b0d0;
}
}
}
}
.challenge-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
.btn {
padding: 8px 16px;
border: 1px solid rgba(0, 213, 255, 0.3);
border-radius: 4px;
background-color: transparent;
color: #00d5ff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.2s ease;
&:hover {
background-color: rgba(0, 213, 255, 0.1);
border-color: #00d5ff;
}
&.btn-accept {
background-color: #00d5ff;
color: #04000f;
border: none;
&:hover {
box-shadow: 0 0 15px rgba(0, 213, 255, 0.6);
}
}
&.btn-decline,
&.btn-cancel {
color: #ff6b6b;
border-color: rgba(255, 107, 107, 0.3);
&:hover {
background-color: rgba(255, 107, 107, 0.1);
border-color: #ff6b6b;
}
}
}
}
@@ -0,0 +1,102 @@
<div class="challenges-container">
<div class="challenges-header">
<h1>Active Challenges</h1>
<button type="button" class="back-btn" (click)="goBack()">← Back</button>
</div>
<div *ngIf="errorMessage" class="error-banner">
{{ errorMessage }}
</div>
<div class="challenges-grid">
<!-- Incoming Challenges -->
<div class="challenges-section">
<h2>Incoming Challenges</h2>
<div *ngIf="loading" class="loading-spinner">Loading...</div>
<div *ngIf="!loading && incomingChallenges.length === 0" class="empty-state">
<p>No incoming challenges</p>
</div>
<div *ngIf="!loading && incomingChallenges.length > 0" class="challenge-list">
<div *ngFor="let challenge of incomingChallenges" class="challenge-card">
<div class="challenge-header">
<span class="challenger-name">{{ getChallengerDisplay(challenge) }}</span>
<span class="time-control">{{ getTimeControlDisplay(challenge) }}</span>
</div>
<div class="challenge-details">
<div class="detail">
<span class="label">Status:</span>
<span class="value" [class]="'status-' + challenge.status">
{{ challenge.status | uppercase }}
</span>
</div>
<div class="detail">
<span class="label">Expires in:</span>
<span class="value">{{ getExpirationInfo(challenge) }}</span>
</div>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'created'">
<button type="button" class="btn btn-decline" (click)="declineChallenge(challenge)">
Decline
</button>
<button type="button" class="btn btn-accept" (click)="acceptChallenge(challenge)">
Accept
</button>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'accepted' && challenge.gameId">
<button type="button" class="btn btn-accept" (click)="openGame(challenge)">
Play
</button>
</div>
</div>
</div>
</div>
<!-- Outgoing Challenges -->
<div class="challenges-section">
<h2>Outgoing Challenges</h2>
<div *ngIf="!loading && outgoingChallenges.length === 0" class="empty-state">
<p>No outgoing challenges</p>
</div>
<div *ngIf="!loading && outgoingChallenges.length > 0" class="challenge-list">
<div *ngFor="let challenge of outgoingChallenges" class="challenge-card">
<div class="challenge-header">
<span class="challenger-name">→ {{ getOpponentDisplay(challenge) }}</span>
<span class="time-control">{{ getTimeControlDisplay(challenge) }}</span>
</div>
<div class="challenge-details">
<div class="detail">
<span class="label">Status:</span>
<span class="value" [class]="'status-' + challenge.status">
{{ challenge.status | uppercase }}
</span>
</div>
<div class="detail">
<span class="label">Expires in:</span>
<span class="value">{{ getExpirationInfo(challenge) }}</span>
</div>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'created'">
<button type="button" class="btn btn-cancel" (click)="cancelChallenge(challenge)">
Cancel
</button>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'accepted' && challenge.gameId">
<button type="button" class="btn btn-accept" (click)="openGame(challenge)">
Play
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,179 @@
import { Component, inject, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ChallengeService } from '../../services/challenge.service';
import { ChallengeEventService } from '../../services/challenge-event.service';
import { Challenge } from '../../models/challenge.models';
import { getErrorMessage } from '../../core/http/error-message.util';
@Component({
selector: 'app-challenges',
standalone: true,
imports: [CommonModule],
templateUrl: './challenges.component.html',
styleUrls: ['./challenges.component.css']
})
export class ChallengesComponent implements OnInit, OnDestroy {
private readonly challengeService = inject(ChallengeService);
private readonly challengeEventService = inject(ChallengeEventService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
incomingChallenges: Challenge[] = [];
outgoingChallenges: Challenge[] = [];
loading = false;
errorMessage = '';
private pollInterval: any = null;
private readonly pollIntervalMs = 5000; // Poll every 5 seconds
ngOnInit(): void {
this.loadChallenges(true);
// Subscribe to challenge events
this.challengeEventService.getChallengeReceived$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.loadChallenges();
});
// Start polling for challenge updates
this.startPolling();
}
ngOnDestroy(): void {
this.stopPolling();
}
private startPolling(): void {
this.pollInterval = setInterval(() => {
this.loadChallenges(false);
}, this.pollIntervalMs);
}
private stopPolling(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
loadChallenges(showLoader = false): void {
if (showLoader) {
this.loading = true;
this.errorMessage = '';
}
this.challengeService.listChallenges()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
this.incomingChallenges = response.in || response.incoming || [];
this.outgoingChallenges = response.out || response.outgoing || [];
if (showLoader) {
this.loading = false;
}
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to load challenges');
if (showLoader) {
this.loading = false;
}
}
});
}
acceptChallenge(challenge: Challenge): void {
this.challengeService.acceptChallenge(challenge.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (acceptedChallenge) => {
this.challengeEventService.onChallengeAccepted(acceptedChallenge);
this.loadChallenges();
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');
}
});
}
declineChallenge(challenge: Challenge): void {
this.challengeService.declineChallenge(challenge.id, { reason: 'generic' })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.challengeEventService.removeChallenge(challenge.id);
this.loadChallenges();
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to decline challenge');
}
});
}
cancelChallenge(challenge: Challenge): void {
this.challengeService.cancelChallenge(challenge.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.loadChallenges();
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to cancel challenge');
}
});
}
goBack(): void {
void this.router.navigate(['/']);
}
openGame(challenge: Challenge): void {
if (!challenge.gameId) {
this.errorMessage = 'Missing game id for this challenge.';
return;
}
void this.router.navigate(['/game', challenge.gameId]);
}
getTimeControlDisplay(challenge: Challenge): string {
const { limit, increment } = challenge.timeControl;
if (!limit || !increment) {
return 'Unlimited';
}
const minutes = Math.floor(limit / 60);
return `${minutes}+${increment}`;
}
getChallengerDisplay(challenge: Challenge): string {
return challenge.challenger.name;
}
getOpponentDisplay(challenge: Challenge): string {
return challenge.destUser.name;
}
getExpirationInfo(challenge: Challenge): string {
const expiresAt = new Date(challenge.expiresAt);
const now = new Date();
const diffMs = expiresAt.getTime() - now.getTime();
if (diffMs <= 0 || challenge.status === 'expired') {
return 'Expired';
}
const minutes = Math.floor(diffMs / 60000);
if (minutes > 60) {
const hours = Math.floor(minutes / 60);
return `${hours}h`;
}
return `${minutes}m`;
}
}
+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>
@@ -46,6 +46,13 @@
<div class="w" *ngFor="let win of windows['wA3']" [ngStyle]="win.style"></div>
</div>
</div>
<!-- Draggable Meat Emoji -->
@if (showMeatEmoji) {
<div class="meat-emoji" [style.left.px]="meatX" [style.top.px]="meatY" (mousedown)="onMeatMouseDown($event)">
🍖
</div>
}
</div>
<div class="bwrap" style="left:21%;width:15%;">
@@ -256,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>
}
+30 -25
View File
@@ -10,6 +10,7 @@ import { AuthDialogService } from '../../services/auth-dialog.service';
import { AuthService } from '../../services/auth.service';
import { GameApiService } from '../../services/game-api.service';
import { ThemeService } from '../../services/theme.service';
import { ChallengeCreateDialogComponent } from '../../components/challenge-create-dialog/challenge-create-dialog.component';
type Difficulty = 'easy' | 'medium' | 'hard';
type ImportMode = 'fen' | 'pgn';
@@ -29,10 +30,25 @@ interface WindowCell {
style: Record<string, string>;
}
interface Star {
style: Record<string, string>;
}
interface BackgroundBuilding {
style: Record<string, string>;
}
interface WindowCell {
state: 'off' | 'on';
color?: string;
glowColor?: string;
style: Record<string, string>;
}
@Component({
selector: 'app-welcome',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, ChallengeCreateDialogComponent],
templateUrl: './welcome.component.html',
styleUrls: ['./welcome.component.css']
})
@@ -51,6 +67,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
showOptionsDialog = false;
showJoinDialog = false;
showImportDialog = false;
showChallengeDialog = false;
gameIdInput = '';
importMode: ImportMode = 'fen';
@@ -207,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 {
@@ -336,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) {
@@ -0,0 +1,78 @@
import { Injectable, inject } from '@angular/core';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { Challenge } from '../models/challenge.models';
/**
* Service to manage challenge events via WebSocket
* Listens for incoming challenges and emits them to subscribers
*/
@Injectable({ providedIn: 'root' })
export class ChallengeEventService {
private readonly incomingChallenges$ = new BehaviorSubject<Challenge[]>([]);
private readonly challengeReceived$ = new Subject<Challenge>();
private readonly challengeAccepted$ = new Subject<Challenge>();
private readonly challengeDeclined$ = new Subject<Challenge>();
getIncomingChallenges$(): Observable<Challenge[]> {
return this.incomingChallenges$.asObservable();
}
getChallengeReceived$(): Observable<Challenge> {
return this.challengeReceived$.asObservable();
}
getChallengeAccepted$(): Observable<Challenge> {
return this.challengeAccepted$.asObservable();
}
getChallengeDeclined$(): Observable<Challenge> {
return this.challengeDeclined$.asObservable();
}
/**
* Called when a new challenge is received via WebSocket
*/
onChallengeReceived(challenge: Challenge): void {
const current = this.incomingChallenges$.value;
this.incomingChallenges$.next([...current, challenge]);
this.challengeReceived$.next(challenge);
}
/**
* Called when a challenge is accepted
*/
onChallengeAccepted(challenge: Challenge): void {
const current = this.incomingChallenges$.value;
this.incomingChallenges$.next(current.filter(c => c.id !== challenge.id));
this.challengeAccepted$.next(challenge);
}
/**
* Called when a challenge is declined or expires
*/
onChallengeRemoved(challengeId: string): void {
const current = this.incomingChallenges$.value;
this.incomingChallenges$.next(current.filter(c => c.id !== challengeId));
}
/**
* Remove a challenge from the incoming list
*/
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([]);
}
}
@@ -0,0 +1,115 @@
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import { environment } from '../../environments/environment';
import { ChallengeEventService } from './challenge-event.service';
import { ChallengeService } from './challenge.service';
@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;
connect(): void {
if (this.ws) return;
const token = localStorage.getItem('token');
if (!token) return;
const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`;
try {
this.intentionalClose = false;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string);
};
this.ws.onerror = () => {
// onclose fires right after, handles reconnect
};
this.ws.onclose = () => {
this.ws = null;
if (!this.intentionalClose) {
this.attemptReconnect();
}
};
} catch {
this.attemptReconnect();
}
}
disconnect(): void {
this.intentionalClose = true;
this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
private handleMessage(data: string): void {
let message: Record<string, unknown>;
try {
message = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
}
switch (message['type']) {
case 'CONNECTED':
break;
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);
}
}
+50
View File
@@ -0,0 +1,50 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Challenge, DeclineChallengeRequest, ListChallengesResponse, SendChallengeRequest } from '../models/challenge.models';
@Injectable({ providedIn: 'root' })
export class ChallengeService {
private readonly http = inject(HttpClient);
private readonly challengeBaseUrl = '/api/challenge';
sendChallenge(username: string, request: SendChallengeRequest): Observable<Challenge> {
return this.http.post<Challenge>(
`${this.challengeBaseUrl}/${username}`,
request
);
}
listChallenges(): Observable<ListChallengesResponse> {
return this.http.get<ListChallengesResponse>(
`${this.challengeBaseUrl}`
);
}
getChallenge(challengeId: string): Observable<Challenge> {
return this.http.get<Challenge>(
`${this.challengeBaseUrl}/${challengeId}`
);
}
acceptChallenge(challengeId: string): Observable<Challenge> {
return this.http.post<Challenge>(
`${this.challengeBaseUrl}/${challengeId}/accept`,
{}
);
}
declineChallenge(challengeId: string, request?: DeclineChallengeRequest): Observable<void> {
return this.http.post<void>(
`${this.challengeBaseUrl}/${challengeId}/decline`,
request || {}
);
}
cancelChallenge(challengeId: string): Observable<void> {
return this.http.post<void>(
`${this.challengeBaseUrl}/${challengeId}/cancel`,
{}
);
}
}
+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 {
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=1
PATCH=0
MINOR=2
PATCH=3