31 Commits

Author SHA1 Message Date
TeamCity ab2c641130 ci: bump version to v0.5.0 2026-06-21 20:08:19 +00:00
Janis Eccarius 412591dfe0 feat(tournaments): remove external server add/remove UI
Servers are now env-var configured; the Servers dialog, add form,
remove buttons, and TournamentServerService are all deleted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 21:40:48 +02:00
TeamCity e374d5e791 ci: bump version to v0.4.4 2026-06-21 14:06:48 +00:00
Janis Eccarius 5b5fd6f027 fix(tournaments): load both user bots and official bots in join dialog
openJoinDialog now fetches user bots and official bots in parallel via
forkJoin. Each section shows its own empty state independently.

Official bot difficulty buttons are hidden when no official bots are
registered. User bots empty state links to /bots to create one.

Disables all join buttons while any join is in progress.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 15:40:24 +02:00
TeamCity ea8048e064 ci: bump version to v0.4.3 2026-06-21 09:06:47 +00:00
Janis Eccarius ce1fb0d60b fix(analysis): fix API field mismatch and enable full game analysis
Map raw backend response (evaluation/continuationMoves) to frontend
model (eval/winChance/continuations). Add getFenHistory() call after
loading a game or PGN so runAnalysis() gets per-ply FEN history and
triggers analyzeGame() instead of falling back to single-position
analysis. Remove !hasAnnotations guard so positionAnalysis card shows
even when a game is loaded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 11:00:29 +02:00
TeamCity 74f82bc0ba ci: bump version to v0.4.2 2026-06-17 09:19:46 +00:00
lq64 1d2c217da8 fix: NCS-122 send WS token via first-message auth instead of query param (#13)
Remove token from WebSocket URL query parameters in ChallengeWebSocketService
and GameApiService. Instead, send {"type":"auth","token":"..."} as the first
text frame after the connection opens, matching the new backend auth protocol.

---------

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #13
2026-06-17 10:50:16 +02:00
TeamCity 3d1b330396 ci: bump version to v0.4.1 2026-06-17 07:20:31 +00:00
Janis a54957aa74 fix(auth): attach Bearer token to /api/bots requests (#12)
OfficialBotService posts to /api/bots/official/join-tournament, but
the auth interceptor's protected-endpoint whitelist omitted /api/bots,
so no Authorization header was sent and the request hit the official-
bots service anonymously -> 401.

Add /api/bots to the whitelist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Reviewed-on: #12
2026-06-17 09:08:55 +02:00
TeamCity c9bf6d21fc ci: bump version to v0.4.0 2026-06-17 06:22:59 +00:00
Janis f9420e5848 feat: NCWF-5/6/7/8/9 chess analysis page and engine integration (#11)
Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com>
Reviewed-on: #11
2026-06-17 08:17:55 +02:00
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
TeamCity ae952d70b0 ci: bump version to v0.2.8 2026-06-10 09:18:51 +00:00
LQ63 a62073511f fix: route play-vs-bot to /vs-bot endpoint
POST /api/board/game is @InternalOnly and returns 401 for browser clients.
Switch to /api/board/game/vs-bot which is @PermitAll and notifies the
official-bots service to play the bot side automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 11:13:37 +02:00
TeamCity 16079dd63a ci: bump version to v0.2.7 2026-06-06 12:36:12 +00:00
Janis Eccarius a65d748f99 chore(claude): add YouTrack command suite
Port create-defect, create-story, estimate-issues, fix-defect,
implement-feature and split-story commands, adapted for the Angular
frontend (npm build/test, prettier gates, NCWF routing).
implement-feature now walks subtasks, respects blocked-by, and reports
cross-project (NCS/NCI) tasks. Ignore .claude/worktrees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:33:07 +02:00
TeamCity 85f1b8e42c ci: bump version to v0.2.6 2026-06-02 19:59:36 +00:00
shosho996 95eff42dfe fix: NCWF-4 Token Issues (#8)
Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Reviewed-on: #8
2026-06-02 21:55:55 +02:00
TeamCity 873bfe3bae ci: bump version to v0.2.5 2026-06-01 21:58:57 +00:00
TeamCity a2d2c00afe ci: bump version to v0.2.4 2026-05-15 15:16:10 +00:00
shahdlala66 51a363a243 fix: build error 2026-05-15 17:13:05 +02:00
shosho996 95f5243c2d ci: trigger 2026-05-15 17:01:58 +02:00
shosho996 c02414ea40 fix: NCWF-2 bugs and desing fixes (#7)
Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Reviewed-on: #7
2026-05-15 02:16:43 +02:00
TeamCity 70a4debb40 ci: bump version to v0.2.3 2026-05-14 15:19:14 +00:00
shosho996 61000f8a22 fix: added missing challenge routes 2026-05-14 17:16:42 +02:00
TeamCity f98bcfd956 ci: bump version to v0.2.2 2026-05-12 22:04:12 +00:00
shosho996 6d1e06dfd6 fix: NCWF-1 401 (#6)
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Reviewed-on: #6
2026-05-13 00:01:26 +02:00
TeamCity ac4fe8b005 ci: bump version to v0.2.1 2026-05-12 21:13:35 +00:00
shosho996 f8f93efff4 fix: NCWF-1 401 (#5)
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Reviewed-on: #5
2026-05-12 23:11:19 +02:00
89 changed files with 8489 additions and 1557 deletions
+97
View File
@@ -0,0 +1,97 @@
# Create Defect in YouTrack
Automated defect creation workflow. Topic/hint: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Defects raised
here usually belong to project `NCWF`, but routing in Step 5 still applies.
## Step 1 — Gather Context
Use `AskUserQuestion` tool to ask the user (max 4 questions at once):
1. **Component** — Where does the bug occur? (e.g. a component, service, route/page, model, styling, API integration)
2. **What breaks** — What is the actual (broken) behavior?
3. **Expected** — What should happen instead?
4. **Reproducibility** — Is it always reproducible? Any known trigger conditions? (route, viewport, browser)
If `$ARGUMENTS` already answers some of these, skip those questions.
## Step 2 — Research (if needed)
If the bug involves component logic, a service, or routing:
- Search repo for relevant code (`Grep`/`Bash`) under `src/app/{components,services,models,core,pages}`.
- Check `.spec.ts` files for existing coverage of the broken area.
- Do NOT guess at root cause. Surface findings before drafting.
## Step 3 — Draft Defect
Compose the full defect report using this template:
```
Summary
[One-sentence description of what is broken.]
Steps to Reproduce
1. Step one
2. Step two
3. Step three
Expected Behavior
[What should happen.]
Actual Behavior
[What actually happens.]
Environment / Notes
[Any relevant context: route/URL, viewport, browser, console errors, API payloads — only if applicable.]
```
Rules:
- Steps must be minimal and reproducible.
- Expected vs actual: concrete and unambiguous.
- Omit "Environment / Notes" section if not relevant.
## Step 4 — Clarify
Show the draft to the user.
**Use `AskUserQuestion` tool to ask:**
- Are steps to reproduce complete and accurate?
- Severity: Blocker / Critical / Major / Minor / Trivial?
- Any related tickets or recent changes to link?
Incorporate feedback. Repeat until user approves.
## Step 5 — Determine Project
> **Project routing rules (always apply these):**
> - Frontend code (UI, UX, web app, components, services) → `NCWF`
> - Backend code (game engine, bots, API, services, coordinator) → `NCS`
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
- Frontend / UI / UX → project: `NCWF` (default for this repo)
- Backend / coordinator / systems / bot / engine → project: `NCS`
- Kubernetes, pipelines, CI/CD, infrastructure → project: `NCI`
If ambiguous, ask the user.
## Step 6 — Create Issue
Call `mcp__youtrack__create_issue` with:
- `project`: determined in Step 5
- `summary`: concise title describing what is broken (≤72 chars, sentence case)
- `description`: full formatted defect report from Step 3 (Markdown)
- `type`: `Bug`
## Step 7 — Report
Display the created issue ID and URL.
Ask if a linked investigation or fix task is needed.
+114
View File
@@ -0,0 +1,114 @@
# Create User Story in YouTrack
Automated user-story creation workflow. Topic/hint: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Stories raised
here usually belong to project `NCWF`, but routing in Step 5 still applies.
## Step 1 — Gather Context
Use `AskUserQuestion` tool to ask the user (max 4 questions at once):
1. **Domain** — Is this frontend (UI/UX) work, or does it touch backend/infrastructure?
2. **User type** — Who is the actor? (e.g. player, spectator, admin, anonymous visitor)
3. **Action** — What should the user be able to do?
4. **Goal/value** — Why? What outcome does it enable?
If `$ARGUMENTS` already answers some of these, skip those questions.
## Step 2 — Research (if needed)
If the topic involves unfamiliar UI flows, component structure, or technical constraints:
- Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`).
- Use `WebSearch` if the topic involves Angular APIs, external standards or protocols.
- Do NOT guess. Surface findings before drafting.
## Step 3 — Draft Story
Compose the full story using this template:
```
As a [type of user]
I want to [perform an action]
So that [achieve a goal or value]
Description
[Additional context or business logic for this story.]
Acceptance Criteria
[List the specific, measurable criteria that define when this story is done:]
- Criterion 1
- Criterion 2
- Criterion 3
Implementation Notes
[Technical notes, component/service references, design refs, or constraints.]
```
Rules:
- User story line: plain English, present tense, from user's perspective.
- Acceptance criteria: testable, unambiguous, one condition each.
- Implementation notes: optional — only include if there are known constraints, related tickets, or design refs.
## Step 4 — Clarify Acceptance Criteria
Show the draft to the user.
**Use `AskUserQuestion` tool to ask:**
- Are the acceptance criteria complete and correct?
- Any implementation constraints to add?
- Priority (if known)?
Incorporate feedback. Repeat until user approves.
## Step 5 — Determine Project
> **Project routing rules (always apply these):**
> - Frontend code (UI, UX, web app, components, services) → `NCWF`
> - Backend code (game engine, bots, API, services, coordinator) → `NCS`
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
- Frontend / UI / UX → project: `NCWF` (default for this repo)
- Backend / coordinator / systems / bot / engine → project: `NCS`
- Kubernetes, pipelines, CI/CD, infrastructure → project: `NCI`
If still ambiguous, ask the user.
## Step 6 — Create Issue
Call `mcp__youtrack__create_issue` with:
- `project`: determined in Step 5
- `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case)
- `description`: full formatted story from Step 3 (Markdown)
- `type`: `Feature` (or `Task` if purely technical with no user-facing value)
## Step 7 — Link Issues
After creation, ask the user (use `AskUserQuestion` if interactive, otherwise infer from context):
> Are there related issues to link? (skip if none)
Collect any issue IDs the user mentions. For each, determine the correct relation and call `mcp__youtrack__link_issues`:
| Situation | Relation to use |
|-----------|----------------|
| This story must be done before another | `blocks` |
| Another story must be done before this | `is blocked by` |
| Stories share domain or are related | `relates to` |
| This is a child of an epic/story | `subtask of` |
| This is a parent grouping subtasks | `parent for` |
| This depends on another ticket's output | `depends on` |
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.
## Step 8 — Report
Display the created issue ID and URL.
List any links created (relation type + linked issue ID).
Ask if a linked sub-task or implementation ticket is needed.
+92
View File
@@ -0,0 +1,92 @@
# Estimate Issue Time in YouTrack
Sprint planning time estimator. Issue ID or empty for full current sprint: `$ARGUMENTS`
This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`.
## Step 1 — Determine Scope
**Single-issue mode** (`$ARGUMENTS` set):
- Call `mcp__youtrack__get_issue` on `$ARGUMENTS`.
- Proceed with that issue only.
**Sprint mode** (`$ARGUMENTS` empty):
- Call `mcp__youtrack__search_issues` with query `project: NCWF Sprints: {current sprint} #Unresolved`.
- If query returns 0 results, use `AskUserQuestion` to ask for the sprint name, then retry with `project: NCWF Sprints: {name}`.
- Collect all returned issues.
## Step 2 — Build Issue Tree
For each top-level issue from Step 1:
1. Fetch full details via `mcp__youtrack__get_issue`: summary, description, acceptance criteria, Type, existing `Zeitschätzung`, linked issues.
2. Identify subtasks from links with relation `subtask of` (i.e. issues where the fetched issue is the parent).
3. Recursively fetch subtasks until all leaves are known.
4. Group into tree: Epic → Story → Task/Subtask.
**Leaf node** = issue with no subtask children.
**Parent node** = issue that has at least one subtask child.
## Step 3 — Estimate Leaf Nodes
For each leaf node:
1. Read: summary, description, acceptance criteria, implementation notes.
2. If scope is unclear, search codebase (`Grep`/`Bash`) under `src/app/` for related files to gauge complexity.
3. Assign estimate using this scale:
| Size | Criteria | Estimate |
|------|----------|----------|
| Trivial | Style tweak, copy change, 1-file tweak | 30m |
| Small | 13 files, single component/service, no unknowns | 1h2h |
| Medium | 36 files, new component + service wiring, some design | 3h5h |
| Large | 6+ files, cross-feature, non-trivial state/routing | 1d2d |
| XL | New feature area, major refactor, research spike | 3d5d |
4. Record: estimate + one-line reasoning.
5. Skip leaf if it already has `Zeitschätzung` set — note it as pre-estimated.
## Step 4 — Roll Up for Display
YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**.
Compute display-only rolled-up totals:
- Parent total = sum of all descendant leaf estimates (including pre-estimated ones).
- Flag any branch where some leaves are missing estimates (partial roll-up).
## Step 5 — Show Summary + Confirm
Display full tree with estimates. Format:
```
Epic NCWF-10: Board UI overhaul [4h 30m] ← rolled up
Story NCWF-11: Drag-and-drop pieces [2h 30m] ← rolled up
Task NCWF-12: Add drag directive 1h 30m ← leaf (new)
Task NCWF-13: Add component specs 1h ← leaf (new)
Story NCWF-14: Move-list panel [2h] ← rolled up
Task NCWF-15: Render SAN move list 2h ← leaf (pre-set, skipped)
```
Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack.
If sprint mode, show grand total at bottom:
```
Sprint total: Xd Yh Zm (N issues, M leaves to update)
```
**Use `AskUserQuestion` tool:**
- Does the breakdown look right?
- Any estimates to adjust before writing to YouTrack?
Incorporate all feedback before proceeding.
## Step 6 — Write Estimates
On user approval, write estimates **only to leaf nodes** (bottom-up order):
- For each leaf with a new estimate, call `mcp__youtrack__update_issue` with field `Zeitschätzung` = approved estimate.
- YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`.
- Skip leaves already pre-estimated.
## Step 7 — Report
List all updated issues with set estimates.
Show final rolled-up totals per Epic/Story (read back from YouTrack via `mcp__youtrack__get_issue` if needed).
In sprint mode, show total sprint estimate.
+143
View File
@@ -0,0 +1,143 @@
# Fix Defect from YouTrack
Automated defect-fix workflow. Ticket ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates:
- Build: `npm run build`
- Test: `npm test -- --watch=false --browsers=ChromeHeadless`
- Format: `npx prettier --write .` (check with `npx prettier --check .`)
## Step 1 — Fetch Ticket
Call `mcp__youtrack__get_issue` with ID `$ARGUMENTS`.
Extract and display: summary, description, steps to reproduce, Priority, Subsystem.
## Step 2 — Create Worktree
Derive branch name from ticket:
- `type` from YouTrack issue type: `bug``fix`, `feature`/`task``feat`, `refactor``refactor`, else `chore`
- `scope` from affected area (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
Branch format: `<type>/<ticket-id>-<description>`
Example: `fix/NCWF-123-board-flip-resets-selection`
Call `EnterWorktree` with that branch name.
All subsequent file work happens inside this worktree.
## Step 3 — Identify Root Cause (read-only)
1. Run `npm run build` — capture all errors and warnings.
2. Run `npm test -- --watch=false --browsers=ChromeHeadless` — capture all failures.
3. Spawn `cavecrew-investigator` with: ticket description + build/test output → locate root cause (files, line numbers, what's wrong).
4. **If anything is ambiguous (reproduction unclear, scope uncertain, conflicting signals), use `AskUserQuestion` tool to ask — max 4 questions at once.**
5. **Report findings to user. No file writes yet. Wait for acknowledgement before continuing.**
## Step 3b — Complexity Assessment + Subtasks
After root cause confirmed, assess scope:
**Simple** (12 files, single concern, < 1 hour estimated): proceed directly to Step 4.
**Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding.
To create subtasks:
1. Break fix into discrete, independently-completable tasks (e.g. "Fix selection state in BoardComponent", "Add spec for flip behaviour", "Update GameService move stream").
2. For each subtask call `mcp__youtrack__create_issue` with:
- `project`: based on subtask content — do **not** inherit from parent. Frontend/UI → `NCWF`; backend code → `NCS`; Kubernetes/pipelines/CI-CD/infrastructure → `NCI`. If ambiguous, ask user.
- `summary`: concise action-oriented title
- `type`: `Task`
- `description`: what to do and why
3. Call `mcp__youtrack__link_issues` to link each subtask to `$ARGUMENTS` with relation `subtask of`.
4. Check if the ticket description or comments mention other issue IDs. For each mentioned ID, suggest a link and confirm with user:
- Fix depends on another fix finishing first → `is blocked by`
- This fix blocks another ticket → `blocks`
- Logically related but independent → `relates to`
5. List created subtask IDs and any additional links to user.
Then proceed to Step 4, implementing subtasks in order.
## Step 4 — Fix
1. Implement fix (use the `general-purpose` agent for non-trivial multi-file changes; inline edits for small ones).
2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green (add new specs for new behaviour; do not modify existing specs unless requirements changed).
4. Run `npx prettier --write .`**blocking, foreground only**. Wait for completion before continuing.
5. Run `npx prettier --check .`**blocking, foreground only** (never `run_in_background`). Wait for exit code 0. Must be green.
- If it fails, fix all issues and re-run until exit code 0.
- **Do NOT proceed to Step 5 until the build, tests and format check all pass.**
If any step fails, iterate until all pass.
## Step 5 — Review
Spawn `cavecrew-reviewer` on the full diff.
Display findings grouped by severity.
## Step 5b — Apply Review Findings
If the review produced any findings (any severity):
1. Implement all agreed fixes.
2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
4. Run `npx prettier --write .` then `npx prettier --check .`**blocking, foreground only**. Wait for exit code 0.
5. Re-spawn `cavecrew-reviewer` on the updated diff to confirm all findings are resolved.
Repeat until review is clean or user explicitly accepts remaining findings.
## Step 6 — Confirm + Push
Show summary: ticket, branch, files changed, review findings.
**Use `AskUserQuestion` tool to ask for explicit approval before pushing.** Include any open questions about commit message scope or body if unclear.
On approval, commit following Conventional Commits:
```
<type>(<scope>): <short description, imperative, ≤50 chars>
<optional body: what changed and why, wrap at 72 chars>
Closes $ARGUMENTS
https://knockoutwhist.youtrack.cloud/issue/$ARGUMENTS
```
- `type`: same as branch type (`fix`, `feat`, `refactor`, `chore`, etc.)
- `scope`: affected area (`ui`, `components`, `services`, `models`, `core`, `routing`, `pages`, `styles`)
- Subject: imperative mood, no period, lowercase
- Footer `Closes $ARGUMENTS` and ticket URL always present
Push branch to remote.
## Step 7 — Comment on Ticket
After successful push, call `mcp__youtrack__add_issue_comment` on `$ARGUMENTS` with:
```
Branch `<branch-name>` pushed.
<one-sentence summary of what was changed and why>
Files changed:
- <file1>
- <file2>
```
## Step 7b — Additional Links
After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked:
| Situation | Relation |
|-----------|---------|
| This fix blocks another open ticket | `blocks` |
| Another ticket must ship first | `is blocked by` |
| Related defect or story | `relates to` |
| Duplicate of another defect | `duplicates` |
Scan the ticket description and comments for any issue IDs that were mentioned but not yet linked. Suggest those automatically.
Call `mcp__youtrack__link_issues` for each confirmed link.
## Step 8 — Cleanup
Call `ExitWorktree` with `discard_changes: true` to delete the worktree.
(Branch was pushed in step 6 — commits are safe on remote; `discard_changes: true` bypasses the local-ahead guard.)
Report: branch pushed, ticket commented, links created, worktree deleted, done.
+159
View File
@@ -0,0 +1,159 @@
# Implement Feature from YouTrack
Automated feature-implementation workflow. Ticket ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project =
`NCWF`. Gates:
- Build: `npm run build`
- Test: `npm test -- --watch=false --browsers=ChromeHeadless`
- Format: `npx prettier --write .` (check with `npx prettier --check .`)
This workflow implements the given ticket **and all of its subtasks**, while
respecting `blocked by` dependencies. Tasks that live in other projects
(`NCS`, `NCI`, or any project other than `NCWF`) are never edited here — they
are collected and reported at the end with a ready-to-run prompt.
## Step 1 — Fetch Ticket + Build Task Tree
1. Call `mcp__youtrack__get_issue` with ID `$ARGUMENTS`.
2. Extract and display: summary, description, acceptance criteria, Priority, Subsystem, and the **issue links**.
3. From the links, build the task tree:
- **Subtasks** = issues linked as `subtask of` / `parent for` (children of `$ARGUMENTS`). Recurse: fetch each subtask with `get_issue` and collect its subtasks too.
- **Blocked-by** = for every task in the tree, record issues linked as `is blocked by`.
4. Classify each task by project (the prefix before the dash in the issue ID):
- **In-project** = `NCWF`.
- **Out-of-project** = `NCS`, `NCI`, or any other prefix. These are **never implemented here**.
5. Display the full tree: root, subtasks (nested), and for each its blockers + project tag.
## Step 2 — Resolve Implementation Order
1. Filter to **in-project (`NCWF`), not-yet-resolved** tasks only (root + subtasks). Out-of-project tasks are excluded from implementation.
2. Topologically sort by `blocked by`: a task is only implementable once all its in-project blockers are resolved.
3. A task is **blocked** (cannot start) if any blocker is:
- an in-project task that is not yet resolved in this run, **or**
- an out-of-project task (`NCS`/`NCI`/etc.) — these can't be resolved here.
4. Produce two lists:
- **Implementable order** — `NCWF` tasks, dependency-sorted.
- **Blocked tasks** — with the blocker(s) that stop them.
5. **Report both lists to the user.** Wait for acknowledgement before continuing.
## Step 3 — Create Worktree
Derive branch name from the root ticket `$ARGUMENTS`:
- `type` from YouTrack issue type: `feature`/`task``feat`, `refactor``refactor`, `bug``fix`, else `chore`
- `scope` from affected area (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
Branch format: `<type>/<ticket-id>-<description>`
Example: `feat/NCWF-456-add-board-theme-selector`
Call `EnterWorktree` with that branch name.
All subsequent file work happens inside this worktree. All implementable
tasks (root + subtasks) are implemented on this one branch.
## Step 4 — Understand Requirements (read-only)
1. Run `npm run build` — confirm baseline is green.
2. Run `npm test -- --watch=false --browsers=ChromeHeadless` — confirm baseline is green.
3. For the root + each implementable subtask, spawn `cavecrew-investigator` with: that task's description + acceptance criteria → locate affected files under `src/app/`, relevant components/services/models, routes, integration touch-points.
4. **If anything is ambiguous (scope unclear, acceptance criteria missing, design decisions needed), use `AskUserQuestion` tool to ask — max 4 questions at once.**
5. **Report plan to user: per task — what will be added/changed, which files, which areas. No file writes yet. Wait for acknowledgement before continuing.**
## Step 5 — Implement (per task, in dependency order)
For each task in the implementable order from Step 2, do the following before moving to the next:
1. Implement task (use the `general-purpose` agent for non-trivial multi-file changes; inline edits for small ones).
2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green (add new specs for new behaviour; do not modify existing specs unless requirements changed).
4. Run `npx prettier --write .`**blocking, foreground only**. Wait for completion before continuing.
5. Run `npx prettier --check .`**blocking, foreground only** (never `run_in_background`). Wait for exit code 0. Must be green.
- If it fails, fix all issues and re-run until exit code 0.
- **Do NOT proceed to the next task until build, tests and format check all pass.**
If any step fails, iterate until all pass. Once a task is fully green it counts
as **resolved** for the purpose of unblocking later tasks — re-check Step 2's
blocked list: any task whose blockers are now all resolved becomes implementable.
## Step 6 — Review
Spawn `cavecrew-reviewer` on the full diff (covering all implemented tasks).
Display findings grouped by severity.
## Step 6b — Apply Review Findings
If the review produced any findings (any severity):
1. Implement all agreed fixes.
2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
4. Run `npx prettier --write .` then `npx prettier --check .`**blocking, foreground only**. Wait for exit code 0.
5. Re-spawn `cavecrew-reviewer` on the updated diff to confirm all findings are resolved.
Repeat until review is clean or user explicitly accepts remaining findings.
## Step 7 — Confirm + Push
Show summary: root ticket, implemented subtasks, branch, files changed, review findings.
**Use `AskUserQuestion` tool to ask for explicit approval before pushing.** Include any open questions about commit message scope or body if unclear.
On approval, commit following Conventional Commits:
```
<type>(<scope>): <short description, imperative, ≤50 chars>
<optional body: what changed and why, wrap at 72 chars>
Closes $ARGUMENTS
<also list Closes <ID> for each implemented subtask>
https://knockoutwhist.youtrack.cloud/issue/$ARGUMENTS
```
- `type`: same as branch type (`feat`, `refactor`, `chore`, etc.)
- `scope`: affected area (`ui`, `components`, `services`, `models`, `core`, `routing`, `pages`, `styles`)
- Subject: imperative mood, no period, lowercase
- Footer `Closes <ID>` for the root and every resolved subtask, plus the root ticket URL.
Push branch to remote.
## Step 8 — Comment on Tickets
After successful push, call `mcp__youtrack__add_issue_comment` on `$ARGUMENTS` **and on each implemented subtask** with:
```
Branch `<branch-name>` pushed.
<one-sentence summary of what was added and why>
Files changed:
- <file1>
- <file2>
```
## Step 9 — Cleanup
Call `ExitWorktree` with `discard_changes: true` to delete the worktree.
(Branch was pushed in step 7 — commits are safe on remote; `discard_changes: true` bypasses the local-ahead guard.)
## Step 10 — Report Blocked + Cross-Project Tasks
Final report to the user, in two sections:
### Blocked in-project tasks
List any `NCWF` tasks that could **not** be implemented, with the blocker(s)
that stopped them. (These can be re-run with this command once blockers clear.)
### Cross-project tasks (NCS / NCI / other)
For every out-of-project task discovered in the tree (whether it was a subtask
or a blocker), output one entry:
```
- <ID> [<PROJECT>]: <summary>
Prompt: /implement-feature <ID>
```
Where `Prompt` is a short, copy-pasteable instruction to implement that task in
its own project — e.g. the ticket ID plus a one-line description of what the
other project needs to do so this project's blocked tasks can proceed.
End with: branch pushed, tickets commented, worktree deleted, plus the counts of
implemented / blocked / cross-project tasks.
+111
View File
@@ -0,0 +1,111 @@
# Split Story into Subtasks in YouTrack
Split a user story into smaller, implementable subtasks. Story ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo. Frontend subtasks route to `NCWF`.
## Step 1 — Fetch Story
Call `mcp__youtrack__get_issue` with ID `$ARGUMENTS`.
Extract and display: summary, description, acceptance criteria, implementation notes.
## Step 2 — Research (if needed)
If the story involves unfamiliar UI flows or technical constraints:
- Search repo for relevant code under `src/app/` (`Grep`/`Bash`).
- Use `WebSearch` for Angular APIs, external standards or protocols.
- Do NOT guess. Surface findings before proposing splits.
## Step 3 — Propose Split
Analyse the story and propose a set of subtasks. Rules:
- Each subtask = one unit of work, completable independently or in sequence.
- No subtask should exceed ~2 days of work.
- Name subtasks in imperative mood (e.g. "Add board theme service", "Render theme picker component").
- Cover the full scope of the parent story — no gaps.
Show proposed subtask list to user (titles only) and ask:
**Use `AskUserQuestion` tool:**
- Does the split look right?
- Any subtasks to add, remove, or merge?
- Should any subtask be assigned to a specific person?
Incorporate feedback. Repeat until user approves the list.
## Step 4 — Draft Each Subtask
For each approved subtask, compose description using this template:
```
[Brief description of what needs to be done for this subtask.]
Steps / Tasks
- Task 1
- Task 2
- Task 3
Definition of Done
What must be true for this subtask to be considered complete:
- Code implemented
- Specs passing
- Reviewed and merged
```
Rules:
- Steps/Tasks: concrete, ordered where order matters.
- Definition of Done: adjust per subtask — not all subtasks need the same criteria (e.g. a research spike has different DoD than an implementation task).
- Keep description short — one paragraph max.
## Step 5 — Determine Project per Subtask
Assign each subtask's project based on its content — do **not** inherit blindly from parent:
- Frontend code (UI, UX, web app, components, services) → `NCWF`
- Backend code (game engine, bots, API, services, coordinator) → `NCS`
- Kubernetes, pipelines, CI/CD, DB setup, infrastructure → `NCI`
If a subtask's project is ambiguous, ask the user before creating it.
## Step 6 — Create Subtasks
For each subtask call `mcp__youtrack__create_issue` with:
- `project`: from Step 5
- `summary`: subtask title (≤72 chars, sentence case)
- `description`: full formatted description from Step 4 (Markdown)
- `type`: `Task`
Then call `mcp__youtrack__link_issues` to link each created subtask to `$ARGUMENTS` with relation `subtask of`.
## Step 6b — Inter-Subtask Links
If subtasks must be done in sequence (one depends on output of another), add ordering links:
- For each dependency pair call `mcp__youtrack__link_issues` with relation `is blocked by` (subtask B is blocked by subtask A).
Ask the user to confirm sequencing before adding these links:
> Do any subtasks have ordering dependencies? (e.g. "Add theme service must come before Render theme picker")
## Step 6c — External Links
Scan `$ARGUMENTS` description and implementation notes for any referenced issue IDs not already linked. For each:
| Situation | Relation |
|-----------|---------|
| Parent story blocks another epic/story | `blocks` |
| Story depends on another epic completing | `is blocked by` |
| Related story in same domain | `relates to` |
| This story duplicates or supersedes | `duplicates` |
Suggest links to the user and call `mcp__youtrack__link_issues` on confirmation.
## Step 7 — Report
List all created subtask IDs and summaries.
List all links created (subtask-of, blocking chains, external).
Display parent story link.
Ask if any subtask needs further splitting.
+4
View File
@@ -37,3 +37,7 @@ __screenshots__/
# System files
.DS_Store
Thumbs.db
# Claude Code
/.claude/settings.local.json
/.claude/worktrees/
+68
View File
@@ -23,3 +23,71 @@
### 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))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.0...0.0.0) (2026-05-12)
### Bug Fixes
* NCWF-1 401 ([#5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/5)) ([f8f93ef](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f8f93efff48f1d7198023fed45b675c2e225df36))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.1...0.0.0) (2026-05-12)
### Bug Fixes
* NCWF-1 401 ([#6](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/6)) ([6d1e06d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/6d1e06dfd606b93d029e9c9b84eea6f8b3b6294e))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.2...0.0.0) (2026-05-14)
### Bug Fixes
* added missing challenge routes ([61000f8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/61000f8a22aff8b524664a756cc933834365f923))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.3...0.0.0) (2026-05-15)
### Bug Fixes
* build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080))
* NCWF-2 bugs and desing fixes ([#7](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/7)) ([c02414e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c02414ea40177b05a5e62dcf68dcb44efa6d3740))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.4...0.0.0) (2026-06-01)
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.5...0.0.0) (2026-06-02)
### Bug Fixes
* NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.6...0.0.0) (2026-06-06)
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.7...0.0.0) (2026-06-10)
### Bug Fixes
* route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea))
## [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))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.3.0...0.0.0) (2026-06-17)
### Features
* NCWF-5/6/7/8/9 chess analysis page and engine integration ([#11](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/11)) ([f9420e5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f9420e5848d8724bcb0e9cf08f08b871c91cf4ba))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.0...0.0.0) (2026-06-17)
### Bug Fixes
* **auth:** attach Bearer token to /api/bots requests ([#12](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/12)) ([a54957a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a54957aa74ef15bf2dd439d386e221ac134c5c5c))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.1...0.0.0) (2026-06-17)
### Bug Fixes
* NCS-122 send WS token via first-message auth instead of query param ([#13](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/13)) ([1d2c217](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1d2c217da8982d361e2eb7de26f6447171a1dd43))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.2...0.0.0) (2026-06-21)
### Bug Fixes
* **analysis:** fix API field mismatch and enable full game analysis ([ce1fb0d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ce1fb0d60b695093495ee0ad824c511dd2db7fbb))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.3...0.0.0) (2026-06-21)
### Bug Fixes
* **tournaments:** load both user bots and official bots in join dialog ([5b5fd6f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/5b5fd6f027b4aedb951a802725fcd929d514c359))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.4...0.0.0) (2026-06-21)
### Features
* **tournaments:** remove external server add/remove UI ([412591d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/412591dfe0119dbec84c3783cd94590810884580))
+1
View File
@@ -23,3 +23,4 @@ RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/docker-entrypoint.sh"]
#
+16
View File
@@ -1,4 +1,14 @@
{
"/api/analysis": {
"target": "http://localhost:8087",
"secure": false,
"changeOrigin": true
},
"/api/tournament": {
"target": "http://localhost:8089",
"secure": false,
"changeOrigin": true
},
"/api/account": {
"target": "http://localhost:8083",
"secure": false,
@@ -9,6 +19,12 @@
"secure": false,
"changeOrigin": true
},
"/api/user/ws": {
"target": "http://localhost:8084",
"secure": false,
"changeOrigin": true,
"ws": true
},
"/api": {
"target": "http://localhost:8080",
"secure": false,
+10
View File
@@ -2,10 +2,20 @@ import { Routes } from '@angular/router';
import { GameComponent } from './pages/game/game.component';
import { WelcomeComponent } from './pages/welcome/welcome.component';
import { ProfileComponent } from './pages/profile/profile.component';
import { ChallengesComponent } from './pages/challenges/challenges.component';
import { GamesComponent } from './pages/games/games.component';
import { TournamentsComponent } from './pages/tournaments/tournaments.component';
import { BotsComponent } from './pages/bots/bots.component';
import { AnalysisComponent } from './pages/analysis/analysis.component';
export const routes: Routes = [
{ path: '', component: WelcomeComponent },
{ path: 'profile', component: ProfileComponent },
{ path: 'games', component: GamesComponent },
{ path: 'challenges', component: ChallengesComponent },
{ path: 'tournaments', component: TournamentsComponent },
{ path: 'bots', component: BotsComponent },
{ path: 'analysis', component: AnalysisComponent },
{ path: 'game/:gameId', component: GameComponent },
{ path: '**', redirectTo: '' }
];
+2 -1
View File
@@ -1,12 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])]
providers: [provideRouter([]), provideHttpClient()]
}).compileComponents();
});
@@ -0,0 +1,108 @@
:host {
display: block;
}
.empty {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
padding: 12px 16px;
font-family: var(--nc-mono, monospace);
letter-spacing: 0.06em;
}
.move-grid {
display: grid;
grid-template-columns: 28px 1fr 1fr;
gap: 2px 4px;
padding: 8px 12px;
font-family: var(--nc-mono, monospace);
font-size: 12px;
}
.mv-num {
color: rgba(255, 255, 255, 0.3);
font-size: 10px;
display: flex;
align-items: center;
letter-spacing: 0.04em;
}
.mv {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
cursor: pointer;
border-radius: 2px;
color: rgba(255, 255, 255, 0.8);
transition:
background 0.12s,
color 0.12s;
}
.mv:hover {
background: rgba(255, 255, 255, 0.06);
color: #fff;
}
.mv.active {
background: rgba(255, 69, 200, 0.18);
color: #fff;
}
.mv-empty {
cursor: default;
color: rgba(255, 255, 255, 0.25);
}
.mv-empty:hover {
background: transparent;
color: rgba(255, 255, 255, 0.25);
}
.mv-san {
flex: 1;
}
.mv-placeholder {
opacity: 0.4;
}
/* Quality badges */
.mv-badge {
font-size: 10px;
font-weight: 700;
padding: 1px 4px;
border-radius: 2px;
flex-shrink: 0;
}
.q-brilliant .mv-badge,
.mv-badge.q-brilliant {
color: #5ee5a1;
background: rgba(94, 229, 161, 0.15);
}
.q-best .mv-badge,
.mv-badge.q-best {
color: #5ee5a1;
background: rgba(94, 229, 161, 0.1);
}
.q-inaccuracy .mv-badge,
.mv-badge.q-inaccuracy {
color: #ffb13a;
background: rgba(255, 177, 58, 0.15);
}
.q-mistake .mv-badge,
.mv-badge.q-mistake {
color: #ff7a7a;
background: rgba(255, 122, 122, 0.15);
}
.q-blunder .mv-badge,
.mv-badge.q-blunder {
color: #ff4444;
background: rgba(255, 68, 68, 0.18);
}
@@ -0,0 +1,48 @@
@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>
}
@@ -0,0 +1,83 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { AnnotatedMove, MoveQuality } from '../../models/analysis.models';
interface AnnotatedPair {
white: AnnotatedMove | null;
black: AnnotatedMove | null;
}
const QUALITY_LABELS: Record<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)}`;
}
}
@@ -0,0 +1,51 @@
.board-actions {
display: flex;
gap: 6px;
padding: 8px;
background: var(--nc-surface);
border: 1px solid var(--nc-border);
}
.board-actions.disabled {
opacity: 0.5;
pointer-events: none;
}
.btn {
flex: 1;
font-family: var(--nc-sans);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 600;
padding: 8px 10px;
cursor: pointer;
border: 1px solid var(--nc-border-strong);
background: var(--nc-btn-bg, rgba(255, 255, 255, 0.03));
color: var(--nc-text);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn:hover:not(:disabled) {
background: var(--nc-btn-hover-bg, rgba(255, 255, 255, 0.07));
border-color: var(--nc-text-muted);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-danger {
color: var(--nc-danger);
border-color: var(--nc-danger-soft, rgba(255, 122, 122, 0.3));
}
.btn-danger:hover:not(:disabled) {
background: var(--nc-danger-bg, rgba(255, 122, 122, 0.08));
border-color: var(--nc-danger);
}
@@ -0,0 +1,27 @@
<div class="board-actions" [class.disabled]="isGameFinished">
<button class="btn" type="button" [disabled]="isGameFinished" (click)="takeback.emit()">
<svg width="12" height="12" viewBox="0 0 24 24" 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>
Takeback
</button>
<button class="btn" type="button" [disabled]="isGameFinished" (click)="offerDraw.emit()">
<svg width="12" height="12" viewBox="0 0 24 24" 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>
Offer Draw
</button>
<button class="btn btn-danger" type="button" [disabled]="isGameFinished" (click)="resign.emit()">
<svg width="12" height="12" 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>
Resign
</button>
</div>
@@ -0,0 +1,16 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
@Component({
selector: 'app-board-actions-bar',
standalone: true,
imports: [],
templateUrl: './board-actions-bar.component.html',
styleUrl: './board-actions-bar.component.css'
})
export class BoardActionsBarComponent {
@Input() undoAvailable = false;
@Input() isGameFinished = false;
@Output() takeback = new EventEmitter<void>();
@Output() offerDraw = new EventEmitter<void>();
@Output() resign = new EventEmitter<void>();
}
@@ -15,7 +15,7 @@
<div class="form-group">
<label for="targetUsername">Opponent Username</label>
<input type="text" id="targetUsername" formControlName="targetUsername"
placeholder="Enter opponent's username" [disabled]="loading" required />
placeholder="Enter opponent's username" required />
<small *ngIf="form.get('targetUsername')?.hasError('required') && form.get('targetUsername')?.touched">
Username is required
</small>
@@ -24,7 +24,7 @@
<!-- Player Color Selection -->
<div class="form-group">
<label for="color">Play as</label>
<select id="color" formControlName="color" [disabled]="loading">
<select id="color" formControlName="color">
<option value="white">White</option>
<option value="black">Black</option>
<option value="random">Random</option>
@@ -34,7 +34,7 @@
<!-- Time Control Mode Selection -->
<div class="form-group">
<label for="timeMode">Time Control</label>
<select id="timeMode" formControlName="timeMode" [disabled]="loading">
<select id="timeMode" formControlName="timeMode">
<option value="blitz">Blitz</option>
<option value="rapid">Rapid</option>
<option value="classical">Classical</option>
@@ -58,13 +58,11 @@
<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"
[disabled]="loading" />
<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"
[disabled]="loading" />
<input type="number" id="incrementSeconds" formControlName="incrementSeconds" min="0" max="300" />
</div>
</div>
</div>
@@ -72,7 +70,7 @@
<!-- TTL (Time to Live) -->
<div class="form-group">
<label for="ttlSeconds">Challenge Expires In</label>
<select id="ttlSeconds" formControlName="ttlSeconds" [disabled]="loading">
<select id="ttlSeconds" formControlName="ttlSeconds">
<option *ngFor="let ttl of ttlOptions" [value]="ttl.seconds">
{{ ttl.label }}
</option>
@@ -124,11 +124,11 @@ export class ChallengeCreateDialogComponent implements OnInit, OnDestroy {
this.errorMessage = '';
this.loading = true;
this.form.disable();
const limitSeconds = Math.round((this.form.get('limitMinutes')?.value || 0) * 60);
const incrementSeconds = this.form.get('incrementSeconds')?.value || 0;
const ttlSeconds = this.form.get('ttlSeconds')?.value;
const color = (this.form.get('color')?.value || 'random') as PlayerColor;
const limitSeconds = Math.round((this.form.getRawValue().limitMinutes || 0) * 60);
const { incrementSeconds, ttlSeconds, color: rawColor } = this.form.getRawValue();
const color = (rawColor || 'random') as PlayerColor;
this.challengeService.sendChallenge(targetUsername, {
timeControl: {
@@ -138,7 +138,7 @@ export class ChallengeCreateDialogComponent implements OnInit, OnDestroy {
color,
ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined
})
.pipe(finalize(() => (this.loading = false)))
.pipe(finalize(() => { this.loading = false; this.form.enable(); }))
.subscribe({
next: (challenge) => {
// Challenge sent successfully - navigate to challenges page to view status
@@ -1,5 +1,6 @@
import { Component, Input, Output, EventEmitter, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Challenge } from '../../models/challenge.models';
import { ChallengeService } from '../../services/challenge.service';
import { finalize } from 'rxjs';
@@ -19,6 +20,7 @@ export class ChallengeNotificationComponent {
@Output() close = new EventEmitter<void>();
private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router);
acceptingChallenge = false;
decliningChallenge = false;
@@ -35,8 +37,13 @@ export class ChallengeNotificationComponent {
this.challengeService.acceptChallenge(this.challenge.id)
.pipe(finalize(() => (this.acceptingChallenge = false)))
.subscribe({
next: () => {
this.accept.emit(this.challenge);
next: (acceptedChallenge) => {
this.accept.emit(acceptedChallenge);
if (acceptedChallenge.gameId) {
void this.router.navigate(['/game', acceptedChallenge.gameId]);
} else {
this.errorMessage = 'Challenge accepted, but no game was created.';
}
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Failed to accept challenge');
@@ -52,7 +59,7 @@ export class ChallengeNotificationComponent {
this.decliningChallenge = true;
this.errorMessage = '';
this.challengeService.declineChallenge(this.challenge.id, { reason: 'Not interested' })
this.challengeService.declineChallenge(this.challenge.id, { reason: 'generic' })
.pipe(finalize(() => (this.decliningChallenge = false)))
.subscribe({
next: () => {
@@ -0,0 +1,53 @@
:host {
display: block;
width: 100%;
}
.timeline-wrap {
width: 100%;
overflow: hidden;
}
.timeline-svg {
display: block;
width: 100%;
height: 80px;
}
.midline {
stroke: rgba(255, 255, 255, 0.12);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.area-white {
fill: rgba(255, 255, 255, 0.18);
}
.area-black {
fill: rgba(20, 20, 30, 0.55);
}
.eval-line {
fill: none;
stroke: var(--nc-neon, #ff45c8);
stroke-width: 1.5;
stroke-linejoin: round;
stroke-linecap: round;
}
.active-marker {
stroke: var(--nc-warning, #ffb13a);
stroke-width: 1.5;
stroke-dasharray: 3 3;
opacity: 0.8;
}
.empty {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
padding: 12px 0;
font-family: var(--nc-mono, monospace);
letter-spacing: 0.06em;
text-align: center;
}
@@ -0,0 +1,53 @@
@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>
}
@@ -0,0 +1,73 @@
import { Component, Input, OnChanges } from '@angular/core';
import { AnnotatedMove } from '../../models/analysis.models';
interface TimelinePoint {
x: number;
y: number;
eval: number;
san: string;
plyIndex: number;
}
const CLAMP = 5; // clamp eval to ±5 pawns for display
const HEIGHT = 80;
const WIDTH = 600;
@Component({
selector: 'app-eval-timeline',
standalone: true,
imports: [],
templateUrl: './eval-timeline.component.html',
styleUrl: './eval-timeline.component.css',
})
export class EvalTimelineComponent implements OnChanges {
@Input({ required: true }) moves: AnnotatedMove[] = [];
@Input() activePly: number | null = null;
points: TimelinePoint[] = [];
evalPolyline = '';
polylineWhite = '';
polylineBlack = '';
svgWidth = WIDTH;
svgHeight = HEIGHT;
ngOnChanges(): void {
this.buildChart();
}
activeX(): number | null {
if (this.activePly === null) return null;
const pt = this.points[this.activePly];
return pt ? pt.x : null;
}
private buildChart(): void {
if (this.moves.length === 0) {
this.points = [];
this.evalPolyline = '';
this.polylineWhite = '';
this.polylineBlack = '';
return;
}
const total = this.moves.length;
this.points = this.moves.map((m, i) => {
const evalValue = m.evalAfter ?? 0;
const clamped = Math.max(-CLAMP, Math.min(CLAMP, evalValue));
const x = (i / Math.max(total - 1, 1)) * WIDTH;
// y=0 => white winning (top), y=HEIGHT => black winning (bottom)
const y = ((CLAMP - clamped) / (CLAMP * 2)) * HEIGHT;
return { x, y, eval: evalValue, san: m.san, plyIndex: i };
});
const coordStr = this.points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
this.evalPolyline = coordStr;
const mid = HEIGHT / 2;
const first = this.points[0];
const last = this.points[this.points.length - 1];
this.polylineWhite = `${first.x.toFixed(1)},${mid} ${coordStr} ${last.x.toFixed(1)},${mid}`;
this.polylineBlack = this.polylineWhite;
}
}
@@ -0,0 +1,132 @@
.card {
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.disclose summary {
list-style: none;
cursor: pointer;
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
.disclose summary::-webkit-details-marker {
display: none;
}
.panel-title {
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--nc-text-muted);
font-weight: 600;
}
.chev {
color: var(--nc-text-dim);
display: flex;
transition: transform 0.2s;
}
.disclose[open] .chev {
transform: rotate(180deg);
}
.disclose[open] summary {
border-bottom: 1px solid var(--nc-border);
}
.panel-body {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* Segmented control */
.seg {
display: flex;
border: 1px solid var(--nc-border);
padding: 2px;
background: var(--nc-seg-bg, rgba(0, 0, 0, 0.2));
}
.seg-btn {
flex: 1;
background: transparent;
border: none;
color: var(--nc-text-muted);
padding: 7px 10px;
font-size: 11px;
font-family: var(--nc-sans);
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.seg-btn.active {
background: var(--nc-neon);
color: #fff;
font-weight: 700;
}
.export-out {
width: 100%;
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
font-family: var(--nc-mono);
font-size: 11px;
padding: 10px;
resize: vertical;
min-height: 70px;
line-height: 1.5;
}
.export-out:focus {
outline: none;
border-color: var(--nc-neon-soft);
}
.export-row {
display: flex;
gap: 6px;
}
/* Buttons */
.btn {
flex: 1;
font-family: var(--nc-sans);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 600;
padding: 8px 10px;
cursor: pointer;
border: 1px solid var(--nc-border-strong);
background: var(--nc-btn-bg, rgba(255, 255, 255, 0.03));
color: var(--nc-text);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.15s, border-color 0.15s;
}
.btn:hover {
background: var(--nc-btn-hover-bg, rgba(255, 255, 255, 0.07));
border-color: var(--nc-text-muted);
}
.copy-notice {
margin: 0;
font-size: 11px;
color: var(--nc-success);
font-family: var(--nc-mono);
letter-spacing: 0.04em;
}
@@ -0,0 +1,51 @@
<details class="card disclose">
<summary>
<span class="panel-title">Export Position</span>
<span class="chev" aria-hidden="true">
<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="6 9 12 15 18 9"/>
</svg>
</span>
</summary>
<div class="panel-body">
<div class="seg" role="tablist" aria-label="Export format">
<button
class="seg-btn"
[class.active]="exportKind === 'fen'"
role="tab"
[attr.aria-selected]="exportKind === 'fen'"
(click)="setKind('fen')">FEN</button>
<button
class="seg-btn"
[class.active]="exportKind === 'pgn'"
role="tab"
[attr.aria-selected]="exportKind === 'pgn'"
(click)="setKind('pgn')">PGN</button>
</div>
<textarea class="export-out" [value]="exportValue" [placeholder]="exportPlaceholder" rows="4" readonly></textarea>
<div class="export-row">
<button class="btn" type="button" (click)="copy()">
<svg width="12" height="12" 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>
Copy
</button>
<button class="btn" type="button" (click)="download()">
<svg width="12" height="12" viewBox="0 0 24 24" 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>
Download
</button>
</div>
@if (copyNotice) {
<p class="copy-notice">{{ copyNotice }}</p>
}
</div>
</details>
@@ -0,0 +1,79 @@
import { Component, Input, OnChanges } from '@angular/core';
import { FormsModule } from '@angular/forms';
type ExportKind = 'fen' | 'pgn';
@Component({
selector: 'app-export-panel',
standalone: true,
imports: [FormsModule],
templateUrl: './export-panel.component.html',
styleUrl: './export-panel.component.css'
})
export class ExportPanelComponent implements OnChanges {
@Input() fen = '';
@Input() pgn = '';
exportKind: ExportKind = 'fen';
exportValue = '';
copyNotice = '';
private copyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
get exportPlaceholder(): string {
return this.exportKind === 'fen' ? 'FEN will appear here' : 'PGN will appear here';
}
ngOnChanges(): void {
this.syncValue();
}
setKind(kind: ExportKind): void {
this.exportKind = kind;
this.syncValue();
}
copy(): void {
if (!this.exportValue.trim()) {
return;
}
if (!navigator.clipboard?.writeText) {
this.showNotice('Ready in the text box.');
return;
}
void navigator.clipboard
.writeText(this.exportValue)
.then(() => this.showNotice('Copied!'))
.catch(() => this.showNotice('Ready in the text box.'));
}
download(): void {
if (!this.exportValue.trim()) {
return;
}
const ext = this.exportKind === 'pgn' ? 'pgn' : 'txt';
const blob = new Blob([this.exportValue], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `game.${ext}`;
a.click();
URL.revokeObjectURL(url);
}
private syncValue(): void {
this.exportValue = this.exportKind === 'fen' ? this.fen : this.pgn;
}
private showNotice(msg: string): void {
this.copyNotice = msg;
if (this.copyNoticeTimer !== null) {
clearTimeout(this.copyNoticeTimer);
}
this.copyNoticeTimer = setTimeout(() => {
this.copyNotice = '';
}, 1800);
}
}
@@ -1,58 +1,293 @@
@import '../../button-template.css';
:host {
--auth-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--auth-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
--auth-neon: #ff45c8;
--auth-neon-soft: rgba(255, 69, 200, 0.55);
--auth-bg: #06060d;
}
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(2, 2, 10, 0.58);
background: rgba(4, 2, 12, 0.72);
backdrop-filter: blur(6px);
display: grid;
place-items: center;
z-index: 350;
padding: 1rem;
animation: backdrop-in 180ms ease-out both;
}
.dialog-card {
width: min(460px, 100%);
background: var(--dlg-bg);
border: 1.5px solid var(--dlg-border);
box-shadow: var(--bb-glow);
border-radius: 4px;
padding: 1rem;
position: relative;
width: min(420px, 100%);
background:
radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.10), transparent 60%),
linear-gradient(180deg, #0a0612 0%, #06060d 100%);
border: 1px solid var(--auth-neon-soft);
border-radius: 14px;
padding: 28px 26px 22px;
display: grid;
gap: 0.7rem;
gap: 14px;
font-family: var(--auth-sans);
color: #fff;
box-shadow:
0 0 0 1px rgba(255, 69, 200, 0.06) inset,
0 0 30px rgba(255, 69, 200, 0.18),
0 30px 60px rgba(0, 0, 0, 0.55);
overflow: hidden;
animation: dialog-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.dialog-card::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
180deg,
transparent 0%,
rgba(255, 69, 200, 0.06) 50%,
transparent 100%
);
height: 35%;
animation: scanline 4.5s linear infinite;
mix-blend-mode: screen;
}
.dialog-card::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image:
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.012) 0px,
rgba(255, 255, 255, 0.012) 1px,
transparent 1px,
transparent 3px
);
border-radius: inherit;
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
position: relative;
z-index: 1;
}
.brand-tag {
font-family: var(--auth-mono);
font-size: 10px;
letter-spacing: 2.5px;
color: var(--auth-neon);
text-transform: uppercase;
opacity: 0.85;
animation: pulse-glow 2.4s ease-in-out infinite;
}
.brand-tag::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--auth-neon);
margin-right: 8px;
vertical-align: 1px;
box-shadow: 0 0 8px var(--auth-neon);
}
.close-btn {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.65);
width: 28px;
height: 28px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
line-height: 1;
display: grid;
place-items: center;
transition: all 0.18s ease;
}
.close-btn:hover {
color: var(--auth-neon);
border-color: var(--auth-neon-soft);
box-shadow: 0 0 10px rgba(255, 69, 200, 0.35);
}
.dialog-title {
font-family: 'Bebas Neue', sans-serif;
font-size: 22px;
letter-spacing: 2px;
color: var(--bb-title);
text-align: center;
font-family: var(--auth-sans);
font-weight: 700;
font-size: 26px;
letter-spacing: 0.5px;
color: #fff;
margin: 4px 0 0;
position: relative;
z-index: 1;
}
.dialog-subtitle {
font-family: var(--auth-mono);
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
letter-spacing: 0.6px;
margin-bottom: 6px;
position: relative;
z-index: 1;
}
form {
display: grid;
gap: 12px;
position: relative;
z-index: 1;
}
.field {
display: grid;
gap: 6px;
}
.field-label {
font-family: var(--auth-mono);
font-size: 10px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.55);
}
.dialog-input {
width: 100%;
background: rgba(4, 4, 20, 0.62);
border: 1px solid var(--bb-border);
color: var(--bb-title);
border-radius: 2px;
padding: 0.6rem 0.7rem;
font-family: 'Space Mono', monospace;
background: rgba(8, 5, 20, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #fff;
border-radius: 10px;
padding: 12px 14px;
font-family: var(--auth-mono);
font-size: 13px;
letter-spacing: 0.3px;
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.dialog-input::placeholder {
color: rgba(255, 255, 255, 0.28);
}
.dialog-input:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(0, 213, 255, 0.25);
}
.dialog-textarea {
min-height: 120px;
resize: vertical;
border-color: var(--auth-neon);
background: rgba(20, 6, 26, 0.7);
box-shadow: 0 0 0 3px rgba(255, 69, 200, 0.15), 0 0 18px rgba(255, 69, 200, 0.18);
}
.text-danger {
color: #dc3545;
font-size: 0.875rem;
color: #ff6ea0;
font-family: var(--auth-mono);
font-size: 11px;
letter-spacing: 0.3px;
}
.error-banner {
font-family: var(--auth-mono);
font-size: 12px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 69, 110, 0.08);
border: 1px solid rgba(255, 69, 110, 0.35);
color: #ff9bb4;
animation: shake 0.35s ease-out;
}
.dialog-actions {
display: flex;
gap: 10px;
margin-top: 6px;
position: relative;
z-index: 1;
}
.btn {
flex: 1;
border-radius: 10px;
padding: 12px 14px;
font-family: var(--auth-sans);
font-weight: 600;
font-size: 13px;
letter-spacing: 0.4px;
cursor: pointer;
transition: all 0.18s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-ghost {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
}
.btn-ghost:hover:not(:disabled) {
color: #fff;
border-color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.03);
}
.btn-primary {
background: var(--auth-neon);
border: 1px solid var(--auth-neon);
color: #1a0210;
box-shadow: 0 0 18px rgba(255, 69, 200, 0.45);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 0 26px rgba(255, 69, 200, 0.7);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.alt-line {
margin-top: 4px;
text-align: center;
font-family: var(--auth-mono);
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
position: relative;
z-index: 1;
}
.alt-line a {
color: var(--auth-neon);
cursor: pointer;
text-decoration: none;
margin-left: 4px;
}
.alt-line a:hover {
text-shadow: 0 0 8px var(--auth-neon-soft);
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(26, 2, 16, 0.35);
border-top-color: #1a0210;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.sr-only {
@@ -66,3 +301,28 @@
white-space: nowrap !important;
border: 0 !important;
}
@keyframes scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(300%); }
}
@keyframes pulse-glow {
0%, 100% { opacity: 0.85; }
50% { opacity: 1; }
}
@keyframes dialog-in {
from { opacity: 0; transform: translateY(8px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-4px); }
40%, 80% { transform: translateX(4px); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -1,36 +1,49 @@
<div class="dialog-overlay" (click)="closeDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-title">LOGIN</div>
<div class="dialog-head">
<span class="brand-tag">NowChess // Auth</span>
<button type="button" class="close-btn" (click)="closeDialog()" aria-label="Close">×</button>
</div>
<h2 class="dialog-title">Welcome back</h2>
<div class="dialog-subtitle">Sign in to continue your match</div>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<label for="username" class="sr-only">Username</label>
<input id="username" type="text" class="dialog-input" formControlName="username" placeholder="Username"
[disabled]="isLoading" />
@if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) {
<small class="text-danger">Username must be at least 3 characters</small>
}
<div class="field">
<label for="username" class="field-label">Username</label>
<input id="username" type="text" class="dialog-input" formControlName="username"
placeholder="your_handle" autocomplete="username" />
@if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) {
<small class="text-danger">Username must be at least 3 characters</small>
}
</div>
<label for="password" class="sr-only">Password</label>
<input id="password" type="password" class="dialog-input" formControlName="password" placeholder="Password"
[disabled]="isLoading" />
@if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) {
<small class="text-danger">Password must be at least 6 characters</small>
}
<div class="field">
<label for="password" class="field-label">Password</label>
<input id="password" type="password" class="dialog-input" formControlName="password"
placeholder="••••••••" autocomplete="current-password" />
@if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) {
<small class="text-danger">Password must be at least 6 characters</small>
}
</div>
@if (errorMessage) {
<div class="error-banner">{{ errorMessage }}</div>
<div class="error-banner">{{ errorMessage }}</div>
}
<div class="dialog-actions">
<button type="button" class="app-btn" (click)="openRegister()">Create account</button>
<button type="button" class="app-btn" (click)="closeDialog()">Cancel</button>
<button type="submit" class="app-btn" [disabled]="isLoading || loginForm.invalid">
<button type="button" class="btn btn-ghost" (click)="closeDialog()">Cancel</button>
<button type="submit" class="btn btn-primary" [disabled]="isLoading || loginForm.invalid">
@if (isLoading) {
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span class="spinner" aria-hidden="true"></span>
}
Login
{{ isLoading ? 'Signing in…' : 'Sign in' }}
</button>
</div>
<div class="alt-line">
New here?<a (click)="openRegister()">Create an account</a>
</div>
</form>
</div>
</div>
</div>
@@ -38,15 +38,18 @@ export class LoginDialogComponent {
this.isLoading = true;
this.errorMessage = null;
this.loginForm.disable();
const { username, password } = this.loginForm.value;
const { username, password } = this.loginForm.getRawValue();
this.authService.login(username, password).subscribe({
next: () => {
this.isLoading = false;
this.loginForm.enable();
this.onSuccess.emit();
},
error: (err) => {
this.isLoading = false;
this.loginForm.enable();
this.errorMessage = err.error?.message || 'Login failed. Please try again.';
}
});
@@ -0,0 +1,99 @@
.moves {
display: grid;
grid-template-columns: 38px 1fr 1fr;
font-family: var(--nc-mono);
font-size: 12px;
max-height: 260px;
overflow-y: auto;
}
.moves-empty {
grid-column: 1 / -1;
padding: 16px;
color: var(--nc-text-dim);
font-size: 12px;
text-align: center;
}
.mv-num {
padding: 6px 12px 6px 10px;
color: var(--nc-text-dim);
text-align: right;
border-right: 1px solid var(--nc-border);
}
.mv {
padding: 6px 10px;
color: var(--nc-text);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.mv:hover {
background: rgba(255, 69, 200, 0.06);
color: var(--nc-neon);
}
.mv.current {
background: rgba(255, 69, 200, 0.10);
color: var(--nc-neon);
}
.mv.mv-empty {
color: var(--nc-text-dim);
cursor: default;
}
.mv.mv-empty:hover {
background: transparent;
color: var(--nc-text-dim);
}
.moves-foot {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-top: 1px solid var(--nc-border);
}
.moves-nav {
display: flex;
gap: 2px;
}
.icon-btn {
background: transparent;
border: 1px solid transparent;
color: var(--nc-text-muted);
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.icon-btn:hover {
color: var(--nc-neon);
border-color: var(--nc-border);
}
.live-label {
font-family: var(--nc-mono);
font-size: 10px;
color: var(--nc-neon);
letter-spacing: 0.14em;
opacity: 0.8;
}
.live-label.reviewing {
color: var(--nc-warning);
opacity: 1;
}
.moves::-webkit-scrollbar { width: 6px; }
.moves::-webkit-scrollbar-track { background: transparent; }
.moves::-webkit-scrollbar-thumb { background: var(--nc-border-strong); border-radius: 3px; }
.moves::-webkit-scrollbar-thumb:hover { background: var(--nc-neon-soft); }
@@ -0,0 +1,46 @@
<div class="moves" role="list" #movesEl>
@if (movePairs.length === 0) {
<div class="moves-empty">No moves yet.</div>
} @else {
@for (pair of movePairs; track $index) {
<div class="mv-num" role="presentation">{{ $index + 1 }}</div>
<div class="mv" [class.current]="isWhiteViewing($index)" role="listitem"
tabindex="0" (click)="clickWhite($index)" (keydown.enter)="clickWhite($index)">
{{ pair.white }}
</div>
<div class="mv" [class.current]="isBlackViewing($index)" [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 ?? '…' }}
</div>
}
}
</div>
<div class="moves-foot">
<div class="moves-nav" role="group" aria-label="Move navigation">
<button class="icon-btn" title="First move" (click)="navigate.emit('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 move" (click)="navigate.emit('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 move" (click)="navigate.emit('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 move / return to live" (click)="navigate.emit('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>
@if (plyCount > 0) {
<span class="live-label" [class.reviewing]="!isLive">{{ isLive ? 'LIVE' : 'REVIEWING' }}</span>
}
</div>
@@ -0,0 +1,65 @@
import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core';
export type MoveNavDirection = 'first' | 'prev' | 'next' | 'last';
interface MovePair {
white: string;
black: string | null;
}
@Component({
selector: 'app-move-history',
standalone: true,
imports: [],
templateUrl: './move-history.component.html',
styleUrl: './move-history.component.css'
})
export class MoveHistoryComponent implements OnChanges {
@Input({ required: true }) moves: string[] = [];
@Input() viewingPly: number | null = null;
@Output() navigate = new EventEmitter<MoveNavDirection>();
@Output() navigateToPly = new EventEmitter<number>();
@ViewChild('movesEl') movesEl?: ElementRef<HTMLElement>;
movePairs: MovePair[] = [];
get plyCount(): number {
return this.moves.length;
}
get isLive(): boolean {
return this.viewingPly === null || this.viewingPly >= this.moves.length - 1;
}
ngOnChanges(): void {
this.movePairs = this.buildPairs(this.moves);
}
isWhiteViewing(pairIndex: number): boolean {
const ply = this.viewingPly ?? this.moves.length - 1;
return ply === pairIndex * 2;
}
isBlackViewing(pairIndex: number): boolean {
const ply = this.viewingPly ?? this.moves.length - 1;
return ply === pairIndex * 2 + 1;
}
clickWhite(pairIndex: number): void {
this.navigateToPly.emit(pairIndex * 2);
}
clickBlack(pairIndex: number, black: string | null): void {
if (!black) return;
this.navigateToPly.emit(pairIndex * 2 + 1);
}
private buildPairs(moves: string[]): MovePair[] {
const pairs: MovePair[] = [];
for (let i = 0; i < moves.length; i += 2) {
pairs.push({ white: moves[i], black: moves[i + 1] ?? null });
}
return pairs;
}
}
@@ -0,0 +1,90 @@
.player {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: border-color 0.2s, box-shadow 0.2s;
}
.player.is-turn {
border-color: var(--nc-neon-soft);
box-shadow: 0 0 0 1px rgba(255, 69, 200, 0.2), 0 0 20px rgba(255, 69, 200, 0.1);
}
.player-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
font-weight: 700;
color: #fff;
}
.avatar-black {
background: linear-gradient(135deg, #2a2a40 0%, #0a0a14 100%);
border: 1px solid var(--nc-border-strong);
}
.avatar-white {
background: linear-gradient(135deg, var(--nc-neon) 0%, #7a2fd6 100%);
}
.player-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.player-name {
font-size: 14px;
font-weight: 600;
color: var(--nc-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.captured {
display: flex;
align-items: center;
gap: 2px;
font-size: 13px;
color: var(--nc-text-muted);
line-height: 1;
}
.clock {
font-family: var(--nc-mono);
font-size: 22px;
font-weight: 600;
padding: 8px 14px;
min-width: 92px;
text-align: center;
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
letter-spacing: 0.02em;
transition: color 0.2s, border-color 0.2s, background 0.2s, text-shadow 0.2s;
}
.clock.clock-active {
color: var(--nc-neon);
border-color: var(--nc-neon-soft);
background: var(--nc-neon-clock-bg, rgba(255, 69, 200, 0.08));
text-shadow: 0 0 8px rgba(255, 69, 200, 0.4);
}
.clock.clock-low {
color: var(--nc-warning);
border-color: var(--nc-warning-soft, rgba(255, 177, 58, 0.4));
}
@@ -0,0 +1,22 @@
<div class="player" [class.is-turn]="isActive">
<div class="player-avatar" [class.avatar-black]="color === 'black'" [class.avatar-white]="color === 'white'">
{{ initial }}
</div>
<div class="player-info">
<div class="player-name">{{ name }}</div>
@if (capturedPieces.length > 0) {
<div class="captured">
@for (pc of capturedPieces; track $index) {
<span class="pc">{{ pc }}</span>
}
</div>
}
</div>
@if (clockDisplay !== '--:--') {
<div class="clock" [class.clock-active]="isActive" [class.clock-low]="isLowTime && !isActive">
{{ clockDisplay }}
</div>
}
</div>
@@ -0,0 +1,18 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-player-card',
standalone: true,
imports: [],
templateUrl: './player-card.component.html',
styleUrl: './player-card.component.css'
})
export class PlayerCardComponent {
@Input({ required: true }) name = '';
@Input({ required: true }) initial = '';
@Input({ required: true }) color: 'white' | 'black' = 'white';
@Input() isActive = false;
@Input() clockDisplay = '--:--';
@Input() isLowTime = false;
@Input() capturedPieces: string[] = [];
}
@@ -1,58 +1,298 @@
@import '../../button-template.css';
:host {
--auth-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--auth-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
--auth-neon: #ff45c8;
--auth-neon-soft: rgba(255, 69, 200, 0.55);
--auth-bg: #06060d;
}
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(2, 2, 10, 0.58);
background: rgba(4, 2, 12, 0.72);
backdrop-filter: blur(6px);
display: grid;
place-items: center;
z-index: 350;
padding: 1rem;
animation: backdrop-in 180ms ease-out both;
}
.dialog-card {
width: min(460px, 100%);
background: var(--dlg-bg);
border: 1.5px solid var(--dlg-border);
box-shadow: var(--bb-glow);
border-radius: 4px;
padding: 1rem;
position: relative;
width: min(440px, 100%);
background:
radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.10), transparent 60%),
linear-gradient(180deg, #0a0612 0%, #06060d 100%);
border: 1px solid var(--auth-neon-soft);
border-radius: 14px;
padding: 28px 26px 22px;
display: grid;
gap: 0.7rem;
gap: 14px;
font-family: var(--auth-sans);
color: #fff;
box-shadow:
0 0 0 1px rgba(255, 69, 200, 0.06) inset,
0 0 30px rgba(255, 69, 200, 0.18),
0 30px 60px rgba(0, 0, 0, 0.55);
overflow: hidden;
animation: dialog-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.dialog-card::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
180deg,
transparent 0%,
rgba(255, 69, 200, 0.06) 50%,
transparent 100%
);
height: 35%;
animation: scanline 4.5s linear infinite;
mix-blend-mode: screen;
}
.dialog-card::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.012) 0px,
rgba(255, 255, 255, 0.012) 1px,
transparent 1px,
transparent 3px
);
border-radius: inherit;
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
position: relative;
z-index: 1;
}
.brand-tag {
font-family: var(--auth-mono);
font-size: 10px;
letter-spacing: 2.5px;
color: var(--auth-neon);
text-transform: uppercase;
opacity: 0.85;
animation: pulse-glow 2.4s ease-in-out infinite;
}
.brand-tag::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--auth-neon);
margin-right: 8px;
vertical-align: 1px;
box-shadow: 0 0 8px var(--auth-neon);
}
.close-btn {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.65);
width: 28px;
height: 28px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
line-height: 1;
display: grid;
place-items: center;
transition: all 0.18s ease;
}
.close-btn:hover {
color: var(--auth-neon);
border-color: var(--auth-neon-soft);
box-shadow: 0 0 10px rgba(255, 69, 200, 0.35);
}
.dialog-title {
font-family: 'Bebas Neue', sans-serif;
font-size: 22px;
letter-spacing: 2px;
color: var(--bb-title);
text-align: center;
font-family: var(--auth-sans);
font-weight: 700;
font-size: 26px;
letter-spacing: 0.5px;
color: #fff;
margin: 4px 0 0;
position: relative;
z-index: 1;
}
.dialog-subtitle {
font-family: var(--auth-mono);
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
letter-spacing: 0.6px;
margin-bottom: 6px;
position: relative;
z-index: 1;
}
form {
display: grid;
gap: 12px;
position: relative;
z-index: 1;
}
.field {
display: grid;
gap: 6px;
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.field-label {
font-family: var(--auth-mono);
font-size: 10px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.55);
}
.dialog-input {
width: 100%;
background: rgba(4, 4, 20, 0.62);
border: 1px solid var(--bb-border);
color: var(--bb-title);
border-radius: 2px;
padding: 0.6rem 0.7rem;
font-family: 'Space Mono', monospace;
background: rgba(8, 5, 20, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #fff;
border-radius: 10px;
padding: 12px 14px;
font-family: var(--auth-mono);
font-size: 13px;
letter-spacing: 0.3px;
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.dialog-input::placeholder {
color: rgba(255, 255, 255, 0.28);
}
.dialog-input:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(0, 213, 255, 0.25);
}
.dialog-textarea {
min-height: 120px;
resize: vertical;
border-color: var(--auth-neon);
background: rgba(20, 6, 26, 0.7);
box-shadow: 0 0 0 3px rgba(255, 69, 200, 0.15), 0 0 18px rgba(255, 69, 200, 0.18);
}
.text-danger {
color: #dc3545;
font-size: 0.875rem;
color: #ff6ea0;
font-family: var(--auth-mono);
font-size: 11px;
letter-spacing: 0.3px;
}
.error-banner {
font-family: var(--auth-mono);
font-size: 12px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 69, 110, 0.08);
border: 1px solid rgba(255, 69, 110, 0.35);
color: #ff9bb4;
animation: shake 0.35s ease-out;
}
.dialog-actions {
display: flex;
gap: 10px;
margin-top: 6px;
position: relative;
z-index: 1;
}
.btn {
flex: 1;
border-radius: 10px;
padding: 12px 14px;
font-family: var(--auth-sans);
font-weight: 600;
font-size: 13px;
letter-spacing: 0.4px;
cursor: pointer;
transition: all 0.18s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-ghost {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
}
.btn-ghost:hover:not(:disabled) {
color: #fff;
border-color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.03);
}
.btn-primary {
background: var(--auth-neon);
border: 1px solid var(--auth-neon);
color: #1a0210;
box-shadow: 0 0 18px rgba(255, 69, 200, 0.45);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 0 26px rgba(255, 69, 200, 0.7);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.alt-line {
margin-top: 4px;
text-align: center;
font-family: var(--auth-mono);
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
position: relative;
z-index: 1;
}
.alt-line a {
color: var(--auth-neon);
cursor: pointer;
text-decoration: none;
margin-left: 4px;
}
.alt-line a:hover {
text-shadow: 0 0 8px var(--auth-neon-soft);
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(26, 2, 16, 0.35);
border-top-color: #1a0210;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.sr-only {
@@ -66,3 +306,28 @@
white-space: nowrap !important;
border: 0 !important;
}
@keyframes scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(300%); }
}
@keyframes pulse-glow {
0%, 100% { opacity: 0.85; }
50% { opacity: 1; }
}
@keyframes dialog-in {
from { opacity: 0; transform: translateY(8px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-4px); }
40%, 80% { transform: translateX(4px); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -1,43 +1,65 @@
<div class="dialog-overlay" (click)="closeDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-title">CREATE ACCOUNT</div>
<div class="dialog-head">
<span class="brand-tag">NowChess // Register</span>
<button type="button" class="close-btn" (click)="closeDialog()" aria-label="Close">×</button>
</div>
<h2 class="dialog-title">Create account</h2>
<div class="dialog-subtitle">Join the board and start playing</div>
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
<input id="username" type="text" class="dialog-input" formControlName="username" placeholder="Username"
[disabled]="isLoading" />
@if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) {
<small class="text-danger">Username must be at least 3 characters</small>
}
<div class="field">
<label for="username" class="field-label">Username</label>
<input id="username" type="text" class="dialog-input" formControlName="username"
placeholder="your_handle" autocomplete="username" />
@if (registerForm.get('username')?.invalid && registerForm.get('username')?.touched) {
<small class="text-danger">Username must be at least 3 characters</small>
}
</div>
<input id="email" type="email" class="dialog-input" formControlName="email" placeholder="Email"
[disabled]="isLoading" />
@if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) {
<small class="text-danger">Please enter a valid email</small>
}
<div class="field">
<label for="email" class="field-label">Email</label>
<input id="email" type="email" class="dialog-input" formControlName="email"
placeholder="you@domain.com" autocomplete="email" />
@if (registerForm.get('email')?.invalid && registerForm.get('email')?.touched) {
<small class="text-danger">Please enter a valid email</small>
}
</div>
<input id="password" type="password" class="dialog-input" formControlName="password" placeholder="Password"
[disabled]="isLoading" />
@if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) {
<small class="text-danger">Password must be at least 6 characters</small>
}
<input id="confirmPassword" type="password" class="dialog-input" formControlName="confirmPassword"
placeholder="Confirm Password" [disabled]="isLoading" />
<div class="field-row">
<div class="field">
<label for="password" class="field-label">Password</label>
<input id="password" type="password" class="dialog-input" formControlName="password"
placeholder="••••••••" autocomplete="new-password" />
@if (registerForm.get('password')?.invalid && registerForm.get('password')?.touched) {
<small class="text-danger">Min 6 characters</small>
}
</div>
<div class="field">
<label for="confirmPassword" class="field-label">Confirm</label>
<input id="confirmPassword" type="password" class="dialog-input" formControlName="confirmPassword"
placeholder="••••••••" autocomplete="new-password" />
</div>
</div>
@if (errorMessage) {
<div class="error-banner">{{ errorMessage }}</div>
<div class="error-banner">{{ errorMessage }}</div>
}
<div class="dialog-actions">
<button type="button" class="app-btn" (click)="openLogin()">Already have an account?</button>
<button type="button" class="app-btn" (click)="closeDialog()">Cancel</button>
<button type="submit" class="app-btn" [disabled]="isLoading || registerForm.invalid">
<button type="button" class="btn btn-ghost" (click)="closeDialog()">Cancel</button>
<button type="submit" class="btn btn-primary" [disabled]="isLoading || registerForm.invalid">
@if (isLoading) {
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span class="spinner" aria-hidden="true"></span>
}
Register
{{ isLoading ? 'Creating…' : 'Create account' }}
</button>
</div>
<div class="alt-line">
Already have an account?<a (click)="openLogin()">Sign in</a>
</div>
</form>
</div>
</div>
</div>
@@ -46,15 +46,18 @@ export class RegisterDialogComponent {
this.isLoading = true;
this.errorMessage = null;
this.registerForm.disable();
const { username, email, password: pwd } = this.registerForm.value;
const { username, email, password: pwd } = this.registerForm.getRawValue();
this.authService.register(username, pwd, email).subscribe({
next: () => {
this.isLoading = false;
this.registerForm.enable();
this.onSuccess.emit();
},
error: (err) => {
this.isLoading = false;
this.registerForm.enable();
this.errorMessage =
err.error?.message || 'Registration failed. Please try again.';
}
+493 -66
View File
@@ -1,84 +1,511 @@
@import '../../button-template.css';
.navbar {
background: rgba(8, 6, 28, 0.85);
backdrop-filter: blur(8px);
box-shadow: 0 4px 20px rgba(0, 210, 255, 0.15);
border-bottom: 1px solid rgba(0, 210, 255, 0.2);
border-radius: 0;
padding: 0.75rem 1rem;
/* ============ THEME TOKENS ============ */
:host {
/* Light mode: warm sunset palette from background gradient */
--nc-accent: #ff3dbb;
--nc-accent-hover: rgba(255, 107, 61, 0.15);
--nc-accent-badge: rgba(223, 61, 255, 0.9);
--nc-badge-text: #1a0800;
--nc-surface: rgba(26, 24, 56, 0.97);
--nc-nav-bg: linear-gradient(180deg, rgba(26,24,56,0.88) 0%, rgba(46,32,80,0.6) 70%, rgba(74,41,98,0) 100%);
--nc-text: #fff;
--nc-text-muted: rgba(255,255,255,0.7);
--nc-text-dim: rgba(255,255,255,0.45);
--nc-border: rgba(255,255,255,0.1);
--nc-popover-glow: 0 20px 60px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,107,61,0.18);
--nc-unread-dot: #ff6b3d;
--nc-avatar-a: #d44d4a;
--nc-avatar-b: #8b3a6b;
--nc-danger: #ff7a7a;
}
.navbar-brand {
font-size: 1.5rem;
font-weight: bold;
color: var(--bb-title) !important;
font-family: 'Bebas Neue', sans-serif;
letter-spacing: 1px;
cursor: pointer;
:host-context(html[data-theme='dark']) {
/* Dark mode: blue neon palette */
--nc-accent: #00d5ff;
--nc-accent-hover: rgba(0, 213, 255, 0.12);
--nc-accent-badge: #00d5ff;
--nc-badge-text: #04000f;
--nc-surface: rgba(8, 6, 28, 0.97);
--nc-nav-bg: linear-gradient(180deg, rgba(8,5,20,0.88) 0%, rgba(8,5,20,0.58) 70%, rgba(8,5,20,0) 100%);
--nc-text: #fff;
--nc-text-muted: rgba(255,255,255,0.65);
--nc-text-dim: rgba(255,255,255,0.4);
--nc-border: rgba(255,255,255,0.08);
--nc-popover-glow: 0 20px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(0,213,255,0.15);
--nc-unread-dot: #00d5ff;
--nc-avatar-a: #00d5ff;
--nc-avatar-b: #1a5fa8;
--nc-danger: #ff7a7a;
}
.gap-2 {
gap: 0.5rem;
}
.user-section {
/* ============ NAV CONTAINER ============ */
.nc-nav {
position: fixed;
top: 0; left: 0; right: 0;
height: 56px;
z-index: 100;
display: flex;
align-items: center;
padding: 0 24px;
background: var(--nc-nav-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
font-family: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.me-btn {
background: rgba(0, 210, 255, 0.1);
color: var(--bb-title);
border: 1px solid var(--bb-border);
border-radius: 2px;
padding: 0.5rem 0.8rem;
font-family: 'Space Mono', monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
/* ============ LOGO ============ */
.nc-logo {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
user-select: none;
}
.nc-logo-mark {
width: 24px; height: 24px;
background: var(--nc-accent);
display: flex;
align-items: center;
justify-content: center;
outline: none;
font-weight: 800;
color: var(--nc-badge-text);
font-size: 14px;
}
.nc-logo-text {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--nc-text);
}
/* ============ CENTER LINKS ============ */
.nc-links {
flex: 1;
display: flex;
justify-content: center;
gap: 4px;
}
.nc-link {
background: transparent;
border: none;
color: var(--nc-text-muted);
padding: 8px 14px;
font-size: 12px;
font-family: inherit;
letter-spacing: 0.08em;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
position: relative;
transition: color 0.15s;
}
.nc-link:hover { color: var(--nc-text); }
.nc-link::after {
content: "";
position: absolute;
bottom: 2px; left: 14px; right: 14px;
height: 1px;
background: var(--nc-accent);
opacity: 0;
transition: opacity 0.15s;
}
.nc-link:hover::after { opacity: 1; }
/* ============ RIGHT CLUSTER ============ */
.nc-right {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
margin-left: auto;
}
/* ============ BELL ============ */
.nc-bell {
width: 36px; height: 36px;
border: 1px solid var(--nc-border);
background: transparent;
color: var(--nc-text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: background 0.15s, color 0.15s;
font-family: inherit;
}
.nc-bell:hover,
.nc-bell.is-open {
background: var(--nc-accent-hover);
color: var(--nc-text);
}
/* ============ BADGE ============ */
.nc-badge {
position: absolute;
top: 5px; right: 5px;
min-width: 14px; height: 14px;
border-radius: 7px;
background: var(--nc-accent-badge);
color: var(--nc-badge-text);
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
padding: 0 3px;
}
/* ============ GAMES BUTTON ============ */
.nc-games-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--nc-border);
background: transparent;
color: var(--nc-text-muted);
font-family: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.nc-games-btn:hover {
background: var(--nc-accent-hover);
color: var(--nc-text);
}
/* ============ PROFILE BUTTON ============ */
.nc-profile {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px 4px 4px;
height: 36px;
border: 1px solid var(--nc-border);
background: transparent;
cursor: pointer;
color: var(--nc-text-muted);
font-family: inherit;
transition: background 0.15s, color 0.15s;
}
.nc-profile:hover,
.nc-profile.is-open {
background: var(--nc-accent-hover);
color: var(--nc-text);
}
.nc-profile-name {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
}
.nc-chevron { opacity: 0.5; }
/* ============ AVATAR ============ */
.nc-avatar {
border-radius: 50%;
background: linear-gradient(135deg, var(--nc-avatar-a) 0%, var(--nc-avatar-b) 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.nc-avatar-sm { width: 26px; height: 26px; font-size: 11px; }
.nc-avatar-md { width: 40px; height: 40px; font-size: 17px; }
/* ============ DROPDOWN WRAPPER ============ */
.nc-dropdown-wrap { position: relative; }
/* ============ POPOVERS ============ */
.nc-popover {
position: absolute;
top: calc(100% + 10px);
right: 0;
background: var(--nc-surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--nc-border);
box-shadow: var(--nc-popover-glow);
z-index: 200;
overflow: hidden;
}
/* ============ NOTIFICATIONS PANEL ============ */
.nc-notif { width: 360px; }
.nc-notif-header {
padding: 14px 18px;
border-bottom: 1px solid rgba(255,255,255,0.06);
display: flex;
justify-content: space-between;
align-items: center;
}
.nc-notif-header-title {
font-size: 11px;
letter-spacing: 0.22em;
color: var(--nc-text-muted);
text-transform: uppercase;
font-weight: 600;
}
.nc-notif-list { max-height: 420px; overflow-y: auto; }
.nc-notif-empty {
padding: 24px 18px;
text-align: center;
font-size: 13px;
color: var(--nc-text-dim);
letter-spacing: 0.04em;
}
.nc-notif-row {
padding: 14px 18px;
border-bottom: 1px solid rgba(255,255,255,0.04);
position: relative;
display: flex;
gap: 12px;
align-items: flex-start;
}
.nc-notif-row.is-unread { background: rgba(255,255,255,0.03); }
.nc-notif-row.is-unread::before {
content: "";
position: absolute;
left: 6px; top: 22px;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--nc-unread-dot);
}
.nc-notif-icon {
width: 32px; height: 32px;
flex-shrink: 0;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.12);
display: flex;
align-items: center;
justify-content: center;
color: var(--nc-accent);
}
.nc-notif-body { flex: 1; min-width: 0; }
.nc-notif-text {
font-size: 13px;
color: var(--nc-text);
line-height: 1.35;
}
.nc-notif-text b { font-weight: 600; }
.nc-notif-meta {
font-size: 10px;
color: var(--nc-text-dim);
margin-top: 4px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.me-btn:hover {
background: rgba(0, 210, 255, 0.2);
border-color: var(--bb-tag);
box-shadow: 0 0 10px rgba(0, 210, 255, 0.4);
transform: scale(1.05);
}
.me-btn:active {
transform: scale(0.98);
}
/* Sunset Mode */
.sunset .navbar {
background: rgba(20, 5, 45, 0.85);
border-bottom-color: rgba(255, 64, 207, 0.2);
box-shadow: 0 4px 20px rgba(242, 106, 226, 0.15);
}
.sunset .me-btn {
background: rgba(242, 106, 226, 0.1);
border-color: var(--bb-border);
}
.sunset .me-btn:hover {
background: rgba(242, 106, 226, 0.2);
border-color: var(--bb-tag);
box-shadow: 0 0 10px rgba(242, 106, 226, 0.4);
}
.container-fluid {
.nc-notif-actions {
display: flex;
gap: 6px;
margin-top: 10px;
}
.nc-btn-accept,
.nc-btn-decline {
padding: 6px 12px;
font-size: 10px;
font-family: inherit;
letter-spacing: 0.18em;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
text-transform: uppercase;
font-weight: 600;
border: none;
transition: opacity 0.15s;
}
.nc-btn-accept {
background: var(--nc-accent);
color: var(--nc-badge-text);
font-weight: 700;
}
.nc-btn-decline {
background: transparent;
color: var(--nc-text-muted);
border: 1px solid rgba(255,255,255,0.15);
}
.nc-btn-accept:disabled,
.nc-btn-decline:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.nc-notif-footer {
padding: 10px 18px;
border-top: 1px solid rgba(255,255,255,0.06);
}
.nc-view-all {
width: 100%;
background: transparent;
border: none;
color: var(--nc-text-dim);
font-size: 11px;
font-family: inherit;
letter-spacing: 0.2em;
cursor: pointer;
text-transform: uppercase;
padding: 6px 0;
transition: color 0.15s;
}
.nc-view-all:hover { color: var(--nc-text-muted); }
/* ============ PROFILE MENU ============ */
.nc-menu { width: 250px; }
.nc-menu-header {
padding: 16px 16px 14px;
border-bottom: 1px solid rgba(255,255,255,0.06);
display: flex;
gap: 12px;
align-items: center;
}
.ms-auto {
margin-left: auto;
.nc-menu-user-name {
font-size: 14px;
color: var(--nc-text);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nc-menu-user-sub {
font-size: 11px;
color: var(--nc-text-dim);
margin-top: 2px;
letter-spacing: 0.06em;
}
.nc-menu-group { padding: 6px 0; }
.nc-menu-group + .nc-menu-group {
border-top: 1px solid rgba(255,255,255,0.06);
}
.nc-menu-item {
padding: 9px 16px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
color: var(--nc-text-muted);
font-size: 13px;
font-family: inherit;
background: transparent;
border: none;
width: 100%;
text-align: left;
transition: background 0.12s, color 0.12s;
}
.nc-menu-item:hover {
background: var(--nc-accent-hover);
color: var(--nc-accent);
}
.nc-menu-item.danger { color: var(--nc-danger); }
.nc-menu-item.danger:hover { background: rgba(255,122,122,0.08); color: var(--nc-danger); }
.nc-menu-icon { opacity: 0.85; display: inline-flex; }
.nc-menu-label { flex: 1; }
/* ============ DARK MODE TOGGLE PILL ============ */
.nc-toggle {
width: 28px; height: 16px;
border-radius: 8px;
background: rgba(255,255,255,0.15);
position: relative;
flex-shrink: 0;
transition: background 0.2s;
}
.nc-toggle.is-on { background: var(--nc-accent); }
.nc-toggle::after {
content: "";
position: absolute;
top: 2px; left: 2px;
width: 12px; height: 12px;
border-radius: 50%;
background: #fff;
transition: left 0.2s;
}
.nc-toggle.is-on::after { left: 14px; }
/* ============ AUTH BUTTONS (logged out) ============ */
.nc-auth-btn {
background: transparent;
border: 1px solid var(--nc-border);
color: var(--nc-text-muted);
padding: 7px 14px;
font-size: 11px;
font-family: inherit;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.nc-auth-btn:hover {
background: rgba(255,255,255,0.06);
color: var(--nc-text);
}
.nc-auth-btn--primary {
background: var(--nc-accent);
border-color: var(--nc-accent);
color: var(--nc-badge-text);
}
.nc-auth-btn--primary:hover {
filter: brightness(1.1);
color: var(--nc-badge-text);
}
/* ============ NOTIF SCROLLBAR ============ */
.nc-notif-list::-webkit-scrollbar { width: 6px; }
.nc-notif-list::-webkit-scrollbar-track { background: transparent; }
.nc-notif-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
+197 -23
View File
@@ -1,28 +1,202 @@
<nav class="navbar">
<div class="container-fluid">
<span class="navbar-brand">NowChess</span>
<div class="ms-auto">
<div class="d-flex align-items-center gap-2">
<button type="button" class="app-btn" (click)="toggleTheme()">
{{ isDarkMode ? 'Light mode' : 'Dark mode' }}
</button>
@if (currentUser; as user) {
<div class="d-flex align-items-center gap-2 user-section">
<button type="button" class="me-btn" (click)="goToProfile()">
👤 {{ user.username }}
</button>
<button type="button" class="app-btn" (click)="logout()">Logout</button>
</div>
} @else {
<button type="button" class="app-btn" (click)="openLoginDialog()">
Login
</button>
<button type="button" class="app-btn" (click)="openRegisterDialog()">
Register
</button>
<nav class="nc-nav">
<!-- Logo -->
<div class="nc-logo" (click)="goToHome()" role="button" tabindex="0">
<div class="nc-logo-mark"></div>
<span class="nc-logo-text">NowChess</span>
</div>
<!-- Center links — only when logged in -->
@if (currentUser) {
<div class="nc-links">
<button type="button" class="nc-link">
<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="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 -->
<div class="nc-right">
@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) {
<div class="nc-popover nc-notif" (click)="$event.stopPropagation()">
<div class="nc-notif-header">
<span class="nc-notif-header-title">Challenges</span>
</div>
<div class="nc-notif-list">
@if (incomingChallenges.length === 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>
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>
@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>
</nav>
@@ -32,4 +206,4 @@
@if (showRegisterDialog) {
<app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" />
}
}
+178 -8
View File
@@ -1,4 +1,4 @@
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { Component, DestroyRef, HostListener, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -8,6 +8,10 @@ import { CurrentUser } from '../../models/auth.models';
import { LoginDialogComponent } from '../login-dialog/login-dialog.component';
import { RegisterDialogComponent } from '../register-dialog/register-dialog.component';
import { ThemeService } from '../../services/theme.service';
import { ChallengeEventService } from '../../services/challenge-event.service';
import { ChallengeService } from '../../services/challenge.service';
import { ChallengeWebSocketService } from '../../services/challenge-websocket.service';
import { Challenge } from '../../models/challenge.models';
@Component({
selector: 'app-toolbar',
@@ -21,35 +25,116 @@ export class ToolbarComponent implements OnInit {
private readonly authService = inject(AuthService);
private readonly authDialogService = inject(AuthDialogService);
private readonly themeService = inject(ThemeService);
private readonly challengeEventService = inject(ChallengeEventService);
private readonly challengeService = inject(ChallengeService);
private readonly challengeWs = inject(ChallengeWebSocketService);
private readonly router = inject(Router);
private pollHandle: ReturnType<typeof setInterval> | null = null;
private readonly navigatedChallengeIds = new Set<string>();
currentUser: CurrentUser | null = null;
showLoginDialog = false;
showRegisterDialog = false;
isDarkMode = false;
profileOpen = false;
notifOpen = false;
incomingChallenges: Challenge[] = [];
acceptingId: string | null = null;
decliningId: string | null = null;
ngOnInit(): void {
this.destroyRef.onDestroy(() => this.stopPolling());
this.authService.currentUser$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
.subscribe(user => {
this.currentUser = user;
if (user) {
this.challengeWs.connect();
this.startPolling();
} else {
this.challengeWs.disconnect();
this.stopPolling();
this.navigatedChallengeIds.clear();
this.challengeEventService.clear();
}
});
this.authDialogService.dialogState$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
.subscribe(state => {
this.showLoginDialog = state === 'login';
this.showRegisterDialog = state === 'register';
});
this.themeService.darkMode$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((isDarkMode) => {
this.isDarkMode = isDarkMode;
});
.subscribe(isDark => { this.isDarkMode = isDark; });
this.challengeEventService.getIncomingChallenges$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(challenges => { this.incomingChallenges = challenges; });
}
private startPolling(): void {
this.fetchChallenges();
this.pollHandle = setInterval(() => this.fetchChallenges(), 10_000);
}
private stopPolling(): void {
if (this.pollHandle !== null) {
clearInterval(this.pollHandle);
this.pollHandle = null;
}
}
private fetchChallenges(): void {
this.challengeService.listChallenges().subscribe({
next: response => {
const incoming = response.in ?? response.incoming ?? [];
this.challengeEventService.setIncomingChallenges(incoming);
const outgoing = response.out ?? response.outgoing ?? [];
for (const c of outgoing) {
if (c.status === 'accepted' && c.gameId && !this.navigatedChallengeIds.has(c.id)) {
this.navigatedChallengeIds.add(c.id);
if (!this.router.url.includes(`/game/${c.gameId}`)) {
void this.router.navigate(['/game', c.gameId]);
}
}
}
}
});
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (!(event.target as HTMLElement).closest('[data-dropdown]')) {
this.profileOpen = false;
this.notifOpen = false;
}
}
toggleProfile(event: MouseEvent): void {
event.stopPropagation();
const wasOpen = this.profileOpen;
this.profileOpen = false;
this.notifOpen = false;
this.profileOpen = !wasOpen;
}
toggleNotif(event: MouseEvent): void {
event.stopPropagation();
const wasOpen = this.notifOpen;
this.profileOpen = false;
this.notifOpen = false;
this.notifOpen = !wasOpen;
}
openLoginDialog(): void {
this.profileOpen = false;
this.notifOpen = false;
this.authDialogService.openLogin();
}
@@ -58,6 +143,8 @@ export class ToolbarComponent implements OnInit {
}
openRegisterDialog(): void {
this.profileOpen = false;
this.notifOpen = false;
this.authDialogService.openRegister();
}
@@ -66,15 +153,54 @@ export class ToolbarComponent implements OnInit {
}
logout(): void {
this.profileOpen = false;
this.notifOpen = false;
this.authService.logout();
}
toggleTheme(): void {
toggleTheme(event: MouseEvent): void {
event.stopPropagation();
this.themeService.toggleTheme();
}
goToHome(): void {
void this.router.navigate(['/']);
}
goToProfile(): void {
this.router.navigate(['/profile']);
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/profile']);
}
goToGames(): void {
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/games']);
}
goToTournaments(): void {
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/tournaments']);
}
goToChallenges(): void {
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/challenges']);
}
goToBots(): void {
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/bots']);
}
goToAnalysis(): void {
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/analysis']);
}
onLoginSuccess(): void {
@@ -84,4 +210,48 @@ export class ToolbarComponent implements OnInit {
onRegisterSuccess(): void {
this.closeRegisterDialog();
}
getInitial(): string {
return this.currentUser?.username?.charAt(0).toUpperCase() ?? '?';
}
getTimeControlDisplay(challenge: Challenge): string {
const { limit, increment } = challenge.timeControl;
if (!limit || !increment) return 'Unlimited';
return `${Math.floor(limit / 60)}+${increment}`;
}
getExpirationInfo(challenge: Challenge): string {
const diff = new Date(challenge.expiresAt).getTime() - Date.now();
if (diff <= 0) return 'Expired';
const min = Math.floor(diff / 60000);
return min > 60 ? `${Math.floor(min / 60)}h` : `${min}m`;
}
acceptChallenge(event: MouseEvent, challenge: Challenge): void {
event.stopPropagation();
if (this.acceptingId || this.decliningId) return;
this.acceptingId = challenge.id;
this.challengeService.acceptChallenge(challenge.id).subscribe({
next: accepted => {
this.acceptingId = null;
this.challengeEventService.onChallengeAccepted(accepted);
if (accepted.gameId) void this.router.navigate(['/game', accepted.gameId]);
},
error: () => { this.acceptingId = null; }
});
}
declineChallenge(event: MouseEvent, challenge: Challenge): void {
event.stopPropagation();
if (this.acceptingId || this.decliningId) return;
this.decliningId = challenge.id;
this.challengeService.declineChallenge(challenge.id, { reason: 'generic' }).subscribe({
next: () => {
this.decliningId = null;
this.challengeEventService.removeChallenge(challenge.id);
},
error: () => { this.decliningId = null; }
});
}
}
+3 -1
View File
@@ -4,8 +4,10 @@
*/
export function loadRuntimeConfig() {
const config = (window as any).__RUNTIME_CONFIG__ || {};
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const derivedWsUrl = `${wsProtocol}://${window.location.host}`;
return {
apiUrl: config.API_URL || '',
wsUrl: config.WEBSOCKET_URL || 'ws://localhost:8080'
wsUrl: config.WEBSOCKET_URL || derivedWsUrl
};
}
+35
View File
@@ -0,0 +1,35 @@
export interface AnalysisRequest {
fen: string;
depth: number;
}
export interface RawAnalysisResponse {
fen: string;
evaluation: number;
depth: number;
bestMove: string;
mate: number | null;
continuationMoves: string[];
}
export interface AnalysisResponse {
eval: number;
winChance: number;
depth: number;
bestMove: string;
mate: number | null;
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;
}
+2 -1
View File
@@ -17,7 +17,8 @@ export interface RegisterResponse {
}
export interface LoginResponse {
token: string;
accessToken: string;
refreshToken: string;
}
export interface CurrentUser {
+10
View File
@@ -0,0 +1,10 @@
export interface Bot {
id: string;
name: string;
rating: number;
createdAt: string;
}
export interface BotWithToken extends Bot {
token: string;
}
+6
View File
@@ -1,5 +1,10 @@
export type GameTurn = 'white' | 'black';
export interface ClockState {
whiteRemainingMs: number;
blackRemainingMs: number;
}
export type GameStatus =
| 'started'
| 'check'
@@ -26,6 +31,7 @@ export interface GameState {
moves: string[];
undoAvailable: boolean;
redoAvailable: boolean;
clock: ClockState | null;
}
export interface GameFull {
+66
View File
@@ -0,0 +1,66 @@
export interface TournamentClock {
limit: number;
increment: number;
}
export interface TournamentVariant {
key: string;
name: string;
}
export interface TournamentBotRef {
id: string;
name: string;
}
export interface TournamentResult {
rank: number;
points: number;
tieBreak: number;
bot: TournamentBotRef;
nbGames: number;
wins: number;
draws: number;
losses: number;
}
export interface TournamentStanding {
page: number;
players: TournamentResult[];
}
export interface Tournament {
id: string;
fullName: string;
clock: TournamentClock;
variant: TournamentVariant;
rated: boolean;
nbPlayers: number;
nbRounds: number;
createdBy: string;
startsAt: string | null;
status: 'created' | 'started' | 'finished';
round: number;
standing: TournamentStanding;
winner: TournamentBotRef | null;
}
export interface TournamentList {
created: Tournament[];
started: Tournament[];
finished: Tournament[];
}
export interface TournamentPairing {
id: string;
round: number;
white: TournamentBotRef | null;
black: TournamentBotRef;
gameId: string | null;
winner: 'white' | 'black' | 'draw' | null;
}
export interface RoundPairings {
round: number;
pairings: TournamentPairing[];
}
@@ -0,0 +1,542 @@
/* ============================================================
DESIGN TOKENS — dark mode (default)
============================================================ */
:host {
--nc-neon: #ff45c8;
--nc-neon-soft: rgba(255, 69, 200, 0.55);
--nc-bg: #06060d;
--nc-surface: rgba(20, 17, 42, 0.6);
--nc-surface-solid: rgba(10, 8, 22, 0.95);
--nc-text: #fff;
--nc-text-muted: rgba(255, 255, 255, 0.65);
--nc-text-dim: rgba(255, 255, 255, 0.45);
--nc-border: rgba(255, 255, 255, 0.08);
--nc-border-strong: rgba(255, 255, 255, 0.15);
--nc-warning: #ffb13a;
--nc-warning-soft: rgba(255, 177, 58, 0.4);
--nc-danger: #ff7a7a;
--nc-danger-bg: rgba(255, 122, 122, 0.08);
--nc-danger-soft: rgba(255, 122, 122, 0.3);
--nc-success: #5ee5a1;
--nc-clock-bg: rgba(0, 0, 0, 0.4);
--nc-btn-bg: rgba(255, 255, 255, 0.03);
--nc-btn-hover-bg: rgba(255, 255, 255, 0.07);
--nc-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--nc-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
}
:host-context(html:not([data-theme='dark'])) {
--nc-neon: #ff3dbb;
--nc-neon-soft: rgba(255, 61, 187, 0.55);
--nc-bg: transparent;
--nc-surface: rgba(26, 24, 56, 0.72);
--nc-surface-solid: rgba(26, 24, 56, 0.97);
--nc-text: #fff;
--nc-text-muted: rgba(255, 255, 255, 0.72);
--nc-text-dim: rgba(255, 255, 255, 0.45);
--nc-border: rgba(255, 255, 255, 0.1);
--nc-border-strong: rgba(255, 255, 255, 0.18);
--nc-btn-bg: rgba(255, 255, 255, 0.05);
--nc-btn-hover-bg: rgba(255, 255, 255, 0.1);
}
/* ============================================================
SHELL
============================================================ */
.analysis-shell {
min-height: 100dvh;
background: var(--nc-bg);
font-family: var(--nc-sans);
color: var(--nc-text);
position: relative;
}
.analysis-shell::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 80% 50% at 20% 100%, rgba(74, 41, 98, 0.12), transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 0%, rgba(41, 74, 98, 0.18), transparent 60%);
pointer-events: none;
z-index: 0;
}
/* ============================================================
PAGE CONTAINER
============================================================ */
.page {
position: relative;
z-index: 1;
max-width: 1320px;
margin: 0 auto;
padding: 28px 32px 60px;
}
/* ============================================================
BREADCRUMB
============================================================ */
.crumb {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
font-family: var(--nc-mono);
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.crumb-link {
color: var(--nc-text-dim);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
transition: color 0.15s;
}
.crumb-link:hover {
color: var(--nc-neon);
}
.crumb-sep {
color: var(--nc-text-dim);
opacity: 0.5;
}
.crumb-current {
color: var(--nc-text-muted);
}
/* ============================================================
PAGE HEADER
============================================================ */
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 1px solid var(--nc-border);
}
.page-title h1 {
margin: 0 0 4px;
font-size: 26px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--nc-text);
}
.page-subtitle {
margin: 0;
font-family: var(--nc-mono);
font-size: 11px;
color: var(--nc-text-dim);
letter-spacing: 0.06em;
}
/* ============================================================
ERROR
============================================================ */
.state-error {
padding: 14px 16px;
margin-bottom: 20px;
color: var(--nc-danger);
background: var(--nc-danger-bg);
border: 1px solid var(--nc-danger-soft);
font-size: 13px;
}
/* ============================================================
INPUT SECTION
============================================================ */
.input-section {
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: 20px;
margin-bottom: 28px;
display: flex;
flex-direction: column;
gap: 14px;
}
.mode-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--nc-border);
padding-bottom: 12px;
}
.mode-tab {
background: transparent;
border: 1px solid transparent;
color: var(--nc-text-muted);
font-family: var(--nc-mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
padding: 6px 14px;
cursor: pointer;
transition:
color 0.15s,
border-color 0.15s;
}
.mode-tab:hover {
color: var(--nc-text);
}
.mode-tab.active {
color: var(--nc-neon);
border-color: var(--nc-neon-soft);
}
.input-row {
display: flex;
gap: 8px;
}
.text-input {
flex: 1;
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
font-family: var(--nc-mono);
font-size: 12px;
padding: 9px 12px;
letter-spacing: 0.04em;
outline: none;
transition: border-color 0.15s;
}
.text-input:focus {
border-color: var(--nc-neon-soft);
}
.text-input::placeholder {
color: var(--nc-text-dim);
}
.pgn-col {
display: flex;
flex-direction: column;
gap: 8px;
}
.text-area {
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
font-family: var(--nc-mono);
font-size: 12px;
padding: 10px 12px;
letter-spacing: 0.04em;
resize: vertical;
outline: none;
transition: border-color 0.15s;
line-height: 1.5;
}
.text-area:focus {
border-color: var(--nc-neon-soft);
}
.text-area::placeholder {
color: var(--nc-text-dim);
}
.depth-row {
display: flex;
align-items: center;
gap: 10px;
padding-top: 6px;
border-top: 1px solid var(--nc-border);
}
.depth-label {
font-family: var(--nc-mono);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--nc-text-dim);
}
.depth-input {
width: 56px;
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
font-family: var(--nc-mono);
font-size: 13px;
padding: 7px 10px;
outline: none;
transition: border-color 0.15s;
text-align: center;
}
.depth-input:focus {
border-color: var(--nc-neon-soft);
}
/* ============================================================
BUTTONS
============================================================ */
.btn {
font-family: var(--nc-sans);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 600;
padding: 9px 14px;
cursor: pointer;
border: 1px solid var(--nc-border-strong);
background: var(--nc-btn-bg);
color: var(--nc-text);
display: inline-flex;
align-items: center;
gap: 6px;
transition:
background 0.15s,
border-color 0.15s;
flex-shrink: 0;
}
.btn:hover:not([disabled]) {
background: var(--nc-btn-hover-bg);
border-color: var(--nc-text-muted);
}
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--nc-neon) !important;
color: #fff !important;
border-color: var(--nc-neon) !important;
box-shadow: 0 0 14px rgba(255, 69, 200, 0.3);
font-weight: 700;
}
.btn-primary:hover:not([disabled]) {
box-shadow: 0 0 20px rgba(255, 69, 200, 0.5);
}
.btn-analyse {
margin-left: auto;
background: rgba(255, 69, 200, 0.12);
color: var(--nc-neon);
border-color: var(--nc-neon-soft);
}
.btn-analyse:hover:not([disabled]) {
background: rgba(255, 69, 200, 0.2);
}
/* ============================================================
MAIN GRID
============================================================ */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 28px;
align-items: start;
}
/* ============================================================
BOARD COLUMN
============================================================ */
.board-col {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 520px;
width: 100%;
margin: 0 auto;
}
.board-wrap {
container-type: size;
aspect-ratio: 1 / 1;
padding: 10px;
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow:
0 8px 40px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 69, 200, 0.06);
}
.nav-bar {
display: flex;
gap: 4px;
justify-content: center;
padding: 6px 0;
}
.icon-btn {
background: var(--nc-btn-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text-muted);
padding: 8px 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color 0.15s,
background 0.15s;
}
.icon-btn:hover {
color: var(--nc-neon);
background: var(--nc-btn-hover-bg);
}
/* ============================================================
SIDE COLUMN
============================================================ */
.side {
display: flex;
flex-direction: column;
gap: 12px;
}
.side-card {
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.side-card-summary {
list-style: none;
cursor: pointer;
padding: 13px 16px;
display: flex;
align-items: center;
gap: 8px;
user-select: none;
}
.side-card-summary::-webkit-details-marker {
display: none;
}
.side-card-title {
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--nc-text-muted);
font-weight: 600;
flex: 1;
}
.side-card-meta {
font-family: var(--nc-mono);
font-size: 10px;
color: var(--nc-text-dim);
letter-spacing: 0.08em;
}
.chev {
color: var(--nc-text-dim);
flex-shrink: 0;
transition: transform 0.2s;
}
.side-card[open] .chev {
transform: rotate(180deg);
}
.side-card[open] .side-card-summary {
border-bottom: 1px solid var(--nc-border);
}
.side-card-body {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.timeline-body {
padding: 10px 16px;
}
/* ============================================================
EVAL GRID
============================================================ */
.eval-grid {
gap: 8px;
}
.eval-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.eval-row--col {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.eval-label {
font-family: var(--nc-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--nc-text-dim);
flex-shrink: 0;
}
.eval-value {
font-size: 14px;
font-weight: 600;
color: var(--nc-text);
}
.eval-value.mono {
font-family: var(--nc-mono);
font-size: 12px;
}
.eval-value.positive {
color: var(--nc-success);
}
.eval-value.negative {
color: var(--nc-danger);
}
.continuation {
font-size: 11px;
color: var(--nc-text-muted);
line-height: 1.6;
word-break: break-all;
}
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 1100px) {
.layout {
grid-template-columns: 1fr;
}
.board-col {
max-width: 560px;
margin: 0 auto;
}
}
@media (max-width: 640px) {
.page {
padding: 16px 16px 48px;
}
.page-title h1 {
font-size: 20px;
}
}
@@ -0,0 +1,355 @@
<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) {
<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>
@@ -0,0 +1,250 @@
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, forkJoin } 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.positionAnalysis = null;
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;
this.fenHistory = [game.state.fen];
this.annotatedMoves = game.state.moves.map((san) => ({
san,
fen: game.state.fen,
evalBefore: null,
evalAfter: null,
quality: null,
bestMove: null,
winChanceBefore: null,
winChanceAfter: null,
}));
this.gameApi
.getFenHistory(game.gameId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (fens) => {
if (fens.length >= 2) {
this.fenHistory = fens;
}
},
});
}
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;
},
});
}
}
+163
View File
@@ -0,0 +1,163 @@
:host {
--nc-neon: #ff45c8;
--nc-bg: #06060d;
--nc-surface: rgba(20, 17, 42, 0.6);
--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-success: #5ee5a1;
--nc-danger: #ff7a7a;
--nc-warn: #ffd166;
--nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
display: block;
min-height: 100vh;
background: var(--nc-bg);
font-family: var(--nc-sans);
color: var(--nc-text);
}
:host-context(html:not([data-theme='dark'])) {
--nc-neon: #c026d3;
--nc-bg: #f5f0fc;
--nc-surface: rgba(255, 255, 255, 0.88);
--nc-text: #0f0022;
--nc-text-muted: rgba(15, 0, 34, 0.65);
--nc-text-dim: rgba(15, 0, 34, 0.4);
--nc-border: rgba(15, 0, 34, 0.1);
--nc-border-strong: rgba(15, 0, 34, 0.2);
--nc-success: #16a34a;
--nc-danger: #dc2626;
--nc-warn: #b45309;
}
.b-shell { padding-top: 72px; min-height: 100vh; }
.page { max-width: 680px; margin: 0 auto; padding: 32px 20px 64px; }
.crumb { display: flex; align-items: center; gap: 8px; margin-bottom: 28px;
font-size: 11px; color: var(--nc-text-dim); letter-spacing: 0.06em; }
.crumb-link { display: inline-flex; align-items: center; gap: 4px;
color: var(--nc-text-dim); text-decoration: none; transition: color 0.15s; }
.crumb-link:hover { color: var(--nc-neon); }
.crumb-sep { opacity: 0.35; }
.crumb-current { color: var(--nc-text-muted); }
.page-header { margin-bottom: 24px; }
.title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.page-sub { font-size: 13px; color: var(--nc-text-muted); margin: 0; line-height: 1.5; }
.btn-new {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; border: none;
background: var(--nc-neon); color: #fff;
font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity 0.15s;
}
.btn-new:hover { opacity: 0.85; }
/* Create panel */
.create-panel {
border: 1px solid var(--nc-border-strong); border-radius: 12px;
background: var(--nc-surface); padding: 16px; margin-bottom: 20px;
}
.create-inner { display: flex; flex-direction: column; gap: 10px; }
.field-label { font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--nc-text-muted); }
.create-row { display: flex; gap: 8px; align-items: center; }
.text-input {
flex: 1; padding: 8px 12px; border-radius: 8px;
border: 1px solid var(--nc-border-strong);
background: rgba(255,255,255,0.04); color: var(--nc-text); font-size: 14px;
}
.text-input:focus { outline: 2px solid var(--nc-neon); outline-offset: 1px; border-color: transparent; }
.text-input:disabled { opacity: 0.5; }
.error-text { font-size: 12px; color: var(--nc-danger); margin: 0; }
/* Buttons */
.btn-primary {
padding: 8px 16px; border-radius: 8px; border: none;
background: var(--nc-neon); color: #fff; font-size: 13px; font-weight: 600;
cursor: pointer; white-space: nowrap; transition: opacity 0.15s;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost {
padding: 8px 14px; border-radius: 8px; border: 1px solid var(--nc-border-strong);
background: transparent; color: var(--nc-text-muted); font-size: 13px; cursor: pointer;
}
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
/* States */
.state-msg { display: flex; align-items: center; gap: 10px;
padding: 24px 0; color: var(--nc-text-muted); font-size: 13px; }
.pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--nc-neon);
flex-shrink: 0; animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } }
.empty-state { display: flex; flex-direction: column; align-items: center;
gap: 8px; padding: 64px 0; text-align: center; }
.empty-icon { color: var(--nc-text-dim); margin-bottom: 4px; }
.empty-title { font-size: 15px; font-weight: 600; margin: 0; }
.empty-sub { font-size: 13px; color: var(--nc-text-muted); margin: 0; }
/* Bot list */
.bot-list { display: flex; flex-direction: column; gap: 8px; }
.bot-card {
border: 1px solid var(--nc-border); border-radius: 12px;
background: var(--nc-surface); overflow: hidden;
}
.bot-main {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px;
}
.bot-avatar {
width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0;
background: var(--nc-neon); color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 16px; font-weight: 700;
}
.bot-info { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; }
.bot-name { font-size: 14px; font-weight: 600; }
.bot-meta { font-size: 11px; color: var(--nc-text-muted); }
.bot-actions { display: flex; gap: 8px; flex-shrink: 0; }
.btn-token {
display: inline-flex; align-items: center; gap: 5px;
padding: 6px 12px; border-radius: 7px; border: 1px solid var(--nc-border-strong);
background: transparent; color: var(--nc-text-muted); font-size: 12px; cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-token:hover, .btn-token.active { background: rgba(255,69,200,0.1); color: var(--nc-neon); border-color: var(--nc-neon); }
.btn-token:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-danger {
padding: 6px 12px; border-radius: 7px; border: 1px solid rgba(255,122,122,0.3);
background: transparent; color: var(--nc-danger); font-size: 12px; cursor: pointer;
transition: background 0.15s;
}
.btn-danger:hover { background: rgba(255,122,122,0.1); }
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
/* Token panel */
.token-panel {
border-top: 1px solid var(--nc-border); padding: 12px 16px;
display: flex; flex-direction: column; gap: 10px;
}
.token-warning {
display: flex; align-items: flex-start; gap: 8px;
font-size: 12px; color: var(--nc-warn);
}
.token-row { display: flex; align-items: center; gap: 8px; }
.token-value {
flex: 1; font-family: monospace; font-size: 11px;
background: rgba(0,0,0,0.2); border-radius: 6px;
padding: 8px 10px; word-break: break-all;
color: var(--nc-text-muted); border: 1px solid var(--nc-border);
}
.btn-copy {
padding: 6px 12px; border-radius: 7px; border: 1px solid var(--nc-border-strong);
background: transparent; color: var(--nc-text-muted); font-size: 12px;
cursor: pointer; white-space: nowrap; transition: color 0.15s;
flex-shrink: 0;
}
.btn-copy:hover { color: var(--nc-success); }
+125
View File
@@ -0,0 +1,125 @@
<div class="b-shell">
<div class="page">
<nav class="crumb">
<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">My Bots</span>
</nav>
<header class="page-header">
<div class="title-row">
<h1 class="page-title">My Bots</h1>
<button type="button" class="btn-new" (click)="openCreate()">
<svg width="13" height="13" 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>
New bot
</button>
</div>
<p class="page-sub">Bots are automated players owned by your account. Each has a token used to join tournaments and make moves.</p>
</header>
@if (showCreate) {
<div class="create-panel">
<div class="create-inner">
<label class="field-label">Bot name</label>
<div class="create-row">
<input type="text" class="text-input" [(ngModel)]="newBotName"
placeholder="e.g. AlphaBot" (keydown.enter)="submitCreate()"
[disabled]="creating" maxlength="40" />
<button type="button" class="btn-primary" (click)="submitCreate()"
[disabled]="creating || !newBotName.trim()">
{{ creating ? 'Creating…' : 'Create' }}
</button>
<button type="button" class="btn-ghost" (click)="cancelCreate()" [disabled]="creating">
Cancel
</button>
</div>
@if (createError) {
<p class="error-text">{{ createError }}</p>
}
</div>
</div>
}
@if (loading) {
<div class="state-msg"><span class="pulse"></span>Loading bots…</div>
} @else if (bots.length === 0) {
<div class="empty-state">
<svg width="36" height="36" viewBox="0 0 24 24" 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>
<p class="empty-title">No bots yet</p>
<p class="empty-sub">Create a bot to join tournaments and play automated games.</p>
</div>
} @else {
<div class="bot-list">
@for (bot of bots; track bot.id) {
<div class="bot-card">
<div class="bot-main">
<div class="bot-avatar">{{ bot.name.charAt(0).toUpperCase() }}</div>
<div class="bot-info">
<span class="bot-name">{{ bot.name }}</span>
<span class="bot-meta">Rating {{ bot.rating }} · Created {{ bot.createdAt | date:'MMM d, yyyy' }}</span>
</div>
<div class="bot-actions">
<button type="button" class="btn-token"
[class.active]="!!revealedTokens[bot.id]"
[disabled]="revealingId === bot.id"
(click)="revealToken(bot.id)">
<svg 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]) {
<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"/>
<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 {
<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>
{{ revealingId === bot.id ? '…' : (revealedTokens[bot.id] ? 'Hide' : 'Token') }}
</button>
<button type="button" class="btn-danger"
[disabled]="deletingId === bot.id"
(click)="deleteBot(bot.id)">
{{ deletingId === bot.id ? '…' : 'Delete' }}
</button>
</div>
</div>
@if (revealedTokens[bot.id]) {
<div class="token-panel">
<div class="token-warning">
<svg width="13" height="13" viewBox="0 0 24 24" 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>
Token was just regenerated — the old one is now invalid. Keep this secret.
</div>
<div class="token-row">
<code class="token-value">{{ revealedTokens[bot.id] }}</code>
<button type="button" class="btn-copy" (click)="copyToken(bot.id)">
{{ copiedId === bot.id ? '✓ Copied' : 'Copy' }}
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
+111
View File
@@ -0,0 +1,111 @@
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { BotService } from '../../services/bot.service';
import { Bot, BotWithToken } from '../../models/bot.models';
@Component({
selector: 'app-bots',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule],
templateUrl: './bots.component.html',
styleUrl: './bots.component.css'
})
export class BotsComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly botService = inject(BotService);
bots: Bot[] = [];
loading = true;
showCreate = false;
newBotName = '';
creating = false;
createError: string | null = null;
revealedTokens: Record<string, string> = {};
revealingId: string | null = null;
copiedId: string | null = null;
deletingId: string | null = null;
ngOnInit(): void {
this.loadBots();
}
loadBots(): void {
this.loading = true;
this.botService.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: bots => { this.bots = bots; this.loading = false; },
error: () => { this.loading = false; }
});
}
openCreate(): void {
this.newBotName = '';
this.createError = null;
this.showCreate = true;
}
cancelCreate(): void {
this.showCreate = false;
}
submitCreate(): void {
const name = this.newBotName.trim();
if (!name) return;
this.creating = true;
this.createError = null;
this.botService.create(name).subscribe({
next: (bot: BotWithToken) => {
this.creating = false;
this.showCreate = false;
this.bots = [bot, ...this.bots];
this.revealedTokens[bot.id] = bot.token;
},
error: err => {
this.creating = false;
this.createError = err.error?.message ?? err.error?.error ?? 'Failed to create bot.';
}
});
}
revealToken(botId: string): void {
if (this.revealedTokens[botId]) {
delete this.revealedTokens[botId];
return;
}
this.revealingId = botId;
this.botService.rotateToken(botId).subscribe({
next: token => {
this.revealingId = null;
this.revealedTokens[botId] = token;
},
error: () => { this.revealingId = null; }
});
}
copyToken(botId: string): void {
const token = this.revealedTokens[botId];
if (!token) return;
navigator.clipboard.writeText(token).then(() => {
this.copiedId = botId;
setTimeout(() => { this.copiedId = null; }, 2000);
});
}
deleteBot(botId: string): void {
this.deletingId = botId;
this.botService.delete(botId).subscribe({
next: () => {
this.deletingId = null;
this.bots = this.bots.filter(b => b.id !== botId);
delete this.revealedTokens[botId];
},
error: () => { this.deletingId = null; }
});
}
}
@@ -104,7 +104,7 @@ export class ChallengesComponent implements OnInit, OnDestroy {
}
declineChallenge(challenge: Challenge): void {
this.challengeService.declineChallenge(challenge.id, { reason: 'Not interested' })
this.challengeService.declineChallenge(challenge.id, { reason: 'generic' })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
+567 -357
View File
@@ -1,419 +1,629 @@
@import '../../button-template.css';
/* ============================================================
DESIGN TOKENS — dark mode (default)
============================================================ */
:host {
--nc-neon: #ff45c8;
--nc-neon-soft: rgba(255, 69, 200, 0.55);
--nc-neon-clock-bg: rgba(255, 69, 200, 0.08);
--nc-bg: #06060d;
--nc-surface: rgba(20, 17, 42, 0.6);
--nc-surface-solid: rgba(10, 8, 22, 0.95);
--nc-text: #fff;
--nc-text-muted: rgba(255, 255, 255, 0.65);
--nc-text-dim: rgba(255, 255, 255, 0.45);
--nc-border: rgba(255, 255, 255, 0.08);
--nc-border-strong: rgba(255, 255, 255, 0.15);
--nc-warning: #ffb13a;
--nc-warning-soft: rgba(255, 177, 58, 0.4);
--nc-danger: #ff7a7a;
--nc-danger-soft: rgba(255, 122, 122, 0.3);
--nc-danger-bg: rgba(255, 122, 122, 0.08);
--nc-success: #5ee5a1;
--nc-clock-bg: rgba(0, 0, 0, 0.4);
--nc-btn-bg: rgba(255, 255, 255, 0.03);
--nc-btn-hover-bg: rgba(255, 255, 255, 0.07);
--nc-seg-bg: rgba(0, 0, 0, 0.3);
--nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--nc-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
}
/* ============================================================
LIGHT MODE TOKEN OVERRIDES (sunset-gradient palette)
============================================================ */
:host-context(html:not([data-theme='dark'])) {
--nc-neon: #ff3dbb;
--nc-neon-soft: rgba(255, 61, 187, 0.55);
--nc-neon-clock-bg: rgba(255, 61, 187, 0.08);
--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.10);
--nc-border-strong: rgba(255, 255, 255, 0.18);
--nc-warning: #ffb13a;
--nc-warning-soft: rgba(255, 177, 58, 0.40);
--nc-danger: #ff7a7a;
--nc-danger-soft: rgba(255, 122, 122, 0.30);
--nc-danger-bg: rgba(255, 122, 122, 0.08);
--nc-success: #5ee5a1;
--nc-clock-bg: rgba(0, 0, 0, 0.30);
--nc-btn-bg: rgba(255, 255, 255, 0.05);
--nc-btn-hover-bg: rgba(255, 255, 255, 0.10);
--nc-seg-bg: rgba(0, 0, 0, 0.28);
}
/* ============================================================
SHELL & AMBIENT BG
============================================================ */
.game-shell {
min-height: 100dvh;
padding: clamp(var(--size-md), 2vw, var(--size-xl));
background: linear-gradient(180deg, var(--color-primary-light) 0%, var(--color-secondary-mint) 100%);
color: var(--color-text-primary);
background: var(--nc-bg);
font-family: var(--nc-sans);
color: var(--nc-text);
position: relative;
}
:host-context(html[data-theme='dark']) .game-shell {
.game-shell::before {
content: "";
position: fixed;
inset: 0;
background:
radial-gradient(circle at top, rgba(185, 194, 218, 0.16) 0%, transparent 35%),
linear-gradient(180deg, #0f1f2e 0%, #17293d 52%, #0b1420 100%);
radial-gradient(ellipse 80% 50% at 20% 100%, rgba(212, 77, 74, 0.08), transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 0%, rgba(74, 41, 98, 0.18), transparent 60%);
pointer-events: none;
z-index: 0;
}
.game-card {
max-width: 1400px;
:host-context(html:not([data-theme='dark'])) .game-shell::before {
background:
radial-gradient(ellipse 80% 50% at 20% 100%, rgba(212, 77, 74, 0.10), transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 0%, rgba(74, 41, 98, 0.22), transparent 60%);
}
/* ============================================================
PAGE CONTAINER
============================================================ */
.page {
position: relative;
z-index: 1;
max-width: 1320px;
margin: 0 auto;
background: var(--color-bg-main);
border: var(--border-width) solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: clamp(var(--size-lg), 2vw, var(--size-xl));
box-shadow: var(--shadow-md);
padding: 28px 32px 60px;
}
:host-context(html[data-theme='dark']) .game-shell .game-card {
background: rgba(26, 47, 71, 0.88);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.34);
/* ============================================================
BREADCRUMB
============================================================ */
.crumb {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
font-family: var(--nc-mono);
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
header {
margin-bottom: var(--size-xl);
margin-bottom: var(--size-xl);
}
h1,
h2 {
color: var(--color-text-primary);
margin: 0 0 var(--size-md);
font-size: var(--heading-h1);
color: var(--color-text-primary);
margin: 0 0 var(--size-md);
font-size: var(--heading-h1);
}
.meta {
color: var(--color-text-primary);
color: var(--color-text-primary);
font-size: 0.95rem;
}
.back-link {
display: inline-block;
margin-bottom: var(--size-sm);
color: var(--color-text-primary);
margin-bottom: var(--size-sm);
color: var(--color-text-primary);
.crumb-link {
color: var(--nc-text-dim);
text-decoration: none;
font-weight: 600;
}
.back-link:hover {
text-decoration: underline;
}
.top-section {
display: grid;
gap: var(--size-md);
margin-top: var(--size-sm);
flex: 0 0 auto;
}
.board-theme-card {
background: var(--color-bg-card);
border: var(--border-width) solid var(--color-border);
border-radius: var(--border-radius-md);
padding: var(--size-md-padding);
display: grid;
gap: var(--size-sm);
}
.board-theme-card h3 {
margin: 0;
color: var(--color-text-primary);
font-size: 1rem;
}
.board-theme-group {
display: flex;
gap: var(--size-md);
flex-wrap: wrap;
}
.board-theme-option {
display: inline-flex;
align-items: center;
gap: var(--size-xs);
color: var(--color-text-primary);
font-weight: 600;
gap: 6px;
transition: color 0.15s;
}
.board-theme-option input {
accent-color: var(--color-primary);
}
.crumb-link:hover { color: var(--nc-neon); }
.crumb-sep { color: var(--nc-text-dim); opacity: 0.5; }
.crumb-current { color: var(--nc-text-muted); }
.move-card {
padding: var(--size-lg-padding);
}
.move-card .btn {
align-self: flex-start;
width: auto;
}
.center-column {
width: 100%;
}
.board-section {
background: var(--color-bg-board);
border: var(--border-width) solid var(--color-border);
border-radius: var(--border-radius-md);
padding: clamp(var(--size-sm), 1vw, var(--size-lg));
background: var(--color-bg-board);
border: var(--border-width) solid var(--color-border);
border-radius: var(--border-radius-md);
padding: clamp(var(--size-sm), 1vw, var(--size-lg));
min-height: 400px;
container-type: size;
}
:host-context(html[data-theme='dark']) .game-shell .board-section,
:host-context(html[data-theme='dark']) .game-shell .timer-card,
:host-context(html[data-theme='dark']) .game-shell .history-card,
:host-context(html[data-theme='dark']) .game-shell .export-card,
:host-context(html[data-theme='dark']) .game-shell .board-theme-card,
:host-context(html[data-theme='dark']) .game-shell .player-timer {
background: rgba(45, 74, 111, 0.72);
}
:host-context(html[data-theme='dark']) .game-shell .export-text {
background: rgba(26, 47, 71, 0.9);
}
:host-context(html[data-theme='dark']) .game-shell .game-completion-alert {
background: linear-gradient(135deg, rgba(74, 124, 124, 0.35) 0%, rgba(90, 111, 165, 0.35) 100%);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.25);
}
.timer-card,
.history-card,
.export-card {
background: var(--color-bg-card);
border: var(--border-width) solid var(--color-border);
border-radius: var(--border-radius-md);
padding: var(--size-lg-padding);
display: grid;
gap: var(--size-md);
}
.timer-card h2,
.history-card h2,
.export-card h2 {
margin: 0;
font-size: 1.1rem;
color: var(--color-text-primary);
}
.history-list {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: var(--size-xs);
max-height: 180px;
overflow: auto;
}
.history-list li {
color: var(--color-text-primary);
/* ============================================================
GAME HEADER
============================================================ */
.game-header {
display: flex;
gap: var(--size-sm);
align-items: baseline;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 1px solid var(--nc-border);
}
.history-number {
font-weight: 700;
min-width: 1.8rem;
}
.history-move {
font-family: monospace;
}
.history-empty {
margin: 0;
color: var(--color-text-primary);
}
.player-timer {
background: var(--color-bg-input);
border: var(--border-width) solid var(--color-border);
border-radius: var(--border-radius-md);
padding: var(--size-md-padding);
}
.active-timer {
box-shadow: 0 0 0 3px rgba(185, 218, 209, 0.25);
}
.timer-label {
margin: 0;
color: var(--color-text-primary);
font-weight: 600;
}
.timer-value {
margin: var(--size-xs) 0 0;
color: var(--color-text-primary);
font-size: 1.35rem;
font-weight: 700;
}
.export-mode-group {
.game-title {
display: flex;
gap: var(--size-lg);
flex-wrap: wrap;
flex-direction: column;
gap: 8px;
}
.export-mode-option {
.game-title h1 {
margin: 0;
font-size: 26px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--nc-text);
display: inline-flex;
align-items: center;
gap: var(--size-sm);
color: var(--color-text-primary);
font-weight: 600;
gap: 14px;
}
.export-mode-option input {
accent-color: var(--color-primary);
.tag-rated {
font-family: var(--nc-mono);
font-size: 10px;
letter-spacing: 0.22em;
color: var(--nc-neon);
border: 1px solid var(--nc-neon-soft);
padding: 4px 10px;
text-transform: uppercase;
}
.export-text {
width: 100%;
min-height: 140px;
border: var(--border-width) solid var(--color-border);
border-radius: var(--border-radius-md);
background: var(--color-bg-input);
color: var(--color-text-primary);
padding: var(--size-md-padding);
resize: vertical;
.game-meta-strip {
display: flex;
align-items: center;
gap: 14px;
font-family: var(--nc-mono);
font-size: 11px;
color: var(--nc-text-dim);
letter-spacing: 0.06em;
}
.export-button {
width: fit-content;
border: var(--button-border);
border-radius: var(--button-radius);
background: var(--color-bg-button);
color: var(--color-text-primary);
font-weight: 700;
padding: var(--button-padding);
.game-id {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--nc-text-muted);
}
.game-id strong { color: var(--nc-text); font-weight: 500; }
.meta-dot {
width: 3px;
height: 3px;
background: var(--nc-text-dim);
border-radius: 50%;
flex-shrink: 0;
}
.copy-btn {
background: transparent;
border: none;
color: var(--nc-text-dim);
cursor: pointer;
padding: 2px 4px;
display: inline-flex;
transition: color 0.15s;
}
.export-button:hover {
background: var(--color-bg-button-hover);
color: var(--color-text-button-hover);
}
.copy-btn:hover { color: var(--nc-neon); }
.export-note {
margin: 0;
color: var(--color-text-primary);
.header-actions { display: flex; gap: 8px; align-items: center; }
/* ============================================================
BUTTONS
============================================================ */
.btn {
font-family: var(--nc-sans);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 600;
padding: 9px 14px;
cursor: pointer;
border: 1px solid var(--nc-border-strong);
background: var(--nc-btn-bg);
color: var(--nc-text);
display: inline-flex;
align-items: center;
gap: 6px;
transition: background 0.15s, border-color 0.15s;
}
.alert {
border-radius: var(--border-radius-sm);
border: var(--border-width) solid var(--color-border);
.btn:hover {
background: var(--nc-btn-hover-bg);
border-color: var(--nc-text-muted);
}
.game-completion-alert {
background: linear-gradient(135deg, var(--color-secondary-mint, #B9DAD1) 0%, var(--color-secondary-blue, #B9C2DA) 100%);
border: 2px solid var(--color-secondary-mint, #B9DAD1) !important;
border-radius: var(--border-radius-lg) !important;
padding: var(--size-xl-padding) !important;
box-shadow: 0 8px 16px rgba(185, 218, 209, 0.3);
animation: slideIn 0.4s ease-out;
.btn-primary {
background: var(--nc-neon) !important;
color: #fff !important;
border-color: var(--nc-neon) !important;
box-shadow: 0 0 14px rgba(255, 69, 200, 0.3);
font-weight: 700;
padding: 9px 14px;
flex-shrink: 0;
}
.btn-primary:hover { box-shadow: 0 0 20px rgba(255, 69, 200, 0.5); }
.btn-ghost {
background: transparent;
border: none;
color: var(--nc-text-muted);
padding: 8px 12px;
font-size: 12px;
letter-spacing: 0.04em;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--nc-sans);
transition: color 0.15s;
}
.btn-ghost:hover { color: var(--nc-neon); }
/* ============================================================
STATE MESSAGES (loading / error)
============================================================ */
.state-message {
display: flex;
align-items: center;
gap: 10px;
padding: 20px;
font-family: var(--nc-mono);
font-size: 13px;
color: var(--nc-text-muted);
letter-spacing: 0.06em;
}
.state-error {
color: var(--nc-danger);
background: var(--nc-danger-bg);
border: 1px solid var(--nc-danger-soft);
}
/* ============================================================
COMPLETION BANNER
============================================================ */
.completion-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
margin-bottom: 24px;
background: rgba(255, 69, 200, 0.06);
border: 1px solid var(--nc-neon-soft);
animation: slideIn 0.35s ease-out;
}
.completion-banner--timeout {
background: rgba(255, 177, 58, 0.06);
border-color: var(--nc-warning-soft);
}
.completion-banner--timeout .completion-title {
color: var(--nc-warning);
}
.completion-left {
display: flex;
align-items: center;
gap: 14px;
}
.completion-icon {
font-size: 22px;
opacity: 0.7;
}
.completion-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.completion-new {
font-size: 11px !important;
padding: 8px 14px !important;
text-decoration: none;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.completion-title {
color: var(--color-text-primary);
font-size: 1.75rem;
margin: 0 0 var(--size-md) 0;
font-weight: 700;
text-align: center;
}
.completion-title { font-size: 15px; font-weight: 700; color: var(--nc-neon); }
.completion-subtitle {
text-align: center;
color: var(--color-text-primary);
font-size: 1rem;
.completion-sub {
font-family: var(--nc-mono);
font-size: 10px;
color: var(--nc-text-dim);
letter-spacing: 0.08em;
margin-top: 2px;
}
.completion-link {
color: var(--color-text-primary);
font-family: var(--nc-mono);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--nc-text-muted);
text-decoration: none;
font-weight: 600;
border-bottom: 2px solid var(--color-text-primary);
transition: all 0.3s ease;
border-bottom: 1px solid var(--nc-border-strong);
padding-bottom: 2px;
transition: color 0.15s, border-color 0.15s;
flex-shrink: 0;
}
.completion-link:hover {
color: var(--color-secondary-blue);
border-bottom-color: var(--color-secondary-blue);
.completion-link:hover { color: var(--nc-neon); border-color: var(--nc-neon-soft); }
/* ============================================================
MAIN GRID
============================================================ */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 28px;
align-items: start;
}
@media (max-width: 991px) {
.game-card {
padding: clamp(var(--size-md), 1.5vw, var(--size-lg));
padding: clamp(var(--size-md), 1.5vw, var(--size-lg));
}
.board-section {
min-height: 350px;
}
h1,
h2 {
font-size: var(--heading-h1-tablet);
font-size: var(--heading-h1-tablet);
}
/* ============================================================
BOARD COLUMN
============================================================ */
.board-col {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 520px;
width: 100%;
margin: 0 auto;
}
@media (max-width: 768px) {
.game-shell {
padding: clamp(var(--size-sm), 1.5vw, var(--size-lg));
padding: clamp(var(--size-sm), 1.5vw, var(--size-lg));
}
.game-card {
padding: clamp(var(--size-sm), 1vw, var(--size-md));
padding: clamp(var(--size-sm), 1vw, var(--size-md));
}
header {
margin-bottom: var(--size-lg);
margin-bottom: var(--size-lg);
}
h1,
h2 {
font-size: var(--heading-h1-mobile);
font-size: var(--heading-h1-mobile);
}
.meta {
font-size: 0.85rem;
}
.top-section {
gap: var(--size-xs);
margin-bottom: var(--size-xs);
gap: var(--size-xs);
margin-bottom: var(--size-xs);
}
.board-section {
min-height: 300px;
}
.status-strip {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(255, 69, 200, 0.05);
border: 1px solid rgba(255, 69, 200, 0.15);
font-size: 12px;
}
@media (max-width: 480px) {
.game-shell {
padding: var(--size-sm);
padding: var(--size-sm);
}
.game-card {
padding: var(--size-sm);
border-radius: var(--border-radius-md);
padding: var(--size-sm);
border-radius: var(--border-radius-md);
}
header {
margin-bottom: var(--size-md);
margin-bottom: var(--size-md);
}
h1 {
font-size: var(--heading-h1-small);
font-size: var(--heading-h1-small);
}
.meta {
font-size: 0.75rem;
}
.top-section {
gap: var(--size-xs-gap);
margin-bottom: var(--size-xs);
gap: var(--size-xs-gap);
margin-bottom: var(--size-xs);
}
.board-section {
min-height: 250px;
}
:host-context(html:not([data-theme='dark'])) .status-strip {
background: rgba(255, 61, 187, 0.06);
border-color: rgba(255, 61, 187, 0.20);
}
.status-left { display: inline-flex; align-items: center; gap: 10px; }
.status-pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--nc-neon);
box-shadow: 0 0 6px var(--nc-neon);
animation: pulse 1.8s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.7); }
}
.status-text { font-weight: 500; color: var(--nc-text); letter-spacing: 0.02em; }
.status-side {
font-family: var(--nc-mono);
font-size: 10px;
letter-spacing: 0.14em;
color: var(--nc-text-dim);
}
/* container-type + aspect-ratio give cqw/cqh a defined size for the chess-board component */
.board-wrap {
container-type: size;
aspect-ratio: 1 / 1;
padding: 10px;
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 69, 200, 0.06);
}
:host-context(html:not([data-theme='dark'])) .board-wrap {
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 61, 187, 0.08);
}
.board-wrap.reviewing {
border-color: var(--nc-warning-soft);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 177, 58, 0.18);
}
/* ============================================================
SIDE COLUMN
============================================================ */
.side { display: flex; flex-direction: column; gap: 12px; }
.side-card {
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.side-card-summary {
list-style: none;
cursor: pointer;
padding: 13px 16px;
display: flex;
align-items: center;
gap: 8px;
user-select: none;
}
.side-card-summary::-webkit-details-marker { display: none; }
.side-card-title {
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--nc-text-muted);
font-weight: 600;
flex: 1;
}
.side-card-meta {
font-family: var(--nc-mono);
font-size: 10px;
color: var(--nc-text-dim);
letter-spacing: 0.08em;
}
.chev { color: var(--nc-text-dim); flex-shrink: 0; transition: transform 0.2s; }
.side-card[open] .chev { transform: rotate(180deg); }
.side-card[open] .side-card-summary { border-bottom: 1px solid var(--nc-border); }
.side-card-body {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* ============================================================
UCI INPUT
============================================================ */
.uci-row { display: flex; gap: 6px; }
.uci-input {
flex: 1;
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
font-family: var(--nc-mono);
font-size: 13px;
padding: 9px 12px;
letter-spacing: 0.04em;
outline: none;
transition: border-color 0.15s;
}
.uci-input:focus { border-color: var(--nc-neon-soft); }
.uci-input::placeholder { color: var(--nc-text-dim); }
.uci-hint { margin: 0; font-size: 11px; color: var(--nc-text-dim); line-height: 1.4; }
/* ============================================================
BOARD DESIGN SEGMENTED CONTROL
============================================================ */
.seg {
display: flex;
border: 1px solid var(--nc-border);
padding: 2px;
background: var(--nc-seg-bg);
}
.seg-btn {
flex: 1;
background: transparent;
border: none;
color: var(--nc-text-muted);
padding: 7px 10px;
font-size: 11px;
font-family: var(--nc-sans);
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.seg-btn.active { background: var(--nc-neon); color: #fff; font-weight: 700; }
/* ============================================================
RESIGN CONFIRM OVERLAY
============================================================ */
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 600;
}
.confirm-box {
background: var(--nc-surface-solid);
border: 1px solid var(--nc-danger-soft);
padding: 28px 32px;
min-width: 300px;
display: flex;
flex-direction: column;
gap: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.confirm-title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--nc-text);
}
.confirm-sub {
margin: 0 0 12px;
font-size: 13px;
color: var(--nc-text-muted);
}
.confirm-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.btn-danger-solid {
background: var(--nc-danger) !important;
color: #fff !important;
border-color: var(--nc-danger) !important;
font-weight: 700;
}
.btn-danger-solid:hover { opacity: 0.88; }
/* ============================================================
TOAST
============================================================ */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--nc-surface-solid);
border: 1px solid var(--nc-neon-soft);
color: var(--nc-text);
padding: 10px 18px;
font-size: 12px;
font-family: var(--nc-mono);
letter-spacing: 0.08em;
z-index: 500;
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 1100px) {
.layout { grid-template-columns: 1fr; }
.board-col { max-width: 560px; margin: 0 auto; }
}
@media (max-width: 640px) {
.page { padding: 16px 16px 48px; }
.game-header { flex-direction: column; align-items: flex-start; gap: 12px; }
.game-title h1 { font-size: 20px; }
}
+231 -128
View File
@@ -1,135 +1,238 @@
<main class="game-shell" [class.theme-dark]="isDarkMode">
<app-promotion-dialog [isOpen]="facade.isPromotionDialogOpen" (promotionSelected)="facade.onPromotionSelected($event)"
(closed)="facade.onPromotionClosed()" />
<app-promotion-dialog
[isOpen]="facade.isPromotionDialogOpen"
(promotionSelected)="facade.onPromotionSelected($event)"
(closed)="facade.onPromotionClosed()" />
<section class="game-card">
<header class="mb-3">
<a routerLink="/" class="back-link">Back</a>
<h1 class="mb-2">1 vs 1 Game</h1>
<p class="meta mb-0">Game ID: <strong>{{ facade.gameId }}</strong></p>
</header>
<div class="game-shell">
<div class="page">
@if (facade.loading) {
<p>Loading game state...</p>
} @else if (facade.state) {
@if (facade.isGameFinished && facade.gameCompletionMessage) {
<div class="game-completion-alert alert alert-success mb-3">
<h2 class="completion-title">{{ facade.gameCompletionMessage }}</h2>
<p class="completion-subtitle mb-0">
<a routerLink="/" class="completion-link">Start a new game</a>
</p>
</div>
}
@if (facade.isGameFinished && facade.gameCompletionMessage) {
<div class="game-completion-alert alert alert-success mb-3">
<h2 class="completion-title">{{ facade.gameCompletionMessage }}</h2>
<p class="completion-subtitle mb-0">
<a routerLink="/" class="completion-link">Start a new game</a>
</p>
</div>
}
<div class="container-fluid">
<div class="row g-3">
<!-- Left Sidebar - Dummy Timers -->
<div class="col-lg-3 col-md-6 col-12 order-lg-1 order-2">
<section class="timer-card">
<h2>Timers</h2>
<div class="player-timer" [class.active-timer]="facade.state.turn === 'white'">
<p class="timer-label">White</p>
<p class="timer-value">{{ formatTimer(whiteTimerSeconds) }}</p>
</div>
<div class="player-timer" [class.active-timer]="facade.state.turn === 'black'">
<p class="timer-label">Black</p>
<p class="timer-value">{{ formatTimer(blackTimerSeconds) }}</p>
</div>
</section>
</div>
<!-- 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">1 vs 1 Game</span>
</nav>
<!-- Center - Chess Board -->
<div class="col-lg-6 col-md-12 col-12 order-lg-2 order-1">
<section class="center-column d-flex flex-column h-100">
<div class="board-section flex-grow-1 d-flex align-items-center justify-content-center">
<app-chess-board [fen]="facade.state.fen" [selectedSquare]="facade.selectedSquare"
[highlightedSquares]="facade.highlightedSquares" [boardTheme]="boardTheme"
(squareSelected)="facade.onBoardSquareSelected($event)" />
</div>
<section class="top-section">
<section class="board-theme-card" aria-label="Board design chooser">
<h3>Board Design</h3>
<div class="board-theme-group" role="radiogroup" aria-label="Board design">
<label class="board-theme-option">
<input type="radio" name="boardTheme" [checked]="boardTheme === 'arabian'"
(change)="setBoardTheme('arabian')" />
<span>Arabian</span>
</label>
<label class="board-theme-option">
<input type="radio" name="boardTheme" [checked]="boardTheme === 'classic'"
(change)="setBoardTheme('classic')" />
<span>Classic</span>
</label>
</div>
</section>
<app-input-card label="Play move (UCI)" placeholder="e2e4" buttonLabel="Send Move" inputType="input"
[value]="facade.moveInput" cardClass="move-card" hintText="Click your piece to highlight legal targets."
(valueChange)="facade.moveInput = $event" (buttonClick)="facade.submitMove()" />
</section>
</section>
</div>
<!-- Right Sidebar - Export -->
<div class="col-lg-3 col-md-6 col-12 order-lg-3 order-3">
<section class="history-card">
<h2>Move History</h2>
@if (facade.state.moves.length === 0) {
<p class="history-empty">No moves yet.</p>
} @else {
<ol class="history-list">
@for (move of facade.state.moves; track $index) {
<li>
<span class="history-number">{{ $index + 1 }}.</span>
<span class="history-move">{{ move }}</span>
</li>
}
</ol>
}
</section>
<section class="export-card">
<h2>Export</h2>
<div class="export-mode-group" role="radiogroup" aria-label="Export mode">
<label class="export-mode-option">
<input type="radio" name="exportType" [checked]="exportType === 'fen'"
(change)="setExportType('fen')" />
<span>FEN</span>
</label>
<label class="export-mode-option">
<input type="radio" name="exportType" [checked]="exportType === 'pgn'"
(change)="setExportType('pgn')" />
<span>PGN</span>
</label>
</div>
<textarea class="export-text" [value]="exportValue"
[placeholder]="exportType === 'fen' ? 'FEN will appear here' : 'PGN will appear here'" rows="8"
readonly></textarea>
<button type="button" class="app-btn w-100" (click)="completeExport()">Done</button>
@if (exportNotice) {
<p class="export-note">{{ exportNotice }}</p>
}
</section>
<!-- Game header -->
<header class="game-header">
<div class="game-title">
<h1>
1 vs 1 Game
@if (facade.game) {
<span class="tag-rated">Live</span>
}
</h1>
<div class="game-meta-strip">
<span class="game-id">
ID <strong>{{ facade.gameId }}</strong>
<button class="copy-btn" type="button" title="Copy game ID" (click)="copyGameId()">
<svg width="13" height="13" 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>
</button>
</span>
@if (facade.state) {
<span class="meta-dot"></span>
<span>Move {{ moveNumber }}</span>
}
</div>
</div>
</div>
<div class="header-actions">
<button class="btn-ghost" type="button" (click)="flipBoard()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" 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>
Flip
</button>
<button class="btn" type="button" (click)="copyUrl()">Share</button>
</div>
</header>
<!-- Loading / error states -->
@if (facade.loading) {
<div class="state-message">
<span class="status-pulse"></span>
Loading game…
</div>
} @else if (facade.state) {
@if (facade.errorMessage) {
<div class="state-message state-error">{{ facade.errorMessage }}</div>
}
<!-- Game completed banner -->
@if (facade.isGameFinished && facade.gameCompletionMessage) {
<div class="completion-banner">
<div class="completion-left">
<span class="completion-icon"></span>
<div>
<div class="completion-title">{{ facade.gameCompletionMessage }}</div>
<div class="completion-sub">Game #{{ facade.gameId }}</div>
</div>
</div>
<div class="completion-actions">
<a routerLink="/games" class="completion-link">My games</a>
<a routerLink="/" class="btn btn-primary completion-new">New game</a>
</div>
</div>
}
@if (!facade.isGameFinished && ((whiteTimerMs !== null && whiteTimerMs <= 0) || (blackTimerMs !== null && blackTimerMs <= 0))) {
<div class="completion-banner completion-banner--timeout">
<span class="completion-title">Time's up!</span>
<span class="completion-sub">Waiting for server to confirm result…</span>
</div>
}
<!-- Main layout -->
<div class="layout">
<!-- BOARD COLUMN -->
<div class="board-col">
<!-- Opponent (top) -->
<app-player-card
[name]="blackPlayerName"
[initial]="blackPlayerInitial"
color="black"
[isActive]="!flipped ? facade.state.turn === 'black' : facade.state.turn === 'white'"
[clockDisplay]="!flipped ? blackClock : whiteClock"
[isLowTime]="!flipped ? isLowTimeBlack : isLowTimeWhite" />
<!-- Status strip -->
<div class="status-strip">
<div class="status-left">
<span class="status-pulse"></span>
<span class="status-text" [innerHTML]="statusMessage"></span>
</div>
<span class="status-side">
{{ facade.state.turn === 'white' ? 'WHITE' : 'BLACK' }} TO MOVE
</span>
</div>
<!-- Board -->
<div class="board-wrap" [class.reviewing]="facade.isReviewing">
<app-chess-board
[fen]="facade.displayFen"
[selectedSquare]="facade.isReviewing ? null : facade.selectedSquare"
[highlightedSquares]="facade.isReviewing ? [] : facade.highlightedSquares"
[boardTheme]="boardTheme"
(squareSelected)="facade.onBoardSquareSelected($event)" />
</div>
<!-- Current player (bottom) -->
<app-player-card
[name]="whitePlayerName"
[initial]="whitePlayerInitial"
color="white"
[isActive]="!flipped ? facade.state.turn === 'white' : facade.state.turn === 'black'"
[clockDisplay]="!flipped ? whiteClock : blackClock"
[isLowTime]="!flipped ? isLowTimeWhite : isLowTimeBlack" />
<!-- Board action buttons -->
<app-board-actions-bar
[undoAvailable]="facade.state.undoAvailable"
[isGameFinished]="facade.isGameFinished"
(takeback)="onTakeback()"
(offerDraw)="onOfferDraw()"
(resign)="onResign()" />
</div>
<!-- SIDE COLUMN -->
<aside class="side">
<!-- Move history (collapsible) -->
<details class="side-card" open>
<summary class="side-card-summary">
<span class="side-card-title">Move History</span>
<span class="side-card-meta">{{ facade.state.moves.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-move-history
[moves]="facade.state.moves"
[viewingPly]="facade.viewingPly"
(navigate)="facade.navigateHistory($event)"
(navigateToPly)="facade.navigateToPly($event)" />
</details>
<!-- Play move (collapsible) -->
<details class="side-card" open>
<summary class="side-card-summary">
<span class="side-card-title">Play Move</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">
<div class="uci-row">
<input
id="uci-input"
type="text"
class="uci-input"
placeholder="e.g. e2e4"
autocomplete="off"
[value]="facade.moveInput"
(input)="facade.moveInput = $any($event.target).value"
(keydown.enter)="facade.submitMove()" />
<button class="btn btn-primary" type="button" (click)="facade.submitMove()">Send</button>
</div>
<p class="uci-hint">Click a piece on the board to see legal targets.</p>
</div>
</details>
<!-- Board design (collapsible) -->
<details class="side-card">
<summary class="side-card-summary">
<span class="side-card-title">Board Design</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">
<div class="seg" role="tablist" aria-label="Board theme">
<button class="seg-btn" [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>
</details>
<!-- Export (collapsible) -->
<app-export-panel [fen]="facade.state.fen" [pgn]="facade.state.pgn" />
</aside>
</div>
}
@if (facade.errorMessage) {
<p class="alert alert-danger mt-3 mb-0">{{ facade.errorMessage }}</p>
}
</section>
</main>
</div>
</div>
<!-- Resign confirmation dialog -->
@if (facade.resignConfirmPending) {
<div class="confirm-overlay" role="dialog" aria-modal="true" aria-label="Confirm resign">
<div class="confirm-box">
<p class="confirm-title">Resign this game?</p>
<p class="confirm-sub">Your opponent will be declared the winner.</p>
<div class="confirm-actions">
<button class="btn" type="button" (click)="facade.cancelResign()">Cancel</button>
<button class="btn btn-danger-solid" type="button" (click)="facade.confirmResign()">Yes, resign</button>
</div>
</div>
</div>
}
<!-- Toast notification -->
@if (toastMessage) {
<div class="toast show">{{ toastMessage }}</div>
}
+149 -191
View File
@@ -1,52 +1,115 @@
import { CommonModule } from '@angular/common';
import { Component, DestroyRef, OnDestroy, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { BoardActionsBarComponent } from '../../components/board-actions-bar/board-actions-bar.component';
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
import { InputCardComponent } from '../../components/input-card/input-card.component';
import { ExportPanelComponent } from '../../components/export-panel/export-panel.component';
import { MoveHistoryComponent } from '../../components/move-history/move-history.component';
import { PlayerCardComponent } from '../../components/player-card/player-card.component';
import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component';
import { GameFacade } from './game.facade';
type TimerTurn = 'white' | 'black';
type BoardTheme = 'arabian' | 'classic';
interface TimerSnapshot {
whiteSeconds: number;
blackSeconds: number;
turn: TimerTurn;
savedAt: number;
}
const LOW_TIME_THRESHOLD_MS = 60_000;
const BOARD_THEME_KEY = 'nowchess.boardTheme';
@Component({
selector: 'app-game',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent, InputCardComponent, PromotionDialogComponent],
imports: [
RouterLink,
ChessBoardComponent,
PromotionDialogComponent,
PlayerCardComponent,
MoveHistoryComponent,
ExportPanelComponent,
BoardActionsBarComponent,
],
providers: [GameFacade],
templateUrl: './game.component.html',
styleUrl: './game.component.css'
})
export class GameComponent implements OnInit, OnDestroy {
private static readonly TIMER_START_SECONDS = 10 * 60;
private static readonly BOARD_THEME_STORAGE_KEY = 'nowchess.boardTheme';
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
readonly facade = inject(GameFacade);
whiteTimerSeconds = GameComponent.TIMER_START_SECONDS;
blackTimerSeconds = GameComponent.TIMER_START_SECONDS;
exportType: 'fen' | 'pgn' = 'fen';
boardTheme: BoardTheme = 'arabian';
isDarkMode = false;
exportValue = '';
exportNotice = '';
private timerIntervalId: number | null = null;
private activeGameId = '';
whiteTimerMs: number | null = null;
blackTimerMs: number | null = null;
boardTheme: BoardTheme = 'arabian';
flipped = false;
toastMessage = '';
private timerIntervalId: number | null = null;
private toastTimer: ReturnType<typeof setTimeout> | null = null;
// ── Player display ──────────────────────────────────────────
get whitePlayerName(): string {
return this.facade.game?.white.displayName ?? 'White';
}
get blackPlayerName(): string {
return this.facade.game?.black.displayName ?? 'Black';
}
get whitePlayerInitial(): string {
return this.whitePlayerName.charAt(0).toUpperCase();
}
get blackPlayerInitial(): string {
return this.blackPlayerName.charAt(0).toUpperCase();
}
// ── Clocks ──────────────────────────────────────────────────
get whiteClock(): string {
return this.formatTimer(this.whiteTimerMs);
}
get blackClock(): string {
return this.formatTimer(this.blackTimerMs);
}
get isLowTimeWhite(): boolean {
return this.whiteTimerMs !== null && this.whiteTimerMs < LOW_TIME_THRESHOLD_MS;
}
get isLowTimeBlack(): boolean {
return this.blackTimerMs !== null && this.blackTimerMs < LOW_TIME_THRESHOLD_MS;
}
// ── Status message ───────────────────────────────────────────
get statusMessage(): string {
const state = this.facade.state;
if (!state) return '';
if (state.status === 'check') {
const who = state.turn === 'white' ? 'White' : 'Black';
return `<b>${who}</b> is in check`;
}
if (state.status === 'drawOffered') {
return 'Draw offer pending';
}
const last = state.moves.length > 0 ? state.moves[state.moves.length - 1] : null;
if (last) {
const mover = state.turn === 'white' ? this.blackPlayerName : this.whitePlayerName;
return `${mover} played <b>${last}</b>`;
}
return 'Game started';
}
// ── Move number ──────────────────────────────────────────────
get moveNumber(): number {
return Math.ceil((this.facade.state?.moves.length ?? 0) / 2);
}
// ── Lifecycle ────────────────────────────────────────────────
ngOnInit(): void {
this.applyIncomingTheme();
this.syncThemeFromDocument();
this.boardTheme = this.resolveStoredBoardTheme();
this.startDummyTimers();
this.startClock();
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => {
const id = paramMap.get('gameId');
@@ -55,11 +118,7 @@ export class GameComponent implements OnInit, OnDestroy {
this.facade.loading = false;
return;
}
this.activeGameId = id;
this.restoreTimers(id);
this.facade.setGameId(id);
this.syncExportValue();
});
}
@@ -67,192 +126,91 @@ export class GameComponent implements OnInit, OnDestroy {
if (this.timerIntervalId !== null) {
window.clearInterval(this.timerIntervalId);
}
this.persistTimers(this.resolveCurrentTurn());
}
private syncThemeFromDocument(): void {
this.isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark';
}
private applyIncomingTheme(): void {
const incomingTheme = window.history.state?.theme;
if (incomingTheme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
return;
}
if (incomingTheme === 'light') {
document.documentElement.removeAttribute('data-theme');
localStorage.removeItem('theme');
}
}
setExportType(type: 'fen' | 'pgn'): void {
this.exportType = type;
this.exportNotice = '';
this.syncExportValue();
}
// ── Board theme ──────────────────────────────────────────────
setBoardTheme(theme: BoardTheme): void {
this.boardTheme = theme;
localStorage.setItem(GameComponent.BOARD_THEME_STORAGE_KEY, theme);
localStorage.setItem(BOARD_THEME_KEY, theme);
}
completeExport(): void {
this.syncExportValue();
if (!this.exportValue.trim()) {
this.exportNotice = 'Nothing to export yet.';
return;
}
if (!navigator.clipboard?.writeText) {
this.exportNotice = 'Export is ready in the text box.';
return;
}
void navigator.clipboard
.writeText(this.exportValue)
.then(() => {
this.exportNotice = 'Copied to clipboard.';
})
.catch(() => {
this.exportNotice = 'Export is ready in the text box.';
});
// ── Board flip ───────────────────────────────────────────────
flipBoard(): void {
this.flipped = !this.flipped;
}
formatTimer(totalSeconds: number): string {
const safeSeconds = Math.max(0, totalSeconds);
const minutes = Math.floor(safeSeconds / 60)
.toString()
.padStart(2, '0');
const seconds = (safeSeconds % 60).toString().padStart(2, '0');
// ── Copy helpers ─────────────────────────────────────────────
copyGameId(): void {
void navigator.clipboard?.writeText(this.facade.gameId).then(() => this.showToast('Game ID copied'));
}
copyUrl(): void {
void navigator.clipboard?.writeText(window.location.href).then(() => this.showToast('Link copied'));
}
// ── Board actions ─────────────────────────────────────────────
onTakeback(): void {
this.showToast('Takeback requested');
}
onOfferDraw(): void {
this.showToast('Draw offered');
}
onResign(): void {
this.facade.requestResign();
}
// ── Timer helpers ─────────────────────────────────────────────
private formatTimer(ms: number | null): string {
if (ms === null) return '--:--';
if (ms < 0) return '—';
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
return `${minutes}:${seconds}`;
}
private startDummyTimers(): void {
if (this.timerIntervalId !== null) {
return;
}
this.timerIntervalId = window.setInterval(() => {
this.tickDummyTimers();
this.syncExportValue();
}, 1000);
// ── Private ───────────────────────────────────────────────────
private startClock(): void {
if (this.timerIntervalId !== null) return;
this.timerIntervalId = window.setInterval(() => this.tickClock(), 200);
}
private tickDummyTimers(): void {
private tickClock(): void {
const state = this.facade.state;
if (!state || this.facade.loading || this.facade.isGameFinished) {
const clock = state?.clock;
if (!clock || this.facade.isGameFinished) {
this.whiteTimerMs = null;
this.blackTimerMs = null;
return;
}
if (state.turn === 'white') {
this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - 1);
this.persistTimers('white');
return;
}
const elapsed = Math.max(0, Date.now() - this.facade.clockSyncedAt);
const activeIsWhite = state!.turn === 'white';
this.whiteTimerMs = clock.whiteRemainingMs < 0
? -1
: Math.max(0, clock.whiteRemainingMs - (activeIsWhite ? elapsed : 0));
this.blackTimerMs = clock.blackRemainingMs < 0
? -1
: Math.max(0, clock.blackRemainingMs - (!activeIsWhite ? elapsed : 0));
this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - 1);
this.persistTimers('black');
}
private syncExportValue(): void {
const state = this.facade.state;
if (!state) {
this.exportValue = '';
return;
}
this.exportValue = this.exportType === 'fen' ? state.fen : state.pgn;
}
private restoreTimers(gameId: string): void {
const fallbackTurn = this.resolveCurrentTurn();
const rawSnapshot = localStorage.getItem(this.getTimerStorageKey(gameId));
if (!rawSnapshot) {
this.resetTimers();
this.persistTimers(fallbackTurn);
return;
}
const snapshot = this.parseSnapshot(rawSnapshot);
if (!snapshot) {
this.resetTimers();
this.persistTimers(fallbackTurn);
return;
}
this.applySnapshot(snapshot);
this.persistTimers(snapshot.turn);
}
private parseSnapshot(rawSnapshot: string): TimerSnapshot | null {
try {
const parsed = JSON.parse(rawSnapshot) as Partial<TimerSnapshot>;
if (
typeof parsed.whiteSeconds !== 'number' ||
typeof parsed.blackSeconds !== 'number' ||
(parsed.turn !== 'white' && parsed.turn !== 'black') ||
typeof parsed.savedAt !== 'number'
) {
return null;
}
return {
whiteSeconds: Math.max(0, Math.floor(parsed.whiteSeconds)),
blackSeconds: Math.max(0, Math.floor(parsed.blackSeconds)),
turn: parsed.turn,
savedAt: parsed.savedAt
};
} catch {
return null;
if ((this.whiteTimerMs !== null && this.whiteTimerMs <= 0 && clock.whiteRemainingMs > 0) ||
(this.blackTimerMs !== null && this.blackTimerMs <= 0 && clock.blackRemainingMs > 0)) {
this.facade.errorMessage = '';
}
}
private applySnapshot(snapshot: TimerSnapshot): void {
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - snapshot.savedAt) / 1000));
this.whiteTimerSeconds = snapshot.whiteSeconds;
this.blackTimerSeconds = snapshot.blackSeconds;
if (snapshot.turn === 'white') {
this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - elapsedSeconds);
return;
}
this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - elapsedSeconds);
}
private persistTimers(turn: TimerTurn): void {
if (!this.activeGameId) {
return;
}
const snapshot: TimerSnapshot = {
whiteSeconds: this.whiteTimerSeconds,
blackSeconds: this.blackTimerSeconds,
turn,
savedAt: Date.now()
};
localStorage.setItem(this.getTimerStorageKey(this.activeGameId), JSON.stringify(snapshot));
}
private resolveCurrentTurn(): TimerTurn {
return this.facade.state?.turn ?? 'white';
}
private resetTimers(): void {
this.whiteTimerSeconds = GameComponent.TIMER_START_SECONDS;
this.blackTimerSeconds = GameComponent.TIMER_START_SECONDS;
}
private getTimerStorageKey(gameId: string): string {
return `nowchess.timer.${gameId}`;
private showToast(msg: string): void {
this.toastMessage = msg;
if (this.toastTimer !== null) clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => {
this.toastMessage = '';
}, 1800);
}
private resolveStoredBoardTheme(): BoardTheme {
const stored = localStorage.getItem(GameComponent.BOARD_THEME_STORAGE_KEY);
const stored = localStorage.getItem(BOARD_THEME_KEY);
return stored === 'classic' ? 'classic' : 'arabian';
}
}
+94 -6
View File
@@ -8,11 +8,13 @@ import { GameCompletionService } from '../../services/game-completion.service';
import { GameImportService } from '../../services/game-import.service';
import { BoardSelectionService, BoardSelection } from '../../services/board-selection.service';
import { GameStreamService } from '../../services/game-stream.service';
import { GameHistoryService } from '../../services/game-history.service';
@Injectable()
export class GameFacade implements OnDestroy {
gameId = '';
game: GameFull | null = null;
clockSyncedAt = 0;
errorMessage = '';
moveInput = '';
fenInput = '';
@@ -21,6 +23,11 @@ export class GameFacade implements OnDestroy {
gameCompletionMessage = '';
isGameFinished = false;
isPromotionDialogOpen = false;
resignConfirmPending = false;
private fenHistory: string[] = [];
private sessionStartPly = 0;
viewingPly: number | null = null;
private boardSelection: BoardSelection = {
selectedSquare: null,
@@ -36,6 +43,7 @@ export class GameFacade implements OnDestroy {
private readonly importService = inject(GameImportService);
private readonly boardSelectionService = inject(BoardSelectionService);
private readonly streamService = inject(GameStreamService);
private readonly gameHistory = inject(GameHistoryService);
get state(): GameState | null {
return this.game?.state ?? null;
@@ -49,6 +57,46 @@ export class GameFacade implements OnDestroy {
return this.boardSelection.highlightedSquares;
}
get displayFen(): string {
if (this.viewingPly !== null) {
const historyIndex = this.viewingPly - this.sessionStartPly;
return this.fenHistory[historyIndex] ?? this.game?.state.fen ?? '';
}
return this.game?.state.fen ?? '';
}
get isReviewing(): boolean {
return this.viewingPly !== null;
}
navigateToPly(ply: number): void {
const historyIndex = ply - this.sessionStartPly;
if (historyIndex < 0 || historyIndex >= this.fenHistory.length) return;
this.viewingPly = ply;
this.boardSelection = this.boardSelectionService.clearSelection();
}
navigateHistory(direction: 'first' | 'prev' | 'next' | 'last'): void {
const totalPly = this.sessionStartPly + this.fenHistory.length - 1;
const current = this.viewingPly ?? totalPly;
let next: number;
switch (direction) {
case 'first': next = this.sessionStartPly; break;
case 'prev': next = Math.max(this.sessionStartPly, current - 1); break;
case 'next': next = Math.min(totalPly, current + 1); break;
case 'last':
default: next = totalPly; break;
}
if (next === totalPly) {
this.viewingPly = null;
} else {
this.viewingPly = next;
}
this.boardSelection = this.boardSelectionService.clearSelection();
}
ngOnDestroy(): void {
this.streamService.cleanup();
this.botMoveService.cleanup();
@@ -60,7 +108,7 @@ export class GameFacade implements OnDestroy {
}
onBoardSquareSelected(square: string): void {
if (!this.state) {
if (!this.state || this.viewingPly !== null) {
return;
}
@@ -119,6 +167,9 @@ export class GameFacade implements OnDestroy {
next: (state) => {
if (this.game) {
this.game = { ...this.game, state };
this.clockSyncedAt = Date.now();
this.pushFen(state.fen);
this.viewingPly = null;
this.updateGameCompletion();
}
this.moveInput = '';
@@ -167,6 +218,26 @@ export class GameFacade implements OnDestroy {
this.pendingPromotionMoves = [];
}
requestResign(): void {
this.resignConfirmPending = true;
}
cancelResign(): void {
this.resignConfirmPending = false;
}
confirmResign(): void {
this.resignConfirmPending = false;
this.gameApi
.resignGame(this.gameId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Could not resign.');
}
});
}
importFen(): void {
this.errorMessage = '';
this.importService.importFen(
@@ -200,6 +271,8 @@ export class GameFacade implements OnDestroy {
this.errorMessage = '';
this.boardSelection = this.boardSelectionService.clearSelection();
this.streamService.cleanup();
this.fenHistory = [];
this.viewingPly = null;
this.gameApi
.getGame(this.gameId)
@@ -207,8 +280,12 @@ export class GameFacade implements OnDestroy {
.subscribe({
next: (game) => {
this.game = game;
this.clockSyncedAt = Date.now();
this.loading = false;
this.sessionStartPly = game.state.moves.length;
this.fenHistory = [game.state.fen];
this.updateGameCompletion();
this.gameHistory.recordGame(this.gameId);
this.startStreaming();
this.tryMakeBotMove();
},
@@ -223,16 +300,18 @@ export class GameFacade implements OnDestroy {
this.streamService.startStreaming(
this.gameId,
(event) => this.applyStreamEvent(event),
() => {
this.errorMessage = 'Live stream disconnected. Falling back to polling.';
}
() => { /* polling fallback — not an error */ }
);
}
private applyStreamEvent(event: GameStreamEvent): void {
if (event.type === 'gameFull') {
this.game = event.game;
this.boardSelection = this.boardSelectionService.clearSelection();
this.clockSyncedAt = Date.now();
this.pushFen(event.game.state.fen);
if (this.viewingPly === null) {
this.boardSelection = this.boardSelectionService.clearSelection();
}
this.updateGameCompletion();
this.tryMakeBotMove();
return;
@@ -241,8 +320,10 @@ export class GameFacade implements OnDestroy {
if (event.type === 'gameState' && this.game) {
const moveCountBefore = this.game.state.moves.length;
this.game = { ...this.game, state: event.state };
this.clockSyncedAt = Date.now();
this.pushFen(event.state.fen);
this.updateGameCompletion();
if (event.state.moves.length !== moveCountBefore) {
if (event.state.moves.length !== moveCountBefore && this.viewingPly === null) {
this.boardSelection = this.boardSelectionService.clearSelection();
this.tryMakeBotMove();
}
@@ -254,6 +335,13 @@ export class GameFacade implements OnDestroy {
}
}
private pushFen(fen: string): void {
const last = this.fenHistory[this.fenHistory.length - 1];
if (last !== fen) {
this.fenHistory.push(fen);
}
}
private tryMakeBotMove(): void {
this.botMoveService.tryMakeBotMove(
this.gameId,
+366
View File
@@ -0,0 +1,366 @@
:host {
--nc-neon: #ff45c8;
--nc-bg: #06060d;
--nc-surface: rgba(20, 17, 42, 0.6);
--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-success: #5ee5a1;
--nc-danger: #ff7a7a;
--nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--nc-mono: "JetBrains Mono", "Fira Code", monospace;
display: block;
min-height: 100vh;
background: var(--nc-bg);
font-family: var(--nc-sans);
color: var(--nc-text);
}
:host-context(html:not([data-theme='dark'])) {
--nc-neon: #c026d3;
--nc-bg: #f5f0fc;
--nc-surface: rgba(255, 255, 255, 0.88);
--nc-text: #0f0022;
--nc-text-muted: rgba(15, 0, 34, 0.65);
--nc-text-dim: rgba(15, 0, 34, 0.4);
--nc-border: rgba(15, 0, 34, 0.1);
--nc-border-strong: rgba(15, 0, 34, 0.2);
--nc-success: #16a34a;
--nc-danger: #dc2626;
}
.games-shell {
padding-top: 72px;
min-height: 100vh;
}
.page {
max-width: 760px;
margin: 0 auto;
padding: 32px 20px 64px;
}
/* ── Breadcrumb ─────────────────────────── */
.crumb {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 28px;
font-size: 11px;
color: var(--nc-text-dim);
letter-spacing: 0.06em;
}
.crumb-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--nc-text-dim);
text-decoration: none;
transition: color 0.15s;
}
.crumb-link:hover { color: var(--nc-neon); }
.crumb-sep { opacity: 0.4; }
.crumb-current {
color: var(--nc-text-muted);
font-weight: 500;
}
/* ── Header ─────────────────────────────── */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
flex-wrap: wrap;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--nc-text);
}
/* ── Tabs ───────────────────────────────── */
.tabs {
display: flex;
gap: 2px;
background: var(--nc-surface);
border: 1px solid var(--nc-border);
padding: 3px;
}
.tab-btn {
background: transparent;
border: none;
color: var(--nc-text-muted);
font-family: var(--nc-sans);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 6px 16px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background 0.15s, color 0.15s;
}
.tab-btn:hover { color: var(--nc-text); }
.tab-btn.active {
background: var(--nc-neon);
color: #1a0014;
}
:host-context(html:not([data-theme='dark'])) .tab-btn.active { color: #fff; }
.tab-badge {
background: rgba(255, 255, 255, 0.25);
color: inherit;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 8px;
line-height: 1.4;
}
/* ── State messages ─────────────────────── */
.state-msg {
display: flex;
align-items: center;
gap: 10px;
color: var(--nc-text-dim);
font-size: 13px;
padding: 32px 0;
}
.pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--nc-neon);
animation: pulse-ring 1.4s ease-in-out infinite;
}
@keyframes pulse-ring {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.6); }
}
/* ── Empty state ────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 64px 20px;
text-align: center;
}
.empty-icon {
width: 56px;
height: 56px;
border: 1px solid var(--nc-border);
background: var(--nc-surface);
display: flex;
align-items: center;
justify-content: center;
color: var(--nc-text-dim);
margin-bottom: 8px;
}
.empty-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--nc-text);
}
.empty-sub {
margin: 0;
font-size: 13px;
color: var(--nc-text-dim);
}
.btn-primary {
margin-top: 12px;
background: var(--nc-neon);
color: #1a0014;
border: none;
padding: 9px 22px;
font-family: var(--nc-sans);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
text-decoration: none;
display: inline-flex;
cursor: pointer;
transition: filter 0.15s;
}
:host-context(html:not([data-theme='dark'])) .btn-primary { color: #fff; }
.btn-primary:hover { filter: brightness(1.1); }
/* ── Game list ──────────────────────────── */
.game-list {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--nc-border);
background: var(--nc-surface);
overflow: hidden;
}
.game-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
border-bottom: 1px solid var(--nc-border);
transition: background 0.12s;
}
.game-row:last-child { border-bottom: none; }
.game-row:hover { background: rgba(255, 255, 255, 0.03); }
:host-context(html:not([data-theme='dark'])) .game-row:hover {
background: rgba(192, 38, 211, 0.04);
}
.game-row-main {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.game-players {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
}
.player { color: var(--nc-text); }
.vs-sep {
font-size: 10px;
font-weight: 500;
color: var(--nc-text-dim);
letter-spacing: 0.1em;
}
.game-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--nc-text-dim);
font-family: var(--nc-mono);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.active-dot {
background: var(--nc-success);
box-shadow: 0 0 6px var(--nc-success);
}
.finished-dot {
background: var(--nc-text-dim);
}
.status-text { color: var(--nc-text-muted); }
.meta-sep { opacity: 0.4; }
.meta-item { color: var(--nc-text-dim); }
.game-id-label {
font-size: 10px;
color: var(--nc-text-dim);
opacity: 0.7;
}
/* ── Row actions ────────────────────────── */
.game-row-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.btn-resume,
.btn-view {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: none;
font-family: var(--nc-sans);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
transition: filter 0.15s;
}
.btn-resume {
background: var(--nc-neon);
color: #1a0014;
}
:host-context(html:not([data-theme='dark'])) .btn-resume { color: #fff; }
.btn-resume:hover { filter: brightness(1.1); }
.btn-view {
background: transparent;
color: var(--nc-text-muted);
border: 1px solid var(--nc-border-strong);
}
.btn-view:hover {
color: var(--nc-neon);
border-color: var(--nc-neon);
}
.btn-remove {
background: transparent;
border: 1px solid transparent;
color: var(--nc-text-dim);
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.btn-remove:hover {
color: var(--nc-danger);
border-color: var(--nc-border);
}
+156
View File
@@ -0,0 +1,156 @@
<div class="games-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">Games</span>
</nav>
<!-- Header -->
<header class="page-header">
<h1 class="page-title">Games</h1>
<!-- Tabs -->
<div class="tabs" role="tablist">
<button type="button" class="tab-btn" [class.active]="tab === 'active'" role="tab"
(click)="setTab('active')">
Active
@if (activeGames.length > 0) {
<span class="tab-badge">{{ activeGames.length }}</span>
}
</button>
<button type="button" class="tab-btn" [class.active]="tab === 'history'" role="tab"
(click)="setTab('history')">
History
</button>
</div>
</header>
<!-- Content -->
@if (loading) {
<div class="state-msg">
<span class="pulse"></span>
Loading games…
</div>
} @else if (tab === 'active') {
@if (activeGames.length === 0) {
<div class="empty-state">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" 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>
</div>
<p class="empty-title">No active games</p>
<p class="empty-sub">Start a new game from the lobby to see it here.</p>
<a routerLink="/" class="btn-primary">Go to lobby</a>
</div>
} @else {
<div class="game-list">
@for (game of activeGames; track game.gameId) {
<div class="game-row">
<div class="game-row-main">
<div class="game-players">
<span class="player white-player">{{ game.white.displayName }}</span>
<span class="vs-sep">vs</span>
<span class="player black-player">{{ game.black.displayName }}</span>
</div>
<div class="game-meta">
<span class="status-dot active-dot"></span>
<span class="status-text">{{ statusLabel(game.state.status) }}</span>
<span class="meta-sep">·</span>
<span class="meta-item">{{ game.state.moves.length }} moves</span>
<span class="meta-sep">·</span>
<span class="game-id-label">{{ game.gameId.slice(0, 8) }}</span>
</div>
</div>
<div class="game-row-actions">
<button type="button" class="btn-resume" (click)="resumeGame(game.gameId)">
<svg width="12" height="12" 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>
Resume
</button>
<button type="button" class="btn-remove" title="Remove from list" (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>
</button>
</div>
</div>
}
</div>
}
} @else {
@if (finishedGames.length === 0) {
<div class="empty-state">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
</div>
<p class="empty-title">No game history yet</p>
<p class="empty-sub">Completed games will appear here.</p>
</div>
} @else {
<div class="game-list">
@for (game of finishedGames; track game.gameId) {
<div class="game-row">
<div class="game-row-main">
<div class="game-players">
<span class="player white-player">{{ game.white.displayName }}</span>
<span class="vs-sep">vs</span>
<span class="player black-player">{{ game.black.displayName }}</span>
</div>
<div class="game-meta">
<span class="status-dot finished-dot"></span>
<span class="status-text">{{ statusLabel(game.state.status) }}</span>
<span class="meta-sep">·</span>
<span class="meta-item">{{ game.state.moves.length }} moves</span>
<span class="meta-sep">·</span>
<span class="game-id-label">{{ game.gameId.slice(0, 8) }}</span>
</div>
</div>
<div class="game-row-actions">
<button type="button" class="btn-view" (click)="resumeGame(game.gameId)">
<svg width="12" height="12" viewBox="0 0 24 24" 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>
View
</button>
<button type="button" class="btn-remove" title="Remove from list" (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>
</button>
</div>
</div>
}
</div>
}
}
</div>
</div>
+107
View File
@@ -0,0 +1,107 @@
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AuthService } from '../../services/auth.service';
import { GameApiService } from '../../services/game-api.service';
import { GameHistoryService } from '../../services/game-history.service';
import { GameFull, GameStatus } from '../../models/game.models';
type GamesTab = 'active' | 'history';
const FINISHED_STATUSES: GameStatus[] = [
'checkmate', 'stalemate', 'resign', 'draw', 'insufficientMaterial'
];
@Component({
selector: 'app-games',
standalone: true,
imports: [RouterLink],
templateUrl: './games.component.html',
styleUrl: './games.component.css'
})
export class GamesComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router);
private readonly authService = inject(AuthService);
private readonly gameApi = inject(GameApiService);
private readonly gameHistory = inject(GameHistoryService);
tab: GamesTab = 'active';
loading = true;
activeGames: GameFull[] = [];
finishedGames: GameFull[] = [];
ngOnInit(): void {
this.authService.currentUser$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
if (!user) void this.router.navigate(['/']);
});
this.loadGames();
}
setTab(tab: GamesTab): void {
this.tab = tab;
}
resumeGame(gameId: string): void {
void this.router.navigate(['/game', gameId]);
}
removeGame(gameId: string): void {
this.gameHistory.removeGame(gameId);
this.activeGames = this.activeGames.filter((g) => g.gameId !== gameId);
this.finishedGames = this.finishedGames.filter((g) => g.gameId !== gameId);
}
statusLabel(status: GameStatus): string {
const labels: Record<GameStatus, string> = {
started: 'In Progress',
check: 'Check',
checkmate: 'Checkmate',
stalemate: 'Stalemate',
resign: 'Resigned',
draw: 'Draw',
drawOffered: 'Draw Offered',
fiftyMoveAvailable: 'In Progress',
promotionPending: 'In Progress',
insufficientMaterial: 'Draw'
};
return labels[status] ?? status;
}
isFinished(status: GameStatus): boolean {
return FINISHED_STATUSES.includes(status);
}
private loadGames(): void {
const ids = this.gameHistory.getGameIds();
if (ids.length === 0) {
this.loading = false;
return;
}
const requests = ids.map((id) =>
this.gameApi.getGame(id).pipe(
catchError((err: unknown) => {
if (typeof err === 'object' && err !== null && (err as { status: number }).status === 404) {
this.gameHistory.removeGame(id);
}
return of(null);
})
)
);
forkJoin(requests)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((results) => {
const valid = results.filter((g): g is GameFull => g !== null);
this.activeGames = valid.filter((g) => !FINISHED_STATUSES.includes(g.state.status));
this.finishedGames = valid.filter((g) => FINISHED_STATUSES.includes(g.state.status));
this.loading = false;
});
}
}
@@ -0,0 +1,412 @@
:host {
--nc-neon: #ff45c8;
--nc-bg: #06060d;
--nc-surface: rgba(20, 17, 42, 0.6);
--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-success: #5ee5a1;
--nc-danger: #ff7a7a;
--nc-warn: #ffd166;
--nc-sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
display: block;
min-height: 100vh;
background: var(--nc-bg);
font-family: var(--nc-sans);
color: var(--nc-text);
}
:host-context(html:not([data-theme='dark'])) {
--nc-neon: #c026d3;
--nc-bg: #f5f0fc;
--nc-surface: rgba(255, 255, 255, 0.88);
--nc-text: #0f0022;
--nc-text-muted: rgba(15, 0, 34, 0.65);
--nc-text-dim: rgba(15, 0, 34, 0.4);
--nc-border: rgba(15, 0, 34, 0.1);
--nc-border-strong: rgba(15, 0, 34, 0.2);
--nc-success: #16a34a;
--nc-danger: #dc2626;
--nc-warn: #b45309;
}
.t-shell { padding-top: 72px; min-height: 100vh; }
.page {
max-width: 760px;
margin: 0 auto;
padding: 32px 20px 64px;
}
/* Breadcrumb */
.crumb {
display: flex; align-items: center; gap: 8px;
margin-bottom: 28px; font-size: 11px;
color: var(--nc-text-dim); letter-spacing: 0.06em;
}
.crumb-link {
display: inline-flex; align-items: center; gap: 4px;
color: var(--nc-text-dim); text-decoration: none;
transition: color 0.15s;
}
.crumb-link:hover { color: var(--nc-neon); }
.crumb-sep { opacity: 0.35; }
.crumb-current { color: var(--nc-text-muted); }
/* Header */
.page-header { margin-bottom: 28px; }
.page-title-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.page-actions { display: flex; align-items: center; gap: 8px; }
.btn-servers {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px;
border: 1px solid var(--nc-border-strong);
background: transparent; color: var(--nc-text-muted);
font-size: 13px; font-weight: 600; cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.btn-servers:hover { color: var(--nc-text); border-color: var(--nc-text-muted); }
.btn-new {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; border: none;
background: var(--nc-neon); color: #fff;
font-size: 13px; font-weight: 600; cursor: pointer;
transition: opacity 0.15s;
}
.btn-new:hover { opacity: 0.85; }
/* Create dialog */
.dialog-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.55); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
padding: 20px;
}
.dialog-card {
background: var(--nc-bg); border: 1px solid var(--nc-border-strong);
border-radius: 16px; padding: 24px; width: 100%; max-width: 420px;
}
.dialog-head {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 20px;
}
.dialog-brand { font-size: 14px; font-weight: 700; color: var(--nc-neon); }
.dialog-close {
background: none; border: none; cursor: pointer;
font-size: 20px; line-height: 1; color: var(--nc-text-muted);
padding: 0 4px;
}
.dialog-close:hover { color: var(--nc-text); }
.dialog-field { display: flex; flex-direction: column; gap: 6px; flex: 1; }
.dialog-label { font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--nc-text-muted); }
.dialog-input {
width: 100%; padding: 8px 10px; border-radius: 8px;
border: 1px solid var(--nc-border-strong);
background: rgba(255,255,255,0.04); color: var(--nc-text);
font-size: 14px; box-sizing: border-box;
}
.dialog-input:focus { outline: 2px solid var(--nc-neon); outline-offset: 1px; border-color: transparent; }
.dialog-row { display: flex; gap: 12px; margin-bottom: 16px; }
.dialog-field:not(.dialog-row .dialog-field) { margin-bottom: 16px; }
.dialog-toggle {
display: flex; align-items: center; gap: 10px; cursor: pointer;
margin-bottom: 20px; user-select: none;
}
.dialog-toggle input[type=checkbox] { display: none; }
.toggle-track {
width: 36px; height: 20px; border-radius: 10px;
background: var(--nc-border-strong); flex-shrink: 0;
transition: background 0.2s; position: relative;
}
.toggle-track::after {
content: ''; position: absolute; top: 3px; left: 3px;
width: 14px; height: 14px; border-radius: 50%;
background: var(--nc-text-muted); transition: transform 0.2s, background 0.2s;
}
.dialog-toggle input:checked ~ .toggle-track { background: var(--nc-neon); }
.dialog-toggle input:checked ~ .toggle-track::after { transform: translateX(16px); background: #fff; }
.toggle-label { font-size: 14px; color: var(--nc-text); }
.dialog-error {
font-size: 13px; color: var(--nc-danger);
background: rgba(255,122,122,0.1); border-radius: 8px;
padding: 10px 12px; margin-bottom: 16px;
}
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; }
.btn-ghost {
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--nc-border-strong);
background: transparent; color: var(--nc-text-muted); font-size: 13px; cursor: pointer;
}
.btn-ghost:hover { color: var(--nc-text); border-color: var(--nc-text-muted); }
.btn-primary {
padding: 8px 18px; border-radius: 8px; border: none;
background: var(--nc-neon); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
transition: opacity 0.15s;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
/* Tabs */
.tabs { display: flex; gap: 4px; }
.tab-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 8px; border: none;
background: transparent; color: var(--nc-text-muted);
font-size: 13px; font-weight: 500; cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tab-btn:hover { background: var(--nc-border); color: var(--nc-text); }
.tab-btn.active { background: var(--nc-surface); color: var(--nc-text); border: 1px solid var(--nc-border-strong); }
.tab-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 18px; height: 18px; padding: 0 5px;
border-radius: 9px; background: var(--nc-border-strong);
font-size: 10px; font-weight: 700; color: var(--nc-text-muted);
}
.live-badge { background: rgba(94, 229, 161, 0.2); color: var(--nc-success); }
/* States */
.state-msg {
display: flex; align-items: center; gap: 10px;
padding: 24px 0; color: var(--nc-text-muted); font-size: 13px;
}
.state-msg.small { padding: 12px 0; }
.pulse {
width: 8px; height: 8px; border-radius: 50%;
background: var(--nc-neon); flex-shrink: 0;
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.85); }
}
.empty-state {
display: flex; flex-direction: column; align-items: center;
gap: 8px; padding: 64px 0; text-align: center;
}
.empty-icon { color: var(--nc-text-dim); margin-bottom: 4px; }
.empty-title { font-size: 15px; font-weight: 600; margin: 0; }
.empty-sub { font-size: 13px; color: var(--nc-text-muted); margin: 0; }
/* Tournament list */
.t-list { display: flex; flex-direction: column; gap: 8px; }
.t-card {
border: 1px solid var(--nc-border);
border-radius: 12px;
background: var(--nc-surface);
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.t-card:hover, .t-card.expanded {
border-color: var(--nc-border-strong);
background: rgba(255, 255, 255, 0.04);
}
.t-card:focus-visible { outline: 2px solid var(--nc-neon); outline-offset: 2px; }
.t-action-btn {
padding: 5px 12px; border-radius: 7px; font-size: 12px; font-weight: 600;
cursor: pointer; border: none; transition: opacity 0.15s; white-space: nowrap;
}
.t-action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.t-btn-start { background: var(--nc-success); color: #0f0022; }
.t-btn-start:hover:not(:disabled) { opacity: 0.85; }
.t-btn-join { background: var(--nc-neon); color: #fff; }
.t-btn-join:hover:not(:disabled) { opacity: 0.85; }
/* Join dialog extras */
.join-hint { font-size: 13px; color: var(--nc-text-muted); margin: 0 0 16px; line-height: 1.5; }
.join-empty { font-size: 13px; color: var(--nc-text-muted); margin: 0 0 8px; }
.dialog-loading { display: flex; align-items: center; gap: 8px;
font-size: 13px; color: var(--nc-text-muted); padding: 12px 0; }
.bot-pick-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
.bot-pick-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: 8px;
border: 1px solid var(--nc-border); background: var(--nc-surface);
cursor: pointer; text-align: left; width: 100%;
transition: border-color 0.15s, background 0.15s;
}
.bot-pick-row:hover:not(:disabled) { border-color: var(--nc-neon); background: rgba(255,69,200,0.06); }
.bot-pick-row:disabled { opacity: 0.5; cursor: not-allowed; }
.bot-pick-avatar {
width: 30px; height: 30px; border-radius: 50%; background: var(--nc-neon);
color: #fff; display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 700; flex-shrink: 0;
}
.bot-pick-name { flex: 1; font-size: 14px; font-weight: 600; color: var(--nc-text); }
.bot-pick-rating { font-size: 12px; color: var(--nc-text-muted); }
.bot-pick-spinner { font-size: 13px; color: var(--nc-neon); }
.t-card-main {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 16px; gap: 12px;
}
.t-card-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
.t-card-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.t-status-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.dot-started { background: var(--nc-success); box-shadow: 0 0 6px var(--nc-success); }
.dot-created { background: var(--nc-warn); }
.dot-finished { background: var(--nc-text-dim); }
.t-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.t-name { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.t-meta { font-size: 11px; color: var(--nc-text-muted); }
.winner-badge {
font-size: 11px; font-weight: 600; color: var(--nc-warn);
padding: 3px 8px; border-radius: 6px;
background: rgba(255, 209, 102, 0.12);
}
.chevron { color: var(--nc-text-dim); transition: transform 0.2s; }
.chevron.open { transform: rotate(180deg); }
/* Detail panel */
.t-detail {
border-top: 1px solid var(--nc-border);
padding: 16px;
display: flex; flex-direction: column; gap: 20px;
}
.detail-section { display: flex; flex-direction: column; gap: 10px; }
.detail-heading {
font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--nc-text-muted); margin: 0;
}
.no-standings { font-size: 12px; color: var(--nc-text-dim); margin: 0; }
/* Standings table */
.standings-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.standings-table th {
text-align: left; padding: 6px 8px;
font-size: 10px; font-weight: 700; letter-spacing: 0.06em;
text-transform: uppercase; color: var(--nc-text-dim);
border-bottom: 1px solid var(--nc-border);
}
.standings-table td { padding: 8px 8px; border-bottom: 1px solid var(--nc-border); }
.standings-table tr:last-child td { border-bottom: none; }
.top-row td { color: var(--nc-text); }
.standings-table tr:not(.top-row) td { color: var(--nc-text-muted); }
.col-rank { width: 40px; font-size: 14px; }
.col-pts { width: 48px; font-weight: 700; color: var(--nc-neon) !important; }
.col-tb { width: 52px; color: var(--nc-text-dim) !important; font-size: 12px; }
.col-games { width: 64px; }
.wdl { font-size: 12px; font-variant-numeric: tabular-nums; }
.w { color: var(--nc-success); }
.d { color: var(--nc-text-muted); }
.l { color: var(--nc-danger); }
/* Pairings */
.pairings-list { display: flex; flex-direction: column; gap: 6px; }
.pairing-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: 8px;
background: rgba(255,255,255,0.025);
font-size: 13px; transition: background 0.15s;
}
.pairing-row.is-watchable { cursor: pointer; }
.pairing-row.is-watchable:hover { background: rgba(255,255,255,0.06); }
.pairing-white { font-weight: 600; flex: 1; }
.pairing-vs { color: var(--nc-text-dim); font-size: 11px; flex-shrink: 0; }
.pairing-black { flex: 1; }
.pairing-result { font-weight: 700; font-size: 12px; margin-left: auto; }
.result-white { color: var(--nc-success); }
.result-black { color: var(--nc-danger); }
.result-draw { color: var(--nc-text-muted); }
.pairing-ongoing {
display: inline-flex; align-items: center; gap: 5px;
margin-left: auto; font-size: 10px; font-weight: 700;
color: var(--nc-success); letter-spacing: 0.06em; text-transform: uppercase;
}
.pairing-ongoing svg { animation: pulse 1.4s ease-in-out infinite; }
/* Official bot join section */
.join-divider {
display: flex; align-items: center; gap: 10px;
margin: 20px 0 14px;
}
.join-divider::before, .join-divider::after {
content: ''; flex: 1; height: 1px; background: var(--nc-border);
}
.join-divider-label {
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--nc-text-dim); white-space: nowrap;
}
.official-bot-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
margin-bottom: 4px;
}
.official-bot-btn {
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 4px; border-radius: 8px; border: 1px solid var(--nc-border);
background: var(--nc-surface); font-size: 12px; font-weight: 700;
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
color: var(--nc-text-muted); text-transform: capitalize;
}
.official-bot-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.official-btn-easy:hover:not(:disabled) { border-color: var(--nc-success); color: var(--nc-success); background: rgba(94,229,161,0.07); }
.official-btn-medium:hover:not(:disabled) { border-color: var(--nc-warn); color: var(--nc-warn); background: rgba(255,209,102,0.07); }
.official-btn-hard:hover:not(:disabled) { border-color: var(--nc-neon); color: var(--nc-neon); background: rgba(255,69,200,0.07); }
.official-btn-expert:hover:not(:disabled) { border-color: var(--nc-danger); color: var(--nc-danger); background: rgba(255,122,122,0.07); }
/* Servers dialog */
.servers-dialog { max-width: 480px; }
.servers-list {
display: flex; flex-direction: column; gap: 6px;
margin-bottom: 20px;
}
.server-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: 8px;
border: 1px solid var(--nc-border); background: var(--nc-surface);
}
.server-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.server-label { font-size: 13px; font-weight: 600; color: var(--nc-text); }
.server-url {
font-size: 11px; color: var(--nc-text-dim);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
font-family: monospace;
}
.server-remove-btn {
background: none; border: none; cursor: pointer;
color: var(--nc-text-dim); padding: 4px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: color 0.15s, background 0.15s;
font-size: 13px;
}
.server-remove-btn:hover:not(:disabled) { color: var(--nc-danger); background: rgba(255,122,122,0.1); }
.server-remove-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.server-add-form {
border-top: 1px solid var(--nc-border);
padding-top: 16px;
display: flex; flex-direction: column; gap: 0;
}
.server-add-heading {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--nc-text-muted);
margin: 0 0 14px;
}
@@ -0,0 +1,343 @@
<div class="t-shell">
<div class="page">
<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">Tournaments</span>
</nav>
<header class="page-header">
<div class="page-title-row">
<h1 class="page-title">Tournaments</h1>
<div class="page-actions">
@if (currentUser) {
<button type="button" class="btn-servers" (click)="openServersDialog()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
Servers
</button>
<button type="button" class="btn-new" (click)="openCreateDialog()">
<svg width="13" height="13" 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>
New tournament
</button>
}
</div>
</div>
<div class="tabs" role="tablist">
<button type="button" class="tab-btn" [class.active]="tab === 'started'" (click)="setTab('started')">
Live
@if (started.length > 0) { <span class="tab-badge live-badge">{{ started.length }}</span> }
</button>
<button type="button" class="tab-btn" [class.active]="tab === 'created'" (click)="setTab('created')">
Upcoming
@if (created.length > 0) { <span class="tab-badge">{{ created.length }}</span> }
</button>
<button type="button" class="tab-btn" [class.active]="tab === 'finished'" (click)="setTab('finished')">
Finished
</button>
</div>
</header>
@if (loading) {
<div class="state-msg"><span class="pulse"></span>Loading tournaments…</div>
} @else if (activeList.length === 0) {
<div class="empty-state">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
</div>
<p class="empty-title">No tournaments here</p>
<p class="empty-sub">Check back later or look in another tab.</p>
</div>
} @else {
<div class="t-list">
@for (t of activeList; track t.id) {
<div class="t-card" [class.expanded]="selectedTournament?.id === t.id"
(click)="selectTournament(t)" role="button" tabindex="0"
(keydown.enter)="selectTournament(t)">
<div class="t-card-main">
<div class="t-card-left">
<span class="t-status-dot" [class]="'dot-' + t.status"></span>
<div class="t-info">
<span class="t-name">{{ t.fullName }}</span>
<span class="t-meta">
{{ clockDisplay(t) }} · {{ t.nbRounds }} rounds ·
@if (t.status === 'started') { Round {{ t.round }}/{{ t.nbRounds }} · }
{{ t.nbPlayers }} player{{ t.nbPlayers === 1 ? '' : 's' }}
@if (t.rated) { · Rated }
</span>
</div>
</div>
<div class="t-card-right">
@if (t.status === 'finished' && t.winner) {
<span class="winner-badge">🏆 {{ t.winner.name }}</span>
}
@if (currentUser && t.status === 'created') {
@if (t.createdBy === currentUser.id) {
<button type="button" class="t-action-btn t-btn-start"
[disabled]="startingId === t.id"
(click)="startTournament($event, t)">
{{ startingId === t.id ? '…' : 'Start' }}
</button>
}
<button type="button" class="t-action-btn t-btn-join"
(click)="openJoinDialog($event, t.id)">
Join with bot
</button>
}
<svg class="chevron" [class.open]="selectedTournament?.id === t.id"
width="14" 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>
</div>
</div>
@if (selectedTournament?.id === t.id) {
<div class="t-detail" (click)="$event.stopPropagation()">
<!-- Leaderboard -->
@if (t.standing.players.length > 0) {
<section class="detail-section">
<h3 class="detail-heading">Leaderboard</h3>
<table class="standings-table">
<thead>
<tr>
<th class="col-rank">#</th>
<th class="col-name">Bot</th>
<th class="col-pts">Pts</th>
<th class="col-tb">Bkh</th>
<th class="col-games">W/D/L</th>
</tr>
</thead>
<tbody>
@for (r of t.standing.players; track r.bot.id) {
<tr [class.top-row]="r.rank <= 3">
<td class="col-rank">{{ rankMedal(r.rank) }}</td>
<td class="col-name">{{ r.bot.name }}</td>
<td class="col-pts">{{ scoreDisplay(r) }}</td>
<td class="col-tb">{{ r.tieBreak }}</td>
<td class="col-games">
<span class="wdl">
<span class="w">{{ r.wins }}</span>/<span class="d">{{ r.draws }}</span>/<span class="l">{{ r.losses }}</span>
</span>
</td>
</tr>
}
</tbody>
</table>
</section>
} @else {
<p class="no-standings">No standings yet — waiting for games to complete.</p>
}
<!-- Current round pairings -->
@if (t.round > 0) {
<section class="detail-section">
<h3 class="detail-heading">Round {{ t.round }} pairings</h3>
@if (pairingsLoading) {
<div class="state-msg small"><span class="pulse"></span>Loading…</div>
} @else if (pairings && pairings.pairings.length > 0) {
<div class="pairings-list">
@for (p of pairings.pairings; track p.id) {
<div class="pairing-row" [class.is-watchable]="!!p.gameId"
(click)="p.gameId && watchGame(p.gameId)">
<span class="pairing-white">{{ p.white?.name ?? 'Bye' }}</span>
<span class="pairing-vs">vs</span>
<span class="pairing-black">{{ p.black.name }}</span>
@if (p.winner) {
<span class="pairing-result" [class]="'result-' + p.winner">
{{ p.winner === 'draw' ? '½–½' : p.winner === 'white' ? '10' : '01' }}
</span>
} @else if (p.gameId) {
<span class="pairing-ongoing">
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10"/>
</svg>
Watch
</span>
}
</div>
}
</div>
} @else {
<p class="no-standings">No pairings recorded yet.</p>
}
</section>
}
</div>
}
</div>
}
</div>
}
</div>
</div>
@if (joinDialogTournamentId) {
<div class="dialog-overlay" (click)="closeJoinDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-head">
<span class="dialog-brand">Join with a bot</span>
<button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button>
</div>
@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. <a routerLink="/bots">Create one</a> to join with your own bot.</p>
} @else {
<div class="bot-pick-list">
@for (bot of userBots; track bot.id) {
<button type="button" class="bot-pick-row"
[disabled]="!!joiningBotId || !!joiningOfficialDifficulty"
(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 class="join-divider">
<span class="join-divider-label">or join with an official bot</span>
</div>
@if (officialBots.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="official-bot-grid">
@for (d of officialDifficulties; track d) {
<button type="button" class="official-bot-btn"
[class]="'official-btn-' + d"
[disabled]="!!joiningOfficialDifficulty || !!joiningBotId"
(click)="joinWithOfficialBot(d)">
@if (joiningOfficialDifficulty === d) {
<span class="pulse"></span>
}
{{ d | titlecase }}
</button>
}
</div>
}
@if (officialJoinError) {
<div class="dialog-error">{{ officialJoinError }}</div>
}
}
</div>
</div>
}
@if (showServersDialog) {
<div class="dialog-overlay" (click)="closeServersDialog()">
<div class="dialog-card servers-dialog" (click)="$event.stopPropagation()">
<div class="dialog-head">
<span class="dialog-brand">Tournament servers</span>
<button type="button" class="dialog-close" (click)="closeServersDialog()">×</button>
</div>
<p class="join-hint">External tournament servers aggregated into this view. Tournaments from all servers appear in the list.</p>
@if (serversLoading) {
<div class="dialog-loading"><span class="pulse"></span>Loading servers…</div>
} @else if (servers.length === 0) {
<p class="join-empty">No external servers registered.</p>
} @else {
<div class="servers-list">
@for (s of servers; track s.id) {
<div class="server-row">
<div class="server-info">
<span class="server-label">{{ s.label }}</span>
<span class="server-url">{{ s.url }}</span>
</div>
</div>
}
</div>
}
<div class="dialog-actions">
<button type="button" class="btn-ghost" (click)="closeServersDialog()">Close</button>
</div>
</div>
</div>
}
@if (showCreateDialog) {
<div class="dialog-overlay" (click)="closeCreateDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()">
<div class="dialog-head">
<span class="dialog-brand">New tournament</span>
<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 class="dialog-row">
<div class="dialog-field">
<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>
<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>
}
@@ -0,0 +1,285 @@
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { CommonModule, TitleCasePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { forkJoin } from 'rxjs';
import { TournamentService } from '../../services/tournament.service';
import { AuthService } from '../../services/auth.service';
import { BotService } from '../../services/bot.service';
import { OfficialBotService } from '../../services/official-bot.service';
import { TournamentServerService, ExternalTournamentServer } from '../../services/tournament-server.service';
import { Bot } from '../../models/bot.models';
import { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models';
import { CurrentUser } from '../../models/auth.models';
type StatusTab = 'started' | 'created' | 'finished';
@Component({
selector: 'app-tournaments',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule, ReactiveFormsModule, TitleCasePipe],
templateUrl: './tournaments.component.html',
styleUrl: './tournaments.component.css'
})
export class TournamentsComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly tournamentService = inject(TournamentService);
private readonly authService = inject(AuthService);
private readonly fb = inject(FormBuilder);
private readonly botService = inject(BotService);
private readonly officialBotService = inject(OfficialBotService);
private readonly tournamentServerService = inject(TournamentServerService);
private readonly router = inject(Router);
loading = true;
tab: StatusTab = 'started';
currentUser: CurrentUser | null = null;
started: Tournament[] = [];
created: Tournament[] = [];
finished: Tournament[] = [];
selectedTournament: Tournament | null = null;
pairings: RoundPairings | null = null;
pairingsLoading = false;
showCreateDialog = false;
createForm: FormGroup = this.fb.group({
name: ['', [Validators.required, Validators.minLength(3)]],
nbRounds: [4, [Validators.required, Validators.min(1), Validators.max(20)]],
clockLimitMinutes: [3, [Validators.required, Validators.min(1), Validators.max(60)]],
clockIncrement: [0, [Validators.required, Validators.min(0), Validators.max(60)]],
rated: [false]
});
createLoading = false;
createError: string | null = null;
startingId: string | null = null;
joinDialogTournamentId: string | null = null;
userBots: Bot[] = [];
officialBots: Bot[] = [];
botsLoading = false;
joiningBotId: string | null = null;
joinError: string | null = null;
readonly officialDifficulties = ['easy', 'medium', 'hard', 'expert'] as const;
joiningOfficialDifficulty: string | null = null;
officialJoinError: string | null = null;
showServersDialog = false;
servers: ExternalTournamentServer[] = [];
serversLoading = false;
ngOnInit(): void {
this.authService.currentUser$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(u => { this.currentUser = u; });
this.loadTournaments();
}
openCreateDialog(): void {
this.createForm.reset({ name: '', nbRounds: 4, clockLimitMinutes: 3, clockIncrement: 0, rated: false });
this.createError = null;
this.showCreateDialog = true;
}
closeCreateDialog(): void {
this.showCreateDialog = false;
}
submitCreate(): void {
if (this.createForm.invalid) return;
this.createLoading = true;
this.createError = null;
this.tournamentService.create(this.createForm.value).subscribe({
next: t => {
this.createLoading = false;
this.showCreateDialog = false;
this.created = [t, ...this.created];
this.tab = 'created';
this.selectedTournament = null;
},
error: err => {
this.createLoading = false;
this.createError = err.error?.message ?? err.error?.error ?? 'Failed to create tournament.';
}
});
}
setTab(tab: StatusTab): void {
this.tab = tab;
this.selectedTournament = null;
this.pairings = null;
}
selectTournament(t: Tournament): void {
if (this.selectedTournament?.id === t.id) {
this.selectedTournament = null;
this.pairings = null;
return;
}
this.selectedTournament = t;
this.pairings = null;
if (t.round > 0) {
this.loadPairings(t.id, t.round);
}
}
get activeList(): Tournament[] {
return this[this.tab];
}
clockDisplay(t: Tournament): string {
const min = Math.floor(t.clock.limit / 60);
return `${min}+${t.clock.increment}`;
}
rankMedal(rank: number): string {
if (rank === 1) return '🥇';
if (rank === 2) return '🥈';
if (rank === 3) return '🥉';
return `${rank}.`;
}
scoreDisplay(r: TournamentResult): string {
return r.points % 1 === 0 ? `${r.points}` : `${r.points}`;
}
startTournament(event: MouseEvent, t: Tournament): void {
event.stopPropagation();
this.startingId = t.id;
this.tournamentService.start(t.id).subscribe({
next: updated => {
this.startingId = null;
const list = this.created.map(x => x.id === t.id ? updated : x);
this.created = list.filter(x => x.status === 'created');
if (!this.started.find(x => x.id === updated.id)) this.started = [updated, ...this.started];
this.selectedTournament = updated;
this.tab = 'started';
},
error: () => { this.startingId = null; }
});
}
watchGame(gameId: string): void {
void this.router.navigate(['/game', gameId]);
}
openJoinDialog(event: MouseEvent, tournamentId: string): void {
event.stopPropagation();
this.joinDialogTournamentId = tournamentId;
this.joinError = null;
this.botsLoading = true;
forkJoin({ user: this.botService.list(), official: this.botService.listOfficial() })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: ({ user, official }) => {
this.userBots = user;
this.officialBots = official;
this.botsLoading = false;
},
error: () => { this.botsLoading = false; }
});
}
closeJoinDialog(): void {
this.joinDialogTournamentId = null;
this.userBots = [];
this.officialBots = [];
this.joiningBotId = null;
this.joinError = null;
this.joiningOfficialDifficulty = null;
this.officialJoinError = null;
}
joinWithOfficialBot(difficulty: string): void {
if (!this.joinDialogTournamentId || this.joiningOfficialDifficulty || this.joiningBotId) return;
this.joiningOfficialDifficulty = difficulty;
this.officialJoinError = null;
const tid = this.joinDialogTournamentId;
this.officialBotService.joinTournament(tid, difficulty).subscribe({
next: () => {
this.joiningOfficialDifficulty = null;
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.joiningOfficialDifficulty = null;
this.officialJoinError = err.error?.error ?? 'Failed to join with official bot.';
}
});
}
joinWithBot(bot: Bot): void {
if (!this.joinDialogTournamentId || this.joiningBotId) return;
this.joiningBotId = bot.id;
this.joinError = null;
this.tournamentService.join(this.joinDialogTournamentId, bot.id, bot.name).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.';
}
});
}
openServersDialog(): void {
this.showServersDialog = true;
this.serversLoading = true;
this.tournamentServerService.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: res => { this.servers = res.servers; this.serversLoading = false; },
error: () => { this.serversLoading = false; }
});
}
closeServersDialog(): void {
this.showServersDialog = false;
}
private loadTournaments(): void {
this.tournamentService.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: list => {
this.started = list.started;
this.created = list.created;
this.finished = list.finished;
this.loading = false;
if (this.started.length === 0 && this.created.length > 0) this.tab = 'created';
else if (this.started.length === 0 && this.finished.length > 0) this.tab = 'finished';
},
error: () => { this.loading = false; }
});
}
private loadPairings(id: string, round: number): void {
this.pairingsLoading = true;
this.tournamentService.roundPairings(id, round)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: p => { this.pairings = p; this.pairingsLoading = false; },
error: () => { this.pairingsLoading = false; }
});
}
}
+89
View File
@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NowChess — Auth Dialog</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<style>
:root {
--sans: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
--neon: #ff45c8;
--neon-soft: rgba(255, 69, 200, 0.55);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: #06060d;
font-family: var(--sans);
color: #fff;
overflow: hidden;
min-height: 100vh;
}
button, input { font-family: var(--sans); }
input { color: #fff; }
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-text-fill-color: #fff;
-webkit-box-shadow: 0 0 0px 1000px rgba(8,5,20,0) inset;
transition: background-color 5000s ease-in-out 0s;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-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 {
0% { transform: translateY(-100%); }
100% { transform: translateY(100%); }
}
@keyframes pulse-glow {
0%, 100% { opacity: 0.85; }
50% { opacity: 1; }
}
@keyframes dialog-in {
from { opacity: 0; transform: translate(-50%, -48%) scale(0.96); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-4px); }
40%, 80% { transform: translateX(4px); }
}
</style>
</head>
<body>
<div id="root"></div>
<script
src="https://unpkg.com/react@18.3.1/umd/react.development.js"
integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L"
crossorigin="anonymous"
></script>
<script
src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"
integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm"
crossorigin="anonymous"
></script>
<script
src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"
integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"
crossorigin="anonymous"
></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="auth-dialog.jsx"></script>
</body>
</html>
-234
View File
@@ -652,240 +652,6 @@
font-weight: 600;
}
/* Speech Bubble Styles */
.speech-bubble-container {
position: fixed;
top: 35%;
left: 55%;
transform: translate(-50%, -50%);
z-index: 500;
cursor: pointer;
animation: slideInBubble 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes slideInBubble {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.5);
}
100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.speech-bubble {
background: linear-gradient(135deg, #B9DAD1 0%, #B9C2DA 100%);
border: 2px solid #8b1270;
border-radius: 20px;
padding: 16px 24px;
font-family: 'Comic Sans MS', 'Comic Sans', cursive;
font-size: 18px;
font-weight: bold;
color: #5A2C28;
white-space: nowrap;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2),
inset 0 1px 3px rgba(255, 255, 255, 0.3);
position: relative;
transition: all 0.3s ease;
}
.speech-bubble:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3),
inset 0 1px 3px rgba(255, 255, 255, 0.5);
}
.bubble-text {
margin: 0;
}
.bubble-tail {
position: absolute;
bottom: -12px;
left: 20px;
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 0px solid transparent;
border-top: 12px solid #B9DAD1;
}
/* Zoom Overlay and Window */
.zoom-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease;
cursor: pointer;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.zoom-window-wrapper {
cursor: auto;
animation: zoomInWindow 1.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes zoomInWindow {
0% {
transform: scale(0.1);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.zoom-window-frame {
background: #13072a;
border: 8px solid #f26ae2;
border-radius: 16px;
padding: 40px 20px 20px 20px;
box-shadow: 0 0 40px rgba(242, 106, 226, 0.6),
inset 0 0 20px rgba(242, 106, 226, 0.2);
max-width: 90vw;
max-height: 90vh;
position: relative;
}
.zoom-player-2 {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.player-2-gif {
max-width: 100%;
max-height: 70vh;
width: auto;
height: auto;
display: block;
border-radius: 12px;
cursor: pointer;
transition: transform 0.2s ease;
}
.player-2-gif:hover {
transform: scale(1.02);
}
.second-speech-bubble {
position: absolute;
top: -60px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #C19EF5 0%, #E1EAA9 100%);
border: 2px solid #BA6D4B;
border-radius: 20px;
padding: 12px 18px;
font-family: 'Comic Sans MS', 'Comic Sans', cursive;
font-size: 16px;
font-weight: bold;
color: #5A2C28;
white-space: nowrap;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2),
inset 0 1px 3px rgba(255, 255, 255, 0.3);
animation: popInBubble 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
z-index: 10;
}
@keyframes popInBubble {
0% {
opacity: 0;
transform: translateX(-50%) scale(0.3);
}
100% {
opacity: 1;
transform: translateX(-50%) scale(1);
}
}
.second-speech-bubble .bubble-tail {
top: 100%;
bottom: auto;
left: 50%;
transform: translateX(-50%);
border-top: 12px solid #C19EF5;
}
/* Happy Meow Bubble */
.happy-speech-bubble {
position: absolute;
top: -60px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #F3C8A0 0%, #BA6D4B 100%);
border: 2px solid #5A2C28;
border-radius: 20px;
padding: 12px 18px;
font-family: 'Comic Sans MS', 'Comic Sans', cursive;
font-size: 16px;
font-weight: bold;
color: #fff;
white-space: nowrap;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3),
inset 0 1px 3px rgba(255, 255, 255, 0.4),
0 0 20px rgba(243, 200, 160, 0.5);
animation: popInBubble 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
z-index: 10;
}
.happy-speech-bubble .bubble-tail {
top: 100%;
bottom: auto;
left: 50%;
transform: translateX(-50%);
border-top: 12px solid #F3C8A0;
}
/* Meat Emoji */
.meat-emoji {
position: fixed;
font-size: 48px;
cursor: grab;
user-select: none;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
animation: meatAppear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
transition: transform 0.1s ease;
}
.meat-emoji:active {
cursor: grabbing;
transform: scale(1.1);
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
}
@keyframes meatAppear {
0% {
opacity: 0;
transform: scale(0);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 900px) {
.bwrap {
transform: scale(0.9);
+8 -51
View File
@@ -46,13 +46,6 @@
<div class="w" *ngFor="let win of windows['wA3']" [ngStyle]="win.style"></div>
</div>
</div>
<!-- Draggable Meat Emoji -->
@if (showMeatEmoji) {
<div class="meat-emoji" [style.left.px]="meatX" [style.top.px]="meatY" (mousedown)="onMeatMouseDown($event)">
🍖
</div>
}
</div>
<div class="bwrap" style="left:21%;width:15%;">
@@ -145,50 +138,6 @@
</div>
</div>
<!-- Speech Bubble -->
@if (showSpeechBubble) {
<div class="speech-bubble-container" (click)="onSpeechBubbleClick()">
<div class="speech-bubble">
<div class="bubble-text">{{ bubbleMessage }}</div>
<div class="bubble-tail"></div>
</div>
</div>
}
<!-- Zoomed Window View -->
@if (isZoomedIn) {
<div class="zoom-overlay" (click)="onZoomedViewClick()" (mousemove)="onMouseMove($event)" (mouseup)="onMouseUp()"
(mouseleave)="onMouseUp()">
<div class="zoom-window-wrapper" (click)="$event.stopPropagation()">
<div class="zoom-window-frame">
<div class="zoom-player-2">
<img src="/assets/arabian-chess/player-two.gif" alt="Player 2" class="player-2-gif"
(click)="$event.stopPropagation()" />
@if (showSecondSpeechBubble) {
<div class="second-speech-bubble">
<div class="bubble-text">Feed me! 🍖</div>
<div class="bubble-tail"></div>
</div>
}
@if (showHappyBubble) {
<div class="happy-speech-bubble">
<div class="bubble-text">Happy meow! 😸</div>
<div class="bubble-tail"></div>
</div>
}
</div>
</div>
<!-- Draggable Meat Emoji -->
@if (showMeatEmoji) {
<div class="meat-emoji" [style.left.px]="meatX" [style.top.px]="meatY" (mousedown)="onMeatMouseDown($event)">
🍖
</div>
}
</div>
</div>
}
<div class="haze"></div>
<div class="ground"></div>
</div>
@@ -263,6 +212,14 @@
</div>
}
@if (showChallengeDialog) {
<div class="dialog-overlay" (click)="closeChallengeDialog()">
<div class="dialog-card" (click)="$event.stopPropagation()">
<app-challenge-create-dialog (closeChallengeDialog)="closeChallengeDialog()"></app-challenge-create-dialog>
</div>
</div>
}
@if (errorMessage) {
<p class="error-banner">{{ errorMessage }}</p>
}
+13 -130
View File
@@ -67,6 +67,7 @@ export class WelcomeComponent implements OnInit, OnDestroy {
showOptionsDialog = false;
showJoinDialog = false;
showImportDialog = false;
showChallengeDialog = false;
gameIdInput = '';
importMode: ImportMode = 'fen';
@@ -78,28 +79,11 @@ export class WelcomeComponent implements OnInit, OnDestroy {
private authDialogState: 'login' | 'register' | null = null;
private pendingAction: (() => void) | null = null;
// Speech bubble and zoom features
showSpeechBubble = false;
isZoomedIn = false;
showSecondSpeechBubble = false;
showHappyBubble = false;
showMeatEmoji = false;
bubbleMessage = 'meow';
// Meat emoji drag state
meatX = 0;
meatY = 0;
isDraggingMeat = false;
meatDragOffsetX = 0;
meatDragOffsetY = 0;
stars: Star[] = [];
bgBuildings: BackgroundBuilding[] = [];
windows: Record<string, WindowCell[]> = {};
private flickerIntervalId: ReturnType<typeof setInterval> | undefined;
private speechBubbleTimeoutId: ReturnType<typeof setTimeout> | undefined;
private zoomTimeoutId: ReturnType<typeof setTimeout> | undefined;
private coolColors = ['#7de8ff', '#00d5ff', '#5bc0de', '#31b0d5', '#4fc3f7', '#29b6f6'];
private coolGlowColors = ['#00d5ff', '#00d5ff', '#31b0d5', '#31b0d5', '#03a9f4', '#0288d1'];
@@ -139,21 +123,10 @@ export class WelcomeComponent implements OnInit, OnDestroy {
this.generateBackgroundBuildings();
this.generateWindowsForAllBuildings();
this.startWindowFlicker();
// Show speech bubble after 5 seconds
this.speechBubbleTimeoutId = setTimeout(() => {
this.showSpeechBubble = true;
}, 5000);
}
ngOnDestroy(): void {
this.stopWindowFlicker();
if (this.speechBubbleTimeoutId) {
clearTimeout(this.speechBubbleTimeoutId);
}
if (this.zoomTimeoutId) {
clearTimeout(this.zoomTimeoutId);
}
}
openDifficultyDialog(): void {
@@ -223,11 +196,21 @@ export class WelcomeComponent implements OnInit, OnDestroy {
}
startOneVsOne(): void {
if (!this.requireAuth(() => this.performStartOneVsOne())) {
if (!this.requireAuth(() => this.openChallengeDialog())) {
return;
}
this.performStartOneVsOne();
this.openChallengeDialog();
}
openChallengeDialog(): void {
this.closeAllDialogs();
this.showChallengeDialog = true;
}
closeChallengeDialog(): void {
this.showChallengeDialog = false;
this.errorMessage = '';
}
startVsBot(difficulty: Difficulty): void {
@@ -254,84 +237,6 @@ export class WelcomeComponent implements OnInit, OnDestroy {
this.performSubmitImportGame();
}
onSpeechBubbleClick(): void {
this.showSpeechBubble = false;
this.isZoomedIn = true;
this.bubbleMessage = 'meow';
this.showMeatEmoji = true;
this.showHappyBubble = false;
this.showSecondSpeechBubble = true;
// Reset meat position
this.meatX = window.innerWidth / 2 - 100;
this.meatY = window.innerHeight / 2 + 150;
}
onZoomedViewClick(): void {
this.isZoomedIn = false;
this.showSecondSpeechBubble = false;
this.showHappyBubble = false;
this.showMeatEmoji = false;
this.bubbleMessage = 'meow';
if (this.zoomTimeoutId) {
clearTimeout(this.zoomTimeoutId);
}
}
onMeatMouseDown(event: MouseEvent): void {
this.isDraggingMeat = true;
const rect = (event.target as HTMLElement).getBoundingClientRect();
this.meatDragOffsetX = event.clientX - rect.left;
this.meatDragOffsetY = event.clientY - rect.top;
}
onMouseMove(event: MouseEvent): void {
if (!this.isDraggingMeat) {
return;
}
this.meatX = event.clientX - this.meatDragOffsetX;
this.meatY = event.clientY - this.meatDragOffsetY;
const gifElement = document.querySelector('.player-2-gif') as HTMLElement;
if (!gifElement) {
return;
}
const gifRect = gifElement.getBoundingClientRect();
const gifCenterX = gifRect.left + gifRect.width / 2;
const gifCenterY = gifRect.top + gifRect.height / 2;
const meatElement = document.querySelector('.meat-emoji') as HTMLElement;
if (!meatElement) {
return;
}
const meatRect = meatElement.getBoundingClientRect();
const meatCenterX = meatRect.left + meatRect.width / 2;
const meatCenterY = meatRect.top + meatRect.height / 2;
const distance = Math.sqrt(
Math.pow(meatCenterX - gifCenterX, 2) + Math.pow(meatCenterY - gifCenterY, 2)
);
if (distance < 50) {
this.onMeatFed();
}
}
onMouseUp(): void {
this.isDraggingMeat = false;
}
onMeatFed(): void {
this.showMeatEmoji = false;
this.showSecondSpeechBubble = false;
this.showHappyBubble = true;
this.isDraggingMeat = false;
}
private requireAuth(action: () => void): boolean {
if (this.authService.isLoggedIn()) {
return true;
@@ -352,28 +257,6 @@ export class WelcomeComponent implements OnInit, OnDestroy {
action();
}
private performStartOneVsOne(): void {
if (this.creating) {
return;
}
this.errorMessage = '';
this.creating = true;
this.gameApi
.createGame()
.pipe(finalize(() => (this.creating = false)))
.subscribe({
next: (game) => {
void this.router.navigate(['/game', game.gameId], {
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
});
},
error: (error) => {
this.errorMessage = getErrorMessage(error, 'Unable to create a game.');
}
});
}
private performStartVsBot(difficulty: Difficulty): void {
if (this.creating) {
+102
View File
@@ -0,0 +1,102 @@
import { Injectable, inject } from '@angular/core';
import { Observable, forkJoin, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { GameApiService } from './game-api.service';
import { AnnotatedMove, AnalysisResponse, MoveQuality } from '../models/analysis.models';
const DEFAULT_DEPTH = 14;
@Injectable({ providedIn: 'root' })
export class AnalysisService {
private readonly gameApi = inject(GameApiService);
analyzePosition(fen: string, depth = DEFAULT_DEPTH): Observable<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';
}
}
+5 -3
View File
@@ -3,14 +3,16 @@ import { HttpInterceptorFn } from '@angular/common/http';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = localStorage.getItem('token');
// Add token to protected endpoints only (not registration or login)
const isProtectedEndpoint =
req.url.includes('/api/account/me') ||
req.url.includes('/api/account/bots') ||
req.url.includes('/api/account/official-bots') ||
req.url.includes('/api/challenge');
req.url.includes('/api/board/game') ||
req.url.includes('/api/challenge') ||
req.url.includes('/api/tournament') ||
req.url.includes('/api/bots');
if (token && isProtectedEndpoint) {
if (token && isProtectedEndpoint && !req.headers.has('Authorization')) {
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
+7 -13
View File
@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import { map, switchMap, tap } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import { LoginRequest, RegisterRequest, RegisterResponse, LoginResponse, CurrentUser } from '../models/auth.models';
@@ -26,9 +26,9 @@ export class AuthService {
})
.pipe(
tap((response) => {
localStorage.setItem('token', response.token);
localStorage.setItem('token', response.accessToken); //GRRRRRRRRRR
localStorage.setItem('refreshToken', response.refreshToken);
localStorage.setItem('username', username);
// After login, fetch current user info
this.getCurrentUser().subscribe();
})
);
@@ -42,16 +42,9 @@ export class AuthService {
email
})
.pipe(
tap((response) => {
localStorage.setItem('username', response.username);
localStorage.setItem('userId', response.id);
this.currentUserSubject.next({
id: response.id,
username: response.username,
rating: response.rating,
createdAt: response.createdAt
});
})
switchMap((response) =>
this.login(username, password).pipe(map(() => response))
)
);
}
@@ -67,6 +60,7 @@ export class AuthService {
logout(): void {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('username');
localStorage.removeItem('userId');
this.currentUserSubject.next(null);
+32
View File
@@ -0,0 +1,32 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { Bot, BotWithToken } from '../models/bot.models';
@Injectable({ providedIn: 'root' })
export class BotService {
private readonly http = inject(HttpClient);
private readonly base = '/api/account/bots';
private readonly officialBase = '/api/account/official-bots';
list(): Observable<Bot[]> {
return this.http.get<Bot[]>(this.base);
}
listOfficial(): Observable<Bot[]> {
return this.http.get<Bot[]>(this.officialBase);
}
create(name: string): Observable<BotWithToken> {
return this.http.post<BotWithToken>(this.base, { name });
}
rotateToken(botId: string): Observable<string> {
return this.http.post<{ token: string }>(`${this.base}/${botId}/rotate-token`, null)
.pipe(map(r => r.token));
}
delete(botId: string): Observable<void> {
return this.http.delete<void>(`${this.base}/${botId}`);
}
}
@@ -61,4 +61,18 @@ export class ChallengeEventService {
removeChallenge(challengeId: string): void {
this.onChallengeRemoved(challengeId);
}
/**
* Replace the full incoming list (used by HTTP polling)
*/
setIncomingChallenges(challenges: Challenge[]): void {
this.incomingChallenges$.next(challenges);
}
/**
* Clear all incoming challenges (used on logout)
*/
clear(): void {
this.incomingChallenges$.next([]);
}
}
+101 -116
View File
@@ -1,135 +1,120 @@
import { Injectable, inject } from '@angular/core';
import { Subject } from 'rxjs';
import { Router } from '@angular/router';
import { environment } from '../../environments/environment';
import { ChallengeEventService } from './challenge-event.service';
import { Challenge } from '../models/challenge.models';
import { ChallengeService } from './challenge.service';
/**
* Service to handle WebSocket connections for challenge events
* Listens for incoming challenge notifications and emits them to ChallengeEventService
*/
@Injectable({ providedIn: 'root' })
export class ChallengeWebSocketService {
private readonly challengeEventService = inject(ChallengeEventService);
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 3000;
private readonly challengeEventService = inject(ChallengeEventService);
private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router);
/**
* Initialize WebSocket connection for challenge events
*/
connect(): void {
if (this.ws) {
return; // Already connected
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 3000;
private intentionalClose = false;
connect(): void {
if (this.ws) return;
const token = localStorage.getItem('token');
if (!token) return;
const url = `${environment.userWsBaseUrl}/api/user/ws`;
try {
this.intentionalClose = false;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.ws?.send(JSON.stringify({ type: 'auth', token }));
};
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();
}
}
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `${wsProtocol}://${window.location.host}/ws/challenges`;
disconnect(): void {
this.intentionalClose = true;
this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('Challenge WebSocket connected');
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
this.ws.onerror = (error) => {
console.error('Challenge WebSocket error:', error);
};
this.ws.onclose = () => {
console.log('Challenge WebSocket disconnected');
this.ws = null;
this.attemptReconnect();
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
this.attemptReconnect();
}
private handleMessage(data: string): void {
let message: Record<string, unknown>;
try {
message = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
}
/**
* Close the WebSocket connection
*/
disconnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
switch (message['type']) {
case 'CONNECTED':
break;
case 'challengeCreated': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({
next: (challenge) => this.challengeEventService.onChallengeReceived(challenge),
error: () => {
/* challenge may have already expired */
},
});
}
}
break;
}
/**
* Send a message through WebSocket
*/
send(message: any): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
case 'challengeAccepted': {
const challengeId = message['challengeId'] as string | undefined;
const gameId = message['gameId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
}
/**
* Handle incoming WebSocket messages
*/
private handleMessage(data: string): void {
try {
const message = JSON.parse(data);
if (!message.type) {
return;
}
switch (message.type) {
case 'challenge.received':
if (message.challenge) {
this.challengeEventService.onChallengeReceived(message.challenge as Challenge);
}
break;
case 'challenge.accepted':
if (message.challenge) {
this.challengeEventService.onChallengeAccepted(message.challenge as Challenge);
}
break;
case 'challenge.declined':
if (message.challengeId) {
this.challengeEventService.removeChallenge(message.challengeId);
}
break;
case 'challenge.expired':
if (message.challengeId) {
this.challengeEventService.removeChallenge(message.challengeId);
}
break;
default:
console.debug('Unknown challenge message type:', message.type);
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
if (gameId) {
void this.router.navigate(['/game', gameId]);
}
}
break;
}
/**
* Attempt to reconnect to WebSocket
*/
private attemptReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max WebSocket reconnection attempts reached');
return;
case 'challengeDeclined':
case 'challengeExpired':
case 'challengeCancelled': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
this.reconnectAttempts++;
console.log(`Attempting WebSocket reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
break;
}
}
}
private attemptReconnect(): void {
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++;
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
}
}
+44 -8
View File
@@ -1,14 +1,16 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import {
GameFull,
GameState,
GameStreamEvent,
LegalMovesResponse,
PlayerInfo
PlayerInfo,
} from '../models/game.models';
import { AnalysisRequest, AnalysisResponse, RawAnalysisResponse } from '../models/analysis.models';
import { StreamHandlerService } from './stream-handler.service';
@Injectable({ providedIn: 'root' })
@@ -28,11 +30,11 @@ export class GameApiService {
const playerColor = Math.random() > 0.5 ? 'white' : 'black';
const playerInfo: PlayerInfo = {
id: `player-${Date.now()}`,
displayName: 'You'
displayName: 'You',
};
const botInfo: PlayerInfo = {
id: `bot-${difficulty}`,
displayName: `Bot (${difficulty})`
displayName: `Bot (${difficulty})`,
};
const payload =
@@ -40,7 +42,7 @@ export class GameApiService {
? { white: playerInfo, black: botInfo }
: { white: botInfo, black: playerInfo };
return this.http.post<GameFull>(`${this.apiBase}${this.apiPath}`, payload);
return this.http.post<GameFull>(`${this.apiBase}${this.apiPath}/vs-bot`, payload);
}
getGame(gameId: string): Observable<GameFull> {
@@ -56,7 +58,9 @@ export class GameApiService {
if (square) {
params = params.set('square', square);
}
return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { params });
return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, {
params,
});
}
importFen(fen: string): Observable<GameFull> {
@@ -67,6 +71,38 @@ export class GameApiService {
return this.http.post<GameFull>(`${this.apiBase}${this.apiPath}/import/pgn`, { pgn });
}
resignGame(gameId: string): Observable<void> {
return this.http.post<void>(`${this.apiBase}${this.apiPath}/${gameId}/resign`, {});
}
offerDraw(gameId: string): Observable<void> {
return this.http.post<void>(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {});
}
getFenHistory(gameId: string): Observable<string[]> {
return this.http
.get<{ fens: string[] }>(`${this.apiBase}${this.apiPath}/${gameId}/fen-history`)
.pipe(map((r) => r.fens));
}
analyzePosition(request: AnalysisRequest): Observable<AnalysisResponse> {
return this.http
.post<RawAnalysisResponse>(`${this.apiBase}/api/analysis/position`, request)
.pipe(map((raw) => this.mapAnalysisResponse(raw)));
}
private mapAnalysisResponse(raw: RawAnalysisResponse): AnalysisResponse {
const evalPawns = raw.evaluation / 100;
return {
eval: evalPawns,
winChance: 1 / (1 + Math.exp(-0.374 * evalPawns)),
depth: raw.depth,
bestMove: raw.bestMove,
mate: raw.mate,
continuations: raw.continuationMoves ?? [],
};
}
private resolveWsBase(): string {
if (this.wsBase) {
return this.wsBase;
@@ -77,8 +113,8 @@ export class GameApiService {
}
streamGame(gameId: string): Observable<GameStreamEvent> {
const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/stream`;
const fallbackUrl = `${this.apiBase}${this.apiPath}/${gameId}/stream`;
return this.streamHandler.createGameStream(wsUrl, fallbackUrl, gameId);
const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
const token = localStorage.getItem('token') ?? '';
return this.streamHandler.createGameStream(wsUrl, gameId, token);
}
}
+14 -9
View File
@@ -24,23 +24,28 @@ export class GameCompletionService {
return { isFinished: true, message };
}
isTimeOut(state: GameState | null): boolean {
if (!state?.clock) return false;
return state.clock.whiteRemainingMs <= 0 || state.clock.blackRemainingMs <= 0;
}
private buildCompletionMessage(status: GameStatus, state: GameState, game: GameFull): string {
const winner = state.winner === 'white' ? game.white.displayName : state.winner === 'black' ? game.black.displayName : null;
const loser = state.winner === 'white' ? game.black.displayName : state.winner === 'black' ? game.white.displayName : null;
switch (status) {
case 'checkmate':
const winner = state.winner === 'white' ? game.white.displayName : game.black.displayName;
return `Checkmate! ${winner} wins!`;
return winner ? `Checkmate — ${winner} wins!` : 'Checkmate!';
case 'stalemate':
return 'Stalemate! The game is a draw.';
return 'Stalemate draw!';
case 'resign':
const resignedPlayer = state.winner === 'white' ? game.black.displayName : game.white.displayName;
const resignedWinner = state.winner === 'white' ? game.white.displayName : game.black.displayName;
return `${resignedPlayer} resigned. ${resignedWinner} wins!`;
return loser && winner ? `${loser} resigned — ${winner} wins!` : 'Resigned.';
case 'draw':
return 'Draw! The game ended in a draw.';
return 'Draw by agreement.';
case 'insufficientMaterial':
return 'Insufficient material! The game is a draw.';
return 'Draw — insufficient material.';
default:
return 'Game ended!';
return 'Game over.';
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
const STORAGE_KEY = 'nowchess.games';
const MAX_ENTRIES = 50;
interface GameEntry {
id: string;
addedAt: number;
}
@Injectable({ providedIn: 'root' })
export class GameHistoryService {
recordGame(gameId: string): void {
const entries = this.load().filter((e) => e.id !== gameId);
entries.unshift({ id: gameId, addedAt: Date.now() });
this.save(entries.slice(0, MAX_ENTRIES));
}
getGameIds(): string[] {
return this.load().map((e) => e.id);
}
removeGame(gameId: string): void {
this.save(this.load().filter((e) => e.id !== gameId));
}
private load(): GameEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as GameEntry[]) : [];
} catch {
return [];
}
}
private save(entries: GameEntry[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
}
}
+17 -7
View File
@@ -10,6 +10,7 @@ export class GameStreamService {
private readonly destroyRef = inject(DestroyRef);
private streamSubscription: Subscription | null = null;
private pollSubscription: Subscription | null = null;
private lastGameStateHash: string | null = null;
startStreaming(
gameId: string,
@@ -20,7 +21,10 @@ export class GameStreamService {
.streamGame(gameId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (event) => onEvent(event),
next: (event) => {
this.lastGameStateHash = JSON.stringify(event);
onEvent(event);
},
error: () => {
onStreamError();
this.startPolling(gameId, onEvent);
@@ -37,7 +41,7 @@ export class GameStreamService {
return;
}
this.pollSubscription = interval(1500)
this.pollSubscription = interval(5000)
.pipe(
startWith(0),
switchMap(() => this.gameApi.getGame(gameId)),
@@ -45,11 +49,16 @@ export class GameStreamService {
)
.subscribe({
next: (game) => {
const event: GameStreamEvent = {
type: 'gameFull',
game
};
onEvent(event);
// Only emit if game state changed to avoid unnecessary updates
const stateHash = JSON.stringify(game.state);
if (this.lastGameStateHash !== stateHash) {
this.lastGameStateHash = stateHash;
const event: GameStreamEvent = {
type: 'gameFull',
game
};
onEvent(event);
}
}
});
}
@@ -59,5 +68,6 @@ export class GameStreamService {
this.pollSubscription?.unsubscribe();
this.streamSubscription = null;
this.pollSubscription = null;
this.lastGameStateHash = null;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface JoinTournamentResponse {
botId: string;
difficulty: string;
status: string;
}
@Injectable({ providedIn: 'root' })
export class OfficialBotService {
private readonly http = inject(HttpClient);
private readonly base = '/api/bots/official';
joinTournament(tournamentId: string, difficulty: string): Observable<JoinTournamentResponse> {
return this.http.post<JoinTournamentResponse>(`${this.base}/join-tournament`, {
tournamentId,
difficulty,
});
}
}
+28 -78
View File
@@ -2,119 +2,69 @@ import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { GameStreamEvent, ErrorEvent } from '../models/game.models';
const WS_CONNECT_TIMEOUT_MS = 3000;
@Injectable({ providedIn: 'root' })
export class StreamHandlerService {
createGameStream(wsUrl: string, fallbackUrl: string, gameId: string): Observable<GameStreamEvent> {
createGameStream(wsUrl: string, gameId: string, token: string): Observable<GameStreamEvent> {
return new Observable<GameStreamEvent>((observer) => {
const ws = new WebSocket(wsUrl);
const abortController = new AbortController();
let connected = false;
let fallbackActive = false;
const parseEvent = (raw: string): GameStreamEvent | null => {
if (!raw.trim()) {
return null;
}
try {
return JSON.parse(raw) as GameStreamEvent;
} catch {
return null;
}
};
const emitErrorEvent = (message: string): void => {
const errorEvent: ErrorEvent = {
type: 'error',
error: { code: 'STREAM_ERROR', message }
error: { code: 'STREAM_ERROR', message },
};
observer.next(errorEvent);
};
const startNdjsonFallback = async (): Promise<void> => {
if (fallbackActive) {
return;
}
fallbackActive = true;
console.log(`[StreamHandler] NDJSON fallback started for ${gameId}, URL:`, fallbackUrl);
try {
const response = await fetch(fallbackUrl, {
headers: { Accept: 'application/x-ndjson' },
signal: abortController.signal
});
if (!response.ok || !response.body) {
console.error(`[StreamHandler] NDJSON fetch failed: HTTP ${response.status}`);
emitErrorEvent(`Unable to open stream: HTTP ${response.status}`);
observer.complete();
return;
}
console.log(`[StreamHandler] NDJSON stream connected for ${gameId}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const event = parseEvent(line);
if (event) {
observer.next(event);
}
}
}
observer.complete();
} catch (error) {
if ((error as Error).name !== 'AbortError') {
emitErrorEvent((error as Error).message);
observer.error(error);
}
}
const failAndComplete = (reason: string): void => {
console.warn(`[StreamHandler] WebSocket failed for ${gameId}: ${reason}`);
emitErrorEvent(reason);
observer.complete();
};
const connectionTimeoutId = setTimeout(() => {
if (!connected) {
ws.close();
failAndComplete('WebSocket connection timed out — falling back to polling');
}
}, WS_CONNECT_TIMEOUT_MS);
ws.onopen = () => {
connected = true;
clearTimeout(connectionTimeoutId);
console.log(`[StreamHandler] WebSocket connected for ${gameId}`);
ws.send(JSON.stringify({ type: 'auth', token }));
};
ws.onmessage = (message) => {
const payload = typeof message.data === 'string' ? message.data : '';
const event = parseEvent(payload);
if (event) {
if (!payload.trim()) return;
try {
const event = JSON.parse(payload) as GameStreamEvent;
observer.next(event);
} catch {
// ignore malformed frames
}
};
ws.onerror = (error) => {
console.warn(`[StreamHandler] WebSocket error for ${gameId}, attempting NDJSON fallback:`, error);
ws.onerror = () => {
clearTimeout(connectionTimeoutId);
if (!connected) {
void startNdjsonFallback();
failAndComplete('WebSocket connection error — falling back to polling');
}
};
ws.onclose = () => {
console.warn(`[StreamHandler] WebSocket closed for ${gameId}, connected=${connected}`);
if (!connected) {
console.log(`[StreamHandler] Starting NDJSON fallback for ${gameId}`);
void startNdjsonFallback();
} else {
clearTimeout(connectionTimeoutId);
if (connected) {
observer.complete();
}
};
return () => {
abortController.abort();
ws.close();
};
});
@@ -0,0 +1,23 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface ExternalTournamentServer {
id: string;
label: string;
url: string;
}
export interface ExternalTournamentServerList {
servers: ExternalTournamentServer[];
}
@Injectable({ providedIn: 'root' })
export class TournamentServerService {
private readonly http = inject(HttpClient);
private readonly base = '/api/tournament/servers';
list(): Observable<ExternalTournamentServerList> {
return this.http.get<ExternalTournamentServerList>(this.base);
}
}
+50
View File
@@ -0,0 +1,50 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Tournament, TournamentList, RoundPairings } from '../models/tournament.models';
export interface CreateTournamentForm {
name: string;
nbRounds: number;
clockLimitMinutes: number;
clockIncrement: number;
rated: boolean;
}
@Injectable({ providedIn: 'root' })
export class TournamentService {
private readonly http = inject(HttpClient);
private readonly base = '/api/tournament';
list(): Observable<TournamentList> {
return this.http.get<TournamentList>(this.base);
}
get(id: string): Observable<Tournament> {
return this.http.get<Tournament>(`${this.base}/${id}`);
}
create(form: CreateTournamentForm): Observable<Tournament> {
const body = new URLSearchParams();
body.set('name', form.name);
body.set('nbRounds', String(form.nbRounds));
body.set('clockLimit', String(form.clockLimitMinutes * 60));
body.set('clockIncrement', String(form.clockIncrement));
body.set('rated', String(form.rated));
return this.http.post<Tournament>(this.base, body.toString(), {
headers: new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' })
});
}
start(id: string): Observable<Tournament> {
return this.http.post<Tournament>(`${this.base}/${id}/start`, null);
}
join(id: string, botId: string, botName: string): Observable<void> {
return this.http.post<void>(`${this.base}/${id}/join`, { botId, botName });
}
roundPairings(id: string, round: number): Observable<RoundPairings> {
return this.http.get<RoundPairings>(`${this.base}/${id}/round/${round}`);
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ export const environment = {
production: false,
apiBaseUrl: '',
accountServiceUrl: '',
wsBaseUrl: 'ws://localhost:8080',
wsBaseUrl: 'ws://localhost:8084',
userWsBaseUrl: 'ws://localhost:8084',
apiPath: '/api/board/game'
};
+1
View File
@@ -7,5 +7,6 @@ export const environment = {
apiBaseUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
accountServiceUrl: runtimeConfig.apiUrl || 'https://st.nowchess.janis-eccarius.de',
wsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
userWsBaseUrl: runtimeConfig.wsUrl || 'wss://st.nowchess.janis-eccarius.de',
apiPath: '/api/board/game'
};
+1
View File
@@ -7,5 +7,6 @@ export const environment = {
apiBaseUrl: runtimeConfig.apiUrl || '',
accountServiceUrl: runtimeConfig.apiUrl || '',
wsBaseUrl: runtimeConfig.wsUrl,
userWsBaseUrl: runtimeConfig.wsUrl,
apiPath: '/api/board/game'
};
+3
View File
@@ -6,6 +6,9 @@
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script src="/env.js" defer></script>
</head>
<body>
+4 -3
View File
@@ -5,11 +5,12 @@
box-sizing: border-box;
}
/* Light Mode (Default) */
/* Light Mode (Default) — sunset gradient palette */
html:not([data-theme='dark']),
html:not([data-theme='dark']) body {
background: linear-gradient(160deg, var(--color-primary-light), var(--color-secondary-mint));
color: var(--color-text-primary);
background: linear-gradient(180deg, #1a1838 0%, #2e2050 25%, #4a2962 45%, #8b3a6b 65%, #d44d4a 85%, #ff6b3d 100%);
background-attachment: fixed;
color: #fff;
}
html:not([data-theme='dark']) body::before {
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=2
MINOR=5
PATCH=0