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/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/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/pages/game/game.component.css b/src/app/pages/game/game.component.css index 130e475..68642a8 100644 --- a/src/app/pages/game/game.component.css +++ b/src/app/pages/game/game.component.css @@ -1,419 +1,529 @@ -@import '../../button-template.css'; +/* ============================================================ + DESIGN TOKENS — dark mode (default) +============================================================ */ +:host { + --nc-neon: #ff45c8; + --nc-neon-soft: rgba(255, 69, 200, 0.55); + --nc-neon-clock-bg: rgba(255, 69, 200, 0.08); + --nc-bg: #06060d; + --nc-surface: rgba(20, 17, 42, 0.6); + --nc-surface-solid: rgba(10, 8, 22, 0.95); + --nc-text: #fff; + --nc-text-muted: rgba(255, 255, 255, 0.65); + --nc-text-dim: rgba(255, 255, 255, 0.45); + --nc-border: rgba(255, 255, 255, 0.08); + --nc-border-strong: rgba(255, 255, 255, 0.15); + --nc-warning: #ffb13a; + --nc-warning-soft: rgba(255, 177, 58, 0.4); + --nc-danger: #ff7a7a; + --nc-danger-soft: rgba(255, 122, 122, 0.3); + --nc-danger-bg: rgba(255, 122, 122, 0.08); + --nc-success: #5ee5a1; + --nc-clock-bg: rgba(0, 0, 0, 0.4); + --nc-btn-bg: rgba(255, 255, 255, 0.03); + --nc-btn-hover-bg: rgba(255, 255, 255, 0.07); + --nc-seg-bg: rgba(0, 0, 0, 0.3); + --nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --nc-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace; +} +/* ============================================================ + LIGHT MODE TOKEN OVERRIDES +============================================================ */ +:host-context(html:not([data-theme='dark'])) { + --nc-neon: #c026d3; + --nc-neon-soft: rgba(192, 38, 211, 0.45); + --nc-neon-clock-bg: rgba(192, 38, 211, 0.07); + --nc-bg: #f5f0fc; + --nc-surface: rgba(255, 255, 255, 0.88); + --nc-surface-solid: rgba(255, 255, 255, 0.98); + --nc-text: #0f0022; + --nc-text-muted: rgba(15, 0, 34, 0.65); + --nc-text-dim: rgba(15, 0, 34, 0.40); + --nc-border: rgba(15, 0, 34, 0.10); + --nc-border-strong: rgba(15, 0, 34, 0.20); + --nc-warning: #d97706; + --nc-warning-soft: rgba(217, 119, 6, 0.35); + --nc-danger: #dc2626; + --nc-danger-soft: rgba(220, 38, 38, 0.25); + --nc-danger-bg: rgba(220, 38, 38, 0.06); + --nc-success: #059669; + --nc-clock-bg: rgba(0, 0, 0, 0.04); + --nc-btn-bg: rgba(0, 0, 0, 0.03); + --nc-btn-hover-bg: rgba(0, 0, 0, 0.06); + --nc-seg-bg: rgba(0, 0, 0, 0.06); +} + +/* ============================================================ + SHELL & AMBIENT BG +============================================================ */ .game-shell { min-height: 100dvh; - padding: clamp(var(--size-md), 2vw, var(--size-xl)); - background: linear-gradient(180deg, var(--color-primary-light) 0%, var(--color-secondary-mint) 100%); - color: var(--color-text-primary); + background: var(--nc-bg); + font-family: var(--nc-sans); + color: var(--nc-text); + position: relative; } -:host-context(html[data-theme='dark']) .game-shell { +.game-shell::before { + content: ""; + position: fixed; + inset: 0; background: - radial-gradient(circle at top, rgba(185, 194, 218, 0.16) 0%, transparent 35%), - linear-gradient(180deg, #0f1f2e 0%, #17293d 52%, #0b1420 100%); + radial-gradient(ellipse 80% 50% at 20% 100%, rgba(212, 77, 74, 0.08), transparent 60%), + radial-gradient(ellipse 60% 40% at 90% 0%, rgba(74, 41, 98, 0.18), transparent 60%); + pointer-events: none; + z-index: 0; } -.game-card { - max-width: 1400px; +:host-context(html:not([data-theme='dark'])) .game-shell::before { + background: + radial-gradient(ellipse 80% 50% at 20% 100%, rgba(192, 38, 211, 0.06), transparent 60%), + radial-gradient(ellipse 60% 40% at 90% 0%, rgba(120, 40, 180, 0.08), transparent 60%); +} + +/* ============================================================ + PAGE CONTAINER +============================================================ */ +.page { + position: relative; + z-index: 1; + max-width: 1320px; margin: 0 auto; - background: var(--color-bg-main); - border: var(--border-width) solid var(--color-border); - border-radius: var(--border-radius-lg); - padding: clamp(var(--size-lg), 2vw, var(--size-xl)); - box-shadow: var(--shadow-md); + padding: 28px 32px 60px; } -:host-context(html[data-theme='dark']) .game-shell .game-card { - background: rgba(26, 47, 71, 0.88); - box-shadow: 0 12px 28px rgba(0, 0, 0, 0.34); +/* ============================================================ + BREADCRUMB +============================================================ */ +.crumb { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 18px; + font-family: var(--nc-mono); + font-size: 11px; + letter-spacing: 0.16em; + text-transform: uppercase; } -header { - margin-bottom: var(--size-xl); - margin-bottom: var(--size-xl); -} - -h1, -h2 { - color: var(--color-text-primary); - margin: 0 0 var(--size-md); - font-size: var(--heading-h1); - color: var(--color-text-primary); - margin: 0 0 var(--size-md); - font-size: var(--heading-h1); -} - -.meta { - color: var(--color-text-primary); - color: var(--color-text-primary); - font-size: 0.95rem; -} - -.back-link { - display: inline-block; - margin-bottom: var(--size-sm); - color: var(--color-text-primary); - margin-bottom: var(--size-sm); - color: var(--color-text-primary); +.crumb-link { + color: var(--nc-text-dim); text-decoration: none; - font-weight: 600; -} - -.back-link:hover { - text-decoration: underline; -} - -.top-section { - display: grid; - gap: var(--size-md); - margin-top: var(--size-sm); - flex: 0 0 auto; -} - -.board-theme-card { - background: var(--color-bg-card); - border: var(--border-width) solid var(--color-border); - border-radius: var(--border-radius-md); - padding: var(--size-md-padding); - display: grid; - gap: var(--size-sm); -} - -.board-theme-card h3 { - margin: 0; - color: var(--color-text-primary); - font-size: 1rem; -} - -.board-theme-group { - display: flex; - gap: var(--size-md); - flex-wrap: wrap; -} - -.board-theme-option { display: inline-flex; align-items: center; - gap: var(--size-xs); - color: var(--color-text-primary); - font-weight: 600; + gap: 6px; + transition: color 0.15s; } -.board-theme-option input { - accent-color: var(--color-primary); -} +.crumb-link:hover { color: var(--nc-neon); } +.crumb-sep { color: var(--nc-text-dim); opacity: 0.5; } +.crumb-current { color: var(--nc-text-muted); } -.move-card { - padding: var(--size-lg-padding); -} - -.move-card .btn { - align-self: flex-start; - width: auto; -} - -.center-column { - width: 100%; -} - -.board-section { - background: var(--color-bg-board); - border: var(--border-width) solid var(--color-border); - border-radius: var(--border-radius-md); - padding: clamp(var(--size-sm), 1vw, var(--size-lg)); - background: var(--color-bg-board); - border: var(--border-width) solid var(--color-border); - border-radius: var(--border-radius-md); - padding: clamp(var(--size-sm), 1vw, var(--size-lg)); - min-height: 400px; - container-type: size; -} - -:host-context(html[data-theme='dark']) .game-shell .board-section, -:host-context(html[data-theme='dark']) .game-shell .timer-card, -:host-context(html[data-theme='dark']) .game-shell .history-card, -:host-context(html[data-theme='dark']) .game-shell .export-card, -:host-context(html[data-theme='dark']) .game-shell .board-theme-card, -:host-context(html[data-theme='dark']) .game-shell .player-timer { - background: rgba(45, 74, 111, 0.72); -} - -:host-context(html[data-theme='dark']) .game-shell .export-text { - background: rgba(26, 47, 71, 0.9); -} - -:host-context(html[data-theme='dark']) .game-shell .game-completion-alert { - background: linear-gradient(135deg, rgba(74, 124, 124, 0.35) 0%, rgba(90, 111, 165, 0.35) 100%); - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.25); -} - -.timer-card, -.history-card, -.export-card { - background: var(--color-bg-card); - border: var(--border-width) solid var(--color-border); - border-radius: var(--border-radius-md); - padding: var(--size-lg-padding); - display: grid; - gap: var(--size-md); -} - -.timer-card h2, -.history-card h2, -.export-card h2 { - margin: 0; - font-size: 1.1rem; - color: var(--color-text-primary); -} - -.history-list { - margin: 0; - padding-left: 1.1rem; - display: grid; - gap: var(--size-xs); - max-height: 180px; - overflow: auto; -} - -.history-list li { - color: var(--color-text-primary); +/* ============================================================ + GAME HEADER +============================================================ */ +.game-header { display: flex; - gap: var(--size-sm); - align-items: baseline; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 28px; + padding-bottom: 20px; + border-bottom: 1px solid var(--nc-border); } -.history-number { - font-weight: 700; - min-width: 1.8rem; -} - -.history-move { - font-family: monospace; -} - -.history-empty { - margin: 0; - color: var(--color-text-primary); -} - -.player-timer { - background: var(--color-bg-input); - border: var(--border-width) solid var(--color-border); - border-radius: var(--border-radius-md); - padding: var(--size-md-padding); -} - -.active-timer { - box-shadow: 0 0 0 3px rgba(185, 218, 209, 0.25); -} - -.timer-label { - margin: 0; - color: var(--color-text-primary); - font-weight: 600; -} - -.timer-value { - margin: var(--size-xs) 0 0; - color: var(--color-text-primary); - font-size: 1.35rem; - font-weight: 700; -} - -.export-mode-group { +.game-title { display: flex; - gap: var(--size-lg); - flex-wrap: wrap; + flex-direction: column; + gap: 8px; } -.export-mode-option { +.game-title h1 { + margin: 0; + font-size: 26px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--nc-text); display: inline-flex; align-items: center; - gap: var(--size-sm); - color: var(--color-text-primary); - font-weight: 600; + gap: 14px; } -.export-mode-option input { - accent-color: var(--color-primary); +.tag-rated { + font-family: var(--nc-mono); + font-size: 10px; + letter-spacing: 0.22em; + color: var(--nc-neon); + border: 1px solid var(--nc-neon-soft); + padding: 4px 10px; + text-transform: uppercase; } -.export-text { - width: 100%; - min-height: 140px; - border: var(--border-width) solid var(--color-border); - border-radius: var(--border-radius-md); - background: var(--color-bg-input); - color: var(--color-text-primary); - padding: var(--size-md-padding); - resize: vertical; +.game-meta-strip { + display: flex; + align-items: center; + gap: 14px; + font-family: var(--nc-mono); + font-size: 11px; + color: var(--nc-text-dim); + letter-spacing: 0.06em; } -.export-button { - width: fit-content; - border: var(--button-border); - border-radius: var(--button-radius); - background: var(--color-bg-button); - color: var(--color-text-primary); - font-weight: 700; - padding: var(--button-padding); +.game-id { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--nc-text-muted); +} + +.game-id strong { color: var(--nc-text); font-weight: 500; } + +.meta-dot { + width: 3px; + height: 3px; + background: var(--nc-text-dim); + border-radius: 50%; + flex-shrink: 0; +} + +.copy-btn { + background: transparent; + border: none; + color: var(--nc-text-dim); cursor: pointer; + padding: 2px 4px; + display: inline-flex; + transition: color 0.15s; } -.export-button:hover { - background: var(--color-bg-button-hover); - color: var(--color-text-button-hover); -} +.copy-btn:hover { color: var(--nc-neon); } -.export-note { - margin: 0; - color: var(--color-text-primary); +.header-actions { display: flex; gap: 8px; align-items: center; } + +/* ============================================================ + BUTTONS +============================================================ */ +.btn { + font-family: var(--nc-sans); + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; font-weight: 600; + padding: 9px 14px; + cursor: pointer; + border: 1px solid var(--nc-border-strong); + background: var(--nc-btn-bg); + color: var(--nc-text); + display: inline-flex; + align-items: center; + gap: 6px; + transition: background 0.15s, border-color 0.15s; } -.alert { - border-radius: var(--border-radius-sm); - border: var(--border-width) solid var(--color-border); +.btn:hover { + background: var(--nc-btn-hover-bg); + border-color: var(--nc-text-muted); } -.game-completion-alert { - background: linear-gradient(135deg, var(--color-secondary-mint, #B9DAD1) 0%, var(--color-secondary-blue, #B9C2DA) 100%); - border: 2px solid var(--color-secondary-mint, #B9DAD1) !important; - border-radius: var(--border-radius-lg) !important; - padding: var(--size-xl-padding) !important; - box-shadow: 0 8px 16px rgba(185, 218, 209, 0.3); - animation: slideIn 0.4s ease-out; +.btn-primary { + background: var(--nc-neon) !important; + color: #fff !important; + border-color: var(--nc-neon) !important; + box-shadow: 0 0 14px rgba(255, 69, 200, 0.3); + font-weight: 700; + padding: 9px 14px; + flex-shrink: 0; +} + +.btn-primary:hover { box-shadow: 0 0 20px rgba(255, 69, 200, 0.5); } + +.btn-ghost { + background: transparent; + border: none; + color: var(--nc-text-muted); + padding: 8px 12px; + font-size: 12px; + letter-spacing: 0.04em; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--nc-sans); + transition: color 0.15s; +} + +.btn-ghost:hover { color: var(--nc-neon); } + +/* ============================================================ + STATE MESSAGES (loading / error) +============================================================ */ +.state-message { + display: flex; + align-items: center; + gap: 10px; + padding: 20px; + font-family: var(--nc-mono); + font-size: 13px; + color: var(--nc-text-muted); + letter-spacing: 0.06em; +} + +.state-error { + color: var(--nc-danger); + background: var(--nc-danger-bg); + border: 1px solid var(--nc-danger-soft); +} + +/* ============================================================ + COMPLETION BANNER +============================================================ */ +.completion-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 20px; + margin-bottom: 24px; + background: rgba(255, 69, 200, 0.06); + border: 1px solid var(--nc-neon-soft); + animation: slideIn 0.35s ease-out; } @keyframes slideIn { - from { - opacity: 0; - transform: translateY(-20px); - } - to { - opacity: 1; - transform: translateY(0); - } + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } } -.completion-title { - color: var(--color-text-primary); - font-size: 1.75rem; - margin: 0 0 var(--size-md) 0; - font-weight: 700; - text-align: center; -} - -.completion-subtitle { - text-align: center; - color: var(--color-text-primary); - font-size: 1rem; -} +.completion-title { font-size: 16px; font-weight: 600; color: var(--nc-neon); } .completion-link { - color: var(--color-text-primary); + font-family: var(--nc-mono); + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--nc-text-muted); text-decoration: none; - font-weight: 600; - border-bottom: 2px solid var(--color-text-primary); - transition: all 0.3s ease; + border-bottom: 1px solid var(--nc-border-strong); padding-bottom: 2px; + transition: color 0.15s, border-color 0.15s; + flex-shrink: 0; } -.completion-link:hover { - color: var(--color-secondary-blue); - border-bottom-color: var(--color-secondary-blue); +.completion-link:hover { color: var(--nc-neon); border-color: var(--nc-neon-soft); } + +/* ============================================================ + MAIN GRID +============================================================ */ +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 28px; + align-items: start; } -@media (max-width: 991px) { - .game-card { - padding: clamp(var(--size-md), 1.5vw, var(--size-lg)); - padding: clamp(var(--size-md), 1.5vw, var(--size-lg)); - } - - .board-section { - min-height: 350px; - } - - h1, - h2 { - font-size: var(--heading-h1-tablet); - font-size: var(--heading-h1-tablet); - } +/* ============================================================ + BOARD COLUMN +============================================================ */ +.board-col { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 520px; + width: 100%; + margin: 0 auto; } -@media (max-width: 768px) { - .game-shell { - padding: clamp(var(--size-sm), 1.5vw, var(--size-lg)); - padding: clamp(var(--size-sm), 1.5vw, var(--size-lg)); - } - - .game-card { - padding: clamp(var(--size-sm), 1vw, var(--size-md)); - padding: clamp(var(--size-sm), 1vw, var(--size-md)); - } - - header { - margin-bottom: var(--size-lg); - margin-bottom: var(--size-lg); - } - - h1, - h2 { - font-size: var(--heading-h1-mobile); - font-size: var(--heading-h1-mobile); - } - - .meta { - font-size: 0.85rem; - } - - .top-section { - gap: var(--size-xs); - margin-bottom: var(--size-xs); - gap: var(--size-xs); - margin-bottom: var(--size-xs); - } - - .board-section { - min-height: 300px; - } +.status-strip { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: rgba(255, 69, 200, 0.05); + border: 1px solid rgba(255, 69, 200, 0.15); + font-size: 12px; } -@media (max-width: 480px) { - .game-shell { - padding: var(--size-sm); - padding: var(--size-sm); - } - - .game-card { - padding: var(--size-sm); - border-radius: var(--border-radius-md); - padding: var(--size-sm); - border-radius: var(--border-radius-md); - } - - header { - margin-bottom: var(--size-md); - margin-bottom: var(--size-md); - } - - h1 { - font-size: var(--heading-h1-small); - font-size: var(--heading-h1-small); - } - - .meta { - font-size: 0.75rem; - } - - .top-section { - gap: var(--size-xs-gap); - margin-bottom: var(--size-xs); - gap: var(--size-xs-gap); - margin-bottom: var(--size-xs); - } - - .board-section { - min-height: 250px; - } +:host-context(html:not([data-theme='dark'])) .status-strip { + background: rgba(192, 38, 211, 0.04); + border-color: rgba(192, 38, 211, 0.18); +} + +.status-left { display: inline-flex; align-items: center; gap: 10px; } + +.status-pulse { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--nc-neon); + box-shadow: 0 0 6px var(--nc-neon); + animation: pulse 1.8s ease-in-out infinite; + flex-shrink: 0; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.35; transform: scale(0.7); } +} + +.status-text { font-weight: 500; color: var(--nc-text); letter-spacing: 0.02em; } + +.status-side { + font-family: var(--nc-mono); + font-size: 10px; + letter-spacing: 0.14em; + color: var(--nc-text-dim); +} + +/* container-type + aspect-ratio give cqw/cqh a defined size for the chess-board component */ +.board-wrap { + container-type: size; + aspect-ratio: 1 / 1; + padding: 10px; + background: var(--nc-surface); + border: 1px solid var(--nc-border); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 69, 200, 0.06); +} + +:host-context(html:not([data-theme='dark'])) .board-wrap { + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.1); +} + +/* ============================================================ + SIDE COLUMN +============================================================ */ +.side { display: flex; flex-direction: column; gap: 12px; } + +.side-card { + background: var(--nc-surface); + border: 1px solid var(--nc-border); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.side-card-summary { + list-style: none; + cursor: pointer; + padding: 13px 16px; + display: flex; + align-items: center; + gap: 8px; + user-select: none; +} + +.side-card-summary::-webkit-details-marker { display: none; } + +.side-card-title { + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--nc-text-muted); + font-weight: 600; + flex: 1; +} + +.side-card-meta { + font-family: var(--nc-mono); + font-size: 10px; + color: var(--nc-text-dim); + letter-spacing: 0.08em; +} + +.chev { color: var(--nc-text-dim); flex-shrink: 0; transition: transform 0.2s; } +.side-card[open] .chev { transform: rotate(180deg); } +.side-card[open] .side-card-summary { border-bottom: 1px solid var(--nc-border); } + +.side-card-body { + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +/* ============================================================ + UCI INPUT +============================================================ */ +.uci-row { display: flex; gap: 6px; } + +.uci-input { + flex: 1; + background: var(--nc-clock-bg); + border: 1px solid var(--nc-border); + color: var(--nc-text); + font-family: var(--nc-mono); + font-size: 13px; + padding: 9px 12px; + letter-spacing: 0.04em; + outline: none; + transition: border-color 0.15s; +} + +.uci-input:focus { border-color: var(--nc-neon-soft); } +.uci-input::placeholder { color: var(--nc-text-dim); } + +.uci-hint { margin: 0; font-size: 11px; color: var(--nc-text-dim); line-height: 1.4; } + +/* ============================================================ + BOARD DESIGN SEGMENTED CONTROL +============================================================ */ +.seg { + display: flex; + border: 1px solid var(--nc-border); + padding: 2px; + background: var(--nc-seg-bg); +} + +.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; } + +/* ============================================================ + TOAST +============================================================ */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--nc-surface-solid); + border: 1px solid var(--nc-neon-soft); + color: var(--nc-text); + padding: 10px 18px; + font-size: 12px; + font-family: var(--nc-mono); + letter-spacing: 0.08em; + z-index: 500; + opacity: 0; + transition: opacity 0.2s, transform 0.2s; + pointer-events: none; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } + +/* ============================================================ + RESPONSIVE +============================================================ */ +@media (max-width: 1100px) { + .layout { grid-template-columns: 1fr; } + .board-col { max-width: 560px; margin: 0 auto; } +} + +@media (max-width: 640px) { + .page { padding: 16px 16px 48px; } + .game-header { flex-direction: column; align-items: flex-start; gap: 12px; } + .game-title h1 { font-size: 20px; } } diff --git a/src/app/pages/game/game.component.html b/src/app/pages/game/game.component.html index e1d8167..07c4593 100644 --- a/src/app/pages/game/game.component.html +++ b/src/app/pages/game/game.component.html @@ -1,129 +1,206 @@ -
- + -
-
- Back -

1 vs 1 Game

-

Game ID: {{ facade.gameId }}

-
+
+
- @if (facade.loading) { -

Loading game state...

- } @else if (facade.state) { - @if (facade.isGameFinished && facade.gameCompletionMessage) { -
-

{{ facade.gameCompletionMessage }}

-

- Start a new game -

-
- } -
-
- - @if (hasTimer) { -
-
-

Timers

-
-

White

-

{{ formatTimer(whiteTimerMs) }}

-
-
-

Black

-

{{ formatTimer(blackTimerMs) }}

-
-
-
- } + + - -
-
-
- -
- -
-
-

Board Design

-
- - - -
-
- - -
-
-
- - -
-
-

Move History

- - @if (facade.state.moves.length === 0) { -

No moves yet.

- } @else { -
    - @for (move of facade.state.moves; track $index) { -
  1. - {{ $index + 1 }}. - {{ move }} -
  2. - } -
- } -
- -
-

Export

-
- - -
- - - - - - @if (exportNotice) { -

{{ exportNotice }}

- } -
+ +
+
+

+ 1 vs 1 Game + @if (facade.game) { + Live + } +

+
+ + ID {{ facade.gameId }} + + + @if (facade.state) { + + Move {{ moveNumber }} + }
-
+ +
+ + +
+ + + + @if (facade.loading) { +
+ + Loading game… +
+ } @else if (facade.state) { + @if (facade.errorMessage) { +
{{ facade.errorMessage }}
+ } + + + @if (facade.isGameFinished && facade.gameCompletionMessage) { +
+ {{ facade.gameCompletionMessage }} + Start new game +
+ } + + +
+ + +
+ + + + + +
+
+ + +
+ + {{ facade.state.turn === 'white' ? 'WHITE' : 'BLACK' }} TO MOVE + +
+ + +
+ +
+ + + + + + + +
+ + + +
} - @if (facade.errorMessage) { -

{{ facade.errorMessage }}

- } -
-
\ No newline at end of file + + + + +@if (toastMessage) { +
{{ toastMessage }}
+} diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts index fa12690..18b68c4 100644 --- a/src/app/pages/game/game.component.ts +++ b/src/app/pages/game/game.component.ts @@ -1,44 +1,113 @@ -import { CommonModule } from '@angular/common'; import { Component, DestroyRef, OnDestroy, OnInit, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; import { ActivatedRoute, RouterLink } from '@angular/router'; +import { BoardActionsBarComponent } from '../../components/board-actions-bar/board-actions-bar.component'; import { ChessBoardComponent } from '../../components/chess-board/chess-board.component'; -import { InputCardComponent } from '../../components/input-card/input-card.component'; +import { ExportPanelComponent } from '../../components/export-panel/export-panel.component'; +import { MoveHistoryComponent, MoveNavDirection } from '../../components/move-history/move-history.component'; +import { PlayerCardComponent } from '../../components/player-card/player-card.component'; import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component'; import { GameFacade } from './game.facade'; type BoardTheme = 'arabian' | 'classic'; +const LOW_TIME_THRESHOLD_MS = 60_000; +const BOARD_THEME_KEY = 'nowchess.boardTheme'; + @Component({ selector: 'app-game', standalone: true, - imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent, InputCardComponent, PromotionDialogComponent], + imports: [ + RouterLink, + ChessBoardComponent, + PromotionDialogComponent, + PlayerCardComponent, + MoveHistoryComponent, + ExportPanelComponent, + BoardActionsBarComponent, + ], providers: [GameFacade], templateUrl: './game.component.html', styleUrl: './game.component.css' }) export class GameComponent implements OnInit, OnDestroy { - private static readonly BOARD_THEME_STORAGE_KEY = 'nowchess.boardTheme'; private readonly route = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); readonly facade = inject(GameFacade); + whiteTimerMs: number | null = null; blackTimerMs: number | null = null; - exportType: 'fen' | 'pgn' = 'fen'; boardTheme: BoardTheme = 'arabian'; - isDarkMode = false; - exportValue = ''; - exportNotice = ''; - private timerIntervalId: number | null = null; + flipped = false; + toastMessage = ''; - get hasTimer(): boolean { - return this.facade.state?.clock != null; + private timerIntervalId: number | null = null; + private toastTimer: ReturnType | null = null; + + // ── Player display ────────────────────────────────────────── + get whitePlayerName(): string { + return this.facade.game?.white.displayName ?? 'White'; } + get blackPlayerName(): string { + return this.facade.game?.black.displayName ?? 'Black'; + } + + get whitePlayerInitial(): string { + return this.whitePlayerName.charAt(0).toUpperCase(); + } + + get blackPlayerInitial(): string { + return this.blackPlayerName.charAt(0).toUpperCase(); + } + + // ── Clocks ────────────────────────────────────────────────── + get whiteClock(): string { + return this.formatTimer(this.whiteTimerMs); + } + + get blackClock(): string { + return this.formatTimer(this.blackTimerMs); + } + + get isLowTimeWhite(): boolean { + return this.whiteTimerMs !== null && this.whiteTimerMs < LOW_TIME_THRESHOLD_MS; + } + + get isLowTimeBlack(): boolean { + return this.blackTimerMs !== null && this.blackTimerMs < LOW_TIME_THRESHOLD_MS; + } + + // ── Status message ─────────────────────────────────────────── + get statusMessage(): string { + const state = this.facade.state; + if (!state) return ''; + + if (state.status === 'check') { + const who = state.turn === 'white' ? 'White' : 'Black'; + return `${who} is in check`; + } + + if (state.status === 'drawOffered') { + return 'Draw offer pending'; + } + + const last = state.moves.length > 0 ? state.moves[state.moves.length - 1] : null; + if (last) { + const mover = state.turn === 'white' ? this.blackPlayerName : this.whitePlayerName; + return `${mover} played ${last}`; + } + + return 'Game started'; + } + + // ── Move number ────────────────────────────────────────────── + get moveNumber(): number { + return Math.ceil((this.facade.state?.moves.length ?? 0) / 2); + } + + // ── Lifecycle ──────────────────────────────────────────────── ngOnInit(): void { - this.applyIncomingTheme(); - this.syncThemeFromDocument(); this.boardTheme = this.resolveStoredBoardTheme(); this.startClock(); @@ -49,9 +118,7 @@ export class GameComponent implements OnInit, OnDestroy { this.facade.loading = false; return; } - this.facade.setGameId(id); - this.syncExportValue(); }); } @@ -61,74 +128,57 @@ export class GameComponent implements OnInit, OnDestroy { } } - private syncThemeFromDocument(): void { - this.isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark'; - } - - private applyIncomingTheme(): void { - const incomingTheme = window.history.state?.theme; - if (incomingTheme === 'dark') { - document.documentElement.setAttribute('data-theme', 'dark'); - localStorage.setItem('theme', 'dark'); - return; - } - - if (incomingTheme === 'light') { - document.documentElement.removeAttribute('data-theme'); - localStorage.removeItem('theme'); - } - } - - setExportType(type: 'fen' | 'pgn'): void { - this.exportType = type; - this.exportNotice = ''; - this.syncExportValue(); - } - + // ── Board theme ────────────────────────────────────────────── setBoardTheme(theme: BoardTheme): void { this.boardTheme = theme; - localStorage.setItem(GameComponent.BOARD_THEME_STORAGE_KEY, theme); + localStorage.setItem(BOARD_THEME_KEY, theme); } - completeExport(): void { - this.syncExportValue(); - if (!this.exportValue.trim()) { - this.exportNotice = 'Nothing to export yet.'; - return; - } - - if (!navigator.clipboard?.writeText) { - this.exportNotice = 'Export is ready in the text box.'; - return; - } - - void navigator.clipboard - .writeText(this.exportValue) - .then(() => { - this.exportNotice = 'Copied to clipboard.'; - }) - .catch(() => { - this.exportNotice = 'Export is ready in the text box.'; - }); + // ── Board flip ─────────────────────────────────────────────── + flipBoard(): void { + this.flipped = !this.flipped; } - formatTimer(ms: number | null): string { - if (ms === null) { - return '--:--'; - } - if (ms < 0) { - return '—'; - } + // ── Copy helpers ───────────────────────────────────────────── + copyGameId(): void { + void navigator.clipboard?.writeText(this.facade.gameId).then(() => this.showToast('Game ID copied')); + } + + copyUrl(): void { + void navigator.clipboard?.writeText(window.location.href).then(() => this.showToast('Link copied')); + } + + // ── Board actions ───────────────────────────────────────────── + onTakeback(): void { + this.showToast('Takeback requested'); + } + + onOfferDraw(): void { + this.showToast('Draw offered'); + } + + onResign(): void { + this.showToast('Resigned'); + } + + // ── Move history navigation ─────────────────────────────────── + onMoveNavigate(_direction: MoveNavDirection): void { + // Visual-only for now; board always reflects live position. + } + + // ── Timer helpers ───────────────────────────────────────────── + private formatTimer(ms: number | null): string { + if (ms === null) return '--:--'; + if (ms < 0) return '—'; const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0'); const seconds = (totalSeconds % 60).toString().padStart(2, '0'); return `${minutes}:${seconds}`; } + // ── Private ─────────────────────────────────────────────────── private startClock(): void { - if (this.timerIntervalId !== null) { - return; - } + if (this.timerIntervalId !== null) return; this.timerIntervalId = window.setInterval(() => this.tickClock(), 200); } @@ -143,25 +193,24 @@ export class GameComponent implements OnInit, OnDestroy { const elapsed = Math.max(0, Date.now() - this.facade.clockSyncedAt); const activeIsWhite = state!.turn === 'white'; - this.whiteTimerMs = - clock.whiteRemainingMs < 0 ? -1 : Math.max(0, clock.whiteRemainingMs - (activeIsWhite ? elapsed : 0)); - this.blackTimerMs = - clock.blackRemainingMs < 0 ? -1 : Math.max(0, clock.blackRemainingMs - (!activeIsWhite ? elapsed : 0)); - this.syncExportValue(); + this.whiteTimerMs = clock.whiteRemainingMs < 0 + ? -1 + : Math.max(0, clock.whiteRemainingMs - (activeIsWhite ? elapsed : 0)); + this.blackTimerMs = clock.blackRemainingMs < 0 + ? -1 + : Math.max(0, clock.blackRemainingMs - (!activeIsWhite ? elapsed : 0)); } - private syncExportValue(): void { - const state = this.facade.state; - if (!state) { - this.exportValue = ''; - return; - } - - this.exportValue = this.exportType === 'fen' ? state.fen : state.pgn; + private showToast(msg: string): void { + this.toastMessage = msg; + if (this.toastTimer !== null) clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => { + this.toastMessage = ''; + }, 1800); } private resolveStoredBoardTheme(): BoardTheme { - const stored = localStorage.getItem(GameComponent.BOARD_THEME_STORAGE_KEY); + const stored = localStorage.getItem(BOARD_THEME_KEY); return stored === 'classic' ? 'classic' : 'arabian'; } } diff --git a/src/app/pages/game/game.facade.ts b/src/app/pages/game/game.facade.ts index 90f1ca8..24bbfed 100644 --- a/src/app/pages/game/game.facade.ts +++ b/src/app/pages/game/game.facade.ts @@ -226,9 +226,7 @@ export class GameFacade implements OnDestroy { this.streamService.startStreaming( this.gameId, (event) => this.applyStreamEvent(event), - () => { - this.errorMessage = 'Live stream disconnected. Falling back to polling.'; - } + () => { /* polling fallback — not an error */ } ); } diff --git a/src/app/pages/welcome/Game (1).html b/src/app/pages/welcome/Game (1).html new file mode 100644 index 0000000..e9ba7a5 --- /dev/null +++ b/src/app/pages/welcome/Game (1).html @@ -0,0 +1,1065 @@ + + + + + +NowChess — Game + + + + + + + + + + + + +
+ + + + + +
+
+

+ Rapid · 10|0 + Rated +

+
+ + ID bDc1rDUF + + + + Move 3 + + Started 4m ago +
+
+
+ + +
+
+ + +
+ + +
+ + +
+
M
+
+
+ magnus_42 + 1840 +
+
+ + +
+
+
09:42
+
+ + +
+
+ + Your turn — magnus_42 played h2h4 +
+ YOU PLAY WHITE +
+ + +
+
+
+ + +
+
S
+
+
+ Sha (you) + 1842 +
+
+ +
+
+
09:38
+
+ + +
+ + + +
+ +
+ + + + +
+
+ + +
Copied
+ + + + + diff --git a/src/app/services/game-api.service.ts b/src/app/services/game-api.service.ts index c362f58..833b747 100644 --- a/src/app/services/game-api.service.ts +++ b/src/app/services/game-api.service.ts @@ -77,8 +77,7 @@ export class GameApiService { } streamGame(gameId: string): Observable { - const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/stream`; - const fallbackUrl = `${this.apiBase}${this.apiPath}/${gameId}/stream`; - return this.streamHandler.createGameStream(wsUrl, fallbackUrl, gameId); + const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`; + return this.streamHandler.createGameStream(wsUrl, gameId); } } diff --git a/src/app/services/stream-handler.service.ts b/src/app/services/stream-handler.service.ts index c41bde6..520a7fe 100644 --- a/src/app/services/stream-handler.service.ts +++ b/src/app/services/stream-handler.service.ts @@ -2,26 +2,14 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { GameStreamEvent, ErrorEvent } from '../models/game.models'; +const WS_CONNECT_TIMEOUT_MS = 3000; + @Injectable({ providedIn: 'root' }) export class StreamHandlerService { - createGameStream(wsUrl: string, fallbackUrl: string, gameId: string): Observable { + createGameStream(wsUrl: string, gameId: string): Observable { return new Observable((observer) => { const ws = new WebSocket(wsUrl); - const abortController = new AbortController(); let connected = false; - let fallbackActive = false; - - const parseEvent = (raw: string): GameStreamEvent | null => { - if (!raw.trim()) { - return null; - } - - try { - return JSON.parse(raw) as GameStreamEvent; - } catch { - return null; - } - }; const emitErrorEvent = (message: string): void => { const errorEvent: ErrorEvent = { @@ -31,67 +19,18 @@ export class StreamHandlerService { observer.next(errorEvent); }; - const startNdjsonFallback = async (): Promise => { - if (fallbackActive) { - return; - } - - fallbackActive = true; - console.log(`[StreamHandler] NDJSON fallback started for ${gameId}, URL:`, fallbackUrl); - - try { - const response = await fetch(fallbackUrl, { - headers: { Accept: 'application/x-ndjson' }, - signal: abortController.signal - }); - - if (!response.ok || !response.body) { - console.error(`[StreamHandler] NDJSON fetch failed: HTTP ${response.status}`); - emitErrorEvent(`Unable to open stream: HTTP ${response.status}`); - observer.complete(); - return; - } - - console.log(`[StreamHandler] NDJSON stream connected for ${gameId}`); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { value, done } = await reader.read(); - if (done) { - break; - } - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const event = parseEvent(line); - if (event) { - observer.next(event); - } - } - } - - observer.complete(); - } catch (error) { - if ((error as Error).name !== 'AbortError') { - emitErrorEvent((error as Error).message); - observer.error(error); - } - } + const failAndComplete = (reason: string): void => { + console.warn(`[StreamHandler] WebSocket failed for ${gameId}: ${reason}`); + emitErrorEvent(reason); + observer.complete(); }; - // Set timeout to fallback if WebSocket doesn't connect quickly const connectionTimeoutId = setTimeout(() => { - if (!connected && !fallbackActive) { - console.warn(`[StreamHandler] WebSocket timeout for ${gameId}, attempting NDJSON fallback`); + if (!connected) { ws.close(); - void startNdjsonFallback(); + failAndComplete('WebSocket connection timed out — falling back to polling'); } - }, 3000); + }, WS_CONNECT_TIMEOUT_MS); ws.onopen = () => { connected = true; @@ -101,35 +40,30 @@ export class StreamHandlerService { ws.onmessage = (message) => { const payload = typeof message.data === 'string' ? message.data : ''; - const event = parseEvent(payload); - if (event) { + if (!payload.trim()) return; + try { + const event = JSON.parse(payload) as GameStreamEvent; observer.next(event); + } catch { + // ignore malformed frames } }; - ws.onerror = (error) => { - console.warn(`[StreamHandler] WebSocket error for ${gameId}:`, error); + ws.onerror = () => { clearTimeout(connectionTimeoutId); - if (!connected && !fallbackActive) { - void startNdjsonFallback(); + if (!connected) { + failAndComplete('WebSocket connection error — falling back to polling'); } }; ws.onclose = () => { clearTimeout(connectionTimeoutId); - console.warn(`[StreamHandler] WebSocket closed for ${gameId}, connected=${connected}`); if (connected) { - // Connection was established but closed, stream is complete observer.complete(); - } else if (!fallbackActive) { - // Connection never established, try fallback - console.log(`[StreamHandler] Starting NDJSON fallback for ${gameId}`); - void startNdjsonFallback(); } }; return () => { - abortController.abort(); ws.close(); }; }); diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 758863f..fa74ddd 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -2,7 +2,7 @@ export const environment = { production: false, apiBaseUrl: '', accountServiceUrl: '', - wsBaseUrl: 'ws://localhost:8080', + wsBaseUrl: '', userWsBaseUrl: 'ws://localhost:8084', apiPath: '/api/board/game' }; diff --git a/src/index.html b/src/index.html index 25cc5c8..53e217f 100644 --- a/src/index.html +++ b/src/index.html @@ -8,7 +8,7 @@ - +