From 8e2afb93f3ead7463ac9bd6a9cf1dd1c369daf6b Mon Sep 17 00:00:00 2001 From: Janis Eccarius Date: Wed, 10 Jun 2026 15:55:14 +0200 Subject: [PATCH] feat: NCWF-5/6/7/8/9 chess analysis page and engine integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NCWF-5: scaffold /analysis route with ChessBoard viewer and navigation - NCWF-6: FEN / PGN / Game-ID input form with depth selector - NCWF-7: extend GameApiService with analyzePosition(); add AnalysisService with game-wide annotation pipeline; proxy /api/analysis -> :8087 - NCWF-8: EvalTimelineComponent — SVG win-chance chart per ply - NCWF-9: AnnotatedMoveListComponent — quality labels (!! ! ?! ? ??) derived from win-chance delta Also fix pre-existing app.spec.ts failure (missing provideHttpClient). Apply project-wide prettier formatting pass. Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/create-defect.md | 5 + .claude/commands/create-story.md | 21 +- .claude/commands/estimate-issues.md | 22 +- .claude/commands/fix-defect.md | 18 +- .claude/commands/implement-feature.md | 5 + .claude/commands/split-story.md | 16 +- CHANGELOG.md | 61 +- angular.json | 17 +- docs/board-api-spec.yaml | 7 +- proxy.conf.json | 5 + public/env.template.js | 4 +- src/app/app.config.ts | 10 +- src/app/app.html | 3 +- src/app/app.routes.ts | 4 +- src/app/app.spec.ts | 3 +- src/app/app.ts | 4 +- src/app/button-template.css | 6 +- .../annotated-move-list.component.css | 108 +++ .../annotated-move-list.component.html | 48 ++ .../annotated-move-list.component.ts | 83 ++ .../board-actions-bar.component.css | 5 +- .../board-actions-bar.component.html | 49 +- .../board-actions-bar.component.ts | 2 +- .../challenge-create-dialog.component.css | 4 +- .../challenge-create-dialog.component.html | 204 ++--- .../challenge-create-dialog.component.ts | 272 ++++--- .../challenge-notification.component.css | 8 +- .../challenge-notification.component.html | 80 +- .../challenge-notification.component.ts | 168 ++-- .../chess-board/chess-board.component.html | 12 +- .../chess-board/chess-board.component.ts | 4 +- .../chess-piece/chess-piece.component.ts | 2 +- .../eval-timeline/eval-timeline.component.css | 53 ++ .../eval-timeline.component.html | 53 ++ .../eval-timeline/eval-timeline.component.ts | 73 ++ .../export-panel/export-panel.component.css | 8 +- .../export-panel/export-panel.component.html | 63 +- .../export-panel/export-panel.component.ts | 2 +- .../input-card/input-card.component.css | 11 +- .../input-card/input-card.component.html | 24 +- .../login-dialog/login-dialog.component.css | 80 +- .../login-dialog/login-dialog.component.html | 24 +- .../login-dialog/login-dialog.component.ts | 6 +- .../move-history/move-history.component.css | 27 +- .../move-history/move-history.component.html | 76 +- .../move-history/move-history.component.ts | 12 +- .../player-card/player-card.component.css | 14 +- .../player-card/player-card.component.html | 6 +- .../player-card/player-card.component.ts | 2 +- .../promotion-dialog.component.css | 4 +- .../promotion-dialog.component.html | 27 +- .../promotion-dialog.component.ts | 4 +- .../register-dialog.component.css | 65 +- .../register-dialog.component.html | 50 +- .../register-dialog.component.ts | 9 +- .../components/toolbar/toolbar.component.css | 263 +++++-- .../components/toolbar/toolbar.component.html | 463 ++++++----- .../components/toolbar/toolbar.component.ts | 63 +- src/app/core/config.loader.ts | 2 +- src/app/models/analysis.models.ts | 25 + src/app/models/challenge.models.ts | 64 +- src/app/pages/analysis/analysis.component.css | 542 +++++++++++++ .../pages/analysis/analysis.component.html | 355 +++++++++ src/app/pages/analysis/analysis.component.ts | 244 ++++++ src/app/pages/bots/bots.component.css | 389 +++++++-- src/app/pages/bots/bots.component.html | 138 +++- src/app/pages/bots/bots.component.ts | 34 +- .../pages/challenges/challenges.component.css | 9 +- .../challenges/challenges.component.html | 190 ++--- .../pages/challenges/challenges.component.ts | 315 ++++---- src/app/pages/game/game.component.css | 210 +++-- src/app/pages/game/game.component.html | 149 +++- src/app/pages/game/game.component.ts | 34 +- src/app/pages/game/game.facade.ts | 55 +- src/app/pages/games/games.component.css | 79 +- src/app/pages/games/games.component.html | 148 +++- src/app/pages/games/games.component.ts | 28 +- src/app/pages/profile/profile.component.css | 85 +- src/app/pages/profile/profile.component.html | 133 ++-- src/app/pages/profile/profile.component.ts | 24 +- .../tournaments/tournaments.component.css | 740 ++++++++++++++---- .../tournaments/tournaments.component.html | 331 +++++--- .../tournaments/tournaments.component.ts | 87 +- src/app/pages/welcome/Auth Dialog.html | 92 ++- src/app/pages/welcome/welcome.component.css | 71 +- src/app/pages/welcome/welcome.component.html | 289 ++++--- src/app/pages/welcome/welcome.component.ts | 71 +- src/app/services/analysis.service.ts | 102 +++ src/app/services/auth-dialog.service.ts | 24 +- src/app/services/auth.interceptor.ts | 4 +- src/app/services/auth.service.ts | 24 +- src/app/services/board-selection.service.ts | 4 +- src/app/services/bot-move.service.ts | 11 +- src/app/services/bot.service.ts | 5 +- src/app/services/challenge-event.service.ts | 114 +-- .../services/challenge-websocket.service.ts | 186 ++--- src/app/services/challenge.service.ts | 63 +- src/app/services/game-api.service.ts | 15 +- src/app/services/game-completion.service.ts | 22 +- src/app/services/game-import.service.ts | 16 +- src/app/services/game-stream.service.ts | 10 +- src/app/services/stream-handler.service.ts | 2 +- src/app/services/theme.service.ts | 50 +- src/app/services/tournament.service.ts | 4 +- src/environments/environment.development.ts | 2 +- src/environments/environment.staging.ts | 2 +- src/environments/environment.ts | 2 +- src/index.html | 31 +- src/main.ts | 3 +- src/styles-variables.css | 56 +- src/styles.css | 42 +- styles.css | 22 +- tsconfig.app.json | 8 +- tsconfig.spec.json | 9 +- versions.env | 4 +- 115 files changed, 6134 insertions(+), 2378 deletions(-) create mode 100644 src/app/components/annotated-move-list/annotated-move-list.component.css create mode 100644 src/app/components/annotated-move-list/annotated-move-list.component.html create mode 100644 src/app/components/annotated-move-list/annotated-move-list.component.ts create mode 100644 src/app/components/eval-timeline/eval-timeline.component.css create mode 100644 src/app/components/eval-timeline/eval-timeline.component.html create mode 100644 src/app/components/eval-timeline/eval-timeline.component.ts create mode 100644 src/app/models/analysis.models.ts create mode 100644 src/app/pages/analysis/analysis.component.css create mode 100644 src/app/pages/analysis/analysis.component.html create mode 100644 src/app/pages/analysis/analysis.component.ts create mode 100644 src/app/services/analysis.service.ts diff --git a/.claude/commands/create-defect.md b/.claude/commands/create-defect.md index 82e19e9..935c7a5 100644 --- a/.claude/commands/create-defect.md +++ b/.claude/commands/create-defect.md @@ -19,6 +19,7 @@ If `$ARGUMENTS` already answers some of these, skip those questions. ## Step 2 — Research (if needed) If the bug involves component logic, a service, or routing: + - Search repo for relevant code (`Grep`/`Bash`) under `src/app/{components,services,models,core,pages}`. - Check `.spec.ts` files for existing coverage of the broken area. - Do NOT guess at root cause. Surface findings before drafting. @@ -56,6 +57,7 @@ Environment / Notes ``` Rules: + - Steps must be minimal and reproducible. - Expected vs actual: concrete and unambiguous. - Omit "Environment / Notes" section if not relevant. @@ -64,6 +66,7 @@ Rules: Show the draft to the user. **Use `AskUserQuestion` tool to ask:** + - Are steps to reproduce complete and accurate? - Severity: Blocker / Critical / Major / Minor / Trivial? - Any related tickets or recent changes to link? @@ -73,6 +76,7 @@ Incorporate feedback. Repeat until user approves. ## Step 5 — Determine Project > **Project routing rules (always apply these):** +> > - Frontend code (UI, UX, web app, components, services) → `NCWF` > - Backend code (game engine, bots, API, services, coordinator) → `NCS` > - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI` @@ -86,6 +90,7 @@ If ambiguous, ask the user. ## Step 6 — Create Issue Call `mcp__youtrack__create_issue` with: + - `project`: determined in Step 5 - `summary`: concise title describing what is broken (≤72 chars, sentence case) - `description`: full formatted defect report from Step 3 (Markdown) diff --git a/.claude/commands/create-story.md b/.claude/commands/create-story.md index 25edb03..8ede476 100644 --- a/.claude/commands/create-story.md +++ b/.claude/commands/create-story.md @@ -19,6 +19,7 @@ If `$ARGUMENTS` already answers some of these, skip those questions. ## Step 2 — Research (if needed) If the topic involves unfamiliar UI flows, component structure, or technical constraints: + - Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`). - Use `WebSearch` if the topic involves Angular APIs, external standards or protocols. - Do NOT guess. Surface findings before drafting. @@ -53,6 +54,7 @@ Implementation Notes ``` Rules: + - User story line: plain English, present tense, from user's perspective. - Acceptance criteria: testable, unambiguous, one condition each. - Implementation notes: optional — only include if there are known constraints, related tickets, or design refs. @@ -61,6 +63,7 @@ Rules: Show the draft to the user. **Use `AskUserQuestion` tool to ask:** + - Are the acceptance criteria complete and correct? - Any implementation constraints to add? - Priority (if known)? @@ -70,6 +73,7 @@ Incorporate feedback. Repeat until user approves. ## Step 5 — Determine Project > **Project routing rules (always apply these):** +> > - Frontend code (UI, UX, web app, components, services) → `NCWF` > - Backend code (game engine, bots, API, services, coordinator) → `NCS` > - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI` @@ -83,6 +87,7 @@ If still ambiguous, ask the user. ## Step 6 — Create Issue Call `mcp__youtrack__create_issue` with: + - `project`: determined in Step 5 - `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case) - `description`: full formatted story from Step 3 (Markdown) @@ -96,14 +101,14 @@ After creation, ask the user (use `AskUserQuestion` if interactive, otherwise in Collect any issue IDs the user mentions. For each, determine the correct relation and call `mcp__youtrack__link_issues`: -| Situation | Relation to use | -|-----------|----------------| -| This story must be done before another | `blocks` | -| Another story must be done before this | `is blocked by` | -| Stories share domain or are related | `relates to` | -| This is a child of an epic/story | `subtask of` | -| This is a parent grouping subtasks | `parent for` | -| This depends on another ticket's output | `depends on` | +| Situation | Relation to use | +| --------------------------------------- | --------------- | +| This story must be done before another | `blocks` | +| Another story must be done before this | `is blocked by` | +| Stories share domain or are related | `relates to` | +| This is a child of an epic/story | `subtask of` | +| This is a parent grouping subtasks | `parent for` | +| This depends on another ticket's output | `depends on` | If the user mentions an issue in the story description or implementation notes (e.g. "see NCS-42", "after NCWF-12 is done"), auto-detect and suggest linking it — confirm before creating the link. diff --git a/.claude/commands/estimate-issues.md b/.claude/commands/estimate-issues.md index cc8ed37..153f696 100644 --- a/.claude/commands/estimate-issues.md +++ b/.claude/commands/estimate-issues.md @@ -7,10 +7,12 @@ This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`. ## Step 1 — Determine Scope **Single-issue mode** (`$ARGUMENTS` set): + - Call `mcp__youtrack__get_issue` on `$ARGUMENTS`. - Proceed with that issue only. **Sprint mode** (`$ARGUMENTS` empty): + - Call `mcp__youtrack__search_issues` with query `project: NCWF Sprints: {current sprint} #Unresolved`. - If query returns 0 results, use `AskUserQuestion` to ask for the sprint name, then retry with `project: NCWF Sprints: {name}`. - Collect all returned issues. @@ -18,6 +20,7 @@ This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`. ## Step 2 — Build Issue Tree For each top-level issue from Step 1: + 1. Fetch full details via `mcp__youtrack__get_issue`: summary, description, acceptance criteria, Type, existing `Zeitschätzung`, linked issues. 2. Identify subtasks from links with relation `subtask of` (i.e. issues where the fetched issue is the parent). 3. Recursively fetch subtasks until all leaves are known. @@ -29,17 +32,18 @@ For each top-level issue from Step 1: ## Step 3 — Estimate Leaf Nodes For each leaf node: + 1. Read: summary, description, acceptance criteria, implementation notes. 2. If scope is unclear, search codebase (`Grep`/`Bash`) under `src/app/` for related files to gauge complexity. 3. Assign estimate using this scale: -| Size | Criteria | Estimate | -|------|----------|----------| -| Trivial | Style tweak, copy change, 1-file tweak | 30m | -| Small | 1–3 files, single component/service, no unknowns | 1h–2h | -| Medium | 3–6 files, new component + service wiring, some design | 3h–5h | -| Large | 6+ files, cross-feature, non-trivial state/routing | 1d–2d | -| XL | New feature area, major refactor, research spike | 3d–5d | +| Size | Criteria | Estimate | +| ------- | ------------------------------------------------------ | -------- | +| Trivial | Style tweak, copy change, 1-file tweak | 30m | +| Small | 1–3 files, single component/service, no unknowns | 1h–2h | +| Medium | 3–6 files, new component + service wiring, some design | 3h–5h | +| Large | 6+ files, cross-feature, non-trivial state/routing | 1d–2d | +| XL | New feature area, major refactor, research spike | 3d–5d | 4. Record: estimate + one-line reasoning. 5. Skip leaf if it already has `Zeitschätzung` set — note it as pre-estimated. @@ -49,6 +53,7 @@ For each leaf node: YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**. Compute display-only rolled-up totals: + - Parent total = sum of all descendant leaf estimates (including pre-estimated ones). - Flag any branch where some leaves are missing estimates (partial roll-up). @@ -68,11 +73,13 @@ Epic NCWF-10: Board UI overhaul [4h 30m] ← rolled up Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack. If sprint mode, show grand total at bottom: + ``` Sprint total: Xd Yh Zm (N issues, M leaves to update) ``` **Use `AskUserQuestion` tool:** + - Does the breakdown look right? - Any estimates to adjust before writing to YouTrack? @@ -81,6 +88,7 @@ Incorporate all feedback before proceeding. ## Step 6 — Write Estimates On user approval, write estimates **only to leaf nodes** (bottom-up order): + - For each leaf with a new estimate, call `mcp__youtrack__update_issue` with field `Zeitschätzung` = approved estimate. - YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`. - Skip leaves already pre-estimated. diff --git a/.claude/commands/fix-defect.md b/.claude/commands/fix-defect.md index e46441c..71aee61 100644 --- a/.claude/commands/fix-defect.md +++ b/.claude/commands/fix-defect.md @@ -3,6 +3,7 @@ Automated defect-fix workflow. Ticket ID: `$ARGUMENTS` This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates: + - Build: `npm run build` - Test: `npm test -- --watch=false --browsers=ChromeHeadless` - Format: `npx prettier --write .` (check with `npx prettier --check .`) @@ -15,6 +16,7 @@ Extract and display: summary, description, steps to reproduce, Priority, Subsyst ## Step 2 — Create Worktree Derive branch name from ticket: + - `type` from YouTrack issue type: `bug` → `fix`, `feature`/`task` → `feat`, `refactor` → `refactor`, else `chore` - `scope` from affected area (kebab-case, omit if unclear) - `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles @@ -42,6 +44,7 @@ After root cause confirmed, assess scope: **Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding. To create subtasks: + 1. Break fix into discrete, independently-completable tasks (e.g. "Fix selection state in BoardComponent", "Add spec for flip behaviour", "Update GameService move stream"). 2. For each subtask call `mcp__youtrack__create_issue` with: - `project`: based on subtask content — do **not** inherit from parent. Frontend/UI → `NCWF`; backend code → `NCS`; Kubernetes/pipelines/CI-CD/infrastructure → `NCI`. If ambiguous, ask user. @@ -66,7 +69,7 @@ Then proceed to Step 4, implementing subtasks in order. 5. Run `npx prettier --check .` — **blocking, foreground only** (never `run_in_background`). Wait for exit code 0. Must be green. - If it fails, fix all issues and re-run until exit code 0. - **Do NOT proceed to Step 5 until the build, tests and format check all pass.** -If any step fails, iterate until all pass. + If any step fails, iterate until all pass. ## Step 5 — Review @@ -76,6 +79,7 @@ Display findings grouped by severity. ## Step 5b — Apply Review Findings If the review produced any findings (any severity): + 1. Implement all agreed fixes. 2. Run `npm run build` — must be green. 3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green. @@ -125,12 +129,12 @@ Files changed: After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked: -| Situation | Relation | -|-----------|---------| -| This fix blocks another open ticket | `blocks` | -| Another ticket must ship first | `is blocked by` | -| Related defect or story | `relates to` | -| Duplicate of another defect | `duplicates` | +| Situation | Relation | +| ----------------------------------- | --------------- | +| This fix blocks another open ticket | `blocks` | +| Another ticket must ship first | `is blocked by` | +| Related defect or story | `relates to` | +| Duplicate of another defect | `duplicates` | Scan the ticket description and comments for any issue IDs that were mentioned but not yet linked. Suggest those automatically. diff --git a/.claude/commands/implement-feature.md b/.claude/commands/implement-feature.md index fa2edb1..fb02ce1 100644 --- a/.claude/commands/implement-feature.md +++ b/.claude/commands/implement-feature.md @@ -4,6 +4,7 @@ Automated feature-implementation workflow. Ticket ID: `$ARGUMENTS` This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project = `NCWF`. Gates: + - Build: `npm run build` - Test: `npm test -- --watch=false --browsers=ChromeHeadless` - Format: `npx prettier --write .` (check with `npx prettier --check .`) @@ -40,6 +41,7 @@ are collected and reported at the end with a ready-to-run prompt. ## Step 3 — Create Worktree Derive branch name from the root ticket `$ARGUMENTS`: + - `type` from YouTrack issue type: `feature`/`task` → `feat`, `refactor` → `refactor`, `bug` → `fix`, else `chore` - `scope` from affected area (kebab-case, omit if unclear) - `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles @@ -83,6 +85,7 @@ Display findings grouped by severity. ## Step 6b — Apply Review Findings If the review produced any findings (any severity): + 1. Implement all agreed fixes. 2. Run `npm run build` — must be green. 3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green. @@ -139,10 +142,12 @@ Call `ExitWorktree` with `discard_changes: true` to delete the worktree. Final report to the user, in two sections: ### Blocked in-project tasks + List any `NCWF` tasks that could **not** be implemented, with the blocker(s) that stopped them. (These can be re-run with this command once blockers clear.) ### Cross-project tasks (NCS / NCI / other) + For every out-of-project task discovered in the tree (whether it was a subtask or a blocker), output one entry: diff --git a/.claude/commands/split-story.md b/.claude/commands/split-story.md index 40bcf1e..1ebe9b1 100644 --- a/.claude/commands/split-story.md +++ b/.claude/commands/split-story.md @@ -12,6 +12,7 @@ Extract and display: summary, description, acceptance criteria, implementation n ## Step 2 — Research (if needed) If the story involves unfamiliar UI flows or technical constraints: + - Search repo for relevant code under `src/app/` (`Grep`/`Bash`). - Use `WebSearch` for Angular APIs, external standards or protocols. - Do NOT guess. Surface findings before proposing splits. @@ -19,6 +20,7 @@ If the story involves unfamiliar UI flows or technical constraints: ## Step 3 — Propose Split Analyse the story and propose a set of subtasks. Rules: + - Each subtask = one unit of work, completable independently or in sequence. - No subtask should exceed ~2 days of work. - Name subtasks in imperative mood (e.g. "Add board theme service", "Render theme picker component"). @@ -26,6 +28,7 @@ Analyse the story and propose a set of subtasks. Rules: Show proposed subtask list to user (titles only) and ask: **Use `AskUserQuestion` tool:** + - Does the split look right? - Any subtasks to add, remove, or merge? - Should any subtask be assigned to a specific person? @@ -57,6 +60,7 @@ What must be true for this subtask to be considered complete: ``` Rules: + - Steps/Tasks: concrete, ordered where order matters. - Definition of Done: adjust per subtask — not all subtasks need the same criteria (e.g. a research spike has different DoD than an implementation task). - Keep description short — one paragraph max. @@ -74,6 +78,7 @@ If a subtask's project is ambiguous, ask the user before creating it. ## Step 6 — Create Subtasks For each subtask call `mcp__youtrack__create_issue` with: + - `project`: from Step 5 - `summary`: subtask title (≤72 chars, sentence case) - `description`: full formatted description from Step 4 (Markdown) @@ -84,6 +89,7 @@ Then call `mcp__youtrack__link_issues` to link each created subtask to `$ARGUMEN ## Step 6b — Inter-Subtask Links If subtasks must be done in sequence (one depends on output of another), add ordering links: + - For each dependency pair call `mcp__youtrack__link_issues` with relation `is blocked by` (subtask B is blocked by subtask A). Ask the user to confirm sequencing before adding these links: @@ -94,12 +100,12 @@ Ask the user to confirm sequencing before adding these links: Scan `$ARGUMENTS` description and implementation notes for any referenced issue IDs not already linked. For each: -| Situation | Relation | -|-----------|---------| -| Parent story blocks another epic/story | `blocks` | +| Situation | Relation | +| ---------------------------------------- | --------------- | +| Parent story blocks another epic/story | `blocks` | | Story depends on another epic completing | `is blocked by` | -| Related story in same domain | `relates to` | -| This story duplicates or supersedes | `duplicates` | +| Related story in same domain | `relates to` | +| This story duplicates or supersedes | `duplicates` | Suggest links to the user and call `mcp__youtrack__link_issues` on confirmation. diff --git a/CHANGELOG.md b/CHANGELOG.md index 643029b..ca63010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,58 +1,77 @@ +## [0.3.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.8...0.3.0) (2026-06-10) + +### Features + +- NCWF-5 Scaffold post-game analysis page at /analysis with board viewer and route +- NCWF-6 FEN / PGN / Game-ID input form for custom analysis +- NCWF-7 Integrate backend analysis API (POST /api/analysis/position) via GameApiService +- NCWF-8 Per-move evaluation timeline SVG chart +- NCWF-9 Annotated move list with quality labels (!! ! ?! ? ??) + ## 0.0.0 (2026-05-12) ### Features -* added bot, light and dark mode ([2de003e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2de003e497baee72f998d0d805ca1e58aababe48)) -* added web view 1v1 ([1828fa3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1828fa3275ddb8ce6bb90a9f6a970ec428ebce3a)) -* NCS-63 User account implementation ([#2](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/2)) ([ff75c8c](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ff75c8ce2fad54137f04a14c15bc1d4a38fa9bb8)) -* NCS-75 Frontend Deployment Dockerfile ([#4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/4)) ([bd7ec58](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd7ec581e38b5d8e775741bf16e19b4dc67b979e)) +- added bot, light and dark mode ([2de003e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2de003e497baee72f998d0d805ca1e58aababe48)) +- added web view 1v1 ([1828fa3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1828fa3275ddb8ce6bb90a9f6a970ec428ebce3a)) +- NCS-63 User account implementation ([#2](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/2)) ([ff75c8c](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ff75c8ce2fad54137f04a14c15bc1d4a38fa9bb8)) +- NCS-75 Frontend Deployment Dockerfile ([#4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/4)) ([bd7ec58](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd7ec581e38b5d8e775741bf16e19b4dc67b979e)) ### Bug Fixes -* build issues ([36d72fd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/36d72fd6cda41be51d28f8ac307dcdbcd31afa91)) -* cleaner components seperation ([8b090e4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8b090e4d96c64c0adb253e3aefad7930108ccfb9)) -* gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d)) -* GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02)) -* npm ([c11c1d4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c11c1d4dce9de4bd5b463e891eebf961b37feb04)) -* removed .vs ([2833ead](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2833ead7be3b47ee5c188d2d21b7326cb3cb3f26)) -* removed cache files ([7ee59c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/7ee59c434bf137a08fd560bbc02ceefbcfd90f04)) -* size of pieces and removed text view of the game state ([c60d00f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c60d00f9d25247504845654615065fbccd7fe448)) -* structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93)) +- build issues ([36d72fd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/36d72fd6cda41be51d28f8ac307dcdbcd31afa91)) +- cleaner components seperation ([8b090e4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8b090e4d96c64c0adb253e3aefad7930108ccfb9)) +- gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d)) +- GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02)) +- npm ([c11c1d4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c11c1d4dce9de4bd5b463e891eebf961b37feb04)) +- removed .vs ([2833ead](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2833ead7be3b47ee5c188d2d21b7326cb3cb3f26)) +- removed cache files ([7ee59c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/7ee59c434bf137a08fd560bbc02ceefbcfd90f04)) +- size of pieces and removed text view of the game state ([c60d00f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c60d00f9d25247504845654615065fbccd7fe448)) +- structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93)) + ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.1.0...0.0.0) (2026-05-12) ### Features -* NCS-69 Challenge request ([#3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/3)) ([bad7366](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bad7366bdbb048c20218257b30ac22efc9ecb6db)) +- NCS-69 Challenge request ([#3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/3)) ([bad7366](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bad7366bdbb048c20218257b30ac22efc9ecb6db)) + ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.0...0.0.0) (2026-05-12) ### Bug Fixes -* NCWF-1 401 ([#5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/5)) ([f8f93ef](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f8f93efff48f1d7198023fed45b675c2e225df36)) +- NCWF-1 401 ([#5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/5)) ([f8f93ef](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f8f93efff48f1d7198023fed45b675c2e225df36)) + ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.1...0.0.0) (2026-05-12) ### Bug Fixes -* NCWF-1 401 ([#6](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/6)) ([6d1e06d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/6d1e06dfd606b93d029e9c9b84eea6f8b3b6294e)) +- NCWF-1 401 ([#6](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/6)) ([6d1e06d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/6d1e06dfd606b93d029e9c9b84eea6f8b3b6294e)) + ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.2...0.0.0) (2026-05-14) ### Bug Fixes -* added missing challenge routes ([61000f8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/61000f8a22aff8b524664a756cc933834365f923)) +- added missing challenge routes ([61000f8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/61000f8a22aff8b524664a756cc933834365f923)) + ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.3...0.0.0) (2026-05-15) ### Bug Fixes -* build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080)) -* NCWF-2 bugs and desing fixes ([#7](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/7)) ([c02414e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c02414ea40177b05a5e62dcf68dcb44efa6d3740)) +- build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080)) +- NCWF-2 bugs and desing fixes ([#7](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/7)) ([c02414e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c02414ea40177b05a5e62dcf68dcb44efa6d3740)) + ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.4...0.0.0) (2026-06-01) + ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.5...0.0.0) (2026-06-02) ### Bug Fixes -* NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f)) +- NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f)) + ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.6...0.0.0) (2026-06-06) + ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.7...0.0.0) (2026-06-10) ### Bug Fixes -* route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea)) +- route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea)) diff --git a/angular.json b/angular.json index 53f40a6..d6c8d66 100644 --- a/angular.json +++ b/angular.json @@ -18,9 +18,7 @@ "builder": "@angular/build:application", "options": { "browser": "src/main.ts", - "polyfills": [ - "zone.js" - ], + "polyfills": ["zone.js"], "tsConfig": "tsconfig.app.json", "assets": [ { @@ -38,9 +36,7 @@ "output": "/assets/ChessAssets" } ], - "styles": [ - "src/styles.css" - ] + "styles": ["src/styles.css"] }, "configurations": { "production": { @@ -106,10 +102,7 @@ "test": { "builder": "@angular/build:karma", "options": { - "polyfills": [ - "zone.js", - "zone.js/testing" - ], + "polyfills": ["zone.js", "zone.js/testing"], "tsConfig": "tsconfig.spec.json", "assets": [ { @@ -127,9 +120,7 @@ "output": "/assets/ChessAssets" } ], - "styles": [ - "src/styles.css" - ] + "styles": ["src/styles.css"] } } } diff --git a/docs/board-api-spec.yaml b/docs/board-api-spec.yaml index 61bf241..b6947ef 100644 --- a/docs/board-api-spec.yaml +++ b/docs/board-api-spec.yaml @@ -55,7 +55,6 @@ tags: description: Export a game as FEN or PGN paths: - # --------------------------------------------------------------------------- # Game lifecycle # --------------------------------------------------------------------------- @@ -467,7 +466,6 @@ paths: # ============================================================================= components: - securitySchemes: bearerAuth: type: http @@ -517,7 +515,6 @@ components: $ref: '#/components/schemas/ApiError' schemas: - # ------------------------------------------------------------------------- # Requests # ------------------------------------------------------------------------- @@ -551,7 +548,7 @@ components: pgn: type: string description: PGN text (headers and move list) - example: "1. e4 e5 2. Nf3 Nc6 *" + example: '1. e4 e5 2. Nf3 Nc6 *' # ------------------------------------------------------------------------- # Game state @@ -587,7 +584,7 @@ components: pgn: type: string description: PGN move text for the full game so far - example: "1. e4" + example: '1. e4' turn: type: string enum: [white, black] diff --git a/proxy.conf.json b/proxy.conf.json index f243619..e65b255 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -1,4 +1,9 @@ { + "/api/analysis": { + "target": "http://localhost:8087", + "secure": false, + "changeOrigin": true + }, "/api/tournament": { "target": "http://localhost:8089", "secure": false, diff --git a/public/env.template.js b/public/env.template.js index 72477ac..165a040 100644 --- a/public/env.template.js +++ b/public/env.template.js @@ -1,4 +1,4 @@ window.__RUNTIME_CONFIG__ = { - API_URL: "${API_URL}", - WEBSOCKET_URL: "${WEBSOCKET_URL}" + API_URL: '${API_URL}', + WEBSOCKET_URL: '${WEBSOCKET_URL}', }; diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 77e96f7..7d5cc1f 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,4 +1,8 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; +import { + ApplicationConfig, + provideBrowserGlobalErrorListeners, + provideZoneChangeDetection, +} from '@angular/core'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideRouter } from '@angular/router'; @@ -10,6 +14,6 @@ export const appConfig: ApplicationConfig = { provideBrowserGlobalErrorListeners(), provideZoneChangeDetection({ eventCoalescing: true }), provideHttpClient(withInterceptors([authInterceptor])), - provideRouter(routes) - ] + provideRouter(routes), + ], }; diff --git a/src/app/app.html b/src/app/app.html index 515b2b7..48f375d 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,2 +1 @@ - - \ No newline at end of file + diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 54f7663..63b01eb 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -6,6 +6,7 @@ import { ChallengesComponent } from './pages/challenges/challenges.component'; import { GamesComponent } from './pages/games/games.component'; import { TournamentsComponent } from './pages/tournaments/tournaments.component'; import { BotsComponent } from './pages/bots/bots.component'; +import { AnalysisComponent } from './pages/analysis/analysis.component'; export const routes: Routes = [ { path: '', component: WelcomeComponent }, @@ -14,6 +15,7 @@ export const routes: Routes = [ { path: 'challenges', component: ChallengesComponent }, { path: 'tournaments', component: TournamentsComponent }, { path: 'bots', component: BotsComponent }, + { path: 'analysis', component: AnalysisComponent }, { path: 'game/:gameId', component: GameComponent }, - { path: '**', redirectTo: '' } + { path: '**', redirectTo: '' }, ]; diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 2f546f6..91e7669 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,12 +1,13 @@ import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; import { App } from './app'; describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [App], - providers: [provideRouter([])] + providers: [provideRouter([]), provideHttpClient()], }).compileComponents(); }); diff --git a/src/app/app.ts b/src/app/app.ts index 4206e4c..57c6cff 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -7,10 +7,10 @@ import { ThemeService } from './services/theme.service'; selector: 'app-root', imports: [RouterOutlet, ToolbarComponent], templateUrl: './app.html', - styleUrl: './app.css' + styleUrl: './app.css', }) export class App implements OnInit { - constructor(private readonly themeService: ThemeService) { } + constructor(private readonly themeService: ThemeService) {} ngOnInit(): void { this.themeService.initTheme(); diff --git a/src/app/button-template.css b/src/app/button-template.css index 5b8888a..58ca321 100644 --- a/src/app/button-template.css +++ b/src/app/button-template.css @@ -12,7 +12,11 @@ letter-spacing: 1px; cursor: pointer; box-shadow: var(--btn-glow); - transition: transform 0.2s ease, filter 0.2s ease, background 1.6s ease, box-shadow 1.6s ease; + transition: + transform 0.2s ease, + filter 0.2s ease, + background 1.6s ease, + box-shadow 1.6s ease; display: inline-flex; align-items: center; justify-content: center; diff --git a/src/app/components/annotated-move-list/annotated-move-list.component.css b/src/app/components/annotated-move-list/annotated-move-list.component.css new file mode 100644 index 0000000..6062a45 --- /dev/null +++ b/src/app/components/annotated-move-list/annotated-move-list.component.css @@ -0,0 +1,108 @@ +:host { + display: block; +} + +.empty { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + padding: 12px 16px; + font-family: var(--nc-mono, monospace); + letter-spacing: 0.06em; +} + +.move-grid { + display: grid; + grid-template-columns: 28px 1fr 1fr; + gap: 2px 4px; + padding: 8px 12px; + font-family: var(--nc-mono, monospace); + font-size: 12px; +} + +.mv-num { + color: rgba(255, 255, 255, 0.3); + font-size: 10px; + display: flex; + align-items: center; + letter-spacing: 0.04em; +} + +.mv { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + cursor: pointer; + border-radius: 2px; + color: rgba(255, 255, 255, 0.8); + transition: + background 0.12s, + color 0.12s; +} + +.mv:hover { + background: rgba(255, 255, 255, 0.06); + color: #fff; +} + +.mv.active { + background: rgba(255, 69, 200, 0.18); + color: #fff; +} + +.mv-empty { + cursor: default; + color: rgba(255, 255, 255, 0.25); +} + +.mv-empty:hover { + background: transparent; + color: rgba(255, 255, 255, 0.25); +} + +.mv-san { + flex: 1; +} + +.mv-placeholder { + opacity: 0.4; +} + +/* Quality badges */ +.mv-badge { + font-size: 10px; + font-weight: 700; + padding: 1px 4px; + border-radius: 2px; + flex-shrink: 0; +} + +.q-brilliant .mv-badge, +.mv-badge.q-brilliant { + color: #5ee5a1; + background: rgba(94, 229, 161, 0.15); +} + +.q-best .mv-badge, +.mv-badge.q-best { + color: #5ee5a1; + background: rgba(94, 229, 161, 0.1); +} + +.q-inaccuracy .mv-badge, +.mv-badge.q-inaccuracy { + color: #ffb13a; + background: rgba(255, 177, 58, 0.15); +} + +.q-mistake .mv-badge, +.mv-badge.q-mistake { + color: #ff7a7a; + background: rgba(255, 122, 122, 0.15); +} + +.q-blunder .mv-badge, +.mv-badge.q-blunder { + color: #ff4444; + background: rgba(255, 68, 68, 0.18); +} diff --git a/src/app/components/annotated-move-list/annotated-move-list.component.html b/src/app/components/annotated-move-list/annotated-move-list.component.html new file mode 100644 index 0000000..111aa14 --- /dev/null +++ b/src/app/components/annotated-move-list/annotated-move-list.component.html @@ -0,0 +1,48 @@ +@if (moves.length === 0) { +
No annotated moves yet.
+} @else { +
+ @for (pair of pairs; track $index) { + + +
+ {{ pair.white?.san ?? '' }} + @if (qualityLabel(pair.white)) { + {{ + qualityLabel(pair.white) + }} + } +
+ +
+ @if (pair.black) { + {{ pair.black.san }} + @if (qualityLabel(pair.black)) { + {{ + qualityLabel(pair.black) + }} + } + } @else { + + } +
+ } +
+} diff --git a/src/app/components/annotated-move-list/annotated-move-list.component.ts b/src/app/components/annotated-move-list/annotated-move-list.component.ts new file mode 100644 index 0000000..b7d7a66 --- /dev/null +++ b/src/app/components/annotated-move-list/annotated-move-list.component.ts @@ -0,0 +1,83 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { AnnotatedMove, MoveQuality } from '../../models/analysis.models'; + +interface AnnotatedPair { + white: AnnotatedMove | null; + black: AnnotatedMove | null; +} + +const QUALITY_LABELS: Record = { + brilliant: '!!', + best: '!', + good: '', + inaccuracy: '?!', + mistake: '?', + blunder: '??', +}; + +const QUALITY_CLASSES: Record = { + brilliant: 'q-brilliant', + best: 'q-best', + good: 'q-good', + inaccuracy: 'q-inaccuracy', + mistake: 'q-mistake', + blunder: 'q-blunder', +}; + +@Component({ + selector: 'app-annotated-move-list', + standalone: true, + imports: [], + templateUrl: './annotated-move-list.component.html', + styleUrl: './annotated-move-list.component.css', +}) +export class AnnotatedMoveListComponent { + @Input({ required: true }) moves: AnnotatedMove[] = []; + @Input() activePly: number | null = null; + @Output() plySelected = new EventEmitter(); + + get pairs(): AnnotatedPair[] { + const result: AnnotatedPair[] = []; + for (let i = 0; i < this.moves.length; i += 2) { + result.push({ + white: this.moves[i] ?? null, + black: this.moves[i + 1] ?? null, + }); + } + return result; + } + + qualityLabel(move: AnnotatedMove | null): string { + if (!move?.quality) return ''; + return QUALITY_LABELS[move.quality] ?? ''; + } + + qualityClass(move: AnnotatedMove | null): string { + if (!move?.quality) return ''; + return QUALITY_CLASSES[move.quality] ?? ''; + } + + isWhiteActive(pairIndex: number): boolean { + return this.activePly === pairIndex * 2; + } + + isBlackActive(pairIndex: number): boolean { + return this.activePly === pairIndex * 2 + 1; + } + + selectWhite(pairIndex: number): void { + this.plySelected.emit(pairIndex * 2); + } + + selectBlack(pairIndex: number, black: AnnotatedMove | null): void { + if (!black) return; + this.plySelected.emit(pairIndex * 2 + 1); + } + + formatEval(move: AnnotatedMove | null): string { + if (!move || move.evalAfter === null) return ''; + const v = move.evalAfter; + const sign = v > 0 ? '+' : ''; + return `${sign}${v.toFixed(2)}`; + } +} 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 index fb76f03..cba36af 100644 --- a/src/app/components/board-actions-bar/board-actions-bar.component.css +++ b/src/app/components/board-actions-bar/board-actions-bar.component.css @@ -27,7 +27,10 @@ align-items: center; justify-content: center; gap: 6px; - transition: background 0.15s, border-color 0.15s, color 0.15s; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s; } .btn:hover:not(:disabled) { 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 index 92c94fa..401b392 100644 --- a/src/app/components/board-actions-bar/board-actions-bar.component.html +++ b/src/app/components/board-actions-bar/board-actions-bar.component.html @@ -1,26 +1,53 @@
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 index d34e859..629dc16 100644 --- a/src/app/components/board-actions-bar/board-actions-bar.component.ts +++ b/src/app/components/board-actions-bar/board-actions-bar.component.ts @@ -5,7 +5,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; standalone: true, imports: [], templateUrl: './board-actions-bar.component.html', - styleUrl: './board-actions-bar.component.css' + styleUrl: './board-actions-bar.component.css', }) export class BoardActionsBarComponent { @Input() undoAvailable = false; diff --git a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.css b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.css index e099c25..2e214d8 100644 --- a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.css +++ b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.css @@ -23,7 +23,9 @@ background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%); border: 2px solid #00d5ff; border-radius: 8px; - box-shadow: 0 0 20px rgba(0, 213, 255, 0.3), inset 0 0 10px rgba(0, 213, 255, 0.05); + box-shadow: + 0 0 20px rgba(0, 213, 255, 0.3), + inset 0 0 10px rgba(0, 213, 255, 0.05); width: 90%; max-width: 500px; max-height: 90vh; 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 9536f0d..3944d08 100644 --- a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html +++ b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html @@ -1,91 +1,117 @@
-
-
-

Create Challenge

- -
- -
- -
- {{ errorMessage }} -
- - -
- - - - Username is required - -
- - -
- - -
- - -
- - -
- - -
- -
- -
-
- - -
-
-
- - -
-
- - -
-
-
- - -
- - -
- - -
- - -
-
+
+
+

Create Challenge

+
-
\ No newline at end of file + +
+ +
+ {{ errorMessage }} +
+ + +
+ + + + Username is required + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+ + +
+ + +
+
+
+
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 2dc9a1f..50f04b9 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 @@ -1,6 +1,20 @@ -import { Component, inject, OnInit, OnDestroy, DestroyRef, Output, EventEmitter } from '@angular/core'; +import { + Component, + inject, + OnInit, + OnDestroy, + DestroyRef, + Output, + EventEmitter, +} from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { + FormsModule, + ReactiveFormsModule, + FormBuilder, + FormGroup, + Validators, +} from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { finalize } from 'rxjs'; import { ChallengeService } from '../../services/challenge.service'; @@ -11,149 +25,155 @@ import { PlayerColor } from '../../models/challenge.models'; type TimeMode = 'blitz' | 'rapid' | 'classical' | 'unlimited'; interface TimePreset { - label: string; - limitSeconds: number; - incrementSeconds: number; + label: string; + limitSeconds: number; + incrementSeconds: number; } @Component({ - selector: 'app-challenge-create-dialog', - standalone: true, - imports: [CommonModule, FormsModule, ReactiveFormsModule], - templateUrl: './challenge-create-dialog.component.html', - styleUrls: ['./challenge-create-dialog.component.css'] + selector: 'app-challenge-create-dialog', + standalone: true, + imports: [CommonModule, FormsModule, ReactiveFormsModule], + templateUrl: './challenge-create-dialog.component.html', + styleUrls: ['./challenge-create-dialog.component.css'], }) export class ChallengeCreateDialogComponent implements OnInit, OnDestroy { - private readonly challengeService = inject(ChallengeService); - private readonly router = inject(Router); - private readonly fb = inject(FormBuilder); - private readonly destroyRef = inject(DestroyRef); + private readonly challengeService = inject(ChallengeService); + private readonly router = inject(Router); + private readonly fb = inject(FormBuilder); + private readonly destroyRef = inject(DestroyRef); - @Output() closeChallengeDialog = new EventEmitter(); + @Output() closeChallengeDialog = new EventEmitter(); - form!: FormGroup; - loading = false; - errorMessage = ''; - selectedTimeMode: TimeMode = 'rapid'; + form!: FormGroup; + loading = false; + errorMessage = ''; + selectedTimeMode: TimeMode = 'rapid'; - timePresets: Record = { - blitz: [ - { label: '1+0', limitSeconds: 60, incrementSeconds: 0 }, - { label: '2+1', limitSeconds: 120, incrementSeconds: 1 }, - { label: '3+0', limitSeconds: 180, incrementSeconds: 0 }, - { label: '3+2', limitSeconds: 180, incrementSeconds: 2 }, - { label: '5+0', limitSeconds: 300, incrementSeconds: 0 } - ], - rapid: [ - { label: '10+0', limitSeconds: 600, incrementSeconds: 0 }, - { label: '10+5', limitSeconds: 600, incrementSeconds: 5 }, - { label: '15+10', limitSeconds: 900, incrementSeconds: 10 }, - { label: '25+10', limitSeconds: 1500, incrementSeconds: 10 } - ], - classical: [ - { label: '30+0', limitSeconds: 1800, incrementSeconds: 0 }, - { label: '30+20', limitSeconds: 1800, incrementSeconds: 20 }, - { label: '60+30', limitSeconds: 3600, incrementSeconds: 30 }, - { label: '90+30', limitSeconds: 5400, incrementSeconds: 30 } - ], - unlimited: [] - }; + timePresets: Record = { + blitz: [ + { label: '1+0', limitSeconds: 60, incrementSeconds: 0 }, + { label: '2+1', limitSeconds: 120, incrementSeconds: 1 }, + { label: '3+0', limitSeconds: 180, incrementSeconds: 0 }, + { label: '3+2', limitSeconds: 180, incrementSeconds: 2 }, + { label: '5+0', limitSeconds: 300, incrementSeconds: 0 }, + ], + rapid: [ + { label: '10+0', limitSeconds: 600, incrementSeconds: 0 }, + { label: '10+5', limitSeconds: 600, incrementSeconds: 5 }, + { label: '15+10', limitSeconds: 900, incrementSeconds: 10 }, + { label: '25+10', limitSeconds: 1500, incrementSeconds: 10 }, + ], + classical: [ + { label: '30+0', limitSeconds: 1800, incrementSeconds: 0 }, + { label: '30+20', limitSeconds: 1800, incrementSeconds: 20 }, + { label: '60+30', limitSeconds: 3600, incrementSeconds: 30 }, + { label: '90+30', limitSeconds: 5400, incrementSeconds: 30 }, + ], + unlimited: [], + }; - ttlOptions = [ - { label: '5 minutes', seconds: 300 }, - { label: '1 hour', seconds: 3600 }, - { label: '1 day', seconds: 86400 }, - { label: 'No expiry', seconds: 0 } - ]; + ttlOptions = [ + { label: '5 minutes', seconds: 300 }, + { label: '1 hour', seconds: 3600 }, + { label: '1 day', seconds: 86400 }, + { label: 'No expiry', seconds: 0 }, + ]; - ngOnInit(): void { - this.initializeForm(); - } + ngOnInit(): void { + this.initializeForm(); + } - ngOnDestroy(): void { - } + ngOnDestroy(): void {} - private initializeForm(): void { - this.form = this.fb.group({ - targetUsername: ['', [Validators.required, Validators.minLength(1)]], - color: ['random', Validators.required], - timeMode: ['rapid'], - limitMinutes: [10, [Validators.required, Validators.min(1), Validators.max(1000)]], - incrementSeconds: [5, [Validators.required, Validators.min(0), Validators.max(300)]], - ttlSeconds: [3600, Validators.required] - }); + private initializeForm(): void { + this.form = this.fb.group({ + targetUsername: ['', [Validators.required, Validators.minLength(1)]], + color: ['random', Validators.required], + timeMode: ['rapid'], + limitMinutes: [10, [Validators.required, Validators.min(1), Validators.max(1000)]], + incrementSeconds: [5, [Validators.required, Validators.min(0), Validators.max(300)]], + ttlSeconds: [3600, Validators.required], + }); - this.form.get('timeMode')?.valueChanges - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((mode: unknown) => { - const timeMode = mode as TimeMode; - this.selectedTimeMode = timeMode; - if (timeMode !== 'unlimited') { - const firstPreset = this.timePresets[timeMode][0]; - if (firstPreset) { - this.form.patchValue({ - limitMinutes: firstPreset.limitSeconds / 60, - incrementSeconds: firstPreset.incrementSeconds - }); - } - } + this.form + .get('timeMode') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((mode: unknown) => { + const timeMode = mode as TimeMode; + this.selectedTimeMode = timeMode; + if (timeMode !== 'unlimited') { + const firstPreset = this.timePresets[timeMode][0]; + if (firstPreset) { + this.form.patchValue({ + limitMinutes: firstPreset.limitSeconds / 60, + incrementSeconds: firstPreset.incrementSeconds, }); - } - - selectPreset(preset: TimePreset): void { - this.form.patchValue({ - limitMinutes: preset.limitSeconds / 60, - incrementSeconds: preset.incrementSeconds - }); - } - - getAvailablePresets(): TimePreset[] { - return this.timePresets[this.selectedTimeMode] || []; - } - - submit(): void { - if (this.form.invalid || this.loading) { - return; + } } + }); + } - const targetUsername = this.form.get('targetUsername')?.value?.trim(); - if (!targetUsername) { - this.errorMessage = 'Please enter a valid username'; - return; - } + selectPreset(preset: TimePreset): void { + this.form.patchValue({ + limitMinutes: preset.limitSeconds / 60, + incrementSeconds: preset.incrementSeconds, + }); + } - this.errorMessage = ''; - this.loading = true; - this.form.disable(); + getAvailablePresets(): TimePreset[] { + return this.timePresets[this.selectedTimeMode] || []; + } - 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: { - limitSeconds, - incrementSeconds - }, - color, - ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined - }) - .pipe(finalize(() => { this.loading = false; this.form.enable(); })) - .subscribe({ - next: (challenge) => { - // Challenge sent successfully - navigate to challenges page to view status - this.form.reset(); - this.closeChallengeDialog.emit(); - void this.router.navigate(['/challenges']); - }, - error: (error) => { - this.errorMessage = getErrorMessage(error, 'Failed to send challenge. Please try again.'); - } - }); + submit(): void { + if (this.form.invalid || this.loading) { + return; } - cancel(): void { - this.form.reset(); - this.closeChallengeDialog.emit(); + const targetUsername = this.form.get('targetUsername')?.value?.trim(); + if (!targetUsername) { + this.errorMessage = 'Please enter a valid username'; + return; } + + this.errorMessage = ''; + this.loading = true; + this.form.disable(); + + 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: { + limitSeconds, + incrementSeconds, + }, + color, + ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined, + }) + .pipe( + finalize(() => { + this.loading = false; + this.form.enable(); + }), + ) + .subscribe({ + next: (challenge) => { + // Challenge sent successfully - navigate to challenges page to view status + this.form.reset(); + this.closeChallengeDialog.emit(); + void this.router.navigate(['/challenges']); + }, + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Failed to send challenge. Please try again.'); + }, + }); + } + + cancel(): void { + this.form.reset(); + this.closeChallengeDialog.emit(); + } } diff --git a/src/app/components/challenge-notification/challenge-notification.component.css b/src/app/components/challenge-notification/challenge-notification.component.css index 79268f7..767e449 100644 --- a/src/app/components/challenge-notification/challenge-notification.component.css +++ b/src/app/components/challenge-notification/challenge-notification.component.css @@ -8,7 +8,9 @@ background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%); border: 2px solid #00d5ff; border-radius: 8px; - box-shadow: 0 0 20px rgba(0, 213, 255, 0.4), inset 0 0 10px rgba(0, 213, 255, 0.05); + box-shadow: + 0 0 20px rgba(0, 213, 255, 0.4), + inset 0 0 10px rgba(0, 213, 255, 0.05); padding: 16px; color: #e0e0e0; z-index: 2000; @@ -16,7 +18,9 @@ &.error { border-color: #ff6b6b; - box-shadow: 0 0 20px rgba(255, 107, 107, 0.4), inset 0 0 10px rgba(255, 107, 107, 0.05); + box-shadow: + 0 0 20px rgba(255, 107, 107, 0.4), + inset 0 0 10px rgba(255, 107, 107, 0.05); } @media (max-width: 768px) { diff --git a/src/app/components/challenge-notification/challenge-notification.component.html b/src/app/components/challenge-notification/challenge-notification.component.html index 46a3907..c099802 100644 --- a/src/app/components/challenge-notification/challenge-notification.component.html +++ b/src/app/components/challenge-notification/challenge-notification.component.html @@ -1,38 +1,50 @@
-
-
- CHALLENGE - {{ getCreatedByDisplay() }} challenged you! -
- +
+
+ CHALLENGE + {{ getCreatedByDisplay() }} challenged you! +
+ +
+ +
+
+ Time Control: + {{ getTimeControlDisplay() }}
-
-
- Time Control: - {{ getTimeControlDisplay() }} -
- -
- {{ getExpirationInfo() }} -
- -
- {{ errorMessage }} -
- -
- - -
+
+ {{ getExpirationInfo() }}
-
\ No newline at end of file + +
+ {{ errorMessage }} +
+ +
+ + +
+
+
diff --git a/src/app/components/challenge-notification/challenge-notification.component.ts b/src/app/components/challenge-notification/challenge-notification.component.ts index 87d760f..b5ff029 100644 --- a/src/app/components/challenge-notification/challenge-notification.component.ts +++ b/src/app/components/challenge-notification/challenge-notification.component.ts @@ -7,102 +7,104 @@ import { finalize } from 'rxjs'; import { getErrorMessage } from '../../core/http/error-message.util'; @Component({ - selector: 'app-challenge-notification', - standalone: true, - imports: [CommonModule], - templateUrl: './challenge-notification.component.html', - styleUrls: ['./challenge-notification.component.css'] + selector: 'app-challenge-notification', + standalone: true, + imports: [CommonModule], + templateUrl: './challenge-notification.component.html', + styleUrls: ['./challenge-notification.component.css'], }) export class ChallengeNotificationComponent { - @Input() challenge!: Challenge; - @Output() accept = new EventEmitter(); - @Output() decline = new EventEmitter(); - @Output() close = new EventEmitter(); + @Input() challenge!: Challenge; + @Output() accept = new EventEmitter(); + @Output() decline = new EventEmitter(); + @Output() close = new EventEmitter(); - private readonly challengeService = inject(ChallengeService); - private readonly router = inject(Router); + private readonly challengeService = inject(ChallengeService); + private readonly router = inject(Router); - acceptingChallenge = false; - decliningChallenge = false; - errorMessage = ''; + acceptingChallenge = false; + decliningChallenge = false; + errorMessage = ''; - onAccept(): void { - if (this.acceptingChallenge || this.decliningChallenge) { - return; - } - - this.acceptingChallenge = true; - this.errorMessage = ''; - - this.challengeService.acceptChallenge(this.challenge.id) - .pipe(finalize(() => (this.acceptingChallenge = false))) - .subscribe({ - next: (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'); - } - }); + onAccept(): void { + if (this.acceptingChallenge || this.decliningChallenge) { + return; } - onDecline(): void { - if (this.acceptingChallenge || this.decliningChallenge) { - return; - } + this.acceptingChallenge = true; + this.errorMessage = ''; - this.decliningChallenge = true; - this.errorMessage = ''; + this.challengeService + .acceptChallenge(this.challenge.id) + .pipe(finalize(() => (this.acceptingChallenge = false))) + .subscribe({ + 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'); + }, + }); + } - this.challengeService.declineChallenge(this.challenge.id, { reason: 'generic' }) - .pipe(finalize(() => (this.decliningChallenge = false))) - .subscribe({ - next: () => { - this.decline.emit(this.challenge); - }, - error: (error) => { - this.errorMessage = getErrorMessage(error, 'Failed to decline challenge'); - } - }); + onDecline(): void { + if (this.acceptingChallenge || this.decliningChallenge) { + return; } - onClose(): void { - this.close.emit(); + this.decliningChallenge = true; + this.errorMessage = ''; + + this.challengeService + .declineChallenge(this.challenge.id, { reason: 'generic' }) + .pipe(finalize(() => (this.decliningChallenge = false))) + .subscribe({ + next: () => { + this.decline.emit(this.challenge); + }, + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Failed to decline challenge'); + }, + }); + } + + onClose(): void { + this.close.emit(); + } + + getTimeControlDisplay(): string { + const { limit, increment } = this.challenge.timeControl; + if (!limit || !increment) { + return 'Unlimited'; + } + const minutes = Math.floor(limit / 60); + return `${minutes}+${increment}`; + } + + getCreatedByDisplay(): string { + return this.challenge.challenger.name; + } + + getExpirationInfo(): string { + const expiresAt = new Date(this.challenge.expiresAt); + const now = new Date(); + const diffMs = expiresAt.getTime() - now.getTime(); + + if (diffMs <= 0) { + return 'Expired'; } - getTimeControlDisplay(): string { - const { limit, increment } = this.challenge.timeControl; - if (!limit || !increment) { - return 'Unlimited'; - } - const minutes = Math.floor(limit / 60); - return `${minutes}+${increment}`; + const minutes = Math.floor(diffMs / 60000); + if (minutes > 60) { + const hours = Math.floor(minutes / 60); + return `Expires in ${hours}h`; } - getCreatedByDisplay(): string { - return this.challenge.challenger.name; - } - - getExpirationInfo(): string { - const expiresAt = new Date(this.challenge.expiresAt); - const now = new Date(); - const diffMs = expiresAt.getTime() - now.getTime(); - - if (diffMs <= 0) { - return 'Expired'; - } - - const minutes = Math.floor(diffMs / 60000); - if (minutes > 60) { - const hours = Math.floor(minutes / 60); - return `Expires in ${hours}h`; - } - - return `Expires in ${minutes}m`; - } + return `Expires in ${minutes}m`; + } } diff --git a/src/app/components/chess-board/chess-board.component.html b/src/app/components/chess-board/chess-board.component.html index 0704d39..eaeec68 100644 --- a/src/app/components/chess-board/chess-board.component.html +++ b/src/app/components/chess-board/chess-board.component.html @@ -1,5 +1,9 @@
-
+
@for (square of squares; track trackByCoordinate($index, square)) {
diff --git a/src/app/components/chess-board/chess-board.component.ts b/src/app/components/chess-board/chess-board.component.ts index 0a1a1a8..3abe46d 100644 --- a/src/app/components/chess-board/chess-board.component.ts +++ b/src/app/components/chess-board/chess-board.component.ts @@ -14,7 +14,7 @@ type BoardTheme = 'arabian' | 'classic'; standalone: true, imports: [ChessPieceComponent], templateUrl: './chess-board.component.html', - styleUrl: './chess-board.component.css' + styleUrl: './chess-board.component.css', }) export class ChessBoardComponent implements OnChanges { @Input({ required: true }) fen = ''; @@ -150,7 +150,7 @@ export class ChessBoardComponent implements OnChanges { return { coordinate: `${file}${rank}`, isLight: (rowIndex + column) % 2 === 0, - pieceCode + pieceCode, }; } diff --git a/src/app/components/chess-piece/chess-piece.component.ts b/src/app/components/chess-piece/chess-piece.component.ts index 6b3e125..70974aa 100644 --- a/src/app/components/chess-piece/chess-piece.component.ts +++ b/src/app/components/chess-piece/chess-piece.component.ts @@ -6,7 +6,7 @@ type BoardTheme = 'arabian' | 'classic'; selector: 'app-chess-piece', standalone: true, templateUrl: './chess-piece.component.html', - styleUrl: './chess-piece.component.css' + styleUrl: './chess-piece.component.css', }) export class ChessPieceComponent { @Input({ required: true }) pieceCode: string | null = null; diff --git a/src/app/components/eval-timeline/eval-timeline.component.css b/src/app/components/eval-timeline/eval-timeline.component.css new file mode 100644 index 0000000..fc29110 --- /dev/null +++ b/src/app/components/eval-timeline/eval-timeline.component.css @@ -0,0 +1,53 @@ +:host { + display: block; + width: 100%; +} + +.timeline-wrap { + width: 100%; + overflow: hidden; +} + +.timeline-svg { + display: block; + width: 100%; + height: 80px; +} + +.midline { + stroke: rgba(255, 255, 255, 0.12); + stroke-width: 1; + stroke-dasharray: 4 4; +} + +.area-white { + fill: rgba(255, 255, 255, 0.18); +} + +.area-black { + fill: rgba(20, 20, 30, 0.55); +} + +.eval-line { + fill: none; + stroke: var(--nc-neon, #ff45c8); + stroke-width: 1.5; + stroke-linejoin: round; + stroke-linecap: round; +} + +.active-marker { + stroke: var(--nc-warning, #ffb13a); + stroke-width: 1.5; + stroke-dasharray: 3 3; + opacity: 0.8; +} + +.empty { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + padding: 12px 0; + font-family: var(--nc-mono, monospace); + letter-spacing: 0.06em; + text-align: center; +} diff --git a/src/app/components/eval-timeline/eval-timeline.component.html b/src/app/components/eval-timeline/eval-timeline.component.html new file mode 100644 index 0000000..1589d21 --- /dev/null +++ b/src/app/components/eval-timeline/eval-timeline.component.html @@ -0,0 +1,53 @@ +@if (points.length === 0) { +
No evaluation data yet.
+} @else { + +} diff --git a/src/app/components/eval-timeline/eval-timeline.component.ts b/src/app/components/eval-timeline/eval-timeline.component.ts new file mode 100644 index 0000000..9099dd9 --- /dev/null +++ b/src/app/components/eval-timeline/eval-timeline.component.ts @@ -0,0 +1,73 @@ +import { Component, Input, OnChanges } from '@angular/core'; +import { AnnotatedMove } from '../../models/analysis.models'; + +interface TimelinePoint { + x: number; + y: number; + eval: number; + san: string; + plyIndex: number; +} + +const CLAMP = 5; // clamp eval to ±5 pawns for display +const HEIGHT = 80; +const WIDTH = 600; + +@Component({ + selector: 'app-eval-timeline', + standalone: true, + imports: [], + templateUrl: './eval-timeline.component.html', + styleUrl: './eval-timeline.component.css', +}) +export class EvalTimelineComponent implements OnChanges { + @Input({ required: true }) moves: AnnotatedMove[] = []; + @Input() activePly: number | null = null; + + points: TimelinePoint[] = []; + evalPolyline = ''; + polylineWhite = ''; + polylineBlack = ''; + svgWidth = WIDTH; + svgHeight = HEIGHT; + + ngOnChanges(): void { + this.buildChart(); + } + + activeX(): number | null { + if (this.activePly === null) return null; + const pt = this.points[this.activePly]; + return pt ? pt.x : null; + } + + private buildChart(): void { + if (this.moves.length === 0) { + this.points = []; + this.evalPolyline = ''; + this.polylineWhite = ''; + this.polylineBlack = ''; + return; + } + + const total = this.moves.length; + this.points = this.moves.map((m, i) => { + const evalValue = m.evalAfter ?? 0; + const clamped = Math.max(-CLAMP, Math.min(CLAMP, evalValue)); + const x = (i / Math.max(total - 1, 1)) * WIDTH; + // y=0 => white winning (top), y=HEIGHT => black winning (bottom) + const y = ((CLAMP - clamped) / (CLAMP * 2)) * HEIGHT; + return { x, y, eval: evalValue, san: m.san, plyIndex: i }; + }); + + const coordStr = this.points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '); + this.evalPolyline = coordStr; + + const mid = HEIGHT / 2; + const first = this.points[0]; + const last = this.points[this.points.length - 1]; + + this.polylineWhite = `${first.x.toFixed(1)},${mid} ${coordStr} ${last.x.toFixed(1)},${mid}`; + this.polylineBlack = this.polylineWhite; + } +} diff --git a/src/app/components/export-panel/export-panel.component.css b/src/app/components/export-panel/export-panel.component.css index 26128d6..7591ce9 100644 --- a/src/app/components/export-panel/export-panel.component.css +++ b/src/app/components/export-panel/export-panel.component.css @@ -66,7 +66,9 @@ font-family: var(--nc-sans); letter-spacing: 0.08em; cursor: pointer; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; } .seg-btn.active { @@ -115,7 +117,9 @@ align-items: center; justify-content: center; gap: 6px; - transition: background 0.15s, border-color 0.15s; + transition: + background 0.15s, + border-color 0.15s; } .btn:hover { diff --git a/src/app/components/export-panel/export-panel.component.html b/src/app/components/export-panel/export-panel.component.html index 35e8bfc..de8d2e6 100644 --- a/src/app/components/export-panel/export-panel.component.html +++ b/src/app/components/export-panel/export-panel.component.html @@ -2,8 +2,17 @@ Export Position @@ -15,30 +24,60 @@ [class.active]="exportKind === 'fen'" role="tab" [attr.aria-selected]="exportKind === 'fen'" - (click)="setKind('fen')">FEN + (click)="setKind('fen')" + > + FEN + + (click)="setKind('pgn')" + > + PGN +
- +
diff --git a/src/app/components/export-panel/export-panel.component.ts b/src/app/components/export-panel/export-panel.component.ts index d53be84..7b9d7e7 100644 --- a/src/app/components/export-panel/export-panel.component.ts +++ b/src/app/components/export-panel/export-panel.component.ts @@ -8,7 +8,7 @@ type ExportKind = 'fen' | 'pgn'; standalone: true, imports: [FormsModule], templateUrl: './export-panel.component.html', - styleUrl: './export-panel.component.css' + styleUrl: './export-panel.component.css', }) export class ExportPanelComponent implements OnChanges { @Input() fen = ''; diff --git a/src/app/components/input-card/input-card.component.css b/src/app/components/input-card/input-card.component.css index 65aa86b..99b01e0 100644 --- a/src/app/components/input-card/input-card.component.css +++ b/src/app/components/input-card/input-card.component.css @@ -29,17 +29,16 @@ .input-card textarea { resize: vertical; height: 40px; - background-color:lightcyan; + background-color: lightcyan; border: 3px solid var(--color-border); - border-radius: var(--border-radius-sm); + border-radius: var(--border-radius-sm); } .input-card input { min-width: unset; - background-color:lightcyan; - border: 3px solid var(--color-border); - border-radius: var(--border-radius-sm); - + background-color: lightcyan; + border: 3px solid var(--color-border); + border-radius: var(--border-radius-sm); } .hint-text { diff --git a/src/app/components/input-card/input-card.component.html b/src/app/components/input-card/input-card.component.html index bdc6202..0239946 100644 --- a/src/app/components/input-card/input-card.component.html +++ b/src/app/components/input-card/input-card.component.html @@ -2,11 +2,23 @@ @if (inputType === 'textarea') { - + } @else { - + } @if (hintText) { -

{{ hintText }}

+

{{ hintText }}

} - \ No newline at end of file + diff --git a/src/app/components/login-dialog/login-dialog.component.css b/src/app/components/login-dialog/login-dialog.component.css index 813b554..d7b5c3f 100644 --- a/src/app/components/login-dialog/login-dialog.component.css +++ b/src/app/components/login-dialog/login-dialog.component.css @@ -22,7 +22,7 @@ position: relative; width: min(420px, 100%); background: - radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.10), transparent 60%), + radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.1), transparent 60%), linear-gradient(180deg, #0a0612 0%, #06060d 100%); border: 1px solid var(--auth-neon-soft); border-radius: 14px; @@ -60,14 +60,13 @@ position: absolute; inset: 0; pointer-events: none; - background-image: - repeating-linear-gradient( - 0deg, - rgba(255, 255, 255, 0.012) 0px, - rgba(255, 255, 255, 0.012) 1px, - transparent 1px, - transparent 3px - ); + background-image: repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.012) 0px, + rgba(255, 255, 255, 0.012) 1px, + transparent 1px, + transparent 3px + ); border-radius: inherit; } @@ -174,7 +173,10 @@ form { font-family: var(--auth-mono); font-size: 13px; letter-spacing: 0.3px; - transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; + transition: + border-color 0.18s ease, + box-shadow 0.18s ease, + background 0.18s ease; } .dialog-input::placeholder { @@ -185,7 +187,9 @@ form { outline: none; border-color: var(--auth-neon); background: rgba(20, 6, 26, 0.7); - box-shadow: 0 0 0 3px rgba(255, 69, 200, 0.15), 0 0 18px rgba(255, 69, 200, 0.18); + box-shadow: + 0 0 0 3px rgba(255, 69, 200, 0.15), + 0 0 18px rgba(255, 69, 200, 0.18); } .text-danger { @@ -303,26 +307,56 @@ form { } @keyframes scanline { - 0% { transform: translateY(-100%); } - 100% { transform: translateY(300%); } + 0% { + transform: translateY(-100%); + } + 100% { + transform: translateY(300%); + } } @keyframes pulse-glow { - 0%, 100% { opacity: 0.85; } - 50% { opacity: 1; } + 0%, + 100% { + opacity: 0.85; + } + 50% { + opacity: 1; + } } @keyframes dialog-in { - from { opacity: 0; transform: translateY(8px) scale(0.96); } - to { opacity: 1; transform: translateY(0) scale(1); } + from { + opacity: 0; + transform: translateY(8px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } } @keyframes backdrop-in { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes shake { - 0%, 100% { transform: translateX(0); } - 20%, 60% { transform: translateX(-4px); } - 40%, 80% { transform: translateX(4px); } + 0%, + 100% { + transform: translateX(0); + } + 20%, + 60% { + transform: translateX(-4px); + } + 40%, + 80% { + transform: translateX(4px); + } } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } diff --git a/src/app/components/login-dialog/login-dialog.component.html b/src/app/components/login-dialog/login-dialog.component.html index e6ff05d..3b853a2 100644 --- a/src/app/components/login-dialog/login-dialog.component.html +++ b/src/app/components/login-dialog/login-dialog.component.html @@ -11,8 +11,14 @@
- + @if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) { Username must be at least 3 characters } @@ -20,8 +26,14 @@
- + @if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) { Password must be at least 6 characters } @@ -41,9 +53,7 @@
-
- New here?Create an account -
+
diff --git a/src/app/components/login-dialog/login-dialog.component.ts b/src/app/components/login-dialog/login-dialog.component.ts index dc025dd..0cc60de 100644 --- a/src/app/components/login-dialog/login-dialog.component.ts +++ b/src/app/components/login-dialog/login-dialog.component.ts @@ -9,7 +9,7 @@ import { AuthDialogService } from '../../services/auth-dialog.service'; standalone: true, imports: [CommonModule, ReactiveFormsModule], templateUrl: './login-dialog.component.html', - styleUrl: './login-dialog.component.css' + styleUrl: './login-dialog.component.css', }) export class LoginDialogComponent { @Output() onClose = new EventEmitter(); @@ -26,7 +26,7 @@ export class LoginDialogComponent { constructor() { this.loginForm = this.formBuilder.group({ username: ['', [Validators.required, Validators.minLength(3)]], - password: ['', [Validators.required, Validators.minLength(6)]] + password: ['', [Validators.required, Validators.minLength(6)]], }); } @@ -51,7 +51,7 @@ export class LoginDialogComponent { 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 index 1727b12..06988f6 100644 --- a/src/app/components/move-history/move-history.component.css +++ b/src/app/components/move-history/move-history.component.css @@ -26,7 +26,9 @@ padding: 6px 10px; color: var(--nc-text); cursor: pointer; - transition: background 0.1s, color 0.1s; + transition: + background 0.1s, + color 0.1s; } .mv:hover { @@ -35,7 +37,7 @@ } .mv.current { - background: rgba(255, 69, 200, 0.10); + background: rgba(255, 69, 200, 0.1); color: var(--nc-neon); } @@ -72,7 +74,9 @@ align-items: center; justify-content: center; cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: + color 0.15s, + border-color 0.15s; } .icon-btn:hover { @@ -93,7 +97,16 @@ opacity: 1; } -.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); } +.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 index 681bc79..439339f 100644 --- a/src/app/components/move-history/move-history.component.html +++ b/src/app/components/move-history/move-history.component.html @@ -4,13 +4,25 @@ } @else { @for (pair of movePairs; track $index) { -
+
{{ pair.white }}
-
+
{{ pair.black ?? '…' }}
} @@ -20,23 +32,61 @@
diff --git a/src/app/components/move-history/move-history.component.ts b/src/app/components/move-history/move-history.component.ts index 0cc6ec0..d881e52 100644 --- a/src/app/components/move-history/move-history.component.ts +++ b/src/app/components/move-history/move-history.component.ts @@ -1,4 +1,12 @@ -import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'; +import { + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + Output, + ViewChild, +} from '@angular/core'; export type MoveNavDirection = 'first' | 'prev' | 'next' | 'last'; @@ -12,7 +20,7 @@ interface MovePair { standalone: true, imports: [], templateUrl: './move-history.component.html', - styleUrl: './move-history.component.css' + styleUrl: './move-history.component.css', }) export class MoveHistoryComponent implements OnChanges { @Input({ required: true }) moves: string[] = []; diff --git a/src/app/components/player-card/player-card.component.css b/src/app/components/player-card/player-card.component.css index ec0c577..0021902 100644 --- a/src/app/components/player-card/player-card.component.css +++ b/src/app/components/player-card/player-card.component.css @@ -7,12 +7,16 @@ border: 1px solid var(--nc-border); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); - transition: border-color 0.2s, box-shadow 0.2s; + 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); + box-shadow: + 0 0 0 1px rgba(255, 69, 200, 0.2), + 0 0 20px rgba(255, 69, 200, 0.1); } .player-avatar { @@ -74,7 +78,11 @@ 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; + transition: + color 0.2s, + border-color 0.2s, + background 0.2s, + text-shadow 0.2s; } .clock.clock-active { diff --git a/src/app/components/player-card/player-card.component.html b/src/app/components/player-card/player-card.component.html index 36bee66..dd9403e 100644 --- a/src/app/components/player-card/player-card.component.html +++ b/src/app/components/player-card/player-card.component.html @@ -1,5 +1,9 @@
-
+
{{ initial }}
diff --git a/src/app/components/player-card/player-card.component.ts b/src/app/components/player-card/player-card.component.ts index 576d361..19facfd 100644 --- a/src/app/components/player-card/player-card.component.ts +++ b/src/app/components/player-card/player-card.component.ts @@ -5,7 +5,7 @@ import { Component, Input } from '@angular/core'; standalone: true, imports: [], templateUrl: './player-card.component.html', - styleUrl: './player-card.component.css' + styleUrl: './player-card.component.css', }) export class PlayerCardComponent { @Input({ required: true }) name = ''; diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.css b/src/app/components/promotion-dialog/promotion-dialog.component.css index aedff18..437eb4d 100644 --- a/src/app/components/promotion-dialog/promotion-dialog.component.css +++ b/src/app/components/promotion-dialog/promotion-dialog.component.css @@ -12,7 +12,9 @@ justify-content: center; opacity: 0; visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; z-index: 1000; &.open { diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.html b/src/app/components/promotion-dialog/promotion-dialog.component.html index 420e0bc..95f1c18 100644 --- a/src/app/components/promotion-dialog/promotion-dialog.component.html +++ b/src/app/components/promotion-dialog/promotion-dialog.component.html @@ -2,8 +2,14 @@

Pawn Promotion

- +
@@ -11,13 +17,18 @@
@for (piece of promotionPieces; track piece.type) { - + }
-
\ No newline at end of file +
diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.ts b/src/app/components/promotion-dialog/promotion-dialog.component.ts index 279339a..263becd 100644 --- a/src/app/components/promotion-dialog/promotion-dialog.component.ts +++ b/src/app/components/promotion-dialog/promotion-dialog.component.ts @@ -13,7 +13,7 @@ interface PromotionPieceOption { standalone: true, imports: [CommonModule], templateUrl: './promotion-dialog.component.html', - styleUrl: './promotion-dialog.component.css' + styleUrl: './promotion-dialog.component.css', }) export class PromotionDialogComponent { @Input() isOpen = false; @@ -24,7 +24,7 @@ export class PromotionDialogComponent { { type: 'queen', label: 'Queen', symbol: '♕' }, { type: 'rook', label: 'Rook', symbol: '♖' }, { type: 'bishop', label: 'Bishop', symbol: '♗' }, - { type: 'knight', label: 'Knight', symbol: '♘' } + { type: 'knight', label: 'Knight', symbol: '♘' }, ]; selectPromotion(type: PromotionPieceType): void { diff --git a/src/app/components/register-dialog/register-dialog.component.css b/src/app/components/register-dialog/register-dialog.component.css index dbc3953..012ae74 100644 --- a/src/app/components/register-dialog/register-dialog.component.css +++ b/src/app/components/register-dialog/register-dialog.component.css @@ -22,7 +22,7 @@ position: relative; width: min(440px, 100%); background: - radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.10), transparent 60%), + radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.1), transparent 60%), linear-gradient(180deg, #0a0612 0%, #06060d 100%); border: 1px solid var(--auth-neon-soft); border-radius: 14px; @@ -179,7 +179,10 @@ form { font-family: var(--auth-mono); font-size: 13px; letter-spacing: 0.3px; - transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; + transition: + border-color 0.18s ease, + box-shadow 0.18s ease, + background 0.18s ease; } .dialog-input::placeholder { @@ -190,7 +193,9 @@ form { outline: none; border-color: var(--auth-neon); background: rgba(20, 6, 26, 0.7); - box-shadow: 0 0 0 3px rgba(255, 69, 200, 0.15), 0 0 18px rgba(255, 69, 200, 0.18); + box-shadow: + 0 0 0 3px rgba(255, 69, 200, 0.15), + 0 0 18px rgba(255, 69, 200, 0.18); } .text-danger { @@ -308,26 +313,56 @@ form { } @keyframes scanline { - 0% { transform: translateY(-100%); } - 100% { transform: translateY(300%); } + 0% { + transform: translateY(-100%); + } + 100% { + transform: translateY(300%); + } } @keyframes pulse-glow { - 0%, 100% { opacity: 0.85; } - 50% { opacity: 1; } + 0%, + 100% { + opacity: 0.85; + } + 50% { + opacity: 1; + } } @keyframes dialog-in { - from { opacity: 0; transform: translateY(8px) scale(0.96); } - to { opacity: 1; transform: translateY(0) scale(1); } + from { + opacity: 0; + transform: translateY(8px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } } @keyframes backdrop-in { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes shake { - 0%, 100% { transform: translateX(0); } - 20%, 60% { transform: translateX(-4px); } - 40%, 80% { transform: translateX(4px); } + 0%, + 100% { + transform: translateX(0); + } + 20%, + 60% { + transform: translateX(-4px); + } + 40%, + 80% { + transform: translateX(4px); + } } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } diff --git a/src/app/components/register-dialog/register-dialog.component.html b/src/app/components/register-dialog/register-dialog.component.html index 123ad58..36ba2a4 100644 --- a/src/app/components/register-dialog/register-dialog.component.html +++ b/src/app/components/register-dialog/register-dialog.component.html @@ -11,8 +11,14 @@
- + @if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) { Username must be at least 3 characters } @@ -20,8 +26,14 @@
- + @if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) { Please enter a valid email } @@ -30,16 +42,28 @@
- + @if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) { Min 6 characters }
- +
@@ -49,7 +73,11 @@
-
-
- Already have an account?Sign in -
+
Already have an account?Sign in
diff --git a/src/app/components/register-dialog/register-dialog.component.ts b/src/app/components/register-dialog/register-dialog.component.ts index ae3e9cc..2140784 100644 --- a/src/app/components/register-dialog/register-dialog.component.ts +++ b/src/app/components/register-dialog/register-dialog.component.ts @@ -9,7 +9,7 @@ import { AuthDialogService } from '../../services/auth-dialog.service'; standalone: true, imports: [CommonModule, ReactiveFormsModule], templateUrl: './register-dialog.component.html', - styleUrl: './register-dialog.component.css' + styleUrl: './register-dialog.component.css', }) export class RegisterDialogComponent { @Output() onClose = new EventEmitter(); @@ -28,7 +28,7 @@ export class RegisterDialogComponent { username: ['', [Validators.required, Validators.minLength(3)]], email: ['', [Validators.required, Validators.email]], password: ['', [Validators.required, Validators.minLength(6)]], - confirmPassword: ['', [Validators.required]] + confirmPassword: ['', [Validators.required]], }); } @@ -58,9 +58,8 @@ export class RegisterDialogComponent { error: (err) => { this.isLoading = false; this.registerForm.enable(); - this.errorMessage = - err.error?.message || 'Registration failed. Please try again.'; - } + 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 859c441..f1dcd2c 100644 --- a/src/app/components/toolbar/toolbar.component.css +++ b/src/app/components/toolbar/toolbar.component.css @@ -1,46 +1,58 @@ /* ============ 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; + --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; } :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; + --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; } /* ============ NAV CONTAINER ============ */ .nc-nav { position: fixed; - top: 0; left: 0; right: 0; + top: 0; + left: 0; + right: 0; height: 56px; z-index: 100; display: flex; @@ -49,7 +61,12 @@ 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; + font-family: + 'Space Grotesk', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; } /* ============ LOGO ============ */ @@ -63,7 +80,8 @@ } .nc-logo-mark { - width: 24px; height: 24px; + width: 24px; + height: 24px; background: var(--nc-accent); display: flex; align-items: center; @@ -105,19 +123,25 @@ transition: color 0.15s; } -.nc-link:hover { color: var(--nc-text); } +.nc-link:hover { + color: var(--nc-text); +} .nc-link::after { - content: ""; + content: ''; position: absolute; - bottom: 2px; left: 14px; right: 14px; + bottom: 2px; + left: 14px; + right: 14px; height: 1px; background: var(--nc-accent); opacity: 0; transition: opacity 0.15s; } -.nc-link:hover::after { opacity: 1; } +.nc-link:hover::after { + opacity: 1; +} /* ============ RIGHT CLUSTER ============ */ .nc-right { @@ -130,7 +154,8 @@ /* ============ BELL ============ */ .nc-bell { - width: 36px; height: 36px; + width: 36px; + height: 36px; border: 1px solid var(--nc-border); background: transparent; color: var(--nc-text-muted); @@ -139,7 +164,9 @@ align-items: center; justify-content: center; position: relative; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; font-family: inherit; } @@ -152,8 +179,10 @@ /* ============ BADGE ============ */ .nc-badge { position: absolute; - top: 5px; right: 5px; - min-width: 14px; height: 14px; + top: 5px; + right: 5px; + min-width: 14px; + height: 14px; border-radius: 7px; background: var(--nc-accent-badge); color: var(--nc-badge-text); @@ -181,7 +210,9 @@ letter-spacing: 0.1em; text-transform: uppercase; cursor: pointer; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; } .nc-games-btn:hover { @@ -201,7 +232,9 @@ cursor: pointer; color: var(--nc-text-muted); font-family: inherit; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; } .nc-profile:hover, @@ -216,7 +249,9 @@ letter-spacing: 0.04em; } -.nc-chevron { opacity: 0.5; } +.nc-chevron { + opacity: 0.5; +} /* ============ AVATAR ============ */ .nc-avatar { @@ -231,11 +266,21 @@ flex-shrink: 0; } -.nc-avatar-sm { width: 26px; height: 26px; font-size: 11px; } -.nc-avatar-md { width: 40px; height: 40px; font-size: 17px; } +.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; } +.nc-dropdown-wrap { + position: relative; +} /* ============ POPOVERS ============ */ .nc-popover { @@ -252,11 +297,13 @@ } /* ============ NOTIFICATIONS PANEL ============ */ -.nc-notif { width: 360px; } +.nc-notif { + width: 360px; +} .nc-notif-header { padding: 14px 18px; - border-bottom: 1px solid rgba(255,255,255,0.06); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); display: flex; justify-content: space-between; align-items: center; @@ -270,7 +317,10 @@ font-weight: 600; } -.nc-notif-list { max-height: 420px; overflow-y: auto; } +.nc-notif-list { + max-height: 420px; + overflow-y: auto; +} .nc-notif-empty { padding: 24px 18px; @@ -282,36 +332,44 @@ .nc-notif-row { padding: 14px 18px; - border-bottom: 1px solid rgba(255,255,255,0.04); + 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 { + background: rgba(255, 255, 255, 0.03); +} .nc-notif-row.is-unread::before { - content: ""; + content: ''; position: absolute; - left: 6px; top: 22px; - width: 4px; height: 4px; + left: 6px; + top: 22px; + width: 4px; + height: 4px; border-radius: 50%; background: var(--nc-unread-dot); } .nc-notif-icon { - width: 32px; height: 32px; + width: 32px; + height: 32px; flex-shrink: 0; - background: rgba(255,255,255,0.04); - border: 1px solid rgba(255,255,255,0.12); + 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-body { + flex: 1; + min-width: 0; +} .nc-notif-text { font-size: 13px; @@ -319,7 +377,9 @@ line-height: 1.35; } -.nc-notif-text b { font-weight: 600; } +.nc-notif-text b { + font-weight: 600; +} .nc-notif-meta { font-size: 10px; @@ -360,7 +420,7 @@ .nc-btn-decline { background: transparent; color: var(--nc-text-muted); - border: 1px solid rgba(255,255,255,0.15); + border: 1px solid rgba(255, 255, 255, 0.15); } .nc-btn-accept:disabled, @@ -371,7 +431,7 @@ .nc-notif-footer { padding: 10px 18px; - border-top: 1px solid rgba(255,255,255,0.06); + border-top: 1px solid rgba(255, 255, 255, 0.06); } .nc-view-all { @@ -388,14 +448,18 @@ transition: color 0.15s; } -.nc-view-all:hover { color: var(--nc-text-muted); } +.nc-view-all:hover { + color: var(--nc-text-muted); +} /* ============ PROFILE MENU ============ */ -.nc-menu { width: 250px; } +.nc-menu { + width: 250px; +} .nc-menu-header { padding: 16px 16px 14px; - border-bottom: 1px solid rgba(255,255,255,0.06); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); display: flex; gap: 12px; align-items: center; @@ -417,10 +481,12 @@ letter-spacing: 0.06em; } -.nc-menu-group { padding: 6px 0; } +.nc-menu-group { + padding: 6px 0; +} .nc-menu-group + .nc-menu-group { - border-top: 1px solid rgba(255,255,255,0.06); + border-top: 1px solid rgba(255, 255, 255, 0.06); } .nc-menu-item { @@ -436,7 +502,9 @@ border: none; width: 100%; text-align: left; - transition: background 0.12s, color 0.12s; + transition: + background 0.12s, + color 0.12s; } .nc-menu-item:hover { @@ -444,35 +512,52 @@ 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-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; } +.nc-menu-icon { + opacity: 0.85; + display: inline-flex; +} +.nc-menu-label { + flex: 1; +} /* ============ DARK MODE TOGGLE PILL ============ */ .nc-toggle { - width: 28px; height: 16px; + width: 28px; + height: 16px; border-radius: 8px; - background: rgba(255,255,255,0.15); + 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.is-on { + background: var(--nc-accent); +} .nc-toggle::after { - content: ""; + content: ''; position: absolute; - top: 2px; left: 2px; - width: 12px; height: 12px; + top: 2px; + left: 2px; + width: 12px; + height: 12px; border-radius: 50%; background: #fff; transition: left 0.2s; } -.nc-toggle.is-on::after { left: 14px; } +.nc-toggle.is-on::after { + left: 14px; +} /* ============ AUTH BUTTONS (logged out) ============ */ .nc-auth-btn { @@ -486,11 +571,14 @@ letter-spacing: 0.1em; text-transform: uppercase; cursor: pointer; - transition: background 0.15s, color 0.15s, border-color 0.15s; + transition: + background 0.15s, + color 0.15s, + border-color 0.15s; } .nc-auth-btn:hover { - background: rgba(255,255,255,0.06); + background: rgba(255, 255, 255, 0.06); color: var(--nc-text); } @@ -506,6 +594,13 @@ } /* ============ 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; } +.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 8522bd5..d859706 100644 --- a/src/app/components/toolbar/toolbar.component.html +++ b/src/app/components/toolbar/toolbar.component.html @@ -1,5 +1,4 @@
} - - - @if (notifOpen) { -
-
- Challenges -
- -
- @if (incomingChallenges.length === 0) { -
No pending challenges
- } - @for (challenge of incomingChallenges; track challenge.id) { -
-
- - - - - - -
-
-
- {{ challenge.challenger.name }} challenged you to a - {{ getTimeControlDisplay(challenge) }} game. -
-
- {{ challenge.challenger.rating }} · {{ challenge.timeControl.type ?? 'Custom' }} · {{ getExpirationInfo(challenge) }} -
-
- - -
-
-
- } -
- -
- } -
- - - - -
- + + @if (profileOpen) { +
+
+
{{ getInitial() }}
+
+
{{ user.username }}
+
{{ user.rating }} · @{{ user.username }}
+
+
+ +
+ + + + + +
+ +
+ +
-
- -
- - - - - -
- -
- -
+ }
- } -
- } @else { - - - - - + + + }
@if (showLoginDialog) { - + } @if (showRegisterDialog) { - + } diff --git a/src/app/components/toolbar/toolbar.component.ts b/src/app/components/toolbar/toolbar.component.ts index 2aa57a8..52f96de 100644 --- a/src/app/components/toolbar/toolbar.component.ts +++ b/src/app/components/toolbar/toolbar.component.ts @@ -18,7 +18,7 @@ import { Challenge } from '../../models/challenge.models'; standalone: true, imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent], templateUrl: './toolbar.component.html', - styleUrl: './toolbar.component.css' + styleUrl: './toolbar.component.css', }) export class ToolbarComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); @@ -46,35 +46,36 @@ export class ToolbarComponent implements OnInit { ngOnInit(): void { this.destroyRef.onDestroy(() => this.stopPolling()); - this.authService.currentUser$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(user => { - this.currentUser = user; - if (user) { - this.challengeWs.connect(); - this.startPolling(); - } else { - this.challengeWs.disconnect(); - this.stopPolling(); - this.navigatedChallengeIds.clear(); - this.challengeEventService.clear(); - } - }); + this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { + this.currentUser = user; + if (user) { + this.challengeWs.connect(); + this.startPolling(); + } else { + this.challengeWs.disconnect(); + this.stopPolling(); + this.navigatedChallengeIds.clear(); + this.challengeEventService.clear(); + } + }); this.authDialogService.dialogState$ .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(state => { + .subscribe((state) => { this.showLoginDialog = state === 'login'; this.showRegisterDialog = state === 'register'; }); - this.themeService.darkMode$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(isDark => { this.isDarkMode = isDark; }); + this.themeService.darkMode$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((isDark) => { + this.isDarkMode = isDark; + }); - this.challengeEventService.getIncomingChallenges$() + this.challengeEventService + .getIncomingChallenges$() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(challenges => { this.incomingChallenges = challenges; }); + .subscribe((challenges) => { + this.incomingChallenges = challenges; + }); } private startPolling(): void { @@ -91,7 +92,7 @@ export class ToolbarComponent implements OnInit { private fetchChallenges(): void { this.challengeService.listChallenges().subscribe({ - next: response => { + next: (response) => { const incoming = response.in ?? response.incoming ?? []; this.challengeEventService.setIncomingChallenges(incoming); @@ -104,7 +105,7 @@ export class ToolbarComponent implements OnInit { } } } - } + }, }); } @@ -197,6 +198,12 @@ export class ToolbarComponent implements OnInit { void this.router.navigate(['/bots']); } + goToAnalysis(): void { + this.profileOpen = false; + this.notifOpen = false; + void this.router.navigate(['/analysis']); + } + onLoginSuccess(): void { this.closeLoginDialog(); } @@ -227,12 +234,14 @@ export class ToolbarComponent implements OnInit { if (this.acceptingId || this.decliningId) return; this.acceptingId = challenge.id; this.challengeService.acceptChallenge(challenge.id).subscribe({ - next: accepted => { + next: (accepted) => { this.acceptingId = null; this.challengeEventService.onChallengeAccepted(accepted); if (accepted.gameId) void this.router.navigate(['/game', accepted.gameId]); }, - error: () => { this.acceptingId = null; } + error: () => { + this.acceptingId = null; + }, }); } @@ -245,7 +254,9 @@ export class ToolbarComponent implements OnInit { this.decliningId = null; this.challengeEventService.removeChallenge(challenge.id); }, - error: () => { this.decliningId = null; } + error: () => { + this.decliningId = null; + }, }); } } diff --git a/src/app/core/config.loader.ts b/src/app/core/config.loader.ts index 9361f5b..dba4e30 100644 --- a/src/app/core/config.loader.ts +++ b/src/app/core/config.loader.ts @@ -8,6 +8,6 @@ export function loadRuntimeConfig() { const derivedWsUrl = `${wsProtocol}://${window.location.host}`; return { apiUrl: config.API_URL || '', - wsUrl: config.WEBSOCKET_URL || derivedWsUrl + wsUrl: config.WEBSOCKET_URL || derivedWsUrl, }; } diff --git a/src/app/models/analysis.models.ts b/src/app/models/analysis.models.ts new file mode 100644 index 0000000..53f1bc1 --- /dev/null +++ b/src/app/models/analysis.models.ts @@ -0,0 +1,25 @@ +export interface AnalysisRequest { + fen: string; + depth: number; +} + +export interface AnalysisResponse { + eval: number; + winChance: number; + depth: number; + bestMove: string; + continuations: string[]; +} + +export type MoveQuality = 'brilliant' | 'best' | 'good' | 'inaccuracy' | 'mistake' | 'blunder'; + +export interface AnnotatedMove { + san: string; + fen: string; + evalBefore: number | null; + evalAfter: number | null; + quality: MoveQuality | null; + bestMove: string | null; + winChanceBefore: number | null; + winChanceAfter: number | null; +} diff --git a/src/app/models/challenge.models.ts b/src/app/models/challenge.models.ts index 9683480..934e54b 100644 --- a/src/app/models/challenge.models.ts +++ b/src/app/models/challenge.models.ts @@ -1,49 +1,55 @@ -export type ChallengeStatus = 'created' | 'pending' | 'accepted' | 'declined' | 'cancelled' | 'expired'; +export type ChallengeStatus = + | 'created' + | 'pending' + | 'accepted' + | 'declined' + | 'cancelled' + | 'expired'; export type PlayerColor = 'white' | 'black' | 'random'; export interface Player { - id: string; - name: string; - rating: number; + id: string; + name: string; + rating: number; } export interface TimeControl { - type: string | null; - limit: number | null; - increment: number | null; + type: string | null; + limit: number | null; + increment: number | null; } export interface Challenge { - id: string; - challenger: Player; - destUser: Player; - variant: string; - color: PlayerColor; - timeControl: TimeControl; - status: ChallengeStatus; - declineReason: string | null; - gameId: string | null; - expiresAt: string; - createdAt: string; + id: string; + challenger: Player; + destUser: Player; + variant: string; + color: PlayerColor; + timeControl: TimeControl; + status: ChallengeStatus; + declineReason: string | null; + gameId: string | null; + expiresAt: string; + createdAt: string; } export interface SendChallengeRequest { - timeControl: { - limitSeconds: number; - incrementSeconds: number; - }; - color?: PlayerColor; - ttlSeconds?: number; + timeControl: { + limitSeconds: number; + incrementSeconds: number; + }; + color?: PlayerColor; + ttlSeconds?: number; } export interface ListChallengesResponse { - 'in'?: Challenge[]; - 'out'?: Challenge[]; - incoming?: Challenge[]; - outgoing?: Challenge[]; + in?: Challenge[]; + out?: Challenge[]; + incoming?: Challenge[]; + outgoing?: Challenge[]; } export interface DeclineChallengeRequest { - reason?: string; + reason?: string; } diff --git a/src/app/pages/analysis/analysis.component.css b/src/app/pages/analysis/analysis.component.css new file mode 100644 index 0000000..0a6e8ae --- /dev/null +++ b/src/app/pages/analysis/analysis.component.css @@ -0,0 +1,542 @@ +/* ============================================================ + DESIGN TOKENS — dark mode (default) +============================================================ */ +:host { + --nc-neon: #ff45c8; + --nc-neon-soft: rgba(255, 69, 200, 0.55); + --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-bg: rgba(255, 122, 122, 0.08); + --nc-danger-soft: rgba(255, 122, 122, 0.3); + --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-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --nc-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace; +} + +:host-context(html:not([data-theme='dark'])) { + --nc-neon: #ff3dbb; + --nc-neon-soft: rgba(255, 61, 187, 0.55); + --nc-bg: transparent; + --nc-surface: rgba(26, 24, 56, 0.72); + --nc-surface-solid: rgba(26, 24, 56, 0.97); + --nc-text: #fff; + --nc-text-muted: rgba(255, 255, 255, 0.72); + --nc-text-dim: rgba(255, 255, 255, 0.45); + --nc-border: rgba(255, 255, 255, 0.1); + --nc-border-strong: rgba(255, 255, 255, 0.18); + --nc-btn-bg: rgba(255, 255, 255, 0.05); + --nc-btn-hover-bg: rgba(255, 255, 255, 0.1); +} + +/* ============================================================ + SHELL +============================================================ */ +.analysis-shell { + min-height: 100dvh; + background: var(--nc-bg); + font-family: var(--nc-sans); + color: var(--nc-text); + position: relative; +} + +.analysis-shell::before { + content: ''; + position: fixed; + inset: 0; + background: + radial-gradient(ellipse 80% 50% at 20% 100%, rgba(74, 41, 98, 0.12), transparent 60%), + radial-gradient(ellipse 60% 40% at 90% 0%, rgba(41, 74, 98, 0.18), transparent 60%); + pointer-events: none; + z-index: 0; +} + +/* ============================================================ + PAGE CONTAINER +============================================================ */ +.page { + position: relative; + z-index: 1; + max-width: 1320px; + margin: 0 auto; + padding: 28px 32px 60px; +} + +/* ============================================================ + 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; +} + +.crumb-link { + color: var(--nc-text-dim); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 6px; + transition: color 0.15s; +} + +.crumb-link:hover { + color: var(--nc-neon); +} +.crumb-sep { + color: var(--nc-text-dim); + opacity: 0.5; +} +.crumb-current { + color: var(--nc-text-muted); +} + +/* ============================================================ + PAGE HEADER +============================================================ */ +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 28px; + padding-bottom: 20px; + border-bottom: 1px solid var(--nc-border); +} + +.page-title h1 { + margin: 0 0 4px; + font-size: 26px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--nc-text); +} + +.page-subtitle { + margin: 0; + font-family: var(--nc-mono); + font-size: 11px; + color: var(--nc-text-dim); + letter-spacing: 0.06em; +} + +/* ============================================================ + ERROR +============================================================ */ +.state-error { + padding: 14px 16px; + margin-bottom: 20px; + color: var(--nc-danger); + background: var(--nc-danger-bg); + border: 1px solid var(--nc-danger-soft); + font-size: 13px; +} + +/* ============================================================ + INPUT SECTION +============================================================ */ +.input-section { + background: var(--nc-surface); + border: 1px solid var(--nc-border); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + padding: 20px; + margin-bottom: 28px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.mode-tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--nc-border); + padding-bottom: 12px; +} + +.mode-tab { + background: transparent; + border: 1px solid transparent; + color: var(--nc-text-muted); + font-family: var(--nc-mono); + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + padding: 6px 14px; + cursor: pointer; + transition: + color 0.15s, + border-color 0.15s; +} + +.mode-tab:hover { + color: var(--nc-text); +} + +.mode-tab.active { + color: var(--nc-neon); + border-color: var(--nc-neon-soft); +} + +.input-row { + display: flex; + gap: 8px; +} + +.text-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: 12px; + padding: 9px 12px; + letter-spacing: 0.04em; + outline: none; + transition: border-color 0.15s; +} + +.text-input:focus { + border-color: var(--nc-neon-soft); +} +.text-input::placeholder { + color: var(--nc-text-dim); +} + +.pgn-col { + display: flex; + flex-direction: column; + gap: 8px; +} + +.text-area { + background: var(--nc-clock-bg); + border: 1px solid var(--nc-border); + color: var(--nc-text); + font-family: var(--nc-mono); + font-size: 12px; + padding: 10px 12px; + letter-spacing: 0.04em; + resize: vertical; + outline: none; + transition: border-color 0.15s; + line-height: 1.5; +} + +.text-area:focus { + border-color: var(--nc-neon-soft); +} +.text-area::placeholder { + color: var(--nc-text-dim); +} + +.depth-row { + display: flex; + align-items: center; + gap: 10px; + padding-top: 6px; + border-top: 1px solid var(--nc-border); +} + +.depth-label { + font-family: var(--nc-mono); + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--nc-text-dim); +} + +.depth-input { + width: 56px; + background: var(--nc-clock-bg); + border: 1px solid var(--nc-border); + color: var(--nc-text); + font-family: var(--nc-mono); + font-size: 13px; + padding: 7px 10px; + outline: none; + transition: border-color 0.15s; + text-align: center; +} + +.depth-input:focus { + border-color: var(--nc-neon-soft); +} + +/* ============================================================ + 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; + flex-shrink: 0; +} + +.btn:hover:not([disabled]) { + background: var(--nc-btn-hover-bg); + border-color: var(--nc-text-muted); +} + +.btn[disabled] { + opacity: 0.5; + cursor: not-allowed; +} + +.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; +} + +.btn-primary:hover:not([disabled]) { + box-shadow: 0 0 20px rgba(255, 69, 200, 0.5); +} + +.btn-analyse { + margin-left: auto; + background: rgba(255, 69, 200, 0.12); + color: var(--nc-neon); + border-color: var(--nc-neon-soft); +} + +.btn-analyse:hover:not([disabled]) { + background: rgba(255, 69, 200, 0.2); +} + +/* ============================================================ + MAIN GRID +============================================================ */ +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 28px; + align-items: start; +} + +/* ============================================================ + BOARD COLUMN +============================================================ */ +.board-col { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 520px; + width: 100%; + margin: 0 auto; +} + +.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); +} + +.nav-bar { + display: flex; + gap: 4px; + justify-content: center; + padding: 6px 0; +} + +.icon-btn { + background: var(--nc-btn-bg); + border: 1px solid var(--nc-border); + color: var(--nc-text-muted); + padding: 8px 10px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: + color 0.15s, + background 0.15s; +} + +.icon-btn:hover { + color: var(--nc-neon); + background: var(--nc-btn-hover-bg); +} + +/* ============================================================ + 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; +} + +.timeline-body { + padding: 10px 16px; +} + +/* ============================================================ + EVAL GRID +============================================================ */ +.eval-grid { + gap: 8px; +} + +.eval-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.eval-row--col { + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.eval-label { + font-family: var(--nc-mono); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--nc-text-dim); + flex-shrink: 0; +} + +.eval-value { + font-size: 14px; + font-weight: 600; + color: var(--nc-text); +} + +.eval-value.mono { + font-family: var(--nc-mono); + font-size: 12px; +} + +.eval-value.positive { + color: var(--nc-success); +} +.eval-value.negative { + color: var(--nc-danger); +} + +.continuation { + font-size: 11px; + color: var(--nc-text-muted); + line-height: 1.6; + word-break: break-all; +} + +/* ============================================================ + 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; + } + .page-title h1 { + font-size: 20px; + } +} diff --git a/src/app/pages/analysis/analysis.component.html b/src/app/pages/analysis/analysis.component.html new file mode 100644 index 0000000..a2ce620 --- /dev/null +++ b/src/app/pages/analysis/analysis.component.html @@ -0,0 +1,355 @@ +
+
+ + + + + + + + @if (errorMessage) { +
{{ errorMessage }}
+ } + + +
+ +
+ + + +
+ + + @if (inputMode === 'fen') { +
+ + +
+ } + + + @if (inputMode === 'pgn') { +
+ + +
+ } + + + @if (inputMode === 'game') { +
+ + +
+ } + + +
+ + + +
+
+ + +
+ +
+
+ +
+ + + @if (fenHistory.length > 1) { + + } +
+ + + +
+
+
diff --git a/src/app/pages/analysis/analysis.component.ts b/src/app/pages/analysis/analysis.component.ts new file mode 100644 index 0000000..1aeb908 --- /dev/null +++ b/src/app/pages/analysis/analysis.component.ts @@ -0,0 +1,244 @@ +import { Component, DestroyRef, inject, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { switchMap, of } from 'rxjs'; + +import { ChessBoardComponent } from '../../components/chess-board/chess-board.component'; +import { EvalTimelineComponent } from '../../components/eval-timeline/eval-timeline.component'; +import { AnnotatedMoveListComponent } from '../../components/annotated-move-list/annotated-move-list.component'; + +import { GameApiService } from '../../services/game-api.service'; +import { AnalysisService } from '../../services/analysis.service'; + +import { AnnotatedMove, AnalysisResponse } from '../../models/analysis.models'; +import { GameFull } from '../../models/game.models'; +import { getErrorMessage } from '../../core/http/error-message.util'; + +const ANALYSIS_DEPTH_DEFAULT = 14; +const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; + +type InputMode = 'fen' | 'pgn' | 'game'; + +@Component({ + selector: 'app-analysis', + standalone: true, + imports: [ + RouterLink, + FormsModule, + ChessBoardComponent, + EvalTimelineComponent, + AnnotatedMoveListComponent, + ], + templateUrl: './analysis.component.html', + styleUrl: './analysis.component.css', +}) +export class AnalysisComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly gameApi = inject(GameApiService); + private readonly analysisService = inject(AnalysisService); + private readonly destroyRef = inject(DestroyRef); + + // ── State ───────────────────────────────────────────────── + inputMode: InputMode = 'fen'; + fenInput = ''; + pgnInput = ''; + depth = ANALYSIS_DEPTH_DEFAULT; + + loading = false; + analysing = false; + errorMessage = ''; + + currentFen = STARTING_FEN; + game: GameFull | null = null; + + /** FEN for each ply (index 0 = position before move 0 was played) */ + fenHistory: string[] = [STARTING_FEN]; + annotatedMoves: AnnotatedMove[] = []; + activePly: number | null = null; + + /** Single-position analysis result (for custom FEN/PGN input) */ + positionAnalysis: AnalysisResponse | null = null; + + get displayFen(): string { + if (this.activePly !== null && this.fenHistory[this.activePly + 1]) { + return this.fenHistory[this.activePly + 1]; + } + return this.currentFen; + } + + get hasAnnotations(): boolean { + return this.annotatedMoves.length > 0; + } + + // ── Lifecycle ───────────────────────────────────────────── + ngOnInit(): void { + // Support /analysis?gameId=xxx deep-link + this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { + const gameId = params.get('gameId'); + if (gameId) { + this.inputMode = 'game'; + this.loadGame(gameId); + } + }); + } + + // ── Input mode ───────────────────────────────────────────── + setInputMode(mode: InputMode): void { + this.inputMode = mode; + this.errorMessage = ''; + } + + // ── Load FEN ────────────────────────────────────────────── + loadFen(): void { + const fen = this.fenInput.trim(); + if (!fen) return; + this.reset(); + this.currentFen = fen; + this.fenHistory = [fen]; + this.analyseSinglePosition(fen); + } + + // ── Load PGN ────────────────────────────────────────────── + loadPgn(): void { + const pgn = this.pgnInput.trim(); + if (!pgn) return; + this.reset(); + this.loading = true; + this.gameApi + .importPgn(pgn) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (game) => { + this.loading = false; + this.applyGame(game); + }, + error: (err) => { + this.loading = false; + this.errorMessage = getErrorMessage(err, 'Could not import PGN.'); + }, + }); + } + + // ── Load game by ID ─────────────────────────────────────── + loadGame(gameId: string): void { + this.reset(); + this.loading = true; + this.gameApi + .getGame(gameId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (game) => { + this.loading = false; + this.applyGame(game); + }, + error: (err) => { + this.loading = false; + this.errorMessage = getErrorMessage(err, 'Could not load game.'); + }, + }); + } + + // ── Run full analysis ───────────────────────────────────── + runAnalysis(): void { + if (this.fenHistory.length < 2) { + this.analyseSinglePosition(this.currentFen); + return; + } + this.analysing = true; + this.errorMessage = ''; + const sans = + this.annotatedMoves.length > 0 + ? this.annotatedMoves.map((m) => m.san) + : (this.game?.state.moves ?? []); + + this.analysisService + .analyzeGame(sans, this.fenHistory, this.depth) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (annotated) => { + this.annotatedMoves = annotated; + this.analysing = false; + }, + error: (err) => { + this.errorMessage = getErrorMessage(err, 'Analysis failed.'); + this.analysing = false; + }, + }); + } + + // ── Board navigation ────────────────────────────────────── + navigateToPly(ply: number): void { + this.activePly = ply; + } + + navigateHistory(direction: 'first' | 'prev' | 'next' | 'last'): void { + const total = this.fenHistory.length - 1; + const current = this.activePly ?? total; + let next: number; + switch (direction) { + case 'first': + next = 0; + break; + case 'prev': + next = Math.max(0, current - 1); + break; + case 'next': + next = Math.min(total, current + 1); + break; + default: + next = total; + break; + } + this.activePly = next >= total ? null : next; + } + + // ── Private helpers ─────────────────────────────────────── + private reset(): void { + this.errorMessage = ''; + this.annotatedMoves = []; + this.positionAnalysis = null; + this.activePly = null; + this.game = null; + this.fenHistory = [STARTING_FEN]; + this.currentFen = STARTING_FEN; + } + + private applyGame(game: GameFull): void { + this.game = game; + this.currentFen = game.state.fen; + // Build a flat FEN history from scratch using moves array + // The server gives us the final FEN. We reconstruct history by + // storing the final FEN; full per-ply history requires per-move API calls + // which is out of scope here — we store what we have and allow analysis to proceed. + this.fenHistory = [game.state.fen]; + // Seed annotated moves with san strings, no quality yet + this.annotatedMoves = game.state.moves.map((san) => ({ + san, + fen: game.state.fen, + evalBefore: null, + evalAfter: null, + quality: null, + bestMove: null, + winChanceBefore: null, + winChanceAfter: null, + })); + } + + private analyseSinglePosition(fen: string): void { + this.analysing = true; + this.analysisService + .analyzePosition(fen, this.depth) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (result) => { + this.positionAnalysis = result; + this.analysing = false; + }, + error: (err) => { + this.errorMessage = getErrorMessage(err, 'Analysis failed.'); + this.analysing = false; + }, + }); + } +} diff --git a/src/app/pages/bots/bots.component.css b/src/app/pages/bots/bots.component.css index 5fc7a1a..f298d2f 100644 --- a/src/app/pages/bots/bots.component.css +++ b/src/app/pages/bots/bots.component.css @@ -10,7 +10,7 @@ --nc-success: #5ee5a1; --nc-danger: #ff7a7a; --nc-warn: #ffd166; - --nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --nc-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: block; min-height: 100vh; @@ -33,131 +33,352 @@ --nc-warn: #b45309; } -.b-shell { padding-top: 72px; min-height: 100vh; } -.page { max-width: 680px; margin: 0 auto; padding: 32px 20px 64px; } +.b-shell { + padding-top: 72px; + min-height: 100vh; +} +.page { + max-width: 680px; + margin: 0 auto; + padding: 32px 20px 64px; +} -.crumb { display: flex; align-items: center; gap: 8px; margin-bottom: 28px; - font-size: 11px; color: var(--nc-text-dim); letter-spacing: 0.06em; } -.crumb-link { display: inline-flex; align-items: center; gap: 4px; - color: var(--nc-text-dim); text-decoration: none; transition: color 0.15s; } -.crumb-link:hover { color: var(--nc-neon); } -.crumb-sep { opacity: 0.35; } -.crumb-current { color: var(--nc-text-muted); } +.crumb { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 28px; + font-size: 11px; + color: var(--nc-text-dim); + letter-spacing: 0.06em; +} +.crumb-link { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--nc-text-dim); + text-decoration: none; + transition: color 0.15s; +} +.crumb-link:hover { + color: var(--nc-neon); +} +.crumb-sep { + opacity: 0.35; +} +.crumb-current { + color: var(--nc-text-muted); +} -.page-header { margin-bottom: 24px; } -.title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } -.page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; } -.page-sub { font-size: 13px; color: var(--nc-text-muted); margin: 0; line-height: 1.5; } +.page-header { + margin-bottom: 24px; +} +.title-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} +.page-title { + font-size: 22px; + font-weight: 700; + margin: 0; + letter-spacing: -0.02em; +} +.page-sub { + font-size: 13px; + color: var(--nc-text-muted); + margin: 0; + line-height: 1.5; +} .btn-new { - display: inline-flex; align-items: center; gap: 6px; - padding: 7px 14px; border-radius: 8px; border: none; - background: var(--nc-neon); color: #fff; - font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity 0.15s; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: 8px; + border: none; + background: var(--nc-neon); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} +.btn-new:hover { + opacity: 0.85; } -.btn-new:hover { opacity: 0.85; } /* Create panel */ .create-panel { - border: 1px solid var(--nc-border-strong); border-radius: 12px; - background: var(--nc-surface); padding: 16px; margin-bottom: 20px; -} -.create-inner { display: flex; flex-direction: column; gap: 10px; } -.field-label { font-size: 11px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.06em; color: var(--nc-text-muted); } -.create-row { display: flex; gap: 8px; align-items: center; } -.text-input { - flex: 1; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--nc-border-strong); - background: rgba(255,255,255,0.04); color: var(--nc-text); font-size: 14px; + border-radius: 12px; + background: var(--nc-surface); + padding: 16px; + margin-bottom: 20px; +} +.create-inner { + display: flex; + flex-direction: column; + gap: 10px; +} +.field-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--nc-text-muted); +} +.create-row { + display: flex; + gap: 8px; + align-items: center; +} +.text-input { + flex: 1; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--nc-border-strong); + background: rgba(255, 255, 255, 0.04); + color: var(--nc-text); + font-size: 14px; +} +.text-input:focus { + outline: 2px solid var(--nc-neon); + outline-offset: 1px; + border-color: transparent; +} +.text-input:disabled { + opacity: 0.5; +} +.error-text { + font-size: 12px; + color: var(--nc-danger); + margin: 0; } -.text-input:focus { outline: 2px solid var(--nc-neon); outline-offset: 1px; border-color: transparent; } -.text-input:disabled { opacity: 0.5; } -.error-text { font-size: 12px; color: var(--nc-danger); margin: 0; } /* Buttons */ .btn-primary { - padding: 8px 16px; border-radius: 8px; border: none; - background: var(--nc-neon); color: #fff; font-size: 13px; font-weight: 600; - cursor: pointer; white-space: nowrap; transition: opacity 0.15s; + padding: 8px 16px; + border-radius: 8px; + border: none; + background: var(--nc-neon); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: opacity 0.15s; +} +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; } -.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .btn-ghost { - padding: 8px 14px; border-radius: 8px; border: 1px solid var(--nc-border-strong); - background: transparent; color: var(--nc-text-muted); font-size: 13px; cursor: pointer; + padding: 8px 14px; + border-radius: 8px; + border: 1px solid var(--nc-border-strong); + background: transparent; + color: var(--nc-text-muted); + font-size: 13px; + cursor: pointer; +} +.btn-ghost:disabled { + opacity: 0.5; + cursor: not-allowed; } -.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; } /* States */ -.state-msg { display: flex; align-items: center; gap: 10px; - padding: 24px 0; color: var(--nc-text-muted); font-size: 13px; } -.pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--nc-neon); - flex-shrink: 0; animation: pulse 1.4s ease-in-out infinite; } -@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } } +.state-msg { + display: flex; + align-items: center; + gap: 10px; + padding: 24px 0; + color: var(--nc-text-muted); + font-size: 13px; +} +.pulse { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--nc-neon); + flex-shrink: 0; + animation: pulse 1.4s ease-in-out infinite; +} +@keyframes pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.4; + transform: scale(0.85); + } +} -.empty-state { display: flex; flex-direction: column; align-items: center; - gap: 8px; padding: 64px 0; text-align: center; } -.empty-icon { color: var(--nc-text-dim); margin-bottom: 4px; } -.empty-title { font-size: 15px; font-weight: 600; margin: 0; } -.empty-sub { font-size: 13px; color: var(--nc-text-muted); margin: 0; } +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 64px 0; + text-align: center; +} +.empty-icon { + color: var(--nc-text-dim); + margin-bottom: 4px; +} +.empty-title { + font-size: 15px; + font-weight: 600; + margin: 0; +} +.empty-sub { + font-size: 13px; + color: var(--nc-text-muted); + margin: 0; +} /* Bot list */ -.bot-list { display: flex; flex-direction: column; gap: 8px; } +.bot-list { + display: flex; + flex-direction: column; + gap: 8px; +} .bot-card { - border: 1px solid var(--nc-border); border-radius: 12px; - background: var(--nc-surface); overflow: hidden; + border: 1px solid var(--nc-border); + border-radius: 12px; + background: var(--nc-surface); + overflow: hidden; } .bot-main { - display: flex; align-items: center; gap: 12px; + display: flex; + align-items: center; + gap: 12px; padding: 14px 16px; } .bot-avatar { - width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0; - background: var(--nc-neon); color: #fff; - display: flex; align-items: center; justify-content: center; - font-size: 16px; font-weight: 700; + width: 36px; + height: 36px; + border-radius: 50%; + flex-shrink: 0; + background: var(--nc-neon); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 700; +} +.bot-info { + display: flex; + flex-direction: column; + gap: 3px; + flex: 1; + min-width: 0; +} +.bot-name { + font-size: 14px; + font-weight: 600; +} +.bot-meta { + font-size: 11px; + color: var(--nc-text-muted); +} +.bot-actions { + display: flex; + gap: 8px; + flex-shrink: 0; } -.bot-info { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; } -.bot-name { font-size: 14px; font-weight: 600; } -.bot-meta { font-size: 11px; color: var(--nc-text-muted); } -.bot-actions { display: flex; gap: 8px; flex-shrink: 0; } .btn-token { - display: inline-flex; align-items: center; gap: 5px; - padding: 6px 12px; border-radius: 7px; border: 1px solid var(--nc-border-strong); - background: transparent; color: var(--nc-text-muted); font-size: 12px; cursor: pointer; - transition: background 0.15s, color 0.15s; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 12px; + border-radius: 7px; + border: 1px solid var(--nc-border-strong); + background: transparent; + color: var(--nc-text-muted); + font-size: 12px; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; +} +.btn-token:hover, +.btn-token.active { + background: rgba(255, 69, 200, 0.1); + color: var(--nc-neon); + border-color: var(--nc-neon); +} +.btn-token:disabled { + opacity: 0.5; + cursor: not-allowed; } -.btn-token:hover, .btn-token.active { background: rgba(255,69,200,0.1); color: var(--nc-neon); border-color: var(--nc-neon); } -.btn-token:disabled { opacity: 0.5; cursor: not-allowed; } .btn-danger { - padding: 6px 12px; border-radius: 7px; border: 1px solid rgba(255,122,122,0.3); - background: transparent; color: var(--nc-danger); font-size: 12px; cursor: pointer; + padding: 6px 12px; + border-radius: 7px; + border: 1px solid rgba(255, 122, 122, 0.3); + background: transparent; + color: var(--nc-danger); + font-size: 12px; + cursor: pointer; transition: background 0.15s; } -.btn-danger:hover { background: rgba(255,122,122,0.1); } -.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-danger:hover { + background: rgba(255, 122, 122, 0.1); +} +.btn-danger:disabled { + opacity: 0.5; + cursor: not-allowed; +} /* Token panel */ .token-panel { - border-top: 1px solid var(--nc-border); padding: 12px 16px; - display: flex; flex-direction: column; gap: 10px; + border-top: 1px solid var(--nc-border); + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 10px; } .token-warning { - display: flex; align-items: flex-start; gap: 8px; - font-size: 12px; color: var(--nc-warn); + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 12px; + color: var(--nc-warn); +} +.token-row { + display: flex; + align-items: center; + gap: 8px; } -.token-row { display: flex; align-items: center; gap: 8px; } .token-value { - flex: 1; font-family: monospace; font-size: 11px; - background: rgba(0,0,0,0.2); border-radius: 6px; - padding: 8px 10px; word-break: break-all; - color: var(--nc-text-muted); border: 1px solid var(--nc-border); + flex: 1; + font-family: monospace; + font-size: 11px; + background: rgba(0, 0, 0, 0.2); + border-radius: 6px; + padding: 8px 10px; + word-break: break-all; + color: var(--nc-text-muted); + border: 1px solid var(--nc-border); } .btn-copy { - padding: 6px 12px; border-radius: 7px; border: 1px solid var(--nc-border-strong); - background: transparent; color: var(--nc-text-muted); font-size: 12px; - cursor: pointer; white-space: nowrap; transition: color 0.15s; + padding: 6px 12px; + border-radius: 7px; + border: 1px solid var(--nc-border-strong); + background: transparent; + color: var(--nc-text-muted); + font-size: 12px; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s; flex-shrink: 0; } -.btn-copy:hover { color: var(--nc-success); } +.btn-copy:hover { + color: var(--nc-success); +} diff --git a/src/app/pages/bots/bots.component.html b/src/app/pages/bots/bots.component.html index 2cee025..7f35629 100644 --- a/src/app/pages/bots/bots.component.html +++ b/src/app/pages/bots/bots.component.html @@ -1,11 +1,18 @@
-
diff --git a/src/app/pages/bots/bots.component.ts b/src/app/pages/bots/bots.component.ts index a4915fe..bd8ffdd 100644 --- a/src/app/pages/bots/bots.component.ts +++ b/src/app/pages/bots/bots.component.ts @@ -11,7 +11,7 @@ import { Bot, BotWithToken } from '../../models/bot.models'; standalone: true, imports: [CommonModule, RouterLink, FormsModule], templateUrl: './bots.component.html', - styleUrl: './bots.component.css' + styleUrl: './bots.component.css', }) export class BotsComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); @@ -36,11 +36,17 @@ export class BotsComponent implements OnInit { loadBots(): void { this.loading = true; - this.botService.list() + this.botService + .list() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: bots => { this.bots = bots; this.loading = false; }, - error: () => { this.loading = false; } + next: (bots) => { + this.bots = bots; + this.loading = false; + }, + error: () => { + this.loading = false; + }, }); } @@ -66,10 +72,10 @@ export class BotsComponent implements OnInit { this.bots = [bot, ...this.bots]; this.revealedTokens[bot.id] = bot.token; }, - error: err => { + error: (err) => { this.creating = false; this.createError = err.error?.message ?? err.error?.error ?? 'Failed to create bot.'; - } + }, }); } @@ -80,11 +86,13 @@ export class BotsComponent implements OnInit { } this.revealingId = botId; this.botService.rotateToken(botId).subscribe({ - next: token => { + next: (token) => { this.revealingId = null; this.revealedTokens[botId] = token; }, - error: () => { this.revealingId = null; } + error: () => { + this.revealingId = null; + }, }); } @@ -93,7 +101,9 @@ export class BotsComponent implements OnInit { if (!token) return; navigator.clipboard.writeText(token).then(() => { this.copiedId = botId; - setTimeout(() => { this.copiedId = null; }, 2000); + setTimeout(() => { + this.copiedId = null; + }, 2000); }); } @@ -102,10 +112,12 @@ export class BotsComponent implements OnInit { this.botService.delete(botId).subscribe({ next: () => { this.deletingId = null; - this.bots = this.bots.filter(b => b.id !== botId); + this.bots = this.bots.filter((b) => b.id !== botId); delete this.revealedTokens[botId]; }, - error: () => { this.deletingId = null; } + error: () => { + this.deletingId = null; + }, }); } } diff --git a/src/app/pages/challenges/challenges.component.css b/src/app/pages/challenges/challenges.component.css index 2e65fbc..a1fa971 100644 --- a/src/app/pages/challenges/challenges.component.css +++ b/src/app/pages/challenges/challenges.component.css @@ -2,7 +2,14 @@ .challenges-container { min-height: 100vh; - background: linear-gradient(135deg, #04000f 0%, #0e0235 25%, #2d0860 50%, #0e0235 75%, #04000f 100%); + background: linear-gradient( + 135deg, + #04000f 0%, + #0e0235 25%, + #2d0860 50%, + #0e0235 75%, + #04000f 100% + ); color: #e0e0e0; padding: 20px; font-family: 'Space Mono', 'Courier New', monospace; diff --git a/src/app/pages/challenges/challenges.component.html b/src/app/pages/challenges/challenges.component.html index 7351234..9a6d042 100644 --- a/src/app/pages/challenges/challenges.component.html +++ b/src/app/pages/challenges/challenges.component.html @@ -1,102 +1,104 @@
-
-

Active Challenges

- -
+
+

Active Challenges

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

Incoming Challenges

-
Loading...
+
+ +
+

Incoming Challenges

+
Loading...
-
-

No incoming challenges

+
+

No incoming challenges

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

Outgoing Challenges

- -
-

No outgoing challenges

-
- -
-
-
- → {{ getOpponentDisplay(challenge) }} - {{ getTimeControlDisplay(challenge) }} -
- -
-
- Status: - - {{ challenge.status | uppercase }} - -
-
- Expires in: - {{ getExpirationInfo(challenge) }} -
-
- -
- -
- -
- -
-
+
+ Expires in: + {{ getExpirationInfo(challenge) }}
+
+ +
+ + +
+ +
+ +
+
-
\ No newline at end of file + + +
+

Outgoing Challenges

+ +
+

No outgoing challenges

+
+ +
+
+
+ → {{ getOpponentDisplay(challenge) }} + {{ getTimeControlDisplay(challenge) }} +
+ +
+
+ Status: + + {{ challenge.status | uppercase }} + +
+
+ Expires in: + {{ getExpirationInfo(challenge) }} +
+
+ +
+ +
+ +
+ +
+
+
+
+
+
diff --git a/src/app/pages/challenges/challenges.component.ts b/src/app/pages/challenges/challenges.component.ts index 8ed4d17..df394a7 100644 --- a/src/app/pages/challenges/challenges.component.ts +++ b/src/app/pages/challenges/challenges.component.ts @@ -8,172 +8,177 @@ import { Challenge } from '../../models/challenge.models'; import { getErrorMessage } from '../../core/http/error-message.util'; @Component({ - selector: 'app-challenges', - standalone: true, - imports: [CommonModule], - templateUrl: './challenges.component.html', - styleUrls: ['./challenges.component.css'] + selector: 'app-challenges', + standalone: true, + imports: [CommonModule], + templateUrl: './challenges.component.html', + styleUrls: ['./challenges.component.css'], }) export class ChallengesComponent implements OnInit, OnDestroy { - private readonly challengeService = inject(ChallengeService); - private readonly challengeEventService = inject(ChallengeEventService); - private readonly router = inject(Router); - private readonly destroyRef = inject(DestroyRef); + private readonly challengeService = inject(ChallengeService); + private readonly challengeEventService = inject(ChallengeEventService); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); - incomingChallenges: Challenge[] = []; - outgoingChallenges: Challenge[] = []; - loading = false; - errorMessage = ''; + incomingChallenges: Challenge[] = []; + outgoingChallenges: Challenge[] = []; + loading = false; + errorMessage = ''; - private pollInterval: any = null; - private readonly pollIntervalMs = 5000; // Poll every 5 seconds + private pollInterval: any = null; + private readonly pollIntervalMs = 5000; // Poll every 5 seconds - ngOnInit(): void { - this.loadChallenges(true); + ngOnInit(): void { + this.loadChallenges(true); - // Subscribe to challenge events - this.challengeEventService.getChallengeReceived$() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.loadChallenges(); - }); + // Subscribe to challenge events + this.challengeEventService + .getChallengeReceived$() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.loadChallenges(); + }); - // Start polling for challenge updates - this.startPolling(); + // Start polling for challenge updates + this.startPolling(); + } + + ngOnDestroy(): void { + this.stopPolling(); + } + + private startPolling(): void { + this.pollInterval = setInterval(() => { + this.loadChallenges(false); + }, this.pollIntervalMs); + } + + private stopPolling(): void { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + } + + loadChallenges(showLoader = false): void { + if (showLoader) { + this.loading = true; + this.errorMessage = ''; } - ngOnDestroy(): void { - this.stopPolling(); + this.challengeService + .listChallenges() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (response) => { + this.incomingChallenges = response.in || response.incoming || []; + this.outgoingChallenges = response.out || response.outgoing || []; + if (showLoader) { + this.loading = false; + } + }, + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Failed to load challenges'); + if (showLoader) { + this.loading = false; + } + }, + }); + } + + acceptChallenge(challenge: Challenge): void { + this.challengeService + .acceptChallenge(challenge.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (acceptedChallenge) => { + this.challengeEventService.onChallengeAccepted(acceptedChallenge); + this.loadChallenges(); + if (acceptedChallenge.gameId) { + void this.router.navigate(['/game', acceptedChallenge.gameId]); + } else { + this.errorMessage = 'Challenge accepted, but no game was created.'; + } + }, + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Failed to accept challenge'); + }, + }); + } + + declineChallenge(challenge: Challenge): void { + this.challengeService + .declineChallenge(challenge.id, { reason: 'generic' }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.challengeEventService.removeChallenge(challenge.id); + this.loadChallenges(); + }, + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Failed to decline challenge'); + }, + }); + } + + cancelChallenge(challenge: Challenge): void { + this.challengeService + .cancelChallenge(challenge.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.loadChallenges(); + }, + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Failed to cancel challenge'); + }, + }); + } + + goBack(): void { + void this.router.navigate(['/']); + } + + openGame(challenge: Challenge): void { + if (!challenge.gameId) { + this.errorMessage = 'Missing game id for this challenge.'; + return; + } + void this.router.navigate(['/game', challenge.gameId]); + } + + getTimeControlDisplay(challenge: Challenge): string { + const { limit, increment } = challenge.timeControl; + if (!limit || !increment) { + return 'Unlimited'; + } + const minutes = Math.floor(limit / 60); + return `${minutes}+${increment}`; + } + + getChallengerDisplay(challenge: Challenge): string { + return challenge.challenger.name; + } + + getOpponentDisplay(challenge: Challenge): string { + return challenge.destUser.name; + } + + getExpirationInfo(challenge: Challenge): string { + const expiresAt = new Date(challenge.expiresAt); + const now = new Date(); + const diffMs = expiresAt.getTime() - now.getTime(); + + if (diffMs <= 0 || challenge.status === 'expired') { + return 'Expired'; } - private startPolling(): void { - this.pollInterval = setInterval(() => { - this.loadChallenges(false); - }, this.pollIntervalMs); + const minutes = Math.floor(diffMs / 60000); + if (minutes > 60) { + const hours = Math.floor(minutes / 60); + return `${hours}h`; } - private stopPolling(): void { - if (this.pollInterval) { - clearInterval(this.pollInterval); - this.pollInterval = null; - } - } - - loadChallenges(showLoader = false): void { - if (showLoader) { - this.loading = true; - this.errorMessage = ''; - } - - this.challengeService.listChallenges() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: (response) => { - this.incomingChallenges = response.in || response.incoming || []; - this.outgoingChallenges = response.out || response.outgoing || []; - if (showLoader) { - this.loading = false; - } - }, - error: (error) => { - this.errorMessage = getErrorMessage(error, 'Failed to load challenges'); - if (showLoader) { - this.loading = false; - } - } - }); - } - - acceptChallenge(challenge: Challenge): void { - this.challengeService.acceptChallenge(challenge.id) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: (acceptedChallenge) => { - this.challengeEventService.onChallengeAccepted(acceptedChallenge); - this.loadChallenges(); - if (acceptedChallenge.gameId) { - void this.router.navigate(['/game', acceptedChallenge.gameId]); - } else { - this.errorMessage = 'Challenge accepted, but no game was created.'; - } - }, - error: (error) => { - this.errorMessage = getErrorMessage(error, 'Failed to accept challenge'); - } - }); - } - - declineChallenge(challenge: Challenge): void { - this.challengeService.declineChallenge(challenge.id, { reason: 'generic' }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.challengeEventService.removeChallenge(challenge.id); - this.loadChallenges(); - }, - error: (error) => { - this.errorMessage = getErrorMessage(error, 'Failed to decline challenge'); - } - }); - } - - cancelChallenge(challenge: Challenge): void { - this.challengeService.cancelChallenge(challenge.id) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.loadChallenges(); - }, - error: (error) => { - this.errorMessage = getErrorMessage(error, 'Failed to cancel challenge'); - } - }); - } - - goBack(): void { - void this.router.navigate(['/']); - } - - openGame(challenge: Challenge): void { - if (!challenge.gameId) { - this.errorMessage = 'Missing game id for this challenge.'; - return; - } - void this.router.navigate(['/game', challenge.gameId]); - } - - getTimeControlDisplay(challenge: Challenge): string { - const { limit, increment } = challenge.timeControl; - if (!limit || !increment) { - return 'Unlimited'; - } - const minutes = Math.floor(limit / 60); - return `${minutes}+${increment}`; - } - - getChallengerDisplay(challenge: Challenge): string { - return challenge.challenger.name; - } - - getOpponentDisplay(challenge: Challenge): string { - return challenge.destUser.name; - } - - getExpirationInfo(challenge: Challenge): string { - const expiresAt = new Date(challenge.expiresAt); - const now = new Date(); - const diffMs = expiresAt.getTime() - now.getTime(); - - if (diffMs <= 0 || challenge.status === 'expired') { - return 'Expired'; - } - - const minutes = Math.floor(diffMs / 60000); - if (minutes > 60) { - const hours = Math.floor(minutes / 60); - return `${hours}h`; - } - - return `${minutes}m`; - } + return `${minutes}m`; + } } diff --git a/src/app/pages/game/game.component.css b/src/app/pages/game/game.component.css index 3885907..9124a26 100644 --- a/src/app/pages/game/game.component.css +++ b/src/app/pages/game/game.component.css @@ -23,8 +23,8 @@ --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; + --nc-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --nc-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace; } /* ============================================================ @@ -40,17 +40,17 @@ --nc-text: #fff; --nc-text-muted: rgba(255, 255, 255, 0.72); --nc-text-dim: rgba(255, 255, 255, 0.45); - --nc-border: rgba(255, 255, 255, 0.10); + --nc-border: rgba(255, 255, 255, 0.1); --nc-border-strong: rgba(255, 255, 255, 0.18); --nc-warning: #ffb13a; - --nc-warning-soft: rgba(255, 177, 58, 0.40); + --nc-warning-soft: rgba(255, 177, 58, 0.4); --nc-danger: #ff7a7a; - --nc-danger-soft: rgba(255, 122, 122, 0.30); + --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.30); + --nc-clock-bg: rgba(0, 0, 0, 0.3); --nc-btn-bg: rgba(255, 255, 255, 0.05); - --nc-btn-hover-bg: rgba(255, 255, 255, 0.10); + --nc-btn-hover-bg: rgba(255, 255, 255, 0.1); --nc-seg-bg: rgba(0, 0, 0, 0.28); } @@ -66,7 +66,7 @@ } .game-shell::before { - content: ""; + content: ''; position: fixed; inset: 0; background: @@ -78,7 +78,7 @@ :host-context(html:not([data-theme='dark'])) .game-shell::before { background: - radial-gradient(ellipse 80% 50% at 20% 100%, rgba(212, 77, 74, 0.10), transparent 60%), + radial-gradient(ellipse 80% 50% at 20% 100%, rgba(212, 77, 74, 0.1), transparent 60%), radial-gradient(ellipse 60% 40% at 90% 0%, rgba(74, 41, 98, 0.22), transparent 60%); } @@ -116,9 +116,16 @@ transition: color 0.15s; } -.crumb-link:hover { color: var(--nc-neon); } -.crumb-sep { color: var(--nc-text-dim); opacity: 0.5; } -.crumb-current { color: var(--nc-text-muted); } +.crumb-link:hover { + color: var(--nc-neon); +} +.crumb-sep { + color: var(--nc-text-dim); + opacity: 0.5; +} +.crumb-current { + color: var(--nc-text-muted); +} /* ============================================================ GAME HEADER @@ -177,7 +184,10 @@ color: var(--nc-text-muted); } -.game-id strong { color: var(--nc-text); font-weight: 500; } +.game-id strong { + color: var(--nc-text); + font-weight: 500; +} .meta-dot { width: 3px; @@ -197,9 +207,15 @@ transition: color 0.15s; } -.copy-btn:hover { color: var(--nc-neon); } +.copy-btn:hover { + color: var(--nc-neon); +} -.header-actions { display: flex; gap: 8px; align-items: center; } +.header-actions { + display: flex; + gap: 8px; + align-items: center; +} /* ============================================================ BUTTONS @@ -218,7 +234,9 @@ display: inline-flex; align-items: center; gap: 6px; - transition: background 0.15s, border-color 0.15s; + transition: + background 0.15s, + border-color 0.15s; } .btn:hover { @@ -236,7 +254,9 @@ flex-shrink: 0; } -.btn-primary:hover { box-shadow: 0 0 20px rgba(255, 69, 200, 0.5); } +.btn-primary:hover { + box-shadow: 0 0 20px rgba(255, 69, 200, 0.5); +} .btn-ghost { background: transparent; @@ -253,7 +273,9 @@ transition: color 0.15s; } -.btn-ghost:hover { color: var(--nc-neon); } +.btn-ghost:hover { + color: var(--nc-neon); +} /* ============================================================ STATE MESSAGES (loading / error) @@ -324,11 +346,21 @@ } @keyframes slideIn { - from { opacity: 0; transform: translateY(-10px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } } -.completion-title { font-size: 15px; font-weight: 700; color: var(--nc-neon); } +.completion-title { + font-size: 15px; + font-weight: 700; + color: var(--nc-neon); +} .completion-sub { font-family: var(--nc-mono); @@ -347,11 +379,16 @@ text-decoration: none; border-bottom: 1px solid var(--nc-border-strong); padding-bottom: 2px; - transition: color 0.15s, border-color 0.15s; + transition: + color 0.15s, + border-color 0.15s; flex-shrink: 0; } -.completion-link:hover { color: var(--nc-neon); border-color: var(--nc-neon-soft); } +.completion-link:hover { + color: var(--nc-neon); + border-color: var(--nc-neon-soft); +} /* ============================================================ MAIN GRID @@ -387,10 +424,14 @@ :host-context(html:not([data-theme='dark'])) .status-strip { background: rgba(255, 61, 187, 0.06); - border-color: rgba(255, 61, 187, 0.20); + border-color: rgba(255, 61, 187, 0.2); } -.status-left { display: inline-flex; align-items: center; gap: 10px; } +.status-left { + display: inline-flex; + align-items: center; + gap: 10px; +} .status-pulse { width: 8px; @@ -403,11 +444,22 @@ } @keyframes pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.35; transform: scale(0.7); } + 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-text { + font-weight: 500; + color: var(--nc-text); + letter-spacing: 0.02em; +} .status-side { font-family: var(--nc-mono); @@ -425,22 +477,32 @@ 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); + 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.35), 0 0 0 1px rgba(255, 61, 187, 0.08); + box-shadow: + 0 8px 40px rgba(0, 0, 0, 0.35), + 0 0 0 1px rgba(255, 61, 187, 0.08); } .board-wrap.reviewing { border-color: var(--nc-warning-soft); - box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 177, 58, 0.18); + box-shadow: + 0 8px 40px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(255, 177, 58, 0.18); } /* ============================================================ SIDE COLUMN ============================================================ */ -.side { display: flex; flex-direction: column; gap: 12px; } +.side { + display: flex; + flex-direction: column; + gap: 12px; +} .side-card { background: var(--nc-surface); @@ -459,7 +521,9 @@ user-select: none; } -.side-card-summary::-webkit-details-marker { display: none; } +.side-card-summary::-webkit-details-marker { + display: none; +} .side-card-title { font-size: 11px; @@ -477,9 +541,17 @@ 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); } +.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; @@ -491,7 +563,10 @@ /* ============================================================ UCI INPUT ============================================================ */ -.uci-row { display: flex; gap: 6px; } +.uci-row { + display: flex; + gap: 6px; +} .uci-input { flex: 1; @@ -506,10 +581,19 @@ transition: border-color 0.15s; } -.uci-input:focus { border-color: var(--nc-neon-soft); } -.uci-input::placeholder { color: var(--nc-text-dim); } +.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; } +.uci-hint { + margin: 0; + font-size: 11px; + color: var(--nc-text-dim); + line-height: 1.4; +} /* ============================================================ BOARD DESIGN SEGMENTED CONTROL @@ -531,10 +615,16 @@ font-family: var(--nc-sans); letter-spacing: 0.08em; cursor: pointer; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; } -.seg-btn.active { background: var(--nc-neon); color: #fff; font-weight: 700; } +.seg-btn.active { + background: var(--nc-neon); + color: #fff; + font-weight: 700; +} /* ============================================================ RESIGN CONFIRM OVERLAY @@ -588,7 +678,9 @@ font-weight: 700; } -.btn-danger-solid:hover { opacity: 0.88; } +.btn-danger-solid:hover { + opacity: 0.88; +} /* ============================================================ TOAST @@ -607,23 +699,41 @@ letter-spacing: 0.08em; z-index: 500; opacity: 0; - transition: opacity 0.2s, transform 0.2s; + 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); } +.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; } + .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; } + .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 f8587ac..a8f291f 100644 --- a/src/app/pages/game/game.component.html +++ b/src/app/pages/game/game.component.html @@ -1,16 +1,25 @@ + (closed)="facade.onPromotionClosed()" +/>
-
@@ -226,7 +303,9 @@

Your opponent will be declared the winner.

- +
diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts index 7bfaf36..bed7082 100644 --- a/src/app/pages/game/game.component.ts +++ b/src/app/pages/game/game.component.ts @@ -28,7 +28,7 @@ const BOARD_THEME_KEY = 'nowchess.boardTheme'; ], providers: [GameFacade], templateUrl: './game.component.html', - styleUrl: './game.component.css' + styleUrl: './game.component.css', }) export class GameComponent implements OnInit, OnDestroy { private readonly route = inject(ActivatedRoute); @@ -141,11 +141,15 @@ export class GameComponent implements OnInit, OnDestroy { // ── Copy helpers ───────────────────────────────────────────── copyGameId(): void { - void navigator.clipboard?.writeText(this.facade.gameId).then(() => this.showToast('Game ID copied')); + 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')); + void navigator.clipboard + ?.writeText(window.location.href) + .then(() => this.showToast('Link copied')); } // ── Board actions ───────────────────────────────────────────── @@ -166,7 +170,9 @@ export class GameComponent implements OnInit, OnDestroy { 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 minutes = Math.floor(totalSeconds / 60) + .toString() + .padStart(2, '0'); const seconds = (totalSeconds % 60).toString().padStart(2, '0'); return `${minutes}:${seconds}`; } @@ -188,15 +194,19 @@ 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.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)); - if ((this.whiteTimerMs !== null && this.whiteTimerMs <= 0 && clock.whiteRemainingMs > 0) || - (this.blackTimerMs !== null && this.blackTimerMs <= 0 && clock.blackRemainingMs > 0)) { + if ( + (this.whiteTimerMs !== null && this.whiteTimerMs <= 0 && clock.whiteRemainingMs > 0) || + (this.blackTimerMs !== null && this.blackTimerMs <= 0 && clock.blackRemainingMs > 0) + ) { this.facade.errorMessage = ''; } } diff --git a/src/app/pages/game/game.facade.ts b/src/app/pages/game/game.facade.ts index 390e735..1aa9670 100644 --- a/src/app/pages/game/game.facade.ts +++ b/src/app/pages/game/game.facade.ts @@ -32,7 +32,7 @@ export class GameFacade implements OnDestroy { private boardSelection: BoardSelection = { selectedSquare: null, highlightedSquares: [], - selectedSquareMoves: [] + selectedSquareMoves: [], }; private pendingPromotionMoves: LegalMove[] = []; @@ -82,11 +82,19 @@ export class GameFacade implements OnDestroy { let next: number; switch (direction) { - case 'first': next = this.sessionStartPly; break; - case 'prev': next = Math.max(this.sessionStartPly, current - 1); break; - case 'next': next = Math.min(totalPly, current + 1); break; + case 'first': + next = this.sessionStartPly; + break; + case 'prev': + next = Math.max(this.sessionStartPly, current - 1); + break; + case 'next': + next = Math.min(totalPly, current + 1); + break; case 'last': - default: next = totalPly; break; + default: + next = totalPly; + break; } if (next === totalPly) { @@ -113,12 +121,17 @@ export class GameFacade implements OnDestroy { } // Handle move selection - if (this.boardSelection.selectedSquare && this.boardSelection.highlightedSquares.includes(square)) { - const selectedMove = this.boardSelection.selectedSquareMoves.find((move) => move.to === square); + if ( + this.boardSelection.selectedSquare && + this.boardSelection.highlightedSquares.includes(square) + ) { + const selectedMove = this.boardSelection.selectedSquareMoves.find( + (move) => move.to === square, + ); if (selectedMove) { // If multiple promotion outcomes exist for the target, ask player to choose one. const promotionMoves = this.boardSelection.selectedSquareMoves.filter( - (move) => move.to === square && !!move.promotion + (move) => move.to === square && !!move.promotion, ); if (promotionMoves.length > 0) { this.pendingPromotionMoves = promotionMoves; @@ -142,13 +155,13 @@ export class GameFacade implements OnDestroy { this.boardSelection = { selectedSquare: square, highlightedSquares: moves.map((move) => move.to), - selectedSquareMoves: moves + selectedSquareMoves: moves, }; }, (error) => { this.errorMessage = error; this.boardSelection = this.boardSelectionService.clearSelection(); - } + }, ); this.boardSelection = newSelection; } @@ -187,17 +200,19 @@ export class GameFacade implements OnDestroy { }, (error) => { this.errorMessage = error; - } + }, ); }, error: (error) => { this.errorMessage = getErrorMessage(error, 'Move rejected.'); - } + }, }); } onPromotionSelected(promotionPiece: 'queen' | 'rook' | 'bishop' | 'knight'): void { - const selectedPromotionMove = this.pendingPromotionMoves.find((move) => move.promotion === promotionPiece); + const selectedPromotionMove = this.pendingPromotionMoves.find( + (move) => move.promotion === promotionPiece, + ); if (!selectedPromotionMove) { this.errorMessage = 'Selected promotion move is unavailable.'; this.isPromotionDialogOpen = false; @@ -234,7 +249,7 @@ export class GameFacade implements OnDestroy { .subscribe({ error: (error) => { this.errorMessage = getErrorMessage(error, 'Could not resign.'); - } + }, }); } @@ -248,7 +263,7 @@ export class GameFacade implements OnDestroy { }, (error) => { this.errorMessage = error; - } + }, ); } @@ -262,7 +277,7 @@ export class GameFacade implements OnDestroy { }, (error) => { this.errorMessage = error; - } + }, ); } @@ -292,7 +307,7 @@ export class GameFacade implements OnDestroy { error: (error) => { this.errorMessage = getErrorMessage(error, `Could not load game ${this.gameId}.`); this.loading = false; - } + }, }); } @@ -300,7 +315,9 @@ export class GameFacade implements OnDestroy { this.streamService.startStreaming( this.gameId, (event) => this.applyStreamEvent(event), - () => { /* polling fallback — not an error */ } + () => { + /* polling fallback — not an error */ + }, ); } @@ -356,7 +373,7 @@ export class GameFacade implements OnDestroy { }, (error) => { this.errorMessage = error; - } + }, ); } diff --git a/src/app/pages/games/games.component.css b/src/app/pages/games/games.component.css index ca11e68..2165c36 100644 --- a/src/app/pages/games/games.component.css +++ b/src/app/pages/games/games.component.css @@ -9,8 +9,8 @@ --nc-border-strong: rgba(255, 255, 255, 0.15); --nc-success: #5ee5a1; --nc-danger: #ff7a7a; - --nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --nc-mono: "JetBrains Mono", "Fira Code", monospace; + --nc-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --nc-mono: 'JetBrains Mono', 'Fira Code', monospace; display: block; min-height: 100vh; @@ -63,9 +63,13 @@ transition: color 0.15s; } -.crumb-link:hover { color: var(--nc-neon); } +.crumb-link:hover { + color: var(--nc-neon); +} -.crumb-sep { opacity: 0.4; } +.crumb-sep { + opacity: 0.4; +} .crumb-current { color: var(--nc-text-muted); @@ -113,17 +117,23 @@ display: inline-flex; align-items: center; gap: 6px; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; } -.tab-btn:hover { color: var(--nc-text); } +.tab-btn:hover { + color: var(--nc-text); +} .tab-btn.active { background: var(--nc-neon); color: #1a0014; } -:host-context(html:not([data-theme='dark'])) .tab-btn.active { color: #fff; } +:host-context(html:not([data-theme='dark'])) .tab-btn.active { + color: #fff; +} .tab-badge { background: rgba(255, 255, 255, 0.25); @@ -154,8 +164,15 @@ } @keyframes pulse-ring { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.4; transform: scale(0.6); } + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.4; + transform: scale(0.6); + } } /* ── Empty state ────────────────────────── */ @@ -210,9 +227,13 @@ transition: filter 0.15s; } -:host-context(html:not([data-theme='dark'])) .btn-primary { color: #fff; } +:host-context(html:not([data-theme='dark'])) .btn-primary { + color: #fff; +} -.btn-primary:hover { filter: brightness(1.1); } +.btn-primary:hover { + filter: brightness(1.1); +} /* ── Game list ──────────────────────────── */ .game-list { @@ -234,9 +255,13 @@ transition: background 0.12s; } -.game-row:last-child { border-bottom: none; } +.game-row:last-child { + border-bottom: none; +} -.game-row:hover { background: rgba(255, 255, 255, 0.03); } +.game-row:hover { + background: rgba(255, 255, 255, 0.03); +} :host-context(html:not([data-theme='dark'])) .game-row:hover { background: rgba(192, 38, 211, 0.04); @@ -257,7 +282,9 @@ font-weight: 600; } -.player { color: var(--nc-text); } +.player { + color: var(--nc-text); +} .vs-sep { font-size: 10px; @@ -291,11 +318,17 @@ background: var(--nc-text-dim); } -.status-text { color: var(--nc-text-muted); } +.status-text { + color: var(--nc-text-muted); +} -.meta-sep { opacity: 0.4; } +.meta-sep { + opacity: 0.4; +} -.meta-item { color: var(--nc-text-dim); } +.meta-item { + color: var(--nc-text-dim); +} .game-id-label { font-size: 10px; @@ -332,9 +365,13 @@ color: #1a0014; } -:host-context(html:not([data-theme='dark'])) .btn-resume { color: #fff; } +:host-context(html:not([data-theme='dark'])) .btn-resume { + color: #fff; +} -.btn-resume:hover { filter: brightness(1.1); } +.btn-resume:hover { + filter: brightness(1.1); +} .btn-view { background: transparent; @@ -357,7 +394,9 @@ align-items: center; justify-content: center; cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: + color 0.15s, + border-color 0.15s; } .btn-remove:hover { diff --git a/src/app/pages/games/games.component.html b/src/app/pages/games/games.component.html index fee4887..88ad188 100644 --- a/src/app/pages/games/games.component.html +++ b/src/app/pages/games/games.component.html @@ -1,11 +1,18 @@ } - } @else { - @if (finishedGames.length === 0) {
- - - - - + + + + + + +

No game history yet

@@ -131,17 +185,39 @@
-
@@ -149,8 +225,6 @@ }
} - } -
diff --git a/src/app/pages/games/games.component.ts b/src/app/pages/games/games.component.ts index dbbfb0f..8aad9ce 100644 --- a/src/app/pages/games/games.component.ts +++ b/src/app/pages/games/games.component.ts @@ -11,7 +11,11 @@ import { GameFull, GameStatus } from '../../models/game.models'; type GamesTab = 'active' | 'history'; const FINISHED_STATUSES: GameStatus[] = [ - 'checkmate', 'stalemate', 'resign', 'draw', 'insufficientMaterial' + 'checkmate', + 'stalemate', + 'resign', + 'draw', + 'insufficientMaterial', ]; @Component({ @@ -19,7 +23,7 @@ const FINISHED_STATUSES: GameStatus[] = [ standalone: true, imports: [RouterLink], templateUrl: './games.component.html', - styleUrl: './games.component.css' + styleUrl: './games.component.css', }) export class GamesComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); @@ -34,11 +38,9 @@ export class GamesComponent implements OnInit { finishedGames: GameFull[] = []; ngOnInit(): void { - this.authService.currentUser$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((user) => { - if (!user) void this.router.navigate(['/']); - }); + this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { + if (!user) void this.router.navigate(['/']); + }); this.loadGames(); } @@ -68,7 +70,7 @@ export class GamesComponent implements OnInit { drawOffered: 'Draw Offered', fiftyMoveAvailable: 'In Progress', promotionPending: 'In Progress', - insufficientMaterial: 'Draw' + insufficientMaterial: 'Draw', }; return labels[status] ?? status; } @@ -87,12 +89,16 @@ export class GamesComponent implements OnInit { const requests = ids.map((id) => this.gameApi.getGame(id).pipe( catchError((err: unknown) => { - if (typeof err === 'object' && err !== null && (err as { status: number }).status === 404) { + if ( + typeof err === 'object' && + err !== null && + (err as { status: number }).status === 404 + ) { this.gameHistory.removeGame(id); } return of(null); - }) - ) + }), + ), ); forkJoin(requests) diff --git a/src/app/pages/profile/profile.component.css b/src/app/pages/profile/profile.component.css index 595c49e..517a981 100644 --- a/src/app/pages/profile/profile.component.css +++ b/src/app/pages/profile/profile.component.css @@ -15,15 +15,24 @@ } .building-structure { - background: linear-gradient(135deg, var(--bldg-body) 0%, var(--bldg-mid) 50%, var(--bldg-lit) 100%); + background: linear-gradient( + 135deg, + var(--bldg-body) 0%, + var(--bldg-mid) 50%, + var(--bldg-lit) 100% + ); border: 2px solid var(--dlg-border); border-radius: 8px; - box-shadow: var(--bb-glow), inset 0 0 20px rgba(0, 210, 255, 0.1); + box-shadow: + var(--bb-glow), + inset 0 0 20px rgba(0, 210, 255, 0.1); overflow: hidden; } .cityscape-shell.sunset .building-structure { - box-shadow: var(--bb-glow), inset 0 0 20px rgba(255, 120, 40, 0.1); + box-shadow: + var(--bb-glow), + inset 0 0 20px rgba(255, 120, 40, 0.1); } .building-top { @@ -55,12 +64,24 @@ gap: 0; padding: 20px; min-height: 200px; - background: linear-gradient(90deg, rgba(0, 210, 255, 0.03) 0%, transparent 15%, transparent 85%, rgba(0, 210, 255, 0.03) 100%); + background: linear-gradient( + 90deg, + rgba(0, 210, 255, 0.03) 0%, + transparent 15%, + transparent 85%, + rgba(0, 210, 255, 0.03) 100% + ); align-items: center; } .cityscape-shell.sunset .building-main { - background: linear-gradient(90deg, rgba(255, 120, 40, 0.05) 0%, transparent 15%, transparent 85%, rgba(255, 120, 40, 0.05) 100%); + background: linear-gradient( + 90deg, + rgba(255, 120, 40, 0.05) 0%, + transparent 15%, + transparent 85%, + rgba(255, 120, 40, 0.05) 100% + ); } .building-side { @@ -94,7 +115,9 @@ background: linear-gradient(135deg, var(--win-cool) 0%, var(--win-cool) 100%); border: 1px solid rgba(0, 210, 255, 0.5); border-radius: 2px; - box-shadow: 0 0 10px rgba(0, 210, 255, 0.4), inset 0 0 8px rgba(0, 210, 255, 0.2); + box-shadow: + 0 0 10px rgba(0, 210, 255, 0.4), + inset 0 0 8px rgba(0, 210, 255, 0.2); position: relative; animation: windowFlicker 4s ease-in-out infinite; } @@ -102,25 +125,37 @@ .cityscape-shell.sunset .window { background: linear-gradient(135deg, var(--win-warm) 0%, var(--win-warm) 100%); border-color: rgba(255, 120, 40, 0.5); - box-shadow: 0 0 10px rgba(255, 120, 40, 0.4), inset 0 0 8px rgba(255, 120, 40, 0.2); + box-shadow: + 0 0 10px rgba(255, 120, 40, 0.4), + inset 0 0 8px rgba(255, 120, 40, 0.2); animation: windowFlickerSunset 4s ease-in-out infinite; } @keyframes windowFlicker { - 0%, 100% { - box-shadow: 0 0 10px rgba(0, 210, 255, 0.4), inset 0 0 8px rgba(0, 210, 255, 0.2); + 0%, + 100% { + box-shadow: + 0 0 10px rgba(0, 210, 255, 0.4), + inset 0 0 8px rgba(0, 210, 255, 0.2); } 50% { - box-shadow: 0 0 15px rgba(0, 210, 255, 0.6), inset 0 0 12px rgba(0, 210, 255, 0.3); + box-shadow: + 0 0 15px rgba(0, 210, 255, 0.6), + inset 0 0 12px rgba(0, 210, 255, 0.3); } } @keyframes windowFlickerSunset { - 0%, 100% { - box-shadow: 0 0 10px rgba(255, 120, 40, 0.4), inset 0 0 8px rgba(255, 120, 40, 0.2); + 0%, + 100% { + box-shadow: + 0 0 10px rgba(255, 120, 40, 0.4), + inset 0 0 8px rgba(255, 120, 40, 0.2); } 50% { - box-shadow: 0 0 15px rgba(255, 120, 40, 0.6), inset 0 0 12px rgba(255, 120, 40, 0.3); + box-shadow: + 0 0 15px rgba(255, 120, 40, 0.6), + inset 0 0 12px rgba(255, 120, 40, 0.3); } } @@ -137,13 +172,17 @@ border-radius: 4px; padding: 30px; text-align: center; - box-shadow: 0 0 20px rgba(0, 210, 255, 0.3), inset 0 0 15px rgba(0, 210, 255, 0.1); + box-shadow: + 0 0 20px rgba(0, 210, 255, 0.3), + inset 0 0 15px rgba(0, 210, 255, 0.1); min-width: 250px; } .cityscape-shell.sunset .player-display-window { background: linear-gradient(135deg, rgba(182, 64, 255, 0.1) 0%, rgba(182, 64, 255, 0.05) 100%); - box-shadow: 0 0 20px rgba(255, 64, 249, 0.3), inset 0 0 15px rgba(255, 64, 249, 0.1); + box-shadow: + 0 0 20px rgba(255, 64, 249, 0.3), + inset 0 0 15px rgba(255, 64, 249, 0.1); } .player-avatar { @@ -154,7 +193,8 @@ } @keyframes avatarPulse { - 0%, 100% { + 0%, + 100% { transform: scale(1); } 50% { @@ -332,7 +372,12 @@ .building-door { height: 60px; - background: linear-gradient(90deg, var(--bldg-mid) 0%, var(--bldg-body) 50%, var(--bldg-mid) 100%); + background: linear-gradient( + 90deg, + var(--bldg-mid) 0%, + var(--bldg-body) 50%, + var(--bldg-mid) 100% + ); border-top: 1px solid rgba(0, 210, 255, 0.2); border-radius: 0 0 8px 8px; display: flex; @@ -380,7 +425,8 @@ } @keyframes handleGlow { - 0%, 100% { + 0%, + 100% { box-shadow: 0 0 10px rgba(0, 210, 255, 0.6); } 50% { @@ -389,7 +435,8 @@ } @keyframes handleGlowSunset { - 0%, 100% { + 0%, + 100% { box-shadow: 0 0 10px rgba(255, 120, 40, 0.6); } 50% { diff --git a/src/app/pages/profile/profile.component.html b/src/app/pages/profile/profile.component.html index 3a7e63b..affdb68 100644 --- a/src/app/pages/profile/profile.component.html +++ b/src/app/pages/profile/profile.component.html @@ -1,5 +1,4 @@
-
@@ -22,71 +21,85 @@
@if (currentUser; as user) { - -
-
- -
- - -
- -
-
-
-
-
-
- - -
-
-
👤
-
{{ user.username }}
-
PLAYER ID
-
{{ user.id }}
- @if (idCopied) { -
✓ COPIED
- } - @if (usernameCopied) { -
✓ COPIED
- } + +
+
+ +
+ + +
+ +
+
+
+
+
+
+ + +
+
+
👤
+
+ {{ user.username }} +
+
PLAYER ID
+
+ {{ user.id }} +
+ @if (idCopied) { +
✓ COPIED
+ } + @if (usernameCopied) { +
✓ COPIED
+ } +
+
+ + +
+
+
+
+
- - -
-
-
-
-
-
-
- - -
-
-
- RATING - {{ user.rating }} -
-
-
- MEMBER SINCE - {{ user.createdAt | date: 'MMM dd, yyyy' }} + + +
+
+
+ RATING + {{ user.rating }} +
+
+
+ MEMBER SINCE + {{ user.createdAt | date: 'MMM dd, yyyy' }} +
+ + +
+
+
- - -
-
-
+ + +
- - - -
}
diff --git a/src/app/pages/profile/profile.component.ts b/src/app/pages/profile/profile.component.ts index e9b6c32..33595d0 100644 --- a/src/app/pages/profile/profile.component.ts +++ b/src/app/pages/profile/profile.component.ts @@ -19,7 +19,7 @@ interface BackgroundBuilding { standalone: true, imports: [CommonModule], templateUrl: './profile.component.html', - styleUrl: './profile.component.css' + styleUrl: './profile.component.css', }) export class ProfileComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); @@ -36,14 +36,12 @@ export class ProfileComponent implements OnInit { bgBuildings: BackgroundBuilding[] = []; ngOnInit(): void { - this.authService.currentUser$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((user) => { - this.currentUser = user; - if (!user) { - this.router.navigate(['']); - } - }); + this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { + this.currentUser = user; + if (!user) { + this.router.navigate(['']); + } + }); this.themeService.darkMode$ .pipe(takeUntilDestroyed(this.destroyRef)) @@ -87,8 +85,8 @@ export class ProfileComponent implements OnInit { left: `${Math.random() * 100}%`, top: `${Math.random() * 62}%`, '--d': `${(Math.random() * 3 + 1.5).toFixed(1)}s`, - '--dl': `${-(Math.random() * 6).toFixed(1)}s` - } + '--dl': `${-(Math.random() * 6).toFixed(1)}s`, + }, }; }); } @@ -117,11 +115,11 @@ export class ProfileComponent implements OnInit { { l: '85.5%', w: '9%', h: '32vh' }, { l: '88%', w: '5%', h: '20vh' }, { l: '91%', w: '3%', h: '16vh' }, - { l: '94%', w: '6%', h: '27vh' } + { l: '94%', w: '6%', h: '27vh' }, ]; this.bgBuildings = specs.map((spec) => ({ - style: { left: spec.l, width: spec.w, height: spec.h } + style: { left: spec.l, width: spec.w, height: spec.h }, })); } } diff --git a/src/app/pages/tournaments/tournaments.component.css b/src/app/pages/tournaments/tournaments.component.css index 6e40a72..bc9eb45 100644 --- a/src/app/pages/tournaments/tournaments.component.css +++ b/src/app/pages/tournaments/tournaments.component.css @@ -10,7 +10,7 @@ --nc-success: #5ee5a1; --nc-danger: #ff7a7a; --nc-warn: #ffd166; - --nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --nc-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: block; min-height: 100vh; @@ -33,7 +33,10 @@ --nc-warn: #b45309; } -.t-shell { padding-top: 72px; min-height: 100vh; } +.t-shell { + padding-top: 72px; + min-height: 100vh; +} .page { max-width: 760px; @@ -43,151 +46,341 @@ /* Breadcrumb */ .crumb { - display: flex; align-items: center; gap: 8px; - margin-bottom: 28px; font-size: 11px; - color: var(--nc-text-dim); letter-spacing: 0.06em; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 28px; + font-size: 11px; + color: var(--nc-text-dim); + letter-spacing: 0.06em; } .crumb-link { - display: inline-flex; align-items: center; gap: 4px; - color: var(--nc-text-dim); text-decoration: none; + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--nc-text-dim); + text-decoration: none; transition: color 0.15s; } -.crumb-link:hover { color: var(--nc-neon); } -.crumb-sep { opacity: 0.35; } -.crumb-current { color: var(--nc-text-muted); } +.crumb-link:hover { + color: var(--nc-neon); +} +.crumb-sep { + opacity: 0.35; +} +.crumb-current { + color: var(--nc-text-muted); +} /* Header */ -.page-header { margin-bottom: 28px; } +.page-header { + margin-bottom: 28px; +} .page-title-row { - display: flex; align-items: center; justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; margin-bottom: 16px; } -.page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; } +.page-title { + font-size: 22px; + font-weight: 700; + margin: 0; + letter-spacing: -0.02em; +} .btn-new { - display: inline-flex; align-items: center; gap: 6px; - padding: 7px 14px; border-radius: 8px; border: none; - background: var(--nc-neon); color: #fff; - font-size: 13px; font-weight: 600; cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: 8px; + border: none; + background: var(--nc-neon); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; transition: opacity 0.15s; } -.btn-new:hover { opacity: 0.85; } +.btn-new:hover { + opacity: 0.85; +} /* Create dialog */ .dialog-overlay { - position: fixed; inset: 0; z-index: 200; - background: rgba(0,0,0,0.55); backdrop-filter: blur(4px); - display: flex; align-items: center; justify-content: center; + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; padding: 20px; } .dialog-card { - background: var(--nc-bg); border: 1px solid var(--nc-border-strong); - border-radius: 16px; padding: 24px; width: 100%; max-width: 420px; + background: var(--nc-bg); + border: 1px solid var(--nc-border-strong); + border-radius: 16px; + padding: 24px; + width: 100%; + max-width: 420px; } .dialog-head { - display: flex; justify-content: space-between; align-items: center; + display: flex; + justify-content: space-between; + align-items: center; margin-bottom: 20px; } -.dialog-brand { font-size: 14px; font-weight: 700; color: var(--nc-neon); } +.dialog-brand { + font-size: 14px; + font-weight: 700; + color: var(--nc-neon); +} .dialog-close { - background: none; border: none; cursor: pointer; - font-size: 20px; line-height: 1; color: var(--nc-text-muted); + background: none; + border: none; + cursor: pointer; + font-size: 20px; + line-height: 1; + color: var(--nc-text-muted); padding: 0 4px; } -.dialog-close:hover { color: var(--nc-text); } -.dialog-field { display: flex; flex-direction: column; gap: 6px; flex: 1; } -.dialog-label { font-size: 11px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.06em; color: var(--nc-text-muted); } +.dialog-close:hover { + color: var(--nc-text); +} +.dialog-field { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} +.dialog-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--nc-text-muted); +} .dialog-input { - width: 100%; padding: 8px 10px; border-radius: 8px; + width: 100%; + padding: 8px 10px; + border-radius: 8px; border: 1px solid var(--nc-border-strong); - background: rgba(255,255,255,0.04); color: var(--nc-text); - font-size: 14px; box-sizing: border-box; + background: rgba(255, 255, 255, 0.04); + color: var(--nc-text); + font-size: 14px; + box-sizing: border-box; +} +.dialog-input:focus { + outline: 2px solid var(--nc-neon); + outline-offset: 1px; + border-color: transparent; +} +.dialog-row { + display: flex; + gap: 12px; + margin-bottom: 16px; +} +.dialog-field:not(.dialog-row .dialog-field) { + margin-bottom: 16px; } -.dialog-input:focus { outline: 2px solid var(--nc-neon); outline-offset: 1px; border-color: transparent; } -.dialog-row { display: flex; gap: 12px; margin-bottom: 16px; } -.dialog-field:not(.dialog-row .dialog-field) { margin-bottom: 16px; } .dialog-toggle { - display: flex; align-items: center; gap: 10px; cursor: pointer; - margin-bottom: 20px; user-select: none; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + margin-bottom: 20px; + user-select: none; +} +.dialog-toggle input[type='checkbox'] { + display: none; } -.dialog-toggle input[type=checkbox] { display: none; } .toggle-track { - width: 36px; height: 20px; border-radius: 10px; - background: var(--nc-border-strong); flex-shrink: 0; - transition: background 0.2s; position: relative; + width: 36px; + height: 20px; + border-radius: 10px; + background: var(--nc-border-strong); + flex-shrink: 0; + transition: background 0.2s; + position: relative; } .toggle-track::after { - content: ''; position: absolute; top: 3px; left: 3px; - width: 14px; height: 14px; border-radius: 50%; - background: var(--nc-text-muted); transition: transform 0.2s, background 0.2s; + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--nc-text-muted); + transition: + transform 0.2s, + background 0.2s; +} +.dialog-toggle input:checked ~ .toggle-track { + background: var(--nc-neon); +} +.dialog-toggle input:checked ~ .toggle-track::after { + transform: translateX(16px); + background: #fff; +} +.toggle-label { + font-size: 14px; + color: var(--nc-text); } -.dialog-toggle input:checked ~ .toggle-track { background: var(--nc-neon); } -.dialog-toggle input:checked ~ .toggle-track::after { transform: translateX(16px); background: #fff; } -.toggle-label { font-size: 14px; color: var(--nc-text); } .dialog-error { - font-size: 13px; color: var(--nc-danger); - background: rgba(255,122,122,0.1); border-radius: 8px; - padding: 10px 12px; margin-bottom: 16px; + font-size: 13px; + color: var(--nc-danger); + background: rgba(255, 122, 122, 0.1); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 16px; +} +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; } -.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; } .btn-ghost { - padding: 8px 16px; border-radius: 8px; border: 1px solid var(--nc-border-strong); - background: transparent; color: var(--nc-text-muted); font-size: 13px; cursor: pointer; + padding: 8px 16px; + border-radius: 8px; + border: 1px solid var(--nc-border-strong); + background: transparent; + color: var(--nc-text-muted); + font-size: 13px; + cursor: pointer; +} +.btn-ghost:hover { + color: var(--nc-text); + border-color: var(--nc-text-muted); } -.btn-ghost:hover { color: var(--nc-text); border-color: var(--nc-text-muted); } .btn-primary { - padding: 8px 18px; border-radius: 8px; border: none; - background: var(--nc-neon); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; + padding: 8px 18px; + border-radius: 8px; + border: none; + background: var(--nc-neon); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; transition: opacity 0.15s; } -.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} /* Tabs */ -.tabs { display: flex; gap: 4px; } +.tabs { + display: flex; + gap: 4px; +} .tab-btn { - display: inline-flex; align-items: center; gap: 6px; - padding: 6px 14px; border-radius: 8px; border: none; - background: transparent; color: var(--nc-text-muted); - font-size: 13px; font-weight: 500; cursor: pointer; - transition: background 0.15s, color 0.15s; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 8px; + border: none; + background: transparent; + color: var(--nc-text-muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; +} +.tab-btn:hover { + background: var(--nc-border); + color: var(--nc-text); +} +.tab-btn.active { + background: var(--nc-surface); + color: var(--nc-text); + border: 1px solid var(--nc-border-strong); } -.tab-btn:hover { background: var(--nc-border); color: var(--nc-text); } -.tab-btn.active { background: var(--nc-surface); color: var(--nc-text); border: 1px solid var(--nc-border-strong); } .tab-badge { - display: inline-flex; align-items: center; justify-content: center; - min-width: 18px; height: 18px; padding: 0 5px; - border-radius: 9px; background: var(--nc-border-strong); - font-size: 10px; font-weight: 700; color: var(--nc-text-muted); + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: var(--nc-border-strong); + font-size: 10px; + font-weight: 700; + color: var(--nc-text-muted); +} +.live-badge { + background: rgba(94, 229, 161, 0.2); + color: var(--nc-success); } -.live-badge { background: rgba(94, 229, 161, 0.2); color: var(--nc-success); } /* States */ .state-msg { - display: flex; align-items: center; gap: 10px; - padding: 24px 0; color: var(--nc-text-muted); font-size: 13px; + display: flex; + align-items: center; + gap: 10px; + padding: 24px 0; + color: var(--nc-text-muted); + font-size: 13px; +} +.state-msg.small { + padding: 12px 0; } -.state-msg.small { padding: 12px 0; } .pulse { - width: 8px; height: 8px; border-radius: 50%; - background: var(--nc-neon); flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--nc-neon); + flex-shrink: 0; animation: pulse 1.4s ease-in-out infinite; } @keyframes pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.4; transform: scale(0.85); } + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.4; + transform: scale(0.85); + } } .empty-state { - display: flex; flex-direction: column; align-items: center; - gap: 8px; padding: 64px 0; text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 64px 0; + text-align: center; +} +.empty-icon { + color: var(--nc-text-dim); + margin-bottom: 4px; +} +.empty-title { + font-size: 15px; + font-weight: 600; + margin: 0; +} +.empty-sub { + font-size: 13px; + color: var(--nc-text-muted); + margin: 0; } -.empty-icon { color: var(--nc-text-dim); margin-bottom: 4px; } -.empty-title { font-size: 15px; font-weight: 600; margin: 0; } -.empty-sub { font-size: 13px; color: var(--nc-text-muted); margin: 0; } /* Tournament list */ -.t-list { display: flex; flex-direction: column; gap: 8px; } +.t-list { + display: flex; + flex-direction: column; + gap: 8px; +} .t-card { border: 1px solid var(--nc-border); @@ -195,135 +388,348 @@ background: var(--nc-surface); overflow: hidden; cursor: pointer; - transition: border-color 0.15s, background 0.15s; + transition: + border-color 0.15s, + background 0.15s; } -.t-card:hover, .t-card.expanded { +.t-card:hover, +.t-card.expanded { border-color: var(--nc-border-strong); background: rgba(255, 255, 255, 0.04); } -.t-card:focus-visible { outline: 2px solid var(--nc-neon); outline-offset: 2px; } +.t-card:focus-visible { + outline: 2px solid var(--nc-neon); + outline-offset: 2px; +} .t-action-btn { - padding: 5px 12px; border-radius: 7px; font-size: 12px; font-weight: 600; - cursor: pointer; border: none; transition: opacity 0.15s; white-space: nowrap; + padding: 5px 12px; + border-radius: 7px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + border: none; + transition: opacity 0.15s; + white-space: nowrap; +} +.t-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.t-btn-start { + background: var(--nc-success); + color: #0f0022; +} +.t-btn-start:hover:not(:disabled) { + opacity: 0.85; +} +.t-btn-join { + background: var(--nc-neon); + color: #fff; +} +.t-btn-join:hover:not(:disabled) { + opacity: 0.85; } -.t-action-btn:disabled { opacity: 0.5; cursor: not-allowed; } -.t-btn-start { background: var(--nc-success); color: #0f0022; } -.t-btn-start:hover:not(:disabled) { opacity: 0.85; } -.t-btn-join { background: var(--nc-neon); color: #fff; } -.t-btn-join:hover:not(:disabled) { opacity: 0.85; } /* Join dialog extras */ -.join-hint { font-size: 13px; color: var(--nc-text-muted); margin: 0 0 16px; line-height: 1.5; } -.join-empty { font-size: 13px; color: var(--nc-text-muted); margin: 0 0 8px; } -.dialog-loading { display: flex; align-items: center; gap: 8px; - font-size: 13px; color: var(--nc-text-muted); padding: 12px 0; } -.bot-pick-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; } +.join-hint { + font-size: 13px; + color: var(--nc-text-muted); + margin: 0 0 16px; + line-height: 1.5; +} +.join-empty { + font-size: 13px; + color: var(--nc-text-muted); + margin: 0 0 8px; +} +.dialog-loading { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--nc-text-muted); + padding: 12px 0; +} +.bot-pick-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 4px; +} .bot-pick-row { - display: flex; align-items: center; gap: 10px; - padding: 10px 12px; border-radius: 8px; - border: 1px solid var(--nc-border); background: var(--nc-surface); - cursor: pointer; text-align: left; width: 100%; - transition: border-color 0.15s, background 0.15s; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--nc-border); + background: var(--nc-surface); + cursor: pointer; + text-align: left; + width: 100%; + transition: + border-color 0.15s, + background 0.15s; +} +.bot-pick-row:hover:not(:disabled) { + border-color: var(--nc-neon); + background: rgba(255, 69, 200, 0.06); +} +.bot-pick-row:disabled { + opacity: 0.5; + cursor: not-allowed; } -.bot-pick-row:hover:not(:disabled) { border-color: var(--nc-neon); background: rgba(255,69,200,0.06); } -.bot-pick-row:disabled { opacity: 0.5; cursor: not-allowed; } .bot-pick-avatar { - width: 30px; height: 30px; border-radius: 50%; background: var(--nc-neon); - color: #fff; display: flex; align-items: center; justify-content: center; - font-size: 13px; font-weight: 700; flex-shrink: 0; + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--nc-neon); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 700; + flex-shrink: 0; +} +.bot-pick-name { + flex: 1; + font-size: 14px; + font-weight: 600; + color: var(--nc-text); +} +.bot-pick-rating { + font-size: 12px; + color: var(--nc-text-muted); +} +.bot-pick-spinner { + font-size: 13px; + color: var(--nc-neon); } -.bot-pick-name { flex: 1; font-size: 14px; font-weight: 600; color: var(--nc-text); } -.bot-pick-rating { font-size: 12px; color: var(--nc-text-muted); } -.bot-pick-spinner { font-size: 13px; color: var(--nc-neon); } .t-card-main { - display: flex; justify-content: space-between; align-items: center; - padding: 14px 16px; gap: 12px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 16px; + gap: 12px; +} +.t-card-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.t-card-right { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; } -.t-card-left { display: flex; align-items: center; gap: 12px; min-width: 0; } -.t-card-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } .t-status-dot { - width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.dot-started { + background: var(--nc-success); + box-shadow: 0 0 6px var(--nc-success); +} +.dot-created { + background: var(--nc-warn); +} +.dot-finished { + background: var(--nc-text-dim); } -.dot-started { background: var(--nc-success); box-shadow: 0 0 6px var(--nc-success); } -.dot-created { background: var(--nc-warn); } -.dot-finished { background: var(--nc-text-dim); } -.t-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; } -.t-name { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.t-meta { font-size: 11px; color: var(--nc-text-muted); } +.t-info { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.t-name { + font-size: 14px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.t-meta { + font-size: 11px; + color: var(--nc-text-muted); +} .winner-badge { - font-size: 11px; font-weight: 600; color: var(--nc-warn); - padding: 3px 8px; border-radius: 6px; + font-size: 11px; + font-weight: 600; + color: var(--nc-warn); + padding: 3px 8px; + border-radius: 6px; background: rgba(255, 209, 102, 0.12); } -.chevron { color: var(--nc-text-dim); transition: transform 0.2s; } -.chevron.open { transform: rotate(180deg); } +.chevron { + color: var(--nc-text-dim); + transition: transform 0.2s; +} +.chevron.open { + transform: rotate(180deg); +} /* Detail panel */ .t-detail { border-top: 1px solid var(--nc-border); padding: 16px; - display: flex; flex-direction: column; gap: 20px; + display: flex; + flex-direction: column; + gap: 20px; } -.detail-section { display: flex; flex-direction: column; gap: 10px; } +.detail-section { + display: flex; + flex-direction: column; + gap: 10px; +} .detail-heading { - font-size: 11px; font-weight: 700; letter-spacing: 0.08em; - text-transform: uppercase; color: var(--nc-text-muted); margin: 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--nc-text-muted); + margin: 0; } -.no-standings { font-size: 12px; color: var(--nc-text-dim); margin: 0; } +.no-standings { + font-size: 12px; + color: var(--nc-text-dim); + margin: 0; +} /* Standings table */ .standings-table { - width: 100%; border-collapse: collapse; font-size: 13px; + width: 100%; + border-collapse: collapse; + font-size: 13px; } .standings-table th { - text-align: left; padding: 6px 8px; - font-size: 10px; font-weight: 700; letter-spacing: 0.06em; - text-transform: uppercase; color: var(--nc-text-dim); + text-align: left; + padding: 6px 8px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--nc-text-dim); border-bottom: 1px solid var(--nc-border); } -.standings-table td { padding: 8px 8px; border-bottom: 1px solid var(--nc-border); } -.standings-table tr:last-child td { border-bottom: none; } -.top-row td { color: var(--nc-text); } -.standings-table tr:not(.top-row) td { color: var(--nc-text-muted); } +.standings-table td { + padding: 8px 8px; + border-bottom: 1px solid var(--nc-border); +} +.standings-table tr:last-child td { + border-bottom: none; +} +.top-row td { + color: var(--nc-text); +} +.standings-table tr:not(.top-row) td { + color: var(--nc-text-muted); +} -.col-rank { width: 40px; font-size: 14px; } -.col-pts { width: 48px; font-weight: 700; color: var(--nc-neon) !important; } -.col-tb { width: 52px; color: var(--nc-text-dim) !important; font-size: 12px; } -.col-games { width: 64px; } +.col-rank { + width: 40px; + font-size: 14px; +} +.col-pts { + width: 48px; + font-weight: 700; + color: var(--nc-neon) !important; +} +.col-tb { + width: 52px; + color: var(--nc-text-dim) !important; + font-size: 12px; +} +.col-games { + width: 64px; +} -.wdl { font-size: 12px; font-variant-numeric: tabular-nums; } -.w { color: var(--nc-success); } -.d { color: var(--nc-text-muted); } -.l { color: var(--nc-danger); } +.wdl { + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.w { + color: var(--nc-success); +} +.d { + color: var(--nc-text-muted); +} +.l { + color: var(--nc-danger); +} /* Pairings */ -.pairings-list { display: flex; flex-direction: column; gap: 6px; } +.pairings-list { + display: flex; + flex-direction: column; + gap: 6px; +} .pairing-row { - display: flex; align-items: center; gap: 8px; - padding: 8px 10px; border-radius: 8px; - background: rgba(255,255,255,0.025); - font-size: 13px; transition: background 0.15s; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.025); + font-size: 13px; + transition: background 0.15s; +} +.pairing-row.is-watchable { + cursor: pointer; +} +.pairing-row.is-watchable:hover { + background: rgba(255, 255, 255, 0.06); +} +.pairing-white { + font-weight: 600; + flex: 1; +} +.pairing-vs { + color: var(--nc-text-dim); + font-size: 11px; + flex-shrink: 0; +} +.pairing-black { + flex: 1; +} +.pairing-result { + font-weight: 700; + font-size: 12px; + margin-left: auto; +} +.result-white { + color: var(--nc-success); +} +.result-black { + color: var(--nc-danger); +} +.result-draw { + color: var(--nc-text-muted); } -.pairing-row.is-watchable { cursor: pointer; } -.pairing-row.is-watchable:hover { background: rgba(255,255,255,0.06); } -.pairing-white { font-weight: 600; flex: 1; } -.pairing-vs { color: var(--nc-text-dim); font-size: 11px; flex-shrink: 0; } -.pairing-black { flex: 1; } -.pairing-result { font-weight: 700; font-size: 12px; margin-left: auto; } -.result-white { color: var(--nc-success); } -.result-black { color: var(--nc-danger); } -.result-draw { color: var(--nc-text-muted); } .pairing-ongoing { - display: inline-flex; align-items: center; gap: 5px; - margin-left: auto; font-size: 10px; font-weight: 700; - color: var(--nc-success); letter-spacing: 0.06em; text-transform: uppercase; + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + font-size: 10px; + font-weight: 700; + color: var(--nc-success); + letter-spacing: 0.06em; + text-transform: uppercase; +} +.pairing-ongoing svg { + animation: pulse 1.4s ease-in-out infinite; } -.pairing-ongoing svg { animation: pulse 1.4s ease-in-out infinite; } diff --git a/src/app/pages/tournaments/tournaments.component.html b/src/app/pages/tournaments/tournaments.component.html index 6ce1fe6..39f7b84 100644 --- a/src/app/pages/tournaments/tournaments.component.html +++ b/src/app/pages/tournaments/tournaments.component.html @@ -1,10 +1,17 @@
- - -
@@ -46,12 +81,22 @@ } @else if (activeList.length === 0) {
- - - - - + + + + + + +

No tournaments here

@@ -60,10 +105,14 @@ } @else {
@for (t of activeList; track t.id) { -
- +
@@ -71,9 +120,13 @@ {{ t.fullName }} {{ clockDisplay(t) }} · {{ t.nbRounds }} rounds · - @if (t.status === 'started') { Round {{ t.round }}/{{ t.nbRounds }} · } + @if (t.status === 'started') { + Round {{ t.round }}/{{ t.nbRounds }} · + } {{ t.nbPlayers }} player{{ t.nbPlayers === 1 ? '' : 's' }} - @if (t.rated) { · Rated } + @if (t.rated) { + · Rated + }
@@ -83,28 +136,42 @@ } @if (currentUser && t.status === 'created') { @if (t.createdBy === currentUser.id) { - } - } - - + +
@if (selectedTournament?.id === t.id) {
- @if (t.standing.players.length > 0) {
@@ -128,7 +195,9 @@ {{ r.tieBreak }} - {{ r.wins }}/{{ r.draws }}/{{ r.losses }} + {{ r.wins }}/{{ r.draws }}/{{ r.losses }} @@ -149,19 +218,24 @@ } @else if (pairings && pairings.pairings.length > 0) {
@for (p of pairings.pairings; track p.id) { -
+
{{ p.white?.name ?? 'Bye' }} vs {{ p.black.name }} @if (p.winner) { - {{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '1–0' : '0–1' }} + {{ + p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '1–0' : '0–1' + }} } @else if (p.gameId) { - + Watch @@ -174,100 +248,127 @@ }
} -
}
}
} -
@if (joinDialogTournamentId) { -
-
-
- Join with a bot - -
-

Select one of your bots to enter this tournament. Its token will be rotated to authenticate the join.

- - @if (botsLoading) { -
Loading bots…
- } @else if (userBots.length === 0) { -

You have no bots yet. Go to Bots in the nav to create one first.

- } @else { -
- @for (bot of userBots; track bot.id) { - - } +
+
+
+ Join with a bot +
- } +

+ Select one of your bots to enter this tournament. Its token will be rotated to authenticate + the join. +

- @if (joinError) { -
{{ joinError }}
- } + @if (botsLoading) { +
Loading bots…
+ } @else if (userBots.length === 0) { +

+ You have no bots yet. Go to Bots in the nav to create one first. +

+ } @else { +
+ @for (bot of userBots; track bot.id) { + + } +
+ } + + @if (joinError) { +
{{ joinError }}
+ } +
-
} @if (showCreateDialog) { -
-
-
- New tournament - +
+
+
+ New tournament + +
+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + @if (createError) { +
{{ createError }}
+ } + +
+ + +
+
- -
-
- - -
- -
-
- - -
-
- - -
-
- - -
-
- - - - @if (createError) { -
{{ createError }}
- } - -
- - -
-
-
} diff --git a/src/app/pages/tournaments/tournaments.component.ts b/src/app/pages/tournaments/tournaments.component.ts index adfbd83..353b827 100644 --- a/src/app/pages/tournaments/tournaments.component.ts +++ b/src/app/pages/tournaments/tournaments.component.ts @@ -17,7 +17,7 @@ type StatusTab = 'started' | 'created' | 'finished'; standalone: true, imports: [CommonModule, RouterLink, ReactiveFormsModule], templateUrl: './tournaments.component.html', - styleUrl: './tournaments.component.css' + styleUrl: './tournaments.component.css', }) export class TournamentsComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); @@ -45,7 +45,7 @@ export class TournamentsComponent implements OnInit { nbRounds: [4, [Validators.required, Validators.min(1), Validators.max(20)]], clockLimitMinutes: [3, [Validators.required, Validators.min(1), Validators.max(60)]], clockIncrement: [0, [Validators.required, Validators.min(0), Validators.max(60)]], - rated: [false] + rated: [false], }); createLoading = false; createError: string | null = null; @@ -59,14 +59,20 @@ export class TournamentsComponent implements OnInit { joinError: string | null = null; ngOnInit(): void { - this.authService.currentUser$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(u => { this.currentUser = u; }); + this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((u) => { + this.currentUser = u; + }); this.loadTournaments(); } openCreateDialog(): void { - this.createForm.reset({ name: '', nbRounds: 4, clockLimitMinutes: 3, clockIncrement: 0, rated: false }); + this.createForm.reset({ + name: '', + nbRounds: 4, + clockLimitMinutes: 3, + clockIncrement: 0, + rated: false, + }); this.createError = null; this.showCreateDialog = true; } @@ -80,17 +86,17 @@ export class TournamentsComponent implements OnInit { this.createLoading = true; this.createError = null; this.tournamentService.create(this.createForm.value).subscribe({ - next: t => { + next: (t) => { this.createLoading = false; this.showCreateDialog = false; this.created = [t, ...this.created]; this.tab = 'created'; this.selectedTournament = null; }, - error: err => { + error: (err) => { this.createLoading = false; this.createError = err.error?.message ?? err.error?.error ?? 'Failed to create tournament.'; - } + }, }); } @@ -137,15 +143,18 @@ export class TournamentsComponent implements OnInit { event.stopPropagation(); this.startingId = t.id; this.tournamentService.start(t.id).subscribe({ - next: updated => { + next: (updated) => { this.startingId = null; - const list = this.created.map(x => x.id === t.id ? updated : x); - this.created = list.filter(x => x.status === 'created'); - if (!this.started.find(x => x.id === updated.id)) this.started = [updated, ...this.started]; + const list = this.created.map((x) => (x.id === t.id ? updated : x)); + this.created = list.filter((x) => x.status === 'created'); + if (!this.started.find((x) => x.id === updated.id)) + this.started = [updated, ...this.started]; this.selectedTournament = updated; this.tab = 'started'; }, - error: () => { this.startingId = null; } + error: () => { + this.startingId = null; + }, }); } @@ -158,11 +167,17 @@ export class TournamentsComponent implements OnInit { this.joinDialogTournamentId = tournamentId; this.joinError = null; this.botsLoading = true; - this.botService.list() + this.botService + .list() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: bots => { this.userBots = bots; this.botsLoading = false; }, - error: () => { this.botsLoading = false; } + next: (bots) => { + this.userBots = bots; + this.botsLoading = false; + }, + error: () => { + this.botsLoading = false; + }, }); } @@ -177,38 +192,40 @@ export class TournamentsComponent implements OnInit { this.joiningBotId = bot.id; this.joinError = null; this.botService.rotateToken(bot.id).subscribe({ - next: token => { + next: (token) => { this.tournamentService.joinWithBotToken(this.joinDialogTournamentId!, token).subscribe({ next: () => { this.joiningBotId = null; const tid = this.joinDialogTournamentId!; this.closeJoinDialog(); - this.tournamentService.get(tid) + this.tournamentService + .get(tid) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(updated => { - this.created = this.created.map(x => x.id === tid ? updated : x); - this.started = this.started.map(x => x.id === tid ? updated : x); + .subscribe((updated) => { + this.created = this.created.map((x) => (x.id === tid ? updated : x)); + this.started = this.started.map((x) => (x.id === tid ? updated : x)); if (this.selectedTournament?.id === tid) this.selectedTournament = updated; }); }, - error: err => { + error: (err) => { this.joiningBotId = null; this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.'; - } + }, }); }, error: () => { this.joiningBotId = null; this.joinError = 'Failed to get bot token.'; - } + }, }); } private loadTournaments(): void { - this.tournamentService.list() + this.tournamentService + .list() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: list => { + next: (list) => { this.started = list.started; this.created = list.created; this.finished = list.finished; @@ -216,17 +233,25 @@ export class TournamentsComponent implements OnInit { if (this.started.length === 0 && this.created.length > 0) this.tab = 'created'; else if (this.started.length === 0 && this.finished.length > 0) this.tab = 'finished'; }, - error: () => { this.loading = false; } + error: () => { + this.loading = false; + }, }); } private loadPairings(id: string, round: number): void { this.pairingsLoading = true; - this.tournamentService.roundPairings(id, round) + this.tournamentService + .roundPairings(id, round) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: p => { this.pairings = p; this.pairingsLoading = false; }, - error: () => { this.pairingsLoading = false; } + next: (p) => { + this.pairings = p; + this.pairingsLoading = false; + }, + error: () => { + this.pairingsLoading = false; + }, }); } } diff --git a/src/app/pages/welcome/Auth Dialog.html b/src/app/pages/welcome/Auth Dialog.html index 32dfb08..70cec2b 100644 --- a/src/app/pages/welcome/Auth Dialog.html +++ b/src/app/pages/welcome/Auth Dialog.html @@ -1,4 +1,4 @@ - + @@ -12,13 +12,16 @@ /> diff --git a/src/app/pages/welcome/welcome.component.css b/src/app/pages/welcome/welcome.component.css index e7162bc..249a146 100644 --- a/src/app/pages/welcome/welcome.component.css +++ b/src/app/pages/welcome/welcome.component.css @@ -49,8 +49,8 @@ .cityscape-shell.sunset { --sky-1: #4d3279; - --sky-2: #5A5485; - --sky-3: #996C96; + --sky-2: #5a5485; + --sky-3: #996c96; --sky-4: #e85040; --sky-5: #f07020; --horizon: #ffaa30; @@ -93,7 +93,15 @@ .sky { position: absolute; inset: 0; - background: linear-gradient(180deg, var(--sky-1) 0%, var(--sky-2) 22%, var(--sky-3) 48%, var(--sky-4) 70%, var(--sky-5) 85%, var(--horizon) 100%); + background: linear-gradient( + 180deg, + var(--sky-1) 0%, + var(--sky-2) 22%, + var(--sky-3) 48%, + var(--sky-4) 70%, + var(--sky-5) 85%, + var(--horizon) 100% + ); transition: background 1.6s ease; } @@ -133,7 +141,9 @@ height: 52px; border-radius: 50%; background: radial-gradient(circle at 36% 34%, #fffbe8, #f5d060 60%, #c8a020); - box-shadow: 0 0 28px rgba(245, 210, 80, 0.55), 0 0 70px rgba(240, 190, 40, 0.25); + box-shadow: + 0 0 28px rgba(245, 210, 80, 0.55), + 0 0 70px rgba(240, 190, 40, 0.25); opacity: var(--moon-vis); transition: opacity 1.6s ease; } @@ -147,7 +157,10 @@ height: 76px; border-radius: 50%; background: radial-gradient(circle at 50% 50%, #fffce0, #ffd020 40%, #ff9000); - box-shadow: 0 0 40px rgba(255, 200, 0, 0.85), 0 0 90px rgba(255, 150, 0, 0.45), 0 0 200px rgba(255, 80, 0, 0.2); + box-shadow: + 0 0 40px rgba(255, 200, 0, 0.85), + 0 0 90px rgba(255, 150, 0, 0.45), + 0 0 200px rgba(255, 80, 0, 0.2); opacity: var(--sun-vis); transition: opacity 1.6s ease; z-index: 4; @@ -247,8 +260,21 @@ content: ''; position: absolute; inset: 0; - background-image: repeating-linear-gradient(0deg, transparent, transparent 14px, rgba(120, 120, 200, 0.08) 14px, rgba(120, 120, 200, 0.08) 16px), - repeating-linear-gradient(90deg, transparent, transparent 12px, rgba(120, 120, 200, 0.08) 12px, rgba(120, 120, 200, 0.08) 14px); + background-image: + repeating-linear-gradient( + 0deg, + transparent, + transparent 14px, + rgba(120, 120, 200, 0.08) 14px, + rgba(120, 120, 200, 0.08) 16px + ), + repeating-linear-gradient( + 90deg, + transparent, + transparent 12px, + rgba(120, 120, 200, 0.08) 12px, + rgba(120, 120, 200, 0.08) 14px + ); } .main-layer { @@ -311,7 +337,9 @@ height: 9px; border-radius: 1px; background: var(--win-off); - transition: background 0.4s ease, box-shadow 0.4s ease; + transition: + background 0.4s ease, + box-shadow 0.4s ease; } .w.lc { @@ -357,7 +385,10 @@ gap: 6px; text-align: center; pointer-events: auto; - transition: border-color 1.6s ease, box-shadow 1.6s ease, background 1.6s ease; + transition: + border-color 1.6s ease, + box-shadow 1.6s ease, + background 1.6s ease; } .bb-tag { @@ -398,7 +429,11 @@ letter-spacing: 1px; cursor: pointer; box-shadow: var(--btn-glow); - transition: transform 0.2s ease, filter 0.2s ease, background 1.6s ease, box-shadow 1.6s ease; + transition: + transform 0.2s ease, + filter 0.2s ease, + background 1.6s ease, + box-shadow 1.6s ease; } .bb-btn:hover:enabled { @@ -419,11 +454,15 @@ border: 1px solid var(--bb-border); padding: 2px 7px; border-radius: 2px; - text-shadow: 0 0 8px currentColor, 0 0 20px currentColor; + text-shadow: + 0 0 8px currentColor, + 0 0 20px currentColor; box-shadow: 0 0 6px currentColor; animation: nflicker 9s ease-in-out infinite; display: inline-block; - transition: color 1.6s ease, border-color 1.6s ease; + transition: + color 1.6s ease, + border-color 1.6s ease; } @keyframes nflicker { @@ -462,7 +501,9 @@ height: 8px; border-radius: 50%; background: #ff2222; - box-shadow: 0 0 10px #ff2222, 0 0 20px rgba(255, 0, 0, 0.4); + box-shadow: + 0 0 10px #ff2222, + 0 0 20px rgba(255, 0, 0, 0.4); animation: blink 1.6s step-start infinite; } @@ -517,7 +558,9 @@ color: rgba(255, 255, 255, 0.85); cursor: pointer; backdrop-filter: blur(12px); - transition: background 0.3s, transform 0.2s; + transition: + background 0.3s, + transform 0.2s; } .tgl:hover { diff --git a/src/app/pages/welcome/welcome.component.html b/src/app/pages/welcome/welcome.component.html index 73cbf94..76919ec 100644 --- a/src/app/pages/welcome/welcome.component.html +++ b/src/app/pages/welcome/welcome.component.html @@ -1,5 +1,4 @@
-
@@ -20,118 +19,156 @@
-
-
-
-
+
+
+
+
-
-
+
+
-
+
JOIN
JOIN
GAME
-
-
+
-
-
-
+
+
+
-
-
-
+
+
+
OPEN 24/7
BOT
PLAY WITH
A BOT
-
-
+
-
+
Player One -
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
WELCOME
-
WELCOME TO
NOWCHESS
+
+ WELCOME TO
NOWCHESS +
Play your next move from the skyline.
-
+
-
-
-
-
+
+
+
+
-
-
-
+
+
+
MORE
OPTIONS
MORE
OPTIONS
- +
-
+
-
-
-
+
+
+
@@ -143,84 +180,140 @@
@if (showDifficultyDialog) { -
-
-
SELECT DIFFICULTY
-
- - - +
+
+
SELECT DIFFICULTY
+
+ + + +
-
} @if (showOptionsDialog) { -
-
-
MORE OPTIONS
-
- +
+
+
MORE OPTIONS
+
+ +
-
} @if (showJoinDialog) { -
-
-
JOIN GAME
- -
- - +
+
+
JOIN GAME
+ +
+ + +
-
} @if (showImportDialog) { -
-
-
IMPORT GAME
-
- - -
- -
- - +
+
+
IMPORT GAME
+
+ + +
+ +
+ + +
-
} @if (showChallengeDialog) { -
-
- +
+
+ +
-
} @if (errorMessage) { -

{{ errorMessage }}

+

{{ errorMessage }}

} -
\ No newline at end of file +
diff --git a/src/app/pages/welcome/welcome.component.ts b/src/app/pages/welcome/welcome.component.ts index 5126331..58f5094 100644 --- a/src/app/pages/welcome/welcome.component.ts +++ b/src/app/pages/welcome/welcome.component.ts @@ -50,7 +50,7 @@ interface WindowCell { standalone: true, imports: [CommonModule, FormsModule, ChallengeCreateDialogComponent], templateUrl: './welcome.component.html', - styleUrls: ['./welcome.component.css'] + styleUrls: ['./welcome.component.css'], }) export class WelcomeComponent implements OnInit, OnDestroy { private readonly destroyRef = inject(DestroyRef); @@ -93,9 +93,8 @@ export class WelcomeComponent implements OnInit, OnDestroy { constructor( private readonly router: Router, - private readonly gameApi: GameApiService - ) { - } + private readonly gameApi: GameApiService, + ) {} ngOnInit(): void { this.themeService.darkMode$ @@ -105,12 +104,10 @@ export class WelcomeComponent implements OnInit, OnDestroy { this.modeBadge = this.isSunsetMode ? 'SUNSET MODE' : 'NIGHT MODE'; }); - this.authService.currentUser$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((user) => { - this.currentUser = user; - this.maybeRunPendingAction(); - }); + this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { + this.currentUser = user; + this.maybeRunPendingAction(); + }); this.authDialogService.dialogState$ .pipe(takeUntilDestroyed(this.destroyRef)) @@ -130,7 +127,7 @@ export class WelcomeComponent implements OnInit, OnDestroy { } openDifficultyDialog(): void { - if (!this.requireAuth(() => this.showDifficultyDialog = true)) { + if (!this.requireAuth(() => (this.showDifficultyDialog = true))) { return; } @@ -154,7 +151,7 @@ export class WelcomeComponent implements OnInit, OnDestroy { } openJoinDialog(): void { - if (!this.requireAuth(() => this.showJoinDialog = true)) { + if (!this.requireAuth(() => (this.showJoinDialog = true))) { return; } @@ -172,7 +169,7 @@ export class WelcomeComponent implements OnInit, OnDestroy { } openImportDialog(): void { - if (!this.requireAuth(() => this.showImportDialog = true)) { + if (!this.requireAuth(() => (this.showImportDialog = true))) { return; } @@ -257,7 +254,6 @@ export class WelcomeComponent implements OnInit, OnDestroy { action(); } - private performStartVsBot(difficulty: Difficulty): void { if (this.creating) { return; @@ -273,12 +269,12 @@ export class WelcomeComponent implements OnInit, OnDestroy { .subscribe({ next: (game) => { void this.router.navigate(['/game', game.gameId], { - state: { theme: this.isSunsetMode ? 'light' : 'dark' } + state: { theme: this.isSunsetMode ? 'light' : 'dark' }, }); }, error: (error) => { this.errorMessage = getErrorMessage(error, 'Unable to create a game against bot.'); - } + }, }); } @@ -298,12 +294,12 @@ export class WelcomeComponent implements OnInit, OnDestroy { next: (game) => { this.closeJoinDialog(); void this.router.navigate(['/game', game.gameId], { - state: { theme: this.isSunsetMode ? 'light' : 'dark' } + state: { theme: this.isSunsetMode ? 'light' : 'dark' }, }); }, error: (error) => { this.errorMessage = getErrorMessage(error, 'Unable to find or join the game.'); - } + }, }); } @@ -317,19 +313,22 @@ export class WelcomeComponent implements OnInit, OnDestroy { this.importing = true; const importRequest = - this.importMode === 'fen' ? this.gameApi.importFen(trimmedImport) : this.gameApi.importPgn(trimmedImport); + this.importMode === 'fen' + ? this.gameApi.importFen(trimmedImport) + : this.gameApi.importPgn(trimmedImport); importRequest.pipe(finalize(() => (this.importing = false))).subscribe({ next: (game) => { this.closeImportDialog(); void this.router.navigate(['/game', game.gameId], { - state: { theme: this.isSunsetMode ? 'light' : 'dark' } + state: { theme: this.isSunsetMode ? 'light' : 'dark' }, }); }, error: (error) => { - const defaultMessage = this.importMode === 'fen' ? 'Unable to import FEN.' : 'Unable to import PGN.'; + const defaultMessage = + this.importMode === 'fen' ? 'Unable to import FEN.' : 'Unable to import PGN.'; this.errorMessage = getErrorMessage(error, defaultMessage); - } + }, }); } @@ -351,8 +350,8 @@ export class WelcomeComponent implements OnInit, OnDestroy { left: `${Math.random() * 100}%`, top: `${Math.random() * 62}%`, '--d': `${(Math.random() * 3 + 1.5).toFixed(1)}s`, - '--dl': `${-(Math.random() * 6).toFixed(1)}s` - } + '--dl': `${-(Math.random() * 6).toFixed(1)}s`, + }, }; }); } @@ -381,11 +380,11 @@ export class WelcomeComponent implements OnInit, OnDestroy { { l: '85.5%', w: '9%', h: '32vh' }, { l: '88%', w: '5%', h: '20vh' }, { l: '91%', w: '3%', h: '16vh' }, // New building - { l: '94%', w: '6%', h: '27vh' } + { l: '94%', w: '6%', h: '27vh' }, ]; this.bgBuildings = specs.map((spec) => ({ - style: { left: spec.l, width: spec.w, height: spec.h } + style: { left: spec.l, width: spec.w, height: spec.h }, })); } @@ -401,7 +400,7 @@ export class WelcomeComponent implements OnInit, OnDestroy { wC3: this.generateWindows(7, 24, 0.6), wD1: this.generateWindows(6, 3, 0.6), wD2: this.generateWindows(6, 20, 0.5), - wE1: this.generateWindows(3, 16, 0.45) + wE1: this.generateWindows(3, 16, 0.45), }; } @@ -416,12 +415,14 @@ export class WelcomeComponent implements OnInit, OnDestroy { let color: string | undefined; let glowColor: string | undefined; - if (random < litRate * 0.58) { // Cool color + if (random < litRate * 0.58) { + // Cool color state = 'on'; const coolIndex = Math.floor(Math.random() * this.coolColors.length); color = this.coolColors[coolIndex]; glowColor = this.coolGlowColors[coolIndex]; - } else if (random < litRate) { // Warm color + } else if (random < litRate) { + // Warm color state = 'on'; const warmIndex = Math.floor(Math.random() * this.warmColors.length); color = this.warmColors[warmIndex]; @@ -432,7 +433,7 @@ export class WelcomeComponent implements OnInit, OnDestroy { return { state, style: {} }; } - const baseDuration = (color && this.coolColors.includes(color)) ? 3 : 4; + const baseDuration = color && this.coolColors.includes(color) ? 3 : 4; return { state, color, @@ -441,8 +442,8 @@ export class WelcomeComponent implements OnInit, OnDestroy { 'background-color': color || '', 'box-shadow': glowColor ? `0 0 6px ${glowColor}, 0 0 16px ${glowColor}35` : '', '--wd': `${(Math.random() * 4 + baseDuration).toFixed(1)}s`, - '--wdl': `${-(Math.random() * 8).toFixed(1)}s` - } + '--wdl': `${-(Math.random() * 8).toFixed(1)}s`, + }, }; } @@ -484,9 +485,11 @@ export class WelcomeComponent implements OnInit, OnDestroy { target.glowColor = glowColors[index]; target.style = { 'background-color': target.color || '', - 'box-shadow': target.glowColor ? `0 0 6px ${target.glowColor}, 0 0 16px ${target.glowColor}35` : '', + 'box-shadow': target.glowColor + ? `0 0 6px ${target.glowColor}, 0 0 16px ${target.glowColor}35` + : '', '--wd': `${(Math.random() * 4 + (isCool ? 3 : 4)).toFixed(1)}s`, - '--wdl': `${-(Math.random() * 8).toFixed(1)}s` + '--wdl': `${-(Math.random() * 8).toFixed(1)}s`, }; } else { target.state = 'off'; diff --git a/src/app/services/analysis.service.ts b/src/app/services/analysis.service.ts new file mode 100644 index 0000000..4982c78 --- /dev/null +++ b/src/app/services/analysis.service.ts @@ -0,0 +1,102 @@ +import { Injectable, inject } from '@angular/core'; +import { Observable, forkJoin, of } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { GameApiService } from './game-api.service'; +import { AnnotatedMove, AnalysisResponse, MoveQuality } from '../models/analysis.models'; + +const DEFAULT_DEPTH = 14; + +@Injectable({ providedIn: 'root' }) +export class AnalysisService { + private readonly gameApi = inject(GameApiService); + + analyzePosition(fen: string, depth = DEFAULT_DEPTH): Observable { + return this.gameApi.analyzePosition({ fen, depth }); + } + + /** + * Analyse a sequence of FEN positions (one per ply) and return annotated moves. + * fenHistory[0] is the starting position; fenHistory[n] is reached after move san[n-1]. + */ + analyzeGame( + sans: string[], + fenHistory: string[], + depth = DEFAULT_DEPTH, + ): Observable { + if (sans.length === 0 || fenHistory.length < 2) { + return of([]); + } + + const requests = fenHistory.map((fen) => this.gameApi.analyzePosition({ fen, depth })); + + return forkJoin(requests).pipe( + map((results) => this.buildAnnotations(sans, fenHistory, results)), + ); + } + + private buildAnnotations( + sans: string[], + fenHistory: string[], + results: AnalysisResponse[], + ): AnnotatedMove[] { + return sans.map((san, i) => { + const evalBefore = results[i]?.eval ?? null; + const winChanceBefore = results[i]?.winChance ?? null; + const evalAfter = results[i + 1]?.eval ?? null; + const winChanceAfter = results[i + 1]?.winChance ?? null; + const bestMove = results[i]?.bestMove ?? null; + + const quality = this.classifyQuality( + evalBefore, + evalAfter, + winChanceBefore, + winChanceAfter, + san, + bestMove, + ); + + return { + san, + fen: fenHistory[i + 1] ?? fenHistory[i], + evalBefore, + evalAfter, + quality, + bestMove, + winChanceBefore, + winChanceAfter, + }; + }); + } + + /** + * Classify move quality based on win-chance delta. + * Win-chance is from the engine's perspective (side to move before the move). + * After the move the side has flipped, so we invert the after value. + */ + private classifyQuality( + _evalBefore: number | null, + _evalAfter: number | null, + winChanceBefore: number | null, + winChanceAfter: number | null, + san: string, + bestMove: string | null, + ): MoveQuality | null { + if (winChanceBefore === null || winChanceAfter === null) return null; + + // The engine expresses win chance for the mover. After our move the mover + // has switched, so the opponent's win chance = winChanceAfter. Our remaining + // winning chance from our own perspective is 1 - winChanceAfter. + const wcAfterOurPerspective = 1 - winChanceAfter; + const delta = wcAfterOurPerspective - winChanceBefore; // negative = we lost win chance + + if (bestMove && san === bestMove) { + if (delta >= -0.01) return 'brilliant'; + return 'best'; + } + + if (delta >= -0.02) return 'good'; + if (delta >= -0.05) return 'inaccuracy'; + if (delta >= -0.1) return 'mistake'; + return 'blunder'; + } +} diff --git a/src/app/services/auth-dialog.service.ts b/src/app/services/auth-dialog.service.ts index 774b4b6..f68f258 100644 --- a/src/app/services/auth-dialog.service.ts +++ b/src/app/services/auth-dialog.service.ts @@ -5,19 +5,19 @@ export type AuthDialogState = 'login' | 'register' | null; @Injectable({ providedIn: 'root' }) export class AuthDialogService { - private readonly dialogStateSubject = new BehaviorSubject(null); + private readonly dialogStateSubject = new BehaviorSubject(null); - readonly dialogState$ = this.dialogStateSubject.asObservable(); + readonly dialogState$ = this.dialogStateSubject.asObservable(); - openLogin(): void { - this.dialogStateSubject.next('login'); - } + openLogin(): void { + this.dialogStateSubject.next('login'); + } - openRegister(): void { - this.dialogStateSubject.next('register'); - } + openRegister(): void { + this.dialogStateSubject.next('register'); + } - close(): void { - this.dialogStateSubject.next(null); - } -} \ No newline at end of file + close(): void { + this.dialogStateSubject.next(null); + } +} diff --git a/src/app/services/auth.interceptor.ts b/src/app/services/auth.interceptor.ts index 4759842..02c4e3c 100644 --- a/src/app/services/auth.interceptor.ts +++ b/src/app/services/auth.interceptor.ts @@ -14,8 +14,8 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { if (token && isProtectedEndpoint && !req.headers.has('Authorization')) { req = req.clone({ setHeaders: { - Authorization: `Bearer ${token}` - } + Authorization: `Bearer ${token}`, + }, }); } diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index cdce9c6..dc088f3 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -3,7 +3,13 @@ import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable } from 'rxjs'; import { map, switchMap, tap } from 'rxjs/operators'; import { environment } from '../../environments/environment'; -import { LoginRequest, RegisterRequest, RegisterResponse, LoginResponse, CurrentUser } from '../models/auth.models'; +import { + LoginRequest, + RegisterRequest, + RegisterResponse, + LoginResponse, + CurrentUser, +} from '../models/auth.models'; @Injectable({ providedIn: 'root' }) export class AuthService { @@ -22,7 +28,7 @@ export class AuthService { return this.http .post(`${this.accountServiceUrl}/api/account/login`, { username, - password + password, }) .pipe( tap((response) => { @@ -30,7 +36,7 @@ export class AuthService { localStorage.setItem('refreshToken', response.refreshToken); localStorage.setItem('username', username); this.getCurrentUser().subscribe(); - }) + }), ); } @@ -39,13 +45,9 @@ export class AuthService { .post(`${this.accountServiceUrl}/api/account`, { username, password, - email + email, }) - .pipe( - switchMap((response) => - this.login(username, password).pipe(map(() => response)) - ) - ); + .pipe(switchMap((response) => this.login(username, password).pipe(map(() => response)))); } getCurrentUser(): Observable { @@ -54,7 +56,7 @@ export class AuthService { localStorage.setItem('username', user.username); localStorage.setItem('userId', user.id); this.currentUserSubject.next(user); - }) + }), ); } @@ -80,7 +82,7 @@ export class AuthService { error: () => { // Token is invalid, clear it this.logout(); - } + }, }); } } diff --git a/src/app/services/board-selection.service.ts b/src/app/services/board-selection.service.ts index 54de571..19006a1 100644 --- a/src/app/services/board-selection.service.ts +++ b/src/app/services/board-selection.service.ts @@ -21,7 +21,7 @@ export class BoardSelectionService { state: GameState | null, currentSelection: BoardSelection, onMovesLoaded: (moves: LegalMove[]) => void, - onError: (error: string) => void + onError: (error: string) => void, ): BoardSelection { if (!state) { return currentSelection; @@ -52,7 +52,7 @@ export class BoardSelectionService { }, error: () => { onError('Could not load legal moves for selected square.'); - } + }, }); return { selectedSquare: square, highlightedSquares: [], selectedSquareMoves: [] }; diff --git a/src/app/services/bot-move.service.ts b/src/app/services/bot-move.service.ts index e625d39..8770050 100644 --- a/src/app/services/bot-move.service.ts +++ b/src/app/services/bot-move.service.ts @@ -35,7 +35,7 @@ export class BotMoveService { game: GameFull | null, state: GameState | null, onSuccess: (updatedState: GameState) => void, - onError: (error: string) => void + onError: (error: string) => void, ): void { if (!this.isPlayingAgainstBot(game) || !this.isCurrentPlayerBot(game, state) || !state) { return; @@ -44,10 +44,7 @@ export class BotMoveService { this.botMoveSubscription?.unsubscribe(); this.botMoveSubscription = this.gameApi .getLegalMoves(gameId) - .pipe( - delay(1000), - takeUntilDestroyed(this.destroyRef) - ) + .pipe(delay(1000), takeUntilDestroyed(this.destroyRef)) .subscribe({ next: (response) => { if (response.moves.length === 0) { @@ -63,12 +60,12 @@ export class BotMoveService { }, error: (error) => { onError(getErrorMessage(error, 'Bot move failed.')); - } + }, }); }, error: () => { onError('Could not get legal moves for bot move.'); - } + }, }); } diff --git a/src/app/services/bot.service.ts b/src/app/services/bot.service.ts index fa77c29..6b7cf4d 100644 --- a/src/app/services/bot.service.ts +++ b/src/app/services/bot.service.ts @@ -17,8 +17,9 @@ export class BotService { } rotateToken(botId: string): Observable { - return this.http.post<{ token: string }>(`${this.base}/${botId}/rotate-token`, null) - .pipe(map(r => r.token)); + return this.http + .post<{ token: string }>(`${this.base}/${botId}/rotate-token`, null) + .pipe(map((r) => r.token)); } delete(botId: string): Observable { diff --git a/src/app/services/challenge-event.service.ts b/src/app/services/challenge-event.service.ts index 33caae3..a2cc273 100644 --- a/src/app/services/challenge-event.service.ts +++ b/src/app/services/challenge-event.service.ts @@ -8,71 +8,71 @@ import { Challenge } from '../models/challenge.models'; */ @Injectable({ providedIn: 'root' }) export class ChallengeEventService { - private readonly incomingChallenges$ = new BehaviorSubject([]); - private readonly challengeReceived$ = new Subject(); - private readonly challengeAccepted$ = new Subject(); - private readonly challengeDeclined$ = new Subject(); + private readonly incomingChallenges$ = new BehaviorSubject([]); + private readonly challengeReceived$ = new Subject(); + private readonly challengeAccepted$ = new Subject(); + private readonly challengeDeclined$ = new Subject(); - getIncomingChallenges$(): Observable { - return this.incomingChallenges$.asObservable(); - } + getIncomingChallenges$(): Observable { + return this.incomingChallenges$.asObservable(); + } - getChallengeReceived$(): Observable { - return this.challengeReceived$.asObservable(); - } + getChallengeReceived$(): Observable { + return this.challengeReceived$.asObservable(); + } - getChallengeAccepted$(): Observable { - return this.challengeAccepted$.asObservable(); - } + getChallengeAccepted$(): Observable { + return this.challengeAccepted$.asObservable(); + } - getChallengeDeclined$(): Observable { - return this.challengeDeclined$.asObservable(); - } + getChallengeDeclined$(): Observable { + return this.challengeDeclined$.asObservable(); + } - /** - * Called when a new challenge is received via WebSocket - */ - onChallengeReceived(challenge: Challenge): void { - const current = this.incomingChallenges$.value; - this.incomingChallenges$.next([...current, challenge]); - this.challengeReceived$.next(challenge); - } + /** + * Called when a new challenge is received via WebSocket + */ + onChallengeReceived(challenge: Challenge): void { + const current = this.incomingChallenges$.value; + this.incomingChallenges$.next([...current, challenge]); + this.challengeReceived$.next(challenge); + } - /** - * Called when a challenge is accepted - */ - onChallengeAccepted(challenge: Challenge): void { - const current = this.incomingChallenges$.value; - this.incomingChallenges$.next(current.filter(c => c.id !== challenge.id)); - this.challengeAccepted$.next(challenge); - } + /** + * Called when a challenge is accepted + */ + onChallengeAccepted(challenge: Challenge): void { + const current = this.incomingChallenges$.value; + this.incomingChallenges$.next(current.filter((c) => c.id !== challenge.id)); + this.challengeAccepted$.next(challenge); + } - /** - * Called when a challenge is declined or expires - */ - onChallengeRemoved(challengeId: string): void { - const current = this.incomingChallenges$.value; - this.incomingChallenges$.next(current.filter(c => c.id !== challengeId)); - } + /** + * Called when a challenge is declined or expires + */ + onChallengeRemoved(challengeId: string): void { + const current = this.incomingChallenges$.value; + this.incomingChallenges$.next(current.filter((c) => c.id !== challengeId)); + } - /** - * Remove a challenge from the incoming list - */ - removeChallenge(challengeId: string): void { - this.onChallengeRemoved(challengeId); - } + /** + * Remove a challenge from the incoming list + */ + removeChallenge(challengeId: string): void { + this.onChallengeRemoved(challengeId); + } - /** - * Replace the full incoming list (used by HTTP polling) - */ - setIncomingChallenges(challenges: Challenge[]): void { - this.incomingChallenges$.next(challenges); - } + /** + * Replace the full incoming list (used by HTTP polling) + */ + setIncomingChallenges(challenges: Challenge[]): void { + this.incomingChallenges$.next(challenges); + } - /** - * Clear all incoming challenges (used on logout) - */ - clear(): void { - this.incomingChallenges$.next([]); - } + /** + * Clear all incoming challenges (used on logout) + */ + clear(): void { + this.incomingChallenges$.next([]); + } } diff --git a/src/app/services/challenge-websocket.service.ts b/src/app/services/challenge-websocket.service.ts index 1a03907..d575562 100644 --- a/src/app/services/challenge-websocket.service.ts +++ b/src/app/services/challenge-websocket.service.ts @@ -6,110 +6,114 @@ import { ChallengeService } from './challenge.service'; @Injectable({ providedIn: 'root' }) export class ChallengeWebSocketService { - private readonly challengeEventService = inject(ChallengeEventService); - private readonly challengeService = inject(ChallengeService); - private readonly router = inject(Router); + private readonly challengeEventService = inject(ChallengeEventService); + private readonly challengeService = inject(ChallengeService); + private readonly router = inject(Router); - private ws: WebSocket | null = null; - private reconnectAttempts = 0; - private readonly maxReconnectAttempts = 5; - private readonly reconnectDelay = 3000; - private intentionalClose = false; + private ws: WebSocket | null = null; + private reconnectAttempts = 0; + private readonly maxReconnectAttempts = 5; + private readonly reconnectDelay = 3000; + private intentionalClose = false; - connect(): void { - if (this.ws) return; + connect(): void { + if (this.ws) return; - const token = localStorage.getItem('token'); - if (!token) return; + const token = localStorage.getItem('token'); + if (!token) return; - const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`; + const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`; - try { - this.intentionalClose = false; - this.ws = new WebSocket(url); + try { + this.intentionalClose = false; + this.ws = new WebSocket(url); - this.ws.onopen = () => { - this.reconnectAttempts = 0; - }; - - this.ws.onmessage = (event) => { - this.handleMessage(event.data as string); - }; - - this.ws.onerror = () => { - // onclose fires right after, handles reconnect - }; - - this.ws.onclose = () => { - this.ws = null; - if (!this.intentionalClose) { - this.attemptReconnect(); - } - }; - } catch { - this.attemptReconnect(); - } - } - - disconnect(): void { - this.intentionalClose = true; + this.ws.onopen = () => { this.reconnectAttempts = 0; - if (this.ws) { - this.ws.close(); - this.ws = null; + }; + + this.ws.onmessage = (event) => { + this.handleMessage(event.data as string); + }; + + this.ws.onerror = () => { + // onclose fires right after, handles reconnect + }; + + this.ws.onclose = () => { + this.ws = null; + if (!this.intentionalClose) { + this.attemptReconnect(); } + }; + } catch { + this.attemptReconnect(); + } + } + + disconnect(): void { + this.intentionalClose = true; + this.reconnectAttempts = 0; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + private handleMessage(data: string): void { + let message: Record; + try { + message = JSON.parse(data) as Record; + } catch { + return; } - private handleMessage(data: string): void { - let message: Record; - try { - message = JSON.parse(data) as Record; - } catch { - return; + switch (message['type']) { + case 'CONNECTED': + break; + + case 'challengeCreated': { + const challengeId = message['challengeId'] as string | undefined; + if (challengeId) { + this.challengeService.getChallenge(challengeId).subscribe({ + next: (challenge) => this.challengeEventService.onChallengeReceived(challenge), + error: () => { + /* challenge may have already expired */ + }, + }); } + break; + } - switch (message['type']) { - case 'CONNECTED': - break; - - case 'challengeCreated': { - const challengeId = message['challengeId'] as string | undefined; - if (challengeId) { - this.challengeService.getChallenge(challengeId).subscribe({ - next: challenge => this.challengeEventService.onChallengeReceived(challenge), - error: () => { /* challenge may have already expired */ } - }); - } - break; - } - - case 'challengeAccepted': { - const challengeId = message['challengeId'] as string | undefined; - const gameId = message['gameId'] as string | undefined; - if (challengeId) { - this.challengeEventService.removeChallenge(challengeId); - } - if (gameId) { - void this.router.navigate(['/game', gameId]); - } - break; - } - - case 'challengeDeclined': - case 'challengeExpired': - case 'challengeCancelled': { - const challengeId = message['challengeId'] as string | undefined; - if (challengeId) { - this.challengeEventService.removeChallenge(challengeId); - } - break; - } + case 'challengeAccepted': { + const challengeId = message['challengeId'] as string | undefined; + const gameId = message['gameId'] as string | undefined; + if (challengeId) { + this.challengeEventService.removeChallenge(challengeId); } - } + if (gameId) { + void this.router.navigate(['/game', gameId]); + } + break; + } - private attemptReconnect(): void { - if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return; - this.reconnectAttempts++; - setTimeout(() => { this.connect(); }, this.reconnectDelay); + case 'challengeDeclined': + case 'challengeExpired': + case 'challengeCancelled': { + const challengeId = message['challengeId'] as string | undefined; + if (challengeId) { + this.challengeEventService.removeChallenge(challengeId); + } + break; + } } + } + + private attemptReconnect(): void { + if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return; + this.reconnectAttempts++; + setTimeout(() => { + this.connect(); + }, this.reconnectDelay); + } } diff --git a/src/app/services/challenge.service.ts b/src/app/services/challenge.service.ts index 7859923..1ad0339 100644 --- a/src/app/services/challenge.service.ts +++ b/src/app/services/challenge.service.ts @@ -1,50 +1,39 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { Challenge, DeclineChallengeRequest, ListChallengesResponse, SendChallengeRequest } from '../models/challenge.models'; +import { + Challenge, + DeclineChallengeRequest, + ListChallengesResponse, + SendChallengeRequest, +} from '../models/challenge.models'; @Injectable({ providedIn: 'root' }) export class ChallengeService { - private readonly http = inject(HttpClient); - private readonly challengeBaseUrl = '/api/challenge'; + private readonly http = inject(HttpClient); + private readonly challengeBaseUrl = '/api/challenge'; - sendChallenge(username: string, request: SendChallengeRequest): Observable { - return this.http.post( - `${this.challengeBaseUrl}/${username}`, - request - ); - } + sendChallenge(username: string, request: SendChallengeRequest): Observable { + return this.http.post(`${this.challengeBaseUrl}/${username}`, request); + } - listChallenges(): Observable { - return this.http.get( - `${this.challengeBaseUrl}` - ); - } + listChallenges(): Observable { + return this.http.get(`${this.challengeBaseUrl}`); + } - getChallenge(challengeId: string): Observable { - return this.http.get( - `${this.challengeBaseUrl}/${challengeId}` - ); - } + getChallenge(challengeId: string): Observable { + return this.http.get(`${this.challengeBaseUrl}/${challengeId}`); + } - acceptChallenge(challengeId: string): Observable { - return this.http.post( - `${this.challengeBaseUrl}/${challengeId}/accept`, - {} - ); - } + acceptChallenge(challengeId: string): Observable { + return this.http.post(`${this.challengeBaseUrl}/${challengeId}/accept`, {}); + } - declineChallenge(challengeId: string, request?: DeclineChallengeRequest): Observable { - return this.http.post( - `${this.challengeBaseUrl}/${challengeId}/decline`, - request || {} - ); - } + declineChallenge(challengeId: string, request?: DeclineChallengeRequest): Observable { + return this.http.post(`${this.challengeBaseUrl}/${challengeId}/decline`, request || {}); + } - cancelChallenge(challengeId: string): Observable { - return this.http.post( - `${this.challengeBaseUrl}/${challengeId}/cancel`, - {} - ); - } + cancelChallenge(challengeId: string): Observable { + return this.http.post(`${this.challengeBaseUrl}/${challengeId}/cancel`, {}); + } } diff --git a/src/app/services/game-api.service.ts b/src/app/services/game-api.service.ts index cf7aef6..5849015 100644 --- a/src/app/services/game-api.service.ts +++ b/src/app/services/game-api.service.ts @@ -7,8 +7,9 @@ import { GameState, GameStreamEvent, LegalMovesResponse, - PlayerInfo + PlayerInfo, } from '../models/game.models'; +import { AnalysisRequest, AnalysisResponse } from '../models/analysis.models'; import { StreamHandlerService } from './stream-handler.service'; @Injectable({ providedIn: 'root' }) @@ -28,11 +29,11 @@ export class GameApiService { const playerColor = Math.random() > 0.5 ? 'white' : 'black'; const playerInfo: PlayerInfo = { id: `player-${Date.now()}`, - displayName: 'You' + displayName: 'You', }; const botInfo: PlayerInfo = { id: `bot-${difficulty}`, - displayName: `Bot (${difficulty})` + displayName: `Bot (${difficulty})`, }; const payload = @@ -56,7 +57,9 @@ export class GameApiService { if (square) { params = params.set('square', square); } - return this.http.get(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { params }); + return this.http.get(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { + params, + }); } importFen(fen: string): Observable { @@ -75,6 +78,10 @@ export class GameApiService { return this.http.post(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {}); } + analyzePosition(request: AnalysisRequest): Observable { + return this.http.post(`${this.apiBase}/api/analysis/position`, request); + } + private resolveWsBase(): string { if (this.wsBase) { return this.wsBase; diff --git a/src/app/services/game-completion.service.ts b/src/app/services/game-completion.service.ts index c0c692d..26d5c5e 100644 --- a/src/app/services/game-completion.service.ts +++ b/src/app/services/game-completion.service.ts @@ -14,7 +14,13 @@ export class GameCompletionService { } const status = state.status; - const gameEndingStatuses: GameStatus[] = ['checkmate', 'stalemate', 'resign', 'draw', 'insufficientMaterial']; + const gameEndingStatuses: GameStatus[] = [ + 'checkmate', + 'stalemate', + 'resign', + 'draw', + 'insufficientMaterial', + ]; if (!gameEndingStatuses.includes(status)) { return { isFinished: false, message: '' }; @@ -30,8 +36,18 @@ export class GameCompletionService { } private buildCompletionMessage(status: GameStatus, state: GameState, game: GameFull): string { - const winner = state.winner === 'white' ? game.white.displayName : state.winner === 'black' ? game.black.displayName : null; - const loser = state.winner === 'white' ? game.black.displayName : state.winner === 'black' ? game.white.displayName : null; + const winner = + state.winner === 'white' + ? game.white.displayName + : state.winner === 'black' + ? game.black.displayName + : null; + const loser = + state.winner === 'white' + ? game.black.displayName + : state.winner === 'black' + ? game.white.displayName + : null; switch (status) { case 'checkmate': diff --git a/src/app/services/game-import.service.ts b/src/app/services/game-import.service.ts index a46c5f4..e7e0ad6 100644 --- a/src/app/services/game-import.service.ts +++ b/src/app/services/game-import.service.ts @@ -10,11 +10,7 @@ export class GameImportService { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); - importFen( - fen: string, - onSuccess: () => void, - onError: (error: string) => void - ): void { + importFen(fen: string, onSuccess: () => void, onError: (error: string) => void): void { const trimmedFen = fen.trim(); if (!trimmedFen) { onError('Please provide a FEN string.'); @@ -31,15 +27,11 @@ export class GameImportService { }, error: (error) => { onError(getErrorMessage(error, 'FEN import failed.')); - } + }, }); } - importPgn( - pgn: string, - onSuccess: () => void, - onError: (error: string) => void - ): void { + importPgn(pgn: string, onSuccess: () => void, onError: (error: string) => void): void { const trimmedPgn = pgn.trim(); if (!trimmedPgn) { onError('Please provide a PGN string.'); @@ -56,7 +48,7 @@ export class GameImportService { }, error: (error) => { onError(getErrorMessage(error, 'PGN import failed.')); - } + }, }); } } diff --git a/src/app/services/game-stream.service.ts b/src/app/services/game-stream.service.ts index 2c19265..570a886 100644 --- a/src/app/services/game-stream.service.ts +++ b/src/app/services/game-stream.service.ts @@ -15,7 +15,7 @@ export class GameStreamService { startStreaming( gameId: string, onEvent: (event: GameStreamEvent) => void, - onStreamError: () => void + onStreamError: () => void, ): void { this.streamSubscription = this.gameApi .streamGame(gameId) @@ -32,7 +32,7 @@ export class GameStreamService { complete: () => { onStreamError(); this.startPolling(gameId, onEvent); - } + }, }); } @@ -45,7 +45,7 @@ export class GameStreamService { .pipe( startWith(0), switchMap(() => this.gameApi.getGame(gameId)), - takeUntilDestroyed(this.destroyRef) + takeUntilDestroyed(this.destroyRef), ) .subscribe({ next: (game) => { @@ -55,11 +55,11 @@ export class GameStreamService { this.lastGameStateHash = stateHash; const event: GameStreamEvent = { type: 'gameFull', - game + game, }; onEvent(event); } - } + }, }); } diff --git a/src/app/services/stream-handler.service.ts b/src/app/services/stream-handler.service.ts index 520a7fe..34894c1 100644 --- a/src/app/services/stream-handler.service.ts +++ b/src/app/services/stream-handler.service.ts @@ -14,7 +14,7 @@ export class StreamHandlerService { const emitErrorEvent = (message: string): void => { const errorEvent: ErrorEvent = { type: 'error', - error: { code: 'STREAM_ERROR', message } + error: { code: 'STREAM_ERROR', message }, }; observer.next(errorEvent); }; diff --git a/src/app/services/theme.service.ts b/src/app/services/theme.service.ts index b450ae9..d332c35 100644 --- a/src/app/services/theme.service.ts +++ b/src/app/services/theme.service.ts @@ -3,32 +3,32 @@ import { BehaviorSubject } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class ThemeService { - private readonly darkModeSubject = new BehaviorSubject(false); + private readonly darkModeSubject = new BehaviorSubject(false); - readonly darkMode$ = this.darkModeSubject.asObservable(); + readonly darkMode$ = this.darkModeSubject.asObservable(); - initTheme(): void { - const savedTheme = localStorage.getItem('theme'); - this.applyDarkMode(savedTheme === 'dark'); + initTheme(): void { + const savedTheme = localStorage.getItem('theme'); + this.applyDarkMode(savedTheme === 'dark'); + } + + toggleTheme(): void { + this.applyDarkMode(!this.darkModeSubject.value); + } + + setDarkMode(isDarkMode: boolean): void { + this.applyDarkMode(isDarkMode); + } + + private applyDarkMode(isDarkMode: boolean): void { + if (isDarkMode) { + document.documentElement.setAttribute('data-theme', 'dark'); + localStorage.setItem('theme', 'dark'); + } else { + document.documentElement.removeAttribute('data-theme'); + localStorage.removeItem('theme'); } - toggleTheme(): void { - this.applyDarkMode(!this.darkModeSubject.value); - } - - setDarkMode(isDarkMode: boolean): void { - this.applyDarkMode(isDarkMode); - } - - private applyDarkMode(isDarkMode: boolean): void { - if (isDarkMode) { - document.documentElement.setAttribute('data-theme', 'dark'); - localStorage.setItem('theme', 'dark'); - } else { - document.documentElement.removeAttribute('data-theme'); - localStorage.removeItem('theme'); - } - - this.darkModeSubject.next(isDarkMode); - } -} \ No newline at end of file + this.darkModeSubject.next(isDarkMode); + } +} diff --git a/src/app/services/tournament.service.ts b/src/app/services/tournament.service.ts index 493d400..9b18edc 100644 --- a/src/app/services/tournament.service.ts +++ b/src/app/services/tournament.service.ts @@ -32,7 +32,7 @@ export class TournamentService { body.set('clockIncrement', String(form.clockIncrement)); body.set('rated', String(form.rated)); return this.http.post(this.base, body.toString(), { - headers: new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' }) + headers: new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' }), }); } @@ -42,7 +42,7 @@ export class TournamentService { joinWithBotToken(id: string, botToken: string): Observable { return this.http.post(`${this.base}/${id}/join`, null, { - headers: new HttpHeaders({ Authorization: `Bearer ${botToken}` }) + headers: new HttpHeaders({ Authorization: `Bearer ${botToken}` }), }); } diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 0bb31eb..d9e4e75 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -4,5 +4,5 @@ export const environment = { accountServiceUrl: '', wsBaseUrl: 'ws://localhost:8084', userWsBaseUrl: 'ws://localhost:8084', - apiPath: '/api/board/game' + apiPath: '/api/board/game', }; diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts index 208ecbe..3ba81af 100644 --- a/src/environments/environment.staging.ts +++ b/src/environments/environment.staging.ts @@ -8,5 +8,5 @@ export const environment = { accountServiceUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de', wsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de', userWsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de', - apiPath: '/api/board/game' + apiPath: '/api/board/game', }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 58d8445..6b8c131 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -8,5 +8,5 @@ export const environment = { accountServiceUrl: runtimeConfig.apiUrl || '', wsBaseUrl: runtimeConfig.wsUrl, userWsBaseUrl: runtimeConfig.wsUrl, - apiPath: '/api/board/game' + apiPath: '/api/board/game', }; diff --git a/src/index.html b/src/index.html index 53e217f..7a39b00 100644 --- a/src/index.html +++ b/src/index.html @@ -1,17 +1,20 @@ - - - NowChess Frontend - - - - - - - - - - - + + + NowChess Frontend + + + + + + + + + + + diff --git a/src/main.ts b/src/main.ts index 5df75f9..190f341 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,5 +2,4 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { App } from './app/app'; -bootstrapApplication(App, appConfig) - .catch((err) => console.error(err)); +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/src/styles-variables.css b/src/styles-variables.css index 8ff4919..94b5225 100644 --- a/src/styles-variables.css +++ b/src/styles-variables.css @@ -1,28 +1,27 @@ - /* Light Mode Colors (Default) */ :root:not([data-theme='dark']) { /* Primary Colors - Light Mode */ - --color-primary: #BA6D4B; - --color-primary-dark: #5A2C28; - --color-primary-light: #F3C8A0; + --color-primary: #ba6d4b; + --color-primary-dark: #5a2c28; + --color-primary-light: #f3c8a0; /* Secondary Colors - Light Mode */ - --color-secondary-mint: #B9DAD1; - --color-secondary-blue: #B9C2DA; - --color-secondary-purple: #C19EF5; - --color-secondary-lime: #E1EAA9; + --color-secondary-mint: #b9dad1; + --color-secondary-blue: #b9c2da; + --color-secondary-purple: #c19ef5; + --color-secondary-lime: #e1eaa9; /* Functional Colors - Light Mode */ - --color-bg-main: #F3C8A0; - --color-bg-board: #B9DAD1; - --color-bg-card: #E1EAA9; - --color-bg-input: #B9DAD1; - --color-bg-input-focus: #B9C2DA; - --color-bg-button: #C19EF5; + --color-bg-main: #f3c8a0; + --color-bg-board: #b9dad1; + --color-bg-card: #e1eaa9; + --color-bg-input: #b9dad1; + --color-bg-input-focus: #b9c2da; + --color-bg-button: #c19ef5; --color-bg-button-hover: #ba4ba7; - --color-text-primary: #5A2C28; - --color-text-button-hover: #F3C8A0; + --color-text-primary: #5a2c28; + --color-text-button-hover: #f3c8a0; --color-border: #5a2843; } @@ -54,7 +53,6 @@ } :root { - /* ======================================== TYPOGRAPHY SIZES ======================================== */ @@ -119,15 +117,15 @@ SHADOWS ======================================== */ --shadow-md: 0 8px 24px rgba(90, 44, 40, 0.2); - /* Neon dialog / card variables (used by welcome dialogs, toolbar, login/register) */ - --bb-bg: rgba(8, 6, 28, 0.92); - --bb-border: #00d5ff; - --bb-glow: 0 0 18px rgba(0, 210, 255, 0.5), inset 0 0 10px rgba(0, 210, 255, 0.05); - --bb-tag: #00d5ff; - --bb-title: #d4f4ff; - --btn-bg: #00d5ff; - --btn-fg: #04000f; - --btn-glow: 0 0 14px rgba(0, 210, 255, 0.9); - --dlg-bg: rgba(8, 6, 28, 0.95); - --dlg-border: #00d5ff; -} \ No newline at end of file + /* Neon dialog / card variables (used by welcome dialogs, toolbar, login/register) */ + --bb-bg: rgba(8, 6, 28, 0.92); + --bb-border: #00d5ff; + --bb-glow: 0 0 18px rgba(0, 210, 255, 0.5), inset 0 0 10px rgba(0, 210, 255, 0.05); + --bb-tag: #00d5ff; + --bb-title: #d4f4ff; + --btn-bg: #00d5ff; + --btn-fg: #04000f; + --btn-glow: 0 0 14px rgba(0, 210, 255, 0.9); + --dlg-bg: rgba(8, 6, 28, 0.95); + --dlg-border: #00d5ff; +} diff --git a/src/styles.css b/src/styles.css index f9eb90b..8c154c2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -8,7 +8,15 @@ /* Light Mode (Default) — sunset gradient palette */ html:not([data-theme='dark']), html:not([data-theme='dark']) body { - background: linear-gradient(180deg, #1a1838 0%, #2e2050 25%, #4a2962 45%, #8b3a6b 65%, #d44d4a 85%, #ff6b3d 100%); + background: linear-gradient( + 180deg, + #1a1838 0%, + #2e2050 25%, + #4a2962 45%, + #8b3a6b 65%, + #d44d4a 85%, + #ff6b3d 100% + ); background-attachment: fixed; color: #fff; } @@ -21,7 +29,7 @@ html:not([data-theme='dark']) body::before { html[data-theme='dark'], html[data-theme='dark'] body { background: #0f1f2e; - background-image: + background-image: radial-gradient(2px 2px at 20px 30px, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0)), radial-gradient(2px 2px at 60px 70px, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0)), radial-gradient(1px 1px at 50px 50px, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0)), @@ -43,7 +51,7 @@ html[data-theme='dark'] body::before { left: 0; width: 100%; height: 100%; - background: + background: radial-gradient(circle at 20% 80%, rgba(45, 90, 123, 0.1) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(90, 111, 165, 0.1) 0%, transparent 50%), radial-gradient(circle at 50% 50%, rgba(29, 53, 82, 0.05) 0%, transparent 50%); @@ -55,7 +63,7 @@ html, body { margin: 0; min-height: 100%; - font-family: "Comic Sans MS", "Comic Sans", cursive; + font-family: 'Comic Sans MS', 'Comic Sans', cursive; position: relative; } @@ -144,11 +152,11 @@ input { display: inline-block; width: 120px; height: 60px; - background-color: #2B2B2B; - border: 5px solid #5B5B5B; + background-color: #2b2b2b; + border: 5px solid #5b5b5b; border-radius: 9999px; cursor: pointer; - transition: all 0.4s cubic-bezier(.46,.03,.52,.96); + transition: all 0.4s cubic-bezier(0.46, 0.03, 0.52, 0.96); } .welcome-shell .switch__indicator { @@ -159,10 +167,10 @@ input { display: block; width: 40px; height: 40px; - background-color: #7B7B7B; + background-color: #7b7b7b; border-radius: 9999px; box-shadow: 10px 0px 0 0 rgba(0, 0, 0, 0.2) inset; - transition: all 0.4s cubic-bezier(.46,.03,.52,.96); + transition: all 0.4s cubic-bezier(0.46, 0.03, 0.52, 0.96); } .welcome-shell .switch__indicator::before, @@ -170,9 +178,9 @@ input { position: absolute; content: ''; display: block; - background-color: #FFFFFF; + background-color: #ffffff; border-radius: 9999px; - transition: all 0.4s cubic-bezier(.46,.03,.52,.96); + transition: all 0.4s cubic-bezier(0.46, 0.03, 0.52, 0.96); } .welcome-shell .switch__indicator::before { @@ -198,10 +206,10 @@ input { display: block; width: 5px; height: 5px; - background-color: #FFFFFF; + background-color: #ffffff; border-radius: 9999px; animation: twinkle-stars 0.8s infinite -0.6s; - transition: all 0.4s cubic-bezier(.46,.03,.52,.96); + transition: all 0.4s cubic-bezier(0.46, 0.03, 0.52, 0.96); } .welcome-shell .switch__decoration::before, @@ -211,7 +219,7 @@ input { content: ''; width: 5px; height: 5px; - background-color: #FFFFFF; + background-color: #ffffff; border-radius: 9999px; } @@ -235,12 +243,12 @@ input { } .welcome-shell .switch__input:checked + .switch__label { - background-color: #8FB5F5; - border-color: #347CF8; + background-color: #8fb5f5; + border-color: #347cf8; } .welcome-shell .switch__input:checked + .switch__label .switch__indicator { - background-color: #ECD21F; + background-color: #ecd21f; box-shadow: none; transform: translate(-50%, -50%) translateX(72%); } diff --git a/styles.css b/styles.css index aae36d1..abe5407 100644 --- a/styles.css +++ b/styles.css @@ -1,30 +1,30 @@ /* Arabian Chess GUI Styles */ .root { - -fx-font-family: "Comic Sans MS", "Comic Sans", cursive; - -fx-background-color: #F3C8A0; + -fx-font-family: 'Comic Sans MS', 'Comic Sans', cursive; + -fx-background-color: #f3c8a0; } .button { - -fx-background-radius: 8; - -fx-padding: 8 16 8 16; - -fx-font-family: "Comic Sans MS", cursive; - -fx-font-size: 12px; - -fx-cursor: hand; + -fx-background-radius: 8; + -fx-padding: 8 16 8 16; + -fx-font-family: 'Comic Sans MS', cursive; + -fx-font-size: 12px; + -fx-cursor: hand; } .button:hover { - -fx-opacity: 0.8; + -fx-opacity: 0.8; } .label { - -fx-font-family: "Comic Sans MS", cursive; + -fx-font-family: 'Comic Sans MS', cursive; } .dialog-pane { - -fx-background-color: #F3C8A0; + -fx-background-color: #f3c8a0; } .dialog-pane .content { - -fx-font-family: "Comic Sans MS", cursive; + -fx-font-family: 'Comic Sans MS', cursive; } diff --git a/tsconfig.app.json b/tsconfig.app.json index 264f459..a0dcc37 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -6,10 +6,6 @@ "outDir": "./out-tsc/app", "types": [] }, - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "src/**/*.spec.ts" - ] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] } diff --git a/tsconfig.spec.json b/tsconfig.spec.json index 940b30a..e299354 100644 --- a/tsconfig.spec.json +++ b/tsconfig.spec.json @@ -4,12 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": [ - "jasmine" - ] + "types": ["jasmine"] }, - "include": [ - "src/**/*.d.ts", - "src/**/*.spec.ts" - ] + "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] } diff --git a/versions.env b/versions.env index 4c39cf4..99f6d57 100644 --- a/versions.env +++ b/versions.env @@ -1,3 +1,3 @@ MAJOR=0 -MINOR=2 -PATCH=8 +MINOR=3 +PATCH=0