2 Commits

Author SHA1 Message Date
TeamCity a3e51bade5 ci: bump version to v0.3.0 2026-06-10 20:41:14 +00:00
lq64 48959daae3 feat: bots (#9)
Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Reviewed-on: #9
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-06-10 22:38:06 +02:00
114 changed files with 2391 additions and 6147 deletions
-5
View File
@@ -19,7 +19,6 @@ If `$ARGUMENTS` already answers some of these, skip those questions.
## Step 2 — Research (if needed) ## Step 2 — Research (if needed)
If the bug involves component logic, a service, or routing: 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}`. - 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. - Check `.spec.ts` files for existing coverage of the broken area.
- Do NOT guess at root cause. Surface findings before drafting. - Do NOT guess at root cause. Surface findings before drafting.
@@ -57,7 +56,6 @@ Environment / Notes
``` ```
Rules: Rules:
- Steps must be minimal and reproducible. - Steps must be minimal and reproducible.
- Expected vs actual: concrete and unambiguous. - Expected vs actual: concrete and unambiguous.
- Omit "Environment / Notes" section if not relevant. - Omit "Environment / Notes" section if not relevant.
@@ -66,7 +64,6 @@ Rules:
Show the draft to the user. Show the draft to the user.
**Use `AskUserQuestion` tool to ask:** **Use `AskUserQuestion` tool to ask:**
- Are steps to reproduce complete and accurate? - Are steps to reproduce complete and accurate?
- Severity: Blocker / Critical / Major / Minor / Trivial? - Severity: Blocker / Critical / Major / Minor / Trivial?
- Any related tickets or recent changes to link? - Any related tickets or recent changes to link?
@@ -76,7 +73,6 @@ Incorporate feedback. Repeat until user approves.
## Step 5 — Determine Project ## Step 5 — Determine Project
> **Project routing rules (always apply these):** > **Project routing rules (always apply these):**
>
> - Frontend code (UI, UX, web app, components, services) → `NCWF` > - Frontend code (UI, UX, web app, components, services) → `NCWF`
> - Backend code (game engine, bots, API, services, coordinator) → `NCS` > - Backend code (game engine, bots, API, services, coordinator) → `NCS`
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI` > - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
@@ -90,7 +86,6 @@ If ambiguous, ask the user.
## Step 6 — Create Issue ## Step 6 — Create Issue
Call `mcp__youtrack__create_issue` with: Call `mcp__youtrack__create_issue` with:
- `project`: determined in Step 5 - `project`: determined in Step 5
- `summary`: concise title describing what is broken (≤72 chars, sentence case) - `summary`: concise title describing what is broken (≤72 chars, sentence case)
- `description`: full formatted defect report from Step 3 (Markdown) - `description`: full formatted defect report from Step 3 (Markdown)
+8 -13
View File
@@ -19,7 +19,6 @@ If `$ARGUMENTS` already answers some of these, skip those questions.
## Step 2 — Research (if needed) ## Step 2 — Research (if needed)
If the topic involves unfamiliar UI flows, component structure, or technical constraints: If the topic involves unfamiliar UI flows, component structure, or technical constraints:
- Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`). - Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`).
- Use `WebSearch` if the topic involves Angular APIs, external standards or protocols. - Use `WebSearch` if the topic involves Angular APIs, external standards or protocols.
- Do NOT guess. Surface findings before drafting. - Do NOT guess. Surface findings before drafting.
@@ -54,7 +53,6 @@ Implementation Notes
``` ```
Rules: Rules:
- User story line: plain English, present tense, from user's perspective. - User story line: plain English, present tense, from user's perspective.
- Acceptance criteria: testable, unambiguous, one condition each. - Acceptance criteria: testable, unambiguous, one condition each.
- Implementation notes: optional — only include if there are known constraints, related tickets, or design refs. - Implementation notes: optional — only include if there are known constraints, related tickets, or design refs.
@@ -63,7 +61,6 @@ Rules:
Show the draft to the user. Show the draft to the user.
**Use `AskUserQuestion` tool to ask:** **Use `AskUserQuestion` tool to ask:**
- Are the acceptance criteria complete and correct? - Are the acceptance criteria complete and correct?
- Any implementation constraints to add? - Any implementation constraints to add?
- Priority (if known)? - Priority (if known)?
@@ -73,7 +70,6 @@ Incorporate feedback. Repeat until user approves.
## Step 5 — Determine Project ## Step 5 — Determine Project
> **Project routing rules (always apply these):** > **Project routing rules (always apply these):**
>
> - Frontend code (UI, UX, web app, components, services) → `NCWF` > - Frontend code (UI, UX, web app, components, services) → `NCWF`
> - Backend code (game engine, bots, API, services, coordinator) → `NCS` > - Backend code (game engine, bots, API, services, coordinator) → `NCS`
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI` > - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
@@ -87,7 +83,6 @@ If still ambiguous, ask the user.
## Step 6 — Create Issue ## Step 6 — Create Issue
Call `mcp__youtrack__create_issue` with: Call `mcp__youtrack__create_issue` with:
- `project`: determined in Step 5 - `project`: determined in Step 5
- `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case) - `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case)
- `description`: full formatted story from Step 3 (Markdown) - `description`: full formatted story from Step 3 (Markdown)
@@ -101,14 +96,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`: Collect any issue IDs the user mentions. For each, determine the correct relation and call `mcp__youtrack__link_issues`:
| Situation | Relation to use | | Situation | Relation to use |
| --------------------------------------- | --------------- | |-----------|----------------|
| This story must be done before another | `blocks` | | This story must be done before another | `blocks` |
| Another story must be done before this | `is blocked by` | | Another story must be done before this | `is blocked by` |
| Stories share domain or are related | `relates to` | | Stories share domain or are related | `relates to` |
| This is a child of an epic/story | `subtask of` | | This is a child of an epic/story | `subtask of` |
| This is a parent grouping subtasks | `parent for` | | This is a parent grouping subtasks | `parent for` |
| This depends on another ticket's output | `depends on` | | 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. 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.
+7 -15
View File
@@ -7,12 +7,10 @@ This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`.
## Step 1 — Determine Scope ## Step 1 — Determine Scope
**Single-issue mode** (`$ARGUMENTS` set): **Single-issue mode** (`$ARGUMENTS` set):
- Call `mcp__youtrack__get_issue` on `$ARGUMENTS`. - Call `mcp__youtrack__get_issue` on `$ARGUMENTS`.
- Proceed with that issue only. - Proceed with that issue only.
**Sprint mode** (`$ARGUMENTS` empty): **Sprint mode** (`$ARGUMENTS` empty):
- Call `mcp__youtrack__search_issues` with query `project: NCWF Sprints: {current sprint} #Unresolved`. - 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}`. - If query returns 0 results, use `AskUserQuestion` to ask for the sprint name, then retry with `project: NCWF Sprints: {name}`.
- Collect all returned issues. - Collect all returned issues.
@@ -20,7 +18,6 @@ This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`.
## Step 2 — Build Issue Tree ## Step 2 — Build Issue Tree
For each top-level issue from Step 1: 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. 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). 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. 3. Recursively fetch subtasks until all leaves are known.
@@ -32,18 +29,17 @@ For each top-level issue from Step 1:
## Step 3 — Estimate Leaf Nodes ## Step 3 — Estimate Leaf Nodes
For each leaf node: For each leaf node:
1. Read: summary, description, acceptance criteria, implementation notes. 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. 2. If scope is unclear, search codebase (`Grep`/`Bash`) under `src/app/` for related files to gauge complexity.
3. Assign estimate using this scale: 3. Assign estimate using this scale:
| Size | Criteria | Estimate | | Size | Criteria | Estimate |
| ------- | ------------------------------------------------------ | -------- | |------|----------|----------|
| Trivial | Style tweak, copy change, 1-file tweak | 30m | | Trivial | Style tweak, copy change, 1-file tweak | 30m |
| Small | 13 files, single component/service, no unknowns | 1h2h | | Small | 13 files, single component/service, no unknowns | 1h2h |
| Medium | 36 files, new component + service wiring, some design | 3h5h | | Medium | 36 files, new component + service wiring, some design | 3h5h |
| Large | 6+ files, cross-feature, non-trivial state/routing | 1d2d | | Large | 6+ files, cross-feature, non-trivial state/routing | 1d2d |
| XL | New feature area, major refactor, research spike | 3d5d | | XL | New feature area, major refactor, research spike | 3d5d |
4. Record: estimate + one-line reasoning. 4. Record: estimate + one-line reasoning.
5. Skip leaf if it already has `Zeitschätzung` set — note it as pre-estimated. 5. Skip leaf if it already has `Zeitschätzung` set — note it as pre-estimated.
@@ -53,7 +49,6 @@ For each leaf node:
YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**. YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**.
Compute display-only rolled-up totals: Compute display-only rolled-up totals:
- Parent total = sum of all descendant leaf estimates (including pre-estimated ones). - Parent total = sum of all descendant leaf estimates (including pre-estimated ones).
- Flag any branch where some leaves are missing estimates (partial roll-up). - Flag any branch where some leaves are missing estimates (partial roll-up).
@@ -73,13 +68,11 @@ Epic NCWF-10: Board UI overhaul [4h 30m] ← rolled up
Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack. Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack.
If sprint mode, show grand total at bottom: If sprint mode, show grand total at bottom:
``` ```
Sprint total: Xd Yh Zm (N issues, M leaves to update) Sprint total: Xd Yh Zm (N issues, M leaves to update)
``` ```
**Use `AskUserQuestion` tool:** **Use `AskUserQuestion` tool:**
- Does the breakdown look right? - Does the breakdown look right?
- Any estimates to adjust before writing to YouTrack? - Any estimates to adjust before writing to YouTrack?
@@ -88,7 +81,6 @@ Incorporate all feedback before proceeding.
## Step 6 — Write Estimates ## Step 6 — Write Estimates
On user approval, write estimates **only to leaf nodes** (bottom-up order): 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. - 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"`. - YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`.
- Skip leaves already pre-estimated. - Skip leaves already pre-estimated.
+7 -11
View File
@@ -3,7 +3,6 @@
Automated defect-fix workflow. Ticket ID: `$ARGUMENTS` Automated defect-fix workflow. Ticket ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates: This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates:
- Build: `npm run build` - Build: `npm run build`
- Test: `npm test -- --watch=false --browsers=ChromeHeadless` - Test: `npm test -- --watch=false --browsers=ChromeHeadless`
- Format: `npx prettier --write .` (check with `npx prettier --check .`) - Format: `npx prettier --write .` (check with `npx prettier --check .`)
@@ -16,7 +15,6 @@ Extract and display: summary, description, steps to reproduce, Priority, Subsyst
## Step 2 — Create Worktree ## Step 2 — Create Worktree
Derive branch name from ticket: Derive branch name from ticket:
- `type` from YouTrack issue type: `bug``fix`, `feature`/`task``feat`, `refactor``refactor`, else `chore` - `type` from YouTrack issue type: `bug``fix`, `feature`/`task``feat`, `refactor``refactor`, else `chore`
- `scope` from affected area (kebab-case, omit if unclear) - `scope` from affected area (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles - `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
@@ -44,7 +42,6 @@ After root cause confirmed, assess scope:
**Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding. **Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding.
To create subtasks: 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"). 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: 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. - `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.
@@ -69,7 +66,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. 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. - 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.** - **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 ## Step 5 — Review
@@ -79,7 +76,6 @@ Display findings grouped by severity.
## Step 5b — Apply Review Findings ## Step 5b — Apply Review Findings
If the review produced any findings (any severity): If the review produced any findings (any severity):
1. Implement all agreed fixes. 1. Implement all agreed fixes.
2. Run `npm run build` — must be green. 2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green. 3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
@@ -129,12 +125,12 @@ Files changed:
After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked: After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked:
| Situation | Relation | | Situation | Relation |
| ----------------------------------- | --------------- | |-----------|---------|
| This fix blocks another open ticket | `blocks` | | This fix blocks another open ticket | `blocks` |
| Another ticket must ship first | `is blocked by` | | Another ticket must ship first | `is blocked by` |
| Related defect or story | `relates to` | | Related defect or story | `relates to` |
| Duplicate of another defect | `duplicates` | | 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. Scan the ticket description and comments for any issue IDs that were mentioned but not yet linked. Suggest those automatically.
-5
View File
@@ -4,7 +4,6 @@ Automated feature-implementation workflow. Ticket ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project = This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project =
`NCWF`. Gates: `NCWF`. Gates:
- Build: `npm run build` - Build: `npm run build`
- Test: `npm test -- --watch=false --browsers=ChromeHeadless` - Test: `npm test -- --watch=false --browsers=ChromeHeadless`
- Format: `npx prettier --write .` (check with `npx prettier --check .`) - Format: `npx prettier --write .` (check with `npx prettier --check .`)
@@ -41,7 +40,6 @@ are collected and reported at the end with a ready-to-run prompt.
## Step 3 — Create Worktree ## Step 3 — Create Worktree
Derive branch name from the root ticket `$ARGUMENTS`: Derive branch name from the root ticket `$ARGUMENTS`:
- `type` from YouTrack issue type: `feature`/`task``feat`, `refactor``refactor`, `bug``fix`, else `chore` - `type` from YouTrack issue type: `feature`/`task``feat`, `refactor``refactor`, `bug``fix`, else `chore`
- `scope` from affected area (kebab-case, omit if unclear) - `scope` from affected area (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles - `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
@@ -85,7 +83,6 @@ Display findings grouped by severity.
## Step 6b — Apply Review Findings ## Step 6b — Apply Review Findings
If the review produced any findings (any severity): If the review produced any findings (any severity):
1. Implement all agreed fixes. 1. Implement all agreed fixes.
2. Run `npm run build` — must be green. 2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green. 3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
@@ -142,12 +139,10 @@ Call `ExitWorktree` with `discard_changes: true` to delete the worktree.
Final report to the user, in two sections: Final report to the user, in two sections:
### Blocked in-project tasks ### Blocked in-project tasks
List any `NCWF` tasks that could **not** be implemented, with the blocker(s) 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.) that stopped them. (These can be re-run with this command once blockers clear.)
### Cross-project tasks (NCS / NCI / other) ### Cross-project tasks (NCS / NCI / other)
For every out-of-project task discovered in the tree (whether it was a subtask For every out-of-project task discovered in the tree (whether it was a subtask
or a blocker), output one entry: or a blocker), output one entry:
+5 -11
View File
@@ -12,7 +12,6 @@ Extract and display: summary, description, acceptance criteria, implementation n
## Step 2 — Research (if needed) ## Step 2 — Research (if needed)
If the story involves unfamiliar UI flows or technical constraints: If the story involves unfamiliar UI flows or technical constraints:
- Search repo for relevant code under `src/app/` (`Grep`/`Bash`). - Search repo for relevant code under `src/app/` (`Grep`/`Bash`).
- Use `WebSearch` for Angular APIs, external standards or protocols. - Use `WebSearch` for Angular APIs, external standards or protocols.
- Do NOT guess. Surface findings before proposing splits. - Do NOT guess. Surface findings before proposing splits.
@@ -20,7 +19,6 @@ If the story involves unfamiliar UI flows or technical constraints:
## Step 3 — Propose Split ## Step 3 — Propose Split
Analyse the story and propose a set of subtasks. Rules: Analyse the story and propose a set of subtasks. Rules:
- Each subtask = one unit of work, completable independently or in sequence. - Each subtask = one unit of work, completable independently or in sequence.
- No subtask should exceed ~2 days of work. - No subtask should exceed ~2 days of work.
- Name subtasks in imperative mood (e.g. "Add board theme service", "Render theme picker component"). - Name subtasks in imperative mood (e.g. "Add board theme service", "Render theme picker component").
@@ -28,7 +26,6 @@ Analyse the story and propose a set of subtasks. Rules:
Show proposed subtask list to user (titles only) and ask: Show proposed subtask list to user (titles only) and ask:
**Use `AskUserQuestion` tool:** **Use `AskUserQuestion` tool:**
- Does the split look right? - Does the split look right?
- Any subtasks to add, remove, or merge? - Any subtasks to add, remove, or merge?
- Should any subtask be assigned to a specific person? - Should any subtask be assigned to a specific person?
@@ -60,7 +57,6 @@ What must be true for this subtask to be considered complete:
``` ```
Rules: Rules:
- Steps/Tasks: concrete, ordered where order matters. - 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). - 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. - Keep description short — one paragraph max.
@@ -78,7 +74,6 @@ If a subtask's project is ambiguous, ask the user before creating it.
## Step 6 — Create Subtasks ## Step 6 — Create Subtasks
For each subtask call `mcp__youtrack__create_issue` with: For each subtask call `mcp__youtrack__create_issue` with:
- `project`: from Step 5 - `project`: from Step 5
- `summary`: subtask title (≤72 chars, sentence case) - `summary`: subtask title (≤72 chars, sentence case)
- `description`: full formatted description from Step 4 (Markdown) - `description`: full formatted description from Step 4 (Markdown)
@@ -89,7 +84,6 @@ Then call `mcp__youtrack__link_issues` to link each created subtask to `$ARGUMEN
## Step 6b — Inter-Subtask Links ## Step 6b — Inter-Subtask Links
If subtasks must be done in sequence (one depends on output of another), add ordering 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). - 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: Ask the user to confirm sequencing before adding these links:
@@ -100,12 +94,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: Scan `$ARGUMENTS` description and implementation notes for any referenced issue IDs not already linked. For each:
| Situation | Relation | | Situation | Relation |
| ---------------------------------------- | --------------- | |-----------|---------|
| Parent story blocks another epic/story | `blocks` | | Parent story blocks another epic/story | `blocks` |
| Story depends on another epic completing | `is blocked by` | | Story depends on another epic completing | `is blocked by` |
| Related story in same domain | `relates to` | | Related story in same domain | `relates to` |
| This story duplicates or supersedes | `duplicates` | | This story duplicates or supersedes | `duplicates` |
Suggest links to the user and call `mcp__youtrack__link_issues` on confirmation. Suggest links to the user and call `mcp__youtrack__link_issues` on confirmation.
+26 -40
View File
@@ -1,77 +1,63 @@
## [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) ## 0.0.0 (2026-05-12)
### Features ### Features
- added bot, light and dark mode ([2de003e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2de003e497baee72f998d0d805ca1e58aababe48)) * 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)) * 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-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)) * 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 ### Bug Fixes
- build issues ([36d72fd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/36d72fd6cda41be51d28f8ac307dcdbcd31afa91)) * 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)) * 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 ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d))
- GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02)) * GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02))
- npm ([c11c1d4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c11c1d4dce9de4bd5b463e891eebf961b37feb04)) * 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 .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)) * 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)) * 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)) * 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) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.1.0...0.0.0) (2026-05-12)
### Features ### 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) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.0...0.0.0) (2026-05-12)
### Bug Fixes ### 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) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.1...0.0.0) (2026-05-12)
### Bug Fixes ### 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) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.2...0.0.0) (2026-05-14)
### Bug Fixes ### 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) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.3...0.0.0) (2026-05-15)
### Bug Fixes ### Bug Fixes
- build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080)) * 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)) * 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.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) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.5...0.0.0) (2026-06-02)
### Bug Fixes ### 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.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) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.7...0.0.0) (2026-06-10)
### Bug Fixes ### 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))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.8...0.0.0) (2026-06-10)
### Features
* bots ([#9](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/9)) ([48959da](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/48959daae36e709ea7782ca04fdde699854f8e66))
+13 -4
View File
@@ -18,7 +18,9 @@
"builder": "@angular/build:application", "builder": "@angular/build:application",
"options": { "options": {
"browser": "src/main.ts", "browser": "src/main.ts",
"polyfills": ["zone.js"], "polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"assets": [ "assets": [
{ {
@@ -36,7 +38,9 @@
"output": "/assets/ChessAssets" "output": "/assets/ChessAssets"
} }
], ],
"styles": ["src/styles.css"] "styles": [
"src/styles.css"
]
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -102,7 +106,10 @@
"test": { "test": {
"builder": "@angular/build:karma", "builder": "@angular/build:karma",
"options": { "options": {
"polyfills": ["zone.js", "zone.js/testing"], "polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"assets": [ "assets": [
{ {
@@ -120,7 +127,9 @@
"output": "/assets/ChessAssets" "output": "/assets/ChessAssets"
} }
], ],
"styles": ["src/styles.css"] "styles": [
"src/styles.css"
]
} }
} }
} }
+5 -2
View File
@@ -55,6 +55,7 @@ tags:
description: Export a game as FEN or PGN description: Export a game as FEN or PGN
paths: paths:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Game lifecycle # Game lifecycle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -466,6 +467,7 @@ paths:
# ============================================================================= # =============================================================================
components: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
type: http type: http
@@ -515,6 +517,7 @@ components:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
schemas: schemas:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Requests # Requests
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -548,7 +551,7 @@ components:
pgn: pgn:
type: string type: string
description: PGN text (headers and move list) description: PGN text (headers and move list)
example: '1. e4 e5 2. Nf3 Nc6 *' example: "1. e4 e5 2. Nf3 Nc6 *"
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Game state # Game state
@@ -584,7 +587,7 @@ components:
pgn: pgn:
type: string type: string
description: PGN move text for the full game so far description: PGN move text for the full game so far
example: '1. e4' example: "1. e4"
turn: turn:
type: string type: string
enum: [white, black] enum: [white, black]
-5
View File
@@ -1,9 +1,4 @@
{ {
"/api/analysis": {
"target": "http://localhost:8087",
"secure": false,
"changeOrigin": true
},
"/api/tournament": { "/api/tournament": {
"target": "http://localhost:8089", "target": "http://localhost:8089",
"secure": false, "secure": false,
+2 -2
View File
@@ -1,4 +1,4 @@
window.__RUNTIME_CONFIG__ = { window.__RUNTIME_CONFIG__ = {
API_URL: '${API_URL}', API_URL: "${API_URL}",
WEBSOCKET_URL: '${WEBSOCKET_URL}', WEBSOCKET_URL: "${WEBSOCKET_URL}"
}; };
+3 -7
View File
@@ -1,8 +1,4 @@
import { import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
ApplicationConfig,
provideBrowserGlobalErrorListeners,
provideZoneChangeDetection,
} from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
@@ -14,6 +10,6 @@ export const appConfig: ApplicationConfig = {
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideHttpClient(withInterceptors([authInterceptor])), provideHttpClient(withInterceptors([authInterceptor])),
provideRouter(routes), provideRouter(routes)
], ]
}; };
+2 -1
View File
@@ -1 +1,2 @@
<app-toolbar /> <router-outlet /> <app-toolbar />
<router-outlet />
+1 -3
View File
@@ -6,7 +6,6 @@ import { ChallengesComponent } from './pages/challenges/challenges.component';
import { GamesComponent } from './pages/games/games.component'; import { GamesComponent } from './pages/games/games.component';
import { TournamentsComponent } from './pages/tournaments/tournaments.component'; import { TournamentsComponent } from './pages/tournaments/tournaments.component';
import { BotsComponent } from './pages/bots/bots.component'; import { BotsComponent } from './pages/bots/bots.component';
import { AnalysisComponent } from './pages/analysis/analysis.component';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: WelcomeComponent }, { path: '', component: WelcomeComponent },
@@ -15,7 +14,6 @@ export const routes: Routes = [
{ path: 'challenges', component: ChallengesComponent }, { path: 'challenges', component: ChallengesComponent },
{ path: 'tournaments', component: TournamentsComponent }, { path: 'tournaments', component: TournamentsComponent },
{ path: 'bots', component: BotsComponent }, { path: 'bots', component: BotsComponent },
{ path: 'analysis', component: AnalysisComponent },
{ path: 'game/:gameId', component: GameComponent }, { path: 'game/:gameId', component: GameComponent },
{ path: '**', redirectTo: '' }, { path: '**', redirectTo: '' }
]; ];
+1 -2
View File
@@ -1,13 +1,12 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { App } from './app'; import { App } from './app';
describe('App', () => { describe('App', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [provideRouter([]), provideHttpClient()], providers: [provideRouter([])]
}).compileComponents(); }).compileComponents();
}); });
+2 -2
View File
@@ -7,10 +7,10 @@ import { ThemeService } from './services/theme.service';
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, ToolbarComponent], imports: [RouterOutlet, ToolbarComponent],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css', styleUrl: './app.css'
}) })
export class App implements OnInit { export class App implements OnInit {
constructor(private readonly themeService: ThemeService) {} constructor(private readonly themeService: ThemeService) { }
ngOnInit(): void { ngOnInit(): void {
this.themeService.initTheme(); this.themeService.initTheme();
+1 -5
View File
@@ -12,11 +12,7 @@
letter-spacing: 1px; letter-spacing: 1px;
cursor: pointer; cursor: pointer;
box-shadow: var(--btn-glow); box-shadow: var(--btn-glow);
transition: transition: transform 0.2s ease, filter 0.2s ease, background 1.6s ease, box-shadow 1.6s ease;
transform 0.2s ease,
filter 0.2s ease,
background 1.6s ease,
box-shadow 1.6s ease;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1,108 +0,0 @@
: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);
}
@@ -1,48 +0,0 @@
@if (moves.length === 0) {
<div class="empty">No annotated moves yet.</div>
} @else {
<div class="move-grid" role="list">
@for (pair of pairs; track $index) {
<div class="mv-num" role="presentation">{{ $index + 1 }}</div>
<div
class="mv"
[class.active]="isWhiteActive($index)"
[class]="qualityClass(pair.white)"
role="listitem"
tabindex="0"
(click)="selectWhite($index)"
(keydown.enter)="selectWhite($index)"
>
<span class="mv-san">{{ pair.white?.san ?? '' }}</span>
@if (qualityLabel(pair.white)) {
<span class="mv-badge" [class]="qualityClass(pair.white)">{{
qualityLabel(pair.white)
}}</span>
}
</div>
<div
class="mv"
[class.active]="isBlackActive($index)"
[class.mv-empty]="!pair.black"
[class]="qualityClass(pair.black)"
role="listitem"
[attr.tabindex]="pair.black ? 0 : null"
(click)="selectBlack($index, pair.black)"
(keydown.enter)="selectBlack($index, pair.black)"
>
@if (pair.black) {
<span class="mv-san">{{ pair.black.san }}</span>
@if (qualityLabel(pair.black)) {
<span class="mv-badge" [class]="qualityClass(pair.black)">{{
qualityLabel(pair.black)
}}</span>
}
} @else {
<span class="mv-san mv-placeholder"></span>
}
</div>
}
</div>
}
@@ -1,83 +0,0 @@
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<MoveQuality, string> = {
brilliant: '!!',
best: '!',
good: '',
inaccuracy: '?!',
mistake: '?',
blunder: '??',
};
const QUALITY_CLASSES: Record<MoveQuality, string> = {
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<number>();
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)}`;
}
}
@@ -27,10 +27,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
transition: transition: background 0.15s, border-color 0.15s, color 0.15s;
background 0.15s,
border-color 0.15s,
color 0.15s;
} }
.btn:hover:not(:disabled) { .btn:hover:not(:disabled) {
@@ -1,53 +1,26 @@
<div class="board-actions" [class.disabled]="isGameFinished"> <div class="board-actions" [class.disabled]="isGameFinished">
<button class="btn" type="button" [disabled]="isGameFinished" (click)="takeback.emit()"> <button class="btn" type="button" [disabled]="isGameFinished" (click)="takeback.emit()">
<svg <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
width="12" <path d="M21 7v6h-6"/>
height="12" <path d="M3 17a9 9 0 0 0 15.5-6"/>
viewBox="0 0 24 24" <path d="M3 7a9 9 0 0 1 15.5 6"/>
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 7v6h-6" />
<path d="M3 17a9 9 0 0 0 15.5-6" />
<path d="M3 7a9 9 0 0 1 15.5 6" />
</svg> </svg>
Takeback Takeback
</button> </button>
<button class="btn" type="button" [disabled]="isGameFinished" (click)="offerDraw.emit()"> <button class="btn" type="button" [disabled]="isGameFinished" (click)="offerDraw.emit()">
<svg <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
width="12" <path d="M5 12h14"/>
height="12" <polyline points="12 5 19 12 12 19"/>
viewBox="0 0 24 24" <polyline points="12 19 5 12 12 5"/>
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14" />
<polyline points="12 5 19 12 12 19" />
<polyline points="12 19 5 12 12 5" />
</svg> </svg>
Offer Draw Offer Draw
</button> </button>
<button class="btn btn-danger" type="button" [disabled]="isGameFinished" (click)="resign.emit()"> <button class="btn btn-danger" type="button" [disabled]="isGameFinished" (click)="resign.emit()">
<svg <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
width="12" <path d="M4 4l16 16"/>
height="12" <path d="M4 20l16-16"/>
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 4l16 16" />
<path d="M4 20l16-16" />
</svg> </svg>
Resign Resign
</button> </button>
@@ -5,7 +5,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
standalone: true, standalone: true,
imports: [], imports: [],
templateUrl: './board-actions-bar.component.html', templateUrl: './board-actions-bar.component.html',
styleUrl: './board-actions-bar.component.css', styleUrl: './board-actions-bar.component.css'
}) })
export class BoardActionsBarComponent { export class BoardActionsBarComponent {
@Input() undoAvailable = false; @Input() undoAvailable = false;
@@ -23,9 +23,7 @@
background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%); background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%);
border: 2px solid #00d5ff; border: 2px solid #00d5ff;
border-radius: 8px; border-radius: 8px;
box-shadow: box-shadow: 0 0 20px rgba(0, 213, 255, 0.3), inset 0 0 10px rgba(0, 213, 255, 0.05);
0 0 20px rgba(0, 213, 255, 0.3),
inset 0 0 10px rgba(0, 213, 255, 0.05);
width: 90%; width: 90%;
max-width: 500px; max-width: 500px;
max-height: 90vh; max-height: 90vh;
@@ -1,117 +1,91 @@
<div class="challenge-create-dialog-overlay" (click)="cancel()" [class.loading]="loading"> <div class="challenge-create-dialog-overlay" (click)="cancel()" [class.loading]="loading">
<div class="challenge-create-dialog" (click)="$event.stopPropagation()"> <div class="challenge-create-dialog" (click)="$event.stopPropagation()">
<div class="dialog-header"> <div class="dialog-header">
<h2>Create Challenge</h2> <h2>Create Challenge</h2>
<button type="button" class="close-btn" (click)="cancel()" [disabled]="loading">×</button> <button type="button" class="close-btn" (click)="cancel()" [disabled]="loading">×</button>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="dialog-form">
<!-- Error Message -->
<div *ngIf="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<!-- Target Username -->
<div class="form-group">
<label for="targetUsername">Opponent Username</label>
<input type="text" id="targetUsername" formControlName="targetUsername"
placeholder="Enter opponent's username" required />
<small *ngIf="form.get('targetUsername')?.hasError('required') && form.get('targetUsername')?.touched">
Username is required
</small>
</div>
<!-- Player Color Selection -->
<div class="form-group">
<label for="color">Play as</label>
<select id="color" formControlName="color">
<option value="white">White</option>
<option value="black">Black</option>
<option value="random">Random</option>
</select>
</div>
<!-- Time Control Mode Selection -->
<div class="form-group">
<label for="timeMode">Time Control</label>
<select id="timeMode" formControlName="timeMode">
<option value="blitz">Blitz</option>
<option value="rapid">Rapid</option>
<option value="classical">Classical</option>
<option value="unlimited">Unlimited</option>
</select>
</div>
<!-- Time Presets -->
<div class="form-group" *ngIf="selectedTimeMode !== 'unlimited'">
<label>Presets</label>
<div class="preset-buttons">
<button type="button" *ngFor="let preset of getAvailablePresets()" class="preset-btn"
(click)="selectPreset(preset)" [disabled]="loading">
{{ preset.label }}
</button>
</div>
</div>
<!-- Custom Time Control -->
<div class="form-group" *ngIf="selectedTimeMode !== 'unlimited'">
<div class="form-row">
<div class="form-col">
<label for="limitMinutes">Time (minutes)</label>
<input type="number" id="limitMinutes" formControlName="limitMinutes" min="1" max="1000" />
</div>
<div class="form-col">
<label for="incrementSeconds">Increment (seconds)</label>
<input type="number" id="incrementSeconds" formControlName="incrementSeconds" min="0" max="300" />
</div>
</div>
</div>
<!-- TTL (Time to Live) -->
<div class="form-group">
<label for="ttlSeconds">Challenge Expires In</label>
<select id="ttlSeconds" formControlName="ttlSeconds">
<option *ngFor="let ttl of ttlOptions" [value]="ttl.seconds">
{{ ttl.label }}
</option>
</select>
</div>
<!-- Buttons -->
<div class="dialog-buttons">
<button type="button" class="btn btn-secondary" (click)="cancel()" [disabled]="loading">
Cancel
</button>
<button type="submit" class="btn btn-primary" [disabled]="form.invalid || loading">
{{ loading ? 'Sending...' : 'Send Challenge' }}
</button>
</div>
</form>
</div> </div>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="dialog-form">
<!-- Error Message -->
<div *ngIf="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<!-- Target Username -->
<div class="form-group">
<label for="targetUsername">Opponent Username</label>
<input
type="text"
id="targetUsername"
formControlName="targetUsername"
placeholder="Enter opponent's username"
required
/>
<small
*ngIf="
form.get('targetUsername')?.hasError('required') && form.get('targetUsername')?.touched
"
>
Username is required
</small>
</div>
<!-- Player Color Selection -->
<div class="form-group">
<label for="color">Play as</label>
<select id="color" formControlName="color">
<option value="white">White</option>
<option value="black">Black</option>
<option value="random">Random</option>
</select>
</div>
<!-- Time Control Mode Selection -->
<div class="form-group">
<label for="timeMode">Time Control</label>
<select id="timeMode" formControlName="timeMode">
<option value="blitz">Blitz</option>
<option value="rapid">Rapid</option>
<option value="classical">Classical</option>
<option value="unlimited">Unlimited</option>
</select>
</div>
<!-- Time Presets -->
<div class="form-group" *ngIf="selectedTimeMode !== 'unlimited'">
<label>Presets</label>
<div class="preset-buttons">
<button
type="button"
*ngFor="let preset of getAvailablePresets()"
class="preset-btn"
(click)="selectPreset(preset)"
[disabled]="loading"
>
{{ preset.label }}
</button>
</div>
</div>
<!-- Custom Time Control -->
<div class="form-group" *ngIf="selectedTimeMode !== 'unlimited'">
<div class="form-row">
<div class="form-col">
<label for="limitMinutes">Time (minutes)</label>
<input
type="number"
id="limitMinutes"
formControlName="limitMinutes"
min="1"
max="1000"
/>
</div>
<div class="form-col">
<label for="incrementSeconds">Increment (seconds)</label>
<input
type="number"
id="incrementSeconds"
formControlName="incrementSeconds"
min="0"
max="300"
/>
</div>
</div>
</div>
<!-- TTL (Time to Live) -->
<div class="form-group">
<label for="ttlSeconds">Challenge Expires In</label>
<select id="ttlSeconds" formControlName="ttlSeconds">
<option *ngFor="let ttl of ttlOptions" [value]="ttl.seconds">
{{ ttl.label }}
</option>
</select>
</div>
<!-- Buttons -->
<div class="dialog-buttons">
<button type="button" class="btn btn-secondary" (click)="cancel()" [disabled]="loading">
Cancel
</button>
<button type="submit" class="btn btn-primary" [disabled]="form.invalid || loading">
{{ loading ? 'Sending...' : 'Send Challenge' }}
</button>
</div>
</form>
</div>
</div>
@@ -1,20 +1,6 @@
import { import { Component, inject, OnInit, OnDestroy, DestroyRef, Output, EventEmitter } from '@angular/core';
Component,
inject,
OnInit,
OnDestroy,
DestroyRef,
Output,
EventEmitter,
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
FormsModule,
ReactiveFormsModule,
FormBuilder,
FormGroup,
Validators,
} from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { finalize } from 'rxjs'; import { finalize } from 'rxjs';
import { ChallengeService } from '../../services/challenge.service'; import { ChallengeService } from '../../services/challenge.service';
@@ -25,155 +11,149 @@ import { PlayerColor } from '../../models/challenge.models';
type TimeMode = 'blitz' | 'rapid' | 'classical' | 'unlimited'; type TimeMode = 'blitz' | 'rapid' | 'classical' | 'unlimited';
interface TimePreset { interface TimePreset {
label: string; label: string;
limitSeconds: number; limitSeconds: number;
incrementSeconds: number; incrementSeconds: number;
} }
@Component({ @Component({
selector: 'app-challenge-create-dialog', selector: 'app-challenge-create-dialog',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, ReactiveFormsModule], imports: [CommonModule, FormsModule, ReactiveFormsModule],
templateUrl: './challenge-create-dialog.component.html', templateUrl: './challenge-create-dialog.component.html',
styleUrls: ['./challenge-create-dialog.component.css'], styleUrls: ['./challenge-create-dialog.component.css']
}) })
export class ChallengeCreateDialogComponent implements OnInit, OnDestroy { export class ChallengeCreateDialogComponent implements OnInit, OnDestroy {
private readonly challengeService = inject(ChallengeService); private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@Output() closeChallengeDialog = new EventEmitter<void>(); @Output() closeChallengeDialog = new EventEmitter<void>();
form!: FormGroup; form!: FormGroup;
loading = false; loading = false;
errorMessage = ''; errorMessage = '';
selectedTimeMode: TimeMode = 'rapid'; selectedTimeMode: TimeMode = 'rapid';
timePresets: Record<TimeMode, TimePreset[]> = { timePresets: Record<TimeMode, TimePreset[]> = {
blitz: [ blitz: [
{ label: '1+0', limitSeconds: 60, incrementSeconds: 0 }, { label: '1+0', limitSeconds: 60, incrementSeconds: 0 },
{ label: '2+1', limitSeconds: 120, incrementSeconds: 1 }, { label: '2+1', limitSeconds: 120, incrementSeconds: 1 },
{ label: '3+0', limitSeconds: 180, incrementSeconds: 0 }, { label: '3+0', limitSeconds: 180, incrementSeconds: 0 },
{ label: '3+2', limitSeconds: 180, incrementSeconds: 2 }, { label: '3+2', limitSeconds: 180, incrementSeconds: 2 },
{ label: '5+0', limitSeconds: 300, incrementSeconds: 0 }, { label: '5+0', limitSeconds: 300, incrementSeconds: 0 }
], ],
rapid: [ rapid: [
{ label: '10+0', limitSeconds: 600, incrementSeconds: 0 }, { label: '10+0', limitSeconds: 600, incrementSeconds: 0 },
{ label: '10+5', limitSeconds: 600, incrementSeconds: 5 }, { label: '10+5', limitSeconds: 600, incrementSeconds: 5 },
{ label: '15+10', limitSeconds: 900, incrementSeconds: 10 }, { label: '15+10', limitSeconds: 900, incrementSeconds: 10 },
{ label: '25+10', limitSeconds: 1500, incrementSeconds: 10 }, { label: '25+10', limitSeconds: 1500, incrementSeconds: 10 }
], ],
classical: [ classical: [
{ label: '30+0', limitSeconds: 1800, incrementSeconds: 0 }, { label: '30+0', limitSeconds: 1800, incrementSeconds: 0 },
{ label: '30+20', limitSeconds: 1800, incrementSeconds: 20 }, { label: '30+20', limitSeconds: 1800, incrementSeconds: 20 },
{ label: '60+30', limitSeconds: 3600, incrementSeconds: 30 }, { label: '60+30', limitSeconds: 3600, incrementSeconds: 30 },
{ label: '90+30', limitSeconds: 5400, incrementSeconds: 30 }, { label: '90+30', limitSeconds: 5400, incrementSeconds: 30 }
], ],
unlimited: [], unlimited: []
}; };
ttlOptions = [ ttlOptions = [
{ label: '5 minutes', seconds: 300 }, { label: '5 minutes', seconds: 300 },
{ label: '1 hour', seconds: 3600 }, { label: '1 hour', seconds: 3600 },
{ label: '1 day', seconds: 86400 }, { label: '1 day', seconds: 86400 },
{ label: 'No expiry', seconds: 0 }, { label: 'No expiry', seconds: 0 }
]; ];
ngOnInit(): void { ngOnInit(): void {
this.initializeForm(); this.initializeForm();
} }
ngOnDestroy(): void {} ngOnDestroy(): void {
}
private initializeForm(): void { private initializeForm(): void {
this.form = this.fb.group({ this.form = this.fb.group({
targetUsername: ['', [Validators.required, Validators.minLength(1)]], targetUsername: ['', [Validators.required, Validators.minLength(1)]],
color: ['random', Validators.required], color: ['random', Validators.required],
timeMode: ['rapid'], timeMode: ['rapid'],
limitMinutes: [10, [Validators.required, Validators.min(1), Validators.max(1000)]], limitMinutes: [10, [Validators.required, Validators.min(1), Validators.max(1000)]],
incrementSeconds: [5, [Validators.required, Validators.min(0), Validators.max(300)]], incrementSeconds: [5, [Validators.required, Validators.min(0), Validators.max(300)]],
ttlSeconds: [3600, Validators.required], ttlSeconds: [3600, Validators.required]
}); });
this.form this.form.get('timeMode')?.valueChanges
.get('timeMode') .pipe(takeUntilDestroyed(this.destroyRef))
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((mode: unknown) => {
.subscribe((mode: unknown) => { const timeMode = mode as TimeMode;
const timeMode = mode as TimeMode; this.selectedTimeMode = timeMode;
this.selectedTimeMode = timeMode; if (timeMode !== 'unlimited') {
if (timeMode !== 'unlimited') { const firstPreset = this.timePresets[timeMode][0];
const firstPreset = this.timePresets[timeMode][0]; if (firstPreset) {
if (firstPreset) { this.form.patchValue({
this.form.patchValue({ limitMinutes: firstPreset.limitSeconds / 60,
limitMinutes: firstPreset.limitSeconds / 60, incrementSeconds: firstPreset.incrementSeconds
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;
} }
});
}
selectPreset(preset: TimePreset): void { const targetUsername = this.form.get('targetUsername')?.value?.trim();
this.form.patchValue({ if (!targetUsername) {
limitMinutes: preset.limitSeconds / 60, this.errorMessage = 'Please enter a valid username';
incrementSeconds: preset.incrementSeconds, return;
}); }
}
getAvailablePresets(): TimePreset[] { this.errorMessage = '';
return this.timePresets[this.selectedTimeMode] || []; this.loading = true;
} this.form.disable();
submit(): void { const limitSeconds = Math.round((this.form.getRawValue().limitMinutes || 0) * 60);
if (this.form.invalid || this.loading) { const { incrementSeconds, ttlSeconds, color: rawColor } = this.form.getRawValue();
return; 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.');
}
});
} }
const targetUsername = this.form.get('targetUsername')?.value?.trim(); cancel(): void {
if (!targetUsername) { this.form.reset();
this.errorMessage = 'Please enter a valid username'; this.closeChallengeDialog.emit();
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();
}
} }
@@ -8,9 +8,7 @@
background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%); background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%);
border: 2px solid #00d5ff; border: 2px solid #00d5ff;
border-radius: 8px; border-radius: 8px;
box-shadow: box-shadow: 0 0 20px rgba(0, 213, 255, 0.4), inset 0 0 10px rgba(0, 213, 255, 0.05);
0 0 20px rgba(0, 213, 255, 0.4),
inset 0 0 10px rgba(0, 213, 255, 0.05);
padding: 16px; padding: 16px;
color: #e0e0e0; color: #e0e0e0;
z-index: 2000; z-index: 2000;
@@ -18,9 +16,7 @@
&.error { &.error {
border-color: #ff6b6b; border-color: #ff6b6b;
box-shadow: box-shadow: 0 0 20px rgba(255, 107, 107, 0.4), inset 0 0 10px rgba(255, 107, 107, 0.05);
0 0 20px rgba(255, 107, 107, 0.4),
inset 0 0 10px rgba(255, 107, 107, 0.05);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -1,50 +1,38 @@
<div class="challenge-notification" [class.error]="!!errorMessage"> <div class="challenge-notification" [class.error]="!!errorMessage">
<div class="notification-header"> <div class="notification-header">
<div class="notification-title"> <div class="notification-title">
<span class="badge">CHALLENGE</span> <span class="badge">CHALLENGE</span>
<span class="title">{{ getCreatedByDisplay() }} challenged you!</span> <span class="title">{{ getCreatedByDisplay() }} challenged you!</span>
</div> </div>
<button <button type="button" class="close-btn" (click)="onClose()"
type="button" [disabled]="acceptingChallenge || decliningChallenge">
class="close-btn" ×
(click)="onClose()" </button>
[disabled]="acceptingChallenge || decliningChallenge"
>
×
</button>
</div>
<div class="notification-content">
<div class="time-control">
<span class="label">Time Control:</span>
<span class="value">{{ getTimeControlDisplay() }}</span>
</div> </div>
<div class="expiration"> <div class="notification-content">
<span class="label">{{ getExpirationInfo() }}</span> <div class="time-control">
</div> <span class="label">Time Control:</span>
<span class="value">{{ getTimeControlDisplay() }}</span>
</div>
<div *ngIf="errorMessage" class="error-message"> <div class="expiration">
{{ errorMessage }} <span class="label">{{ getExpirationInfo() }}</span>
</div> </div>
<div class="notification-actions"> <div *ngIf="errorMessage" class="error-message">
<button {{ errorMessage }}
type="button" </div>
class="btn btn-decline"
(click)="onDecline()" <div class="notification-actions">
[disabled]="acceptingChallenge || decliningChallenge" <button type="button" class="btn btn-decline" (click)="onDecline()"
> [disabled]="acceptingChallenge || decliningChallenge">
{{ decliningChallenge ? 'Declining...' : 'Decline' }} {{ decliningChallenge ? 'Declining...' : 'Decline' }}
</button> </button>
<button <button type="button" class="btn btn-accept" (click)="onAccept()"
type="button" [disabled]="acceptingChallenge || decliningChallenge">
class="btn btn-accept" {{ acceptingChallenge ? 'Accepting...' : 'Accept' }}
(click)="onAccept()" </button>
[disabled]="acceptingChallenge || decliningChallenge" </div>
>
{{ acceptingChallenge ? 'Accepting...' : 'Accept' }}
</button>
</div> </div>
</div> </div>
</div>
@@ -7,104 +7,102 @@ import { finalize } from 'rxjs';
import { getErrorMessage } from '../../core/http/error-message.util'; import { getErrorMessage } from '../../core/http/error-message.util';
@Component({ @Component({
selector: 'app-challenge-notification', selector: 'app-challenge-notification',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
templateUrl: './challenge-notification.component.html', templateUrl: './challenge-notification.component.html',
styleUrls: ['./challenge-notification.component.css'], styleUrls: ['./challenge-notification.component.css']
}) })
export class ChallengeNotificationComponent { export class ChallengeNotificationComponent {
@Input() challenge!: Challenge; @Input() challenge!: Challenge;
@Output() accept = new EventEmitter<Challenge>(); @Output() accept = new EventEmitter<Challenge>();
@Output() decline = new EventEmitter<Challenge>(); @Output() decline = new EventEmitter<Challenge>();
@Output() close = new EventEmitter<void>(); @Output() close = new EventEmitter<void>();
private readonly challengeService = inject(ChallengeService); private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router); private readonly router = inject(Router);
acceptingChallenge = false; acceptingChallenge = false;
decliningChallenge = false; decliningChallenge = false;
errorMessage = ''; errorMessage = '';
onAccept(): void { onAccept(): void {
if (this.acceptingChallenge || this.decliningChallenge) { if (this.acceptingChallenge || this.decliningChallenge) {
return; 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');
}
});
} }
this.acceptingChallenge = true; onDecline(): void {
this.errorMessage = ''; if (this.acceptingChallenge || this.decliningChallenge) {
return;
}
this.challengeService this.decliningChallenge = true;
.acceptChallenge(this.challenge.id) this.errorMessage = '';
.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');
},
});
}
onDecline(): void { this.challengeService.declineChallenge(this.challenge.id, { reason: 'generic' })
if (this.acceptingChallenge || this.decliningChallenge) { .pipe(finalize(() => (this.decliningChallenge = false)))
return; .subscribe({
next: () => {
this.decline.emit(this.challenge);
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to decline challenge');
}
});
} }
this.decliningChallenge = true; onClose(): void {
this.errorMessage = ''; this.close.emit();
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';
} }
const minutes = Math.floor(diffMs / 60000); getTimeControlDisplay(): string {
if (minutes > 60) { const { limit, increment } = this.challenge.timeControl;
const hours = Math.floor(minutes / 60); if (!limit || !increment) {
return `Expires in ${hours}h`; return 'Unlimited';
}
const minutes = Math.floor(limit / 60);
return `${minutes}+${increment}`;
} }
return `Expires in ${minutes}m`; 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`;
}
} }
@@ -1,9 +1,5 @@
<div class="board-shell"> <div class="board-shell">
<div <div class="board-grid" [class.board-grid--classic]="boardTheme === 'classic'" [class.board-grid--arabian]="boardTheme === 'arabian'">
class="board-grid"
[class.board-grid--classic]="boardTheme === 'classic'"
[class.board-grid--arabian]="boardTheme === 'arabian'"
>
@for (square of squares; track trackByCoordinate($index, square)) { @for (square of squares; track trackByCoordinate($index, square)) {
<button <button
type="button" type="button"
@@ -31,10 +27,6 @@
</div> </div>
@if (boardTheme === 'arabian') { @if (boardTheme === 'arabian') {
<img <img class="board-bottom" src="/assets/arabian-chess/sprites/board/board_bottom.png" alt="Board frame" />
class="board-bottom"
src="/assets/arabian-chess/sprites/board/board_bottom.png"
alt="Board frame"
/>
} }
</div> </div>
@@ -14,7 +14,7 @@ type BoardTheme = 'arabian' | 'classic';
standalone: true, standalone: true,
imports: [ChessPieceComponent], imports: [ChessPieceComponent],
templateUrl: './chess-board.component.html', templateUrl: './chess-board.component.html',
styleUrl: './chess-board.component.css', styleUrl: './chess-board.component.css'
}) })
export class ChessBoardComponent implements OnChanges { export class ChessBoardComponent implements OnChanges {
@Input({ required: true }) fen = ''; @Input({ required: true }) fen = '';
@@ -150,7 +150,7 @@ export class ChessBoardComponent implements OnChanges {
return { return {
coordinate: `${file}${rank}`, coordinate: `${file}${rank}`,
isLight: (rowIndex + column) % 2 === 0, isLight: (rowIndex + column) % 2 === 0,
pieceCode, pieceCode
}; };
} }
@@ -6,7 +6,7 @@ type BoardTheme = 'arabian' | 'classic';
selector: 'app-chess-piece', selector: 'app-chess-piece',
standalone: true, standalone: true,
templateUrl: './chess-piece.component.html', templateUrl: './chess-piece.component.html',
styleUrl: './chess-piece.component.css', styleUrl: './chess-piece.component.css'
}) })
export class ChessPieceComponent { export class ChessPieceComponent {
@Input({ required: true }) pieceCode: string | null = null; @Input({ required: true }) pieceCode: string | null = null;
@@ -1,53 +0,0 @@
: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;
}
@@ -1,53 +0,0 @@
@if (points.length === 0) {
<div class="empty">No evaluation data yet.</div>
} @else {
<div class="timeline-wrap" role="img" aria-label="Evaluation timeline">
<svg
class="timeline-svg"
[attr.viewBox]="'0 0 ' + svgWidth + ' ' + svgHeight"
preserveAspectRatio="none"
>
<!-- Midline (balanced) -->
<line
x1="0"
[attr.y1]="svgHeight / 2"
[attr.x2]="svgWidth"
[attr.y2]="svgHeight / 2"
class="midline"
/>
<!-- White-advantage fill (clip above midline) -->
<defs>
<clipPath id="clip-white">
<rect x="0" y="0" [attr.width]="svgWidth" [attr.height]="svgHeight / 2" />
</clipPath>
<clipPath id="clip-black">
<rect
x="0"
[attr.y]="svgHeight / 2"
[attr.width]="svgWidth"
[attr.height]="svgHeight / 2"
/>
</clipPath>
</defs>
<polygon [attr.points]="polylineWhite" class="area-white" clip-path="url(#clip-white)" />
<polygon [attr.points]="polylineBlack" class="area-black" clip-path="url(#clip-black)" />
<!-- Eval line -->
<polyline [attr.points]="evalPolyline" class="eval-line" />
<!-- Active ply marker -->
@if (activeX() !== null) {
<line
[attr.x1]="activeX()"
y1="0"
[attr.x2]="activeX()"
[attr.y2]="svgHeight"
class="active-marker"
/>
}
</svg>
</div>
}
@@ -1,73 +0,0 @@
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;
}
}
@@ -66,9 +66,7 @@
font-family: var(--nc-sans); font-family: var(--nc-sans);
letter-spacing: 0.08em; letter-spacing: 0.08em;
cursor: pointer; cursor: pointer;
transition: transition: background 0.15s, color 0.15s;
background 0.15s,
color 0.15s;
} }
.seg-btn.active { .seg-btn.active {
@@ -117,9 +115,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
transition: transition: background 0.15s, border-color 0.15s;
background 0.15s,
border-color 0.15s;
} }
.btn:hover { .btn:hover {
@@ -2,17 +2,8 @@
<summary> <summary>
<span class="panel-title">Export Position</span> <span class="panel-title">Export Position</span>
<span class="chev" aria-hidden="true"> <span class="chev" aria-hidden="true">
<svg <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
width="12" <polyline points="6 9 12 15 18 9"/>
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg> </svg>
</span> </span>
</summary> </summary>
@@ -24,60 +15,30 @@
[class.active]="exportKind === 'fen'" [class.active]="exportKind === 'fen'"
role="tab" role="tab"
[attr.aria-selected]="exportKind === 'fen'" [attr.aria-selected]="exportKind === 'fen'"
(click)="setKind('fen')" (click)="setKind('fen')">FEN</button>
>
FEN
</button>
<button <button
class="seg-btn" class="seg-btn"
[class.active]="exportKind === 'pgn'" [class.active]="exportKind === 'pgn'"
role="tab" role="tab"
[attr.aria-selected]="exportKind === 'pgn'" [attr.aria-selected]="exportKind === 'pgn'"
(click)="setKind('pgn')" (click)="setKind('pgn')">PGN</button>
>
PGN
</button>
</div> </div>
<textarea <textarea class="export-out" [value]="exportValue" [placeholder]="exportPlaceholder" rows="4" readonly></textarea>
class="export-out"
[value]="exportValue"
[placeholder]="exportPlaceholder"
rows="4"
readonly
></textarea>
<div class="export-row"> <div class="export-row">
<button class="btn" type="button" (click)="copy()"> <button class="btn" type="button" (click)="copy()">
<svg <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
width="12" <rect x="9" y="9" width="13" height="13" rx="2"/>
height="12" <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg> </svg>
Copy Copy
</button> </button>
<button class="btn" type="button" (click)="download()"> <button class="btn" type="button" (click)="download()">
<svg <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
width="12" <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
height="12" <polyline points="7 10 12 15 17 10"/>
viewBox="0 0 24 24" <line x1="12" y1="15" x2="12" y2="3"/>
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg> </svg>
Download Download
</button> </button>
@@ -8,7 +8,7 @@ type ExportKind = 'fen' | 'pgn';
standalone: true, standalone: true,
imports: [FormsModule], imports: [FormsModule],
templateUrl: './export-panel.component.html', templateUrl: './export-panel.component.html',
styleUrl: './export-panel.component.css', styleUrl: './export-panel.component.css'
}) })
export class ExportPanelComponent implements OnChanges { export class ExportPanelComponent implements OnChanges {
@Input() fen = ''; @Input() fen = '';
@@ -29,16 +29,17 @@
.input-card textarea { .input-card textarea {
resize: vertical; resize: vertical;
height: 40px; height: 40px;
background-color: lightcyan; background-color:lightcyan;
border: 3px solid var(--color-border); border: 3px solid var(--color-border);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
} }
.input-card input { .input-card input {
min-width: unset; min-width: unset;
background-color: lightcyan; background-color:lightcyan;
border: 3px solid var(--color-border); border: 3px solid var(--color-border);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
} }
.hint-text { .hint-text {
@@ -2,23 +2,11 @@
<label>{{ label }}</label> <label>{{ label }}</label>
@if (inputType === 'textarea') { @if (inputType === 'textarea') {
<textarea <textarea #textareaInput [placeholder]="placeholder" [value]="value" [rows]="rows"
#textareaInput (input)="onValueChange(textareaInput.value)" class="form-input"></textarea>
[placeholder]="placeholder"
[value]="value"
[rows]="rows"
(input)="onValueChange(textareaInput.value)"
class="form-input"
></textarea>
} @else { } @else {
<input <input #textInput type="text" [placeholder]="placeholder" [value]="value" (input)="onValueChange(textInput.value)"
#textInput class="form-input" />
type="text"
[placeholder]="placeholder"
[value]="value"
(input)="onValueChange(textInput.value)"
class="form-input"
/>
} }
<button type="button" class="app-btn w-100" (click)="onButtonClick()"> <button type="button" class="app-btn w-100" (click)="onButtonClick()">
@@ -26,6 +14,6 @@
</button> </button>
@if (hintText) { @if (hintText) {
<p class="hint-text">{{ hintText }}</p> <p class="hint-text">{{ hintText }}</p>
} }
</section> </section>
@@ -22,7 +22,7 @@
position: relative; position: relative;
width: min(420px, 100%); width: min(420px, 100%);
background: background:
radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.1), transparent 60%), radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.10), transparent 60%),
linear-gradient(180deg, #0a0612 0%, #06060d 100%); linear-gradient(180deg, #0a0612 0%, #06060d 100%);
border: 1px solid var(--auth-neon-soft); border: 1px solid var(--auth-neon-soft);
border-radius: 14px; border-radius: 14px;
@@ -60,13 +60,14 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
background-image: repeating-linear-gradient( background-image:
0deg, repeating-linear-gradient(
rgba(255, 255, 255, 0.012) 0px, 0deg,
rgba(255, 255, 255, 0.012) 1px, rgba(255, 255, 255, 0.012) 0px,
transparent 1px, rgba(255, 255, 255, 0.012) 1px,
transparent 3px transparent 1px,
); transparent 3px
);
border-radius: inherit; border-radius: inherit;
} }
@@ -173,10 +174,7 @@ form {
font-family: var(--auth-mono); font-family: var(--auth-mono);
font-size: 13px; font-size: 13px;
letter-spacing: 0.3px; letter-spacing: 0.3px;
transition: transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
border-color 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease;
} }
.dialog-input::placeholder { .dialog-input::placeholder {
@@ -187,9 +185,7 @@ form {
outline: none; outline: none;
border-color: var(--auth-neon); border-color: var(--auth-neon);
background: rgba(20, 6, 26, 0.7); background: rgba(20, 6, 26, 0.7);
box-shadow: box-shadow: 0 0 0 3px rgba(255, 69, 200, 0.15), 0 0 18px rgba(255, 69, 200, 0.18);
0 0 0 3px rgba(255, 69, 200, 0.15),
0 0 18px rgba(255, 69, 200, 0.18);
} }
.text-danger { .text-danger {
@@ -307,56 +303,26 @@ form {
} }
@keyframes scanline { @keyframes scanline {
0% { 0% { transform: translateY(-100%); }
transform: translateY(-100%); 100% { transform: translateY(300%); }
}
100% {
transform: translateY(300%);
}
} }
@keyframes pulse-glow { @keyframes pulse-glow {
0%, 0%, 100% { opacity: 0.85; }
100% { 50% { opacity: 1; }
opacity: 0.85;
}
50% {
opacity: 1;
}
} }
@keyframes dialog-in { @keyframes dialog-in {
from { from { opacity: 0; transform: translateY(8px) scale(0.96); }
opacity: 0; to { opacity: 1; transform: translateY(0) scale(1); }
transform: translateY(8px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
} }
@keyframes backdrop-in { @keyframes backdrop-in {
from { from { opacity: 0; }
opacity: 0; to { opacity: 1; }
}
to {
opacity: 1;
}
} }
@keyframes shake { @keyframes shake {
0%, 0%, 100% { transform: translateX(0); }
100% { 20%, 60% { transform: translateX(-4px); }
transform: translateX(0); 40%, 80% { transform: translateX(4px); }
}
20%,
60% {
transform: translateX(-4px);
}
40%,
80% {
transform: translateX(4px);
}
} }
@keyframes spin { @keyframes spin {
to { to { transform: rotate(360deg); }
transform: rotate(360deg);
}
} }
@@ -11,14 +11,8 @@
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()"> <form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<div class="field"> <div class="field">
<label for="username" class="field-label">Username</label> <label for="username" class="field-label">Username</label>
<input <input id="username" type="text" class="dialog-input" formControlName="username"
id="username" placeholder="your_handle" autocomplete="username" />
type="text"
class="dialog-input"
formControlName="username"
placeholder="your_handle"
autocomplete="username"
/>
@if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) { @if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) {
<small class="text-danger">Username must be at least 3 characters</small> <small class="text-danger">Username must be at least 3 characters</small>
} }
@@ -26,14 +20,8 @@
<div class="field"> <div class="field">
<label for="password" class="field-label">Password</label> <label for="password" class="field-label">Password</label>
<input <input id="password" type="password" class="dialog-input" formControlName="password"
id="password" placeholder="••••••••" autocomplete="current-password" />
type="password"
class="dialog-input"
formControlName="password"
placeholder="••••••••"
autocomplete="current-password"
/>
@if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) { @if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) {
<small class="text-danger">Password must be at least 6 characters</small> <small class="text-danger">Password must be at least 6 characters</small>
} }
@@ -53,7 +41,9 @@
</button> </button>
</div> </div>
<div class="alt-line">New here?<a (click)="openRegister()">Create an account</a></div> <div class="alt-line">
New here?<a (click)="openRegister()">Create an account</a>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -9,7 +9,7 @@ import { AuthDialogService } from '../../services/auth-dialog.service';
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule], imports: [CommonModule, ReactiveFormsModule],
templateUrl: './login-dialog.component.html', templateUrl: './login-dialog.component.html',
styleUrl: './login-dialog.component.css', styleUrl: './login-dialog.component.css'
}) })
export class LoginDialogComponent { export class LoginDialogComponent {
@Output() onClose = new EventEmitter<void>(); @Output() onClose = new EventEmitter<void>();
@@ -26,7 +26,7 @@ export class LoginDialogComponent {
constructor() { constructor() {
this.loginForm = this.formBuilder.group({ this.loginForm = this.formBuilder.group({
username: ['', [Validators.required, Validators.minLength(3)]], 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.isLoading = false;
this.loginForm.enable(); this.loginForm.enable();
this.errorMessage = err.error?.message || 'Login failed. Please try again.'; this.errorMessage = err.error?.message || 'Login failed. Please try again.';
}, }
}); });
} }
@@ -26,9 +26,7 @@
padding: 6px 10px; padding: 6px 10px;
color: var(--nc-text); color: var(--nc-text);
cursor: pointer; cursor: pointer;
transition: transition: background 0.1s, color 0.1s;
background 0.1s,
color 0.1s;
} }
.mv:hover { .mv:hover {
@@ -37,7 +35,7 @@
} }
.mv.current { .mv.current {
background: rgba(255, 69, 200, 0.1); background: rgba(255, 69, 200, 0.10);
color: var(--nc-neon); color: var(--nc-neon);
} }
@@ -74,9 +72,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: transition: color 0.15s, border-color 0.15s;
color 0.15s,
border-color 0.15s;
} }
.icon-btn:hover { .icon-btn:hover {
@@ -97,16 +93,7 @@
opacity: 1; opacity: 1;
} }
.moves::-webkit-scrollbar { .moves::-webkit-scrollbar { width: 6px; }
width: 6px; .moves::-webkit-scrollbar-track { background: transparent; }
} .moves::-webkit-scrollbar-thumb { background: var(--nc-border-strong); border-radius: 3px; }
.moves::-webkit-scrollbar-track { .moves::-webkit-scrollbar-thumb:hover { background: var(--nc-neon-soft); }
background: transparent;
}
.moves::-webkit-scrollbar-thumb {
background: var(--nc-border-strong);
border-radius: 3px;
}
.moves::-webkit-scrollbar-thumb:hover {
background: var(--nc-neon-soft);
}
@@ -4,25 +4,13 @@
} @else { } @else {
@for (pair of movePairs; track $index) { @for (pair of movePairs; track $index) {
<div class="mv-num" role="presentation">{{ $index + 1 }}</div> <div class="mv-num" role="presentation">{{ $index + 1 }}</div>
<div <div class="mv" [class.current]="isWhiteViewing($index)" role="listitem"
class="mv" tabindex="0" (click)="clickWhite($index)" (keydown.enter)="clickWhite($index)">
[class.current]="isWhiteViewing($index)"
role="listitem"
tabindex="0"
(click)="clickWhite($index)"
(keydown.enter)="clickWhite($index)"
>
{{ pair.white }} {{ pair.white }}
</div> </div>
<div <div class="mv" [class.current]="isBlackViewing($index)" [class.mv-empty]="!pair.black" role="listitem"
class="mv" [attr.tabindex]="pair.black ? 0 : null"
[class.current]="isBlackViewing($index)" (click)="clickBlack($index, pair.black)" (keydown.enter)="clickBlack($index, pair.black)">
[class.mv-empty]="!pair.black"
role="listitem"
[attr.tabindex]="pair.black ? 0 : null"
(click)="clickBlack($index, pair.black)"
(keydown.enter)="clickBlack($index, pair.black)"
>
{{ pair.black ?? '…' }} {{ pair.black ?? '…' }}
</div> </div>
} }
@@ -32,61 +20,23 @@
<div class="moves-foot"> <div class="moves-foot">
<div class="moves-nav" role="group" aria-label="Move navigation"> <div class="moves-nav" role="group" aria-label="Move navigation">
<button class="icon-btn" title="First move" (click)="navigate.emit('first')"> <button class="icon-btn" title="First move" (click)="navigate.emit('first')">
<svg <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
width="12" <polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/>
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="11 17 6 12 11 7" />
<polyline points="18 17 13 12 18 7" />
</svg> </svg>
</button> </button>
<button class="icon-btn" title="Previous move" (click)="navigate.emit('prev')"> <button class="icon-btn" title="Previous move" (click)="navigate.emit('prev')">
<svg <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
width="12" <polyline points="15 18 9 12 15 6"/>
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg> </svg>
</button> </button>
<button class="icon-btn" title="Next move" (click)="navigate.emit('next')"> <button class="icon-btn" title="Next move" (click)="navigate.emit('next')">
<svg <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
width="12" <polyline points="9 18 15 12 9 6"/>
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg> </svg>
</button> </button>
<button class="icon-btn" title="Last move / return to live" (click)="navigate.emit('last')"> <button class="icon-btn" title="Last move / return to live" (click)="navigate.emit('last')">
<svg <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
width="12" <polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/>
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="13 17 18 12 13 7" />
<polyline points="6 17 11 12 6 7" />
</svg> </svg>
</button> </button>
</div> </div>
@@ -1,12 +1,4 @@
import { import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core';
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
Output,
ViewChild,
} from '@angular/core';
export type MoveNavDirection = 'first' | 'prev' | 'next' | 'last'; export type MoveNavDirection = 'first' | 'prev' | 'next' | 'last';
@@ -20,7 +12,7 @@ interface MovePair {
standalone: true, standalone: true,
imports: [], imports: [],
templateUrl: './move-history.component.html', templateUrl: './move-history.component.html',
styleUrl: './move-history.component.css', styleUrl: './move-history.component.css'
}) })
export class MoveHistoryComponent implements OnChanges { export class MoveHistoryComponent implements OnChanges {
@Input({ required: true }) moves: string[] = []; @Input({ required: true }) moves: string[] = [];
@@ -7,16 +7,12 @@
border: 1px solid var(--nc-border); border: 1px solid var(--nc-border);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
transition: transition: border-color 0.2s, box-shadow 0.2s;
border-color 0.2s,
box-shadow 0.2s;
} }
.player.is-turn { .player.is-turn {
border-color: var(--nc-neon-soft); border-color: var(--nc-neon-soft);
box-shadow: box-shadow: 0 0 0 1px rgba(255, 69, 200, 0.2), 0 0 20px rgba(255, 69, 200, 0.1);
0 0 0 1px rgba(255, 69, 200, 0.2),
0 0 20px rgba(255, 69, 200, 0.1);
} }
.player-avatar { .player-avatar {
@@ -78,11 +74,7 @@
border: 1px solid var(--nc-border); border: 1px solid var(--nc-border);
color: var(--nc-text); color: var(--nc-text);
letter-spacing: 0.02em; letter-spacing: 0.02em;
transition: transition: color 0.2s, border-color 0.2s, background 0.2s, text-shadow 0.2s;
color 0.2s,
border-color 0.2s,
background 0.2s,
text-shadow 0.2s;
} }
.clock.clock-active { .clock.clock-active {
@@ -1,9 +1,5 @@
<div class="player" [class.is-turn]="isActive"> <div class="player" [class.is-turn]="isActive">
<div <div class="player-avatar" [class.avatar-black]="color === 'black'" [class.avatar-white]="color === 'white'">
class="player-avatar"
[class.avatar-black]="color === 'black'"
[class.avatar-white]="color === 'white'"
>
{{ initial }} {{ initial }}
</div> </div>
@@ -5,7 +5,7 @@ import { Component, Input } from '@angular/core';
standalone: true, standalone: true,
imports: [], imports: [],
templateUrl: './player-card.component.html', templateUrl: './player-card.component.html',
styleUrl: './player-card.component.css', styleUrl: './player-card.component.css'
}) })
export class PlayerCardComponent { export class PlayerCardComponent {
@Input({ required: true }) name = ''; @Input({ required: true }) name = '';
@@ -12,9 +12,7 @@
justify-content: center; justify-content: center;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: transition: opacity 0.2s, visibility 0.2s;
opacity 0.2s,
visibility 0.2s;
z-index: 1000; z-index: 1000;
&.open { &.open {
@@ -2,14 +2,8 @@
<div class="promotion-dialog"> <div class="promotion-dialog">
<div class="promotion-header"> <div class="promotion-header">
<h3>Pawn Promotion</h3> <h3>Pawn Promotion</h3>
<button <button class="app-btn" (click)="close()" aria-label="Close"
class="app-btn" style="padding:0.2rem 0.5rem;min-width:unset;">&times;</button>
(click)="close()"
aria-label="Close"
style="padding: 0.2rem 0.5rem; min-width: unset"
>
&times;
</button>
</div> </div>
<div class="promotion-body"> <div class="promotion-body">
@@ -17,18 +11,13 @@
<div class="promotion-options"> <div class="promotion-options">
@for (piece of promotionPieces; track piece.type) { @for (piece of promotionPieces; track piece.type) {
<button <button type="button" class="app-btn promotion-choice" [attr.data-piece]="piece.type"
type="button" (click)="selectPromotion(piece.type)" [title]="piece.label">
class="app-btn promotion-choice" <span class="piece-symbol">{{ piece.symbol }}</span>
[attr.data-piece]="piece.type" <span class="piece-label">{{ piece.label }}</span>
(click)="selectPromotion(piece.type)" </button>
[title]="piece.label"
>
<span class="piece-symbol">{{ piece.symbol }}</span>
<span class="piece-label">{{ piece.label }}</span>
</button>
} }
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -13,7 +13,7 @@ interface PromotionPieceOption {
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
templateUrl: './promotion-dialog.component.html', templateUrl: './promotion-dialog.component.html',
styleUrl: './promotion-dialog.component.css', styleUrl: './promotion-dialog.component.css'
}) })
export class PromotionDialogComponent { export class PromotionDialogComponent {
@Input() isOpen = false; @Input() isOpen = false;
@@ -24,7 +24,7 @@ export class PromotionDialogComponent {
{ type: 'queen', label: 'Queen', symbol: '♕' }, { type: 'queen', label: 'Queen', symbol: '♕' },
{ type: 'rook', label: 'Rook', symbol: '♖' }, { type: 'rook', label: 'Rook', symbol: '♖' },
{ type: 'bishop', label: 'Bishop', symbol: '♗' }, { type: 'bishop', label: 'Bishop', symbol: '♗' },
{ type: 'knight', label: 'Knight', symbol: '♘' }, { type: 'knight', label: 'Knight', symbol: '♘' }
]; ];
selectPromotion(type: PromotionPieceType): void { selectPromotion(type: PromotionPieceType): void {
@@ -22,7 +22,7 @@
position: relative; position: relative;
width: min(440px, 100%); width: min(440px, 100%);
background: background:
radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.1), transparent 60%), radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.10), transparent 60%),
linear-gradient(180deg, #0a0612 0%, #06060d 100%); linear-gradient(180deg, #0a0612 0%, #06060d 100%);
border: 1px solid var(--auth-neon-soft); border: 1px solid var(--auth-neon-soft);
border-radius: 14px; border-radius: 14px;
@@ -179,10 +179,7 @@ form {
font-family: var(--auth-mono); font-family: var(--auth-mono);
font-size: 13px; font-size: 13px;
letter-spacing: 0.3px; letter-spacing: 0.3px;
transition: transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
border-color 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease;
} }
.dialog-input::placeholder { .dialog-input::placeholder {
@@ -193,9 +190,7 @@ form {
outline: none; outline: none;
border-color: var(--auth-neon); border-color: var(--auth-neon);
background: rgba(20, 6, 26, 0.7); background: rgba(20, 6, 26, 0.7);
box-shadow: box-shadow: 0 0 0 3px rgba(255, 69, 200, 0.15), 0 0 18px rgba(255, 69, 200, 0.18);
0 0 0 3px rgba(255, 69, 200, 0.15),
0 0 18px rgba(255, 69, 200, 0.18);
} }
.text-danger { .text-danger {
@@ -313,56 +308,26 @@ form {
} }
@keyframes scanline { @keyframes scanline {
0% { 0% { transform: translateY(-100%); }
transform: translateY(-100%); 100% { transform: translateY(300%); }
}
100% {
transform: translateY(300%);
}
} }
@keyframes pulse-glow { @keyframes pulse-glow {
0%, 0%, 100% { opacity: 0.85; }
100% { 50% { opacity: 1; }
opacity: 0.85;
}
50% {
opacity: 1;
}
} }
@keyframes dialog-in { @keyframes dialog-in {
from { from { opacity: 0; transform: translateY(8px) scale(0.96); }
opacity: 0; to { opacity: 1; transform: translateY(0) scale(1); }
transform: translateY(8px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
} }
@keyframes backdrop-in { @keyframes backdrop-in {
from { from { opacity: 0; }
opacity: 0; to { opacity: 1; }
}
to {
opacity: 1;
}
} }
@keyframes shake { @keyframes shake {
0%, 0%, 100% { transform: translateX(0); }
100% { 20%, 60% { transform: translateX(-4px); }
transform: translateX(0); 40%, 80% { transform: translateX(4px); }
}
20%,
60% {
transform: translateX(-4px);
}
40%,
80% {
transform: translateX(4px);
}
} }
@keyframes spin { @keyframes spin {
to { to { transform: rotate(360deg); }
transform: rotate(360deg);
}
} }
@@ -11,14 +11,8 @@
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()"> <form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
<div class="field"> <div class="field">
<label for="username" class="field-label">Username</label> <label for="username" class="field-label">Username</label>
<input <input id="username" type="text" class="dialog-input" formControlName="username"
id="username" placeholder="your_handle" autocomplete="username" />
type="text"
class="dialog-input"
formControlName="username"
placeholder="your_handle"
autocomplete="username"
/>
@if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) { @if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) {
<small class="text-danger">Username must be at least 3 characters</small> <small class="text-danger">Username must be at least 3 characters</small>
} }
@@ -26,14 +20,8 @@
<div class="field"> <div class="field">
<label for="email" class="field-label">Email</label> <label for="email" class="field-label">Email</label>
<input <input id="email" type="email" class="dialog-input" formControlName="email"
id="email" placeholder="you@domain.com" autocomplete="email" />
type="email"
class="dialog-input"
formControlName="email"
placeholder="you@domain.com"
autocomplete="email"
/>
@if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) { @if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) {
<small class="text-danger">Please enter a valid email</small> <small class="text-danger">Please enter a valid email</small>
} }
@@ -42,28 +30,16 @@
<div class="field-row"> <div class="field-row">
<div class="field"> <div class="field">
<label for="password" class="field-label">Password</label> <label for="password" class="field-label">Password</label>
<input <input id="password" type="password" class="dialog-input" formControlName="password"
id="password" placeholder="••••••••" autocomplete="new-password" />
type="password"
class="dialog-input"
formControlName="password"
placeholder="••••••••"
autocomplete="new-password"
/>
@if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) { @if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) {
<small class="text-danger">Min 6 characters</small> <small class="text-danger">Min 6 characters</small>
} }
</div> </div>
<div class="field"> <div class="field">
<label for="confirmPassword" class="field-label">Confirm</label> <label for="confirmPassword" class="field-label">Confirm</label>
<input <input id="confirmPassword" type="password" class="dialog-input" formControlName="confirmPassword"
id="confirmPassword" placeholder="••••••••" autocomplete="new-password" />
type="password"
class="dialog-input"
formControlName="confirmPassword"
placeholder="••••••••"
autocomplete="new-password"
/>
</div> </div>
</div> </div>
@@ -73,11 +49,7 @@
<div class="dialog-actions"> <div class="dialog-actions">
<button type="button" class="btn btn-ghost" (click)="closeDialog()">Cancel</button> <button type="button" class="btn btn-ghost" (click)="closeDialog()">Cancel</button>
<button <button type="submit" class="btn btn-primary" [disabled]="isLoading || registerForm.invalid">
type="submit"
class="btn btn-primary"
[disabled]="isLoading || registerForm.invalid"
>
@if (isLoading) { @if (isLoading) {
<span class="spinner" aria-hidden="true"></span> <span class="spinner" aria-hidden="true"></span>
} }
@@ -85,7 +57,9 @@
</button> </button>
</div> </div>
<div class="alt-line">Already have an account?<a (click)="openLogin()">Sign in</a></div> <div class="alt-line">
Already have an account?<a (click)="openLogin()">Sign in</a>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -9,7 +9,7 @@ import { AuthDialogService } from '../../services/auth-dialog.service';
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule], imports: [CommonModule, ReactiveFormsModule],
templateUrl: './register-dialog.component.html', templateUrl: './register-dialog.component.html',
styleUrl: './register-dialog.component.css', styleUrl: './register-dialog.component.css'
}) })
export class RegisterDialogComponent { export class RegisterDialogComponent {
@Output() onClose = new EventEmitter<void>(); @Output() onClose = new EventEmitter<void>();
@@ -28,7 +28,7 @@ export class RegisterDialogComponent {
username: ['', [Validators.required, Validators.minLength(3)]], username: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]], email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]], password: ['', [Validators.required, Validators.minLength(6)]],
confirmPassword: ['', [Validators.required]], confirmPassword: ['', [Validators.required]]
}); });
} }
@@ -58,8 +58,9 @@ export class RegisterDialogComponent {
error: (err) => { error: (err) => {
this.isLoading = false; this.isLoading = false;
this.registerForm.enable(); this.registerForm.enable();
this.errorMessage = err.error?.message || 'Registration failed. Please try again.'; this.errorMessage =
}, err.error?.message || 'Registration failed. Please try again.';
}
}); });
} }
+84 -179
View File
@@ -1,58 +1,46 @@
/* ============ THEME TOKENS ============ */ /* ============ THEME TOKENS ============ */
:host { :host {
/* Light mode: warm sunset palette from background gradient */ /* Light mode: warm sunset palette from background gradient */
--nc-accent: #ff3dbb; --nc-accent: #ff3dbb;
--nc-accent-hover: rgba(255, 107, 61, 0.15); --nc-accent-hover: rgba(255, 107, 61, 0.15);
--nc-accent-badge: rgba(223, 61, 255, 0.9); --nc-accent-badge: rgba(223, 61, 255, 0.9);
--nc-badge-text: #1a0800; --nc-badge-text: #1a0800;
--nc-surface: rgba(26, 24, 56, 0.97); --nc-surface: rgba(26, 24, 56, 0.97);
--nc-nav-bg: linear-gradient( --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%);
180deg, --nc-text: #fff;
rgba(26, 24, 56, 0.88) 0%, --nc-text-muted: rgba(255,255,255,0.7);
rgba(46, 32, 80, 0.6) 70%, --nc-text-dim: rgba(255,255,255,0.45);
rgba(74, 41, 98, 0) 100% --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-text: #fff; --nc-unread-dot: #ff6b3d;
--nc-text-muted: rgba(255, 255, 255, 0.7); --nc-avatar-a: #d44d4a;
--nc-text-dim: rgba(255, 255, 255, 0.45); --nc-avatar-b: #8b3a6b;
--nc-border: rgba(255, 255, 255, 0.1); --nc-danger: #ff7a7a;
--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']) { :host-context(html[data-theme='dark']) {
/* Dark mode: blue neon palette */ /* Dark mode: blue neon palette */
--nc-accent: #00d5ff; --nc-accent: #00d5ff;
--nc-accent-hover: rgba(0, 213, 255, 0.12); --nc-accent-hover: rgba(0, 213, 255, 0.12);
--nc-accent-badge: #00d5ff; --nc-accent-badge: #00d5ff;
--nc-badge-text: #04000f; --nc-badge-text: #04000f;
--nc-surface: rgba(8, 6, 28, 0.97); --nc-surface: rgba(8, 6, 28, 0.97);
--nc-nav-bg: linear-gradient( --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%);
180deg, --nc-text: #fff;
rgba(8, 5, 20, 0.88) 0%, --nc-text-muted: rgba(255,255,255,0.65);
rgba(8, 5, 20, 0.58) 70%, --nc-text-dim: rgba(255,255,255,0.4);
rgba(8, 5, 20, 0) 100% --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-text: #fff; --nc-unread-dot: #00d5ff;
--nc-text-muted: rgba(255, 255, 255, 0.65); --nc-avatar-a: #00d5ff;
--nc-text-dim: rgba(255, 255, 255, 0.4); --nc-avatar-b: #1a5fa8;
--nc-border: rgba(255, 255, 255, 0.08); --nc-danger: #ff7a7a;
--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 ============ */ /* ============ NAV CONTAINER ============ */
.nc-nav { .nc-nav {
position: fixed; position: fixed;
top: 0; top: 0; left: 0; right: 0;
left: 0;
right: 0;
height: 56px; height: 56px;
z-index: 100; z-index: 100;
display: flex; display: flex;
@@ -61,12 +49,7 @@
background: var(--nc-nav-bg); background: var(--nc-nav-bg);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
font-family: font-family: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
'Space Grotesk',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
} }
/* ============ LOGO ============ */ /* ============ LOGO ============ */
@@ -80,8 +63,7 @@
} }
.nc-logo-mark { .nc-logo-mark {
width: 24px; width: 24px; height: 24px;
height: 24px;
background: var(--nc-accent); background: var(--nc-accent);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -123,25 +105,19 @@
transition: color 0.15s; transition: color 0.15s;
} }
.nc-link:hover { .nc-link:hover { color: var(--nc-text); }
color: var(--nc-text);
}
.nc-link::after { .nc-link::after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 2px; bottom: 2px; left: 14px; right: 14px;
left: 14px;
right: 14px;
height: 1px; height: 1px;
background: var(--nc-accent); background: var(--nc-accent);
opacity: 0; opacity: 0;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.nc-link:hover::after { .nc-link:hover::after { opacity: 1; }
opacity: 1;
}
/* ============ RIGHT CLUSTER ============ */ /* ============ RIGHT CLUSTER ============ */
.nc-right { .nc-right {
@@ -154,8 +130,7 @@
/* ============ BELL ============ */ /* ============ BELL ============ */
.nc-bell { .nc-bell {
width: 36px; width: 36px; height: 36px;
height: 36px;
border: 1px solid var(--nc-border); border: 1px solid var(--nc-border);
background: transparent; background: transparent;
color: var(--nc-text-muted); color: var(--nc-text-muted);
@@ -164,9 +139,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; position: relative;
transition: transition: background 0.15s, color 0.15s;
background 0.15s,
color 0.15s;
font-family: inherit; font-family: inherit;
} }
@@ -179,10 +152,8 @@
/* ============ BADGE ============ */ /* ============ BADGE ============ */
.nc-badge { .nc-badge {
position: absolute; position: absolute;
top: 5px; top: 5px; right: 5px;
right: 5px; min-width: 14px; height: 14px;
min-width: 14px;
height: 14px;
border-radius: 7px; border-radius: 7px;
background: var(--nc-accent-badge); background: var(--nc-accent-badge);
color: var(--nc-badge-text); color: var(--nc-badge-text);
@@ -210,9 +181,7 @@
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
cursor: pointer; cursor: pointer;
transition: transition: background 0.15s, color 0.15s;
background 0.15s,
color 0.15s;
} }
.nc-games-btn:hover { .nc-games-btn:hover {
@@ -232,9 +201,7 @@
cursor: pointer; cursor: pointer;
color: var(--nc-text-muted); color: var(--nc-text-muted);
font-family: inherit; font-family: inherit;
transition: transition: background 0.15s, color 0.15s;
background 0.15s,
color 0.15s;
} }
.nc-profile:hover, .nc-profile:hover,
@@ -249,9 +216,7 @@
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.nc-chevron { .nc-chevron { opacity: 0.5; }
opacity: 0.5;
}
/* ============ AVATAR ============ */ /* ============ AVATAR ============ */
.nc-avatar { .nc-avatar {
@@ -266,21 +231,11 @@
flex-shrink: 0; flex-shrink: 0;
} }
.nc-avatar-sm { .nc-avatar-sm { width: 26px; height: 26px; font-size: 11px; }
width: 26px; .nc-avatar-md { width: 40px; height: 40px; font-size: 17px; }
height: 26px;
font-size: 11px;
}
.nc-avatar-md {
width: 40px;
height: 40px;
font-size: 17px;
}
/* ============ DROPDOWN WRAPPER ============ */ /* ============ DROPDOWN WRAPPER ============ */
.nc-dropdown-wrap { .nc-dropdown-wrap { position: relative; }
position: relative;
}
/* ============ POPOVERS ============ */ /* ============ POPOVERS ============ */
.nc-popover { .nc-popover {
@@ -297,13 +252,11 @@
} }
/* ============ NOTIFICATIONS PANEL ============ */ /* ============ NOTIFICATIONS PANEL ============ */
.nc-notif { .nc-notif { width: 360px; }
width: 360px;
}
.nc-notif-header { .nc-notif-header {
padding: 14px 18px; 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; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -317,10 +270,7 @@
font-weight: 600; font-weight: 600;
} }
.nc-notif-list { .nc-notif-list { max-height: 420px; overflow-y: auto; }
max-height: 420px;
overflow-y: auto;
}
.nc-notif-empty { .nc-notif-empty {
padding: 24px 18px; padding: 24px 18px;
@@ -332,44 +282,36 @@
.nc-notif-row { .nc-notif-row {
padding: 14px 18px; 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; position: relative;
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: flex-start; align-items: flex-start;
} }
.nc-notif-row.is-unread { .nc-notif-row.is-unread { background: rgba(255,255,255,0.03); }
background: rgba(255, 255, 255, 0.03);
}
.nc-notif-row.is-unread::before { .nc-notif-row.is-unread::before {
content: ''; content: "";
position: absolute; position: absolute;
left: 6px; left: 6px; top: 22px;
top: 22px; width: 4px; height: 4px;
width: 4px;
height: 4px;
border-radius: 50%; border-radius: 50%;
background: var(--nc-unread-dot); background: var(--nc-unread-dot);
} }
.nc-notif-icon { .nc-notif-icon {
width: 32px; width: 32px; height: 32px;
height: 32px;
flex-shrink: 0; flex-shrink: 0;
background: rgba(255, 255, 255, 0.04); background: rgba(255,255,255,0.04);
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255,255,255,0.12);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--nc-accent); color: var(--nc-accent);
} }
.nc-notif-body { .nc-notif-body { flex: 1; min-width: 0; }
flex: 1;
min-width: 0;
}
.nc-notif-text { .nc-notif-text {
font-size: 13px; font-size: 13px;
@@ -377,9 +319,7 @@
line-height: 1.35; line-height: 1.35;
} }
.nc-notif-text b { .nc-notif-text b { font-weight: 600; }
font-weight: 600;
}
.nc-notif-meta { .nc-notif-meta {
font-size: 10px; font-size: 10px;
@@ -420,7 +360,7 @@
.nc-btn-decline { .nc-btn-decline {
background: transparent; background: transparent;
color: var(--nc-text-muted); 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, .nc-btn-accept:disabled,
@@ -431,7 +371,7 @@
.nc-notif-footer { .nc-notif-footer {
padding: 10px 18px; 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 { .nc-view-all {
@@ -448,18 +388,14 @@
transition: color 0.15s; transition: color 0.15s;
} }
.nc-view-all:hover { .nc-view-all:hover { color: var(--nc-text-muted); }
color: var(--nc-text-muted);
}
/* ============ PROFILE MENU ============ */ /* ============ PROFILE MENU ============ */
.nc-menu { .nc-menu { width: 250px; }
width: 250px;
}
.nc-menu-header { .nc-menu-header {
padding: 16px 16px 14px; 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; display: flex;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
@@ -481,12 +417,10 @@
letter-spacing: 0.06em; letter-spacing: 0.06em;
} }
.nc-menu-group { .nc-menu-group { padding: 6px 0; }
padding: 6px 0;
}
.nc-menu-group + .nc-menu-group { .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 { .nc-menu-item {
@@ -502,9 +436,7 @@
border: none; border: none;
width: 100%; width: 100%;
text-align: left; text-align: left;
transition: transition: background 0.12s, color 0.12s;
background 0.12s,
color 0.12s;
} }
.nc-menu-item:hover { .nc-menu-item:hover {
@@ -512,52 +444,35 @@
color: var(--nc-accent); color: var(--nc-accent);
} }
.nc-menu-item.danger { .nc-menu-item.danger { color: var(--nc-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:hover {
background: rgba(255, 122, 122, 0.08);
color: var(--nc-danger);
}
.nc-menu-icon { .nc-menu-icon { opacity: 0.85; display: inline-flex; }
opacity: 0.85; .nc-menu-label { flex: 1; }
display: inline-flex;
}
.nc-menu-label {
flex: 1;
}
/* ============ DARK MODE TOGGLE PILL ============ */ /* ============ DARK MODE TOGGLE PILL ============ */
.nc-toggle { .nc-toggle {
width: 28px; width: 28px; height: 16px;
height: 16px;
border-radius: 8px; border-radius: 8px;
background: rgba(255, 255, 255, 0.15); background: rgba(255,255,255,0.15);
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
transition: background 0.2s; transition: background 0.2s;
} }
.nc-toggle.is-on { .nc-toggle.is-on { background: var(--nc-accent); }
background: var(--nc-accent);
}
.nc-toggle::after { .nc-toggle::after {
content: ''; content: "";
position: absolute; position: absolute;
top: 2px; top: 2px; left: 2px;
left: 2px; width: 12px; height: 12px;
width: 12px;
height: 12px;
border-radius: 50%; border-radius: 50%;
background: #fff; background: #fff;
transition: left 0.2s; transition: left 0.2s;
} }
.nc-toggle.is-on::after { .nc-toggle.is-on::after { left: 14px; }
left: 14px;
}
/* ============ AUTH BUTTONS (logged out) ============ */ /* ============ AUTH BUTTONS (logged out) ============ */
.nc-auth-btn { .nc-auth-btn {
@@ -571,14 +486,11 @@
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
cursor: pointer; cursor: pointer;
transition: transition: background 0.15s, color 0.15s, border-color 0.15s;
background 0.15s,
color 0.15s,
border-color 0.15s;
} }
.nc-auth-btn:hover { .nc-auth-btn:hover {
background: rgba(255, 255, 255, 0.06); background: rgba(255,255,255,0.06);
color: var(--nc-text); color: var(--nc-text);
} }
@@ -594,13 +506,6 @@
} }
/* ============ NOTIF SCROLLBAR ============ */ /* ============ NOTIF SCROLLBAR ============ */
.nc-notif-list::-webkit-scrollbar { .nc-notif-list::-webkit-scrollbar { width: 6px; }
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-track {
background: transparent;
}
.nc-notif-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
+175 -288
View File
@@ -1,4 +1,5 @@
<nav class="nc-nav"> <nav class="nc-nav">
<!-- Logo --> <!-- Logo -->
<div class="nc-logo" (click)="goToHome()" role="button" tabindex="0"> <div class="nc-logo" (click)="goToHome()" role="button" tabindex="0">
<div class="nc-logo-mark"></div> <div class="nc-logo-mark"></div>
@@ -7,315 +8,201 @@
<!-- Center links — only when logged in --> <!-- Center links — only when logged in -->
@if (currentUser) { @if (currentUser) {
<div class="nc-links"> <div class="nc-links">
<button type="button" class="nc-link"> <button type="button" class="nc-link">
<svg <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
width="14" stroke-linecap="round" stroke-linejoin="round">
height="14" <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
viewBox="0 0 24 24" <circle cx="12" cy="12" r="3" />
fill="none" </svg>
stroke="currentColor" Watch
stroke-width="1.7" </button>
stroke-linecap="round" <button type="button" class="nc-link" (click)="goToTournaments()">Tournaments</button>
stroke-linejoin="round" <button type="button" class="nc-link" (click)="goToBots()">Bots</button>
> </div>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
Watch
</button>
<button type="button" class="nc-link" (click)="goToTournaments()">Tournaments</button>
<button type="button" class="nc-link" (click)="goToBots()">Bots</button>
<button type="button" class="nc-link" (click)="goToAnalysis()">Analysis</button>
</div>
} }
<!-- Right cluster --> <!-- Right cluster -->
<div class="nc-right"> <div class="nc-right">
@if (currentUser; as user) { @if (currentUser; as user) {
<!-- Notifications bell -->
<div class="nc-dropdown-wrap" data-dropdown="notif">
<button
type="button"
class="nc-bell"
[class.is-open]="notifOpen"
(click)="toggleNotif($event)"
aria-label="Notifications"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
@if (incomingChallenges.length > 0) {
<span class="nc-badge">{{ incomingChallenges.length }}</span>
}
</button>
@if (notifOpen) { <!-- Notifications bell -->
<div class="nc-popover nc-notif" (click)="$event.stopPropagation()"> <div class="nc-dropdown-wrap" data-dropdown="notif">
<div class="nc-notif-header"> <button type="button" class="nc-bell" [class.is-open]="notifOpen" (click)="toggleNotif($event)"
<span class="nc-notif-header-title">Challenges</span> aria-label="Notifications">
</div> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<div class="nc-notif-list"> <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
@if (incomingChallenges.length === 0) { <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
<div class="nc-notif-empty">No pending challenges</div>
}
@for (challenge of incomingChallenges; track challenge.id) {
<div class="nc-notif-row is-unread">
<div class="nc-notif-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" />
<path d="M16 16l4 4" />
<path d="M19 21l2 -2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</div>
<div class="nc-notif-body">
<div class="nc-notif-text">
<b>{{ challenge.challenger.name }}</b> challenged you to a
{{ getTimeControlDisplay(challenge) }} game.
</div>
<div class="nc-notif-meta">
{{ challenge.challenger.rating }} ·
{{ challenge.timeControl.type ?? 'Custom' }} ·
{{ getExpirationInfo(challenge) }}
</div>
<div class="nc-notif-actions">
<button
type="button"
class="nc-btn-accept"
[disabled]="acceptingId === challenge.id || !!decliningId"
(click)="acceptChallenge($event, challenge)"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{{ acceptingId === challenge.id ? '...' : 'Accept' }}
</button>
<button
type="button"
class="nc-btn-decline"
[disabled]="!!acceptingId || decliningId === challenge.id"
(click)="declineChallenge($event, challenge)"
>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
{{ decliningId === challenge.id ? '...' : 'Decline' }}
</button>
</div>
</div>
</div>
}
</div>
<div class="nc-notif-footer">
<button type="button" class="nc-view-all" (click)="goToChallenges()">
View all challenges
</button>
</div>
</div>
}
</div>
<!-- Games quick-access -->
<button type="button" class="nc-games-btn" (click)="goToGames()">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14.5 17.5L3 6" />
<path d="M13 19l6-6" />
<path d="M16 16l4 4" />
<path d="M19 21l2-2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1-4 4" />
</svg> </svg>
Games @if (incomingChallenges.length > 0) {
<span class="nc-badge">{{ incomingChallenges.length }}</span>
}
</button> </button>
<!-- Profile --> @if (notifOpen) {
<div class="nc-dropdown-wrap" data-dropdown="profile"> <div class="nc-popover nc-notif" (click)="$event.stopPropagation()">
<button <div class="nc-notif-header">
type="button" <span class="nc-notif-header-title">Challenges</span>
class="nc-profile" </div>
[class.is-open]="profileOpen"
(click)="toggleProfile($event)"
>
<div class="nc-avatar nc-avatar-sm">{{ getInitial() }}</div>
<span class="nc-profile-name">{{ user.username }}</span>
<svg
class="nc-chevron"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
@if (profileOpen) { <div class="nc-notif-list">
<div class="nc-popover nc-menu" (click)="$event.stopPropagation()"> @if (incomingChallenges.length === 0) {
<div class="nc-menu-header"> <div class="nc-notif-empty">No pending challenges</div>
<div class="nc-avatar nc-avatar-md">{{ getInitial() }}</div> }
<div> @for (challenge of incomingChallenges; track challenge.id) {
<div class="nc-menu-user-name">{{ user.username }}</div> <div class="nc-notif-row is-unread">
<div class="nc-menu-user-sub">{{ user.rating }} · &#64;{{ user.username }}</div> <div class="nc-notif-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" /><path d="M16 16l4 4" />
<path d="M19 21l2 -2" /><path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</div>
<div class="nc-notif-body">
<div class="nc-notif-text">
<b>{{ challenge.challenger.name }}</b> challenged you to a
{{ getTimeControlDisplay(challenge) }} game.
</div>
<div class="nc-notif-meta">
{{ challenge.challenger.rating }} · {{ challenge.timeControl.type ?? 'Custom' }} · {{ getExpirationInfo(challenge) }}
</div>
<div class="nc-notif-actions">
<button type="button" class="nc-btn-accept"
[disabled]="acceptingId === challenge.id || !!decliningId"
(click)="acceptChallenge($event, challenge)">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
{{ acceptingId === challenge.id ? '...' : 'Accept' }}
</button>
<button type="button" class="nc-btn-decline"
[disabled]="!!acceptingId || decliningId === challenge.id"
(click)="declineChallenge($event, challenge)">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
{{ decliningId === challenge.id ? '...' : 'Decline' }}
</button>
</div> </div>
</div> </div>
<div class="nc-menu-group">
<button type="button" class="nc-menu-item" (click)="goToProfile()">
<span class="nc-menu-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<span class="nc-menu-label">My profile</span>
</button>
<button type="button" class="nc-menu-item" (click)="goToChallenges()">
<span class="nc-menu-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" />
<path d="M16 16l4 4" />
<path d="M19 21l2 -2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</span>
<span class="nc-menu-label">Challenges</span>
</button>
<button type="button" class="nc-menu-item" (click)="toggleTheme($event)">
<span class="nc-menu-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
</span>
<span class="nc-menu-label">{{ isDarkMode ? 'Light mode' : 'Dark mode' }}</span>
<span class="nc-toggle" [class.is-on]="isDarkMode"></span>
</button>
</div>
<div class="nc-menu-group">
<button type="button" class="nc-menu-item danger" (click)="logout()">
<span class="nc-menu-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</span>
<span class="nc-menu-label">Log out</span>
</button>
</div>
</div> </div>
} }
</div>
<div class="nc-notif-footer">
<button type="button" class="nc-view-all" (click)="goToChallenges()">View all challenges</button>
</div>
</div> </div>
} @else { }
<!-- Logged-out auth buttons --> </div>
<button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button>
<button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()"> <!-- Games quick-access -->
Register <button type="button" class="nc-games-btn" (click)="goToGames()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 17.5L3 6"/>
<path d="M13 19l6-6"/><path d="M16 16l4 4"/>
<path d="M19 21l2-2"/><path d="M15 5l4 4"/>
<path d="M21 3l-3 1-4 4"/>
</svg>
Games
</button>
<!-- Profile -->
<div class="nc-dropdown-wrap" data-dropdown="profile">
<button type="button" class="nc-profile" [class.is-open]="profileOpen" (click)="toggleProfile($event)">
<div class="nc-avatar nc-avatar-sm">{{ getInitial() }}</div>
<span class="nc-profile-name">{{ user.username }}</span>
<svg class="nc-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button> </button>
@if (profileOpen) {
<div class="nc-popover nc-menu" (click)="$event.stopPropagation()">
<div class="nc-menu-header">
<div class="nc-avatar nc-avatar-md">{{ getInitial() }}</div>
<div>
<div class="nc-menu-user-name">{{ user.username }}</div>
<div class="nc-menu-user-sub">{{ user.rating }} · &#64;{{ user.username }}</div>
</div>
</div>
<div class="nc-menu-group">
<button type="button" class="nc-menu-item" (click)="goToProfile()">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<span class="nc-menu-label">My profile</span>
</button>
<button type="button" class="nc-menu-item" (click)="goToChallenges()">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" /><path d="M16 16l4 4" />
<path d="M19 21l2 -2" /><path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</span>
<span class="nc-menu-label">Challenges</span>
</button>
<button type="button" class="nc-menu-item" (click)="toggleTheme($event)">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
</span>
<span class="nc-menu-label">{{ isDarkMode ? 'Light mode' : 'Dark mode' }}</span>
<span class="nc-toggle" [class.is-on]="isDarkMode"></span>
</button>
</div>
<div class="nc-menu-group">
<button type="button" class="nc-menu-item danger" (click)="logout()">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</span>
<span class="nc-menu-label">Log out</span>
</button>
</div>
</div>
}
</div>
} @else {
<!-- Logged-out auth buttons -->
<button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button>
<button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()">Register</button>
} }
</div> </div>
</nav> </nav>
@if (showLoginDialog) { @if (showLoginDialog) {
<app-login-dialog (onClose)="closeLoginDialog()" (onSuccess)="onLoginSuccess()" /> <app-login-dialog (onClose)="closeLoginDialog()" (onSuccess)="onLoginSuccess()" />
} }
@if (showRegisterDialog) { @if (showRegisterDialog) {
<app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" /> <app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" />
} }
+27 -38
View File
@@ -18,7 +18,7 @@ import { Challenge } from '../../models/challenge.models';
standalone: true, standalone: true,
imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent], imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent],
templateUrl: './toolbar.component.html', templateUrl: './toolbar.component.html',
styleUrl: './toolbar.component.css', styleUrl: './toolbar.component.css'
}) })
export class ToolbarComponent implements OnInit { export class ToolbarComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -46,36 +46,35 @@ export class ToolbarComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.destroyRef.onDestroy(() => this.stopPolling()); this.destroyRef.onDestroy(() => this.stopPolling());
this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { this.authService.currentUser$
this.currentUser = user; .pipe(takeUntilDestroyed(this.destroyRef))
if (user) { .subscribe(user => {
this.challengeWs.connect(); this.currentUser = user;
this.startPolling(); if (user) {
} else { this.challengeWs.connect();
this.challengeWs.disconnect(); this.startPolling();
this.stopPolling(); } else {
this.navigatedChallengeIds.clear(); this.challengeWs.disconnect();
this.challengeEventService.clear(); this.stopPolling();
} this.navigatedChallengeIds.clear();
}); this.challengeEventService.clear();
}
});
this.authDialogService.dialogState$ this.authDialogService.dialogState$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe(state => {
this.showLoginDialog = state === 'login'; this.showLoginDialog = state === 'login';
this.showRegisterDialog = state === 'register'; this.showRegisterDialog = state === 'register';
}); });
this.themeService.darkMode$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((isDark) => { this.themeService.darkMode$
this.isDarkMode = isDark;
});
this.challengeEventService
.getIncomingChallenges$()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((challenges) => { .subscribe(isDark => { this.isDarkMode = isDark; });
this.incomingChallenges = challenges;
}); this.challengeEventService.getIncomingChallenges$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(challenges => { this.incomingChallenges = challenges; });
} }
private startPolling(): void { private startPolling(): void {
@@ -92,7 +91,7 @@ export class ToolbarComponent implements OnInit {
private fetchChallenges(): void { private fetchChallenges(): void {
this.challengeService.listChallenges().subscribe({ this.challengeService.listChallenges().subscribe({
next: (response) => { next: response => {
const incoming = response.in ?? response.incoming ?? []; const incoming = response.in ?? response.incoming ?? [];
this.challengeEventService.setIncomingChallenges(incoming); this.challengeEventService.setIncomingChallenges(incoming);
@@ -105,7 +104,7 @@ export class ToolbarComponent implements OnInit {
} }
} }
} }
}, }
}); });
} }
@@ -198,12 +197,6 @@ export class ToolbarComponent implements OnInit {
void this.router.navigate(['/bots']); void this.router.navigate(['/bots']);
} }
goToAnalysis(): void {
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/analysis']);
}
onLoginSuccess(): void { onLoginSuccess(): void {
this.closeLoginDialog(); this.closeLoginDialog();
} }
@@ -234,14 +227,12 @@ export class ToolbarComponent implements OnInit {
if (this.acceptingId || this.decliningId) return; if (this.acceptingId || this.decliningId) return;
this.acceptingId = challenge.id; this.acceptingId = challenge.id;
this.challengeService.acceptChallenge(challenge.id).subscribe({ this.challengeService.acceptChallenge(challenge.id).subscribe({
next: (accepted) => { next: accepted => {
this.acceptingId = null; this.acceptingId = null;
this.challengeEventService.onChallengeAccepted(accepted); this.challengeEventService.onChallengeAccepted(accepted);
if (accepted.gameId) void this.router.navigate(['/game', accepted.gameId]); if (accepted.gameId) void this.router.navigate(['/game', accepted.gameId]);
}, },
error: () => { error: () => { this.acceptingId = null; }
this.acceptingId = null;
},
}); });
} }
@@ -254,9 +245,7 @@ export class ToolbarComponent implements OnInit {
this.decliningId = null; this.decliningId = null;
this.challengeEventService.removeChallenge(challenge.id); this.challengeEventService.removeChallenge(challenge.id);
}, },
error: () => { error: () => { this.decliningId = null; }
this.decliningId = null;
},
}); });
} }
} }
+1 -1
View File
@@ -8,6 +8,6 @@ export function loadRuntimeConfig() {
const derivedWsUrl = `${wsProtocol}://${window.location.host}`; const derivedWsUrl = `${wsProtocol}://${window.location.host}`;
return { return {
apiUrl: config.API_URL || '', apiUrl: config.API_URL || '',
wsUrl: config.WEBSOCKET_URL || derivedWsUrl, wsUrl: config.WEBSOCKET_URL || derivedWsUrl
}; };
} }
-25
View File
@@ -1,25 +0,0 @@
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;
}
+29 -35
View File
@@ -1,55 +1,49 @@
export type ChallengeStatus = export type ChallengeStatus = 'created' | 'pending' | 'accepted' | 'declined' | 'cancelled' | 'expired';
| 'created'
| 'pending'
| 'accepted'
| 'declined'
| 'cancelled'
| 'expired';
export type PlayerColor = 'white' | 'black' | 'random'; export type PlayerColor = 'white' | 'black' | 'random';
export interface Player { export interface Player {
id: string; id: string;
name: string; name: string;
rating: number; rating: number;
} }
export interface TimeControl { export interface TimeControl {
type: string | null; type: string | null;
limit: number | null; limit: number | null;
increment: number | null; increment: number | null;
} }
export interface Challenge { export interface Challenge {
id: string; id: string;
challenger: Player; challenger: Player;
destUser: Player; destUser: Player;
variant: string; variant: string;
color: PlayerColor; color: PlayerColor;
timeControl: TimeControl; timeControl: TimeControl;
status: ChallengeStatus; status: ChallengeStatus;
declineReason: string | null; declineReason: string | null;
gameId: string | null; gameId: string | null;
expiresAt: string; expiresAt: string;
createdAt: string; createdAt: string;
} }
export interface SendChallengeRequest { export interface SendChallengeRequest {
timeControl: { timeControl: {
limitSeconds: number; limitSeconds: number;
incrementSeconds: number; incrementSeconds: number;
}; };
color?: PlayerColor; color?: PlayerColor;
ttlSeconds?: number; ttlSeconds?: number;
} }
export interface ListChallengesResponse { export interface ListChallengesResponse {
in?: Challenge[]; 'in'?: Challenge[];
out?: Challenge[]; 'out'?: Challenge[];
incoming?: Challenge[]; incoming?: Challenge[];
outgoing?: Challenge[]; outgoing?: Challenge[];
} }
export interface DeclineChallengeRequest { export interface DeclineChallengeRequest {
reason?: string; reason?: string;
} }
@@ -1,542 +0,0 @@
/* ============================================================
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;
}
}
@@ -1,355 +0,0 @@
<div class="analysis-shell">
<div class="page">
<!-- Breadcrumb -->
<nav class="crumb" aria-label="Breadcrumb">
<a routerLink="/" class="crumb-link">
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
Back to lobby
</a>
<span class="crumb-sep">/</span>
<span class="crumb-current">Analysis</span>
</nav>
<!-- Page header -->
<header class="page-header">
<div class="page-title">
<h1>Chess Analysis</h1>
<p class="page-subtitle">Analyse positions, games or custom PGN with the engine</p>
</div>
</header>
<!-- Error -->
@if (errorMessage) {
<div class="state-error">{{ errorMessage }}</div>
}
<!-- Input section -->
<section class="input-section">
<!-- Mode tabs -->
<div class="mode-tabs" role="tablist" aria-label="Analysis input mode">
<button
class="mode-tab"
[class.active]="inputMode === 'fen'"
role="tab"
(click)="setInputMode('fen')"
>
FEN
</button>
<button
class="mode-tab"
[class.active]="inputMode === 'pgn'"
role="tab"
(click)="setInputMode('pgn')"
>
PGN
</button>
<button
class="mode-tab"
[class.active]="inputMode === 'game'"
role="tab"
(click)="setInputMode('game')"
>
Game ID
</button>
</div>
<!-- FEN input -->
@if (inputMode === 'fen') {
<div class="input-row">
<input
id="fen-input"
type="text"
class="text-input"
placeholder="Paste FEN string here…"
autocomplete="off"
[(ngModel)]="fenInput"
(keydown.enter)="loadFen()"
/>
<button
class="btn btn-primary"
type="button"
(click)="loadFen()"
[disabled]="!fenInput.trim()"
>
Load
</button>
</div>
}
<!-- PGN input -->
@if (inputMode === 'pgn') {
<div class="pgn-col">
<textarea
id="pgn-input"
class="text-area"
rows="5"
placeholder="Paste PGN here…"
[(ngModel)]="pgnInput"
></textarea>
<button
class="btn btn-primary"
type="button"
(click)="loadPgn()"
[disabled]="!pgnInput.trim() || loading"
>
@if (loading) {
Loading…
} @else {
Import PGN
}
</button>
</div>
}
<!-- Game ID input -->
@if (inputMode === 'game') {
<div class="input-row">
<input
id="gameid-input"
type="text"
class="text-input"
placeholder="Game ID"
autocomplete="off"
[(ngModel)]="fenInput"
(keydown.enter)="loadGame(fenInput)"
/>
<button
class="btn btn-primary"
type="button"
(click)="loadGame(fenInput)"
[disabled]="!fenInput.trim() || loading"
>
@if (loading) {
Loading…
} @else {
Load game
}
</button>
</div>
}
<!-- Depth + analyse -->
<div class="depth-row">
<label class="depth-label" for="depth-input">Depth</label>
<input
id="depth-input"
type="number"
class="depth-input"
min="1"
max="18"
[(ngModel)]="depth"
/>
<button
class="btn btn-analyse"
type="button"
(click)="runAnalysis()"
[disabled]="analysing"
>
@if (analysing) {
Analysing…
} @else {
Analyse
}
</button>
</div>
</section>
<!-- Main layout: board + sidebar -->
<div class="layout">
<!-- Board column -->
<div class="board-col">
<div class="board-wrap">
<app-chess-board
[fen]="displayFen"
[selectedSquare]="null"
[highlightedSquares]="[]"
boardTheme="arabian"
/>
</div>
<!-- Navigation bar -->
@if (fenHistory.length > 1) {
<div class="nav-bar">
<button class="icon-btn" title="First position" (click)="navigateHistory('first')">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="11 17 6 12 11 7" />
<polyline points="18 17 13 12 18 7" />
</svg>
</button>
<button class="icon-btn" title="Previous" (click)="navigateHistory('prev')">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<button class="icon-btn" title="Next" (click)="navigateHistory('next')">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<button class="icon-btn" title="Last position" (click)="navigateHistory('last')">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="13 17 18 12 13 7" />
<polyline points="6 17 11 12 6 7" />
</svg>
</button>
</div>
}
</div>
<!-- Side column -->
<aside class="side">
<!-- Single position analysis result -->
@if (positionAnalysis && !hasAnnotations) {
<details class="side-card" open>
<summary class="side-card-summary">
<span class="side-card-title">Position Analysis</span>
<svg
class="chev"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
<div class="side-card-body eval-grid">
<div class="eval-row">
<span class="eval-label">Evaluation</span>
<span
class="eval-value"
[class.positive]="positionAnalysis.eval > 0"
[class.negative]="positionAnalysis.eval < 0"
>
{{ positionAnalysis.eval > 0 ? '+' : '' }}{{ positionAnalysis.eval.toFixed(2) }}
</span>
</div>
<div class="eval-row">
<span class="eval-label">Win chance</span>
<span class="eval-value">{{ (positionAnalysis.winChance * 100).toFixed(1) }}%</span>
</div>
<div class="eval-row">
<span class="eval-label">Best move</span>
<span class="eval-value mono">{{ positionAnalysis.bestMove }}</span>
</div>
<div class="eval-row">
<span class="eval-label">Depth</span>
<span class="eval-value mono">{{ positionAnalysis.depth }}</span>
</div>
@if (positionAnalysis.continuations.length > 0) {
<div class="eval-row eval-row--col">
<span class="eval-label">Continuation</span>
<span class="eval-value mono continuation">{{
positionAnalysis.continuations.join(' ')
}}</span>
</div>
}
</div>
</details>
}
<!-- Evaluation timeline -->
@if (hasAnnotations) {
<details class="side-card" open>
<summary class="side-card-summary">
<span class="side-card-title">Evaluation</span>
<svg
class="chev"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
<div class="side-card-body timeline-body">
<app-eval-timeline [moves]="annotatedMoves" [activePly]="activePly" />
</div>
</details>
<!-- Annotated moves -->
<details class="side-card" open>
<summary class="side-card-summary">
<span class="side-card-title">Moves</span>
<span class="side-card-meta">{{ annotatedMoves.length }} plies</span>
<svg
class="chev"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
<app-annotated-move-list
[moves]="annotatedMoves"
[activePly]="activePly"
(plySelected)="navigateToPly($event)"
/>
</details>
}
</aside>
</div>
</div>
</div>
@@ -1,244 +0,0 @@
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;
},
});
}
}
+82 -303
View File
@@ -10,7 +10,7 @@
--nc-success: #5ee5a1; --nc-success: #5ee5a1;
--nc-danger: #ff7a7a; --nc-danger: #ff7a7a;
--nc-warn: #ffd166; --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; display: block;
min-height: 100vh; min-height: 100vh;
@@ -33,352 +33,131 @@
--nc-warn: #b45309; --nc-warn: #b45309;
} }
.b-shell { .b-shell { padding-top: 72px; min-height: 100vh; }
padding-top: 72px; .page { max-width: 680px; margin: 0 auto; padding: 32px 20px 64px; }
min-height: 100vh;
}
.page {
max-width: 680px;
margin: 0 auto;
padding: 32px 20px 64px;
}
.crumb { .crumb { display: flex; align-items: center; gap: 8px; margin-bottom: 28px;
display: flex; font-size: 11px; color: var(--nc-text-dim); letter-spacing: 0.06em; }
align-items: center; .crumb-link { display: inline-flex; align-items: center; gap: 4px;
gap: 8px; color: var(--nc-text-dim); text-decoration: none; transition: color 0.15s; }
margin-bottom: 28px; .crumb-link:hover { color: var(--nc-neon); }
font-size: 11px; .crumb-sep { opacity: 0.35; }
color: var(--nc-text-dim); .crumb-current { color: var(--nc-text-muted); }
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 { .page-header { margin-bottom: 24px; }
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; }
.title-row { .page-sub { font-size: 13px; color: var(--nc-text-muted); margin: 0; line-height: 1.5; }
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 { .btn-new {
display: inline-flex; display: inline-flex; align-items: center; gap: 6px;
align-items: center; padding: 7px 14px; border-radius: 8px; border: none;
gap: 6px; background: var(--nc-neon); color: #fff;
padding: 7px 14px; font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity 0.15s;
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 */
.create-panel { .create-panel {
border: 1px solid var(--nc-border-strong); border: 1px solid var(--nc-border-strong); border-radius: 12px;
border-radius: 12px; background: var(--nc-surface); padding: 16px; margin-bottom: 20px;
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;
} }
.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 { .text-input {
flex: 1; flex: 1; padding: 8px 12px; border-radius: 8px;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--nc-border-strong); border: 1px solid var(--nc-border-strong);
background: rgba(255, 255, 255, 0.04); background: rgba(255,255,255,0.04); color: var(--nc-text); font-size: 14px;
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 */ /* Buttons */
.btn-primary { .btn-primary {
padding: 8px 16px; padding: 8px 16px; border-radius: 8px; border: none;
border-radius: 8px; background: var(--nc-neon); color: #fff; font-size: 13px; font-weight: 600;
border: none; cursor: pointer; white-space: nowrap; transition: opacity 0.15s;
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 { .btn-ghost {
padding: 8px 14px; padding: 8px 14px; border-radius: 8px; border: 1px solid var(--nc-border-strong);
border-radius: 8px; background: transparent; color: var(--nc-text-muted); font-size: 13px; cursor: pointer;
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 */ /* States */
.state-msg { .state-msg { display: flex; align-items: center; gap: 10px;
display: flex; padding: 24px 0; color: var(--nc-text-muted); font-size: 13px; }
align-items: center; .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--nc-neon);
gap: 10px; flex-shrink: 0; animation: pulse 1.4s ease-in-out infinite; }
padding: 24px 0; @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } }
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 { .empty-state { display: flex; flex-direction: column; align-items: center;
display: flex; gap: 8px; padding: 64px 0; text-align: center; }
flex-direction: column; .empty-icon { color: var(--nc-text-dim); margin-bottom: 4px; }
align-items: center; .empty-title { font-size: 15px; font-weight: 600; margin: 0; }
gap: 8px; .empty-sub { font-size: 13px; color: var(--nc-text-muted); margin: 0; }
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 */
.bot-list { .bot-list { display: flex; flex-direction: column; gap: 8px; }
display: flex;
flex-direction: column;
gap: 8px;
}
.bot-card { .bot-card {
border: 1px solid var(--nc-border); border: 1px solid var(--nc-border); border-radius: 12px;
border-radius: 12px; background: var(--nc-surface); overflow: hidden;
background: var(--nc-surface);
overflow: hidden;
} }
.bot-main { .bot-main {
display: flex; display: flex; align-items: center; gap: 12px;
align-items: center;
gap: 12px;
padding: 14px 16px; padding: 14px 16px;
} }
.bot-avatar { .bot-avatar {
width: 36px; width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0;
height: 36px; background: var(--nc-neon); color: #fff;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
flex-shrink: 0; font-size: 16px; font-weight: 700;
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 { .btn-token {
display: inline-flex; display: inline-flex; align-items: center; gap: 5px;
align-items: center; padding: 6px 12px; border-radius: 7px; border: 1px solid var(--nc-border-strong);
gap: 5px; background: transparent; color: var(--nc-text-muted); font-size: 12px; cursor: pointer;
padding: 6px 12px; transition: background 0.15s, color 0.15s;
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 { .btn-danger {
padding: 6px 12px; padding: 6px 12px; border-radius: 7px; border: 1px solid rgba(255,122,122,0.3);
border-radius: 7px; background: transparent; color: var(--nc-danger); font-size: 12px; cursor: pointer;
border: 1px solid rgba(255, 122, 122, 0.3);
background: transparent;
color: var(--nc-danger);
font-size: 12px;
cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
} }
.btn-danger:hover { .btn-danger:hover { background: rgba(255,122,122,0.1); }
background: rgba(255, 122, 122, 0.1); .btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Token panel */ /* Token panel */
.token-panel { .token-panel {
border-top: 1px solid var(--nc-border); border-top: 1px solid var(--nc-border); padding: 12px 16px;
padding: 12px 16px; display: flex; flex-direction: column; gap: 10px;
display: flex;
flex-direction: column;
gap: 10px;
} }
.token-warning { .token-warning {
display: flex; display: flex; align-items: flex-start; gap: 8px;
align-items: flex-start; font-size: 12px; color: var(--nc-warn);
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 { .token-value {
flex: 1; flex: 1; font-family: monospace; font-size: 11px;
font-family: monospace; background: rgba(0,0,0,0.2); border-radius: 6px;
font-size: 11px; padding: 8px 10px; word-break: break-all;
background: rgba(0, 0, 0, 0.2); color: var(--nc-text-muted); border: 1px solid var(--nc-border);
border-radius: 6px;
padding: 8px 10px;
word-break: break-all;
color: var(--nc-text-muted);
border: 1px solid var(--nc-border);
} }
.btn-copy { .btn-copy {
padding: 6px 12px; padding: 6px 12px; border-radius: 7px; border: 1px solid var(--nc-border-strong);
border-radius: 7px; background: transparent; color: var(--nc-text-muted); font-size: 12px;
border: 1px solid var(--nc-border-strong); cursor: pointer; white-space: nowrap; transition: color 0.15s;
background: transparent;
color: var(--nc-text-muted);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
transition: color 0.15s;
flex-shrink: 0; flex-shrink: 0;
} }
.btn-copy:hover { .btn-copy:hover { color: var(--nc-success); }
color: var(--nc-success);
}
+35 -103
View File
@@ -1,18 +1,11 @@
<div class="b-shell"> <div class="b-shell">
<div class="page"> <div class="page">
<nav class="crumb"> <nav class="crumb">
<a routerLink="/" class="crumb-link"> <a routerLink="/" class="crumb-link">
<svg <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"
width="11" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
height="11" <polyline points="15 18 9 12 15 6"/>
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg> </svg>
Back to lobby Back to lobby
</a> </a>
@@ -24,26 +17,14 @@
<div class="title-row"> <div class="title-row">
<h1 class="page-title">My Bots</h1> <h1 class="page-title">My Bots</h1>
<button type="button" class="btn-new" (click)="openCreate()"> <button type="button" class="btn-new" (click)="openCreate()">
<svg <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
width="13" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
height="13" <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
New bot New bot
</button> </button>
</div> </div>
<p class="page-sub"> <p class="page-sub">Bots are automated players owned by your account. Each has a token used to join tournaments and make moves.</p>
Bots are automated players owned by your account. Each has a token used to join tournaments
and make moves.
</p>
</header> </header>
@if (showCreate) { @if (showCreate) {
@@ -51,21 +32,11 @@
<div class="create-inner"> <div class="create-inner">
<label class="field-label">Bot name</label> <label class="field-label">Bot name</label>
<div class="create-row"> <div class="create-row">
<input <input type="text" class="text-input" [(ngModel)]="newBotName"
type="text" placeholder="e.g. AlphaBot" (keydown.enter)="submitCreate()"
class="text-input" [disabled]="creating" maxlength="40" />
[(ngModel)]="newBotName" <button type="button" class="btn-primary" (click)="submitCreate()"
placeholder="e.g. AlphaBot" [disabled]="creating || !newBotName.trim()">
(keydown.enter)="submitCreate()"
[disabled]="creating"
maxlength="40"
/>
<button
type="button"
class="btn-primary"
(click)="submitCreate()"
[disabled]="creating || !newBotName.trim()"
>
{{ creating ? 'Creating…' : 'Create' }} {{ creating ? 'Creating…' : 'Create' }}
</button> </button>
<button type="button" class="btn-ghost" (click)="cancelCreate()" [disabled]="creating"> <button type="button" class="btn-ghost" (click)="cancelCreate()" [disabled]="creating">
@@ -83,19 +54,10 @@
<div class="state-msg"><span class="pulse"></span>Loading bots…</div> <div class="state-msg"><span class="pulse"></span>Loading bots…</div>
} @else if (bots.length === 0) { } @else if (bots.length === 0) {
<div class="empty-state"> <div class="empty-state">
<svg <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor"
width="36" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" class="empty-icon">
height="36" <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
viewBox="0 0 24 24" <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
fill="none"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
class="empty-icon"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg> </svg>
<p class="empty-title">No bots yet</p> <p class="empty-title">No bots yet</p>
<p class="empty-sub">Create a bot to join tournaments and play automated games.</p> <p class="empty-sub">Create a bot to join tournaments and play automated games.</p>
@@ -108,49 +70,29 @@
<div class="bot-avatar">{{ bot.name.charAt(0).toUpperCase() }}</div> <div class="bot-avatar">{{ bot.name.charAt(0).toUpperCase() }}</div>
<div class="bot-info"> <div class="bot-info">
<span class="bot-name">{{ bot.name }}</span> <span class="bot-name">{{ bot.name }}</span>
<span class="bot-meta" <span class="bot-meta">Rating {{ bot.rating }} · Created {{ bot.createdAt | date:'MMM d, yyyy' }}</span>
>Rating {{ bot.rating }} · Created {{ bot.createdAt | date: 'MMM d, yyyy' }}</span
>
</div> </div>
<div class="bot-actions"> <div class="bot-actions">
<button <button type="button" class="btn-token"
type="button"
class="btn-token"
[class.active]="!!revealedTokens[bot.id]" [class.active]="!!revealedTokens[bot.id]"
[disabled]="revealingId === bot.id" [disabled]="revealingId === bot.id"
(click)="revealToken(bot.id)" (click)="revealToken(bot.id)">
> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<svg stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
@if (revealedTokens[bot.id]) { @if (revealedTokens[bot.id]) {
<path <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" <path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
/> <line x1="1" y1="1" x2="23" y2="23"/>
<path
d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"
/>
<line x1="1" y1="1" x2="23" y2="23" />
} @else { } @else {
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3"/>
} }
</svg> </svg>
{{ revealingId === bot.id ? '…' : revealedTokens[bot.id] ? 'Hide' : 'Token' }} {{ revealingId === bot.id ? '…' : (revealedTokens[bot.id] ? 'Hide' : 'Token') }}
</button> </button>
<button <button type="button" class="btn-danger"
type="button"
class="btn-danger"
[disabled]="deletingId === bot.id" [disabled]="deletingId === bot.id"
(click)="deleteBot(bot.id)" (click)="deleteBot(bot.id)">
>
{{ deletingId === bot.id ? '…' : 'Delete' }} {{ deletingId === bot.id ? '…' : 'Delete' }}
</button> </button>
</div> </div>
@@ -159,21 +101,10 @@
@if (revealedTokens[bot.id]) { @if (revealedTokens[bot.id]) {
<div class="token-panel"> <div class="token-panel">
<div class="token-warning"> <div class="token-warning">
<svg <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
width="13" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
height="13" <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
viewBox="0 0 24 24" <line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
/>
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
Token was just regenerated — the old one is now invalid. Keep this secret. Token was just regenerated — the old one is now invalid. Keep this secret.
</div> </div>
@@ -189,5 +120,6 @@
} }
</div> </div>
} }
</div> </div>
</div> </div>
+11 -23
View File
@@ -11,7 +11,7 @@ import { Bot, BotWithToken } from '../../models/bot.models';
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, FormsModule], imports: [CommonModule, RouterLink, FormsModule],
templateUrl: './bots.component.html', templateUrl: './bots.component.html',
styleUrl: './bots.component.css', styleUrl: './bots.component.css'
}) })
export class BotsComponent implements OnInit { export class BotsComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -36,17 +36,11 @@ export class BotsComponent implements OnInit {
loadBots(): void { loadBots(): void {
this.loading = true; this.loading = true;
this.botService this.botService.list()
.list()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: (bots) => { next: bots => { this.bots = bots; this.loading = false; },
this.bots = bots; error: () => { this.loading = false; }
this.loading = false;
},
error: () => {
this.loading = false;
},
}); });
} }
@@ -72,10 +66,10 @@ export class BotsComponent implements OnInit {
this.bots = [bot, ...this.bots]; this.bots = [bot, ...this.bots];
this.revealedTokens[bot.id] = bot.token; this.revealedTokens[bot.id] = bot.token;
}, },
error: (err) => { error: err => {
this.creating = false; this.creating = false;
this.createError = err.error?.message ?? err.error?.error ?? 'Failed to create bot.'; this.createError = err.error?.message ?? err.error?.error ?? 'Failed to create bot.';
}, }
}); });
} }
@@ -86,13 +80,11 @@ export class BotsComponent implements OnInit {
} }
this.revealingId = botId; this.revealingId = botId;
this.botService.rotateToken(botId).subscribe({ this.botService.rotateToken(botId).subscribe({
next: (token) => { next: token => {
this.revealingId = null; this.revealingId = null;
this.revealedTokens[botId] = token; this.revealedTokens[botId] = token;
}, },
error: () => { error: () => { this.revealingId = null; }
this.revealingId = null;
},
}); });
} }
@@ -101,9 +93,7 @@ export class BotsComponent implements OnInit {
if (!token) return; if (!token) return;
navigator.clipboard.writeText(token).then(() => { navigator.clipboard.writeText(token).then(() => {
this.copiedId = botId; this.copiedId = botId;
setTimeout(() => { setTimeout(() => { this.copiedId = null; }, 2000);
this.copiedId = null;
}, 2000);
}); });
} }
@@ -112,12 +102,10 @@ export class BotsComponent implements OnInit {
this.botService.delete(botId).subscribe({ this.botService.delete(botId).subscribe({
next: () => { next: () => {
this.deletingId = null; 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]; delete this.revealedTokens[botId];
}, },
error: () => { error: () => { this.deletingId = null; }
this.deletingId = null;
},
}); });
} }
} }
@@ -2,14 +2,7 @@
.challenges-container { .challenges-container {
min-height: 100vh; min-height: 100vh;
background: linear-gradient( background: linear-gradient(135deg, #04000f 0%, #0e0235 25%, #2d0860 50%, #0e0235 75%, #04000f 100%);
135deg,
#04000f 0%,
#0e0235 25%,
#2d0860 50%,
#0e0235 75%,
#04000f 100%
);
color: #e0e0e0; color: #e0e0e0;
padding: 20px; padding: 20px;
font-family: 'Space Mono', 'Courier New', monospace; font-family: 'Space Mono', 'Courier New', monospace;
@@ -1,104 +1,102 @@
<div class="challenges-container"> <div class="challenges-container">
<div class="challenges-header"> <div class="challenges-header">
<h1>Active Challenges</h1> <h1>Active Challenges</h1>
<button type="button" class="back-btn" (click)="goBack()">← Back</button> <button type="button" class="back-btn" (click)="goBack()">← Back</button>
</div>
<div *ngIf="errorMessage" class="error-banner">
{{ errorMessage }}
</div>
<div class="challenges-grid">
<!-- Incoming Challenges -->
<div class="challenges-section">
<h2>Incoming Challenges</h2>
<div *ngIf="loading" class="loading-spinner">Loading...</div>
<div *ngIf="!loading && incomingChallenges.length === 0" class="empty-state">
<p>No incoming challenges</p>
</div>
<div *ngIf="!loading && incomingChallenges.length > 0" class="challenge-list">
<div *ngFor="let challenge of incomingChallenges" class="challenge-card">
<div class="challenge-header">
<span class="challenger-name">{{ getChallengerDisplay(challenge) }}</span>
<span class="time-control">{{ getTimeControlDisplay(challenge) }}</span>
</div>
<div class="challenge-details">
<div class="detail">
<span class="label">Status:</span>
<span class="value" [class]="'status-' + challenge.status">
{{ challenge.status | uppercase }}
</span>
</div>
<div class="detail">
<span class="label">Expires in:</span>
<span class="value">{{ getExpirationInfo(challenge) }}</span>
</div>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'created'">
<button type="button" class="btn btn-decline" (click)="declineChallenge(challenge)">
Decline
</button>
<button type="button" class="btn btn-accept" (click)="acceptChallenge(challenge)">
Accept
</button>
</div>
<div
class="challenge-actions"
*ngIf="challenge.status === 'accepted' && challenge.gameId"
>
<button type="button" class="btn btn-accept" (click)="openGame(challenge)">Play</button>
</div>
</div>
</div>
</div> </div>
<!-- Outgoing Challenges --> <div *ngIf="errorMessage" class="error-banner">
<div class="challenges-section"> {{ errorMessage }}
<h2>Outgoing Challenges</h2>
<div *ngIf="!loading && outgoingChallenges.length === 0" class="empty-state">
<p>No outgoing challenges</p>
</div>
<div *ngIf="!loading && outgoingChallenges.length > 0" class="challenge-list">
<div *ngFor="let challenge of outgoingChallenges" class="challenge-card">
<div class="challenge-header">
<span class="challenger-name">→ {{ getOpponentDisplay(challenge) }}</span>
<span class="time-control">{{ getTimeControlDisplay(challenge) }}</span>
</div>
<div class="challenge-details">
<div class="detail">
<span class="label">Status:</span>
<span class="value" [class]="'status-' + challenge.status">
{{ challenge.status | uppercase }}
</span>
</div>
<div class="detail">
<span class="label">Expires in:</span>
<span class="value">{{ getExpirationInfo(challenge) }}</span>
</div>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'created'">
<button type="button" class="btn btn-cancel" (click)="cancelChallenge(challenge)">
Cancel
</button>
</div>
<div
class="challenge-actions"
*ngIf="challenge.status === 'accepted' && challenge.gameId"
>
<button type="button" class="btn btn-accept" (click)="openGame(challenge)">Play</button>
</div>
</div>
</div>
</div> </div>
</div>
</div> <div class="challenges-grid">
<!-- Incoming Challenges -->
<div class="challenges-section">
<h2>Incoming Challenges</h2>
<div *ngIf="loading" class="loading-spinner">Loading...</div>
<div *ngIf="!loading && incomingChallenges.length === 0" class="empty-state">
<p>No incoming challenges</p>
</div>
<div *ngIf="!loading && incomingChallenges.length > 0" class="challenge-list">
<div *ngFor="let challenge of incomingChallenges" class="challenge-card">
<div class="challenge-header">
<span class="challenger-name">{{ getChallengerDisplay(challenge) }}</span>
<span class="time-control">{{ getTimeControlDisplay(challenge) }}</span>
</div>
<div class="challenge-details">
<div class="detail">
<span class="label">Status:</span>
<span class="value" [class]="'status-' + challenge.status">
{{ challenge.status | uppercase }}
</span>
</div>
<div class="detail">
<span class="label">Expires in:</span>
<span class="value">{{ getExpirationInfo(challenge) }}</span>
</div>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'created'">
<button type="button" class="btn btn-decline" (click)="declineChallenge(challenge)">
Decline
</button>
<button type="button" class="btn btn-accept" (click)="acceptChallenge(challenge)">
Accept
</button>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'accepted' && challenge.gameId">
<button type="button" class="btn btn-accept" (click)="openGame(challenge)">
Play
</button>
</div>
</div>
</div>
</div>
<!-- Outgoing Challenges -->
<div class="challenges-section">
<h2>Outgoing Challenges</h2>
<div *ngIf="!loading && outgoingChallenges.length === 0" class="empty-state">
<p>No outgoing challenges</p>
</div>
<div *ngIf="!loading && outgoingChallenges.length > 0" class="challenge-list">
<div *ngFor="let challenge of outgoingChallenges" class="challenge-card">
<div class="challenge-header">
<span class="challenger-name">→ {{ getOpponentDisplay(challenge) }}</span>
<span class="time-control">{{ getTimeControlDisplay(challenge) }}</span>
</div>
<div class="challenge-details">
<div class="detail">
<span class="label">Status:</span>
<span class="value" [class]="'status-' + challenge.status">
{{ challenge.status | uppercase }}
</span>
</div>
<div class="detail">
<span class="label">Expires in:</span>
<span class="value">{{ getExpirationInfo(challenge) }}</span>
</div>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'created'">
<button type="button" class="btn btn-cancel" (click)="cancelChallenge(challenge)">
Cancel
</button>
</div>
<div class="challenge-actions" *ngIf="challenge.status === 'accepted' && challenge.gameId">
<button type="button" class="btn btn-accept" (click)="openGame(challenge)">
Play
</button>
</div>
</div>
</div>
</div>
</div>
</div>
+155 -160
View File
@@ -8,177 +8,172 @@ import { Challenge } from '../../models/challenge.models';
import { getErrorMessage } from '../../core/http/error-message.util'; import { getErrorMessage } from '../../core/http/error-message.util';
@Component({ @Component({
selector: 'app-challenges', selector: 'app-challenges',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
templateUrl: './challenges.component.html', templateUrl: './challenges.component.html',
styleUrls: ['./challenges.component.css'], styleUrls: ['./challenges.component.css']
}) })
export class ChallengesComponent implements OnInit, OnDestroy { export class ChallengesComponent implements OnInit, OnDestroy {
private readonly challengeService = inject(ChallengeService); private readonly challengeService = inject(ChallengeService);
private readonly challengeEventService = inject(ChallengeEventService); private readonly challengeEventService = inject(ChallengeEventService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
incomingChallenges: Challenge[] = []; incomingChallenges: Challenge[] = [];
outgoingChallenges: Challenge[] = []; outgoingChallenges: Challenge[] = [];
loading = false; loading = false;
errorMessage = ''; errorMessage = '';
private pollInterval: any = null; private pollInterval: any = null;
private readonly pollIntervalMs = 5000; // Poll every 5 seconds private readonly pollIntervalMs = 5000; // Poll every 5 seconds
ngOnInit(): void { ngOnInit(): void {
this.loadChallenges(true); this.loadChallenges(true);
// Subscribe to challenge events // Subscribe to challenge events
this.challengeEventService this.challengeEventService.getChallengeReceived$()
.getChallengeReceived$() .pipe(takeUntilDestroyed(this.destroyRef))
.pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => {
.subscribe(() => { this.loadChallenges();
this.loadChallenges(); });
});
// Start polling for challenge updates // Start polling for challenge updates
this.startPolling(); this.startPolling();
}
ngOnDestroy(): void {
this.stopPolling();
}
private startPolling(): void {
this.pollInterval = setInterval(() => {
this.loadChallenges(false);
}, this.pollIntervalMs);
}
private stopPolling(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
loadChallenges(showLoader = false): void {
if (showLoader) {
this.loading = true;
this.errorMessage = '';
} }
this.challengeService ngOnDestroy(): void {
.listChallenges() this.stopPolling();
.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); private startPolling(): void {
if (minutes > 60) { this.pollInterval = setInterval(() => {
const hours = Math.floor(minutes / 60); this.loadChallenges(false);
return `${hours}h`; }, this.pollIntervalMs);
} }
return `${minutes}m`; 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`;
}
} }
+50 -160
View File
@@ -23,8 +23,8 @@
--nc-btn-bg: rgba(255, 255, 255, 0.03); --nc-btn-bg: rgba(255, 255, 255, 0.03);
--nc-btn-hover-bg: rgba(255, 255, 255, 0.07); --nc-btn-hover-bg: rgba(255, 255, 255, 0.07);
--nc-seg-bg: rgba(0, 0, 0, 0.3); --nc-seg-bg: rgba(0, 0, 0, 0.3);
--nc-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--nc-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace; --nc-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
} }
/* ============================================================ /* ============================================================
@@ -40,17 +40,17 @@
--nc-text: #fff; --nc-text: #fff;
--nc-text-muted: rgba(255, 255, 255, 0.72); --nc-text-muted: rgba(255, 255, 255, 0.72);
--nc-text-dim: rgba(255, 255, 255, 0.45); --nc-text-dim: rgba(255, 255, 255, 0.45);
--nc-border: rgba(255, 255, 255, 0.1); --nc-border: rgba(255, 255, 255, 0.10);
--nc-border-strong: rgba(255, 255, 255, 0.18); --nc-border-strong: rgba(255, 255, 255, 0.18);
--nc-warning: #ffb13a; --nc-warning: #ffb13a;
--nc-warning-soft: rgba(255, 177, 58, 0.4); --nc-warning-soft: rgba(255, 177, 58, 0.40);
--nc-danger: #ff7a7a; --nc-danger: #ff7a7a;
--nc-danger-soft: rgba(255, 122, 122, 0.3); --nc-danger-soft: rgba(255, 122, 122, 0.30);
--nc-danger-bg: rgba(255, 122, 122, 0.08); --nc-danger-bg: rgba(255, 122, 122, 0.08);
--nc-success: #5ee5a1; --nc-success: #5ee5a1;
--nc-clock-bg: rgba(0, 0, 0, 0.3); --nc-clock-bg: rgba(0, 0, 0, 0.30);
--nc-btn-bg: rgba(255, 255, 255, 0.05); --nc-btn-bg: rgba(255, 255, 255, 0.05);
--nc-btn-hover-bg: rgba(255, 255, 255, 0.1); --nc-btn-hover-bg: rgba(255, 255, 255, 0.10);
--nc-seg-bg: rgba(0, 0, 0, 0.28); --nc-seg-bg: rgba(0, 0, 0, 0.28);
} }
@@ -66,7 +66,7 @@
} }
.game-shell::before { .game-shell::before {
content: ''; content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
background: background:
@@ -78,7 +78,7 @@
:host-context(html:not([data-theme='dark'])) .game-shell::before { :host-context(html:not([data-theme='dark'])) .game-shell::before {
background: background:
radial-gradient(ellipse 80% 50% at 20% 100%, rgba(212, 77, 74, 0.1), transparent 60%), radial-gradient(ellipse 80% 50% at 20% 100%, rgba(212, 77, 74, 0.10), transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 0%, rgba(74, 41, 98, 0.22), transparent 60%); radial-gradient(ellipse 60% 40% at 90% 0%, rgba(74, 41, 98, 0.22), transparent 60%);
} }
@@ -116,16 +116,9 @@
transition: color 0.15s; transition: color 0.15s;
} }
.crumb-link:hover { .crumb-link:hover { color: var(--nc-neon); }
color: var(--nc-neon); .crumb-sep { color: var(--nc-text-dim); opacity: 0.5; }
} .crumb-current { color: var(--nc-text-muted); }
.crumb-sep {
color: var(--nc-text-dim);
opacity: 0.5;
}
.crumb-current {
color: var(--nc-text-muted);
}
/* ============================================================ /* ============================================================
GAME HEADER GAME HEADER
@@ -184,10 +177,7 @@
color: var(--nc-text-muted); color: var(--nc-text-muted);
} }
.game-id strong { .game-id strong { color: var(--nc-text); font-weight: 500; }
color: var(--nc-text);
font-weight: 500;
}
.meta-dot { .meta-dot {
width: 3px; width: 3px;
@@ -207,15 +197,9 @@
transition: color 0.15s; transition: color 0.15s;
} }
.copy-btn:hover { .copy-btn:hover { color: var(--nc-neon); }
color: var(--nc-neon);
}
.header-actions { .header-actions { display: flex; gap: 8px; align-items: center; }
display: flex;
gap: 8px;
align-items: center;
}
/* ============================================================ /* ============================================================
BUTTONS BUTTONS
@@ -234,9 +218,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
transition: transition: background 0.15s, border-color 0.15s;
background 0.15s,
border-color 0.15s;
} }
.btn:hover { .btn:hover {
@@ -254,9 +236,7 @@
flex-shrink: 0; flex-shrink: 0;
} }
.btn-primary:hover { .btn-primary:hover { box-shadow: 0 0 20px rgba(255, 69, 200, 0.5); }
box-shadow: 0 0 20px rgba(255, 69, 200, 0.5);
}
.btn-ghost { .btn-ghost {
background: transparent; background: transparent;
@@ -273,9 +253,7 @@
transition: color 0.15s; transition: color 0.15s;
} }
.btn-ghost:hover { .btn-ghost:hover { color: var(--nc-neon); }
color: var(--nc-neon);
}
/* ============================================================ /* ============================================================
STATE MESSAGES (loading / error) STATE MESSAGES (loading / error)
@@ -346,21 +324,11 @@
} }
@keyframes slideIn { @keyframes slideIn {
from { from { opacity: 0; transform: translateY(-10px); }
opacity: 0; to { opacity: 1; transform: translateY(0); }
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.completion-title { .completion-title { font-size: 15px; font-weight: 700; color: var(--nc-neon); }
font-size: 15px;
font-weight: 700;
color: var(--nc-neon);
}
.completion-sub { .completion-sub {
font-family: var(--nc-mono); font-family: var(--nc-mono);
@@ -379,16 +347,11 @@
text-decoration: none; text-decoration: none;
border-bottom: 1px solid var(--nc-border-strong); border-bottom: 1px solid var(--nc-border-strong);
padding-bottom: 2px; padding-bottom: 2px;
transition: transition: color 0.15s, border-color 0.15s;
color 0.15s,
border-color 0.15s;
flex-shrink: 0; flex-shrink: 0;
} }
.completion-link:hover { .completion-link:hover { color: var(--nc-neon); border-color: var(--nc-neon-soft); }
color: var(--nc-neon);
border-color: var(--nc-neon-soft);
}
/* ============================================================ /* ============================================================
MAIN GRID MAIN GRID
@@ -424,14 +387,10 @@
:host-context(html:not([data-theme='dark'])) .status-strip { :host-context(html:not([data-theme='dark'])) .status-strip {
background: rgba(255, 61, 187, 0.06); background: rgba(255, 61, 187, 0.06);
border-color: rgba(255, 61, 187, 0.2); border-color: rgba(255, 61, 187, 0.20);
} }
.status-left { .status-left { display: inline-flex; align-items: center; gap: 10px; }
display: inline-flex;
align-items: center;
gap: 10px;
}
.status-pulse { .status-pulse {
width: 8px; width: 8px;
@@ -444,22 +403,11 @@
} }
@keyframes pulse { @keyframes pulse {
0%, 0%, 100% { opacity: 1; transform: scale(1); }
100% { 50% { opacity: 0.35; transform: scale(0.7); }
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.7);
}
} }
.status-text { .status-text { font-weight: 500; color: var(--nc-text); letter-spacing: 0.02em; }
font-weight: 500;
color: var(--nc-text);
letter-spacing: 0.02em;
}
.status-side { .status-side {
font-family: var(--nc-mono); font-family: var(--nc-mono);
@@ -477,32 +425,22 @@
border: 1px solid var(--nc-border); border: 1px solid var(--nc-border);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
box-shadow: box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 69, 200, 0.06);
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 { :host-context(html:not([data-theme='dark'])) .board-wrap {
box-shadow: box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 61, 187, 0.08);
0 8px 40px rgba(0, 0, 0, 0.35),
0 0 0 1px rgba(255, 61, 187, 0.08);
} }
.board-wrap.reviewing { .board-wrap.reviewing {
border-color: var(--nc-warning-soft); border-color: var(--nc-warning-soft);
box-shadow: box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 177, 58, 0.18);
0 8px 40px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 177, 58, 0.18);
} }
/* ============================================================ /* ============================================================
SIDE COLUMN SIDE COLUMN
============================================================ */ ============================================================ */
.side { .side { display: flex; flex-direction: column; gap: 12px; }
display: flex;
flex-direction: column;
gap: 12px;
}
.side-card { .side-card {
background: var(--nc-surface); background: var(--nc-surface);
@@ -521,9 +459,7 @@
user-select: none; user-select: none;
} }
.side-card-summary::-webkit-details-marker { .side-card-summary::-webkit-details-marker { display: none; }
display: none;
}
.side-card-title { .side-card-title {
font-size: 11px; font-size: 11px;
@@ -541,17 +477,9 @@
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.chev { .chev { color: var(--nc-text-dim); flex-shrink: 0; transition: transform 0.2s; }
color: var(--nc-text-dim); .side-card[open] .chev { transform: rotate(180deg); }
flex-shrink: 0; .side-card[open] .side-card-summary { border-bottom: 1px solid var(--nc-border); }
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 { .side-card-body {
padding: 14px 16px; padding: 14px 16px;
@@ -563,10 +491,7 @@
/* ============================================================ /* ============================================================
UCI INPUT UCI INPUT
============================================================ */ ============================================================ */
.uci-row { .uci-row { display: flex; gap: 6px; }
display: flex;
gap: 6px;
}
.uci-input { .uci-input {
flex: 1; flex: 1;
@@ -581,19 +506,10 @@
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.uci-input:focus { .uci-input:focus { border-color: var(--nc-neon-soft); }
border-color: var(--nc-neon-soft); .uci-input::placeholder { color: var(--nc-text-dim); }
}
.uci-input::placeholder {
color: var(--nc-text-dim);
}
.uci-hint { .uci-hint { margin: 0; font-size: 11px; color: var(--nc-text-dim); line-height: 1.4; }
margin: 0;
font-size: 11px;
color: var(--nc-text-dim);
line-height: 1.4;
}
/* ============================================================ /* ============================================================
BOARD DESIGN SEGMENTED CONTROL BOARD DESIGN SEGMENTED CONTROL
@@ -615,16 +531,10 @@
font-family: var(--nc-sans); font-family: var(--nc-sans);
letter-spacing: 0.08em; letter-spacing: 0.08em;
cursor: pointer; cursor: pointer;
transition: transition: background 0.15s, color 0.15s;
background 0.15s,
color 0.15s;
} }
.seg-btn.active { .seg-btn.active { background: var(--nc-neon); color: #fff; font-weight: 700; }
background: var(--nc-neon);
color: #fff;
font-weight: 700;
}
/* ============================================================ /* ============================================================
RESIGN CONFIRM OVERLAY RESIGN CONFIRM OVERLAY
@@ -678,9 +588,7 @@
font-weight: 700; font-weight: 700;
} }
.btn-danger-solid:hover { .btn-danger-solid:hover { opacity: 0.88; }
opacity: 0.88;
}
/* ============================================================ /* ============================================================
TOAST TOAST
@@ -699,41 +607,23 @@
letter-spacing: 0.08em; letter-spacing: 0.08em;
z-index: 500; z-index: 500;
opacity: 0; opacity: 0;
transition: transition: opacity 0.2s, transform 0.2s;
opacity 0.2s,
transform 0.2s;
pointer-events: none; pointer-events: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
} }
.toast.show { .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ============================================================ /* ============================================================
RESPONSIVE RESPONSIVE
============================================================ */ ============================================================ */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.layout { .layout { grid-template-columns: 1fr; }
grid-template-columns: 1fr; .board-col { max-width: 560px; margin: 0 auto; }
}
.board-col {
max-width: 560px;
margin: 0 auto;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.page { .page { padding: 16px 16px 48px; }
padding: 16px 16px 48px; .game-header { flex-direction: column; align-items: flex-start; gap: 12px; }
} .game-title h1 { font-size: 20px; }
.game-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.game-title h1 {
font-size: 20px;
}
} }
+35 -114
View File
@@ -1,25 +1,16 @@
<app-promotion-dialog <app-promotion-dialog
[isOpen]="facade.isPromotionDialogOpen" [isOpen]="facade.isPromotionDialogOpen"
(promotionSelected)="facade.onPromotionSelected($event)" (promotionSelected)="facade.onPromotionSelected($event)"
(closed)="facade.onPromotionClosed()" (closed)="facade.onPromotionClosed()" />
/>
<div class="game-shell"> <div class="game-shell">
<div class="page"> <div class="page">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav class="crumb" aria-label="Breadcrumb"> <nav class="crumb" aria-label="Breadcrumb">
<a routerLink="/" class="crumb-link"> <a routerLink="/" class="crumb-link">
<svg <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
width="11" <polyline points="15 18 9 12 15 6"/>
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg> </svg>
Back to lobby Back to lobby
</a> </a>
@@ -40,18 +31,9 @@
<span class="game-id"> <span class="game-id">
ID <strong>{{ facade.gameId }}</strong> ID <strong>{{ facade.gameId }}</strong>
<button class="copy-btn" type="button" title="Copy game ID" (click)="copyGameId()"> <button class="copy-btn" type="button" title="Copy game ID" (click)="copyGameId()">
<svg <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
width="13" <rect x="9" y="9" width="13" height="13" rx="2"/>
height="13" <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg> </svg>
</button> </button>
</span> </span>
@@ -64,20 +46,11 @@
<div class="header-actions"> <div class="header-actions">
<button class="btn-ghost" type="button" (click)="flipBoard()"> <button class="btn-ghost" type="button" (click)="flipBoard()">
<svg <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
width="14" <polyline points="17 1 21 5 17 9"/>
height="14" <path d="M3 11V9a4 4 0 0 1 4-4h14"/>
viewBox="0 0 24 24" <polyline points="7 23 3 19 7 15"/>
fill="none" <path d="M21 13v2a4 4 0 0 1-4 4H3"/>
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg> </svg>
Flip Flip
</button> </button>
@@ -113,11 +86,7 @@
</div> </div>
} }
@if ( @if (!facade.isGameFinished && ((whiteTimerMs !== null && whiteTimerMs <= 0) || (blackTimerMs !== null && blackTimerMs <= 0))) {
!facade.isGameFinished &&
((whiteTimerMs !== null && whiteTimerMs <= 0) ||
(blackTimerMs !== null && blackTimerMs <= 0))
) {
<div class="completion-banner completion-banner--timeout"> <div class="completion-banner completion-banner--timeout">
<span class="completion-title">Time's up!</span> <span class="completion-title">Time's up!</span>
<span class="completion-sub">Waiting for server to confirm result…</span> <span class="completion-sub">Waiting for server to confirm result…</span>
@@ -126,8 +95,10 @@
<!-- Main layout --> <!-- Main layout -->
<div class="layout"> <div class="layout">
<!-- BOARD COLUMN --> <!-- BOARD COLUMN -->
<div class="board-col"> <div class="board-col">
<!-- Opponent (top) --> <!-- Opponent (top) -->
<app-player-card <app-player-card
[name]="blackPlayerName" [name]="blackPlayerName"
@@ -135,8 +106,7 @@
color="black" color="black"
[isActive]="!flipped ? facade.state.turn === 'black' : facade.state.turn === 'white'" [isActive]="!flipped ? facade.state.turn === 'black' : facade.state.turn === 'white'"
[clockDisplay]="!flipped ? blackClock : whiteClock" [clockDisplay]="!flipped ? blackClock : whiteClock"
[isLowTime]="!flipped ? isLowTimeBlack : isLowTimeWhite" [isLowTime]="!flipped ? isLowTimeBlack : isLowTimeWhite" />
/>
<!-- Status strip --> <!-- Status strip -->
<div class="status-strip"> <div class="status-strip">
@@ -156,8 +126,7 @@
[selectedSquare]="facade.isReviewing ? null : facade.selectedSquare" [selectedSquare]="facade.isReviewing ? null : facade.selectedSquare"
[highlightedSquares]="facade.isReviewing ? [] : facade.highlightedSquares" [highlightedSquares]="facade.isReviewing ? [] : facade.highlightedSquares"
[boardTheme]="boardTheme" [boardTheme]="boardTheme"
(squareSelected)="facade.onBoardSquareSelected($event)" (squareSelected)="facade.onBoardSquareSelected($event)" />
/>
</div> </div>
<!-- Current player (bottom) --> <!-- Current player (bottom) -->
@@ -167,8 +136,7 @@
color="white" color="white"
[isActive]="!flipped ? facade.state.turn === 'white' : facade.state.turn === 'black'" [isActive]="!flipped ? facade.state.turn === 'white' : facade.state.turn === 'black'"
[clockDisplay]="!flipped ? whiteClock : blackClock" [clockDisplay]="!flipped ? whiteClock : blackClock"
[isLowTime]="!flipped ? isLowTimeWhite : isLowTimeBlack" [isLowTime]="!flipped ? isLowTimeWhite : isLowTimeBlack" />
/>
<!-- Board action buttons --> <!-- Board action buttons -->
<app-board-actions-bar <app-board-actions-bar
@@ -176,55 +144,35 @@
[isGameFinished]="facade.isGameFinished" [isGameFinished]="facade.isGameFinished"
(takeback)="onTakeback()" (takeback)="onTakeback()"
(offerDraw)="onOfferDraw()" (offerDraw)="onOfferDraw()"
(resign)="onResign()" (resign)="onResign()" />
/>
</div> </div>
<!-- SIDE COLUMN --> <!-- SIDE COLUMN -->
<aside class="side"> <aside class="side">
<!-- Move history (collapsible) --> <!-- Move history (collapsible) -->
<details class="side-card" open> <details class="side-card" open>
<summary class="side-card-summary"> <summary class="side-card-summary">
<span class="side-card-title">Move History</span> <span class="side-card-title">Move History</span>
<span class="side-card-meta">{{ facade.state.moves.length }} plies</span> <span class="side-card-meta">{{ facade.state.moves.length }} plies</span>
<svg <svg class="chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
class="chev" <polyline points="6 9 12 15 18 9"/>
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg> </svg>
</summary> </summary>
<app-move-history <app-move-history
[moves]="facade.state.moves" [moves]="facade.state.moves"
[viewingPly]="facade.viewingPly" [viewingPly]="facade.viewingPly"
(navigate)="facade.navigateHistory($event)" (navigate)="facade.navigateHistory($event)"
(navigateToPly)="facade.navigateToPly($event)" (navigateToPly)="facade.navigateToPly($event)" />
/>
</details> </details>
<!-- Play move (collapsible) --> <!-- Play move (collapsible) -->
<details class="side-card" open> <details class="side-card" open>
<summary class="side-card-summary"> <summary class="side-card-summary">
<span class="side-card-title">Play Move</span> <span class="side-card-title">Play Move</span>
<svg <svg class="chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
class="chev" <polyline points="6 9 12 15 18 9"/>
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg> </svg>
</summary> </summary>
<div class="side-card-body"> <div class="side-card-body">
@@ -237,11 +185,8 @@
autocomplete="off" autocomplete="off"
[value]="facade.moveInput" [value]="facade.moveInput"
(input)="facade.moveInput = $any($event.target).value" (input)="facade.moveInput = $any($event.target).value"
(keydown.enter)="facade.submitMove()" (keydown.enter)="facade.submitMove()" />
/> <button class="btn btn-primary" type="button" (click)="facade.submitMove()">Send</button>
<button class="btn btn-primary" type="button" (click)="facade.submitMove()">
Send
</button>
</div> </div>
<p class="uci-hint">Click a piece on the board to see legal targets.</p> <p class="uci-hint">Click a piece on the board to see legal targets.</p>
</div> </div>
@@ -251,47 +196,25 @@
<details class="side-card"> <details class="side-card">
<summary class="side-card-summary"> <summary class="side-card-summary">
<span class="side-card-title">Board Design</span> <span class="side-card-title">Board Design</span>
<svg <svg class="chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
class="chev" <polyline points="6 9 12 15 18 9"/>
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg> </svg>
</summary> </summary>
<div class="side-card-body"> <div class="side-card-body">
<div class="seg" role="tablist" aria-label="Board theme"> <div class="seg" role="tablist" aria-label="Board theme">
<button <button class="seg-btn" [class.active]="boardTheme === 'arabian'" role="tab" (click)="setBoardTheme('arabian')">Arabian</button>
class="seg-btn" <button class="seg-btn" [class.active]="boardTheme === 'classic'" role="tab" (click)="setBoardTheme('classic')">Classic</button>
[class.active]="boardTheme === 'arabian'"
role="tab"
(click)="setBoardTheme('arabian')"
>
Arabian
</button>
<button
class="seg-btn"
[class.active]="boardTheme === 'classic'"
role="tab"
(click)="setBoardTheme('classic')"
>
Classic
</button>
</div> </div>
</div> </div>
</details> </details>
<!-- Export (collapsible) --> <!-- Export (collapsible) -->
<app-export-panel [fen]="facade.state.fen" [pgn]="facade.state.pgn" /> <app-export-panel [fen]="facade.state.fen" [pgn]="facade.state.pgn" />
</aside> </aside>
</div> </div>
} }
</div> </div>
</div> </div>
@@ -303,9 +226,7 @@
<p class="confirm-sub">Your opponent will be declared the winner.</p> <p class="confirm-sub">Your opponent will be declared the winner.</p>
<div class="confirm-actions"> <div class="confirm-actions">
<button class="btn" type="button" (click)="facade.cancelResign()">Cancel</button> <button class="btn" type="button" (click)="facade.cancelResign()">Cancel</button>
<button class="btn btn-danger-solid" type="button" (click)="facade.confirmResign()"> <button class="btn btn-danger-solid" type="button" (click)="facade.confirmResign()">Yes, resign</button>
Yes, resign
</button>
</div> </div>
</div> </div>
</div> </div>
+12 -22
View File
@@ -28,7 +28,7 @@ const BOARD_THEME_KEY = 'nowchess.boardTheme';
], ],
providers: [GameFacade], providers: [GameFacade],
templateUrl: './game.component.html', templateUrl: './game.component.html',
styleUrl: './game.component.css', styleUrl: './game.component.css'
}) })
export class GameComponent implements OnInit, OnDestroy { export class GameComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
@@ -141,15 +141,11 @@ export class GameComponent implements OnInit, OnDestroy {
// ── Copy helpers ───────────────────────────────────────────── // ── Copy helpers ─────────────────────────────────────────────
copyGameId(): void { copyGameId(): void {
void navigator.clipboard void navigator.clipboard?.writeText(this.facade.gameId).then(() => this.showToast('Game ID copied'));
?.writeText(this.facade.gameId)
.then(() => this.showToast('Game ID copied'));
} }
copyUrl(): void { copyUrl(): void {
void navigator.clipboard void navigator.clipboard?.writeText(window.location.href).then(() => this.showToast('Link copied'));
?.writeText(window.location.href)
.then(() => this.showToast('Link copied'));
} }
// ── Board actions ───────────────────────────────────────────── // ── Board actions ─────────────────────────────────────────────
@@ -170,9 +166,7 @@ export class GameComponent implements OnInit, OnDestroy {
if (ms === null) return '--:--'; if (ms === null) return '--:--';
if (ms < 0) return '—'; if (ms < 0) return '—';
const totalSeconds = Math.floor(ms / 1000); const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60) const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
.toString()
.padStart(2, '0');
const seconds = (totalSeconds % 60).toString().padStart(2, '0'); const seconds = (totalSeconds % 60).toString().padStart(2, '0');
return `${minutes}:${seconds}`; return `${minutes}:${seconds}`;
} }
@@ -194,19 +188,15 @@ export class GameComponent implements OnInit, OnDestroy {
const elapsed = Math.max(0, Date.now() - this.facade.clockSyncedAt); const elapsed = Math.max(0, Date.now() - this.facade.clockSyncedAt);
const activeIsWhite = state!.turn === 'white'; const activeIsWhite = state!.turn === 'white';
this.whiteTimerMs = this.whiteTimerMs = clock.whiteRemainingMs < 0
clock.whiteRemainingMs < 0 ? -1
? -1 : Math.max(0, clock.whiteRemainingMs - (activeIsWhite ? elapsed : 0));
: Math.max(0, clock.whiteRemainingMs - (activeIsWhite ? elapsed : 0)); this.blackTimerMs = clock.blackRemainingMs < 0
this.blackTimerMs = ? -1
clock.blackRemainingMs < 0 : Math.max(0, clock.blackRemainingMs - (!activeIsWhite ? elapsed : 0));
? -1
: Math.max(0, clock.blackRemainingMs - (!activeIsWhite ? elapsed : 0));
if ( if ((this.whiteTimerMs !== null && this.whiteTimerMs <= 0 && clock.whiteRemainingMs > 0) ||
(this.whiteTimerMs !== null && this.whiteTimerMs <= 0 && clock.whiteRemainingMs > 0) || (this.blackTimerMs !== null && this.blackTimerMs <= 0 && clock.blackRemainingMs > 0)) {
(this.blackTimerMs !== null && this.blackTimerMs <= 0 && clock.blackRemainingMs > 0)
) {
this.facade.errorMessage = ''; this.facade.errorMessage = '';
} }
} }
+19 -36
View File
@@ -32,7 +32,7 @@ export class GameFacade implements OnDestroy {
private boardSelection: BoardSelection = { private boardSelection: BoardSelection = {
selectedSquare: null, selectedSquare: null,
highlightedSquares: [], highlightedSquares: [],
selectedSquareMoves: [], selectedSquareMoves: []
}; };
private pendingPromotionMoves: LegalMove[] = []; private pendingPromotionMoves: LegalMove[] = [];
@@ -82,19 +82,11 @@ export class GameFacade implements OnDestroy {
let next: number; let next: number;
switch (direction) { switch (direction) {
case 'first': case 'first': next = this.sessionStartPly; break;
next = this.sessionStartPly; case 'prev': next = Math.max(this.sessionStartPly, current - 1); break;
break; case 'next': next = Math.min(totalPly, current + 1); break;
case 'prev':
next = Math.max(this.sessionStartPly, current - 1);
break;
case 'next':
next = Math.min(totalPly, current + 1);
break;
case 'last': case 'last':
default: default: next = totalPly; break;
next = totalPly;
break;
} }
if (next === totalPly) { if (next === totalPly) {
@@ -121,17 +113,12 @@ export class GameFacade implements OnDestroy {
} }
// Handle move selection // Handle move selection
if ( if (this.boardSelection.selectedSquare && this.boardSelection.highlightedSquares.includes(square)) {
this.boardSelection.selectedSquare && const selectedMove = this.boardSelection.selectedSquareMoves.find((move) => move.to === square);
this.boardSelection.highlightedSquares.includes(square)
) {
const selectedMove = this.boardSelection.selectedSquareMoves.find(
(move) => move.to === square,
);
if (selectedMove) { if (selectedMove) {
// If multiple promotion outcomes exist for the target, ask player to choose one. // If multiple promotion outcomes exist for the target, ask player to choose one.
const promotionMoves = this.boardSelection.selectedSquareMoves.filter( const promotionMoves = this.boardSelection.selectedSquareMoves.filter(
(move) => move.to === square && !!move.promotion, (move) => move.to === square && !!move.promotion
); );
if (promotionMoves.length > 0) { if (promotionMoves.length > 0) {
this.pendingPromotionMoves = promotionMoves; this.pendingPromotionMoves = promotionMoves;
@@ -155,13 +142,13 @@ export class GameFacade implements OnDestroy {
this.boardSelection = { this.boardSelection = {
selectedSquare: square, selectedSquare: square,
highlightedSquares: moves.map((move) => move.to), highlightedSquares: moves.map((move) => move.to),
selectedSquareMoves: moves, selectedSquareMoves: moves
}; };
}, },
(error) => { (error) => {
this.errorMessage = error; this.errorMessage = error;
this.boardSelection = this.boardSelectionService.clearSelection(); this.boardSelection = this.boardSelectionService.clearSelection();
}, }
); );
this.boardSelection = newSelection; this.boardSelection = newSelection;
} }
@@ -200,19 +187,17 @@ export class GameFacade implements OnDestroy {
}, },
(error) => { (error) => {
this.errorMessage = error; this.errorMessage = error;
}, }
); );
}, },
error: (error) => { error: (error) => {
this.errorMessage = getErrorMessage(error, 'Move rejected.'); this.errorMessage = getErrorMessage(error, 'Move rejected.');
}, }
}); });
} }
onPromotionSelected(promotionPiece: 'queen' | 'rook' | 'bishop' | 'knight'): void { onPromotionSelected(promotionPiece: 'queen' | 'rook' | 'bishop' | 'knight'): void {
const selectedPromotionMove = this.pendingPromotionMoves.find( const selectedPromotionMove = this.pendingPromotionMoves.find((move) => move.promotion === promotionPiece);
(move) => move.promotion === promotionPiece,
);
if (!selectedPromotionMove) { if (!selectedPromotionMove) {
this.errorMessage = 'Selected promotion move is unavailable.'; this.errorMessage = 'Selected promotion move is unavailable.';
this.isPromotionDialogOpen = false; this.isPromotionDialogOpen = false;
@@ -249,7 +234,7 @@ export class GameFacade implements OnDestroy {
.subscribe({ .subscribe({
error: (error) => { error: (error) => {
this.errorMessage = getErrorMessage(error, 'Could not resign.'); this.errorMessage = getErrorMessage(error, 'Could not resign.');
}, }
}); });
} }
@@ -263,7 +248,7 @@ export class GameFacade implements OnDestroy {
}, },
(error) => { (error) => {
this.errorMessage = error; this.errorMessage = error;
}, }
); );
} }
@@ -277,7 +262,7 @@ export class GameFacade implements OnDestroy {
}, },
(error) => { (error) => {
this.errorMessage = error; this.errorMessage = error;
}, }
); );
} }
@@ -307,7 +292,7 @@ export class GameFacade implements OnDestroy {
error: (error) => { error: (error) => {
this.errorMessage = getErrorMessage(error, `Could not load game ${this.gameId}.`); this.errorMessage = getErrorMessage(error, `Could not load game ${this.gameId}.`);
this.loading = false; this.loading = false;
}, }
}); });
} }
@@ -315,9 +300,7 @@ export class GameFacade implements OnDestroy {
this.streamService.startStreaming( this.streamService.startStreaming(
this.gameId, this.gameId,
(event) => this.applyStreamEvent(event), (event) => this.applyStreamEvent(event),
() => { () => { /* polling fallback — not an error */ }
/* polling fallback — not an error */
},
); );
} }
@@ -373,7 +356,7 @@ export class GameFacade implements OnDestroy {
}, },
(error) => { (error) => {
this.errorMessage = error; this.errorMessage = error;
}, }
); );
} }
+20 -59
View File
@@ -9,8 +9,8 @@
--nc-border-strong: rgba(255, 255, 255, 0.15); --nc-border-strong: rgba(255, 255, 255, 0.15);
--nc-success: #5ee5a1; --nc-success: #5ee5a1;
--nc-danger: #ff7a7a; --nc-danger: #ff7a7a;
--nc-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--nc-mono: 'JetBrains Mono', 'Fira Code', monospace; --nc-mono: "JetBrains Mono", "Fira Code", monospace;
display: block; display: block;
min-height: 100vh; min-height: 100vh;
@@ -63,13 +63,9 @@
transition: color 0.15s; transition: color 0.15s;
} }
.crumb-link:hover { .crumb-link:hover { color: var(--nc-neon); }
color: var(--nc-neon);
}
.crumb-sep { .crumb-sep { opacity: 0.4; }
opacity: 0.4;
}
.crumb-current { .crumb-current {
color: var(--nc-text-muted); color: var(--nc-text-muted);
@@ -117,23 +113,17 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
transition: transition: background 0.15s, color 0.15s;
background 0.15s,
color 0.15s;
} }
.tab-btn:hover { .tab-btn:hover { color: var(--nc-text); }
color: var(--nc-text);
}
.tab-btn.active { .tab-btn.active {
background: var(--nc-neon); background: var(--nc-neon);
color: #1a0014; color: #1a0014;
} }
:host-context(html:not([data-theme='dark'])) .tab-btn.active { :host-context(html:not([data-theme='dark'])) .tab-btn.active { color: #fff; }
color: #fff;
}
.tab-badge { .tab-badge {
background: rgba(255, 255, 255, 0.25); background: rgba(255, 255, 255, 0.25);
@@ -164,15 +154,8 @@
} }
@keyframes pulse-ring { @keyframes pulse-ring {
0%, 0%, 100% { opacity: 1; transform: scale(1); }
100% { 50% { opacity: 0.4; transform: scale(0.6); }
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(0.6);
}
} }
/* ── Empty state ────────────────────────── */ /* ── Empty state ────────────────────────── */
@@ -227,13 +210,9 @@
transition: filter 0.15s; transition: filter 0.15s;
} }
:host-context(html:not([data-theme='dark'])) .btn-primary { :host-context(html:not([data-theme='dark'])) .btn-primary { color: #fff; }
color: #fff;
}
.btn-primary:hover { .btn-primary:hover { filter: brightness(1.1); }
filter: brightness(1.1);
}
/* ── Game list ──────────────────────────── */ /* ── Game list ──────────────────────────── */
.game-list { .game-list {
@@ -255,13 +234,9 @@
transition: background 0.12s; transition: background 0.12s;
} }
.game-row:last-child { .game-row:last-child { border-bottom: none; }
border-bottom: none;
}
.game-row:hover { .game-row:hover { background: rgba(255, 255, 255, 0.03); }
background: rgba(255, 255, 255, 0.03);
}
:host-context(html:not([data-theme='dark'])) .game-row:hover { :host-context(html:not([data-theme='dark'])) .game-row:hover {
background: rgba(192, 38, 211, 0.04); background: rgba(192, 38, 211, 0.04);
@@ -282,9 +257,7 @@
font-weight: 600; font-weight: 600;
} }
.player { .player { color: var(--nc-text); }
color: var(--nc-text);
}
.vs-sep { .vs-sep {
font-size: 10px; font-size: 10px;
@@ -318,17 +291,11 @@
background: var(--nc-text-dim); background: var(--nc-text-dim);
} }
.status-text { .status-text { color: var(--nc-text-muted); }
color: var(--nc-text-muted);
}
.meta-sep { .meta-sep { opacity: 0.4; }
opacity: 0.4;
}
.meta-item { .meta-item { color: var(--nc-text-dim); }
color: var(--nc-text-dim);
}
.game-id-label { .game-id-label {
font-size: 10px; font-size: 10px;
@@ -365,13 +332,9 @@
color: #1a0014; color: #1a0014;
} }
:host-context(html:not([data-theme='dark'])) .btn-resume { :host-context(html:not([data-theme='dark'])) .btn-resume { color: #fff; }
color: #fff;
}
.btn-resume:hover { .btn-resume:hover { filter: brightness(1.1); }
filter: brightness(1.1);
}
.btn-view { .btn-view {
background: transparent; background: transparent;
@@ -394,9 +357,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: transition: color 0.15s, border-color 0.15s;
color 0.15s,
border-color 0.15s;
} }
.btn-remove:hover { .btn-remove:hover {
+37 -111
View File
@@ -1,18 +1,11 @@
<div class="games-shell"> <div class="games-shell">
<div class="page"> <div class="page">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav class="crumb" aria-label="Breadcrumb"> <nav class="crumb" aria-label="Breadcrumb">
<a routerLink="/" class="crumb-link"> <a routerLink="/" class="crumb-link">
<svg <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
width="11" stroke-linecap="round" stroke-linejoin="round">
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" /> <polyline points="15 18 9 12 15 6" />
</svg> </svg>
Back to lobby Back to lobby
@@ -27,25 +20,15 @@
<!-- Tabs --> <!-- Tabs -->
<div class="tabs" role="tablist"> <div class="tabs" role="tablist">
<button <button type="button" class="tab-btn" [class.active]="tab === 'active'" role="tab"
type="button" (click)="setTab('active')">
class="tab-btn"
[class.active]="tab === 'active'"
role="tab"
(click)="setTab('active')"
>
Active Active
@if (activeGames.length > 0) { @if (activeGames.length > 0) {
<span class="tab-badge">{{ activeGames.length }}</span> <span class="tab-badge">{{ activeGames.length }}</span>
} }
</button> </button>
<button <button type="button" class="tab-btn" [class.active]="tab === 'history'" role="tab"
type="button" (click)="setTab('history')">
class="tab-btn"
[class.active]="tab === 'history'"
role="tab"
(click)="setTab('history')"
>
History History
</button> </button>
</div> </div>
@@ -58,21 +41,14 @@
Loading games… Loading games…
</div> </div>
} @else if (tab === 'active') { } @else if (tab === 'active') {
@if (activeGames.length === 0) { @if (activeGames.length === 0) {
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon"> <div class="empty-icon">
<svg <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"
width="32" stroke-linecap="round" stroke-linejoin="round">
height="32" <rect x="2" y="2" width="20" height="20" rx="2"/>
viewBox="0 0 24 24" <path d="M8 12h8M12 8v8"/>
fill="none"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="2" y="2" width="20" height="20" rx="2" />
<path d="M8 12h8M12 8v8" />
</svg> </svg>
</div> </div>
<p class="empty-title">No active games</p> <p class="empty-title">No active games</p>
@@ -100,38 +76,16 @@
</div> </div>
<div class="game-row-actions"> <div class="game-row-actions">
<button type="button" class="btn-resume" (click)="resumeGame(game.gameId)"> <button type="button" class="btn-resume" (click)="resumeGame(game.gameId)">
<svg <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
width="12" stroke-linecap="round" stroke-linejoin="round">
height="12" <polygon points="5 3 19 12 5 21 5 3"/>
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg> </svg>
Resume Resume
</button> </button>
<button <button type="button" class="btn-remove" title="Remove from list" (click)="removeGame(game.gameId)">
type="button" <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
class="btn-remove" stroke-linecap="round" stroke-linejoin="round">
title="Remove from list" <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
(click)="removeGame(game.gameId)"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
</button> </button>
</div> </div>
@@ -139,26 +93,18 @@
} }
</div> </div>
} }
} @else { } @else {
@if (finishedGames.length === 0) { @if (finishedGames.length === 0) {
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon"> <div class="empty-icon">
<svg <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"
width="32" stroke-linecap="round" stroke-linejoin="round">
height="32" <path d="M14.5 17.5L3 6"/>
viewBox="0 0 24 24" <path d="M13 19l6-6"/><path d="M16 16l4 4"/>
fill="none" <path d="M19 21l2-2"/><path d="M15 5l4 4"/>
stroke="currentColor" <path d="M21 3l-3 1-4 4"/>
stroke-width="1.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14.5 17.5L3 6" />
<path d="M13 19l6-6" />
<path d="M16 16l4 4" />
<path d="M19 21l2-2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1-4 4" />
</svg> </svg>
</div> </div>
<p class="empty-title">No game history yet</p> <p class="empty-title">No game history yet</p>
@@ -185,39 +131,17 @@
</div> </div>
<div class="game-row-actions"> <div class="game-row-actions">
<button type="button" class="btn-view" (click)="resumeGame(game.gameId)"> <button type="button" class="btn-view" (click)="resumeGame(game.gameId)">
<svg <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
width="12" stroke-linecap="round" stroke-linejoin="round">
height="12" <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
viewBox="0 0 24 24" <circle cx="12" cy="12" r="3"/>
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg> </svg>
View View
</button> </button>
<button <button type="button" class="btn-remove" title="Remove from list" (click)="removeGame(game.gameId)">
type="button" <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
class="btn-remove" stroke-linecap="round" stroke-linejoin="round">
title="Remove from list" <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
(click)="removeGame(game.gameId)"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
</button> </button>
</div> </div>
@@ -225,6 +149,8 @@
} }
</div> </div>
} }
} }
</div> </div>
</div> </div>
+11 -17
View File
@@ -11,11 +11,7 @@ import { GameFull, GameStatus } from '../../models/game.models';
type GamesTab = 'active' | 'history'; type GamesTab = 'active' | 'history';
const FINISHED_STATUSES: GameStatus[] = [ const FINISHED_STATUSES: GameStatus[] = [
'checkmate', 'checkmate', 'stalemate', 'resign', 'draw', 'insufficientMaterial'
'stalemate',
'resign',
'draw',
'insufficientMaterial',
]; ];
@Component({ @Component({
@@ -23,7 +19,7 @@ const FINISHED_STATUSES: GameStatus[] = [
standalone: true, standalone: true,
imports: [RouterLink], imports: [RouterLink],
templateUrl: './games.component.html', templateUrl: './games.component.html',
styleUrl: './games.component.css', styleUrl: './games.component.css'
}) })
export class GamesComponent implements OnInit { export class GamesComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -38,9 +34,11 @@ export class GamesComponent implements OnInit {
finishedGames: GameFull[] = []; finishedGames: GameFull[] = [];
ngOnInit(): void { ngOnInit(): void {
this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { this.authService.currentUser$
if (!user) void this.router.navigate(['/']); .pipe(takeUntilDestroyed(this.destroyRef))
}); .subscribe((user) => {
if (!user) void this.router.navigate(['/']);
});
this.loadGames(); this.loadGames();
} }
@@ -70,7 +68,7 @@ export class GamesComponent implements OnInit {
drawOffered: 'Draw Offered', drawOffered: 'Draw Offered',
fiftyMoveAvailable: 'In Progress', fiftyMoveAvailable: 'In Progress',
promotionPending: 'In Progress', promotionPending: 'In Progress',
insufficientMaterial: 'Draw', insufficientMaterial: 'Draw'
}; };
return labels[status] ?? status; return labels[status] ?? status;
} }
@@ -89,16 +87,12 @@ export class GamesComponent implements OnInit {
const requests = ids.map((id) => const requests = ids.map((id) =>
this.gameApi.getGame(id).pipe( this.gameApi.getGame(id).pipe(
catchError((err: unknown) => { catchError((err: unknown) => {
if ( if (typeof err === 'object' && err !== null && (err as { status: number }).status === 404) {
typeof err === 'object' &&
err !== null &&
(err as { status: number }).status === 404
) {
this.gameHistory.removeGame(id); this.gameHistory.removeGame(id);
} }
return of(null); return of(null);
}), })
), )
); );
forkJoin(requests) forkJoin(requests)
+19 -66
View File
@@ -15,24 +15,15 @@
} }
.building-structure { .building-structure {
background: linear-gradient( background: linear-gradient(135deg, var(--bldg-body) 0%, var(--bldg-mid) 50%, var(--bldg-lit) 100%);
135deg,
var(--bldg-body) 0%,
var(--bldg-mid) 50%,
var(--bldg-lit) 100%
);
border: 2px solid var(--dlg-border); border: 2px solid var(--dlg-border);
border-radius: 8px; border-radius: 8px;
box-shadow: box-shadow: var(--bb-glow), inset 0 0 20px rgba(0, 210, 255, 0.1);
var(--bb-glow),
inset 0 0 20px rgba(0, 210, 255, 0.1);
overflow: hidden; overflow: hidden;
} }
.cityscape-shell.sunset .building-structure { .cityscape-shell.sunset .building-structure {
box-shadow: box-shadow: var(--bb-glow), inset 0 0 20px rgba(255, 120, 40, 0.1);
var(--bb-glow),
inset 0 0 20px rgba(255, 120, 40, 0.1);
} }
.building-top { .building-top {
@@ -64,24 +55,12 @@
gap: 0; gap: 0;
padding: 20px; padding: 20px;
min-height: 200px; min-height: 200px;
background: linear-gradient( background: linear-gradient(90deg, rgba(0, 210, 255, 0.03) 0%, transparent 15%, transparent 85%, rgba(0, 210, 255, 0.03) 100%);
90deg,
rgba(0, 210, 255, 0.03) 0%,
transparent 15%,
transparent 85%,
rgba(0, 210, 255, 0.03) 100%
);
align-items: center; align-items: center;
} }
.cityscape-shell.sunset .building-main { .cityscape-shell.sunset .building-main {
background: linear-gradient( background: linear-gradient(90deg, rgba(255, 120, 40, 0.05) 0%, transparent 15%, transparent 85%, rgba(255, 120, 40, 0.05) 100%);
90deg,
rgba(255, 120, 40, 0.05) 0%,
transparent 15%,
transparent 85%,
rgba(255, 120, 40, 0.05) 100%
);
} }
.building-side { .building-side {
@@ -115,9 +94,7 @@
background: linear-gradient(135deg, var(--win-cool) 0%, var(--win-cool) 100%); background: linear-gradient(135deg, var(--win-cool) 0%, var(--win-cool) 100%);
border: 1px solid rgba(0, 210, 255, 0.5); border: 1px solid rgba(0, 210, 255, 0.5);
border-radius: 2px; border-radius: 2px;
box-shadow: box-shadow: 0 0 10px rgba(0, 210, 255, 0.4), inset 0 0 8px rgba(0, 210, 255, 0.2);
0 0 10px rgba(0, 210, 255, 0.4),
inset 0 0 8px rgba(0, 210, 255, 0.2);
position: relative; position: relative;
animation: windowFlicker 4s ease-in-out infinite; animation: windowFlicker 4s ease-in-out infinite;
} }
@@ -125,37 +102,25 @@
.cityscape-shell.sunset .window { .cityscape-shell.sunset .window {
background: linear-gradient(135deg, var(--win-warm) 0%, var(--win-warm) 100%); background: linear-gradient(135deg, var(--win-warm) 0%, var(--win-warm) 100%);
border-color: rgba(255, 120, 40, 0.5); border-color: rgba(255, 120, 40, 0.5);
box-shadow: box-shadow: 0 0 10px rgba(255, 120, 40, 0.4), inset 0 0 8px rgba(255, 120, 40, 0.2);
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; animation: windowFlickerSunset 4s ease-in-out infinite;
} }
@keyframes windowFlicker { @keyframes windowFlicker {
0%, 0%, 100% {
100% { 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);
} }
50% { 50% {
box-shadow: box-shadow: 0 0 15px rgba(0, 210, 255, 0.6), inset 0 0 12px rgba(0, 210, 255, 0.3);
0 0 15px rgba(0, 210, 255, 0.6),
inset 0 0 12px rgba(0, 210, 255, 0.3);
} }
} }
@keyframes windowFlickerSunset { @keyframes windowFlickerSunset {
0%, 0%, 100% {
100% { 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);
} }
50% { 50% {
box-shadow: box-shadow: 0 0 15px rgba(255, 120, 40, 0.6), inset 0 0 12px rgba(255, 120, 40, 0.3);
0 0 15px rgba(255, 120, 40, 0.6),
inset 0 0 12px rgba(255, 120, 40, 0.3);
} }
} }
@@ -172,17 +137,13 @@
border-radius: 4px; border-radius: 4px;
padding: 30px; padding: 30px;
text-align: center; text-align: center;
box-shadow: box-shadow: 0 0 20px rgba(0, 210, 255, 0.3), inset 0 0 15px rgba(0, 210, 255, 0.1);
0 0 20px rgba(0, 210, 255, 0.3),
inset 0 0 15px rgba(0, 210, 255, 0.1);
min-width: 250px; min-width: 250px;
} }
.cityscape-shell.sunset .player-display-window { .cityscape-shell.sunset .player-display-window {
background: linear-gradient(135deg, rgba(182, 64, 255, 0.1) 0%, rgba(182, 64, 255, 0.05) 100%); background: linear-gradient(135deg, rgba(182, 64, 255, 0.1) 0%, rgba(182, 64, 255, 0.05) 100%);
box-shadow: box-shadow: 0 0 20px rgba(255, 64, 249, 0.3), inset 0 0 15px rgba(255, 64, 249, 0.1);
0 0 20px rgba(255, 64, 249, 0.3),
inset 0 0 15px rgba(255, 64, 249, 0.1);
} }
.player-avatar { .player-avatar {
@@ -193,8 +154,7 @@
} }
@keyframes avatarPulse { @keyframes avatarPulse {
0%, 0%, 100% {
100% {
transform: scale(1); transform: scale(1);
} }
50% { 50% {
@@ -372,12 +332,7 @@
.building-door { .building-door {
height: 60px; height: 60px;
background: linear-gradient( background: linear-gradient(90deg, var(--bldg-mid) 0%, var(--bldg-body) 50%, var(--bldg-mid) 100%);
90deg,
var(--bldg-mid) 0%,
var(--bldg-body) 50%,
var(--bldg-mid) 100%
);
border-top: 1px solid rgba(0, 210, 255, 0.2); border-top: 1px solid rgba(0, 210, 255, 0.2);
border-radius: 0 0 8px 8px; border-radius: 0 0 8px 8px;
display: flex; display: flex;
@@ -425,8 +380,7 @@
} }
@keyframes handleGlow { @keyframes handleGlow {
0%, 0%, 100% {
100% {
box-shadow: 0 0 10px rgba(0, 210, 255, 0.6); box-shadow: 0 0 10px rgba(0, 210, 255, 0.6);
} }
50% { 50% {
@@ -435,8 +389,7 @@
} }
@keyframes handleGlowSunset { @keyframes handleGlowSunset {
0%, 0%, 100% {
100% {
box-shadow: 0 0 10px rgba(255, 120, 40, 0.6); box-shadow: 0 0 10px rgba(255, 120, 40, 0.6);
} }
50% { 50% {
+61 -74
View File
@@ -1,4 +1,5 @@
<div class="cityscape-shell" [class.sunset]="isSunsetMode"> <div class="cityscape-shell" [class.sunset]="isSunsetMode">
<div class="scene"> <div class="scene">
<div class="sky"> <div class="sky">
<div class="stars-layer"> <div class="stars-layer">
@@ -21,85 +22,71 @@
<div class="main-layer"> <div class="main-layer">
<div class="profile-building-container"> <div class="profile-building-container">
@if (currentUser; as user) { @if (currentUser; as user) {
<!-- Player Building with Info Inside --> <!-- Player Building with Info Inside -->
<div class="building-wrapper"> <div class="building-wrapper">
<div class="building-structure"> <div class="building-structure">
<!-- Building top --> <!-- Building top -->
<div class="building-top"></div> <div class="building-top"></div>
<!-- Building main section with user inside --> <!-- Building main section with user inside -->
<div class="building-main"> <div class="building-main">
<!-- Left side windows --> <!-- Left side windows -->
<div class="building-side left-side"> <div class="building-side left-side">
<div class="window"></div> <div class="window"></div>
<div class="window"></div> <div class="window"></div>
<div class="window"></div> <div class="window"></div>
<div class="window"></div> <div class="window"></div>
</div> </div>
<!-- Center section - User Info --> <!-- Center section - User Info -->
<div class="building-center"> <div class="building-center">
<div class="player-display-window"> <div class="player-display-window">
<div class="player-avatar">👤</div> <div class="player-avatar">👤</div>
<div <div class="player-name" (click)="copyUsername(user.username)" [class.copied]="usernameCopied" title="Click to copy">{{ user.username }}</div>
class="player-name" <div class="player-id-label">PLAYER ID</div>
(click)="copyUsername(user.username)" <div class="player-id-value" (click)="copyPlayerId(user.id)" [class.copied]="idCopied" title="Click to copy">{{ user.id }}</div>
[class.copied]="usernameCopied" @if (idCopied) {
title="Click to copy" <div class="copy-notification">✓ COPIED</div>
> }
{{ user.username }} @if (usernameCopied) {
</div> <div class="copy-notification">✓ COPIED</div>
<div class="player-id-label">PLAYER ID</div> }
<div
class="player-id-value"
(click)="copyPlayerId(user.id)"
[class.copied]="idCopied"
title="Click to copy"
>
{{ user.id }}
</div>
@if (idCopied) {
<div class="copy-notification">✓ COPIED</div>
}
@if (usernameCopied) {
<div class="copy-notification">✓ COPIED</div>
}
</div>
</div>
<!-- Right side windows -->
<div class="building-side right-side">
<div class="window"></div>
<div class="window"></div>
<div class="window"></div>
<div class="window"></div>
</div> </div>
</div> </div>
<!-- Building base with stats --> <!-- Right side windows -->
<div class="building-base"> <div class="building-side right-side">
<div class="stat-panel"> <div class="window"></div>
<div class="stat"> <div class="window"></div>
<span class="stat-label">RATING</span> <div class="window"></div>
<span class="stat-value">{{ user.rating }}</span> <div class="window"></div>
</div>
<div class="stat-divider"></div>
<div class="stat">
<span class="stat-label">MEMBER SINCE</span>
<span class="stat-value">{{ user.createdAt | date: 'MMM dd, yyyy' }}</span>
</div>
</div>
</div>
<!-- Building door -->
<div class="building-door">
<div class="door-handle"></div>
</div> </div>
</div> </div>
<!-- Back button --> <!-- Building base with stats -->
<button type="button" class="back-btn" (click)="goBack()">← BACK</button> <div class="building-base">
<div class="stat-panel">
<div class="stat">
<span class="stat-label">RATING</span>
<span class="stat-value">{{ user.rating }}</span>
</div>
<div class="stat-divider"></div>
<div class="stat">
<span class="stat-label">MEMBER SINCE</span>
<span class="stat-value">{{ user.createdAt | date: 'MMM dd, yyyy' }}</span>
</div>
</div>
</div>
<!-- Building door -->
<div class="building-door">
<div class="door-handle"></div>
</div>
</div> </div>
<!-- Back button -->
<button type="button" class="back-btn" (click)="goBack()">← BACK</button>
</div>
} }
</div> </div>
</div> </div>
+13 -11
View File
@@ -19,7 +19,7 @@ interface BackgroundBuilding {
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
templateUrl: './profile.component.html', templateUrl: './profile.component.html',
styleUrl: './profile.component.css', styleUrl: './profile.component.css'
}) })
export class ProfileComponent implements OnInit { export class ProfileComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -36,12 +36,14 @@ export class ProfileComponent implements OnInit {
bgBuildings: BackgroundBuilding[] = []; bgBuildings: BackgroundBuilding[] = [];
ngOnInit(): void { ngOnInit(): void {
this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { this.authService.currentUser$
this.currentUser = user; .pipe(takeUntilDestroyed(this.destroyRef))
if (!user) { .subscribe((user) => {
this.router.navigate(['']); this.currentUser = user;
} if (!user) {
}); this.router.navigate(['']);
}
});
this.themeService.darkMode$ this.themeService.darkMode$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@@ -85,8 +87,8 @@ export class ProfileComponent implements OnInit {
left: `${Math.random() * 100}%`, left: `${Math.random() * 100}%`,
top: `${Math.random() * 62}%`, top: `${Math.random() * 62}%`,
'--d': `${(Math.random() * 3 + 1.5).toFixed(1)}s`, '--d': `${(Math.random() * 3 + 1.5).toFixed(1)}s`,
'--dl': `${-(Math.random() * 6).toFixed(1)}s`, '--dl': `${-(Math.random() * 6).toFixed(1)}s`
}, }
}; };
}); });
} }
@@ -115,11 +117,11 @@ export class ProfileComponent implements OnInit {
{ l: '85.5%', w: '9%', h: '32vh' }, { l: '85.5%', w: '9%', h: '32vh' },
{ l: '88%', w: '5%', h: '20vh' }, { l: '88%', w: '5%', h: '20vh' },
{ l: '91%', w: '3%', h: '16vh' }, { l: '91%', w: '3%', h: '16vh' },
{ l: '94%', w: '6%', h: '27vh' }, { l: '94%', w: '6%', h: '27vh' }
]; ];
this.bgBuildings = specs.map((spec) => ({ 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 }
})); }));
} }
} }
@@ -10,7 +10,7 @@
--nc-success: #5ee5a1; --nc-success: #5ee5a1;
--nc-danger: #ff7a7a; --nc-danger: #ff7a7a;
--nc-warn: #ffd166; --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; display: block;
min-height: 100vh; min-height: 100vh;
@@ -33,10 +33,7 @@
--nc-warn: #b45309; --nc-warn: #b45309;
} }
.t-shell { .t-shell { padding-top: 72px; min-height: 100vh; }
padding-top: 72px;
min-height: 100vh;
}
.page { .page {
max-width: 760px; max-width: 760px;
@@ -46,341 +43,151 @@
/* Breadcrumb */ /* Breadcrumb */
.crumb { .crumb {
display: flex; display: flex; align-items: center; gap: 8px;
align-items: center; margin-bottom: 28px; font-size: 11px;
gap: 8px; color: var(--nc-text-dim); letter-spacing: 0.06em;
margin-bottom: 28px;
font-size: 11px;
color: var(--nc-text-dim);
letter-spacing: 0.06em;
} }
.crumb-link { .crumb-link {
display: inline-flex; display: inline-flex; align-items: center; gap: 4px;
align-items: center; color: var(--nc-text-dim); text-decoration: none;
gap: 4px;
color: var(--nc-text-dim);
text-decoration: none;
transition: color 0.15s; transition: color 0.15s;
} }
.crumb-link:hover { .crumb-link:hover { color: var(--nc-neon); }
color: var(--nc-neon); .crumb-sep { opacity: 0.35; }
} .crumb-current { color: var(--nc-text-muted); }
.crumb-sep {
opacity: 0.35;
}
.crumb-current {
color: var(--nc-text-muted);
}
/* Header */ /* Header */
.page-header { .page-header { margin-bottom: 28px; }
margin-bottom: 28px;
}
.page-title-row { .page-title-row {
display: flex; display: flex; align-items: center; justify-content: space-between;
align-items: center;
justify-content: space-between;
margin-bottom: 16px; margin-bottom: 16px;
} }
.page-title { .page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
font-size: 22px;
font-weight: 700;
margin: 0;
letter-spacing: -0.02em;
}
.btn-new { .btn-new {
display: inline-flex; display: inline-flex; align-items: center; gap: 6px;
align-items: center; padding: 7px 14px; border-radius: 8px; border: none;
gap: 6px; background: var(--nc-neon); color: #fff;
padding: 7px 14px; font-size: 13px; font-weight: 600; cursor: pointer;
border-radius: 8px;
border: none;
background: var(--nc-neon);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.btn-new:hover { .btn-new:hover { opacity: 0.85; }
opacity: 0.85;
}
/* Create dialog */ /* Create dialog */
.dialog-overlay { .dialog-overlay {
position: fixed; position: fixed; inset: 0; z-index: 200;
inset: 0; background: rgba(0,0,0,0.55); backdrop-filter: blur(4px);
z-index: 200; display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 20px; padding: 20px;
} }
.dialog-card { .dialog-card {
background: var(--nc-bg); background: var(--nc-bg); border: 1px solid var(--nc-border-strong);
border: 1px solid var(--nc-border-strong); border-radius: 16px; padding: 24px; width: 100%; max-width: 420px;
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 420px;
} }
.dialog-head { .dialog-head {
display: flex; display: flex; justify-content: space-between; align-items: center;
justify-content: space-between;
align-items: center;
margin-bottom: 20px; margin-bottom: 20px;
} }
.dialog-brand { .dialog-brand { font-size: 14px; font-weight: 700; color: var(--nc-neon); }
font-size: 14px;
font-weight: 700;
color: var(--nc-neon);
}
.dialog-close { .dialog-close {
background: none; background: none; border: none; cursor: pointer;
border: none; font-size: 20px; line-height: 1; color: var(--nc-text-muted);
cursor: pointer;
font-size: 20px;
line-height: 1;
color: var(--nc-text-muted);
padding: 0 4px; padding: 0 4px;
} }
.dialog-close:hover { .dialog-close:hover { color: var(--nc-text); }
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;
.dialog-field { letter-spacing: 0.06em; color: var(--nc-text-muted); }
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 { .dialog-input {
width: 100%; width: 100%; padding: 8px 10px; border-radius: 8px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--nc-border-strong); border: 1px solid var(--nc-border-strong);
background: rgba(255, 255, 255, 0.04); background: rgba(255,255,255,0.04); color: var(--nc-text);
color: var(--nc-text); font-size: 14px; box-sizing: border-box;
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 { .dialog-toggle {
display: flex; display: flex; align-items: center; gap: 10px; cursor: pointer;
align-items: center; margin-bottom: 20px; user-select: none;
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 { .toggle-track {
width: 36px; width: 36px; height: 20px; border-radius: 10px;
height: 20px; background: var(--nc-border-strong); flex-shrink: 0;
border-radius: 10px; transition: background 0.2s; position: relative;
background: var(--nc-border-strong);
flex-shrink: 0;
transition: background 0.2s;
position: relative;
} }
.toggle-track::after { .toggle-track::after {
content: ''; content: ''; position: absolute; top: 3px; left: 3px;
position: absolute; width: 14px; height: 14px; border-radius: 50%;
top: 3px; background: var(--nc-text-muted); transition: transform 0.2s, background 0.2s;
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 { .dialog-error {
font-size: 13px; font-size: 13px; color: var(--nc-danger);
color: var(--nc-danger); background: rgba(255,122,122,0.1); border-radius: 8px;
background: rgba(255, 122, 122, 0.1); padding: 10px 12px; margin-bottom: 16px;
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 { .btn-ghost {
padding: 8px 16px; padding: 8px 16px; border-radius: 8px; border: 1px solid var(--nc-border-strong);
border-radius: 8px; background: transparent; color: var(--nc-text-muted); font-size: 13px; cursor: pointer;
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 { .btn-primary {
padding: 8px 18px; padding: 8px 18px; border-radius: 8px; border: none;
border-radius: 8px; background: var(--nc-neon); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
border: none;
background: var(--nc-neon);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.btn-primary:disabled { .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
opacity: 0.5;
cursor: not-allowed;
}
/* Tabs */ /* Tabs */
.tabs { .tabs { display: flex; gap: 4px; }
display: flex;
gap: 4px;
}
.tab-btn { .tab-btn {
display: inline-flex; display: inline-flex; align-items: center; gap: 6px;
align-items: center; padding: 6px 14px; border-radius: 8px; border: none;
gap: 6px; background: transparent; color: var(--nc-text-muted);
padding: 6px 14px; font-size: 13px; font-weight: 500; cursor: pointer;
border-radius: 8px; transition: background 0.15s, color 0.15s;
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 { .tab-badge {
display: inline-flex; display: inline-flex; align-items: center; justify-content: center;
align-items: center; min-width: 18px; height: 18px; padding: 0 5px;
justify-content: center; border-radius: 9px; background: var(--nc-border-strong);
min-width: 18px; font-size: 10px; font-weight: 700; color: var(--nc-text-muted);
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 */ /* States */
.state-msg { .state-msg {
display: flex; display: flex; align-items: center; gap: 10px;
align-items: center; padding: 24px 0; color: var(--nc-text-muted); font-size: 13px;
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 { .pulse {
width: 8px; width: 8px; height: 8px; border-radius: 50%;
height: 8px; background: var(--nc-neon); flex-shrink: 0;
border-radius: 50%;
background: var(--nc-neon);
flex-shrink: 0;
animation: pulse 1.4s ease-in-out infinite; animation: pulse 1.4s ease-in-out infinite;
} }
@keyframes pulse { @keyframes pulse {
0%, 0%, 100% { opacity: 1; transform: scale(1); }
100% { 50% { opacity: 0.4; transform: scale(0.85); }
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(0.85);
}
} }
.empty-state { .empty-state {
display: flex; display: flex; flex-direction: column; align-items: center;
flex-direction: column; gap: 8px; padding: 64px 0; text-align: center;
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 */ /* Tournament list */
.t-list { .t-list { display: flex; flex-direction: column; gap: 8px; }
display: flex;
flex-direction: column;
gap: 8px;
}
.t-card { .t-card {
border: 1px solid var(--nc-border); border: 1px solid var(--nc-border);
@@ -388,348 +195,135 @@
background: var(--nc-surface); background: var(--nc-surface);
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
transition: transition: border-color 0.15s, background 0.15s;
border-color 0.15s,
background 0.15s;
} }
.t-card:hover, .t-card:hover, .t-card.expanded {
.t-card.expanded {
border-color: var(--nc-border-strong); border-color: var(--nc-border-strong);
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
} }
.t-card:focus-visible { .t-card:focus-visible { outline: 2px solid var(--nc-neon); outline-offset: 2px; }
outline: 2px solid var(--nc-neon);
outline-offset: 2px;
}
.t-action-btn { .t-action-btn {
padding: 5px 12px; padding: 5px 12px; border-radius: 7px; font-size: 12px; font-weight: 600;
border-radius: 7px; cursor: pointer; border: none; transition: opacity 0.15s; white-space: nowrap;
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 dialog extras */
.join-hint { .join-hint { font-size: 13px; color: var(--nc-text-muted); margin: 0 0 16px; line-height: 1.5; }
font-size: 13px; .join-empty { font-size: 13px; color: var(--nc-text-muted); margin: 0 0 8px; }
color: var(--nc-text-muted); .dialog-loading { display: flex; align-items: center; gap: 8px;
margin: 0 0 16px; font-size: 13px; color: var(--nc-text-muted); padding: 12px 0; }
line-height: 1.5; .bot-pick-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
}
.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 { .bot-pick-row {
display: flex; display: flex; align-items: center; gap: 10px;
align-items: center; padding: 10px 12px; border-radius: 8px;
gap: 10px; border: 1px solid var(--nc-border); background: var(--nc-surface);
padding: 10px 12px; cursor: pointer; text-align: left; width: 100%;
border-radius: 8px; transition: border-color 0.15s, background 0.15s;
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 { .bot-pick-avatar {
width: 30px; width: 30px; height: 30px; border-radius: 50%; background: var(--nc-neon);
height: 30px; color: #fff; display: flex; align-items: center; justify-content: center;
border-radius: 50%; font-size: 13px; font-weight: 700; flex-shrink: 0;
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 { .t-card-main {
display: flex; display: flex; justify-content: space-between; align-items: center;
justify-content: space-between; padding: 14px 16px; gap: 12px;
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 { .t-status-dot {
width: 8px; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
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 { .t-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
display: flex; .t-name { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
flex-direction: column; .t-meta { font-size: 11px; color: var(--nc-text-muted); }
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 { .winner-badge {
font-size: 11px; font-size: 11px; font-weight: 600; color: var(--nc-warn);
font-weight: 600; padding: 3px 8px; border-radius: 6px;
color: var(--nc-warn);
padding: 3px 8px;
border-radius: 6px;
background: rgba(255, 209, 102, 0.12); background: rgba(255, 209, 102, 0.12);
} }
.chevron { .chevron { color: var(--nc-text-dim); transition: transform 0.2s; }
color: var(--nc-text-dim); .chevron.open { transform: rotate(180deg); }
transition: transform 0.2s;
}
.chevron.open {
transform: rotate(180deg);
}
/* Detail panel */ /* Detail panel */
.t-detail { .t-detail {
border-top: 1px solid var(--nc-border); border-top: 1px solid var(--nc-border);
padding: 16px; padding: 16px;
display: flex; display: flex; flex-direction: column; gap: 20px;
flex-direction: column;
gap: 20px;
} }
.detail-section { .detail-section { display: flex; flex-direction: column; gap: 10px; }
display: flex;
flex-direction: column;
gap: 10px;
}
.detail-heading { .detail-heading {
font-size: 11px; font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
font-weight: 700; text-transform: uppercase; color: var(--nc-text-muted); margin: 0;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--nc-text-muted);
margin: 0;
} }
.no-standings { .no-standings { font-size: 12px; color: var(--nc-text-dim); margin: 0; }
font-size: 12px;
color: var(--nc-text-dim);
margin: 0;
}
/* Standings table */ /* Standings table */
.standings-table { .standings-table {
width: 100%; width: 100%; border-collapse: collapse; font-size: 13px;
border-collapse: collapse;
font-size: 13px;
} }
.standings-table th { .standings-table th {
text-align: left; text-align: left; padding: 6px 8px;
padding: 6px 8px; font-size: 10px; font-weight: 700; letter-spacing: 0.06em;
font-size: 10px; text-transform: uppercase; color: var(--nc-text-dim);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--nc-text-dim);
border-bottom: 1px solid var(--nc-border); border-bottom: 1px solid var(--nc-border);
} }
.standings-table td { .standings-table td { padding: 8px 8px; border-bottom: 1px solid var(--nc-border); }
padding: 8px 8px; .standings-table tr:last-child td { border-bottom: none; }
border-bottom: 1px solid var(--nc-border); .top-row td { color: var(--nc-text); }
} .standings-table tr:not(.top-row) td { color: var(--nc-text-muted); }
.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 { .col-rank { width: 40px; font-size: 14px; }
width: 40px; .col-pts { width: 48px; font-weight: 700; color: var(--nc-neon) !important; }
font-size: 14px; .col-tb { width: 52px; color: var(--nc-text-dim) !important; font-size: 12px; }
} .col-games { width: 64px; }
.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 { .wdl { font-size: 12px; font-variant-numeric: tabular-nums; }
font-size: 12px; .w { color: var(--nc-success); }
font-variant-numeric: tabular-nums; .d { color: var(--nc-text-muted); }
} .l { color: var(--nc-danger); }
.w {
color: var(--nc-success);
}
.d {
color: var(--nc-text-muted);
}
.l {
color: var(--nc-danger);
}
/* Pairings */ /* Pairings */
.pairings-list { .pairings-list { display: flex; flex-direction: column; gap: 6px; }
display: flex;
flex-direction: column;
gap: 6px;
}
.pairing-row { .pairing-row {
display: flex; display: flex; align-items: center; gap: 8px;
align-items: center; padding: 8px 10px; border-radius: 8px;
gap: 8px; background: rgba(255,255,255,0.025);
padding: 8px 10px; font-size: 13px; transition: background 0.15s;
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 { .pairing-ongoing {
display: inline-flex; display: inline-flex; align-items: center; gap: 5px;
align-items: center; margin-left: auto; font-size: 10px; font-weight: 700;
gap: 5px; color: var(--nc-success); letter-spacing: 0.06em; text-transform: uppercase;
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; }
@@ -1,17 +1,10 @@
<div class="t-shell"> <div class="t-shell">
<div class="page"> <div class="page">
<nav class="crumb" aria-label="Breadcrumb"> <nav class="crumb" aria-label="Breadcrumb">
<a routerLink="/" class="crumb-link"> <a routerLink="/" class="crumb-link">
<svg <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
width="11" stroke-linecap="round" stroke-linejoin="round">
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" /> <polyline points="15 18 9 12 15 6" />
</svg> </svg>
Back to lobby Back to lobby
@@ -25,52 +18,24 @@
<h1 class="page-title">Tournaments</h1> <h1 class="page-title">Tournaments</h1>
@if (currentUser) { @if (currentUser) {
<button type="button" class="btn-new" (click)="openCreateDialog()"> <button type="button" class="btn-new" (click)="openCreateDialog()">
<svg <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
width="13" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
height="13" <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
New tournament New tournament
</button> </button>
} }
</div> </div>
<div class="tabs" role="tablist"> <div class="tabs" role="tablist">
<button <button type="button" class="tab-btn" [class.active]="tab === 'started'" (click)="setTab('started')">
type="button"
class="tab-btn"
[class.active]="tab === 'started'"
(click)="setTab('started')"
>
Live Live
@if (started.length > 0) { @if (started.length > 0) { <span class="tab-badge live-badge">{{ started.length }}</span> }
<span class="tab-badge live-badge">{{ started.length }}</span>
}
</button> </button>
<button <button type="button" class="tab-btn" [class.active]="tab === 'created'" (click)="setTab('created')">
type="button"
class="tab-btn"
[class.active]="tab === 'created'"
(click)="setTab('created')"
>
Upcoming Upcoming
@if (created.length > 0) { @if (created.length > 0) { <span class="tab-badge">{{ created.length }}</span> }
<span class="tab-badge">{{ created.length }}</span>
}
</button> </button>
<button <button type="button" class="tab-btn" [class.active]="tab === 'finished'" (click)="setTab('finished')">
type="button"
class="tab-btn"
[class.active]="tab === 'finished'"
(click)="setTab('finished')"
>
Finished Finished
</button> </button>
</div> </div>
@@ -81,22 +46,12 @@
} @else if (activeList.length === 0) { } @else if (activeList.length === 0) {
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon"> <div class="empty-icon">
<svg <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"
width="32" stroke-linecap="round" stroke-linejoin="round">
height="32" <path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/>
viewBox="0 0 24 24" <path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/>
fill="none" <path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/>
stroke="currentColor" <path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>
stroke-width="1.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
<path d="M4 22h16" />
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
</svg> </svg>
</div> </div>
<p class="empty-title">No tournaments here</p> <p class="empty-title">No tournaments here</p>
@@ -105,14 +60,10 @@
} @else { } @else {
<div class="t-list"> <div class="t-list">
@for (t of activeList; track t.id) { @for (t of activeList; track t.id) {
<div <div class="t-card" [class.expanded]="selectedTournament?.id === t.id"
class="t-card" (click)="selectTournament(t)" role="button" tabindex="0"
[class.expanded]="selectedTournament?.id === t.id" (keydown.enter)="selectTournament(t)">
(click)="selectTournament(t)"
role="button"
tabindex="0"
(keydown.enter)="selectTournament(t)"
>
<div class="t-card-main"> <div class="t-card-main">
<div class="t-card-left"> <div class="t-card-left">
<span class="t-status-dot" [class]="'dot-' + t.status"></span> <span class="t-status-dot" [class]="'dot-' + t.status"></span>
@@ -120,13 +71,9 @@
<span class="t-name">{{ t.fullName }}</span> <span class="t-name">{{ t.fullName }}</span>
<span class="t-meta"> <span class="t-meta">
{{ clockDisplay(t) }} · {{ t.nbRounds }} rounds · {{ clockDisplay(t) }} · {{ t.nbRounds }} rounds ·
@if (t.status === 'started') { @if (t.status === 'started') { Round {{ t.round }}/{{ t.nbRounds }} · }
Round {{ t.round }}/{{ t.nbRounds }} ·
}
{{ t.nbPlayers }} player{{ t.nbPlayers === 1 ? '' : 's' }} {{ t.nbPlayers }} player{{ t.nbPlayers === 1 ? '' : 's' }}
@if (t.rated) { @if (t.rated) { · Rated }
· Rated
}
</span> </span>
</div> </div>
</div> </div>
@@ -136,42 +83,28 @@
} }
@if (currentUser && t.status === 'created') { @if (currentUser && t.status === 'created') {
@if (t.createdBy === currentUser.id) { @if (t.createdBy === currentUser.id) {
<button <button type="button" class="t-action-btn t-btn-start"
type="button"
class="t-action-btn t-btn-start"
[disabled]="startingId === t.id" [disabled]="startingId === t.id"
(click)="startTournament($event, t)" (click)="startTournament($event, t)">
>
{{ startingId === t.id ? '…' : 'Start' }} {{ startingId === t.id ? '…' : 'Start' }}
</button> </button>
} }
<button <button type="button" class="t-action-btn t-btn-join"
type="button" (click)="openJoinDialog($event, t.id)">
class="t-action-btn t-btn-join"
(click)="openJoinDialog($event, t.id)"
>
Join with bot Join with bot
</button> </button>
} }
<svg <svg class="chevron" [class.open]="selectedTournament?.id === t.id"
class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none"
[class.open]="selectedTournament?.id === t.id" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
width="14" <polyline points="6 9 12 15 18 9"/>
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg> </svg>
</div> </div>
</div> </div>
@if (selectedTournament?.id === t.id) { @if (selectedTournament?.id === t.id) {
<div class="t-detail" (click)="$event.stopPropagation()"> <div class="t-detail" (click)="$event.stopPropagation()">
<!-- Leaderboard --> <!-- Leaderboard -->
@if (t.standing.players.length > 0) { @if (t.standing.players.length > 0) {
<section class="detail-section"> <section class="detail-section">
@@ -195,9 +128,7 @@
<td class="col-tb">{{ r.tieBreak }}</td> <td class="col-tb">{{ r.tieBreak }}</td>
<td class="col-games"> <td class="col-games">
<span class="wdl"> <span class="wdl">
<span class="w">{{ r.wins }}</span <span class="w">{{ r.wins }}</span>/<span class="d">{{ r.draws }}</span>/<span class="l">{{ r.losses }}</span>
>/<span class="d">{{ r.draws }}</span
>/<span class="l">{{ r.losses }}</span>
</span> </span>
</td> </td>
</tr> </tr>
@@ -218,24 +149,19 @@
} @else if (pairings && pairings.pairings.length > 0) { } @else if (pairings && pairings.pairings.length > 0) {
<div class="pairings-list"> <div class="pairings-list">
@for (p of pairings.pairings; track p.id) { @for (p of pairings.pairings; track p.id) {
<div <div class="pairing-row" [class.is-watchable]="!!p.gameId"
class="pairing-row" (click)="p.gameId && watchGame(p.gameId)">
[class.is-watchable]="!!p.gameId"
(click)="p.gameId && watchGame(p.gameId)"
>
<span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span> <span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span>
<span class="pairing-vs">vs</span> <span class="pairing-vs">vs</span>
<span class="pairing-black">{{ p.black.name }}</span> <span class="pairing-black">{{ p.black.name }}</span>
@if (p.winner) { @if (p.winner) {
<span class="pairing-result" [class]="'result-' + p.winner"> <span class="pairing-result" [class]="'result-' + p.winner">
{{ {{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '10' : '01' }}
p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '10' : '01'
}}
</span> </span>
} @else if (p.gameId) { } @else if (p.gameId) {
<span class="pairing-ongoing"> <span class="pairing-ongoing">
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"> <svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10"/>
</svg> </svg>
Watch Watch
</span> </span>
@@ -248,127 +174,100 @@
} }
</section> </section>
} }
</div> </div>
} }
</div> </div>
} }
</div> </div>
} }
</div> </div>
</div> </div>
@if (joinDialogTournamentId) { @if (joinDialogTournamentId) {
<div class="dialog-overlay" (click)="closeJoinDialog()"> <div class="dialog-overlay" (click)="closeJoinDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()"> <div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-head"> <div class="dialog-head">
<span class="dialog-brand">Join with a bot</span> <span class="dialog-brand">Join with a bot</span>
<button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button> <button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button>
</div>
<p class="join-hint">
Select one of your bots to enter this tournament. Its token will be rotated to authenticate
the join.
</p>
@if (botsLoading) {
<div class="dialog-loading"><span class="pulse"></span>Loading bots…</div>
} @else if (userBots.length === 0) {
<p class="join-empty">
You have no bots yet. Go to <strong>Bots</strong> in the nav to create one first.
</p>
} @else {
<div class="bot-pick-list">
@for (bot of userBots; track bot.id) {
<button
type="button"
class="bot-pick-row"
[disabled]="!!joiningBotId"
(click)="joinWithBot(bot)"
>
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
<span class="bot-pick-name">{{ bot.name }}</span>
<span class="bot-pick-rating">{{ bot.rating }}</span>
@if (joiningBotId === bot.id) {
<span class="bot-pick-spinner"></span>
}
</button>
}
</div>
}
@if (joinError) {
<div class="dialog-error">{{ joinError }}</div>
}
</div> </div>
<p class="join-hint">Select an official (engine-backed) bot to enter this tournament. These are the bots that actually play their moves.</p>
@if (botsLoading) {
<div class="dialog-loading"><span class="pulse"></span>Loading bots…</div>
} @else if (userBots.length === 0) {
<p class="join-empty">No official bots are available. The official-bots engine service must be running to register them.</p>
} @else {
<div class="bot-pick-list">
@for (bot of userBots; track bot.id) {
<button type="button" class="bot-pick-row"
[disabled]="!!joiningBotId"
(click)="joinWithBot(bot)">
<span class="bot-pick-avatar">{{ bot.name.charAt(0).toUpperCase() }}</span>
<span class="bot-pick-name">{{ bot.name }}</span>
<span class="bot-pick-rating">{{ bot.rating }}</span>
@if (joiningBotId === bot.id) {
<span class="bot-pick-spinner"></span>
}
</button>
}
</div>
}
@if (joinError) {
<div class="dialog-error">{{ joinError }}</div>
}
</div> </div>
</div>
} }
@if (showCreateDialog) { @if (showCreateDialog) {
<div class="dialog-overlay" (click)="closeCreateDialog()"> <div class="dialog-overlay" (click)="closeCreateDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()"> <div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-head"> <div class="dialog-head">
<span class="dialog-brand">New tournament</span> <span class="dialog-brand">New tournament</span>
<button type="button" class="dialog-close" (click)="closeCreateDialog()">×</button> <button type="button" class="dialog-close" (click)="closeCreateDialog()">×</button>
</div>
<form [formGroup]="createForm" (ngSubmit)="submitCreate()">
<div class="dialog-field">
<label class="dialog-label">Name</label>
<input type="text" class="dialog-input" formControlName="name" placeholder="e.g. Friday Blitz Open" />
</div> </div>
<form [formGroup]="createForm" (ngSubmit)="submitCreate()"> <div class="dialog-row">
<div class="dialog-field"> <div class="dialog-field">
<label class="dialog-label">Name</label> <label class="dialog-label">Rounds</label>
<input <input type="number" class="dialog-input" formControlName="nbRounds" min="1" max="20" />
type="text"
class="dialog-input"
formControlName="name"
placeholder="e.g. Friday Blitz Open"
/>
</div> </div>
<div class="dialog-field">
<div class="dialog-row"> <label class="dialog-label">Clock (min)</label>
<div class="dialog-field"> <input type="number" class="dialog-input" formControlName="clockLimitMinutes" min="1" max="60" />
<label class="dialog-label">Rounds</label>
<input type="number" class="dialog-input" formControlName="nbRounds" min="1" max="20" />
</div>
<div class="dialog-field">
<label class="dialog-label">Clock (min)</label>
<input
type="number"
class="dialog-input"
formControlName="clockLimitMinutes"
min="1"
max="60"
/>
</div>
<div class="dialog-field">
<label class="dialog-label">Increment (s)</label>
<input
type="number"
class="dialog-input"
formControlName="clockIncrement"
min="0"
max="60"
/>
</div>
</div> </div>
<div class="dialog-field">
<label class="dialog-toggle"> <label class="dialog-label">Increment (s)</label>
<input type="checkbox" formControlName="rated" /> <input type="number" class="dialog-input" formControlName="clockIncrement" min="0" max="60" />
<span class="toggle-track"></span>
<span class="toggle-label">Rated</span>
</label>
@if (createError) {
<div class="dialog-error">{{ createError }}</div>
}
<div class="dialog-actions">
<button type="button" class="btn-ghost" (click)="closeCreateDialog()">Cancel</button>
<button
type="submit"
class="btn-primary"
[disabled]="createLoading || createForm.invalid"
>
{{ createLoading ? 'Creating…' : 'Create' }}
</button>
</div> </div>
</form> </div>
</div>
<label class="dialog-toggle">
<input type="checkbox" formControlName="rated" />
<span class="toggle-track"></span>
<span class="toggle-label">Rated</span>
</label>
@if (createError) {
<div class="dialog-error">{{ createError }}</div>
}
<div class="dialog-actions">
<button type="button" class="btn-ghost" (click)="closeCreateDialog()">Cancel</button>
<button type="submit" class="btn-primary" [disabled]="createLoading || createForm.invalid">
{{ createLoading ? 'Creating…' : 'Create' }}
</button>
</div>
</form>
</div> </div>
</div>
} }
@@ -17,7 +17,7 @@ type StatusTab = 'started' | 'created' | 'finished';
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, ReactiveFormsModule], imports: [CommonModule, RouterLink, ReactiveFormsModule],
templateUrl: './tournaments.component.html', templateUrl: './tournaments.component.html',
styleUrl: './tournaments.component.css', styleUrl: './tournaments.component.css'
}) })
export class TournamentsComponent implements OnInit { export class TournamentsComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -45,7 +45,7 @@ export class TournamentsComponent implements OnInit {
nbRounds: [4, [Validators.required, Validators.min(1), Validators.max(20)]], nbRounds: [4, [Validators.required, Validators.min(1), Validators.max(20)]],
clockLimitMinutes: [3, [Validators.required, Validators.min(1), Validators.max(60)]], clockLimitMinutes: [3, [Validators.required, Validators.min(1), Validators.max(60)]],
clockIncrement: [0, [Validators.required, Validators.min(0), Validators.max(60)]], clockIncrement: [0, [Validators.required, Validators.min(0), Validators.max(60)]],
rated: [false], rated: [false]
}); });
createLoading = false; createLoading = false;
createError: string | null = null; createError: string | null = null;
@@ -59,20 +59,14 @@ export class TournamentsComponent implements OnInit {
joinError: string | null = null; joinError: string | null = null;
ngOnInit(): void { ngOnInit(): void {
this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((u) => { this.authService.currentUser$
this.currentUser = u; .pipe(takeUntilDestroyed(this.destroyRef))
}); .subscribe(u => { this.currentUser = u; });
this.loadTournaments(); this.loadTournaments();
} }
openCreateDialog(): void { openCreateDialog(): void {
this.createForm.reset({ this.createForm.reset({ name: '', nbRounds: 4, clockLimitMinutes: 3, clockIncrement: 0, rated: false });
name: '',
nbRounds: 4,
clockLimitMinutes: 3,
clockIncrement: 0,
rated: false,
});
this.createError = null; this.createError = null;
this.showCreateDialog = true; this.showCreateDialog = true;
} }
@@ -86,17 +80,17 @@ export class TournamentsComponent implements OnInit {
this.createLoading = true; this.createLoading = true;
this.createError = null; this.createError = null;
this.tournamentService.create(this.createForm.value).subscribe({ this.tournamentService.create(this.createForm.value).subscribe({
next: (t) => { next: t => {
this.createLoading = false; this.createLoading = false;
this.showCreateDialog = false; this.showCreateDialog = false;
this.created = [t, ...this.created]; this.created = [t, ...this.created];
this.tab = 'created'; this.tab = 'created';
this.selectedTournament = null; this.selectedTournament = null;
}, },
error: (err) => { error: err => {
this.createLoading = false; this.createLoading = false;
this.createError = err.error?.message ?? err.error?.error ?? 'Failed to create tournament.'; this.createError = err.error?.message ?? err.error?.error ?? 'Failed to create tournament.';
}, }
}); });
} }
@@ -143,18 +137,15 @@ export class TournamentsComponent implements OnInit {
event.stopPropagation(); event.stopPropagation();
this.startingId = t.id; this.startingId = t.id;
this.tournamentService.start(t.id).subscribe({ this.tournamentService.start(t.id).subscribe({
next: (updated) => { next: updated => {
this.startingId = null; this.startingId = null;
const list = this.created.map((x) => (x.id === t.id ? updated : x)); const list = this.created.map(x => x.id === t.id ? updated : x);
this.created = list.filter((x) => x.status === 'created'); this.created = list.filter(x => x.status === 'created');
if (!this.started.find((x) => x.id === updated.id)) if (!this.started.find(x => x.id === updated.id)) this.started = [updated, ...this.started];
this.started = [updated, ...this.started];
this.selectedTournament = updated; this.selectedTournament = updated;
this.tab = 'started'; this.tab = 'started';
}, },
error: () => { error: () => { this.startingId = null; }
this.startingId = null;
},
}); });
} }
@@ -167,17 +158,11 @@ export class TournamentsComponent implements OnInit {
this.joinDialogTournamentId = tournamentId; this.joinDialogTournamentId = tournamentId;
this.joinError = null; this.joinError = null;
this.botsLoading = true; this.botsLoading = true;
this.botService this.botService.listOfficial()
.list()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: (bots) => { next: bots => { this.userBots = bots; this.botsLoading = false; },
this.userBots = bots; error: () => { this.botsLoading = false; }
this.botsLoading = false;
},
error: () => {
this.botsLoading = false;
},
}); });
} }
@@ -191,41 +176,31 @@ export class TournamentsComponent implements OnInit {
if (!this.joinDialogTournamentId || this.joiningBotId) return; if (!this.joinDialogTournamentId || this.joiningBotId) return;
this.joiningBotId = bot.id; this.joiningBotId = bot.id;
this.joinError = null; this.joinError = null;
this.botService.rotateToken(bot.id).subscribe({ this.tournamentService.join(this.joinDialogTournamentId, bot.id, bot.name).subscribe({
next: (token) => { next: () => {
this.tournamentService.joinWithBotToken(this.joinDialogTournamentId!, token).subscribe({
next: () => {
this.joiningBotId = null;
const tid = this.joinDialogTournamentId!;
this.closeJoinDialog();
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));
if (this.selectedTournament?.id === tid) this.selectedTournament = updated;
});
},
error: (err) => {
this.joiningBotId = null;
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
},
});
},
error: () => {
this.joiningBotId = null; this.joiningBotId = null;
this.joinError = 'Failed to get bot token.'; const tid = this.joinDialogTournamentId!;
this.closeJoinDialog();
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);
if (this.selectedTournament?.id === tid) this.selectedTournament = updated;
});
}, },
error: err => {
this.joiningBotId = null;
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
}
}); });
} }
private loadTournaments(): void { private loadTournaments(): void {
this.tournamentService this.tournamentService.list()
.list()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: (list) => { next: list => {
this.started = list.started; this.started = list.started;
this.created = list.created; this.created = list.created;
this.finished = list.finished; this.finished = list.finished;
@@ -233,25 +208,17 @@ export class TournamentsComponent implements OnInit {
if (this.started.length === 0 && this.created.length > 0) this.tab = 'created'; 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'; else if (this.started.length === 0 && this.finished.length > 0) this.tab = 'finished';
}, },
error: () => { error: () => { this.loading = false; }
this.loading = false;
},
}); });
} }
private loadPairings(id: string, round: number): void { private loadPairings(id: string, round: number): void {
this.pairingsLoading = true; this.pairingsLoading = true;
this.tournamentService this.tournamentService.roundPairings(id, round)
.roundPairings(id, round)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: (p) => { next: p => { this.pairings = p; this.pairingsLoading = false; },
this.pairings = p; error: () => { this.pairingsLoading = false; }
this.pairingsLoading = false;
},
error: () => {
this.pairingsLoading = false;
},
}); });
} }
} }
+23 -69
View File
@@ -1,4 +1,4 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@@ -12,16 +12,13 @@
/> />
<style> <style>
:root { :root {
--sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace; --mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
--neon: #ff45c8; --neon: #ff45c8;
--neon-soft: rgba(255, 69, 200, 0.55); --neon-soft: rgba(255, 69, 200, 0.55);
} }
* { * { box-sizing: border-box; }
box-sizing: border-box; html, body {
}
html,
body {
margin: 0; margin: 0;
padding: 0; padding: 0;
background: #06060d; background: #06060d;
@@ -30,83 +27,40 @@
overflow: hidden; overflow: hidden;
min-height: 100vh; min-height: 100vh;
} }
button, button, input { font-family: var(--sans); }
input { input { color: #fff; }
font-family: var(--sans);
}
input {
color: #fff;
}
input:-webkit-autofill, input:-webkit-autofill,
input:-webkit-autofill:hover, input:-webkit-autofill:hover,
input:-webkit-autofill:focus { input:-webkit-autofill:focus {
-webkit-text-fill-color: #fff; -webkit-text-fill-color: #fff;
-webkit-box-shadow: 0 0 0px 1000px rgba(8, 5, 20, 0) inset; -webkit-box-shadow: 0 0 0px 1000px rgba(8,5,20,0) inset;
transition: background-color 5000s ease-in-out 0s; transition: background-color 5000s ease-in-out 0s;
} }
::-webkit-scrollbar { ::-webkit-scrollbar { width: 8px; height: 8px; }
width: 8px; ::-webkit-scrollbar-track { background: transparent; }
height: 8px; ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; }
} ::-webkit-scrollbar-thumb:hover { background: rgba(255,69,200,0.3); }
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 69, 200, 0.3);
}
@keyframes scanline { @keyframes scanline {
0% { 0% { transform: translateY(-100%); }
transform: translateY(-100%); 100% { transform: translateY(100%); }
}
100% {
transform: translateY(100%);
}
} }
@keyframes pulse-glow { @keyframes pulse-glow {
0%, 0%, 100% { opacity: 0.85; }
100% { 50% { opacity: 1; }
opacity: 0.85;
}
50% {
opacity: 1;
}
} }
@keyframes dialog-in { @keyframes dialog-in {
from { from { opacity: 0; transform: translate(-50%, -48%) scale(0.96); }
opacity: 0; to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
transform: translate(-50%, -48%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
} }
@keyframes backdrop-in { @keyframes backdrop-in {
from { from { opacity: 0; }
opacity: 0; to { opacity: 1; }
}
to {
opacity: 1;
}
} }
@keyframes shake { @keyframes shake {
0%, 0%, 100% { transform: translateX(0); }
100% { 20%, 60% { transform: translateX(-4px); }
transform: translateX(0); 40%, 80% { transform: translateX(4px); }
}
20%,
60% {
transform: translateX(-4px);
}
40%,
80% {
transform: translateX(4px);
}
} }
</style> </style>
</head> </head>
+14 -57
View File
@@ -49,8 +49,8 @@
.cityscape-shell.sunset { .cityscape-shell.sunset {
--sky-1: #4d3279; --sky-1: #4d3279;
--sky-2: #5a5485; --sky-2: #5A5485;
--sky-3: #996c96; --sky-3: #996C96;
--sky-4: #e85040; --sky-4: #e85040;
--sky-5: #f07020; --sky-5: #f07020;
--horizon: #ffaa30; --horizon: #ffaa30;
@@ -93,15 +93,7 @@
.sky { .sky {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: linear-gradient( 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%);
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; transition: background 1.6s ease;
} }
@@ -141,9 +133,7 @@
height: 52px; height: 52px;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle at 36% 34%, #fffbe8, #f5d060 60%, #c8a020); background: radial-gradient(circle at 36% 34%, #fffbe8, #f5d060 60%, #c8a020);
box-shadow: box-shadow: 0 0 28px rgba(245, 210, 80, 0.55), 0 0 70px rgba(240, 190, 40, 0.25);
0 0 28px rgba(245, 210, 80, 0.55),
0 0 70px rgba(240, 190, 40, 0.25);
opacity: var(--moon-vis); opacity: var(--moon-vis);
transition: opacity 1.6s ease; transition: opacity 1.6s ease;
} }
@@ -157,10 +147,7 @@
height: 76px; height: 76px;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle at 50% 50%, #fffce0, #ffd020 40%, #ff9000); background: radial-gradient(circle at 50% 50%, #fffce0, #ffd020 40%, #ff9000);
box-shadow: 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);
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); opacity: var(--sun-vis);
transition: opacity 1.6s ease; transition: opacity 1.6s ease;
z-index: 4; z-index: 4;
@@ -260,21 +247,8 @@
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
background-image: 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( repeating-linear-gradient(90deg, transparent, transparent 12px, rgba(120, 120, 200, 0.08) 12px, rgba(120, 120, 200, 0.08) 14px);
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 { .main-layer {
@@ -337,9 +311,7 @@
height: 9px; height: 9px;
border-radius: 1px; border-radius: 1px;
background: var(--win-off); background: var(--win-off);
transition: transition: background 0.4s ease, box-shadow 0.4s ease;
background 0.4s ease,
box-shadow 0.4s ease;
} }
.w.lc { .w.lc {
@@ -385,10 +357,7 @@
gap: 6px; gap: 6px;
text-align: center; text-align: center;
pointer-events: auto; pointer-events: auto;
transition: transition: border-color 1.6s ease, box-shadow 1.6s ease, background 1.6s ease;
border-color 1.6s ease,
box-shadow 1.6s ease,
background 1.6s ease;
} }
.bb-tag { .bb-tag {
@@ -429,11 +398,7 @@
letter-spacing: 1px; letter-spacing: 1px;
cursor: pointer; cursor: pointer;
box-shadow: var(--btn-glow); box-shadow: var(--btn-glow);
transition: transition: transform 0.2s ease, filter 0.2s ease, background 1.6s ease, box-shadow 1.6s ease;
transform 0.2s ease,
filter 0.2s ease,
background 1.6s ease,
box-shadow 1.6s ease;
} }
.bb-btn:hover:enabled { .bb-btn:hover:enabled {
@@ -454,15 +419,11 @@
border: 1px solid var(--bb-border); border: 1px solid var(--bb-border);
padding: 2px 7px; padding: 2px 7px;
border-radius: 2px; border-radius: 2px;
text-shadow: text-shadow: 0 0 8px currentColor, 0 0 20px currentColor;
0 0 8px currentColor,
0 0 20px currentColor;
box-shadow: 0 0 6px currentColor; box-shadow: 0 0 6px currentColor;
animation: nflicker 9s ease-in-out infinite; animation: nflicker 9s ease-in-out infinite;
display: inline-block; display: inline-block;
transition: transition: color 1.6s ease, border-color 1.6s ease;
color 1.6s ease,
border-color 1.6s ease;
} }
@keyframes nflicker { @keyframes nflicker {
@@ -501,9 +462,7 @@
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: #ff2222; background: #ff2222;
box-shadow: box-shadow: 0 0 10px #ff2222, 0 0 20px rgba(255, 0, 0, 0.4);
0 0 10px #ff2222,
0 0 20px rgba(255, 0, 0, 0.4);
animation: blink 1.6s step-start infinite; animation: blink 1.6s step-start infinite;
} }
@@ -558,9 +517,7 @@
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.85);
cursor: pointer; cursor: pointer;
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
transition: transition: background 0.3s, transform 0.2s;
background 0.3s,
transform 0.2s;
} }
.tgl:hover { .tgl:hover {
+98 -191
View File
@@ -1,4 +1,5 @@
<div class="cityscape-shell" [class.sunset]="isSunsetMode"> <div class="cityscape-shell" [class.sunset]="isSunsetMode">
<div class="scene"> <div class="scene">
<div class="sky"> <div class="sky">
<div class="stars-layer"> <div class="stars-layer">
@@ -19,156 +20,118 @@
</div> </div>
<div class="main-layer"> <div class="main-layer">
<div class="bwrap" style="left: 2.5%; width: 16%"> <div class="bwrap" style="left:2.5%;width:16%;">
<div class="ant" style="height: 38px"></div> <div class="ant" style="height:38px;"></div>
<div class="bpart" style="width: 55%; margin: 0 auto; height: 7vh"> <div class="bpart" style="width:55%;margin:0 auto;height:7vh;">
<div <div class="wins" style="grid-template-columns:repeat(3,1fr);height:100%;align-content:start;">
class="wins"
style="grid-template-columns: repeat(3, 1fr); height: 100%; align-content: start"
>
<div class="w" *ngFor="let win of windows['wA1']" [ngStyle]="win.style"></div> <div class="w" *ngFor="let win of windows['wA1']" [ngStyle]="win.style"></div>
</div> </div>
</div> </div>
<div class="bpart" style="width: 80%; margin: 0 auto; height: 9vh"> <div class="bpart" style="width:80%;margin:0 auto;height:9vh;">
<div <div class="wins" style="grid-template-columns:repeat(4,1fr);align-content:start;height:100%;">
class="wins"
style="grid-template-columns: repeat(4, 1fr); align-content: start; height: 100%"
>
<div class="w" *ngFor="let win of windows['wA2']" [ngStyle]="win.style"></div> <div class="w" *ngFor="let win of windows['wA2']" [ngStyle]="win.style"></div>
</div> </div>
</div> </div>
<div class="bpart" style="height: 36vh"> <div class="bpart" style="height:36vh;">
<div> <div>
<div class="bb"> <div class="bb">
<div class="bb-tag">JOIN</div> <div class="bb-tag">JOIN</div>
<div class="bb-title">JOIN<br />GAME</div> <div class="bb-title">JOIN<br />GAME</div>
<button <button type="button" class="app-btn" (click)="openJoinDialog()" [disabled]="joiningGame">
type="button"
class="app-btn"
(click)="openJoinDialog()"
[disabled]="joiningGame"
>
{{ joiningGame ? 'JOINING...' : 'JOIN GAME →' }} {{ joiningGame ? 'JOINING...' : 'JOIN GAME →' }}
</button> </button>
</div> </div>
</div> </div>
<div class="wins" style="grid-template-columns: repeat(5, 1fr)"> <div class="wins" style="grid-template-columns:repeat(5,1fr);">
<div class="w" *ngFor="let win of windows['wA3']" [ngStyle]="win.style"></div> <div class="w" *ngFor="let win of windows['wA3']" [ngStyle]="win.style"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="bwrap" style="left: 21%; width: 15%"> <div class="bwrap" style="left:21%;width:15%;">
<div class="bpart" style="height: 5vh; width: 90%; margin: 0 auto"> <div class="bpart" style="height:5vh;width:90%;margin:0 auto;">
<div <div class="wins" style="grid-template-columns:repeat(4,1fr);height:100%;align-content:start;">
class="wins"
style="grid-template-columns: repeat(4, 1fr); height: 100%; align-content: start"
>
<div class="w" *ngFor="let win of windows['wB1']" [ngStyle]="win.style"></div> <div class="w" *ngFor="let win of windows['wB1']" [ngStyle]="win.style"></div>
</div> </div>
</div> </div>
<div class="bpart" style="height: 44vh"> <div class="bpart" style="height:44vh;">
<div style="padding: 5px 5px 0"> <div style="padding:5px 5px 0;">
<div style="text-align: center; padding: 4px 0 6px"> <div style="text-align:center;padding:4px 0 6px;">
<span class="neon">OPEN 24/7</span> <span class="neon">OPEN 24/7</span>
</div> </div>
<div class="bb"> <div class="bb">
<div class="bb-tag">BOT</div> <div class="bb-tag">BOT</div>
<div class="bb-title">PLAY WITH<br />A BOT</div> <div class="bb-title">PLAY WITH<br />A BOT</div>
<button <button type="button" class="app-btn" (click)="openDifficultyDialog()" [disabled]="creating">
type="button"
class="app-btn"
(click)="openDifficultyDialog()"
[disabled]="creating"
>
{{ creating ? 'CREATING...' : 'GET STARTED →' }} {{ creating ? 'CREATING...' : 'GET STARTED →' }}
</button> </button>
</div> </div>
</div> </div>
<div class="wins" style="grid-template-columns: repeat(5, 1fr)"> <div class="wins" style="grid-template-columns:repeat(5,1fr);">
<div class="w" *ngFor="let win of windows['wB2']" [ngStyle]="win.style"></div> <div class="w" *ngFor="let win of windows['wB2']" [ngStyle]="win.style"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="bwrap" style="left: 39%; width: 20%"> <div class="bwrap" style="left:39%;width:20%;">
<img class="playerone-gif" src="/assets/arabian-chess/player-one.gif" alt="Player One" /> <img class="playerone-gif" src="/assets/arabian-chess/player-one.gif" alt="Player One" />
<div style="display: flex; justify-content: center; gap: 16px; margin-bottom: 0"> <div style="display:flex;justify-content:center;gap:16px;margin-bottom:0;">
<div class="ant" style="height: 65px; margin-top: -15px"></div> <div class="ant" style="height:65px;margin-top:-15px;"></div>
</div> </div>
<div <div class="bpart" style="height:6vh;width:70%;margin:0 auto;border-radius:2px 2px 0 0;">
class="bpart" <div class="wins" style="grid-template-columns:repeat(5,1fr);height:100%;align-content:start;">
style="height: 6vh; width: 70%; margin: 0 auto; border-radius: 2px 2px 0 0"
>
<div
class="wins"
style="grid-template-columns: repeat(5, 1fr); height: 100%; align-content: start"
>
<div class="w" *ngFor="let win of windows['wC1']" [ngStyle]="win.style"></div> <div class="w" *ngFor="let win of windows['wC1']" [ngStyle]="win.style"></div>
</div> </div>
</div> </div>
<div class="bpart" style="height: 10vh"> <div class="bpart" style="height:10vh;">
<div <div class="wins" style="grid-template-columns:repeat(6,1fr);align-content:start;height:100%;">
class="wins"
style="grid-template-columns: repeat(6, 1fr); align-content: start; height: 100%"
>
<div class="w" *ngFor="let win of windows['wC2']" [ngStyle]="win.style"></div> <div class="w" *ngFor="let win of windows['wC2']" [ngStyle]="win.style"></div>
</div> </div>
</div> </div>
<div class="bpart" style="height: 48vh"> <div class="bpart" style="height:48vh;">
<div style="padding: 6px 6px 0"> <div style="padding:6px 6px 0;">
<div class="bb" style="padding: 14px 14px 12px"> <div class="bb" style="padding:14px 14px 12px;">
<div class="bb-tag">WELCOME</div> <div class="bb-tag">WELCOME</div>
<div class="bb-title" style="font-size: clamp(16px, 1.8vw, 26px)"> <div class="bb-title" style="font-size:clamp(16px,1.8vw,26px);">WELCOME TO<br />NOWCHESS</div>
WELCOME TO<br />NOWCHESS
</div>
<div class="bb-subtitle">Play your next move from the skyline.</div> <div class="bb-subtitle">Play your next move from the skyline.</div>
<button type="button" class="app-btn" (click)="startOneVsOne()" [disabled]="creating"> <button type="button" class="app-btn" (click)="startOneVsOne()" [disabled]="creating">
{{ creating ? 'CREATING...' : 'START NOW →' }} {{ creating ? 'CREATING...' : 'START NOW →' }}
</button> </button>
</div> </div>
</div> </div>
<div class="wins" style="grid-template-columns: repeat(7, 1fr)"> <div class="wins" style="grid-template-columns:repeat(7,1fr);">
<div class="w" *ngFor="let win of windows['wC3']" [ngStyle]="win.style"></div> <div class="w" *ngFor="let win of windows['wC3']" [ngStyle]="win.style"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="bwrap" style="left: 63%; width: 18%"> <div class="bwrap" style="left:63%;width:18%;">
<div class="ant" style="height: 30px"></div> <div class="ant" style="height:30px;"></div>
<div class="bpart" style="height: 5vh; width: 110%; margin-left: -5%"> <div class="bpart" style="height:5vh;width:110%;margin-left:-5%;">
<div <div class="wins" style="grid-template-columns:repeat(6,1fr);height:100%;align-content:start;">
class="wins"
style="grid-template-columns: repeat(6, 1fr); height: 100%; align-content: start"
>
<div class="w" *ngFor="let win of windows['wD1']" [ngStyle]="win.style"></div> <div class="w" *ngFor="let win of windows['wD1']" [ngStyle]="win.style"></div>
</div> </div>
</div> </div>
<div class="bpart" style="height: 42vh"> <div class="bpart" style="height:42vh;">
<div style="padding: 5px 5px 0"> <div style="padding:5px 5px 0;">
<div style="text-align: center; padding: 4px 0 6px"> <div style="text-align:center;padding:4px 0 6px;">
<span class="neon">MORE</span> <span class="neon">MORE</span>
</div> </div>
<div class="bb"> <div class="bb">
<div class="bb-tag">OPTIONS</div> <div class="bb-tag">OPTIONS</div>
<div class="bb-title">MORE<br />OPTIONS</div> <div class="bb-title">MORE<br />OPTIONS</div>
<button type="button" class="app-btn" (click)="openOptionsDialog()"> <button type="button" class="app-btn" (click)="openOptionsDialog()">OPEN MENU →</button>
OPEN MENU →
</button>
</div> </div>
</div> </div>
<div class="wins" style="grid-template-columns: repeat(6, 1fr)"> <div class="wins" style="grid-template-columns:repeat(6,1fr);">
<div class="w" *ngFor="let win of windows['wD2']" [ngStyle]="win.style"></div> <div class="w" *ngFor="let win of windows['wD2']" [ngStyle]="win.style"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="bwrap" style="left: 83%; width: 10%"> <div class="bwrap" style="left:83%;width:10%;">
<div class="bpart" style="height: 30vh"> <div class="bpart" style="height:30vh;">
<div <div class="wins" style="grid-template-columns:repeat(3,1fr);height:100%;align-content:start;">
class="wins"
style="grid-template-columns: repeat(3, 1fr); height: 100%; align-content: start"
>
<div class="w" *ngFor="let win of windows['wE1']" [ngStyle]="win.style"></div> <div class="w" *ngFor="let win of windows['wE1']" [ngStyle]="win.style"></div>
</div> </div>
</div> </div>
@@ -180,140 +143,84 @@
</div> </div>
@if (showDifficultyDialog) { @if (showDifficultyDialog) {
<div class="dialog-overlay" (click)="closeDifficultyDialog()"> <div class="dialog-overlay" (click)="closeDifficultyDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()"> <div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-title">SELECT DIFFICULTY</div> <div class="dialog-title">SELECT DIFFICULTY</div>
<div class="dialog-actions"> <div class="dialog-actions">
<button type="button" class="app-btn" (click)="startVsBot('easy')" [disabled]="creating"> <button type="button" class="app-btn" (click)="startVsBot('easy')" [disabled]="creating">EASY</button>
EASY <button type="button" class="app-btn" (click)="startVsBot('medium')" [disabled]="creating">MEDIUM</button>
</button> <button type="button" class="app-btn" (click)="startVsBot('hard')" [disabled]="creating">HARD</button>
<button
type="button"
class="app-btn"
(click)="startVsBot('medium')"
[disabled]="creating"
>
MEDIUM
</button>
<button type="button" class="app-btn" (click)="startVsBot('hard')" [disabled]="creating">
HARD
</button>
</div>
</div> </div>
</div> </div>
</div>
} }
@if (showOptionsDialog) { @if (showOptionsDialog) {
<div class="dialog-overlay" (click)="closeOptionsDialog()"> <div class="dialog-overlay" (click)="closeOptionsDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()"> <div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-title">MORE OPTIONS</div> <div class="dialog-title">MORE OPTIONS</div>
<div class="dialog-actions"> <div class="dialog-actions">
<button type="button" class="app-btn" (click)="openImportDialog()">IMPORT GAME</button> <button type="button" class="app-btn" (click)="openImportDialog()">IMPORT GAME</button>
</div>
</div> </div>
</div> </div>
</div>
} }
@if (showJoinDialog) { @if (showJoinDialog) {
<div class="dialog-overlay" (click)="closeJoinDialog()"> <div class="dialog-overlay" (click)="closeJoinDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()"> <div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-title">JOIN GAME</div> <div class="dialog-title">JOIN GAME</div>
<input <input type="text" class="dialog-input" [(ngModel)]="gameIdInput" placeholder="Paste game ID here"
type="text" [disabled]="joiningGame" (keyup.enter)="submitJoinGame()" />
class="dialog-input" <div class="dialog-actions">
[(ngModel)]="gameIdInput" <button type="button" class="app-btn" (click)="submitJoinGame()"
placeholder="Paste game ID here" [disabled]="joiningGame || !gameIdInput.trim()">
[disabled]="joiningGame" {{ joiningGame ? 'JOINING...' : 'JOIN' }}
(keyup.enter)="submitJoinGame()" </button>
/> <button type="button" class="app-btn" (click)="closeJoinDialog()" [disabled]="joiningGame">CANCEL</button>
<div class="dialog-actions">
<button
type="button"
class="app-btn"
(click)="submitJoinGame()"
[disabled]="joiningGame || !gameIdInput.trim()"
>
{{ joiningGame ? 'JOINING...' : 'JOIN' }}
</button>
<button
type="button"
class="app-btn"
(click)="closeJoinDialog()"
[disabled]="joiningGame"
>
CANCEL
</button>
</div>
</div> </div>
</div> </div>
</div>
} }
@if (showImportDialog) { @if (showImportDialog) {
<div class="dialog-overlay" (click)="closeImportDialog()"> <div class="dialog-overlay" (click)="closeImportDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()"> <div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-title">IMPORT GAME</div> <div class="dialog-title">IMPORT GAME</div>
<div class="import-mode-group" role="radiogroup" aria-label="Import mode"> <div class="import-mode-group" role="radiogroup" aria-label="Import mode">
<label class="import-mode-option"> <label class="import-mode-option">
<input <input type="radio" name="importMode" [checked]="importMode === 'fen'" (change)="setImportMode('fen')"
type="radio" [disabled]="importing" />
name="importMode" <span>FEN</span>
[checked]="importMode === 'fen'" </label>
(change)="setImportMode('fen')" <label class="import-mode-option">
[disabled]="importing" <input type="radio" name="importMode" [checked]="importMode === 'pgn'" (change)="setImportMode('pgn')"
/> [disabled]="importing" />
<span>FEN</span> <span>PGN</span>
</label> </label>
<label class="import-mode-option"> </div>
<input <textarea class="dialog-input dialog-textarea" [(ngModel)]="importText"
type="radio" [placeholder]="importMode === 'fen' ? 'Paste FEN here' : 'Paste PGN here'" [disabled]="importing"
name="importMode" rows="5"></textarea>
[checked]="importMode === 'pgn'" <div class="dialog-actions">
(change)="setImportMode('pgn')" <button type="button" class="app-btn" (click)="submitImportGame()" [disabled]="importing || !importText.trim()">
[disabled]="importing" {{ importing ? 'IMPORTING...' : 'IMPORT' }}
/> </button>
<span>PGN</span> <button type="button" class="app-btn" (click)="closeImportDialog()" [disabled]="importing">CANCEL</button>
</label>
</div>
<textarea
class="dialog-input dialog-textarea"
[(ngModel)]="importText"
[placeholder]="importMode === 'fen' ? 'Paste FEN here' : 'Paste PGN here'"
[disabled]="importing"
rows="5"
></textarea>
<div class="dialog-actions">
<button
type="button"
class="app-btn"
(click)="submitImportGame()"
[disabled]="importing || !importText.trim()"
>
{{ importing ? 'IMPORTING...' : 'IMPORT' }}
</button>
<button
type="button"
class="app-btn"
(click)="closeImportDialog()"
[disabled]="importing"
>
CANCEL
</button>
</div>
</div> </div>
</div> </div>
</div>
} }
@if (showChallengeDialog) { @if (showChallengeDialog) {
<div class="dialog-overlay" (click)="closeChallengeDialog()"> <div class="dialog-overlay" (click)="closeChallengeDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()"> <div class="dialog-card" (click)="$event.stopPropagation()">
<app-challenge-create-dialog <app-challenge-create-dialog (closeChallengeDialog)="closeChallengeDialog()"></app-challenge-create-dialog>
(closeChallengeDialog)="closeChallengeDialog()"
></app-challenge-create-dialog>
</div>
</div> </div>
</div>
} }
@if (errorMessage) { @if (errorMessage) {
<p class="error-banner">{{ errorMessage }}</p> <p class="error-banner">{{ errorMessage }}</p>
} }
</div> </div>
+34 -37
View File
@@ -50,7 +50,7 @@ interface WindowCell {
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, ChallengeCreateDialogComponent], imports: [CommonModule, FormsModule, ChallengeCreateDialogComponent],
templateUrl: './welcome.component.html', templateUrl: './welcome.component.html',
styleUrls: ['./welcome.component.css'], styleUrls: ['./welcome.component.css']
}) })
export class WelcomeComponent implements OnInit, OnDestroy { export class WelcomeComponent implements OnInit, OnDestroy {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -93,8 +93,9 @@ export class WelcomeComponent implements OnInit, OnDestroy {
constructor( constructor(
private readonly router: Router, private readonly router: Router,
private readonly gameApi: GameApiService, private readonly gameApi: GameApiService
) {} ) {
}
ngOnInit(): void { ngOnInit(): void {
this.themeService.darkMode$ this.themeService.darkMode$
@@ -104,10 +105,12 @@ export class WelcomeComponent implements OnInit, OnDestroy {
this.modeBadge = this.isSunsetMode ? 'SUNSET MODE' : 'NIGHT MODE'; this.modeBadge = this.isSunsetMode ? 'SUNSET MODE' : 'NIGHT MODE';
}); });
this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { this.authService.currentUser$
this.currentUser = user; .pipe(takeUntilDestroyed(this.destroyRef))
this.maybeRunPendingAction(); .subscribe((user) => {
}); this.currentUser = user;
this.maybeRunPendingAction();
});
this.authDialogService.dialogState$ this.authDialogService.dialogState$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@@ -127,7 +130,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
} }
openDifficultyDialog(): void { openDifficultyDialog(): void {
if (!this.requireAuth(() => (this.showDifficultyDialog = true))) { if (!this.requireAuth(() => this.showDifficultyDialog = true)) {
return; return;
} }
@@ -151,7 +154,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
} }
openJoinDialog(): void { openJoinDialog(): void {
if (!this.requireAuth(() => (this.showJoinDialog = true))) { if (!this.requireAuth(() => this.showJoinDialog = true)) {
return; return;
} }
@@ -169,7 +172,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
} }
openImportDialog(): void { openImportDialog(): void {
if (!this.requireAuth(() => (this.showImportDialog = true))) { if (!this.requireAuth(() => this.showImportDialog = true)) {
return; return;
} }
@@ -254,6 +257,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
action(); action();
} }
private performStartVsBot(difficulty: Difficulty): void { private performStartVsBot(difficulty: Difficulty): void {
if (this.creating) { if (this.creating) {
return; return;
@@ -269,12 +273,12 @@ export class WelcomeComponent implements OnInit, OnDestroy {
.subscribe({ .subscribe({
next: (game) => { next: (game) => {
void this.router.navigate(['/game', game.gameId], { void this.router.navigate(['/game', game.gameId], {
state: { theme: this.isSunsetMode ? 'light' : 'dark' }, state: { theme: this.isSunsetMode ? 'light' : 'dark' }
}); });
}, },
error: (error) => { error: (error) => {
this.errorMessage = getErrorMessage(error, 'Unable to create a game against bot.'); this.errorMessage = getErrorMessage(error, 'Unable to create a game against bot.');
}, }
}); });
} }
@@ -294,12 +298,12 @@ export class WelcomeComponent implements OnInit, OnDestroy {
next: (game) => { next: (game) => {
this.closeJoinDialog(); this.closeJoinDialog();
void this.router.navigate(['/game', game.gameId], { void this.router.navigate(['/game', game.gameId], {
state: { theme: this.isSunsetMode ? 'light' : 'dark' }, state: { theme: this.isSunsetMode ? 'light' : 'dark' }
}); });
}, },
error: (error) => { error: (error) => {
this.errorMessage = getErrorMessage(error, 'Unable to find or join the game.'); this.errorMessage = getErrorMessage(error, 'Unable to find or join the game.');
}, }
}); });
} }
@@ -313,22 +317,19 @@ export class WelcomeComponent implements OnInit, OnDestroy {
this.importing = true; this.importing = true;
const importRequest = const importRequest =
this.importMode === 'fen' this.importMode === 'fen' ? this.gameApi.importFen(trimmedImport) : this.gameApi.importPgn(trimmedImport);
? this.gameApi.importFen(trimmedImport)
: this.gameApi.importPgn(trimmedImport);
importRequest.pipe(finalize(() => (this.importing = false))).subscribe({ importRequest.pipe(finalize(() => (this.importing = false))).subscribe({
next: (game) => { next: (game) => {
this.closeImportDialog(); this.closeImportDialog();
void this.router.navigate(['/game', game.gameId], { void this.router.navigate(['/game', game.gameId], {
state: { theme: this.isSunsetMode ? 'light' : 'dark' }, state: { theme: this.isSunsetMode ? 'light' : 'dark' }
}); });
}, },
error: (error) => { error: (error) => {
const defaultMessage = const defaultMessage = this.importMode === 'fen' ? 'Unable to import FEN.' : 'Unable to import PGN.';
this.importMode === 'fen' ? 'Unable to import FEN.' : 'Unable to import PGN.';
this.errorMessage = getErrorMessage(error, defaultMessage); this.errorMessage = getErrorMessage(error, defaultMessage);
}, }
}); });
} }
@@ -350,8 +351,8 @@ export class WelcomeComponent implements OnInit, OnDestroy {
left: `${Math.random() * 100}%`, left: `${Math.random() * 100}%`,
top: `${Math.random() * 62}%`, top: `${Math.random() * 62}%`,
'--d': `${(Math.random() * 3 + 1.5).toFixed(1)}s`, '--d': `${(Math.random() * 3 + 1.5).toFixed(1)}s`,
'--dl': `${-(Math.random() * 6).toFixed(1)}s`, '--dl': `${-(Math.random() * 6).toFixed(1)}s`
}, }
}; };
}); });
} }
@@ -380,11 +381,11 @@ export class WelcomeComponent implements OnInit, OnDestroy {
{ l: '85.5%', w: '9%', h: '32vh' }, { l: '85.5%', w: '9%', h: '32vh' },
{ l: '88%', w: '5%', h: '20vh' }, { l: '88%', w: '5%', h: '20vh' },
{ l: '91%', w: '3%', h: '16vh' }, // New building { 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) => ({ 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 }
})); }));
} }
@@ -400,7 +401,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
wC3: this.generateWindows(7, 24, 0.6), wC3: this.generateWindows(7, 24, 0.6),
wD1: this.generateWindows(6, 3, 0.6), wD1: this.generateWindows(6, 3, 0.6),
wD2: this.generateWindows(6, 20, 0.5), wD2: this.generateWindows(6, 20, 0.5),
wE1: this.generateWindows(3, 16, 0.45), wE1: this.generateWindows(3, 16, 0.45)
}; };
} }
@@ -415,14 +416,12 @@ export class WelcomeComponent implements OnInit, OnDestroy {
let color: string | undefined; let color: string | undefined;
let glowColor: string | undefined; let glowColor: string | undefined;
if (random < litRate * 0.58) { if (random < litRate * 0.58) { // Cool color
// Cool color
state = 'on'; state = 'on';
const coolIndex = Math.floor(Math.random() * this.coolColors.length); const coolIndex = Math.floor(Math.random() * this.coolColors.length);
color = this.coolColors[coolIndex]; color = this.coolColors[coolIndex];
glowColor = this.coolGlowColors[coolIndex]; glowColor = this.coolGlowColors[coolIndex];
} else if (random < litRate) { } else if (random < litRate) { // Warm color
// Warm color
state = 'on'; state = 'on';
const warmIndex = Math.floor(Math.random() * this.warmColors.length); const warmIndex = Math.floor(Math.random() * this.warmColors.length);
color = this.warmColors[warmIndex]; color = this.warmColors[warmIndex];
@@ -433,7 +432,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
return { state, style: {} }; return { state, style: {} };
} }
const baseDuration = color && this.coolColors.includes(color) ? 3 : 4; const baseDuration = (color && this.coolColors.includes(color)) ? 3 : 4;
return { return {
state, state,
color, color,
@@ -442,8 +441,8 @@ export class WelcomeComponent implements OnInit, OnDestroy {
'background-color': color || '', 'background-color': color || '',
'box-shadow': glowColor ? `0 0 6px ${glowColor}, 0 0 16px ${glowColor}35` : '', 'box-shadow': glowColor ? `0 0 6px ${glowColor}, 0 0 16px ${glowColor}35` : '',
'--wd': `${(Math.random() * 4 + baseDuration).toFixed(1)}s`, '--wd': `${(Math.random() * 4 + baseDuration).toFixed(1)}s`,
'--wdl': `${-(Math.random() * 8).toFixed(1)}s`, '--wdl': `${-(Math.random() * 8).toFixed(1)}s`
}, }
}; };
} }
@@ -485,11 +484,9 @@ export class WelcomeComponent implements OnInit, OnDestroy {
target.glowColor = glowColors[index]; target.glowColor = glowColors[index];
target.style = { target.style = {
'background-color': target.color || '', 'background-color': target.color || '',
'box-shadow': target.glowColor 'box-shadow': target.glowColor ? `0 0 6px ${target.glowColor}, 0 0 16px ${target.glowColor}35` : '',
? `0 0 6px ${target.glowColor}, 0 0 16px ${target.glowColor}35`
: '',
'--wd': `${(Math.random() * 4 + (isCool ? 3 : 4)).toFixed(1)}s`, '--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 { } else {
target.state = 'off'; target.state = 'off';
-102
View File
@@ -1,102 +0,0 @@
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<AnalysisResponse> {
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<AnnotatedMove[]> {
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';
}
}
+12 -12
View File
@@ -5,19 +5,19 @@ export type AuthDialogState = 'login' | 'register' | null;
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthDialogService { export class AuthDialogService {
private readonly dialogStateSubject = new BehaviorSubject<AuthDialogState>(null); private readonly dialogStateSubject = new BehaviorSubject<AuthDialogState>(null);
readonly dialogState$ = this.dialogStateSubject.asObservable(); readonly dialogState$ = this.dialogStateSubject.asObservable();
openLogin(): void { openLogin(): void {
this.dialogStateSubject.next('login'); this.dialogStateSubject.next('login');
} }
openRegister(): void { openRegister(): void {
this.dialogStateSubject.next('register'); this.dialogStateSubject.next('register');
} }
close(): void { close(): void {
this.dialogStateSubject.next(null); this.dialogStateSubject.next(null);
} }
} }
+2 -2
View File
@@ -14,8 +14,8 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (token && isProtectedEndpoint && !req.headers.has('Authorization')) { if (token && isProtectedEndpoint && !req.headers.has('Authorization')) {
req = req.clone({ req = req.clone({
setHeaders: { setHeaders: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`
}, }
}); });
} }
+11 -13
View File
@@ -3,13 +3,7 @@ import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { map, switchMap, tap } from 'rxjs/operators'; import { map, switchMap, tap } from 'rxjs/operators';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { import { LoginRequest, RegisterRequest, RegisterResponse, LoginResponse, CurrentUser } from '../models/auth.models';
LoginRequest,
RegisterRequest,
RegisterResponse,
LoginResponse,
CurrentUser,
} from '../models/auth.models';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthService { export class AuthService {
@@ -28,7 +22,7 @@ export class AuthService {
return this.http return this.http
.post<LoginResponse>(`${this.accountServiceUrl}/api/account/login`, { .post<LoginResponse>(`${this.accountServiceUrl}/api/account/login`, {
username, username,
password, password
}) })
.pipe( .pipe(
tap((response) => { tap((response) => {
@@ -36,7 +30,7 @@ export class AuthService {
localStorage.setItem('refreshToken', response.refreshToken); localStorage.setItem('refreshToken', response.refreshToken);
localStorage.setItem('username', username); localStorage.setItem('username', username);
this.getCurrentUser().subscribe(); this.getCurrentUser().subscribe();
}), })
); );
} }
@@ -45,9 +39,13 @@ export class AuthService {
.post<RegisterResponse>(`${this.accountServiceUrl}/api/account`, { .post<RegisterResponse>(`${this.accountServiceUrl}/api/account`, {
username, username,
password, 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<CurrentUser> { getCurrentUser(): Observable<CurrentUser> {
@@ -56,7 +54,7 @@ export class AuthService {
localStorage.setItem('username', user.username); localStorage.setItem('username', user.username);
localStorage.setItem('userId', user.id); localStorage.setItem('userId', user.id);
this.currentUserSubject.next(user); this.currentUserSubject.next(user);
}), })
); );
} }
@@ -82,7 +80,7 @@ export class AuthService {
error: () => { error: () => {
// Token is invalid, clear it // Token is invalid, clear it
this.logout(); this.logout();
}, }
}); });
} }
} }
+2 -2
View File
@@ -21,7 +21,7 @@ export class BoardSelectionService {
state: GameState | null, state: GameState | null,
currentSelection: BoardSelection, currentSelection: BoardSelection,
onMovesLoaded: (moves: LegalMove[]) => void, onMovesLoaded: (moves: LegalMove[]) => void,
onError: (error: string) => void, onError: (error: string) => void
): BoardSelection { ): BoardSelection {
if (!state) { if (!state) {
return currentSelection; return currentSelection;
@@ -52,7 +52,7 @@ export class BoardSelectionService {
}, },
error: () => { error: () => {
onError('Could not load legal moves for selected square.'); onError('Could not load legal moves for selected square.');
}, }
}); });
return { selectedSquare: square, highlightedSquares: [], selectedSquareMoves: [] }; return { selectedSquare: square, highlightedSquares: [], selectedSquareMoves: [] };
+7 -4
View File
@@ -35,7 +35,7 @@ export class BotMoveService {
game: GameFull | null, game: GameFull | null,
state: GameState | null, state: GameState | null,
onSuccess: (updatedState: GameState) => void, onSuccess: (updatedState: GameState) => void,
onError: (error: string) => void, onError: (error: string) => void
): void { ): void {
if (!this.isPlayingAgainstBot(game) || !this.isCurrentPlayerBot(game, state) || !state) { if (!this.isPlayingAgainstBot(game) || !this.isCurrentPlayerBot(game, state) || !state) {
return; return;
@@ -44,7 +44,10 @@ export class BotMoveService {
this.botMoveSubscription?.unsubscribe(); this.botMoveSubscription?.unsubscribe();
this.botMoveSubscription = this.gameApi this.botMoveSubscription = this.gameApi
.getLegalMoves(gameId) .getLegalMoves(gameId)
.pipe(delay(1000), takeUntilDestroyed(this.destroyRef)) .pipe(
delay(1000),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({ .subscribe({
next: (response) => { next: (response) => {
if (response.moves.length === 0) { if (response.moves.length === 0) {
@@ -60,12 +63,12 @@ export class BotMoveService {
}, },
error: (error) => { error: (error) => {
onError(getErrorMessage(error, 'Bot move failed.')); onError(getErrorMessage(error, 'Bot move failed.'));
}, }
}); });
}, },
error: () => { error: () => {
onError('Could not get legal moves for bot move.'); onError('Could not get legal moves for bot move.');
}, }
}); });
} }
+7 -3
View File
@@ -7,19 +7,23 @@ import { Bot, BotWithToken } from '../models/bot.models';
export class BotService { export class BotService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly base = '/api/account/bots'; private readonly base = '/api/account/bots';
private readonly officialBase = '/api/account/official-bots';
list(): Observable<Bot[]> { list(): Observable<Bot[]> {
return this.http.get<Bot[]>(this.base); return this.http.get<Bot[]>(this.base);
} }
listOfficial(): Observable<Bot[]> {
return this.http.get<Bot[]>(this.officialBase);
}
create(name: string): Observable<BotWithToken> { create(name: string): Observable<BotWithToken> {
return this.http.post<BotWithToken>(this.base, { name }); return this.http.post<BotWithToken>(this.base, { name });
} }
rotateToken(botId: string): Observable<string> { rotateToken(botId: string): Observable<string> {
return this.http return this.http.post<{ token: string }>(`${this.base}/${botId}/rotate-token`, null)
.post<{ token: string }>(`${this.base}/${botId}/rotate-token`, null) .pipe(map(r => r.token));
.pipe(map((r) => r.token));
} }
delete(botId: string): Observable<void> { delete(botId: string): Observable<void> {
+57 -57
View File
@@ -8,71 +8,71 @@ import { Challenge } from '../models/challenge.models';
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ChallengeEventService { export class ChallengeEventService {
private readonly incomingChallenges$ = new BehaviorSubject<Challenge[]>([]); private readonly incomingChallenges$ = new BehaviorSubject<Challenge[]>([]);
private readonly challengeReceived$ = new Subject<Challenge>(); private readonly challengeReceived$ = new Subject<Challenge>();
private readonly challengeAccepted$ = new Subject<Challenge>(); private readonly challengeAccepted$ = new Subject<Challenge>();
private readonly challengeDeclined$ = new Subject<Challenge>(); private readonly challengeDeclined$ = new Subject<Challenge>();
getIncomingChallenges$(): Observable<Challenge[]> { getIncomingChallenges$(): Observable<Challenge[]> {
return this.incomingChallenges$.asObservable(); return this.incomingChallenges$.asObservable();
} }
getChallengeReceived$(): Observable<Challenge> { getChallengeReceived$(): Observable<Challenge> {
return this.challengeReceived$.asObservable(); return this.challengeReceived$.asObservable();
} }
getChallengeAccepted$(): Observable<Challenge> { getChallengeAccepted$(): Observable<Challenge> {
return this.challengeAccepted$.asObservable(); return this.challengeAccepted$.asObservable();
} }
getChallengeDeclined$(): Observable<Challenge> { getChallengeDeclined$(): Observable<Challenge> {
return this.challengeDeclined$.asObservable(); return this.challengeDeclined$.asObservable();
} }
/** /**
* Called when a new challenge is received via WebSocket * Called when a new challenge is received via WebSocket
*/ */
onChallengeReceived(challenge: Challenge): void { onChallengeReceived(challenge: Challenge): void {
const current = this.incomingChallenges$.value; const current = this.incomingChallenges$.value;
this.incomingChallenges$.next([...current, challenge]); this.incomingChallenges$.next([...current, challenge]);
this.challengeReceived$.next(challenge); this.challengeReceived$.next(challenge);
} }
/** /**
* Called when a challenge is accepted * Called when a challenge is accepted
*/ */
onChallengeAccepted(challenge: Challenge): void { onChallengeAccepted(challenge: Challenge): void {
const current = this.incomingChallenges$.value; const current = this.incomingChallenges$.value;
this.incomingChallenges$.next(current.filter((c) => c.id !== challenge.id)); this.incomingChallenges$.next(current.filter(c => c.id !== challenge.id));
this.challengeAccepted$.next(challenge); this.challengeAccepted$.next(challenge);
} }
/** /**
* Called when a challenge is declined or expires * Called when a challenge is declined or expires
*/ */
onChallengeRemoved(challengeId: string): void { onChallengeRemoved(challengeId: string): void {
const current = this.incomingChallenges$.value; const current = this.incomingChallenges$.value;
this.incomingChallenges$.next(current.filter((c) => c.id !== challengeId)); this.incomingChallenges$.next(current.filter(c => c.id !== challengeId));
} }
/** /**
* Remove a challenge from the incoming list * Remove a challenge from the incoming list
*/ */
removeChallenge(challengeId: string): void { removeChallenge(challengeId: string): void {
this.onChallengeRemoved(challengeId); this.onChallengeRemoved(challengeId);
} }
/** /**
* Replace the full incoming list (used by HTTP polling) * Replace the full incoming list (used by HTTP polling)
*/ */
setIncomingChallenges(challenges: Challenge[]): void { setIncomingChallenges(challenges: Challenge[]): void {
this.incomingChallenges$.next(challenges); this.incomingChallenges$.next(challenges);
} }
/** /**
* Clear all incoming challenges (used on logout) * Clear all incoming challenges (used on logout)
*/ */
clear(): void { clear(): void {
this.incomingChallenges$.next([]); this.incomingChallenges$.next([]);
} }
} }
+90 -94
View File
@@ -6,114 +6,110 @@ import { ChallengeService } from './challenge.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ChallengeWebSocketService { export class ChallengeWebSocketService {
private readonly challengeEventService = inject(ChallengeEventService); private readonly challengeEventService = inject(ChallengeEventService);
private readonly challengeService = inject(ChallengeService); private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router); private readonly router = inject(Router);
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private reconnectAttempts = 0; private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5; private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 3000; private readonly reconnectDelay = 3000;
private intentionalClose = false; private intentionalClose = false;
connect(): void { connect(): void {
if (this.ws) return; if (this.ws) return;
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) return; if (!token) return;
const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`; const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`;
try { try {
this.intentionalClose = false; this.intentionalClose = false;
this.ws = new WebSocket(url); this.ws = new WebSocket(url);
this.ws.onopen = () => { 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.reconnectAttempts = 0; this.reconnectAttempts = 0;
}; if (this.ws) {
this.ws.close();
this.ws.onmessage = (event) => { this.ws = null;
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<string, unknown>;
try {
message = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
} }
switch (message['type']) { private handleMessage(data: string): void {
case 'CONNECTED': let message: Record<string, unknown>;
break; try {
message = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
}
case 'challengeCreated': { switch (message['type']) {
const challengeId = message['challengeId'] as string | undefined; case 'CONNECTED':
if (challengeId) { break;
this.challengeService.getChallenge(challengeId).subscribe({
next: (challenge) => this.challengeEventService.onChallengeReceived(challenge),
error: () => {
/* challenge may have already expired */
},
});
}
break;
}
case 'challengeAccepted': { case 'challengeCreated': {
const challengeId = message['challengeId'] as string | undefined; const challengeId = message['challengeId'] as string | undefined;
const gameId = message['gameId'] as string | undefined; if (challengeId) {
if (challengeId) { this.challengeService.getChallenge(challengeId).subscribe({
this.challengeEventService.removeChallenge(challengeId); next: challenge => this.challengeEventService.onChallengeReceived(challenge),
} error: () => { /* challenge may have already expired */ }
if (gameId) { });
void this.router.navigate(['/game', gameId]); }
} break;
break; }
}
case 'challengeDeclined': case 'challengeAccepted': {
case 'challengeExpired': const challengeId = message['challengeId'] as string | undefined;
case 'challengeCancelled': { const gameId = message['gameId'] as string | undefined;
const challengeId = message['challengeId'] as string | undefined; if (challengeId) {
if (challengeId) { this.challengeEventService.removeChallenge(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;
}
} }
break;
}
} }
}
private attemptReconnect(): void { private attemptReconnect(): void {
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return; if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++; this.reconnectAttempts++;
setTimeout(() => { setTimeout(() => { this.connect(); }, this.reconnectDelay);
this.connect(); }
}, this.reconnectDelay);
}
} }
+37 -26
View File
@@ -1,39 +1,50 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import { Challenge, DeclineChallengeRequest, ListChallengesResponse, SendChallengeRequest } from '../models/challenge.models';
Challenge,
DeclineChallengeRequest,
ListChallengesResponse,
SendChallengeRequest,
} from '../models/challenge.models';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ChallengeService { export class ChallengeService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly challengeBaseUrl = '/api/challenge'; private readonly challengeBaseUrl = '/api/challenge';
sendChallenge(username: string, request: SendChallengeRequest): Observable<Challenge> { sendChallenge(username: string, request: SendChallengeRequest): Observable<Challenge> {
return this.http.post<Challenge>(`${this.challengeBaseUrl}/${username}`, request); return this.http.post<Challenge>(
} `${this.challengeBaseUrl}/${username}`,
request
);
}
listChallenges(): Observable<ListChallengesResponse> { listChallenges(): Observable<ListChallengesResponse> {
return this.http.get<ListChallengesResponse>(`${this.challengeBaseUrl}`); return this.http.get<ListChallengesResponse>(
} `${this.challengeBaseUrl}`
);
}
getChallenge(challengeId: string): Observable<Challenge> { getChallenge(challengeId: string): Observable<Challenge> {
return this.http.get<Challenge>(`${this.challengeBaseUrl}/${challengeId}`); return this.http.get<Challenge>(
} `${this.challengeBaseUrl}/${challengeId}`
);
}
acceptChallenge(challengeId: string): Observable<Challenge> { acceptChallenge(challengeId: string): Observable<Challenge> {
return this.http.post<Challenge>(`${this.challengeBaseUrl}/${challengeId}/accept`, {}); return this.http.post<Challenge>(
} `${this.challengeBaseUrl}/${challengeId}/accept`,
{}
);
}
declineChallenge(challengeId: string, request?: DeclineChallengeRequest): Observable<void> { declineChallenge(challengeId: string, request?: DeclineChallengeRequest): Observable<void> {
return this.http.post<void>(`${this.challengeBaseUrl}/${challengeId}/decline`, request || {}); return this.http.post<void>(
} `${this.challengeBaseUrl}/${challengeId}/decline`,
request || {}
);
}
cancelChallenge(challengeId: string): Observable<void> { cancelChallenge(challengeId: string): Observable<void> {
return this.http.post<void>(`${this.challengeBaseUrl}/${challengeId}/cancel`, {}); return this.http.post<void>(
} `${this.challengeBaseUrl}/${challengeId}/cancel`,
{}
);
}
} }
+4 -11
View File
@@ -7,9 +7,8 @@ import {
GameState, GameState,
GameStreamEvent, GameStreamEvent,
LegalMovesResponse, LegalMovesResponse,
PlayerInfo, PlayerInfo
} from '../models/game.models'; } from '../models/game.models';
import { AnalysisRequest, AnalysisResponse } from '../models/analysis.models';
import { StreamHandlerService } from './stream-handler.service'; import { StreamHandlerService } from './stream-handler.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -29,11 +28,11 @@ export class GameApiService {
const playerColor = Math.random() > 0.5 ? 'white' : 'black'; const playerColor = Math.random() > 0.5 ? 'white' : 'black';
const playerInfo: PlayerInfo = { const playerInfo: PlayerInfo = {
id: `player-${Date.now()}`, id: `player-${Date.now()}`,
displayName: 'You', displayName: 'You'
}; };
const botInfo: PlayerInfo = { const botInfo: PlayerInfo = {
id: `bot-${difficulty}`, id: `bot-${difficulty}`,
displayName: `Bot (${difficulty})`, displayName: `Bot (${difficulty})`
}; };
const payload = const payload =
@@ -57,9 +56,7 @@ export class GameApiService {
if (square) { if (square) {
params = params.set('square', square); params = params.set('square', square);
} }
return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { params });
params,
});
} }
importFen(fen: string): Observable<GameFull> { importFen(fen: string): Observable<GameFull> {
@@ -78,10 +75,6 @@ export class GameApiService {
return this.http.post<void>(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {}); return this.http.post<void>(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {});
} }
analyzePosition(request: AnalysisRequest): Observable<AnalysisResponse> {
return this.http.post<AnalysisResponse>(`${this.apiBase}/api/analysis/position`, request);
}
private resolveWsBase(): string { private resolveWsBase(): string {
if (this.wsBase) { if (this.wsBase) {
return this.wsBase; return this.wsBase;
+3 -19
View File
@@ -14,13 +14,7 @@ export class GameCompletionService {
} }
const status = state.status; const status = state.status;
const gameEndingStatuses: GameStatus[] = [ const gameEndingStatuses: GameStatus[] = ['checkmate', 'stalemate', 'resign', 'draw', 'insufficientMaterial'];
'checkmate',
'stalemate',
'resign',
'draw',
'insufficientMaterial',
];
if (!gameEndingStatuses.includes(status)) { if (!gameEndingStatuses.includes(status)) {
return { isFinished: false, message: '' }; return { isFinished: false, message: '' };
@@ -36,18 +30,8 @@ export class GameCompletionService {
} }
private buildCompletionMessage(status: GameStatus, state: GameState, game: GameFull): string { private buildCompletionMessage(status: GameStatus, state: GameState, game: GameFull): string {
const winner = const winner = state.winner === 'white' ? game.white.displayName : state.winner === 'black' ? game.black.displayName : null;
state.winner === 'white' const loser = state.winner === 'white' ? game.black.displayName : state.winner === 'black' ? game.white.displayName : null;
? 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) { switch (status) {
case 'checkmate': case 'checkmate':
+12 -4
View File
@@ -10,7 +10,11 @@ export class GameImportService {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef); 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(); const trimmedFen = fen.trim();
if (!trimmedFen) { if (!trimmedFen) {
onError('Please provide a FEN string.'); onError('Please provide a FEN string.');
@@ -27,11 +31,15 @@ export class GameImportService {
}, },
error: (error) => { error: (error) => {
onError(getErrorMessage(error, 'FEN import failed.')); 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(); const trimmedPgn = pgn.trim();
if (!trimmedPgn) { if (!trimmedPgn) {
onError('Please provide a PGN string.'); onError('Please provide a PGN string.');
@@ -48,7 +56,7 @@ export class GameImportService {
}, },
error: (error) => { error: (error) => {
onError(getErrorMessage(error, 'PGN import failed.')); onError(getErrorMessage(error, 'PGN import failed.'));
}, }
}); });
} }
} }

Some files were not shown because too many files have changed in this diff Show More