From bad7366bdbb048c20218257b30ac22efc9ecb6db Mon Sep 17 00:00:00 2001 From: Shahd Lala Date: Tue, 12 May 2026 22:38:57 +0200 Subject: [PATCH] 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 Co-authored-by: Lala, Shahd Reviewed-on: https://git.janis-eccarius.de/NowChess/NowChess-Frontend/pulls/3 --- .dockerignore | 12 + .../challenge-create-dialog.component.css | 261 ++++++++++++++++++ .../challenge-create-dialog.component.html | 93 +++++++ .../challenge-create-dialog.component.ts | 159 +++++++++++ .../challenge-notification.component.css | 194 +++++++++++++ .../challenge-notification.component.html | 38 +++ .../challenge-notification.component.ts | 101 +++++++ src/app/models/challenge.models.ts | 49 ++++ .../pages/challenges/challenges.component.css | 222 +++++++++++++++ .../challenges/challenges.component.html | 102 +++++++ .../pages/challenges/challenges.component.ts | 179 ++++++++++++ src/app/pages/welcome/welcome.component.html | 7 + src/app/pages/welcome/welcome.component.ts | 18 +- src/app/services/challenge-event.service.ts | 64 +++++ .../services/challenge-websocket.service.ts | 135 +++++++++ src/app/services/challenge.service.ts | 50 ++++ 16 files changed, 1683 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 src/app/components/challenge-create-dialog/challenge-create-dialog.component.css create mode 100644 src/app/components/challenge-create-dialog/challenge-create-dialog.component.html create mode 100644 src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts create mode 100644 src/app/components/challenge-notification/challenge-notification.component.css create mode 100644 src/app/components/challenge-notification/challenge-notification.component.html create mode 100644 src/app/components/challenge-notification/challenge-notification.component.ts create mode 100644 src/app/models/challenge.models.ts create mode 100644 src/app/pages/challenges/challenges.component.css create mode 100644 src/app/pages/challenges/challenges.component.html create mode 100644 src/app/pages/challenges/challenges.component.ts create mode 100644 src/app/services/challenge-event.service.ts create mode 100644 src/app/services/challenge-websocket.service.ts create mode 100644 src/app/services/challenge.service.ts 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 @@ +
+
+
+

Create Challenge

+ +
+ +
+ +
+ {{ errorMessage }} +
+ + +
+ + + + Username is required + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+ + +
+ + +
+
+
+
\ 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 @@ +
+
+
+ CHALLENGE + {{ getCreatedByDisplay() }} challenged you! +
+ +
+ +
+
+ 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 @@ +
+
+

Active Challenges

+ +
+ +
+ {{ errorMessage }} +
+ +
+ +
+

Incoming Challenges

+
Loading...
+ +
+

No incoming challenges

+
+ +
+
+
+ {{ getChallengerDisplay(challenge) }} + {{ getTimeControlDisplay(challenge) }} +
+ +
+
+ Status: + + {{ challenge.status | uppercase }} + +
+
+ Expires in: + {{ getExpirationInfo(challenge) }} +
+
+ +
+ + +
+ +
+ +
+
+
+
+ + +
+

Outgoing Challenges

+ +
+

No outgoing challenges

+
+ +
+
+
+ → {{ getOpponentDisplay(challenge) }} + {{ getTimeControlDisplay(challenge) }} +
+ +
+
+ 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`, + {} + ); + } +}