diff --git a/proxy.conf.json b/proxy.conf.json index 1f2ef6a..fa98e8c 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -9,6 +9,12 @@ "secure": false, "changeOrigin": true }, + "/api/user/ws": { + "target": "http://localhost:8084", + "secure": false, + "changeOrigin": true, + "ws": true + }, "/api": { "target": "http://localhost:8080", "secure": false, diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 59dd95e..db5cc47 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -3,10 +3,12 @@ import { GameComponent } from './pages/game/game.component'; import { WelcomeComponent } from './pages/welcome/welcome.component'; import { ProfileComponent } from './pages/profile/profile.component'; import { ChallengesComponent } from './pages/challenges/challenges.component'; +import { GamesComponent } from './pages/games/games.component'; export const routes: Routes = [ { path: '', component: WelcomeComponent }, { path: 'profile', component: ProfileComponent }, + { path: 'games', component: GamesComponent }, { path: 'challenges', component: ChallengesComponent }, { path: 'game/:gameId', component: GameComponent }, { path: '**', redirectTo: '' } diff --git a/src/app/components/board-actions-bar/board-actions-bar.component.css b/src/app/components/board-actions-bar/board-actions-bar.component.css new file mode 100644 index 0000000..fb76f03 --- /dev/null +++ b/src/app/components/board-actions-bar/board-actions-bar.component.css @@ -0,0 +1,51 @@ +.board-actions { + display: flex; + gap: 6px; + padding: 8px; + background: var(--nc-surface); + border: 1px solid var(--nc-border); +} + +.board-actions.disabled { + opacity: 0.5; + pointer-events: none; +} + +.btn { + flex: 1; + font-family: var(--nc-sans); + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + font-weight: 600; + padding: 8px 10px; + cursor: pointer; + border: 1px solid var(--nc-border-strong); + background: var(--nc-btn-bg, rgba(255, 255, 255, 0.03)); + color: var(--nc-text); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.btn:hover:not(:disabled) { + background: var(--nc-btn-hover-bg, rgba(255, 255, 255, 0.07)); + border-color: var(--nc-text-muted); +} + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn-danger { + color: var(--nc-danger); + border-color: var(--nc-danger-soft, rgba(255, 122, 122, 0.3)); +} + +.btn-danger:hover:not(:disabled) { + background: var(--nc-danger-bg, rgba(255, 122, 122, 0.08)); + border-color: var(--nc-danger); +} diff --git a/src/app/components/board-actions-bar/board-actions-bar.component.html b/src/app/components/board-actions-bar/board-actions-bar.component.html new file mode 100644 index 0000000..92c94fa --- /dev/null +++ b/src/app/components/board-actions-bar/board-actions-bar.component.html @@ -0,0 +1,27 @@ +
+ + + + + +
diff --git a/src/app/components/board-actions-bar/board-actions-bar.component.ts b/src/app/components/board-actions-bar/board-actions-bar.component.ts new file mode 100644 index 0000000..d34e859 --- /dev/null +++ b/src/app/components/board-actions-bar/board-actions-bar.component.ts @@ -0,0 +1,16 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'app-board-actions-bar', + standalone: true, + imports: [], + templateUrl: './board-actions-bar.component.html', + styleUrl: './board-actions-bar.component.css' +}) +export class BoardActionsBarComponent { + @Input() undoAvailable = false; + @Input() isGameFinished = false; + @Output() takeback = new EventEmitter(); + @Output() offerDraw = new EventEmitter(); + @Output() resign = new EventEmitter(); +} 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 0911de4..9536f0d 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 @@ -15,7 +15,7 @@
+ placeholder="Enter opponent's username" required /> Username is required @@ -24,7 +24,7 @@
- @@ -34,7 +34,7 @@
- @@ -58,13 +58,11 @@
- +
- +
@@ -72,7 +70,7 @@
- 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 7c04003..2dc9a1f 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 @@ -124,11 +124,11 @@ export class ChallengeCreateDialogComponent implements OnInit, OnDestroy { this.errorMessage = ''; this.loading = true; + this.form.disable(); - 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; + const limitSeconds = Math.round((this.form.getRawValue().limitMinutes || 0) * 60); + const { incrementSeconds, ttlSeconds, color: rawColor } = this.form.getRawValue(); + const color = (rawColor || 'random') as PlayerColor; this.challengeService.sendChallenge(targetUsername, { timeControl: { @@ -138,7 +138,7 @@ export class ChallengeCreateDialogComponent implements OnInit, OnDestroy { color, ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined }) - .pipe(finalize(() => (this.loading = false))) + .pipe(finalize(() => { this.loading = false; this.form.enable(); })) .subscribe({ next: (challenge) => { // Challenge sent successfully - navigate to challenges page to view status diff --git a/src/app/components/challenge-notification/challenge-notification.component.ts b/src/app/components/challenge-notification/challenge-notification.component.ts index 7c5ca57..87d760f 100644 --- a/src/app/components/challenge-notification/challenge-notification.component.ts +++ b/src/app/components/challenge-notification/challenge-notification.component.ts @@ -1,5 +1,6 @@ import { Component, Input, Output, EventEmitter, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; import { Challenge } from '../../models/challenge.models'; import { ChallengeService } from '../../services/challenge.service'; import { finalize } from 'rxjs'; @@ -19,6 +20,7 @@ export class ChallengeNotificationComponent { @Output() close = new EventEmitter(); private readonly challengeService = inject(ChallengeService); + private readonly router = inject(Router); acceptingChallenge = false; decliningChallenge = false; @@ -35,8 +37,13 @@ export class ChallengeNotificationComponent { this.challengeService.acceptChallenge(this.challenge.id) .pipe(finalize(() => (this.acceptingChallenge = false))) .subscribe({ - next: () => { - this.accept.emit(this.challenge); + next: (acceptedChallenge) => { + this.accept.emit(acceptedChallenge); + 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'); @@ -52,7 +59,7 @@ export class ChallengeNotificationComponent { this.decliningChallenge = true; this.errorMessage = ''; - this.challengeService.declineChallenge(this.challenge.id, { reason: 'Not interested' }) + this.challengeService.declineChallenge(this.challenge.id, { reason: 'generic' }) .pipe(finalize(() => (this.decliningChallenge = false))) .subscribe({ next: () => { diff --git a/src/app/components/export-panel/export-panel.component.css b/src/app/components/export-panel/export-panel.component.css new file mode 100644 index 0000000..26128d6 --- /dev/null +++ b/src/app/components/export-panel/export-panel.component.css @@ -0,0 +1,132 @@ +.card { + background: var(--nc-surface); + border: 1px solid var(--nc-border); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.disclose summary { + list-style: none; + cursor: pointer; + padding: 14px 16px; + display: flex; + align-items: center; + justify-content: space-between; + user-select: none; +} + +.disclose summary::-webkit-details-marker { + display: none; +} + +.panel-title { + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--nc-text-muted); + font-weight: 600; +} + +.chev { + color: var(--nc-text-dim); + display: flex; + transition: transform 0.2s; +} + +.disclose[open] .chev { + transform: rotate(180deg); +} + +.disclose[open] summary { + border-bottom: 1px solid var(--nc-border); +} + +.panel-body { + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Segmented control */ +.seg { + display: flex; + border: 1px solid var(--nc-border); + padding: 2px; + background: var(--nc-seg-bg, rgba(0, 0, 0, 0.2)); +} + +.seg-btn { + flex: 1; + background: transparent; + border: none; + color: var(--nc-text-muted); + padding: 7px 10px; + font-size: 11px; + font-family: var(--nc-sans); + letter-spacing: 0.08em; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.seg-btn.active { + background: var(--nc-neon); + color: #fff; + font-weight: 700; +} + +.export-out { + width: 100%; + background: var(--nc-clock-bg); + border: 1px solid var(--nc-border); + color: var(--nc-text); + font-family: var(--nc-mono); + font-size: 11px; + padding: 10px; + resize: vertical; + min-height: 70px; + line-height: 1.5; +} + +.export-out:focus { + outline: none; + border-color: var(--nc-neon-soft); +} + +.export-row { + display: flex; + gap: 6px; +} + +/* Buttons */ +.btn { + flex: 1; + font-family: var(--nc-sans); + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + font-weight: 600; + padding: 8px 10px; + cursor: pointer; + border: 1px solid var(--nc-border-strong); + background: var(--nc-btn-bg, rgba(255, 255, 255, 0.03)); + color: var(--nc-text); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: background 0.15s, border-color 0.15s; +} + +.btn:hover { + background: var(--nc-btn-hover-bg, rgba(255, 255, 255, 0.07)); + border-color: var(--nc-text-muted); +} + +.copy-notice { + margin: 0; + font-size: 11px; + color: var(--nc-success); + font-family: var(--nc-mono); + letter-spacing: 0.04em; +} diff --git a/src/app/components/export-panel/export-panel.component.html b/src/app/components/export-panel/export-panel.component.html new file mode 100644 index 0000000..35e8bfc --- /dev/null +++ b/src/app/components/export-panel/export-panel.component.html @@ -0,0 +1,51 @@ +
+ + Export Position + + + +
+
+ + +
+ + + +
+ + +
+ + @if (copyNotice) { +

{{ copyNotice }}

+ } +
+
diff --git a/src/app/components/export-panel/export-panel.component.ts b/src/app/components/export-panel/export-panel.component.ts new file mode 100644 index 0000000..d53be84 --- /dev/null +++ b/src/app/components/export-panel/export-panel.component.ts @@ -0,0 +1,79 @@ +import { Component, Input, OnChanges } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +type ExportKind = 'fen' | 'pgn'; + +@Component({ + selector: 'app-export-panel', + standalone: true, + imports: [FormsModule], + templateUrl: './export-panel.component.html', + styleUrl: './export-panel.component.css' +}) +export class ExportPanelComponent implements OnChanges { + @Input() fen = ''; + @Input() pgn = ''; + + exportKind: ExportKind = 'fen'; + exportValue = ''; + copyNotice = ''; + private copyNoticeTimer: ReturnType | null = null; + + get exportPlaceholder(): string { + return this.exportKind === 'fen' ? 'FEN will appear here' : 'PGN will appear here'; + } + + ngOnChanges(): void { + this.syncValue(); + } + + setKind(kind: ExportKind): void { + this.exportKind = kind; + this.syncValue(); + } + + copy(): void { + if (!this.exportValue.trim()) { + return; + } + + if (!navigator.clipboard?.writeText) { + this.showNotice('Ready in the text box.'); + return; + } + + void navigator.clipboard + .writeText(this.exportValue) + .then(() => this.showNotice('Copied!')) + .catch(() => this.showNotice('Ready in the text box.')); + } + + download(): void { + if (!this.exportValue.trim()) { + return; + } + + const ext = this.exportKind === 'pgn' ? 'pgn' : 'txt'; + const blob = new Blob([this.exportValue], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `game.${ext}`; + a.click(); + URL.revokeObjectURL(url); + } + + private syncValue(): void { + this.exportValue = this.exportKind === 'fen' ? this.fen : this.pgn; + } + + private showNotice(msg: string): void { + this.copyNotice = msg; + if (this.copyNoticeTimer !== null) { + clearTimeout(this.copyNoticeTimer); + } + this.copyNoticeTimer = setTimeout(() => { + this.copyNotice = ''; + }, 1800); + } +} diff --git a/src/app/components/login-dialog/login-dialog.component.html b/src/app/components/login-dialog/login-dialog.component.html index 0f3d9e4..0be3592 100644 --- a/src/app/components/login-dialog/login-dialog.component.html +++ b/src/app/components/login-dialog/login-dialog.component.html @@ -4,15 +4,13 @@
- + @if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) { Username must be at least 3 characters } - + @if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) { Password must be at least 6 characters } diff --git a/src/app/components/login-dialog/login-dialog.component.ts b/src/app/components/login-dialog/login-dialog.component.ts index 25a74a2..dc025dd 100644 --- a/src/app/components/login-dialog/login-dialog.component.ts +++ b/src/app/components/login-dialog/login-dialog.component.ts @@ -38,15 +38,18 @@ export class LoginDialogComponent { this.isLoading = true; this.errorMessage = null; + this.loginForm.disable(); - const { username, password } = this.loginForm.value; + const { username, password } = this.loginForm.getRawValue(); this.authService.login(username, password).subscribe({ next: () => { this.isLoading = false; + this.loginForm.enable(); this.onSuccess.emit(); }, error: (err) => { this.isLoading = false; + this.loginForm.enable(); this.errorMessage = err.error?.message || 'Login failed. Please try again.'; } }); diff --git a/src/app/components/move-history/move-history.component.css b/src/app/components/move-history/move-history.component.css new file mode 100644 index 0000000..8498110 --- /dev/null +++ b/src/app/components/move-history/move-history.component.css @@ -0,0 +1,94 @@ +.moves { + display: grid; + grid-template-columns: 38px 1fr 1fr; + font-family: var(--nc-mono); + font-size: 12px; + max-height: 260px; + overflow-y: auto; +} + +.moves-empty { + grid-column: 1 / -1; + padding: 16px; + color: var(--nc-text-dim); + font-size: 12px; + text-align: center; +} + +.mv-num { + padding: 6px 12px 6px 10px; + color: var(--nc-text-dim); + text-align: right; + border-right: 1px solid var(--nc-border); +} + +.mv { + padding: 6px 10px; + color: var(--nc-text); + cursor: pointer; + transition: background 0.1s, color 0.1s; +} + +.mv:hover { + background: rgba(255, 69, 200, 0.06); + color: var(--nc-neon); +} + +.mv.current { + background: rgba(255, 69, 200, 0.10); + color: var(--nc-neon); +} + +.mv.mv-empty { + color: var(--nc-text-dim); + cursor: default; +} + +.mv.mv-empty:hover { + background: transparent; + color: var(--nc-text-dim); +} + +.moves-foot { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-top: 1px solid var(--nc-border); +} + +.moves-nav { + display: flex; + gap: 2px; +} + +.icon-btn { + background: transparent; + border: 1px solid transparent; + color: var(--nc-text-muted); + width: 26px; + height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.icon-btn:hover { + color: var(--nc-neon); + border-color: var(--nc-border); +} + +.live-label { + font-family: var(--nc-mono); + font-size: 10px; + color: var(--nc-neon); + letter-spacing: 0.14em; + opacity: 0.8; +} + +.moves::-webkit-scrollbar { width: 6px; } +.moves::-webkit-scrollbar-track { background: transparent; } +.moves::-webkit-scrollbar-thumb { background: var(--nc-border-strong); border-radius: 3px; } +.moves::-webkit-scrollbar-thumb:hover { background: var(--nc-neon-soft); } diff --git a/src/app/components/move-history/move-history.component.html b/src/app/components/move-history/move-history.component.html new file mode 100644 index 0000000..8bae41c --- /dev/null +++ b/src/app/components/move-history/move-history.component.html @@ -0,0 +1,43 @@ +
+ @if (movePairs.length === 0) { +
No moves yet.
+ } @else { + @for (pair of movePairs; track $index) { + +
+ {{ pair.white }} +
+
+ {{ pair.black ?? '…' }} +
+ } + } +
+ +
+
+ + + + +
+ @if (plyCount > 0) { + LIVE + } +
diff --git a/src/app/components/move-history/move-history.component.ts b/src/app/components/move-history/move-history.component.ts new file mode 100644 index 0000000..0221780 --- /dev/null +++ b/src/app/components/move-history/move-history.component.ts @@ -0,0 +1,52 @@ +import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; + +export type MoveNavDirection = 'first' | 'prev' | 'next' | 'last'; + +interface MovePair { + white: string; + black: string | null; +} + +@Component({ + selector: 'app-move-history', + standalone: true, + imports: [], + templateUrl: './move-history.component.html', + styleUrl: './move-history.component.css' +}) +export class MoveHistoryComponent implements OnChanges { + @Input({ required: true }) moves: string[] = []; + @Output() navigate = new EventEmitter(); + + movePairs: MovePair[] = []; + + get plyCount(): number { + return this.moves.length; + } + + get currentWhiteIndex(): number { + const lastPairIndex = this.movePairs.length - 1; + if (lastPairIndex < 0) return -1; + const lastMove = this.moves.length - 1; + return lastMove % 2 === 0 ? lastPairIndex : -1; + } + + get currentBlackIndex(): number { + const lastPairIndex = this.movePairs.length - 1; + if (lastPairIndex < 0) return -1; + const lastMove = this.moves.length - 1; + return lastMove % 2 === 1 ? lastPairIndex : -1; + } + + ngOnChanges(): void { + this.movePairs = this.buildPairs(this.moves); + } + + private buildPairs(moves: string[]): MovePair[] { + const pairs: MovePair[] = []; + for (let i = 0; i < moves.length; i += 2) { + pairs.push({ white: moves[i], black: moves[i + 1] ?? null }); + } + return pairs; + } +} diff --git a/src/app/components/player-card/player-card.component.css b/src/app/components/player-card/player-card.component.css new file mode 100644 index 0000000..ec0c577 --- /dev/null +++ b/src/app/components/player-card/player-card.component.css @@ -0,0 +1,90 @@ +.player { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + background: var(--nc-surface); + border: 1px solid var(--nc-border); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.player.is-turn { + border-color: var(--nc-neon-soft); + box-shadow: 0 0 0 1px rgba(255, 69, 200, 0.2), 0 0 20px rgba(255, 69, 200, 0.1); +} + +.player-avatar { + width: 42px; + height: 42px; + border-radius: 50%; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 17px; + font-weight: 700; + color: #fff; +} + +.avatar-black { + background: linear-gradient(135deg, #2a2a40 0%, #0a0a14 100%); + border: 1px solid var(--nc-border-strong); +} + +.avatar-white { + background: linear-gradient(135deg, var(--nc-neon) 0%, #7a2fd6 100%); +} + +.player-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.player-name { + font-size: 14px; + font-weight: 600; + color: var(--nc-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.captured { + display: flex; + align-items: center; + gap: 2px; + font-size: 13px; + color: var(--nc-text-muted); + line-height: 1; +} + +.clock { + font-family: var(--nc-mono); + font-size: 22px; + font-weight: 600; + padding: 8px 14px; + min-width: 92px; + text-align: center; + background: var(--nc-clock-bg); + border: 1px solid var(--nc-border); + color: var(--nc-text); + letter-spacing: 0.02em; + transition: color 0.2s, border-color 0.2s, background 0.2s, text-shadow 0.2s; +} + +.clock.clock-active { + color: var(--nc-neon); + border-color: var(--nc-neon-soft); + background: var(--nc-neon-clock-bg, rgba(255, 69, 200, 0.08)); + text-shadow: 0 0 8px rgba(255, 69, 200, 0.4); +} + +.clock.clock-low { + color: var(--nc-warning); + border-color: var(--nc-warning-soft, rgba(255, 177, 58, 0.4)); +} diff --git a/src/app/components/player-card/player-card.component.html b/src/app/components/player-card/player-card.component.html new file mode 100644 index 0000000..36bee66 --- /dev/null +++ b/src/app/components/player-card/player-card.component.html @@ -0,0 +1,22 @@ +
+
+ {{ initial }} +
+ +
+
{{ name }}
+ @if (capturedPieces.length > 0) { +
+ @for (pc of capturedPieces; track $index) { + {{ pc }} + } +
+ } +
+ + @if (clockDisplay !== '--:--') { +
+ {{ clockDisplay }} +
+ } +
diff --git a/src/app/components/player-card/player-card.component.ts b/src/app/components/player-card/player-card.component.ts new file mode 100644 index 0000000..576d361 --- /dev/null +++ b/src/app/components/player-card/player-card.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-player-card', + standalone: true, + imports: [], + templateUrl: './player-card.component.html', + styleUrl: './player-card.component.css' +}) +export class PlayerCardComponent { + @Input({ required: true }) name = ''; + @Input({ required: true }) initial = ''; + @Input({ required: true }) color: 'white' | 'black' = 'white'; + @Input() isActive = false; + @Input() clockDisplay = '--:--'; + @Input() isLowTime = false; + @Input() capturedPieces: string[] = []; +} diff --git a/src/app/components/register-dialog/register-dialog.component.html b/src/app/components/register-dialog/register-dialog.component.html index 6562df8..4c75996 100644 --- a/src/app/components/register-dialog/register-dialog.component.html +++ b/src/app/components/register-dialog/register-dialog.component.html @@ -3,26 +3,23 @@
CREATE ACCOUNT
- + @if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) { Username must be at least 3 characters } - + @if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) { Please enter a valid email } - + @if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) { Password must be at least 6 characters } + placeholder="Confirm Password" /> @if (errorMessage) {
{{ errorMessage }}
diff --git a/src/app/components/register-dialog/register-dialog.component.ts b/src/app/components/register-dialog/register-dialog.component.ts index ef11842..ae3e9cc 100644 --- a/src/app/components/register-dialog/register-dialog.component.ts +++ b/src/app/components/register-dialog/register-dialog.component.ts @@ -46,15 +46,18 @@ export class RegisterDialogComponent { this.isLoading = true; this.errorMessage = null; + this.registerForm.disable(); - const { username, email, password: pwd } = this.registerForm.value; + const { username, email, password: pwd } = this.registerForm.getRawValue(); this.authService.register(username, pwd, email).subscribe({ next: () => { this.isLoading = false; + this.registerForm.enable(); this.onSuccess.emit(); }, error: (err) => { this.isLoading = false; + this.registerForm.enable(); this.errorMessage = err.error?.message || 'Registration failed. Please try again.'; } diff --git a/src/app/components/toolbar/toolbar.component.css b/src/app/components/toolbar/toolbar.component.css index be8f759..859c441 100644 --- a/src/app/components/toolbar/toolbar.component.css +++ b/src/app/components/toolbar/toolbar.component.css @@ -1,84 +1,511 @@ -@import '../../button-template.css'; - -.navbar { - background: rgba(8, 6, 28, 0.85); - backdrop-filter: blur(8px); - box-shadow: 0 4px 20px rgba(0, 210, 255, 0.15); - border-bottom: 1px solid rgba(0, 210, 255, 0.2); - border-radius: 0; - padding: 0.75rem 1rem; +/* ============ THEME TOKENS ============ */ +:host { + /* Light mode: warm sunset palette from background gradient */ + --nc-accent: #ff3dbb; + --nc-accent-hover: rgba(255, 107, 61, 0.15); + --nc-accent-badge: rgba(223, 61, 255, 0.9); + --nc-badge-text: #1a0800; + --nc-surface: rgba(26, 24, 56, 0.97); + --nc-nav-bg: linear-gradient(180deg, rgba(26,24,56,0.88) 0%, rgba(46,32,80,0.6) 70%, rgba(74,41,98,0) 100%); + --nc-text: #fff; + --nc-text-muted: rgba(255,255,255,0.7); + --nc-text-dim: rgba(255,255,255,0.45); + --nc-border: rgba(255,255,255,0.1); + --nc-popover-glow: 0 20px 60px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,107,61,0.18); + --nc-unread-dot: #ff6b3d; + --nc-avatar-a: #d44d4a; + --nc-avatar-b: #8b3a6b; + --nc-danger: #ff7a7a; } -.navbar-brand { - font-size: 1.5rem; - font-weight: bold; - color: var(--bb-title) !important; - font-family: 'Bebas Neue', sans-serif; - letter-spacing: 1px; - cursor: pointer; +:host-context(html[data-theme='dark']) { + /* Dark mode: blue neon palette */ + --nc-accent: #00d5ff; + --nc-accent-hover: rgba(0, 213, 255, 0.12); + --nc-accent-badge: #00d5ff; + --nc-badge-text: #04000f; + --nc-surface: rgba(8, 6, 28, 0.97); + --nc-nav-bg: linear-gradient(180deg, rgba(8,5,20,0.88) 0%, rgba(8,5,20,0.58) 70%, rgba(8,5,20,0) 100%); + --nc-text: #fff; + --nc-text-muted: rgba(255,255,255,0.65); + --nc-text-dim: rgba(255,255,255,0.4); + --nc-border: rgba(255,255,255,0.08); + --nc-popover-glow: 0 20px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(0,213,255,0.15); + --nc-unread-dot: #00d5ff; + --nc-avatar-a: #00d5ff; + --nc-avatar-b: #1a5fa8; + --nc-danger: #ff7a7a; } -.gap-2 { - gap: 0.5rem; -} - -.user-section { +/* ============ NAV CONTAINER ============ */ +.nc-nav { + position: fixed; + top: 0; left: 0; right: 0; + height: 56px; + z-index: 100; + display: flex; align-items: center; + padding: 0 24px; + background: var(--nc-nav-bg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + font-family: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } -.me-btn { - background: rgba(0, 210, 255, 0.1); - color: var(--bb-title); - border: 1px solid var(--bb-border); - border-radius: 2px; - padding: 0.5rem 0.8rem; - font-family: 'Space Mono', monospace; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.5px; +/* ============ LOGO ============ */ +.nc-logo { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; cursor: pointer; - transition: all 0.2s ease; - display: inline-flex; + user-select: none; +} + +.nc-logo-mark { + width: 24px; height: 24px; + background: var(--nc-accent); + display: flex; align-items: center; justify-content: center; - outline: none; + font-weight: 800; + color: var(--nc-badge-text); + font-size: 14px; +} + +.nc-logo-text { + font-size: 15px; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--nc-text); +} + +/* ============ CENTER LINKS ============ */ +.nc-links { + flex: 1; + display: flex; + justify-content: center; + gap: 4px; +} + +.nc-link { + background: transparent; + border: none; + color: var(--nc-text-muted); + padding: 8px 14px; + font-size: 12px; + font-family: inherit; + letter-spacing: 0.08em; + font-weight: 500; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + position: relative; + transition: color 0.15s; +} + +.nc-link:hover { color: var(--nc-text); } + +.nc-link::after { + content: ""; + position: absolute; + bottom: 2px; left: 14px; right: 14px; + height: 1px; + background: var(--nc-accent); + opacity: 0; + transition: opacity 0.15s; +} + +.nc-link:hover::after { opacity: 1; } + +/* ============ RIGHT CLUSTER ============ */ +.nc-right { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + margin-left: auto; +} + +/* ============ BELL ============ */ +.nc-bell { + width: 36px; height: 36px; + border: 1px solid var(--nc-border); + background: transparent; + color: var(--nc-text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + position: relative; + transition: background 0.15s, color 0.15s; + font-family: inherit; +} + +.nc-bell:hover, +.nc-bell.is-open { + background: var(--nc-accent-hover); + color: var(--nc-text); +} + +/* ============ BADGE ============ */ +.nc-badge { + position: absolute; + top: 5px; right: 5px; + min-width: 14px; height: 14px; + border-radius: 7px; + background: var(--nc-accent-badge); + color: var(--nc-badge-text); + font-size: 9px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + padding: 0 3px; +} + +/* ============ GAMES BUTTON ============ */ +.nc-games-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 14px; + border: 1px solid var(--nc-border); + background: transparent; + color: var(--nc-text-muted); + font-family: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.nc-games-btn:hover { + background: var(--nc-accent-hover); + color: var(--nc-text); +} + +/* ============ PROFILE BUTTON ============ */ +.nc-profile { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px 4px 4px; + height: 36px; + border: 1px solid var(--nc-border); + background: transparent; + cursor: pointer; + color: var(--nc-text-muted); + font-family: inherit; + transition: background 0.15s, color 0.15s; +} + +.nc-profile:hover, +.nc-profile.is-open { + background: var(--nc-accent-hover); + color: var(--nc-text); +} + +.nc-profile-name { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; +} + +.nc-chevron { opacity: 0.5; } + +/* ============ AVATAR ============ */ +.nc-avatar { + border-radius: 50%; + background: linear-gradient(135deg, var(--nc-avatar-a) 0%, var(--nc-avatar-b) 100%); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + letter-spacing: 0.02em; + flex-shrink: 0; +} + +.nc-avatar-sm { width: 26px; height: 26px; font-size: 11px; } +.nc-avatar-md { width: 40px; height: 40px; font-size: 17px; } + +/* ============ DROPDOWN WRAPPER ============ */ +.nc-dropdown-wrap { position: relative; } + +/* ============ POPOVERS ============ */ +.nc-popover { + position: absolute; + top: calc(100% + 10px); + right: 0; + background: var(--nc-surface); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--nc-border); + box-shadow: var(--nc-popover-glow); + z-index: 200; + overflow: hidden; +} + +/* ============ NOTIFICATIONS PANEL ============ */ +.nc-notif { width: 360px; } + +.nc-notif-header { + padding: 14px 18px; + border-bottom: 1px solid rgba(255,255,255,0.06); + display: flex; + justify-content: space-between; + align-items: center; +} + +.nc-notif-header-title { + font-size: 11px; + letter-spacing: 0.22em; + color: var(--nc-text-muted); + text-transform: uppercase; + font-weight: 600; +} + +.nc-notif-list { max-height: 420px; overflow-y: auto; } + +.nc-notif-empty { + padding: 24px 18px; + text-align: center; + font-size: 13px; + color: var(--nc-text-dim); + letter-spacing: 0.04em; +} + +.nc-notif-row { + padding: 14px 18px; + border-bottom: 1px solid rgba(255,255,255,0.04); + position: relative; + display: flex; + gap: 12px; + align-items: flex-start; +} + +.nc-notif-row.is-unread { background: rgba(255,255,255,0.03); } + +.nc-notif-row.is-unread::before { + content: ""; + position: absolute; + left: 6px; top: 22px; + width: 4px; height: 4px; + border-radius: 50%; + background: var(--nc-unread-dot); +} + +.nc-notif-icon { + width: 32px; height: 32px; + flex-shrink: 0; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.12); + display: flex; + align-items: center; + justify-content: center; + color: var(--nc-accent); +} + +.nc-notif-body { flex: 1; min-width: 0; } + +.nc-notif-text { + font-size: 13px; + color: var(--nc-text); + line-height: 1.35; +} + +.nc-notif-text b { font-weight: 600; } + +.nc-notif-meta { + font-size: 10px; + color: var(--nc-text-dim); + margin-top: 4px; + letter-spacing: 0.08em; text-transform: uppercase; } -.me-btn:hover { - background: rgba(0, 210, 255, 0.2); - border-color: var(--bb-tag); - box-shadow: 0 0 10px rgba(0, 210, 255, 0.4); - transform: scale(1.05); -} - -.me-btn:active { - transform: scale(0.98); -} - -/* Sunset Mode */ -.sunset .navbar { - background: rgba(20, 5, 45, 0.85); - border-bottom-color: rgba(255, 64, 207, 0.2); - box-shadow: 0 4px 20px rgba(242, 106, 226, 0.15); -} - -.sunset .me-btn { - background: rgba(242, 106, 226, 0.1); - border-color: var(--bb-border); -} - -.sunset .me-btn:hover { - background: rgba(242, 106, 226, 0.2); - border-color: var(--bb-tag); - box-shadow: 0 0 10px rgba(242, 106, 226, 0.4); -} - -.container-fluid { +.nc-notif-actions { display: flex; + gap: 6px; + margin-top: 10px; +} + +.nc-btn-accept, +.nc-btn-decline { + padding: 6px 12px; + font-size: 10px; + font-family: inherit; + letter-spacing: 0.18em; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + text-transform: uppercase; + font-weight: 600; + border: none; + transition: opacity 0.15s; +} + +.nc-btn-accept { + background: var(--nc-accent); + color: var(--nc-badge-text); + font-weight: 700; +} + +.nc-btn-decline { + background: transparent; + color: var(--nc-text-muted); + border: 1px solid rgba(255,255,255,0.15); +} + +.nc-btn-accept:disabled, +.nc-btn-decline:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.nc-notif-footer { + padding: 10px 18px; + border-top: 1px solid rgba(255,255,255,0.06); +} + +.nc-view-all { + width: 100%; + background: transparent; + border: none; + color: var(--nc-text-dim); + font-size: 11px; + font-family: inherit; + letter-spacing: 0.2em; + cursor: pointer; + text-transform: uppercase; + padding: 6px 0; + transition: color 0.15s; +} + +.nc-view-all:hover { color: var(--nc-text-muted); } + +/* ============ PROFILE MENU ============ */ +.nc-menu { width: 250px; } + +.nc-menu-header { + padding: 16px 16px 14px; + border-bottom: 1px solid rgba(255,255,255,0.06); + display: flex; + gap: 12px; align-items: center; } -.ms-auto { - margin-left: auto; +.nc-menu-user-name { + font-size: 14px; + color: var(--nc-text); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } + +.nc-menu-user-sub { + font-size: 11px; + color: var(--nc-text-dim); + margin-top: 2px; + letter-spacing: 0.06em; +} + +.nc-menu-group { padding: 6px 0; } + +.nc-menu-group + .nc-menu-group { + border-top: 1px solid rgba(255,255,255,0.06); +} + +.nc-menu-item { + padding: 9px 16px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + color: var(--nc-text-muted); + font-size: 13px; + font-family: inherit; + background: transparent; + border: none; + width: 100%; + text-align: left; + transition: background 0.12s, color 0.12s; +} + +.nc-menu-item:hover { + background: var(--nc-accent-hover); + color: var(--nc-accent); +} + +.nc-menu-item.danger { color: var(--nc-danger); } +.nc-menu-item.danger:hover { background: rgba(255,122,122,0.08); color: var(--nc-danger); } + +.nc-menu-icon { opacity: 0.85; display: inline-flex; } +.nc-menu-label { flex: 1; } + +/* ============ DARK MODE TOGGLE PILL ============ */ +.nc-toggle { + width: 28px; height: 16px; + border-radius: 8px; + background: rgba(255,255,255,0.15); + position: relative; + flex-shrink: 0; + transition: background 0.2s; +} + +.nc-toggle.is-on { background: var(--nc-accent); } + +.nc-toggle::after { + content: ""; + position: absolute; + top: 2px; left: 2px; + width: 12px; height: 12px; + border-radius: 50%; + background: #fff; + transition: left 0.2s; +} + +.nc-toggle.is-on::after { left: 14px; } + +/* ============ AUTH BUTTONS (logged out) ============ */ +.nc-auth-btn { + background: transparent; + border: 1px solid var(--nc-border); + color: var(--nc-text-muted); + padding: 7px 14px; + font-size: 11px; + font-family: inherit; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.nc-auth-btn:hover { + background: rgba(255,255,255,0.06); + color: var(--nc-text); +} + +.nc-auth-btn--primary { + background: var(--nc-accent); + border-color: var(--nc-accent); + color: var(--nc-badge-text); +} + +.nc-auth-btn--primary:hover { + filter: brightness(1.1); + color: var(--nc-badge-text); +} + +/* ============ NOTIF SCROLLBAR ============ */ +.nc-notif-list::-webkit-scrollbar { width: 6px; } +.nc-notif-list::-webkit-scrollbar-track { background: transparent; } +.nc-notif-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; } diff --git a/src/app/components/toolbar/toolbar.component.html b/src/app/components/toolbar/toolbar.component.html index cba57a3..6bcfdfe 100644 --- a/src/app/components/toolbar/toolbar.component.html +++ b/src/app/components/toolbar/toolbar.component.html @@ -1,28 +1,200 @@ -