Compare commits
22 Commits
feat/NCS-55
...
0.2.7
| Author | SHA1 | Date | |
|---|---|---|---|
| 16079dd63a | |||
| a65d748f99 | |||
| 85f1b8e42c | |||
| 95eff42dfe | |||
| 873bfe3bae | |||
| a2d2c00afe | |||
| 51a363a243 | |||
| 95f5243c2d | |||
| c02414ea40 | |||
| 70a4debb40 | |||
| 61000f8a22 | |||
| f98bcfd956 | |||
| 6d1e06dfd6 | |||
| ac4fe8b005 | |||
| f8f93efff4 | |||
| 9ad23b01cc | |||
| bad7366bdb | |||
| 3c1f5c76e0 | |||
| 36d72fd6cd | |||
| bd7ec581e3 | |||
| ff75c8ce2f | |||
| 2de003e497 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 | 1–3 files, single component/service, no unknowns | 1h–2h |
|
||||
| Medium | 3–6 files, new component + service wiring, some design | 3h–5h |
|
||||
| Large | 6+ files, cross-feature, non-trivial state/routing | 1d–2d |
|
||||
| XL | New feature area, major refactor, research spike | 3d–5d |
|
||||
|
||||
4. Record: estimate + one-line reasoning.
|
||||
5. Skip leaf if it already has `Zeitschätzung` set — note it as pre-estimated.
|
||||
|
||||
## 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.
|
||||
@@ -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** (1–2 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
tmp
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
^\.env$
|
||||
.idea
|
||||
vscode
|
||||
coverage
|
||||
@@ -37,3 +37,7 @@ __screenshots__/
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Claude Code
|
||||
/.claude/settings.local.json
|
||||
/.claude/worktrees/
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
## 0.0.0 (2026-05-12)
|
||||
|
||||
### Features
|
||||
|
||||
* added bot, light and dark mode ([2de003e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2de003e497baee72f998d0d805ca1e58aababe48))
|
||||
* added web view 1v1 ([1828fa3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1828fa3275ddb8ce6bb90a9f6a970ec428ebce3a))
|
||||
* NCS-63 User account implementation ([#2](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/2)) ([ff75c8c](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ff75c8ce2fad54137f04a14c15bc1d4a38fa9bb8))
|
||||
* NCS-75 Frontend Deployment Dockerfile ([#4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/4)) ([bd7ec58](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd7ec581e38b5d8e775741bf16e19b4dc67b979e))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* build issues ([36d72fd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/36d72fd6cda41be51d28f8ac307dcdbcd31afa91))
|
||||
* cleaner components seperation ([8b090e4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8b090e4d96c64c0adb253e3aefad7930108ccfb9))
|
||||
* gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d))
|
||||
* GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02))
|
||||
* npm ([c11c1d4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c11c1d4dce9de4bd5b463e891eebf961b37feb04))
|
||||
* removed .vs ([2833ead](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2833ead7be3b47ee5c188d2d21b7326cb3cb3f26))
|
||||
* removed cache files ([7ee59c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/7ee59c434bf137a08fd560bbc02ceefbcfd90f04))
|
||||
* size of pieces and removed text view of the game state ([c60d00f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c60d00f9d25247504845654615065fbccd7fe448))
|
||||
* structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93))
|
||||
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.1.0...0.0.0) (2026-05-12)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-69 Challenge request ([#3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/3)) ([bad7366](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bad7366bdbb048c20218257b30ac22efc9ecb6db))
|
||||
## [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 +1,26 @@
|
||||
FROM --platform=$BUILDPLATFORM node:lts-alpine3.23 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM --platform=$TARGETPLATFORM nginx:stable-alpine AS production
|
||||
|
||||
RUN apk add --no-cache gettext
|
||||
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
COPY --from=builder /app/dist/nowchess-frontend/browser /usr/share/nginx/html
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
COPY public/env.template.js /usr/share/nginx/html/env.template.js
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
#
|
||||
@@ -2,7 +2,8 @@
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
@@ -28,8 +29,13 @@
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "arabian-chess",
|
||||
"output": "/arabian-chess"
|
||||
"input": "assets/arabian-chess",
|
||||
"output": "/assets/arabian-chess"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "assets/ChessAssets",
|
||||
"output": "/assets/ChessAssets"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
@@ -41,18 +47,32 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
"maximumWarning": "1MB",
|
||||
"maximumError": "5MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
"maximumWarning": "1MB",
|
||||
"maximumError": "5MB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"staging": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.staging.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.development.ts"
|
||||
}
|
||||
],
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
@@ -62,10 +82,18 @@
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json",
|
||||
"host": "0.0.0.0",
|
||||
"port": 4200
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "nowchess-frontend:build:production"
|
||||
},
|
||||
"staging": {
|
||||
"buildTarget": "nowchess-frontend:build:staging"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "nowchess-frontend:build:development"
|
||||
}
|
||||
@@ -90,8 +118,13 @@
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "arabian-chess",
|
||||
"output": "/arabian-chess"
|
||||
"input": "assets/arabian-chess",
|
||||
"output": "/assets/arabian-chess"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "assets/ChessAssets",
|
||||
"output": "/assets/ChessAssets"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 518 KiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 907 B After Width: | Height: | Size: 907 B |
|
Before Width: | Height: | Size: 919 B After Width: | Height: | Size: 919 B |
|
Before Width: | Height: | Size: 818 B After Width: | Height: | Size: 818 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 161 B After Width: | Height: | Size: 161 B |
|
Before Width: | Height: | Size: 188 B After Width: | Height: | Size: 188 B |
|
Before Width: | Height: | Size: 188 B After Width: | Height: | Size: 188 B |
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 237 B |
|
Before Width: | Height: | Size: 243 B After Width: | Height: | Size: 243 B |
|
Before Width: | Height: | Size: 264 B After Width: | Height: | Size: 264 B |
|
Before Width: | Height: | Size: 244 B After Width: | Height: | Size: 244 B |
|
Before Width: | Height: | Size: 240 B After Width: | Height: | Size: 240 B |
|
Before Width: | Height: | Size: 232 B After Width: | Height: | Size: 232 B |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 287 B |
|
Before Width: | Height: | Size: 211 B After Width: | Height: | Size: 211 B |
|
Before Width: | Height: | Size: 238 B After Width: | Height: | Size: 238 B |
|
Before Width: | Height: | Size: 227 B After Width: | Height: | Size: 227 B |
|
Before Width: | Height: | Size: 267 B After Width: | Height: | Size: 267 B |
|
Before Width: | Height: | Size: 300 B After Width: | Height: | Size: 300 B |
|
Before Width: | Height: | Size: 218 B After Width: | Height: | Size: 218 B |
|
Before Width: | Height: | Size: 244 B After Width: | Height: | Size: 244 B |
|
Before Width: | Height: | Size: 245 B After Width: | Height: | Size: 245 B |
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 229 B |
|
Before Width: | Height: | Size: 286 B After Width: | Height: | Size: 286 B |
|
Before Width: | Height: | Size: 245 B After Width: | Height: | Size: 245 B |
|
Before Width: | Height: | Size: 266 B After Width: | Height: | Size: 266 B |
|
Before Width: | Height: | Size: 297 B After Width: | Height: | Size: 297 B |
|
Before Width: | Height: | Size: 258 B After Width: | Height: | Size: 258 B |
|
Before Width: | Height: | Size: 263 B After Width: | Height: | Size: 263 B |
|
Before Width: | Height: | Size: 313 B After Width: | Height: | Size: 313 B |
|
Before Width: | Height: | Size: 251 B After Width: | Height: | Size: 251 B |
|
Before Width: | Height: | Size: 275 B After Width: | Height: | Size: 275 B |
|
Before Width: | Height: | Size: 305 B After Width: | Height: | Size: 305 B |
|
Before Width: | Height: | Size: 281 B After Width: | Height: | Size: 281 B |
|
Before Width: | Height: | Size: 280 B After Width: | Height: | Size: 280 B |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Replace placeholders in env.template.js with environment variables and write env.js
|
||||
TEMPLATE_PATH="/usr/share/nginx/html/env.template.js"
|
||||
TARGET_PATH="/usr/share/nginx/html/env.js"
|
||||
|
||||
if [ -f "$TEMPLATE_PATH" ]; then
|
||||
echo "Rendering runtime config from $TEMPLATE_PATH"
|
||||
echo "Using environment variables:"
|
||||
printenv
|
||||
echo "----"
|
||||
envsubst < "$TEMPLATE_PATH" > "$TARGET_PATH"
|
||||
else
|
||||
echo "No runtime template found at $TEMPLATE_PATH, skipping"
|
||||
fi
|
||||
|
||||
exec nginx -g 'daemon off;'
|
||||
@@ -1,6 +1,6 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: NowChess API
|
||||
title: NowChess Board API
|
||||
description: |
|
||||
REST API for the NowChess application. Designed to feel familiar to users
|
||||
of the [lichess API](https://lichess.org/api).
|
||||
@@ -186,11 +186,8 @@ paths:
|
||||
currently to move.
|
||||
|
||||
For promotion moves include the target piece as the fifth character:
|
||||
`e7e8q`, `a2a1r`, etc.
|
||||
|
||||
If the move results in a pawn reaching the back rank and no promotion
|
||||
character is supplied, the game enters `promotionPending` status and
|
||||
the move is not yet applied — resubmit with the promotion character.
|
||||
`e7e8q`, `a2a1r`, etc. Promotion moves without the fifth character
|
||||
are rejected with `400 INVALID_MOVE`.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
@@ -630,7 +627,6 @@ components:
|
||||
| `draw` | Draw agreed or claimed — game over |
|
||||
| `drawOffered` | Waiting for the opponent to accept or decline a draw offer |
|
||||
| `fiftyMoveAvailable` | Fifty-move rule threshold reached; active player may claim draw |
|
||||
| `promotionPending` | A pawn reached the back rank; awaiting promotion piece selection |
|
||||
| `insufficientMaterial` | Neither side has enough pieces to deliver checkmate — game over (draw) |
|
||||
enum:
|
||||
- started
|
||||
@@ -641,7 +637,6 @@ components:
|
||||
- draw
|
||||
- drawOffered
|
||||
- fiftyMoveAvailable
|
||||
- promotionPending
|
||||
- insufficientMaterial
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /env.js {
|
||||
root /usr/share/nginx/html;
|
||||
default_type application/javascript;
|
||||
expires -1;
|
||||
add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
@@ -458,7 +458,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.19.tgz",
|
||||
"integrity": "sha512-hcB1eUEN8LGcKGc4DlRJ+abS6AYfbEHDZKg8LnXNugkbwI6Ebyh2AUYTDhzZL2S4aH+C8biHKgSYHFCqieCRhA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -475,7 +474,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.19.tgz",
|
||||
"integrity": "sha512-ETkgDKm0l2PuaBubgPJe0ccy8kE75DFu6/zKcz7TUuk3KrKF2OZAopbbjftsUSZGeCNvCdqHzjmcL6hQ6oAOwA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -489,7 +487,6 @@
|
||||
"integrity": "sha512-ET/JjO8s62kAHfgIsGXlvW5VUwLqHm03q1y/2yD7aQW/WdDvssMsvZv7Knl440989vdOFemIGTMwVPakmWqRmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.28.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@@ -522,7 +519,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.19.tgz",
|
||||
"integrity": "sha512-SYnwW+q51bQoPtGFoGovm1P5GK9fMEXsG0lGaEAUapjskblAYyX7hLlM/jgueSojv2SjhqNF8aXR+gjHLhZVNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -566,7 +562,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.19.tgz",
|
||||
"integrity": "sha512-TRZfatH1B/kreDwFRwtpLEurJQ6044qh6DWpvxzTbugaG5otLQJKTk+1z81/KsJwQqc1+24v+yuywc1LM7aq7w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -633,7 +628,6 @@
|
||||
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@@ -1607,7 +1601,6 @@
|
||||
"integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/checkbox": "^4.2.1",
|
||||
"@inquirer/confirm": "^5.1.14",
|
||||
@@ -3538,7 +3531,6 @@
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
@@ -3895,7 +3887,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -4911,7 +4902,6 @@
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -5353,7 +5343,6 @@
|
||||
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -5850,8 +5839,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.9.0.tgz",
|
||||
"integrity": "sha512-OMUvF1iI6+gSRYOhMrH4QYothVLN9C3EJ6wm4g7zLJlnaTl8zbaPOr0bTw70l7QxkoM7sVFOWo83u9B2Fe2Zng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.2",
|
||||
@@ -5953,7 +5941,6 @@
|
||||
"integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "1.5.0",
|
||||
"body-parser": "^1.19.0",
|
||||
@@ -6421,7 +6408,6 @@
|
||||
"integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cli-truncate": "^4.0.0",
|
||||
"colorette": "^2.0.20",
|
||||
@@ -7934,7 +7920,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
@@ -7970,7 +7955,6 @@
|
||||
"integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^5.0.2",
|
||||
@@ -8590,8 +8574,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tuf-js": {
|
||||
"version": "4.1.0",
|
||||
@@ -8629,7 +8612,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -8759,7 +8741,6 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -9557,7 +9538,6 @@
|
||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -9576,8 +9556,7 @@
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
|
||||
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"build": "ng build --configuration production",
|
||||
"build:staging": "ng build --configuration staging",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"/api/tournament": {
|
||||
"target": "http://localhost:8089",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/api/account": {
|
||||
"target": "http://localhost:8083",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/api/challenge": {
|
||||
"target": "http://localhost:8083",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/api/user/ws": {
|
||||
"target": "http://localhost:8084",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"ws": true
|
||||
},
|
||||
"/api": {
|
||||
"target": "http://localhost:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"ws": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
window.__RUNTIME_CONFIG__ = {
|
||||
API_URL: "${API_URL}",
|
||||
WEBSOCKET_URL: "${WEBSOCKET_URL}"
|
||||
};
|
||||
@@ -1,14 +1,15 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './services/auth.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideHttpClient(),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideRouter(routes)
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
<router-outlet />
|
||||
<app-toolbar />
|
||||
<router-outlet />
|
||||
@@ -1,9 +1,19 @@
|
||||
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';
|
||||
|
||||
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: 'game/:gameId', component: GameComponent },
|
||||
{ path: '**', redirectTo: '' }
|
||||
];
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { ToolbarComponent } from './components/toolbar/toolbar.component';
|
||||
import { ThemeService } from './services/theme.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
imports: [RouterOutlet, ToolbarComponent],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css'
|
||||
})
|
||||
export class App {
|
||||
export class App implements OnInit {
|
||||
constructor(private readonly themeService: ThemeService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.themeService.initTheme();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Shared Button Template - All Button Styles Consolidated */
|
||||
|
||||
.app-btn {
|
||||
background: var(--btn-bg);
|
||||
color: var(--btn-fg);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
padding: 0.6rem 1rem;
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--btn-glow);
|
||||
transition: transform 0.2s ease, filter 0.2s ease, background 1.6s ease, box-shadow 1.6s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-btn:hover:enabled {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.app-btn:active:enabled {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.app-btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-btn.w-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Dialog Button Layouts */
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dialog-actions .app-btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Promotion Dialog Button Variant */
|
||||
.promotion-choice {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
padding: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.promotion-choice .piece-symbol {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.promotion-choice .piece-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
@import '../../button-template.css';
|
||||
|
||||
.challenge-create-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(3px);
|
||||
|
||||
&.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.challenge-create-dialog {
|
||||
background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%);
|
||||
border: 2px solid #00d5ff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 20px rgba(0, 213, 255, 0.3), inset 0 0 10px rgba(0, 213, 255, 0.05);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid rgba(0, 213, 255, 0.2);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #00d5ff;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
color: #00d5ff;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: #ffffff;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
color: #b0b0d0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
background-color: rgba(15, 20, 50, 0.8);
|
||||
border: 1px solid rgba(0, 213, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-family: 'Space Mono', monospace;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #00d5ff;
|
||||
box-shadow: 0 0 10px rgba(0, 213, 255, 0.3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: #0a0e27;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
small {
|
||||
color: #ff6b6b;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
color: #b0b0d0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: rgba(15, 20, 50, 0.8);
|
||||
border: 1px solid rgba(0, 213, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-family: 'Space Mono', monospace;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #00d5ff;
|
||||
box-shadow: 0 0 10px rgba(0, 213, 255, 0.3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preset-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 8px 16px;
|
||||
background-color: rgba(0, 213, 255, 0.1);
|
||||
border: 1px solid #00d5ff;
|
||||
border-radius: 4px;
|
||||
color: #00d5ff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: rgba(0, 213, 255, 0.2);
|
||||
box-shadow: 0 0 10px rgba(0, 213, 255, 0.3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: rgba(255, 107, 107, 0.1);
|
||||
border: 1px solid #ff6b6b;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
color: #ff9999;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid rgba(0, 213, 255, 0.2);
|
||||
|
||||
button {
|
||||
padding: 10px 24px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&.btn-secondary {
|
||||
background-color: transparent;
|
||||
border: 1px solid rgba(0, 213, 255, 0.3);
|
||||
color: #00d5ff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: rgba(0, 213, 255, 0.1);
|
||||
border-color: #00d5ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background-color: #00d5ff;
|
||||
border: none;
|
||||
color: #0a0e27;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 0 20px rgba(0, 213, 255, 0.6);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<div class="challenge-create-dialog-overlay" (click)="cancel()" [class.loading]="loading">
|
||||
<div class="challenge-create-dialog" (click)="$event.stopPropagation()">
|
||||
<div class="dialog-header">
|
||||
<h2>Create Challenge</h2>
|
||||
<button type="button" class="close-btn" (click)="cancel()" [disabled]="loading">×</button>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="dialog-form">
|
||||
<!-- Error Message -->
|
||||
<div *ngIf="errorMessage" class="error-message">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Target Username -->
|
||||
<div class="form-group">
|
||||
<label for="targetUsername">Opponent Username</label>
|
||||
<input type="text" id="targetUsername" formControlName="targetUsername"
|
||||
placeholder="Enter opponent's username" required />
|
||||
<small *ngIf="form.get('targetUsername')?.hasError('required') && form.get('targetUsername')?.touched">
|
||||
Username is required
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Player Color Selection -->
|
||||
<div class="form-group">
|
||||
<label for="color">Play as</label>
|
||||
<select id="color" formControlName="color">
|
||||
<option value="white">White</option>
|
||||
<option value="black">Black</option>
|
||||
<option value="random">Random</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Time Control Mode Selection -->
|
||||
<div class="form-group">
|
||||
<label for="timeMode">Time Control</label>
|
||||
<select id="timeMode" formControlName="timeMode">
|
||||
<option value="blitz">Blitz</option>
|
||||
<option value="rapid">Rapid</option>
|
||||
<option value="classical">Classical</option>
|
||||
<option value="unlimited">Unlimited</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Time Presets -->
|
||||
<div class="form-group" *ngIf="selectedTimeMode !== 'unlimited'">
|
||||
<label>Presets</label>
|
||||
<div class="preset-buttons">
|
||||
<button type="button" *ngFor="let preset of getAvailablePresets()" class="preset-btn"
|
||||
(click)="selectPreset(preset)" [disabled]="loading">
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Time Control -->
|
||||
<div class="form-group" *ngIf="selectedTimeMode !== 'unlimited'">
|
||||
<div class="form-row">
|
||||
<div class="form-col">
|
||||
<label for="limitMinutes">Time (minutes)</label>
|
||||
<input type="number" id="limitMinutes" formControlName="limitMinutes" min="1" max="1000" />
|
||||
</div>
|
||||
<div class="form-col">
|
||||
<label for="incrementSeconds">Increment (seconds)</label>
|
||||
<input type="number" id="incrementSeconds" formControlName="incrementSeconds" min="0" max="300" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTL (Time to Live) -->
|
||||
<div class="form-group">
|
||||
<label for="ttlSeconds">Challenge Expires In</label>
|
||||
<select id="ttlSeconds" formControlName="ttlSeconds">
|
||||
<option *ngFor="let ttl of ttlOptions" [value]="ttl.seconds">
|
||||
{{ ttl.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="dialog-buttons">
|
||||
<button type="button" class="btn btn-secondary" (click)="cancel()" [disabled]="loading">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="form.invalid || loading">
|
||||
{{ loading ? 'Sending...' : 'Send Challenge' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Component, inject, OnInit, OnDestroy, DestroyRef, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { finalize } from 'rxjs';
|
||||
import { ChallengeService } from '../../services/challenge.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { getErrorMessage } from '../../core/http/error-message.util';
|
||||
import { PlayerColor } from '../../models/challenge.models';
|
||||
|
||||
type TimeMode = 'blitz' | 'rapid' | 'classical' | 'unlimited';
|
||||
|
||||
interface TimePreset {
|
||||
label: string;
|
||||
limitSeconds: number;
|
||||
incrementSeconds: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-challenge-create-dialog',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule],
|
||||
templateUrl: './challenge-create-dialog.component.html',
|
||||
styleUrls: ['./challenge-create-dialog.component.css']
|
||||
})
|
||||
export class ChallengeCreateDialogComponent implements OnInit, OnDestroy {
|
||||
private readonly challengeService = inject(ChallengeService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
@Output() closeChallengeDialog = new EventEmitter<void>();
|
||||
|
||||
form!: FormGroup;
|
||||
loading = false;
|
||||
errorMessage = '';
|
||||
selectedTimeMode: TimeMode = 'rapid';
|
||||
|
||||
timePresets: Record<TimeMode, TimePreset[]> = {
|
||||
blitz: [
|
||||
{ label: '1+0', limitSeconds: 60, incrementSeconds: 0 },
|
||||
{ label: '2+1', limitSeconds: 120, incrementSeconds: 1 },
|
||||
{ label: '3+0', limitSeconds: 180, incrementSeconds: 0 },
|
||||
{ label: '3+2', limitSeconds: 180, incrementSeconds: 2 },
|
||||
{ label: '5+0', limitSeconds: 300, incrementSeconds: 0 }
|
||||
],
|
||||
rapid: [
|
||||
{ label: '10+0', limitSeconds: 600, incrementSeconds: 0 },
|
||||
{ label: '10+5', limitSeconds: 600, incrementSeconds: 5 },
|
||||
{ label: '15+10', limitSeconds: 900, incrementSeconds: 10 },
|
||||
{ label: '25+10', limitSeconds: 1500, incrementSeconds: 10 }
|
||||
],
|
||||
classical: [
|
||||
{ label: '30+0', limitSeconds: 1800, incrementSeconds: 0 },
|
||||
{ label: '30+20', limitSeconds: 1800, incrementSeconds: 20 },
|
||||
{ label: '60+30', limitSeconds: 3600, incrementSeconds: 30 },
|
||||
{ label: '90+30', limitSeconds: 5400, incrementSeconds: 30 }
|
||||
],
|
||||
unlimited: []
|
||||
};
|
||||
|
||||
ttlOptions = [
|
||||
{ label: '5 minutes', seconds: 300 },
|
||||
{ label: '1 hour', seconds: 3600 },
|
||||
{ label: '1 day', seconds: 86400 },
|
||||
{ label: 'No expiry', seconds: 0 }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initializeForm();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
}
|
||||
|
||||
private initializeForm(): void {
|
||||
this.form = this.fb.group({
|
||||
targetUsername: ['', [Validators.required, Validators.minLength(1)]],
|
||||
color: ['random', Validators.required],
|
||||
timeMode: ['rapid'],
|
||||
limitMinutes: [10, [Validators.required, Validators.min(1), Validators.max(1000)]],
|
||||
incrementSeconds: [5, [Validators.required, Validators.min(0), Validators.max(300)]],
|
||||
ttlSeconds: [3600, Validators.required]
|
||||
});
|
||||
|
||||
this.form.get('timeMode')?.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((mode: unknown) => {
|
||||
const timeMode = mode as TimeMode;
|
||||
this.selectedTimeMode = timeMode;
|
||||
if (timeMode !== 'unlimited') {
|
||||
const firstPreset = this.timePresets[timeMode][0];
|
||||
if (firstPreset) {
|
||||
this.form.patchValue({
|
||||
limitMinutes: firstPreset.limitSeconds / 60,
|
||||
incrementSeconds: firstPreset.incrementSeconds
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectPreset(preset: TimePreset): void {
|
||||
this.form.patchValue({
|
||||
limitMinutes: preset.limitSeconds / 60,
|
||||
incrementSeconds: preset.incrementSeconds
|
||||
});
|
||||
}
|
||||
|
||||
getAvailablePresets(): TimePreset[] {
|
||||
return this.timePresets[this.selectedTimeMode] || [];
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid || this.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUsername = this.form.get('targetUsername')?.value?.trim();
|
||||
if (!targetUsername) {
|
||||
this.errorMessage = 'Please enter a valid username';
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = '';
|
||||
this.loading = true;
|
||||
this.form.disable();
|
||||
|
||||
const limitSeconds = Math.round((this.form.getRawValue().limitMinutes || 0) * 60);
|
||||
const { incrementSeconds, ttlSeconds, color: rawColor } = this.form.getRawValue();
|
||||
const color = (rawColor || 'random') as PlayerColor;
|
||||
|
||||
this.challengeService.sendChallenge(targetUsername, {
|
||||
timeControl: {
|
||||
limitSeconds,
|
||||
incrementSeconds
|
||||
},
|
||||
color,
|
||||
ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined
|
||||
})
|
||||
.pipe(finalize(() => { this.loading = false; this.form.enable(); }))
|
||||
.subscribe({
|
||||
next: (challenge) => {
|
||||
// Challenge sent successfully - navigate to challenges page to view status
|
||||
this.form.reset();
|
||||
this.closeChallengeDialog.emit();
|
||||
void this.router.navigate(['/challenges']);
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'Failed to send challenge. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.form.reset();
|
||||
this.closeChallengeDialog.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
@import '../../button-template.css';
|
||||
|
||||
.challenge-notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
max-width: 400px;
|
||||
background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%);
|
||||
border: 2px solid #00d5ff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 20px rgba(0, 213, 255, 0.4), inset 0 0 10px rgba(0, 213, 255, 0.05);
|
||||
padding: 16px;
|
||||
color: #e0e0e0;
|
||||
z-index: 2000;
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
|
||||
&.error {
|
||||
border-color: #ff6b6b;
|
||||
box-shadow: 0 0 20px rgba(255, 107, 107, 0.4), inset 0 0 10px rgba(255, 107, 107, 0.05);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(450px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(0, 213, 255, 0.2);
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
|
||||
.badge {
|
||||
background-color: rgba(0, 213, 255, 0.2);
|
||||
border: 1px solid #00d5ff;
|
||||
border-radius: 3px;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #00d5ff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #d4f4ff;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: #00d5ff;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: #ffffff;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.time-control {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
|
||||
.label {
|
||||
color: #b0b0d0;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.value {
|
||||
background-color: rgba(0, 213, 255, 0.1);
|
||||
border: 1px solid rgba(0, 213, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
padding: 4px 12px;
|
||||
color: #00d5ff;
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.expiration {
|
||||
font-size: 12px;
|
||||
color: #b0b0d0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: rgba(255, 107, 107, 0.1);
|
||||
border: 1px solid #ff6b6b;
|
||||
border-radius: 3px;
|
||||
padding: 8px 10px;
|
||||
color: #ff9999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border: none;
|
||||
|
||||
&.btn-decline {
|
||||
background-color: transparent;
|
||||
border: 1px solid rgba(0, 213, 255, 0.3);
|
||||
color: #00d5ff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: rgba(0, 213, 255, 0.1);
|
||||
border-color: #00d5ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-accept {
|
||||
background-color: #00d5ff;
|
||||
color: #0a0e27;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 0 15px rgba(0, 213, 255, 0.6);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<div class="challenge-notification" [class.error]="!!errorMessage">
|
||||
<div class="notification-header">
|
||||
<div class="notification-title">
|
||||
<span class="badge">CHALLENGE</span>
|
||||
<span class="title">{{ getCreatedByDisplay() }} challenged you!</span>
|
||||
</div>
|
||||
<button type="button" class="close-btn" (click)="onClose()"
|
||||
[disabled]="acceptingChallenge || decliningChallenge">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="notification-content">
|
||||
<div class="time-control">
|
||||
<span class="label">Time Control:</span>
|
||||
<span class="value">{{ getTimeControlDisplay() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="expiration">
|
||||
<span class="label">{{ getExpirationInfo() }}</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="errorMessage" class="error-message">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="notification-actions">
|
||||
<button type="button" class="btn btn-decline" (click)="onDecline()"
|
||||
[disabled]="acceptingChallenge || decliningChallenge">
|
||||
{{ decliningChallenge ? 'Declining...' : 'Decline' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-accept" (click)="onAccept()"
|
||||
[disabled]="acceptingChallenge || decliningChallenge">
|
||||
{{ acceptingChallenge ? 'Accepting...' : 'Accept' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,108 @@
|
||||
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';
|
||||
import { getErrorMessage } from '../../core/http/error-message.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-challenge-notification',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './challenge-notification.component.html',
|
||||
styleUrls: ['./challenge-notification.component.css']
|
||||
})
|
||||
export class ChallengeNotificationComponent {
|
||||
@Input() challenge!: Challenge;
|
||||
@Output() accept = new EventEmitter<Challenge>();
|
||||
@Output() decline = new EventEmitter<Challenge>();
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
private readonly challengeService = inject(ChallengeService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
acceptingChallenge = false;
|
||||
decliningChallenge = false;
|
||||
errorMessage = '';
|
||||
|
||||
onAccept(): void {
|
||||
if (this.acceptingChallenge || this.decliningChallenge) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.acceptingChallenge = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
this.challengeService.acceptChallenge(this.challenge.id)
|
||||
.pipe(finalize(() => (this.acceptingChallenge = false)))
|
||||
.subscribe({
|
||||
next: (acceptedChallenge) => {
|
||||
this.accept.emit(acceptedChallenge);
|
||||
if (acceptedChallenge.gameId) {
|
||||
void this.router.navigate(['/game', acceptedChallenge.gameId]);
|
||||
} else {
|
||||
this.errorMessage = 'Challenge accepted, but no game was created.';
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'Failed to accept challenge');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onDecline(): void {
|
||||
if (this.acceptingChallenge || this.decliningChallenge) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.decliningChallenge = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
this.challengeService.declineChallenge(this.challenge.id, { reason: 'generic' })
|
||||
.pipe(finalize(() => (this.decliningChallenge = false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.decline.emit(this.challenge);
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'Failed to decline challenge');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
getTimeControlDisplay(): string {
|
||||
const { limit, increment } = this.challenge.timeControl;
|
||||
if (!limit || !increment) {
|
||||
return 'Unlimited';
|
||||
}
|
||||
const minutes = Math.floor(limit / 60);
|
||||
return `${minutes}+${increment}`;
|
||||
}
|
||||
|
||||
getCreatedByDisplay(): string {
|
||||
return this.challenge.challenger.name;
|
||||
}
|
||||
|
||||
getExpirationInfo(): string {
|
||||
const expiresAt = new Date(this.challenge.expiresAt);
|
||||
const now = new Date();
|
||||
const diffMs = expiresAt.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0) {
|
||||
return 'Expired';
|
||||
}
|
||||
|
||||
const minutes = Math.floor(diffMs / 60000);
|
||||
if (minutes > 60) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `Expires in ${hours}h`;
|
||||
}
|
||||
|
||||
return `Expires in ${minutes}m`;
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@
|
||||
.board-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
border: 2px solid #5A2C28;
|
||||
border-radius: 10px 10px 0 0;
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--border-radius-md) var(--border-radius-md) 0 0;
|
||||
overflow: hidden;
|
||||
background: #5A2C28;
|
||||
background: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.square {
|
||||
@@ -27,12 +27,56 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.square[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.square.drag-source {
|
||||
opacity: 0.65;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.square.drag-over {
|
||||
outline: 3px dashed var(--color-primary);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
.square.light {
|
||||
background-image: url('/arabian-chess/sprites/board/board_square_white.png');
|
||||
background-image: url('/assets/arabian-chess/sprites/board/board_square_white.png');
|
||||
}
|
||||
|
||||
.square.dark {
|
||||
background-image: url('/arabian-chess/sprites/board/board_square_black.png');
|
||||
background-image: url('/assets/arabian-chess/sprites/board/board_square_black.png');
|
||||
}
|
||||
|
||||
.board-grid--classic {
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.board-grid--classic .square {
|
||||
background-image: none;
|
||||
transition: filter 160ms ease;
|
||||
}
|
||||
|
||||
.board-grid--classic .square.light {
|
||||
background-color: #f3c8a0;
|
||||
}
|
||||
|
||||
.board-grid--classic .square.dark {
|
||||
background-color: #ba6d4b;
|
||||
}
|
||||
|
||||
.board-grid--classic .square.drag-over {
|
||||
outline-color: #5a2c28;
|
||||
}
|
||||
|
||||
.board-grid--classic .square.selected {
|
||||
outline-color: #5a2c28;
|
||||
}
|
||||
|
||||
.board-grid--classic .square.highlighted::after {
|
||||
background: #b9dad1;
|
||||
border-color: #5a2c28;
|
||||
}
|
||||
|
||||
.square.highlighted::after {
|
||||
@@ -41,12 +85,13 @@
|
||||
width: 28%;
|
||||
height: 28%;
|
||||
border-radius: 50%;
|
||||
background: rgba(193, 158, 245, 0.75);
|
||||
border: 2px solid #5A2C28;
|
||||
background: var(--color-secondary-purple);
|
||||
opacity: 0.75;
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.square.selected {
|
||||
outline: 3px solid #BA6D4B;
|
||||
outline: 3px solid var(--color-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
@@ -55,7 +100,7 @@
|
||||
width: 100%;
|
||||
display: block;
|
||||
object-fit: fill;
|
||||
border: 2px solid #5A2C28;
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 10px 10px;
|
||||
border-radius: 0 0 var(--border-radius-md) var(--border-radius-md);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="board-shell">
|
||||
<div class="board-grid">
|
||||
<div class="board-grid" [class.board-grid--classic]="boardTheme === 'classic'" [class.board-grid--arabian]="boardTheme === 'arabian'">
|
||||
@for (square of squares; track trackByCoordinate($index, square)) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -8,13 +8,25 @@
|
||||
[class.dark]="!square.isLight"
|
||||
[class.selected]="isSelected(square)"
|
||||
[class.highlighted]="isHighlighted(square)"
|
||||
[class.drag-source]="isDraggingSource(square)"
|
||||
[class.drag-over]="isDragOver(square)"
|
||||
[attr.data-square]="square.coordinate"
|
||||
(click)="onSquareClick(square)"
|
||||
(dragover)="onSquareDragOver($event, square)"
|
||||
(drop)="onSquareDrop($event, square)"
|
||||
>
|
||||
<app-chess-piece [pieceCode]="square.pieceCode" />
|
||||
<app-chess-piece
|
||||
[pieceCode]="square.pieceCode"
|
||||
[boardTheme]="boardTheme"
|
||||
[draggable]="!!square.pieceCode"
|
||||
(pieceDragStart)="onPieceDragStart($event, square)"
|
||||
(pieceDragEnd)="onSquareDragEnd()"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<img class="board-bottom" src="/arabian-chess/sprites/board/board_bottom.png" alt="Board frame" />
|
||||
@if (boardTheme === 'arabian') {
|
||||
<img class="board-bottom" src="/assets/arabian-chess/sprites/board/board_bottom.png" alt="Board frame" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ interface BoardSquare {
|
||||
pieceCode: string | null;
|
||||
}
|
||||
|
||||
type BoardTheme = 'arabian' | 'classic';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chess-board',
|
||||
standalone: true,
|
||||
@@ -18,10 +20,14 @@ export class ChessBoardComponent implements OnChanges {
|
||||
@Input({ required: true }) fen = '';
|
||||
@Input() selectedSquare: string | null = null;
|
||||
@Input() highlightedSquares: string[] = [];
|
||||
@Input() boardTheme: BoardTheme = 'arabian';
|
||||
@Output() squareSelected = new EventEmitter<string>();
|
||||
|
||||
squares: BoardSquare[] = [];
|
||||
private highlightedSquareSet = new Set<string>();
|
||||
private draggingFromSquare: string | null = null;
|
||||
private dragOverSquare: string | null = null;
|
||||
private suppressNextClick = false;
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['fen']) {
|
||||
@@ -38,9 +44,61 @@ export class ChessBoardComponent implements OnChanges {
|
||||
}
|
||||
|
||||
onSquareClick(square: BoardSquare): void {
|
||||
if (this.suppressNextClick) {
|
||||
this.suppressNextClick = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.squareSelected.emit(square.coordinate);
|
||||
}
|
||||
|
||||
onPieceDragStart(event: DragEvent, square: BoardSquare): void {
|
||||
if (!square.pieceCode) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
this.draggingFromSquare = square.coordinate;
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('text/plain', square.coordinate);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
this.squareSelected.emit(square.coordinate);
|
||||
}
|
||||
|
||||
onSquareDragOver(event: DragEvent, square: BoardSquare): void {
|
||||
if (!this.draggingFromSquare) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
this.dragOverSquare = square.coordinate === this.draggingFromSquare ? null : square.coordinate;
|
||||
}
|
||||
|
||||
onSquareDrop(event: DragEvent, square: BoardSquare): void {
|
||||
event.preventDefault();
|
||||
if (!this.draggingFromSquare) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fromSquare = this.draggingFromSquare;
|
||||
this.clearDragState();
|
||||
|
||||
if (fromSquare === square.coordinate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.suppressNextClick = true;
|
||||
this.squareSelected.emit(square.coordinate);
|
||||
}
|
||||
|
||||
onSquareDragEnd(): void {
|
||||
this.clearDragState();
|
||||
}
|
||||
|
||||
isSelected(square: BoardSquare): boolean {
|
||||
return this.selectedSquare === square.coordinate;
|
||||
}
|
||||
@@ -49,6 +107,14 @@ export class ChessBoardComponent implements OnChanges {
|
||||
return this.highlightedSquareSet.has(square.coordinate);
|
||||
}
|
||||
|
||||
isDraggingSource(square: BoardSquare): boolean {
|
||||
return this.draggingFromSquare === square.coordinate;
|
||||
}
|
||||
|
||||
isDragOver(square: BoardSquare): boolean {
|
||||
return this.dragOverSquare === square.coordinate;
|
||||
}
|
||||
|
||||
private buildSquares(fen: string): BoardSquare[] {
|
||||
const placement = fen.split(' ')[0] ?? '';
|
||||
const rows = placement.split('/');
|
||||
@@ -87,4 +153,9 @@ export class ChessBoardComponent implements OnChanges {
|
||||
pieceCode
|
||||
};
|
||||
}
|
||||
|
||||
private clearDragState(): void {
|
||||
this.draggingFromSquare = null;
|
||||
this.dragOverSquare = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
.piece {
|
||||
width: clamp(50px, 11cqh, 160px);
|
||||
height: clamp(50px, 11cqh, 160px);
|
||||
height: clamp(40px, 8cqh, 120px);
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.piece[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.piece[draggable='true']:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
@if (pieceCode) {
|
||||
<img class="piece" [src]="spriteUrl" [alt]="pieceCode" />
|
||||
<img
|
||||
class="piece"
|
||||
[src]="spriteUrl"
|
||||
[alt]="pieceCode"
|
||||
[attr.draggable]="draggable ? 'true' : null"
|
||||
(dragstart)="onDragStart($event)"
|
||||
(dragend)="onDragEnd()"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
type BoardTheme = 'arabian' | 'classic';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chess-piece',
|
||||
@@ -8,18 +10,44 @@ import { Component, Input } from '@angular/core';
|
||||
})
|
||||
export class ChessPieceComponent {
|
||||
@Input({ required: true }) pieceCode: string | null = null;
|
||||
@Input() boardTheme: BoardTheme = 'arabian';
|
||||
@Input() draggable = false;
|
||||
@Output() pieceDragStart = new EventEmitter<DragEvent>();
|
||||
@Output() pieceDragEnd = new EventEmitter<void>();
|
||||
|
||||
onDragStart(event: DragEvent): void {
|
||||
if (!this.draggable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
this.pieceDragStart.emit(event);
|
||||
}
|
||||
|
||||
onDragEnd(): void {
|
||||
this.pieceDragEnd.emit();
|
||||
}
|
||||
|
||||
get spriteUrl(): string {
|
||||
if (!this.pieceCode) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const color = this.pieceCode === this.pieceCode.toUpperCase() ? 'white' : 'black';
|
||||
const pieceName = this.getPieceName(this.pieceCode.toLowerCase());
|
||||
return `/arabian-chess/sprites/pieces/${color}_${pieceName}.png`;
|
||||
const isWhite = this.pieceCode === this.pieceCode.toUpperCase();
|
||||
const pieceCode = this.pieceCode.toLowerCase();
|
||||
|
||||
if (this.boardTheme === 'classic') {
|
||||
const colorPrefix = isWhite ? 'w' : 'b';
|
||||
const classicPieceName = this.getClassicPieceName(pieceCode);
|
||||
return `/assets/ChessAssets/${colorPrefix}_${classicPieceName}.png`;
|
||||
}
|
||||
|
||||
const arabianColor = isWhite ? 'white' : 'black';
|
||||
const arabianPieceName = this.getArabianPieceName(pieceCode);
|
||||
return `/assets/arabian-chess/sprites/pieces/${arabianColor}_${arabianPieceName}.png`;
|
||||
}
|
||||
|
||||
private getPieceName(piece: string): string {
|
||||
private getArabianPieceName(piece: string): string {
|
||||
switch (piece) {
|
||||
case 'k':
|
||||
return 'king';
|
||||
@@ -37,4 +65,23 @@ export class ChessPieceComponent {
|
||||
return 'pawn';
|
||||
}
|
||||
}
|
||||
|
||||
private getClassicPieceName(piece: string): string {
|
||||
switch (piece) {
|
||||
case 'k':
|
||||
return 'King';
|
||||
case 'q':
|
||||
return 'Queen';
|
||||
case 'r':
|
||||
return 'Rook';
|
||||
case 'b':
|
||||
return 'Bishop';
|
||||
case 'n':
|
||||
return 'Knight';
|
||||
case 'p':
|
||||
return 'Pawn';
|
||||
default:
|
||||
return 'Pawn';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@import '../../button-template.css';
|
||||
|
||||
.input-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: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-lg-gap);
|
||||
}
|
||||
|
||||
.move-card {
|
||||
padding: var(--size-sm-padding);
|
||||
gap: var(--size-sm-gap);
|
||||
}
|
||||
|
||||
.move-card input {
|
||||
min-height: 45px;
|
||||
padding: var(--size-md-padding);
|
||||
}
|
||||
|
||||
.input-card label {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--size-xs);
|
||||
}
|
||||
|
||||
.input-card textarea {
|
||||
resize: vertical;
|
||||
height: 40px;
|
||||
background-color:lightcyan;
|
||||
border: 3px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.input-card input {
|
||||
min-width: unset;
|
||||
background-color:lightcyan;
|
||||
border: 3px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||