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 index dae8565..0911de4 100644 --- a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html +++ b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html @@ -1,125 +1,93 @@
-
-
-

Create Challenge

- +
+
+

Create Challenge

+ +
+ +
+ +
+ {{ errorMessage }} +
+ + +
+ + + + Username is required + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+ + +
+ + +
+
- -
- -
- {{ 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 index 291c712..7c04003 100644 --- a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts +++ b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts @@ -11,149 +11,149 @@ import { PlayerColor } from '../../models/challenge.models'; type TimeMode = 'blitz' | 'rapid' | 'classical' | 'unlimited'; interface TimePreset { - label: string; - limitSeconds: number; - incrementSeconds: number; + 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'] + 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); + private readonly challengeService = inject(ChallengeService); + private readonly router = inject(Router); + private readonly fb = inject(FormBuilder); + private readonly destroyRef = inject(DestroyRef); - @Output() closeChallengeDialog = new EventEmitter(); + @Output() closeChallengeDialog = new EventEmitter(); - form!: FormGroup; - loading = false; - errorMessage = ''; - selectedTimeMode: TimeMode = 'rapid'; + 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: [] - }; + 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 } - ]; + 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(); - } + ngOnInit(): void { + this.initializeForm(); + } - ngOnDestroy(): void { - } + 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] - }); + 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 + 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; + selectPreset(preset: TimePreset): void { + this.form.patchValue({ + limitMinutes: preset.limitSeconds / 60, + incrementSeconds: preset.incrementSeconds + }); } - this.errorMessage = ''; - this.loading = true; + getAvailablePresets(): TimePreset[] { + return this.timePresets[this.selectedTimeMode] || []; + } - 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.'); + submit(): void { + if (this.form.invalid || this.loading) { + return; } - }); - } - cancel(): void { - this.form.reset(); - this.closeChallengeDialog.emit(); - } + 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.html b/src/app/components/challenge-notification/challenge-notification.component.html index fdb11bf..46a3907 100644 --- a/src/app/components/challenge-notification/challenge-notification.component.html +++ b/src/app/components/challenge-notification/challenge-notification.component.html @@ -1,45 +1,38 @@
-
-
- CHALLENGE - {{ getCreatedByDisplay() }} challenged you! -
- -
- -
-
- Time Control: - {{ getTimeControlDisplay() }} +
+
+ CHALLENGE + {{ getCreatedByDisplay() }} challenged you! +
+
-
- {{ getExpirationInfo() }} -
+
+
+ Time Control: + {{ getTimeControlDisplay() }} +
-
- {{ errorMessage }} -
+
+ {{ 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 index 4a27b7b..7c5ca57 100644 --- a/src/app/components/challenge-notification/challenge-notification.component.ts +++ b/src/app/components/challenge-notification/challenge-notification.component.ts @@ -6,96 +6,96 @@ 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'] + 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(); + @Input() challenge!: Challenge; + @Output() accept = new EventEmitter(); + @Output() decline = new EventEmitter(); + @Output() close = new EventEmitter(); - private readonly challengeService = inject(ChallengeService); + private readonly challengeService = inject(ChallengeService); - acceptingChallenge = false; - decliningChallenge = false; - errorMessage = ''; + 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'); + onAccept(): void { + if (this.acceptingChallenge || this.decliningChallenge) { + return; } - }); - } - onDecline(): 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'); + } + }); } - 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'); + onDecline(): void { + if (this.acceptingChallenge || this.decliningChallenge) { + return; } - }); - } - onClose(): void { - this.close.emit(); - } + this.decliningChallenge = true; + this.errorMessage = ''; - 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'; + 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'); + } + }); } - const minutes = Math.floor(diffMs / 60000); - if (minutes > 60) { - const hours = Math.floor(minutes / 60); - return `Expires in ${hours}h`; + onClose(): void { + this.close.emit(); } - return `Expires in ${minutes}m`; - } + 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.html b/src/app/components/toolbar/toolbar.component.html index e24d8c7..0e63cd8 100644 --- a/src/app/components/toolbar/toolbar.component.html +++ b/src/app/components/toolbar/toolbar.component.html @@ -5,13 +5,8 @@
- -
- -
- {{ errorMessage }} -
- -
- -
-

Incoming Challenges

-
Loading...
- -
-

No incoming challenges

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

Active Challenges

+
- -
-

Outgoing Challenges

- -
-

No outgoing challenges

-
- -
-
-
- → {{ getOpponentDisplay(challenge) }} - {{ getTimeControlDisplay(challenge) }} -
- -
-
- Status: - - {{ challenge.status | uppercase }} - -
-
- Expires in: - {{ getExpirationInfo(challenge) }} -
-
- -
- -
-
-
+
+ {{ 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 index ebca54e..4db5b13 100644 --- a/src/app/pages/challenges/challenges.component.ts +++ b/src/app/pages/challenges/challenges.component.ts @@ -8,160 +8,160 @@ 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'] + 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); + 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 = ''; + incomingChallenges: Challenge[] = []; + outgoingChallenges: Challenge[] = []; + loading = false; + errorMessage = ''; - private pollInterval: any = null; - private readonly pollIntervalMs = 5000; // Poll every 5 seconds + private pollInterval: any = null; + private readonly pollIntervalMs = 5000; // Poll every 5 seconds - ngOnInit(): void { - this.loadChallenges(true); + ngOnInit(): void { + this.loadChallenges(true); - // Subscribe to challenge events - this.challengeEventService.getChallengeReceived$() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.loadChallenges(); - }); + // 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 = ''; + // Start polling for challenge updates + this.startPolling(); } - 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; - } + 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; } - }); - } - - 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`; + 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; + } + } + }); } - return `${minutes}m`; - } + 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/services/challenge-event.service.ts b/src/app/services/challenge-event.service.ts index fa55170..fca0c7d 100644 --- a/src/app/services/challenge-event.service.ts +++ b/src/app/services/challenge-event.service.ts @@ -8,57 +8,57 @@ import { Challenge } from '../models/challenge.models'; */ @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(); + 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(); - } + getIncomingChallenges$(): Observable { + return this.incomingChallenges$.asObservable(); + } - getChallengeReceived$(): Observable { - return this.challengeReceived$.asObservable(); - } + getChallengeReceived$(): Observable { + return this.challengeReceived$.asObservable(); + } - getChallengeAccepted$(): Observable { - return this.challengeAccepted$.asObservable(); - } + getChallengeAccepted$(): Observable { + return this.challengeAccepted$.asObservable(); + } - getChallengeDeclined$(): Observable { - return this.challengeDeclined$.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 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 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)); - } + /** + * 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); - } + /** + * 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 index 18eb554..d13131f 100644 --- a/src/app/services/challenge-websocket.service.ts +++ b/src/app/services/challenge-websocket.service.ts @@ -9,127 +9,127 @@ import { Challenge } from '../models/challenge.models'; */ @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; + 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 + /** + * 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(); + } } - 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; + /** + * Close the WebSocket connection + */ + disconnect(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } } - this.reconnectAttempts++; - console.log(`Attempting WebSocket reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`); + /** + * Send a message through WebSocket + */ + send(message: any): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + } - setTimeout(() => { - this.connect(); - }, this.reconnectDelay); - } + /** + * 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 index 433f97d..5f7b809 100644 --- a/src/app/services/challenge.service.ts +++ b/src/app/services/challenge.service.ts @@ -5,46 +5,46 @@ import { Challenge, DeclineChallengeRequest, ListChallengesResponse, SendChallen @Injectable({ providedIn: 'root' }) export class ChallengeService { - private readonly http = inject(HttpClient); - private readonly challengeBaseUrl = '/api/challenge'; + private readonly http = inject(HttpClient); + private readonly challengeBaseUrl = '/api/challenge'; - sendChallenge(username: string, request: SendChallengeRequest): Observable { - return this.http.post( - `${this.challengeBaseUrl}/${username}`, - request - ); - } + sendChallenge(username: string, request: SendChallengeRequest): Observable { + return this.http.post( + `${this.challengeBaseUrl}/${username}`, + request + ); + } - listChallenges(): Observable { - return this.http.get( - `${this.challengeBaseUrl}` - ); - } + listChallenges(): Observable { + return this.http.get( + `${this.challengeBaseUrl}` + ); + } - getChallenge(challengeId: string): Observable { - return this.http.get( - `${this.challengeBaseUrl}/${challengeId}` - ); - } + getChallenge(challengeId: string): Observable { + return this.http.get( + `${this.challengeBaseUrl}/${challengeId}` + ); + } - acceptChallenge(challengeId: string): Observable { - return this.http.post( - `${this.challengeBaseUrl}/${challengeId}/accept`, - {} - ); - } + 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 || {} - ); - } + 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`, - {} - ); - } + cancelChallenge(challengeId: string): Observable { + return this.http.post( + `${this.challengeBaseUrl}/${challengeId}/cancel`, + {} + ); + } }