diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 9fb0ff3..59dd95e 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -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: '' }
];
diff --git a/src/app/app.ts b/src/app/app.ts
index 4206e4c..5211464 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ToolbarComponent } from './components/toolbar/toolbar.component';
import { ThemeService } from './services/theme.service';
+import { ChallengeWebSocketService } from './services/challenge-websocket.service';
@Component({
selector: 'app-root',
@@ -10,9 +11,13 @@ import { ThemeService } from './services/theme.service';
styleUrl: './app.css'
})
export class App implements OnInit {
- constructor(private readonly themeService: ThemeService) { }
+ constructor(
+ private readonly themeService: ThemeService,
+ private readonly challengeWs: ChallengeWebSocketService
+ ) { }
ngOnInit(): void {
this.themeService.initTheme();
+ this.challengeWs.connect();
}
}
diff --git a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.css b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.css
new file mode 100644
index 0000000..e099c25
--- /dev/null
+++ b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.css
@@ -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;
+ }
+ }
+ }
+}
diff --git a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html
new file mode 100644
index 0000000..dae8565
--- /dev/null
+++ b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html
@@ -0,0 +1,125 @@
+
diff --git a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts
new file mode 100644
index 0000000..291c712
--- /dev/null
+++ b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts
@@ -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();
+
+ form!: FormGroup;
+ loading = false;
+ errorMessage = '';
+ selectedTimeMode: TimeMode = 'rapid';
+
+ timePresets: Record = {
+ 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;
+
+ const limitSeconds = Math.round((this.form.get('limitMinutes')?.value || 0) * 60);
+ const incrementSeconds = this.form.get('incrementSeconds')?.value || 0;
+ const ttlSeconds = this.form.get('ttlSeconds')?.value;
+ const color = (this.form.get('color')?.value || 'random') as PlayerColor;
+
+ this.challengeService.sendChallenge(targetUsername, {
+ timeControl: {
+ limitSeconds,
+ incrementSeconds
+ },
+ color,
+ ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined
+ })
+ .pipe(finalize(() => (this.loading = false)))
+ .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();
+ }
+}
diff --git a/src/app/components/challenge-notification/challenge-notification.component.css b/src/app/components/challenge-notification/challenge-notification.component.css
new file mode 100644
index 0000000..79268f7
--- /dev/null
+++ b/src/app/components/challenge-notification/challenge-notification.component.css
@@ -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;
+ }
+ }
+ }
+}
diff --git a/src/app/components/challenge-notification/challenge-notification.component.html b/src/app/components/challenge-notification/challenge-notification.component.html
new file mode 100644
index 0000000..fdb11bf
--- /dev/null
+++ b/src/app/components/challenge-notification/challenge-notification.component.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+ Time Control:
+ {{ getTimeControlDisplay() }}
+
+
+
+ {{ getExpirationInfo() }}
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
diff --git a/src/app/components/challenge-notification/challenge-notification.component.ts b/src/app/components/challenge-notification/challenge-notification.component.ts
new file mode 100644
index 0000000..4a27b7b
--- /dev/null
+++ b/src/app/components/challenge-notification/challenge-notification.component.ts
@@ -0,0 +1,101 @@
+import { Component, Input, Output, EventEmitter, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+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();
+ @Output() decline = new EventEmitter();
+ @Output() close = new EventEmitter();
+
+ private readonly challengeService = inject(ChallengeService);
+
+ 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: () => {
+ this.accept.emit(this.challenge);
+ },
+ 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: 'Not interested' })
+ .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`;
+ }
+}
diff --git a/src/app/components/toolbar/toolbar.component.css b/src/app/components/toolbar/toolbar.component.css
index be8f759..511a0d5 100644
--- a/src/app/components/toolbar/toolbar.component.css
+++ b/src/app/components/toolbar/toolbar.component.css
@@ -82,3 +82,148 @@
.ms-auto {
margin-left: auto;
}
+
+.notification-container {
+ position: relative;
+}
+
+.notification-badge {
+ background: none;
+ border: none;
+ font-size: 1.5rem;
+ cursor: pointer;
+ position: relative;
+ padding: 0;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+
+ &:hover {
+ transform: scale(1.1);
+ }
+
+ &.has-notifications {
+ animation: pulse 2s infinite;
+ }
+
+ .badge {
+ position: absolute;
+ top: -8px;
+ right: -8px;
+ background-color: #ff6b6b;
+ color: white;
+ border-radius: 50%;
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: bold;
+ box-shadow: 0 0 10px rgba(255, 107, 107, 0.5);
+ }
+}
+
+@keyframes pulse {
+ 0% {
+ filter: drop-shadow(0 0 3px rgba(0, 213, 255, 0.3));
+ }
+ 50% {
+ filter: drop-shadow(0 0 8px rgba(0, 213, 255, 0.6));
+ }
+ 100% {
+ filter: drop-shadow(0 0 3px rgba(0, 213, 255, 0.3));
+ }
+}
+
+.notification-menu {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%);
+ border: 1px solid rgba(0, 213, 255, 0.3);
+ border-radius: 8px;
+ min-width: 250px;
+ max-height: 300px;
+ overflow-y: auto;
+ z-index: 1001;
+ box-shadow: 0 0 20px rgba(0, 213, 255, 0.3);
+ margin-top: 10px;
+
+ .notification-menu-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px;
+ border-bottom: 1px solid rgba(0, 213, 255, 0.2);
+ color: #00d5ff;
+ font-size: 13px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+
+ .close-btn {
+ background: none;
+ border: none;
+ color: #00d5ff;
+ font-size: 20px;
+ cursor: pointer;
+ padding: 0;
+ transition: all 0.2s ease;
+
+ &:hover {
+ transform: scale(1.1);
+ }
+ }
+ }
+
+ .notification-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 8px;
+ }
+
+ .notification-item {
+ padding: 10px 12px;
+ background-color: rgba(0, 213, 255, 0.05);
+ border: 1px solid rgba(0, 213, 255, 0.1);
+ border-radius: 4px;
+ font-size: 12px;
+ color: #b0b0d0;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background-color: rgba(0, 213, 255, 0.1);
+ border-color: rgba(0, 213, 255, 0.3);
+ }
+ }
+
+ .notification-menu-footer {
+ padding: 8px 8px 0;
+ border-top: 1px solid rgba(0, 213, 255, 0.2);
+ }
+
+ .view-all-btn {
+ width: 100%;
+ padding: 8px 12px;
+ background-color: rgba(0, 213, 255, 0.1);
+ border: 1px solid rgba(0, 213, 255, 0.3);
+ border-radius: 4px;
+ color: #00d5ff;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background-color: rgba(0, 213, 255, 0.2);
+ border-color: #00d5ff;
+ }
+ }
+}
diff --git a/src/app/components/toolbar/toolbar.component.html b/src/app/components/toolbar/toolbar.component.html
index cba57a3..e24d8c7 100644
--- a/src/app/components/toolbar/toolbar.component.html
+++ b/src/app/components/toolbar/toolbar.component.html
@@ -3,6 +3,42 @@
NowChess
+
+
+
+
+
+ @if (showNotificationMenu && incomingChallenges.length > 0) {
+
+ }
+
+
@@ -11,6 +47,9 @@
+
} @else {
@@ -26,6 +65,16 @@
+
+@if (displayedChallenge) {
+
+}
+
@if (showLoginDialog) {
}
diff --git a/src/app/components/toolbar/toolbar.component.ts b/src/app/components/toolbar/toolbar.component.ts
index bc4a815..0834d57 100644
--- a/src/app/components/toolbar/toolbar.component.ts
+++ b/src/app/components/toolbar/toolbar.component.ts
@@ -8,11 +8,14 @@ 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 { ChallengeNotificationComponent } from '../challenge-notification/challenge-notification.component';
+import { Challenge } from '../../models/challenge.models';
@Component({
selector: 'app-toolbar',
standalone: true,
- imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent],
+ imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent, ChallengeNotificationComponent],
templateUrl: './toolbar.component.html',
styleUrl: './toolbar.component.css'
})
@@ -21,12 +24,16 @@ 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 router = inject(Router);
currentUser: CurrentUser | null = null;
showLoginDialog = false;
showRegisterDialog = false;
isDarkMode = false;
+ incomingChallenges: Challenge[] = [];
+ showNotificationMenu = false;
+ displayedChallenge: Challenge | null = null;
ngOnInit(): void {
this.authService.currentUser$
@@ -47,6 +54,22 @@ export class ToolbarComponent implements OnInit {
.subscribe((isDarkMode) => {
this.isDarkMode = isDarkMode;
});
+
+ this.challengeEventService.getIncomingChallenges$()
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((challenges) => {
+ this.incomingChallenges = challenges;
+ // Show the most recent challenge as notification
+ if (challenges.length > 0) {
+ this.displayedChallenge = challenges[0];
+ }
+ });
+
+ this.challengeEventService.getChallengeReceived$()
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((challenge) => {
+ this.displayedChallenge = challenge;
+ });
}
openLoginDialog(): void {
@@ -84,4 +107,34 @@ export class ToolbarComponent implements OnInit {
onRegisterSuccess(): void {
this.closeRegisterDialog();
}
+
+ toggleNotificationMenu(): void {
+ this.showNotificationMenu = !this.showNotificationMenu;
+ }
+
+ closeNotificationMenu(): void {
+ this.showNotificationMenu = false;
+ }
+
+ onChallengeAccepted(challenge: Challenge): void {
+ this.challengeEventService.onChallengeAccepted(challenge);
+ this.displayedChallenge = null;
+ this.closeNotificationMenu();
+ // Navigate to the game (once game creation is handled by backend)
+ }
+
+ onChallengeDeclined(challenge: Challenge): void {
+ this.challengeEventService.removeChallenge(challenge.id);
+ this.displayedChallenge = null;
+ this.closeNotificationMenu();
+ }
+
+ onNotificationClose(): void {
+ this.displayedChallenge = null;
+ }
+
+ goToChallenges(): void {
+ this.closeNotificationMenu();
+ void this.router.navigate(['/challenges']);
+ }
}
diff --git a/src/app/models/challenge.models.ts b/src/app/models/challenge.models.ts
new file mode 100644
index 0000000..e32ba92
--- /dev/null
+++ b/src/app/models/challenge.models.ts
@@ -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;
+}
diff --git a/src/app/pages/challenges/challenges.component.css b/src/app/pages/challenges/challenges.component.css
new file mode 100644
index 0000000..2e65fbc
--- /dev/null
+++ b/src/app/pages/challenges/challenges.component.css
@@ -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;
+ }
+ }
+ }
+}
diff --git a/src/app/pages/challenges/challenges.component.html b/src/app/pages/challenges/challenges.component.html
new file mode 100644
index 0000000..37bb3f0
--- /dev/null
+++ b/src/app/pages/challenges/challenges.component.html
@@ -0,0 +1,102 @@
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
Incoming Challenges
+
Loading...
+
+
+
No incoming challenges
+
+
+
0" class="challenge-list">
+
+
+
+
+
+ Status:
+
+ {{ challenge.status | uppercase }}
+
+
+
+ Expires in:
+ {{ getExpirationInfo(challenge) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Outgoing Challenges
+
+
+
No outgoing challenges
+
+
+
0" class="challenge-list">
+
+
+
+
+
+ Status:
+
+ {{ challenge.status | uppercase }}
+
+
+
+ Expires in:
+ {{ getExpirationInfo(challenge) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/pages/challenges/challenges.component.ts b/src/app/pages/challenges/challenges.component.ts
new file mode 100644
index 0000000..ebca54e
--- /dev/null
+++ b/src/app/pages/challenges/challenges.component.ts
@@ -0,0 +1,167 @@
+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: () => {
+ this.challengeEventService.onChallengeAccepted(challenge);
+ this.loadChallenges();
+ // Navigate to game (if backend creates game automatically)
+ },
+ error: (error) => {
+ this.errorMessage = getErrorMessage(error, 'Failed to accept challenge');
+ }
+ });
+ }
+
+ declineChallenge(challenge: Challenge): void {
+ this.challengeService.declineChallenge(challenge.id, { reason: 'Not interested' })
+ .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(['/']);
+ }
+
+ 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`;
+ }
+}
diff --git a/src/app/pages/welcome/welcome.component.html b/src/app/pages/welcome/welcome.component.html
index ec83f91..98509a7 100644
--- a/src/app/pages/welcome/welcome.component.html
+++ b/src/app/pages/welcome/welcome.component.html
@@ -94,8 +94,8 @@
WELCOME
WELCOME TO
NOWCHESS
Play your next move from the skyline.
-