diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..3fc38d2
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,12 @@
+node_modules
+dist
+tmp
+*.log
+.git
+.gitignore
+Dockerfile
+docker-compose*.yml
+^\.env$
+.idea
+vscode
+coverage
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..0911de4
--- /dev/null
+++ b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html
@@ -0,0 +1,93 @@
+
\ No newline at end of file
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..7c04003
--- /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..46a3907
--- /dev/null
+++ b/src/app/components/challenge-notification/challenge-notification.component.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+ Time Control:
+ {{ getTimeControlDisplay() }}
+
+
+
+ {{ getExpirationInfo() }}
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
\ No newline at end of file
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..7c5ca57
--- /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/models/challenge.models.ts b/src/app/models/challenge.models.ts
new file mode 100644
index 0000000..9683480
--- /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..7351234
--- /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) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/pages/challenges/challenges.component.ts b/src/app/pages/challenges/challenges.component.ts
new file mode 100644
index 0000000..1044dd2
--- /dev/null
+++ b/src/app/pages/challenges/challenges.component.ts
@@ -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: '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(['/']);
+ }
+
+ 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`;
+ }
+}
diff --git a/src/app/pages/welcome/welcome.component.html b/src/app/pages/welcome/welcome.component.html
index ec83f91..e6ba5a2 100644
--- a/src/app/pages/welcome/welcome.component.html
+++ b/src/app/pages/welcome/welcome.component.html
@@ -46,6 +46,13 @@
+
+
+ @if (showMeatEmoji) {
+
+ 🍖
+
+ }
diff --git a/src/app/pages/welcome/welcome.component.ts b/src/app/pages/welcome/welcome.component.ts
index 749de98..3669d3b 100644
--- a/src/app/pages/welcome/welcome.component.ts
+++ b/src/app/pages/welcome/welcome.component.ts
@@ -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;
}
+interface Star {
+ style: Record;
+}
+
+interface BackgroundBuilding {
+ style: Record;
+}
+
+interface WindowCell {
+ state: 'off' | 'on';
+ color?: string;
+ glowColor?: string;
+ style: Record;
+}
+
@Component({
selector: 'app-welcome',
standalone: true,
- imports: [CommonModule, FormsModule],
+ imports: [CommonModule, FormsModule, ChallengeCreateDialogComponent],
templateUrl: './welcome.component.html',
styleUrls: ['./welcome.component.css']
})
diff --git a/src/app/services/challenge-event.service.ts b/src/app/services/challenge-event.service.ts
new file mode 100644
index 0000000..fca0c7d
--- /dev/null
+++ b/src/app/services/challenge-event.service.ts
@@ -0,0 +1,64 @@
+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([]);
+ private readonly challengeReceived$ = new Subject();
+ private readonly challengeAccepted$ = new Subject();
+ private readonly challengeDeclined$ = new Subject();
+
+ getIncomingChallenges$(): Observable {
+ return this.incomingChallenges$.asObservable();
+ }
+
+ getChallengeReceived$(): Observable {
+ return this.challengeReceived$.asObservable();
+ }
+
+ getChallengeAccepted$(): Observable {
+ return this.challengeAccepted$.asObservable();
+ }
+
+ getChallengeDeclined$(): Observable {
+ 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);
+ }
+}
diff --git a/src/app/services/challenge-websocket.service.ts b/src/app/services/challenge-websocket.service.ts
new file mode 100644
index 0000000..d13131f
--- /dev/null
+++ b/src/app/services/challenge-websocket.service.ts
@@ -0,0 +1,135 @@
+import { Injectable, inject } from '@angular/core';
+import { Subject } from 'rxjs';
+import { ChallengeEventService } from './challenge-event.service';
+import { Challenge } from '../models/challenge.models';
+
+/**
+ * Service to handle WebSocket connections for challenge events
+ * Listens for incoming challenge notifications and emits them to ChallengeEventService
+ */
+@Injectable({ providedIn: 'root' })
+export class ChallengeWebSocketService {
+ private readonly challengeEventService = inject(ChallengeEventService);
+ private ws: WebSocket | null = null;
+ private reconnectAttempts = 0;
+ private readonly maxReconnectAttempts = 5;
+ private readonly reconnectDelay = 3000;
+
+ /**
+ * Initialize WebSocket connection for challenge events
+ */
+ connect(): void {
+ if (this.ws) {
+ return; // Already connected
+ }
+
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
+ const wsUrl = `${wsProtocol}://${window.location.host}/ws/challenges`;
+
+ try {
+ this.ws = new WebSocket(wsUrl);
+
+ this.ws.onopen = () => {
+ console.log('Challenge WebSocket connected');
+ this.reconnectAttempts = 0;
+ };
+
+ this.ws.onmessage = (event) => {
+ this.handleMessage(event.data);
+ };
+
+ this.ws.onerror = (error) => {
+ console.error('Challenge WebSocket error:', error);
+ };
+
+ this.ws.onclose = () => {
+ console.log('Challenge WebSocket disconnected');
+ this.ws = null;
+ this.attemptReconnect();
+ };
+ } catch (error) {
+ console.error('Failed to create WebSocket:', error);
+ this.attemptReconnect();
+ }
+ }
+
+ /**
+ * Close the WebSocket connection
+ */
+ disconnect(): void {
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+ }
+
+ /**
+ * Send a message through WebSocket
+ */
+ send(message: any): void {
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(message));
+ }
+ }
+
+ /**
+ * Handle incoming WebSocket messages
+ */
+ private handleMessage(data: string): void {
+ try {
+ const message = JSON.parse(data);
+
+ if (!message.type) {
+ return;
+ }
+
+ switch (message.type) {
+ case 'challenge.received':
+ if (message.challenge) {
+ this.challengeEventService.onChallengeReceived(message.challenge as Challenge);
+ }
+ break;
+
+ case 'challenge.accepted':
+ if (message.challenge) {
+ this.challengeEventService.onChallengeAccepted(message.challenge as Challenge);
+ }
+ break;
+
+ case 'challenge.declined':
+ if (message.challengeId) {
+ this.challengeEventService.removeChallenge(message.challengeId);
+ }
+ break;
+
+ case 'challenge.expired':
+ if (message.challengeId) {
+ this.challengeEventService.removeChallenge(message.challengeId);
+ }
+ break;
+
+ default:
+ console.debug('Unknown challenge message type:', message.type);
+ }
+ } catch (error) {
+ console.error('Failed to parse WebSocket message:', error);
+ }
+ }
+
+ /**
+ * Attempt to reconnect to WebSocket
+ */
+ private attemptReconnect(): void {
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
+ console.error('Max WebSocket reconnection attempts reached');
+ return;
+ }
+
+ this.reconnectAttempts++;
+ console.log(`Attempting WebSocket reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
+
+ setTimeout(() => {
+ this.connect();
+ }, this.reconnectDelay);
+ }
+}
diff --git a/src/app/services/challenge.service.ts b/src/app/services/challenge.service.ts
new file mode 100644
index 0000000..7859923
--- /dev/null
+++ b/src/app/services/challenge.service.ts
@@ -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 {
+ return this.http.post(
+ `${this.challengeBaseUrl}/${username}`,
+ request
+ );
+ }
+
+ listChallenges(): Observable {
+ return this.http.get(
+ `${this.challengeBaseUrl}`
+ );
+ }
+
+ getChallenge(challengeId: string): Observable {
+ return this.http.get(
+ `${this.challengeBaseUrl}/${challengeId}`
+ );
+ }
+
+ acceptChallenge(challengeId: string): Observable {
+ return this.http.post(
+ `${this.challengeBaseUrl}/${challengeId}/accept`,
+ {}
+ );
+ }
+
+ declineChallenge(challengeId: string, request?: DeclineChallengeRequest): Observable {
+ return this.http.post(
+ `${this.challengeBaseUrl}/${challengeId}/decline`,
+ request || {}
+ );
+ }
+
+ cancelChallenge(challengeId: string): Observable {
+ return this.http.post(
+ `${this.challengeBaseUrl}/${challengeId}/cancel`,
+ {}
+ );
+ }
+}