feat: challange request, accept, decline, page, added (not complete, polling issues, and not stable, and too much code for nothing)
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
@import '../../button-template.css';
|
||||
|
||||
.challenges-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #04000f 0%, #0e0235 25%, #2d0860 50%, #0e0235 75%, #04000f 100%);
|
||||
color: #e0e0e0;
|
||||
padding: 20px;
|
||||
font-family: 'Space Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.challenges-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid rgba(0, 213, 255, 0.3);
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #00d5ff;
|
||||
font-size: 32px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(0, 213, 255, 0.3);
|
||||
color: #00d5ff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 213, 255, 0.1);
|
||||
border-color: #00d5ff;
|
||||
}
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background-color: rgba(255, 107, 107, 0.1);
|
||||
border: 1px solid #ff6b6b;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
color: #ff9999;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.challenges-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 30px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.challenges-section {
|
||||
h2 {
|
||||
color: #00d5ff;
|
||||
font-size: 20px;
|
||||
margin: 0 0 20px 0;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(0, 213, 255, 0.2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #00d5ff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #b0b0d0;
|
||||
font-size: 14px;
|
||||
background-color: rgba(0, 213, 255, 0.05);
|
||||
border: 1px dashed rgba(0, 213, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.challenge-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.challenge-card {
|
||||
background: linear-gradient(135deg, rgba(10, 14, 39, 0.8) 0%, rgba(26, 26, 62, 0.8) 100%);
|
||||
border: 1px solid rgba(0, 213, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(0, 213, 255, 0.6);
|
||||
box-shadow: 0 0 15px rgba(0, 213, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.challenge-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(0, 213, 255, 0.1);
|
||||
|
||||
.challenger-name {
|
||||
color: #d4f4ff;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.time-control {
|
||||
background-color: rgba(0, 213, 255, 0.15);
|
||||
border: 1px solid rgba(0, 213, 255, 0.3);
|
||||
color: #00d5ff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.challenge-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
|
||||
.label {
|
||||
color: #b0b0d0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #00d5ff;
|
||||
font-weight: 600;
|
||||
|
||||
&.status-pending {
|
||||
color: #ffcc30;
|
||||
}
|
||||
|
||||
&.status-accepted {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
&.status-declined {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
&.status-expired {
|
||||
color: #b0b0d0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.challenge-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid rgba(0, 213, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
color: #00d5ff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 213, 255, 0.1);
|
||||
border-color: #00d5ff;
|
||||
}
|
||||
|
||||
&.btn-accept {
|
||||
background-color: #00d5ff;
|
||||
color: #04000f;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 15px rgba(0, 213, 255, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-decline,
|
||||
&.btn-cancel {
|
||||
color: #ff6b6b;
|
||||
border-color: rgba(255, 107, 107, 0.3);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 107, 107, 0.1);
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<div class="challenges-container">
|
||||
<div class="challenges-header">
|
||||
<h1>Active Challenges</h1>
|
||||
<button type="button" class="back-btn" (click)="goBack()">← Back</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="errorMessage" class="error-banner">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="challenges-grid">
|
||||
<!-- Incoming Challenges -->
|
||||
<div class="challenges-section">
|
||||
<h2>Incoming Challenges</h2>
|
||||
<div *ngIf="loading" class="loading-spinner">Loading...</div>
|
||||
|
||||
<div *ngIf="!loading && incomingChallenges.length === 0" class="empty-state">
|
||||
<p>No incoming challenges</p>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading && incomingChallenges.length > 0" class="challenge-list">
|
||||
<div *ngFor="let challenge of incomingChallenges" class="challenge-card">
|
||||
<div class="challenge-header">
|
||||
<span class="challenger-name">{{ getChallengerDisplay(challenge) }}</span>
|
||||
<span class="time-control">{{ getTimeControlDisplay(challenge) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="challenge-details">
|
||||
<div class="detail">
|
||||
<span class="label">Status:</span>
|
||||
<span class="value" [class]="'status-' + challenge.status">
|
||||
{{ challenge.status | uppercase }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="label">Expires in:</span>
|
||||
<span class="value">{{ getExpirationInfo(challenge) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="challenge-actions" *ngIf="challenge.status === 'created'">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-decline"
|
||||
(click)="declineChallenge(challenge)"
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-accept"
|
||||
(click)="acceptChallenge(challenge)"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outgoing Challenges -->
|
||||
<div class="challenges-section">
|
||||
<h2>Outgoing Challenges</h2>
|
||||
|
||||
<div *ngIf="!loading && outgoingChallenges.length === 0" class="empty-state">
|
||||
<p>No outgoing challenges</p>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading && outgoingChallenges.length > 0" class="challenge-list">
|
||||
<div *ngFor="let challenge of outgoingChallenges" class="challenge-card">
|
||||
<div class="challenge-header">
|
||||
<span class="challenger-name">→ {{ getOpponentDisplay(challenge) }}</span>
|
||||
<span class="time-control">{{ getTimeControlDisplay(challenge) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="challenge-details">
|
||||
<div class="detail">
|
||||
<span class="label">Status:</span>
|
||||
<span class="value" [class]="'status-' + challenge.status">
|
||||
{{ challenge.status | uppercase }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="label">Expires in:</span>
|
||||
<span class="value">{{ getExpirationInfo(challenge) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="challenge-actions" *ngIf="challenge.status === 'created'">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-cancel"
|
||||
(click)="cancelChallenge(challenge)"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Component, inject, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ChallengeService } from '../../services/challenge.service';
|
||||
import { ChallengeEventService } from '../../services/challenge-event.service';
|
||||
import { Challenge } from '../../models/challenge.models';
|
||||
import { getErrorMessage } from '../../core/http/error-message.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-challenges',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './challenges.component.html',
|
||||
styleUrls: ['./challenges.component.css']
|
||||
})
|
||||
export class ChallengesComponent implements OnInit, OnDestroy {
|
||||
private readonly challengeService = inject(ChallengeService);
|
||||
private readonly challengeEventService = inject(ChallengeEventService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
incomingChallenges: Challenge[] = [];
|
||||
outgoingChallenges: Challenge[] = [];
|
||||
loading = false;
|
||||
errorMessage = '';
|
||||
|
||||
private pollInterval: any = null;
|
||||
private readonly pollIntervalMs = 5000; // Poll every 5 seconds
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadChallenges(true);
|
||||
|
||||
// Subscribe to challenge events
|
||||
this.challengeEventService.getChallengeReceived$()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => {
|
||||
this.loadChallenges();
|
||||
});
|
||||
|
||||
// Start polling for challenge updates
|
||||
this.startPolling();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
this.pollInterval = setInterval(() => {
|
||||
this.loadChallenges(false);
|
||||
}, this.pollIntervalMs);
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
loadChallenges(showLoader = false): void {
|
||||
if (showLoader) {
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
}
|
||||
|
||||
this.challengeService.listChallenges()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.incomingChallenges = response.in || response.incoming || [];
|
||||
this.outgoingChallenges = response.out || response.outgoing || [];
|
||||
if (showLoader) {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'Failed to load challenges');
|
||||
if (showLoader) {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
acceptChallenge(challenge: Challenge): void {
|
||||
this.challengeService.acceptChallenge(challenge.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.challengeEventService.onChallengeAccepted(challenge);
|
||||
this.loadChallenges();
|
||||
// Navigate to game (if backend creates game automatically)
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'Failed to accept challenge');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
declineChallenge(challenge: Challenge): void {
|
||||
this.challengeService.declineChallenge(challenge.id, { reason: 'Not interested' })
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.challengeEventService.removeChallenge(challenge.id);
|
||||
this.loadChallenges();
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'Failed to decline challenge');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelChallenge(challenge: Challenge): void {
|
||||
this.challengeService.cancelChallenge(challenge.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.loadChallenges();
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'Failed to cancel challenge');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
void this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
getTimeControlDisplay(challenge: Challenge): string {
|
||||
const { limit, increment } = challenge.timeControl;
|
||||
if (!limit || !increment) {
|
||||
return 'Unlimited';
|
||||
}
|
||||
const minutes = Math.floor(limit / 60);
|
||||
return `${minutes}+${increment}`;
|
||||
}
|
||||
|
||||
getChallengerDisplay(challenge: Challenge): string {
|
||||
return challenge.challenger.name;
|
||||
}
|
||||
|
||||
getOpponentDisplay(challenge: Challenge): string {
|
||||
return challenge.destUser.name;
|
||||
}
|
||||
|
||||
getExpirationInfo(challenge: Challenge): string {
|
||||
const expiresAt = new Date(challenge.expiresAt);
|
||||
const now = new Date();
|
||||
const diffMs = expiresAt.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0 || challenge.status === 'expired') {
|
||||
return 'Expired';
|
||||
}
|
||||
|
||||
const minutes = Math.floor(diffMs / 60000);
|
||||
if (minutes > 60) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
return `${minutes}m`;
|
||||
}
|
||||
}
|
||||
@@ -94,8 +94,8 @@
|
||||
<div class="bb-tag">WELCOME</div>
|
||||
<div class="bb-title" style="font-size:clamp(16px,1.8vw,26px);">WELCOME TO<br />NOWCHESS</div>
|
||||
<div class="bb-subtitle">Play your next move from the skyline.</div>
|
||||
<button type="button" class="app-btn" (click)="startOneVsOne()" [disabled]="creating">
|
||||
{{ creating ? 'CREATING...' : 'START NOW →' }}
|
||||
<button type="button" class="app-btn" (click)="openChallengeDialog()" [disabled]="creating">
|
||||
{{ creating ? 'CREATING...' : 'CREATE GAME →' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,6 +204,7 @@
|
||||
<div class="dialog-card" (click)="$event.stopPropagation()">
|
||||
<div class="dialog-title">MORE OPTIONS</div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="app-btn" (click)="openChallengeDialog()">START GAME</button>
|
||||
<button type="button" class="app-btn" (click)="openImportDialog()">IMPORT GAME</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,6 +257,10 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showChallengeDialog) {
|
||||
<app-challenge-create-dialog (closeChallengeDialog)="closeChallengeDialog()"></app-challenge-create-dialog>
|
||||
}
|
||||
|
||||
@if (errorMessage) {
|
||||
<p class="error-banner">{{ errorMessage }}</p>
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -32,7 +33,7 @@ interface WindowCell {
|
||||
@Component({
|
||||
selector: 'app-welcome',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [CommonModule, FormsModule, ChallengeCreateDialogComponent],
|
||||
templateUrl: './welcome.component.html',
|
||||
styleUrls: ['./welcome.component.css']
|
||||
})
|
||||
@@ -45,6 +46,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
||||
creating = false;
|
||||
joiningGame = false;
|
||||
importing = false;
|
||||
showChallengeDialog = false;
|
||||
errorMessage = '';
|
||||
|
||||
showDifficultyDialog = false;
|
||||
@@ -154,6 +156,20 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
||||
this.errorMessage = '';
|
||||
}
|
||||
|
||||
openChallengeDialog(): void {
|
||||
if (!this.requireAuth(() => this.showChallengeDialog = true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeAllDialogs();
|
||||
this.showChallengeDialog = true;
|
||||
}
|
||||
|
||||
closeChallengeDialog(): void {
|
||||
this.showChallengeDialog = false;
|
||||
this.errorMessage = '';
|
||||
}
|
||||
|
||||
openOptionsDialog(): void {
|
||||
this.closeAllDialogs();
|
||||
this.showOptionsDialog = true;
|
||||
@@ -439,6 +455,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
|
||||
this.showOptionsDialog = false;
|
||||
this.showJoinDialog = false;
|
||||
this.showImportDialog = false;
|
||||
this.showChallengeDialog = false;
|
||||
this.errorMessage = '';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user