Align repo config with global development standards
- Add missing tsconfig strictness flags (noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride, noPropertyAccessFromIndexSignature) and fix all resulting type errors - Replace ESLint/Prettier with oxlint 1.48.0 and oxfmt 0.33.0 - Pin all frontend and backend dependencies to exact versions - Pin GitHub Actions to SHA hashes with persist-credentials: false - Fix CI Python version mismatch (3.12 -> 3.14) and ruff target-version - Add vitest 4.0.18 with jsdom environment for frontend testing - Add ty 0.0.17 for Python type checking (non-blocking in CI) - Add actionlint and zizmor CI job for workflow linting and security audit - Add Dependabot config for npm, pip, and github-actions - Update CLAUDE.md and pre-commit hooks to reflect new tooling - Ignore Claude Code sandbox artifacts in gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-2zc9
|
||||||
|
title: Add zizmor and actionlint to CI
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-16T19:13:50Z
|
||||||
|
updated_at: 2026-02-16T19:26:23Z
|
||||||
|
parent: nuzlocke-tracker-a5es
|
||||||
|
---
|
||||||
|
|
||||||
|
Global standards require scanning GitHub Actions workflows with zizmor (security audit) and actionlint (linter).
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Add actionlint check to CI
|
||||||
|
- [ ] Add zizmor scan to CI
|
||||||
|
- [ ] Fix any issues found
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-44ps
|
||||||
|
title: Replace ESLint/Prettier with oxlint/oxfmt
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-16T19:13:49Z
|
||||||
|
updated_at: 2026-02-16T19:24:15Z
|
||||||
|
parent: nuzlocke-tracker-a5es
|
||||||
|
---
|
||||||
|
|
||||||
|
Global standards require oxlint and oxfmt instead of ESLint and Prettier. This involves:
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Install oxlint as devDependency
|
||||||
|
- [ ] Configure oxlint with typescript, import, unicorn plugins
|
||||||
|
- [ ] Install oxfmt as devDependency (or use oxlint --fix for formatting)
|
||||||
|
- [ ] Remove ESLint and all ESLint plugins/configs
|
||||||
|
- [ ] Remove Prettier and eslint-config-prettier
|
||||||
|
- [ ] Update package.json scripts
|
||||||
|
- [ ] Update pre-commit hooks
|
||||||
|
- [ ] Update CI workflow
|
||||||
|
|
||||||
|
Note: oxfmt may not be stable yet — check current status before proceeding.
|
||||||
18
.beans/nuzlocke-tracker-9vny--add-dependabot-config.md
Normal file
18
.beans/nuzlocke-tracker-9vny--add-dependabot-config.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-9vny
|
||||||
|
title: Add Dependabot config
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-16T19:13:50Z
|
||||||
|
updated_at: 2026-02-16T19:24:15Z
|
||||||
|
parent: nuzlocke-tracker-a5es
|
||||||
|
---
|
||||||
|
|
||||||
|
No .github/dependabot.yml exists. Global standards require Dependabot with 7-day cooldowns and grouped updates.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Create .github/dependabot.yml
|
||||||
|
- [ ] Configure for npm (frontend), pip (backend), and github-actions
|
||||||
|
- [ ] Set 7-day schedule intervals
|
||||||
|
- [ ] Group minor/patch updates
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-a5es
|
||||||
|
title: Align repo config with global dev standards
|
||||||
|
status: completed
|
||||||
|
type: epic
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-16T19:13:24Z
|
||||||
|
updated_at: 2026-02-16T19:26:23Z
|
||||||
|
---
|
||||||
|
|
||||||
|
Audit found multiple deviations from the global CLAUDE.md development standards. This epic tracks all the fixes needed.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-ecij
|
||||||
|
title: Add vitest for frontend testing
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-16T19:13:50Z
|
||||||
|
updated_at: 2026-02-16T19:26:23Z
|
||||||
|
parent: nuzlocke-tracker-a5es
|
||||||
|
---
|
||||||
|
|
||||||
|
No frontend test runner is configured. Global standards require vitest.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Install vitest as devDependency
|
||||||
|
- [ ] Create vitest.config.ts
|
||||||
|
- [ ] Add test script to package.json
|
||||||
|
- [ ] Add test step to CI workflow
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-mn8d
|
||||||
|
title: 'Pin GitHub Actions to SHA hashes and add persist-credentials: false'
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-16T19:13:49Z
|
||||||
|
updated_at: 2026-02-16T19:24:15Z
|
||||||
|
parent: nuzlocke-tracker-a5es
|
||||||
|
---
|
||||||
|
|
||||||
|
All GitHub Actions use tag references (@v4, @v5) instead of SHA hashes with version comments. Also missing persist-credentials: false on checkout steps.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Pin actions/checkout to SHA with version comment
|
||||||
|
- [ ] Pin actions/setup-python to SHA with version comment
|
||||||
|
- [ ] Pin actions/setup-node to SHA with version comment
|
||||||
|
- [ ] Add persist-credentials: false to all checkout steps
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-o1ek
|
||||||
|
title: Fix CI Python version mismatch and ruff target-version
|
||||||
|
status: completed
|
||||||
|
type: bug
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-16T19:13:49Z
|
||||||
|
updated_at: 2026-02-16T19:24:15Z
|
||||||
|
parent: nuzlocke-tracker-a5es
|
||||||
|
---
|
||||||
|
|
||||||
|
CI uses python-version 3.12 but .tool-versions and Docker use 3.14. Also, ruff target-version in pyproject.toml is py312 but should be py314.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Update ci.yml python-version from 3.12 to 3.14
|
||||||
|
- [ ] Update pyproject.toml ruff target-version from py312 to py314
|
||||||
|
- [ ] Update requires-python to >=3.14
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-sqb9
|
||||||
|
title: Pin frontend dependencies to exact versions
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-16T19:13:49Z
|
||||||
|
updated_at: 2026-02-16T19:24:15Z
|
||||||
|
parent: nuzlocke-tracker-a5es
|
||||||
|
---
|
||||||
|
|
||||||
|
All frontend dependencies in package.json use ^ or ~ ranges. Global standards require exact pinning (no ^ or ~).
|
||||||
|
|
||||||
|
Pin all dependencies and devDependencies to their currently installed exact versions.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-w5vu
|
||||||
|
title: Pin backend Python dependencies to exact versions
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-16T19:13:49Z
|
||||||
|
updated_at: 2026-02-16T19:24:15Z
|
||||||
|
parent: nuzlocke-tracker-a5es
|
||||||
|
---
|
||||||
|
|
||||||
|
All backend dependencies in pyproject.toml use >= ranges. Global standards require exact pins (== not >=).
|
||||||
|
|
||||||
|
Pin all dependencies and dev dependencies to their currently installed exact versions.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-yyce
|
||||||
|
title: Add ty for Python type checking
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-16T19:13:50Z
|
||||||
|
updated_at: 2026-02-16T19:26:23Z
|
||||||
|
parent: nuzlocke-tracker-a5es
|
||||||
|
---
|
||||||
|
|
||||||
|
Global standards require ty check for static type analysis. Currently not configured.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Add ty to dev dependencies
|
||||||
|
- [ ] Configure ty rules in pyproject.toml
|
||||||
|
- [ ] Add ty check step to CI workflow
|
||||||
|
- [ ] Fix any type errors surfaced
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-zom1
|
||||||
|
title: Add missing tsconfig strictness flags
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-16T19:13:49Z
|
||||||
|
updated_at: 2026-02-16T19:24:15Z
|
||||||
|
parent: nuzlocke-tracker-a5es
|
||||||
|
---
|
||||||
|
|
||||||
|
tsconfig.app.json is missing 4 required strict flags from the global standards:
|
||||||
|
|
||||||
|
- noUncheckedIndexedAccess: true
|
||||||
|
- exactOptionalPropertyTypes: true
|
||||||
|
- noImplicitOverride: true
|
||||||
|
- noPropertyAccessFromIndexSignature: true
|
||||||
|
|
||||||
|
These need to be added and any resulting type errors fixed.
|
||||||
31
.github/dependabot.yml
vendored
Normal file
31
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/frontend"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
|
||||||
|
- package-ecosystem: "pip"
|
||||||
|
directory: "/backend"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
@@ -22,21 +22,45 @@ jobs:
|
|||||||
backend-lint:
|
backend-lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
persist-credentials: false
|
||||||
- run: pip install ruff
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
|
with:
|
||||||
|
python-version: "3.14"
|
||||||
|
- run: pip install ruff ty
|
||||||
- name: Check linting
|
- name: Check linting
|
||||||
run: ruff check backend/
|
run: ruff check backend/
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: ruff format --check backend/
|
run: ruff format --check backend/
|
||||||
|
- name: Type check
|
||||||
|
run: ty check backend/src/
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
actions-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Install actionlint
|
||||||
|
run: |
|
||||||
|
curl -sL https://github.com/rhysd/actionlint/releases/latest/download/actionlint_linux_amd64.tar.gz | tar xz
|
||||||
|
sudo mv actionlint /usr/local/bin/
|
||||||
|
- name: Lint GitHub Actions
|
||||||
|
run: actionlint
|
||||||
|
- name: Install zizmor
|
||||||
|
run: pip install zizmor
|
||||||
|
- name: Audit GitHub Actions security
|
||||||
|
run: zizmor .github/workflows/
|
||||||
|
|
||||||
frontend-lint:
|
frontend-lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
- uses: actions/setup-node@v4
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -46,7 +70,7 @@ jobs:
|
|||||||
run: npm run lint
|
run: npm run lint
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: npx prettier --check "src/**/*.{ts,tsx,css,json}"
|
run: npx oxfmt --check "src/"
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npx tsc -b
|
run: npx tsc -b
|
||||||
|
|||||||
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -8,7 +8,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Login to Gitea registry
|
- name: Login to Gitea registry
|
||||||
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login gitea.nerdboden.de -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login gitea.nerdboden.de -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||||
|
|||||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -67,3 +67,28 @@ temp/
|
|||||||
|
|
||||||
# Local config overrides
|
# Local config overrides
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Claude Code sandbox artifacts
|
||||||
|
/HEAD
|
||||||
|
/config
|
||||||
|
/hooks
|
||||||
|
/objects
|
||||||
|
/refs
|
||||||
|
/frontend/HEAD
|
||||||
|
/frontend/config
|
||||||
|
/frontend/hooks
|
||||||
|
/frontend/objects
|
||||||
|
/frontend/refs
|
||||||
|
/.gitmodules
|
||||||
|
/.gitconfig
|
||||||
|
/.bash_profile
|
||||||
|
/.bashrc
|
||||||
|
/.profile
|
||||||
|
/.zprofile
|
||||||
|
/.zshrc
|
||||||
|
/.ripgreprc
|
||||||
|
/.mcp.json
|
||||||
|
/.claude/agents
|
||||||
|
/.claude/commands
|
||||||
|
/.claude/skills
|
||||||
|
/frontend/.claude/
|
||||||
|
|||||||
@@ -12,18 +12,18 @@ repos:
|
|||||||
# Frontend (TypeScript/React) — local hooks using project node_modules
|
# Frontend (TypeScript/React) — local hooks using project node_modules
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: eslint
|
- id: oxlint
|
||||||
name: eslint
|
name: oxlint
|
||||||
entry: npx eslint
|
entry: npx oxlint -c frontend/.oxlintrc.json
|
||||||
language: system
|
language: system
|
||||||
files: ^frontend/src/.*\.(ts|tsx)$
|
files: ^frontend/src/.*\.(ts|tsx)$
|
||||||
pass_filenames: true
|
pass_filenames: true
|
||||||
|
|
||||||
- id: prettier
|
- id: oxfmt
|
||||||
name: prettier
|
name: oxfmt
|
||||||
entry: npx prettier --check
|
entry: npx oxfmt --check --config frontend/.oxfmtrc.json
|
||||||
language: system
|
language: system
|
||||||
files: ^frontend/src/.*\.(ts|tsx|css|json)$
|
files: ^frontend/src/.*\.(ts|tsx)$
|
||||||
pass_filenames: true
|
pass_filenames: true
|
||||||
|
|
||||||
- id: tsc
|
- id: tsc
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ This project uses [pre-commit](https://pre-commit.com/) to run linting and forma
|
|||||||
|
|
||||||
**Hooks configured:**
|
**Hooks configured:**
|
||||||
- **Backend:** `ruff check --fix` and `ruff format` on Python files under `backend/`
|
- **Backend:** `ruff check --fix` and `ruff format` on Python files under `backend/`
|
||||||
- **Frontend:** `eslint`, `prettier --check`, and `tsc -b` on files under `frontend/`
|
- **Frontend:** `oxlint`, `oxfmt --check`, and `tsc -b` on files under `frontend/`
|
||||||
|
|
||||||
Frontend hooks require `npm ci` in `frontend/` first (they use `npx` to run from local `node_modules`).
|
Frontend hooks require `npm ci` in `frontend/` first (they use `npx` to run from local `node_modules`).
|
||||||
|
|
||||||
|
|||||||
@@ -3,25 +3,26 @@ name = "nuzlocke-tracker-api"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Backend API for Nuzlocke Tracker"
|
description = "Backend API for Nuzlocke Tracker"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115.0",
|
"fastapi==0.128.4",
|
||||||
"uvicorn[standard]>=0.34.0",
|
"uvicorn[standard]==0.40.0",
|
||||||
"pydantic>=2.10.0",
|
"pydantic==2.12.5",
|
||||||
"pydantic-settings>=2.7.0",
|
"pydantic-settings==2.12.0",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv==1.2.1",
|
||||||
"sqlalchemy[asyncio]>=2.0.0",
|
"sqlalchemy[asyncio]==2.0.46",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg==0.31.0",
|
||||||
"alembic>=1.14.0",
|
"alembic==1.18.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff>=0.9.0",
|
"ruff==0.15.0",
|
||||||
"pre-commit>=4.0.0",
|
"ty==0.0.17",
|
||||||
"pytest>=8.0.0",
|
"pre-commit==4.5.1",
|
||||||
"pytest-asyncio>=0.25.0",
|
"pytest==9.0.2",
|
||||||
"httpx>=0.28.0",
|
"pytest-asyncio==1.3.0",
|
||||||
|
"httpx==0.28.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
@@ -32,7 +33,7 @@ build-backend = "hatchling.build"
|
|||||||
packages = ["src/app"]
|
packages = ["src/app"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py312"
|
target-version = "py314"
|
||||||
line-length = 88
|
line-length = 88
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
@@ -57,6 +58,12 @@ ignore = [
|
|||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = ["app"]
|
known-first-party = ["app"]
|
||||||
|
|
||||||
|
[tool.ty.environment]
|
||||||
|
python-version = "3.14"
|
||||||
|
|
||||||
|
[tool.ty.src]
|
||||||
|
root = "src"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
9
frontend/.oxlintrc.json
Normal file
9
frontend/.oxlintrc.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"plugins": ["typescript", "import", "unicorn", "react"],
|
||||||
|
"rules": {
|
||||||
|
"react/exhaustive-deps": "warn",
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"unicorn/no-null": "off"
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["dist"]
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
reactHooks.configs.flat.recommended,
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
eslintConfigPrettier,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'react-refresh/only-export-components': [
|
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
'react-hooks/set-state-in-effect': 'off',
|
|
||||||
'react-hooks/preserve-manual-memoization': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
2423
frontend/package-lock.json
generated
2423
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,35 +6,34 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "oxlint src/",
|
||||||
"preview": "vite preview"
|
"format": "oxfmt --write src/",
|
||||||
|
"format:check": "oxfmt --check src/",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@tanstack/react-query": "^5.90.20",
|
"@tanstack/react-query": "5.90.20",
|
||||||
"react": "^19.2.0",
|
"react": "19.2.4",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "19.2.4",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "7.13.0",
|
||||||
"sonner": "^2.0.7"
|
"sonner": "2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@tailwindcss/vite": "4.1.18",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@types/node": "24.10.10",
|
||||||
"@types/node": "^24.10.1",
|
"@types/react": "19.2.11",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@vitejs/plugin-react": "5.1.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"oxfmt": "0.33.0",
|
||||||
"eslint": "^9.39.1",
|
"oxlint": "1.48.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"tailwindcss": "4.1.18",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"typescript": "5.9.3",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"vite": "7.3.1",
|
||||||
"globals": "^16.5.0",
|
"vitest": "4.0.18"
|
||||||
"prettier": "^3.8.1",
|
|
||||||
"tailwindcss": "^4.1.18",
|
|
||||||
"typescript": "~5.9.3",
|
|
||||||
"typescript-eslint": "^8.46.4",
|
|
||||||
"vite": "^7.2.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,18 +42,12 @@ function App() {
|
|||||||
<Route index element={<Navigate to="/admin/games" replace />} />
|
<Route index element={<Navigate to="/admin/games" replace />} />
|
||||||
<Route path="games" element={<AdminGames />} />
|
<Route path="games" element={<AdminGames />} />
|
||||||
<Route path="games/:gameId" element={<AdminGameDetail />} />
|
<Route path="games/:gameId" element={<AdminGameDetail />} />
|
||||||
<Route
|
<Route path="games/:gameId/routes/:routeId" element={<AdminRouteDetail />} />
|
||||||
path="games/:gameId/routes/:routeId"
|
|
||||||
element={<AdminRouteDetail />}
|
|
||||||
/>
|
|
||||||
<Route path="pokemon" element={<AdminPokemon />} />
|
<Route path="pokemon" element={<AdminPokemon />} />
|
||||||
<Route path="evolutions" element={<AdminEvolutions />} />
|
<Route path="evolutions" element={<AdminEvolutions />} />
|
||||||
<Route path="runs" element={<AdminRuns />} />
|
<Route path="runs" element={<AdminRuns />} />
|
||||||
<Route path="genlockes" element={<AdminGenlockes />} />
|
<Route path="genlockes" element={<AdminGenlockes />} />
|
||||||
<Route
|
<Route path="genlockes/:genlockeId" element={<AdminGenlockeDetail />} />
|
||||||
path="genlockes/:genlockeId"
|
|
||||||
element={<AdminGenlockeDetail />}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -30,11 +30,9 @@ import type {
|
|||||||
import type { Genlocke } from '../types/game'
|
import type { Genlocke } from '../types/game'
|
||||||
|
|
||||||
// Games
|
// Games
|
||||||
export const createGame = (data: CreateGameInput) =>
|
export const createGame = (data: CreateGameInput) => api.post<Game>('/games', data)
|
||||||
api.post<Game>('/games', data)
|
|
||||||
|
|
||||||
export const updateGame = (id: number, data: UpdateGameInput) =>
|
export const updateGame = (id: number, data: UpdateGameInput) => api.put<Game>(`/games/${id}`, data)
|
||||||
api.put<Game>(`/games/${id}`, data)
|
|
||||||
|
|
||||||
export const deleteGame = (id: number) => api.del(`/games/${id}`)
|
export const deleteGame = (id: number) => api.del(`/games/${id}`)
|
||||||
|
|
||||||
@@ -42,11 +40,8 @@ export const deleteGame = (id: number) => api.del(`/games/${id}`)
|
|||||||
export const createRoute = (gameId: number, data: CreateRouteInput) =>
|
export const createRoute = (gameId: number, data: CreateRouteInput) =>
|
||||||
api.post<Route>(`/games/${gameId}/routes`, data)
|
api.post<Route>(`/games/${gameId}/routes`, data)
|
||||||
|
|
||||||
export const updateRoute = (
|
export const updateRoute = (gameId: number, routeId: number, data: UpdateRouteInput) =>
|
||||||
gameId: number,
|
api.put<Route>(`/games/${gameId}/routes/${routeId}`, data)
|
||||||
routeId: number,
|
|
||||||
data: UpdateRouteInput
|
|
||||||
) => api.put<Route>(`/games/${gameId}/routes/${routeId}`, data)
|
|
||||||
|
|
||||||
export const deleteRoute = (gameId: number, routeId: number) =>
|
export const deleteRoute = (gameId: number, routeId: number) =>
|
||||||
api.del(`/games/${gameId}/routes/${routeId}`)
|
api.del(`/games/${gameId}/routes/${routeId}`)
|
||||||
@@ -55,12 +50,7 @@ export const reorderRoutes = (gameId: number, routes: RouteReorderItem[]) =>
|
|||||||
api.put<Route[]>(`/games/${gameId}/routes/reorder`, { routes })
|
api.put<Route[]>(`/games/${gameId}/routes/reorder`, { routes })
|
||||||
|
|
||||||
// Pokemon
|
// Pokemon
|
||||||
export const listPokemon = (
|
export const listPokemon = (search?: string, limit = 50, offset = 0, type?: string) => {
|
||||||
search?: string,
|
|
||||||
limit = 50,
|
|
||||||
offset = 0,
|
|
||||||
type?: string
|
|
||||||
) => {
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (search) params.set('search', search)
|
if (search) params.set('search', search)
|
||||||
if (type) params.set('type', type)
|
if (type) params.set('type', type)
|
||||||
@@ -69,8 +59,7 @@ export const listPokemon = (
|
|||||||
return api.get<PaginatedPokemon>(`/pokemon?${params}`)
|
return api.get<PaginatedPokemon>(`/pokemon?${params}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createPokemon = (data: CreatePokemonInput) =>
|
export const createPokemon = (data: CreatePokemonInput) => api.post<Pokemon>('/pokemon', data)
|
||||||
api.post<Pokemon>('/pokemon', data)
|
|
||||||
|
|
||||||
export const updatePokemon = (id: number, data: UpdatePokemonInput) =>
|
export const updatePokemon = (id: number, data: UpdatePokemonInput) =>
|
||||||
api.put<Pokemon>(`/pokemon/${id}`, data)
|
api.put<Pokemon>(`/pokemon/${id}`, data)
|
||||||
@@ -97,12 +86,7 @@ export const bulkImportBosses = (gameId: number, items: unknown[]) =>
|
|||||||
api.post<BulkImportResult>(`/games/${gameId}/bosses/bulk-import`, items)
|
api.post<BulkImportResult>(`/games/${gameId}/bosses/bulk-import`, items)
|
||||||
|
|
||||||
// Evolutions
|
// Evolutions
|
||||||
export const listEvolutions = (
|
export const listEvolutions = (search?: string, limit = 50, offset = 0, trigger?: string) => {
|
||||||
search?: string,
|
|
||||||
limit = 50,
|
|
||||||
offset = 0,
|
|
||||||
trigger?: string
|
|
||||||
) => {
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (search) params.set('search', search)
|
if (search) params.set('search', search)
|
||||||
if (trigger) params.set('trigger', trigger)
|
if (trigger) params.set('trigger', trigger)
|
||||||
@@ -120,8 +104,7 @@ export const updateEvolution = (id: number, data: UpdateEvolutionInput) =>
|
|||||||
export const deleteEvolution = (id: number) => api.del(`/evolutions/${id}`)
|
export const deleteEvolution = (id: number) => api.del(`/evolutions/${id}`)
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
export const exportGames = () =>
|
export const exportGames = () => api.get<Record<string, unknown>[]>('/export/games')
|
||||||
api.get<Record<string, unknown>[]>('/export/games')
|
|
||||||
|
|
||||||
export const exportGameRoutes = (gameId: number) =>
|
export const exportGameRoutes = (gameId: number) =>
|
||||||
api.get<{ filename: string; data: unknown }>(`/export/games/${gameId}/routes`)
|
api.get<{ filename: string; data: unknown }>(`/export/games/${gameId}/routes`)
|
||||||
@@ -129,27 +112,19 @@ export const exportGameRoutes = (gameId: number) =>
|
|||||||
export const exportGameBosses = (gameId: number) =>
|
export const exportGameBosses = (gameId: number) =>
|
||||||
api.get<{ filename: string; data: unknown }>(`/export/games/${gameId}/bosses`)
|
api.get<{ filename: string; data: unknown }>(`/export/games/${gameId}/bosses`)
|
||||||
|
|
||||||
export const exportPokemon = () =>
|
export const exportPokemon = () => api.get<Record<string, unknown>[]>('/export/pokemon')
|
||||||
api.get<Record<string, unknown>[]>('/export/pokemon')
|
|
||||||
|
|
||||||
export const exportEvolutions = () =>
|
export const exportEvolutions = () => api.get<Record<string, unknown>[]>('/export/evolutions')
|
||||||
api.get<Record<string, unknown>[]>('/export/evolutions')
|
|
||||||
|
|
||||||
// Route Encounters
|
// Route Encounters
|
||||||
export const addRouteEncounter = (
|
export const addRouteEncounter = (routeId: number, data: CreateRouteEncounterInput) =>
|
||||||
routeId: number,
|
api.post<RouteEncounterDetail>(`/routes/${routeId}/pokemon`, data)
|
||||||
data: CreateRouteEncounterInput
|
|
||||||
) => api.post<RouteEncounterDetail>(`/routes/${routeId}/pokemon`, data)
|
|
||||||
|
|
||||||
export const updateRouteEncounter = (
|
export const updateRouteEncounter = (
|
||||||
routeId: number,
|
routeId: number,
|
||||||
encounterId: number,
|
encounterId: number,
|
||||||
data: UpdateRouteEncounterInput
|
data: UpdateRouteEncounterInput
|
||||||
) =>
|
) => api.put<RouteEncounterDetail>(`/routes/${routeId}/pokemon/${encounterId}`, data)
|
||||||
api.put<RouteEncounterDetail>(
|
|
||||||
`/routes/${routeId}/pokemon/${encounterId}`,
|
|
||||||
data
|
|
||||||
)
|
|
||||||
|
|
||||||
export const removeRouteEncounter = (routeId: number, encounterId: number) =>
|
export const removeRouteEncounter = (routeId: number, encounterId: number) =>
|
||||||
api.del(`/routes/${routeId}/pokemon/${encounterId}`)
|
api.del(`/routes/${routeId}/pokemon/${encounterId}`)
|
||||||
@@ -158,11 +133,8 @@ export const removeRouteEncounter = (routeId: number, encounterId: number) =>
|
|||||||
export const createBossBattle = (gameId: number, data: CreateBossBattleInput) =>
|
export const createBossBattle = (gameId: number, data: CreateBossBattleInput) =>
|
||||||
api.post<BossBattle>(`/games/${gameId}/bosses`, data)
|
api.post<BossBattle>(`/games/${gameId}/bosses`, data)
|
||||||
|
|
||||||
export const updateBossBattle = (
|
export const updateBossBattle = (gameId: number, bossId: number, data: UpdateBossBattleInput) =>
|
||||||
gameId: number,
|
api.put<BossBattle>(`/games/${gameId}/bosses/${bossId}`, data)
|
||||||
bossId: number,
|
|
||||||
data: UpdateBossBattleInput
|
|
||||||
) => api.put<BossBattle>(`/games/${gameId}/bosses/${bossId}`, data)
|
|
||||||
|
|
||||||
export const deleteBossBattle = (gameId: number, bossId: number) =>
|
export const deleteBossBattle = (gameId: number, bossId: number) =>
|
||||||
api.del(`/games/${gameId}/bosses/${bossId}`)
|
api.del(`/games/${gameId}/bosses/${bossId}`)
|
||||||
@@ -170,11 +142,8 @@ export const deleteBossBattle = (gameId: number, bossId: number) =>
|
|||||||
export const reorderBosses = (gameId: number, bosses: BossReorderItem[]) =>
|
export const reorderBosses = (gameId: number, bosses: BossReorderItem[]) =>
|
||||||
api.put<BossBattle[]>(`/games/${gameId}/bosses/reorder`, { bosses })
|
api.put<BossBattle[]>(`/games/${gameId}/bosses/reorder`, { bosses })
|
||||||
|
|
||||||
export const setBossTeam = (
|
export const setBossTeam = (gameId: number, bossId: number, team: BossPokemonInput[]) =>
|
||||||
gameId: number,
|
api.put<BossBattle>(`/games/${gameId}/bosses/${bossId}/pokemon`, team)
|
||||||
bossId: number,
|
|
||||||
team: BossPokemonInput[]
|
|
||||||
) => api.put<BossBattle>(`/games/${gameId}/bosses/${bossId}/pokemon`, team)
|
|
||||||
|
|
||||||
// Genlockes
|
// Genlockes
|
||||||
export const updateGenlocke = (id: number, data: UpdateGenlockeInput) =>
|
export const updateGenlocke = (id: number, data: UpdateGenlockeInput) =>
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
import type {
|
import type { BossBattle, BossResult, CreateBossResultInput } from '../types/game'
|
||||||
BossBattle,
|
|
||||||
BossResult,
|
|
||||||
CreateBossResultInput,
|
|
||||||
} from '../types/game'
|
|
||||||
|
|
||||||
export function getGameBosses(
|
export function getGameBosses(gameId: number, all?: boolean): Promise<BossBattle[]> {
|
||||||
gameId: number,
|
|
||||||
all?: boolean
|
|
||||||
): Promise<BossBattle[]> {
|
|
||||||
const params = all ? '?all=true' : ''
|
const params = all ? '?all=true' : ''
|
||||||
return api.get(`/games/${gameId}/bosses${params}`)
|
return api.get(`/games/${gameId}/bosses${params}`)
|
||||||
}
|
}
|
||||||
@@ -17,16 +10,10 @@ export function getBossResults(runId: number): Promise<BossResult[]> {
|
|||||||
return api.get(`/runs/${runId}/boss-results`)
|
return api.get(`/runs/${runId}/boss-results`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBossResult(
|
export function createBossResult(runId: number, data: CreateBossResultInput): Promise<BossResult> {
|
||||||
runId: number,
|
|
||||||
data: CreateBossResultInput
|
|
||||||
): Promise<BossResult> {
|
|
||||||
return api.post(`/runs/${runId}/boss-results`, data)
|
return api.post(`/runs/${runId}/boss-results`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteBossResult(
|
export function deleteBossResult(runId: number, resultId: number): Promise<void> {
|
||||||
runId: number,
|
|
||||||
resultId: number
|
|
||||||
): Promise<void> {
|
|
||||||
return api.del(`/runs/${runId}/boss-results/${resultId}`)
|
return api.del(`/runs/${runId}/boss-results/${resultId}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
const API_BASE = import.meta.env['VITE_API_URL'] ?? ''
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number
|
status: number
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ export function createEncounter(
|
|||||||
return api.post(`/runs/${runId}/encounters`, data)
|
return api.post(`/runs/${runId}/encounters`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateEncounter(
|
export function updateEncounter(id: number, data: UpdateEncounterInput): Promise<EncounterDetail> {
|
||||||
id: number,
|
|
||||||
data: UpdateEncounterInput
|
|
||||||
): Promise<EncounterDetail> {
|
|
||||||
return api.patch(`/encounters/${id}`, data)
|
return api.patch(`/encounters/${id}`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,10 +22,7 @@ export function deleteEncounter(id: number): Promise<void> {
|
|||||||
return api.del(`/encounters/${id}`)
|
return api.del(`/encounters/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchEvolutions(
|
export function fetchEvolutions(pokemonId: number, region?: string): Promise<Evolution[]> {
|
||||||
pokemonId: number,
|
|
||||||
region?: string
|
|
||||||
): Promise<Evolution[]> {
|
|
||||||
const params = region ? `?region=${encodeURIComponent(region)}` : ''
|
const params = region ? `?region=${encodeURIComponent(region)}` : ''
|
||||||
return api.get(`/pokemon/${pokemonId}/evolutions${params}`)
|
return api.get(`/pokemon/${pokemonId}/evolutions${params}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ export function getGameRoutes(gameId: number): Promise<Route[]> {
|
|||||||
return api.get(`/games/${gameId}/routes?flat=true`)
|
return api.get(`/games/${gameId}/routes?flat=true`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRoutePokemon(
|
export function getRoutePokemon(routeId: number, gameId?: number): Promise<RouteEncounterDetail[]> {
|
||||||
routeId: number,
|
|
||||||
gameId?: number
|
|
||||||
): Promise<RouteEncounterDetail[]> {
|
|
||||||
const params = gameId != null ? `?game_id=${gameId}` : ''
|
const params = gameId != null ? `?game_id=${gameId}` : ''
|
||||||
return api.get(`/routes/${routeId}/pokemon${params}`)
|
return api.get(`/routes/${routeId}/pokemon${params}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,5 @@ export function advanceLeg(
|
|||||||
legOrder: number,
|
legOrder: number,
|
||||||
data?: AdvanceLegInput
|
data?: AdvanceLegInput
|
||||||
): Promise<Genlocke> {
|
): Promise<Genlocke> {
|
||||||
return api.post(
|
return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, data ?? {})
|
||||||
`/genlockes/${genlockeId}/legs/${legOrder}/advance`,
|
|
||||||
data ?? {}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export function fetchPokemonEncounterLocations(
|
|||||||
return api.get(`/pokemon/${pokemonId}/encounter-locations`)
|
return api.get(`/pokemon/${pokemonId}/encounter-locations`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchPokemonEvolutionChain(
|
export function fetchPokemonEvolutionChain(pokemonId: number): Promise<EvolutionAdmin[]> {
|
||||||
pokemonId: number
|
|
||||||
): Promise<EvolutionAdmin[]> {
|
|
||||||
return api.get(`/pokemon/${pokemonId}/evolution-chain`)
|
return api.get(`/pokemon/${pokemonId}/evolution-chain`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
import type {
|
import type { NuzlockeRun, RunDetail, CreateRunInput, UpdateRunInput } from '../types/game'
|
||||||
NuzlockeRun,
|
|
||||||
RunDetail,
|
|
||||||
CreateRunInput,
|
|
||||||
UpdateRunInput,
|
|
||||||
} from '../types/game'
|
|
||||||
|
|
||||||
export function getRuns(): Promise<NuzlockeRun[]> {
|
export function getRuns(): Promise<NuzlockeRun[]> {
|
||||||
return api.get('/runs')
|
return api.get('/runs')
|
||||||
@@ -18,10 +13,7 @@ export function createRun(data: CreateRunInput): Promise<NuzlockeRun> {
|
|||||||
return api.post('/runs', data)
|
return api.post('/runs', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateRun(
|
export function updateRun(id: number, data: UpdateRunInput): Promise<NuzlockeRun> {
|
||||||
id: number,
|
|
||||||
data: UpdateRunInput
|
|
||||||
): Promise<NuzlockeRun> {
|
|
||||||
return api.patch(`/runs/${id}`, data)
|
return api.patch(`/runs/${id}`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,11 @@ interface BossDefeatModalProps {
|
|||||||
starterName?: string | null
|
starterName?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchVariant(
|
function matchVariant(labels: string[], starterName?: string | null): string | null {
|
||||||
labels: string[],
|
|
||||||
starterName?: string | null
|
|
||||||
): string | null {
|
|
||||||
if (!starterName || labels.length === 0) return null
|
if (!starterName || labels.length === 0) return null
|
||||||
const lower = starterName.toLowerCase()
|
const lower = starterName.toLowerCase()
|
||||||
const matches = labels.filter((l) => l.toLowerCase().includes(lower))
|
const matches = labels.filter((l) => l.toLowerCase().includes(lower))
|
||||||
return matches.length === 1 ? matches[0] : null
|
return matches.length === 1 ? (matches[0] ?? null) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BossDefeatModal({
|
export function BossDefeatModal({
|
||||||
@@ -46,14 +43,13 @@ export function BossDefeatModal({
|
|||||||
)
|
)
|
||||||
const showPills = hasVariants && autoMatch === null
|
const showPills = hasVariants && autoMatch === null
|
||||||
const [selectedVariant, setSelectedVariant] = useState<string | null>(
|
const [selectedVariant, setSelectedVariant] = useState<string | null>(
|
||||||
autoMatch ?? (hasVariants ? variantLabels[0] : null)
|
autoMatch ?? (hasVariants ? (variantLabels[0] ?? null) : null)
|
||||||
)
|
)
|
||||||
|
|
||||||
const displayedPokemon = useMemo(() => {
|
const displayedPokemon = useMemo(() => {
|
||||||
if (!hasVariants) return boss.pokemon
|
if (!hasVariants) return boss.pokemon
|
||||||
return boss.pokemon.filter(
|
return boss.pokemon.filter(
|
||||||
(bp) =>
|
(bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null
|
||||||
bp.conditionLabel === selectedVariant || bp.conditionLabel === null
|
|
||||||
)
|
)
|
||||||
}, [boss.pokemon, hasVariants, selectedVariant])
|
}, [boss.pokemon, hasVariants, selectedVariant])
|
||||||
|
|
||||||
@@ -72,9 +68,7 @@ export function BossDefeatModal({
|
|||||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold">Battle: {boss.name}</h2>
|
<h2 className="text-lg font-semibold">Battle: {boss.name}</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">{boss.location}</p>
|
||||||
{boss.location}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Boss team preview */}
|
{/* Boss team preview */}
|
||||||
@@ -104,11 +98,7 @@ export function BossDefeatModal({
|
|||||||
.map((bp) => (
|
.map((bp) => (
|
||||||
<div key={bp.id} className="flex flex-col items-center">
|
<div key={bp.id} className="flex flex-col items-center">
|
||||||
{bp.pokemon.spriteUrl ? (
|
{bp.pokemon.spriteUrl ? (
|
||||||
<img
|
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
|
||||||
src={bp.pokemon.spriteUrl}
|
|
||||||
alt={bp.pokemon.name}
|
|
||||||
className="w-10 h-10"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||||
)}
|
)}
|
||||||
@@ -158,9 +148,7 @@ export function BossDefeatModal({
|
|||||||
|
|
||||||
{!hardcoreMode && (
|
{!hardcoreMode && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">Attempts</label>
|
||||||
Attempts
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ interface EggEncounterModalProps {
|
|||||||
onSubmit: (data: {
|
onSubmit: (data: {
|
||||||
routeId: number
|
routeId: number
|
||||||
pokemonId: number
|
pokemonId: number
|
||||||
nickname?: string
|
nickname?: string | undefined
|
||||||
status: 'caught'
|
status: 'caught'
|
||||||
catchLevel?: number
|
catchLevel?: number | undefined
|
||||||
origin: 'egg'
|
origin: 'egg'
|
||||||
}) => void
|
}) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -87,12 +87,7 @@ export function EggEncounterModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
<svg
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -142,7 +137,7 @@ export function EggEncounterModal({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
||||||
{selectedPokemon.name[0].toUpperCase()}
|
{selectedPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100 capitalize">
|
<span className="font-medium text-gray-900 dark:text-gray-100 capitalize">
|
||||||
@@ -183,14 +178,10 @@ export function EggEncounterModal({
|
|||||||
className="flex flex-col items-center p-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 text-center transition-colors"
|
className="flex flex-col items-center p-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 text-center transition-colors"
|
||||||
>
|
>
|
||||||
{p.spriteUrl ? (
|
{p.spriteUrl ? (
|
||||||
<img
|
<img src={p.spriteUrl} alt={p.name} className="w-10 h-10" />
|
||||||
src={p.spriteUrl}
|
|
||||||
alt={p.name}
|
|
||||||
className="w-10 h-10"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
||||||
{p.name[0].toUpperCase()}
|
{p.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||||
@@ -200,12 +191,8 @@ export function EggEncounterModal({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{search.length >= 2 &&
|
{search.length >= 2 && !isSearching && searchResults.length === 0 && (
|
||||||
!isSearching &&
|
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">No pokemon found</p>
|
||||||
searchResults.length === 0 && (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">
|
|
||||||
No pokemon found
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
starter: {
|
starter: {
|
||||||
label: 'Starter',
|
label: 'Starter',
|
||||||
color:
|
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
|
|
||||||
},
|
},
|
||||||
gift: {
|
gift: {
|
||||||
label: 'Gift',
|
label: 'Gift',
|
||||||
@@ -10,18 +9,15 @@ export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
|||||||
},
|
},
|
||||||
fossil: {
|
fossil: {
|
||||||
label: 'Fossil',
|
label: 'Fossil',
|
||||||
color:
|
color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||||
'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
|
||||||
},
|
},
|
||||||
trade: {
|
trade: {
|
||||||
label: 'Trade',
|
label: 'Trade',
|
||||||
color:
|
color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||||
'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
|
||||||
},
|
},
|
||||||
walk: {
|
walk: {
|
||||||
label: 'Grass',
|
label: 'Grass',
|
||||||
color:
|
color: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
|
||||||
},
|
},
|
||||||
headbutt: {
|
headbutt: {
|
||||||
label: 'Headbutt',
|
label: 'Headbutt',
|
||||||
@@ -33,8 +29,7 @@ export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
|||||||
},
|
},
|
||||||
'rock-smash': {
|
'rock-smash': {
|
||||||
label: 'Rock Smash',
|
label: 'Rock Smash',
|
||||||
color:
|
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
|
||||||
'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
|
|
||||||
},
|
},
|
||||||
'old-rod': {
|
'old-rod': {
|
||||||
label: 'Old Rod',
|
label: 'Old Rod',
|
||||||
@@ -46,8 +41,7 @@ export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
|||||||
},
|
},
|
||||||
'super-rod': {
|
'super-rod': {
|
||||||
label: 'Super Rod',
|
label: 'Super Rod',
|
||||||
color:
|
color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||||
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +69,7 @@ export function getMethodLabel(method: string): string {
|
|||||||
|
|
||||||
export function getMethodColor(method: string): string {
|
export function getMethodColor(method: string): string {
|
||||||
return (
|
return (
|
||||||
METHOD_CONFIG[method]?.color ??
|
METHOD_CONFIG[method]?.color ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +82,9 @@ export function EncounterMethodBadge({
|
|||||||
}) {
|
}) {
|
||||||
const config = METHOD_CONFIG[method]
|
const config = METHOD_CONFIG[method]
|
||||||
if (!config) return null
|
if (!config) return null
|
||||||
const sizeClass =
|
const sizeClass = size === 'xs' ? 'text-[8px] px-1 py-0' : 'text-[9px] px-1.5 py-0.5'
|
||||||
size === 'xs' ? 'text-[8px] px-1 py-0' : 'text-[9px] px-1.5 py-0.5'
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}>
|
||||||
className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}
|
|
||||||
>
|
|
||||||
{config.label}
|
{config.label}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,43 +1,36 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useRoutePokemon } from '../hooks/useGames'
|
import { useRoutePokemon } from '../hooks/useGames'
|
||||||
import { useNameSuggestions } from '../hooks/useRuns'
|
import { useNameSuggestions } from '../hooks/useRuns'
|
||||||
import {
|
import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge'
|
||||||
EncounterMethodBadge,
|
import type { Route, EncounterDetail, EncounterStatus, RouteEncounterDetail } from '../types'
|
||||||
getMethodLabel,
|
|
||||||
METHOD_ORDER,
|
|
||||||
} from './EncounterMethodBadge'
|
|
||||||
import type {
|
|
||||||
Route,
|
|
||||||
EncounterDetail,
|
|
||||||
EncounterStatus,
|
|
||||||
RouteEncounterDetail,
|
|
||||||
} from '../types'
|
|
||||||
|
|
||||||
interface EncounterModalProps {
|
interface EncounterModalProps {
|
||||||
route: Route
|
route: Route
|
||||||
gameId: number
|
gameId: number
|
||||||
runId: number
|
runId: number
|
||||||
namingScheme?: string | null
|
namingScheme?: string | null | undefined
|
||||||
isGenlocke?: boolean
|
isGenlocke?: boolean | undefined
|
||||||
existing?: EncounterDetail
|
existing?: EncounterDetail | undefined
|
||||||
dupedPokemonIds?: Set<number>
|
dupedPokemonIds?: Set<number> | undefined
|
||||||
retiredPokemonIds?: Set<number>
|
retiredPokemonIds?: Set<number> | undefined
|
||||||
onSubmit: (data: {
|
onSubmit: (data: {
|
||||||
routeId: number
|
routeId: number
|
||||||
pokemonId: number
|
pokemonId: number
|
||||||
nickname?: string
|
nickname?: string | undefined
|
||||||
status: EncounterStatus
|
status: EncounterStatus
|
||||||
catchLevel?: number
|
catchLevel?: number | undefined
|
||||||
}) => void
|
}) => void
|
||||||
onUpdate?: (data: {
|
onUpdate?:
|
||||||
|
| ((data: {
|
||||||
id: number
|
id: number
|
||||||
data: {
|
data: {
|
||||||
nickname?: string
|
nickname?: string | undefined
|
||||||
status?: EncounterStatus
|
status?: EncounterStatus | undefined
|
||||||
faintLevel?: number
|
faintLevel?: number | undefined
|
||||||
deathCause?: string
|
deathCause?: string | undefined
|
||||||
}
|
}
|
||||||
}) => void
|
}) => void)
|
||||||
|
| undefined
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
isPending: boolean
|
isPending: boolean
|
||||||
}
|
}
|
||||||
@@ -91,11 +84,9 @@ function pickRandomPokemon(
|
|||||||
pokemon: RouteEncounterDetail[],
|
pokemon: RouteEncounterDetail[],
|
||||||
dupedIds?: Set<number>
|
dupedIds?: Set<number>
|
||||||
): RouteEncounterDetail | null {
|
): RouteEncounterDetail | null {
|
||||||
const eligible = dupedIds
|
const eligible = dupedIds ? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId)) : pokemon
|
||||||
? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId))
|
|
||||||
: pokemon
|
|
||||||
if (eligible.length === 0) return null
|
if (eligible.length === 0) return null
|
||||||
return eligible[Math.floor(Math.random() * eligible.length)]
|
return eligible[Math.floor(Math.random() * eligible.length)] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EncounterModal({
|
export function EncounterModal({
|
||||||
@@ -112,20 +103,12 @@ export function EncounterModal({
|
|||||||
onClose,
|
onClose,
|
||||||
isPending,
|
isPending,
|
||||||
}: EncounterModalProps) {
|
}: EncounterModalProps) {
|
||||||
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
|
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(route.id, gameId)
|
||||||
route.id,
|
|
||||||
gameId
|
|
||||||
)
|
|
||||||
|
|
||||||
const [selectedPokemon, setSelectedPokemon] =
|
const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null)
|
||||||
useState<RouteEncounterDetail | null>(null)
|
const [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught')
|
||||||
const [status, setStatus] = useState<EncounterStatus>(
|
|
||||||
existing?.status ?? 'caught'
|
|
||||||
)
|
|
||||||
const [nickname, setNickname] = useState(existing?.nickname ?? '')
|
const [nickname, setNickname] = useState(existing?.nickname ?? '')
|
||||||
const [catchLevel, setCatchLevel] = useState<string>(
|
const [catchLevel, setCatchLevel] = useState<string>(existing?.catchLevel?.toString() ?? '')
|
||||||
existing?.catchLevel?.toString() ?? ''
|
|
||||||
)
|
|
||||||
const [faintLevel, setFaintLevel] = useState<string>('')
|
const [faintLevel, setFaintLevel] = useState<string>('')
|
||||||
const [deathCause, setDeathCause] = useState('')
|
const [deathCause, setDeathCause] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -133,8 +116,7 @@ export function EncounterModal({
|
|||||||
const isEditing = !!existing
|
const isEditing = !!existing
|
||||||
|
|
||||||
const showSuggestions = !!namingScheme && status === 'caught' && !isEditing
|
const showSuggestions = !!namingScheme && status === 'caught' && !isEditing
|
||||||
const lineagePokemonId =
|
const lineagePokemonId = isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
|
||||||
isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
|
|
||||||
const {
|
const {
|
||||||
data: suggestions,
|
data: suggestions,
|
||||||
refetch: regenerate,
|
refetch: regenerate,
|
||||||
@@ -144,9 +126,7 @@ export function EncounterModal({
|
|||||||
// Pre-select pokemon when editing
|
// Pre-select pokemon when editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existing && routePokemon) {
|
if (existing && routePokemon) {
|
||||||
const match = routePokemon.find(
|
const match = routePokemon.find((rp) => rp.pokemonId === existing.pokemonId)
|
||||||
(rp) => rp.pokemonId === existing.pokemonId
|
|
||||||
)
|
|
||||||
if (match) setSelectedPokemon(match)
|
if (match) setSelectedPokemon(match)
|
||||||
}
|
}
|
||||||
}, [existing, routePokemon])
|
}, [existing, routePokemon])
|
||||||
@@ -198,12 +178,7 @@ export function EncounterModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
<svg
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -213,9 +188,7 @@ export function EncounterModal({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{route.name}</p>
|
||||||
{route.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 space-y-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
@@ -233,16 +206,12 @@ export function EncounterModal({
|
|||||||
loadingPokemon ||
|
loadingPokemon ||
|
||||||
!routePokemon ||
|
!routePokemon ||
|
||||||
(dupedPokemonIds
|
(dupedPokemonIds
|
||||||
? routePokemon.every((rp) =>
|
? routePokemon.every((rp) => dupedPokemonIds.has(rp.pokemonId))
|
||||||
dupedPokemonIds.has(rp.pokemonId)
|
|
||||||
)
|
|
||||||
: false)
|
: false)
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (routePokemon) {
|
if (routePokemon) {
|
||||||
setSelectedPokemon(
|
setSelectedPokemon(pickRandomPokemon(routePokemon, dupedPokemonIds))
|
||||||
pickRandomPokemon(routePokemon, dupedPokemonIds)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-300 dark:border-purple-600 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-300 dark:border-purple-600 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
@@ -279,15 +248,12 @@ export function EncounterModal({
|
|||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{pokemon.map((rp) => {
|
{pokemon.map((rp) => {
|
||||||
const isDuped =
|
const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
|
||||||
dupedPokemonIds?.has(rp.pokemonId) ?? false
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={rp.id}
|
key={rp.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => !isDuped && setSelectedPokemon(rp)}
|
||||||
!isDuped && setSelectedPokemon(rp)
|
|
||||||
}
|
|
||||||
disabled={isDuped}
|
disabled={isDuped}
|
||||||
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
|
||||||
isDuped
|
isDuped
|
||||||
@@ -305,7 +271,7 @@ export function EncounterModal({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
||||||
{rp.pokemon.name[0].toUpperCase()}
|
{rp.pokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||||
@@ -318,19 +284,13 @@ export function EncounterModal({
|
|||||||
: 'already caught'}
|
: 'already caught'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isDuped &&
|
{!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
||||||
SPECIAL_METHODS.includes(
|
<EncounterMethodBadge method={rp.encounterMethod} />
|
||||||
rp.encounterMethod
|
|
||||||
) && (
|
|
||||||
<EncounterMethodBadge
|
|
||||||
method={rp.encounterMethod}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{!isDuped && (
|
{!isDuped && (
|
||||||
<span className="text-[10px] text-gray-400">
|
<span className="text-[10px] text-gray-400">
|
||||||
Lv. {rp.minLevel}
|
Lv. {rp.minLevel}
|
||||||
{rp.maxLevel !== rp.minLevel &&
|
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
||||||
`–${rp.maxLevel}`}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -360,7 +320,7 @@ export function EncounterModal({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
|
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
|
||||||
{existing.pokemon.name[0].toUpperCase()}
|
{existing.pokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
@@ -477,19 +437,14 @@ export function EncounterModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Faint Level + Death Cause (only when editing a caught pokemon to mark dead) */}
|
{/* Faint Level + Death Cause (only when editing a caught pokemon to mark dead) */}
|
||||||
{isEditing &&
|
{isEditing && existing?.status === 'caught' && existing?.faintLevel === null && (
|
||||||
existing?.status === 'caught' &&
|
|
||||||
existing?.faintLevel === null && (
|
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="faint-level"
|
htmlFor="faint-level"
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Faint Level{' '}
|
Faint Level <span className="font-normal text-gray-400">(mark as dead)</span>
|
||||||
<span className="font-normal text-gray-400">
|
|
||||||
(mark as dead)
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="faint-level"
|
id="faint-level"
|
||||||
@@ -507,10 +462,7 @@ export function EncounterModal({
|
|||||||
htmlFor="death-cause"
|
htmlFor="death-cause"
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Cause of Death{' '}
|
Cause of Death <span className="font-normal text-gray-400">(optional)</span>
|
||||||
<span className="font-normal text-gray-400">
|
|
||||||
(optional)
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="death-cause"
|
id="death-cause"
|
||||||
|
|||||||
@@ -7,12 +7,7 @@ interface EndRunModalProps {
|
|||||||
genlockeContext?: RunGenlockeContext | null
|
genlockeContext?: RunGenlockeContext | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EndRunModal({
|
export function EndRunModal({ onConfirm, onClose, isPending, genlockeContext }: EndRunModalProps) {
|
||||||
onConfirm,
|
|
||||||
onClose,
|
|
||||||
isPending,
|
|
||||||
genlockeContext,
|
|
||||||
}: EndRunModalProps) {
|
|
||||||
const victoryDescription = genlockeContext
|
const victoryDescription = genlockeContext
|
||||||
? genlockeContext.isFinalLeg
|
? genlockeContext.isFinalLeg
|
||||||
? 'Complete the final leg of your genlocke!'
|
? 'Complete the final leg of your genlocke!'
|
||||||
@@ -31,9 +26,7 @@ export function EndRunModal({
|
|||||||
<h2 className="text-lg font-semibold">End Run</h2>
|
<h2 className="text-lg font-semibold">End Run</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-6">
|
<div className="px-6 py-6">
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
<p className="text-gray-600 dark:text-gray-400 mb-6">How did your run end?</p>
|
||||||
How did your run end?
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => onConfirm('completed')}
|
onClick={() => onConfirm('completed')}
|
||||||
|
|||||||
@@ -32,27 +32,20 @@ export function GameCard({ game, selected, onSelect }: GameCardProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="w-full h-48 flex items-center justify-center" style={{ backgroundColor }}>
|
||||||
className="w-full h-48 flex items-center justify-center"
|
|
||||||
style={{ backgroundColor }}
|
|
||||||
>
|
|
||||||
<span className="text-white text-2xl font-bold text-center px-4 drop-shadow-md">
|
<span className="text-white text-2xl font-bold text-center px-4 drop-shadow-md">
|
||||||
{game.name.replace('Pokemon ', '')}
|
{game.name.replace('Pokemon ', '')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-3 bg-white dark:bg-gray-800 text-left">
|
<div className="p-3 bg-white dark:bg-gray-800 text-left">
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{game.name}</h3>
|
||||||
{game.name}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||||
{game.region.charAt(0).toUpperCase() + game.region.slice(1)}
|
{game.region.charAt(0).toUpperCase() + game.region.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
{game.releaseYear && (
|
{game.releaseYear && (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
<span className="text-xs text-gray-500 dark:text-gray-400">{game.releaseYear}</span>
|
||||||
{game.releaseYear}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,11 +58,7 @@ export function GameCard({ game, selected, onSelect }: GameCardProps) {
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
>
|
>
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface GameGridProps {
|
|||||||
games: Game[]
|
games: Game[]
|
||||||
selectedId: number | null
|
selectedId: number | null
|
||||||
onSelect: (game: Game) => void
|
onSelect: (game: Game) => void
|
||||||
runs?: NuzlockeRun[]
|
runs?: NuzlockeRun[] | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
|
export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
|
||||||
@@ -27,38 +27,26 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
|
|||||||
const [hideWithActiveRun, setHideWithActiveRun] = useState(false)
|
const [hideWithActiveRun, setHideWithActiveRun] = useState(false)
|
||||||
const [hideCompleted, setHideCompleted] = useState(false)
|
const [hideCompleted, setHideCompleted] = useState(false)
|
||||||
|
|
||||||
const generations = useMemo(
|
const generations = useMemo(() => [...new Set(games.map((g) => g.generation))].sort(), [games])
|
||||||
() => [...new Set(games.map((g) => g.generation))].sort(),
|
|
||||||
[games]
|
|
||||||
)
|
|
||||||
|
|
||||||
const regions = useMemo(
|
const regions = useMemo(() => [...new Set(games.map((g) => g.region))].sort(), [games])
|
||||||
() => [...new Set(games.map((g) => g.region))].sort(),
|
|
||||||
[games]
|
|
||||||
)
|
|
||||||
|
|
||||||
const activeRunGameIds = useMemo(() => {
|
const activeRunGameIds = useMemo(() => {
|
||||||
if (!runs) return new Set<number>()
|
if (!runs) return new Set<number>()
|
||||||
return new Set(
|
return new Set(runs.filter((r) => r.status === 'active').map((r) => r.gameId))
|
||||||
runs.filter((r) => r.status === 'active').map((r) => r.gameId)
|
|
||||||
)
|
|
||||||
}, [runs])
|
}, [runs])
|
||||||
|
|
||||||
const completedRunGameIds = useMemo(() => {
|
const completedRunGameIds = useMemo(() => {
|
||||||
if (!runs) return new Set<number>()
|
if (!runs) return new Set<number>()
|
||||||
return new Set(
|
return new Set(runs.filter((r) => r.status === 'completed').map((r) => r.gameId))
|
||||||
runs.filter((r) => r.status === 'completed').map((r) => r.gameId)
|
|
||||||
)
|
|
||||||
}, [runs])
|
}, [runs])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let result = games
|
let result = games
|
||||||
if (filter) result = result.filter((g) => g.generation === filter)
|
if (filter) result = result.filter((g) => g.generation === filter)
|
||||||
if (regionFilter) result = result.filter((g) => g.region === regionFilter)
|
if (regionFilter) result = result.filter((g) => g.region === regionFilter)
|
||||||
if (hideWithActiveRun)
|
if (hideWithActiveRun) result = result.filter((g) => !activeRunGameIds.has(g.id))
|
||||||
result = result.filter((g) => !activeRunGameIds.has(g.id))
|
if (hideCompleted) result = result.filter((g) => !completedRunGameIds.has(g.id))
|
||||||
if (hideCompleted)
|
|
||||||
result = result.filter((g) => !completedRunGameIds.has(g.id))
|
|
||||||
return result
|
return result
|
||||||
}, [
|
}, [
|
||||||
games,
|
games,
|
||||||
@@ -91,9 +79,7 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">Gen:</span>
|
||||||
Gen:
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFilter(null)}
|
onClick={() => setFilter(null)}
|
||||||
@@ -114,9 +100,7 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">Region:</span>
|
||||||
Region:
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRegionFilter(null)}
|
onClick={() => setRegionFilter(null)}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-25 h-25 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
<div className="w-25 h-25 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
||||||
{displayPokemon.name[0].toUpperCase()}
|
{displayPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -35,9 +35,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{entry.nickname && (
|
{entry.nickname && (
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-gray-500 dark:text-gray-400">{displayPokemon.name}</div>
|
||||||
{displayPokemon.name}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-0.5 mt-1">
|
<div className="flex flex-col items-center gap-0.5 mt-1">
|
||||||
@@ -50,9 +48,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
|
|||||||
Lv. {entry.catchLevel} → {entry.faintLevel}
|
Lv. {entry.catchLevel} → {entry.faintLevel}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{entry.routeName}</div>
|
||||||
{entry.routeName}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-[10px] text-purple-600 dark:text-purple-400 mt-0.5 font-medium">
|
<div className="text-[10px] text-purple-600 dark:text-purple-400 mt-0.5 font-medium">
|
||||||
Leg {entry.legOrder} — {entry.gameName}
|
Leg {entry.legOrder} — {entry.gameName}
|
||||||
@@ -134,8 +130,8 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
|
|||||||
</span>
|
</span>
|
||||||
{data.deadliestLeg && (
|
{data.deadliestLeg && (
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
Deadliest: Leg {data.deadliestLeg.legOrder} —{' '}
|
Deadliest: Leg {data.deadliestLeg.legOrder} — {data.deadliestLeg.gameName} (
|
||||||
{data.deadliestLeg.gameName} ({data.deadliestLeg.deathCount})
|
{data.deadliestLeg.deathCount})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -144,9 +140,7 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
|
|||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<select
|
<select
|
||||||
value={filterLeg ?? ''}
|
value={filterLeg ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFilterLeg(e.target.value ? Number(e.target.value) : null)}
|
||||||
setFilterLeg(e.target.value ? Number(e.target.value) : null)
|
|
||||||
}
|
|
||||||
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
<option value="">All Legs</option>
|
<option value="">All Legs</option>
|
||||||
|
|||||||
@@ -38,21 +38,13 @@ function LegDot({ leg }: { leg: LineageLegEntry }) {
|
|||||||
<div className="font-semibold">{leg.gameName}</div>
|
<div className="font-semibold">{leg.gameName}</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{displayPokemon.spriteUrl && (
|
{displayPokemon.spriteUrl && (
|
||||||
<img
|
<img src={displayPokemon.spriteUrl} alt={displayPokemon.name} className="w-6 h-6" />
|
||||||
src={displayPokemon.spriteUrl}
|
|
||||||
alt={displayPokemon.name}
|
|
||||||
className="w-6 h-6"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<span>{displayPokemon.name}</span>
|
<span>{displayPokemon.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{leg.catchLevel !== null && <div>Caught Lv. {leg.catchLevel}</div>}
|
{leg.catchLevel !== null && <div>Caught Lv. {leg.catchLevel}</div>}
|
||||||
{leg.faintLevel !== null && (
|
{leg.faintLevel !== null && <div className="text-red-300">Died Lv. {leg.faintLevel}</div>}
|
||||||
<div className="text-red-300">Died Lv. {leg.faintLevel}</div>
|
{leg.deathCause && <div className="text-red-300 italic">{leg.deathCause}</div>}
|
||||||
)}
|
|
||||||
{leg.deathCause && (
|
|
||||||
<div className="text-red-300 italic">{leg.deathCause}</div>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className={`font-medium ${
|
className={`font-medium ${
|
||||||
leg.faintLevel !== null
|
leg.faintLevel !== null
|
||||||
@@ -84,8 +76,8 @@ function TimelineGrid({
|
|||||||
allLegOrders: number[]
|
allLegOrders: number[]
|
||||||
}) {
|
}) {
|
||||||
const legMap = new Map(lineage.legs.map((l) => [l.legOrder, l]))
|
const legMap = new Map(lineage.legs.map((l) => [l.legOrder, l]))
|
||||||
const minLeg = lineage.legs[0].legOrder
|
const minLeg = lineage.legs[0]!.legOrder
|
||||||
const maxLeg = lineage.legs[lineage.legs.length - 1].legOrder
|
const maxLeg = lineage.legs[lineage.legs.length - 1]!.legOrder
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -97,18 +89,12 @@ function TimelineGrid({
|
|||||||
{allLegOrders.map((legOrder, i) => {
|
{allLegOrders.map((legOrder, i) => {
|
||||||
const leg = legMap.get(legOrder)
|
const leg = legMap.get(legOrder)
|
||||||
const inRange = legOrder >= minLeg && legOrder <= maxLeg
|
const inRange = legOrder >= minLeg && legOrder <= maxLeg
|
||||||
const showLeftLine = inRange && i > 0 && allLegOrders[i - 1] >= minLeg
|
const showLeftLine = inRange && i > 0 && (allLegOrders[i - 1] ?? 0) >= minLeg
|
||||||
const showRightLine =
|
const showRightLine =
|
||||||
inRange &&
|
inRange && i < allLegOrders.length - 1 && (allLegOrders[i + 1] ?? 0) <= maxLeg
|
||||||
i < allLegOrders.length - 1 &&
|
|
||||||
allLegOrders[i + 1] <= maxLeg
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={legOrder} className="flex justify-center relative" style={{ height: '20px' }}>
|
||||||
key={legOrder}
|
|
||||||
className="flex justify-center relative"
|
|
||||||
style={{ height: '20px' }}
|
|
||||||
>
|
|
||||||
{/* Left half connector */}
|
{/* Left half connector */}
|
||||||
{showLeftLine && (
|
{showLeftLine && (
|
||||||
<div className="absolute top-[9px] left-0 right-1/2 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
<div className="absolute top-[9px] left-0 right-1/2 h-0.5 bg-gray-300 dark:bg-gray-600" />
|
||||||
@@ -132,14 +118,8 @@ function TimelineGrid({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LineageCard({
|
function LineageCard({ lineage, allLegOrders }: { lineage: LineageEntry; allLegOrders: number[] }) {
|
||||||
lineage,
|
const firstLeg = lineage.legs[0]!
|
||||||
allLegOrders,
|
|
||||||
}: {
|
|
||||||
lineage: LineageEntry
|
|
||||||
allLegOrders: number[]
|
|
||||||
}) {
|
|
||||||
const firstLeg = lineage.legs[0]
|
|
||||||
const displayPokemon = firstLeg.currentPokemon ?? firstLeg.pokemon
|
const displayPokemon = firstLeg.currentPokemon ?? firstLeg.pokemon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -155,7 +135,7 @@ function LineageCard({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
||||||
{displayPokemon.name[0].toUpperCase()}
|
{displayPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 mt-1 text-center">
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 mt-1 text-center">
|
||||||
@@ -194,11 +174,9 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
|
|||||||
|
|
||||||
const allLegOrders = useMemo(() => {
|
const allLegOrders = useMemo(() => {
|
||||||
if (!data) return []
|
if (!data) return []
|
||||||
return [
|
return [...new Set(data.lineages.flatMap((l) => l.legs.map((leg) => leg.legOrder)))].sort(
|
||||||
...new Set(
|
(a, b) => a - b
|
||||||
data.lineages.flatMap((l) => l.legs.map((leg) => leg.legOrder))
|
)
|
||||||
),
|
|
||||||
].sort((a, b) => a - b)
|
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const legGameNames = useMemo(() => {
|
const legGameNames = useMemo(() => {
|
||||||
@@ -241,8 +219,8 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
|
|||||||
{/* Summary bar */}
|
{/* Summary bar */}
|
||||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''}{' '}
|
{data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''} across{' '}
|
||||||
across {allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
|
{allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,7 +254,7 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{data.lineages.map((lineage) => (
|
{data.lineages.map((lineage) => (
|
||||||
<LineageCard
|
<LineageCard
|
||||||
key={lineage.legs[0].encounterId}
|
key={lineage.legs[0]!.encounterId}
|
||||||
lineage={lineage}
|
lineage={lineage}
|
||||||
allLegOrders={allLegOrders}
|
allLegOrders={allLegOrders}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,12 +8,7 @@ interface HofTeamModalProps {
|
|||||||
isPending: boolean
|
isPending: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HofTeamModal({
|
export function HofTeamModal({ alive, onSubmit, onSkip, isPending }: HofTeamModalProps) {
|
||||||
alive,
|
|
||||||
onSubmit,
|
|
||||||
onSkip,
|
|
||||||
isPending,
|
|
||||||
}: HofTeamModalProps) {
|
|
||||||
const [selected, setSelected] = useState<Set<number>>(() => {
|
const [selected, setSelected] = useState<Set<number>>(() => {
|
||||||
// Pre-select all if 6 or fewer
|
// Pre-select all if 6 or fewer
|
||||||
if (alive.length <= 6) return new Set(alive.map((e) => e.id))
|
if (alive.length <= 6) return new Set(alive.map((e) => e.id))
|
||||||
@@ -74,16 +69,14 @@ export function HofTeamModal({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
|
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
|
||||||
{displayPokemon.name[0].toUpperCase()}
|
{displayPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||||
{enc.nickname || displayPokemon.name}
|
{enc.nickname || displayPokemon.name}
|
||||||
</span>
|
</span>
|
||||||
{enc.nickname && (
|
{enc.nickname && (
|
||||||
<span className="text-[10px] text-gray-400">
|
<span className="text-[10px] text-gray-400">{displayPokemon.name}</span>
|
||||||
{displayPokemon.name}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,12 +55,7 @@ export function Layout() {
|
|||||||
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
>
|
>
|
||||||
<svg
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-6 h-6"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
{menuOpen ? (
|
{menuOpen ? (
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import type { EncounterDetail } from '../types'
|
import type { EncounterDetail } from '../types'
|
||||||
import { TypeBadge } from './TypeBadge'
|
import { TypeBadge } from './TypeBadge'
|
||||||
|
|
||||||
interface PokemonCardProps {
|
export interface PokemonCardProps {
|
||||||
encounter: EncounterDetail
|
encounter: EncounterDetail
|
||||||
showFaintLevel?: boolean
|
showFaintLevel?: boolean | undefined
|
||||||
onClick?: () => void
|
onClick?: (() => void) | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PokemonCard({
|
export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardProps) {
|
||||||
encounter,
|
const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } = encounter
|
||||||
showFaintLevel,
|
|
||||||
onClick,
|
|
||||||
}: PokemonCardProps) {
|
|
||||||
const {
|
|
||||||
pokemon,
|
|
||||||
currentPokemon,
|
|
||||||
route,
|
|
||||||
nickname,
|
|
||||||
catchLevel,
|
|
||||||
faintLevel,
|
|
||||||
deathCause,
|
|
||||||
} = encounter
|
|
||||||
const isDead = faintLevel !== null
|
const isDead = faintLevel !== null
|
||||||
const displayPokemon = currentPokemon ?? pokemon
|
const displayPokemon = currentPokemon ?? pokemon
|
||||||
const isEvolved = currentPokemon !== null
|
const isEvolved = currentPokemon !== null
|
||||||
@@ -33,14 +21,10 @@ export function PokemonCard({
|
|||||||
} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-blue-400 transition-shadow' : ''}`}
|
} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-blue-400 transition-shadow' : ''}`}
|
||||||
>
|
>
|
||||||
{displayPokemon.spriteUrl ? (
|
{displayPokemon.spriteUrl ? (
|
||||||
<img
|
<img src={displayPokemon.spriteUrl} alt={displayPokemon.name} className="w-25 h-25" />
|
||||||
src={displayPokemon.spriteUrl}
|
|
||||||
alt={displayPokemon.name}
|
|
||||||
className="w-25 h-25"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="w-25 h-25 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
<div className="w-25 h-25 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
||||||
{displayPokemon.name[0].toUpperCase()}
|
{displayPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -53,9 +37,7 @@ export function PokemonCard({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{nickname && (
|
{nickname && (
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-gray-500 dark:text-gray-400">{displayPokemon.name}</div>
|
||||||
{displayPokemon.name}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-0.5 mt-1">
|
<div className="flex flex-col items-center gap-0.5 mt-1">
|
||||||
@@ -70,9 +52,7 @@ export function PokemonCard({
|
|||||||
: `Lv. ${catchLevel ?? '?'}`}
|
: `Lv. ${catchLevel ?? '?'}`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{route.name}</div>
|
||||||
{route.name}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEvolved && (
|
{isEvolved && (
|
||||||
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
|
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
|
|||||||
const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key])
|
const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key])
|
||||||
|
|
||||||
if (enabledRules.length === 0) {
|
if (enabledRules.length === 0) {
|
||||||
return (
|
return <span className="text-sm text-gray-500 dark:text-gray-400">No rules enabled</span>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
No rules enabled
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,21 +7,14 @@ interface RuleToggleProps {
|
|||||||
onChange: (enabled: boolean) => void
|
onChange: (enabled: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RuleToggle({
|
export function RuleToggle({ name, description, enabled, onChange }: RuleToggleProps) {
|
||||||
name,
|
|
||||||
description,
|
|
||||||
enabled,
|
|
||||||
onChange,
|
|
||||||
}: RuleToggleProps) {
|
|
||||||
const [showTooltip, setShowTooltip] = useState(false)
|
const [showTooltip, setShowTooltip] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||||
<div className="flex-1 pr-4">
|
<div className="flex-1 pr-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
<span className="font-medium text-gray-900 dark:text-gray-100">{name}</span>
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
@@ -30,12 +23,7 @@ export function RuleToggle({
|
|||||||
onClick={() => setShowTooltip(!showTooltip)}
|
onClick={() => setShowTooltip(!showTooltip)}
|
||||||
aria-label={`Info about ${name}`}
|
aria-label={`Info about ${name}`}
|
||||||
>
|
>
|
||||||
<svg
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -46,9 +34,7 @@ export function RuleToggle({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{showTooltip && (
|
{showTooltip && (
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { RuleToggle } from './RuleToggle'
|
|||||||
interface RulesConfigurationProps {
|
interface RulesConfigurationProps {
|
||||||
rules: NuzlockeRules
|
rules: NuzlockeRules
|
||||||
onChange: (rules: NuzlockeRules) => void
|
onChange: (rules: NuzlockeRules) => void
|
||||||
onReset?: () => void
|
onReset?: (() => void) | undefined
|
||||||
hiddenRules?: Set<keyof NuzlockeRules>
|
hiddenRules?: Set<keyof NuzlockeRules> | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RulesConfiguration({
|
export function RulesConfiguration({
|
||||||
@@ -19,12 +19,8 @@ export function RulesConfiguration({
|
|||||||
? RULE_DEFINITIONS.filter((r) => !hiddenRules.has(r.key))
|
? RULE_DEFINITIONS.filter((r) => !hiddenRules.has(r.key))
|
||||||
: RULE_DEFINITIONS
|
: RULE_DEFINITIONS
|
||||||
const coreRules = visibleRules.filter((r) => r.category === 'core')
|
const coreRules = visibleRules.filter((r) => r.category === 'core')
|
||||||
const difficultyRules = visibleRules.filter(
|
const difficultyRules = visibleRules.filter((r) => r.category === 'difficulty')
|
||||||
(r) => r.category === 'difficulty'
|
const completionRules = visibleRules.filter((r) => r.category === 'completion')
|
||||||
)
|
|
||||||
const completionRules = visibleRules.filter(
|
|
||||||
(r) => r.category === 'completion'
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
|
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
|
||||||
onChange({ ...rules, [key]: value })
|
onChange({ ...rules, [key]: value })
|
||||||
@@ -60,9 +56,7 @@ export function RulesConfiguration({
|
|||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Core Rules</h3>
|
||||||
Core Rules
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
The fundamental rules of a Nuzlocke challenge
|
The fundamental rules of a Nuzlocke challenge
|
||||||
</p>
|
</p>
|
||||||
@@ -105,9 +99,7 @@ export function RulesConfiguration({
|
|||||||
{completionRules.length > 0 && (
|
{completionRules.length > 0 && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Completion</h3>
|
||||||
Completion
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
When is the run considered complete
|
When is the run considered complete
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { EncounterDetail } from '../types'
|
|||||||
|
|
||||||
interface ShinyBoxProps {
|
interface ShinyBoxProps {
|
||||||
encounters: EncounterDetail[]
|
encounters: EncounterDetail[]
|
||||||
onEncounterClick?: (encounter: EncounterDetail) => void
|
onEncounterClick?: ((encounter: EncounterDetail) => void) | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
|
export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
|
||||||
@@ -22,9 +22,7 @@ export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
|
|||||||
<PokemonCard
|
<PokemonCard
|
||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
onClick={
|
onClick={onEncounterClick ? () => onEncounterClick(enc) : undefined}
|
||||||
onEncounterClick ? () => onEncounterClick(enc) : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useRoutePokemon } from '../hooks/useGames'
|
import { useRoutePokemon } from '../hooks/useGames'
|
||||||
import {
|
import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge'
|
||||||
EncounterMethodBadge,
|
|
||||||
getMethodLabel,
|
|
||||||
METHOD_ORDER,
|
|
||||||
} from './EncounterMethodBadge'
|
|
||||||
import type { Route, RouteEncounterDetail } from '../types'
|
import type { Route, RouteEncounterDetail } from '../types'
|
||||||
|
|
||||||
interface ShinyEncounterModalProps {
|
interface ShinyEncounterModalProps {
|
||||||
@@ -13,9 +9,9 @@ interface ShinyEncounterModalProps {
|
|||||||
onSubmit: (data: {
|
onSubmit: (data: {
|
||||||
routeId: number
|
routeId: number
|
||||||
pokemonId: number
|
pokemonId: number
|
||||||
nickname?: string
|
nickname?: string | undefined
|
||||||
status: 'caught'
|
status: 'caught'
|
||||||
catchLevel?: number
|
catchLevel?: number | undefined
|
||||||
isShiny: true
|
isShiny: true
|
||||||
}) => void
|
}) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -50,13 +46,9 @@ export function ShinyEncounterModal({
|
|||||||
isPending,
|
isPending,
|
||||||
}: ShinyEncounterModalProps) {
|
}: ShinyEncounterModalProps) {
|
||||||
const [selectedRouteId, setSelectedRouteId] = useState<number | null>(null)
|
const [selectedRouteId, setSelectedRouteId] = useState<number | null>(null)
|
||||||
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
|
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(selectedRouteId, gameId)
|
||||||
selectedRouteId,
|
|
||||||
gameId
|
|
||||||
)
|
|
||||||
|
|
||||||
const [selectedPokemon, setSelectedPokemon] =
|
const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null)
|
||||||
useState<RouteEncounterDetail | null>(null)
|
|
||||||
const [nickname, setNickname] = useState('')
|
const [nickname, setNickname] = useState('')
|
||||||
const [catchLevel, setCatchLevel] = useState<string>('')
|
const [catchLevel, setCatchLevel] = useState<string>('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -111,12 +103,7 @@ export function ShinyEncounterModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
<svg
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -203,21 +190,18 @@ export function ShinyEncounterModal({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
|
||||||
{rp.pokemon.name[0].toUpperCase()}
|
{rp.pokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||||
{rp.pokemon.name}
|
{rp.pokemon.name}
|
||||||
</span>
|
</span>
|
||||||
{SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
{SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
||||||
<EncounterMethodBadge
|
<EncounterMethodBadge method={rp.encounterMethod} />
|
||||||
method={rp.encounterMethod}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-gray-400">
|
<span className="text-[10px] text-gray-400">
|
||||||
Lv. {rp.minLevel}
|
Lv. {rp.minLevel}
|
||||||
{rp.maxLevel !== rp.minLevel &&
|
{rp.maxLevel !== rp.minLevel && `–${rp.maxLevel}`}
|
||||||
`–${rp.maxLevel}`}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string
|
label: string
|
||||||
value: number
|
value: number
|
||||||
total?: number
|
total?: number | undefined
|
||||||
color: string
|
color: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,10 +22,7 @@ export function StatCard({ label, value, total, color }: StatCardProps) {
|
|||||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
{value}
|
{value}
|
||||||
{total !== undefined && (
|
{total !== undefined && (
|
||||||
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
|
<span className="text-sm font-normal text-gray-500 dark:text-gray-400"> / {total}</span>
|
||||||
{' '}
|
|
||||||
/ {total}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">{label}</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400">{label}</div>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import type {
|
import type { EncounterDetail, UpdateEncounterInput, CreateEncounterInput } from '../types'
|
||||||
EncounterDetail,
|
|
||||||
UpdateEncounterInput,
|
|
||||||
CreateEncounterInput,
|
|
||||||
} from '../types'
|
|
||||||
import { useEvolutions, useForms } from '../hooks/useEncounters'
|
import { useEvolutions, useForms } from '../hooks/useEncounters'
|
||||||
import { TypeBadge } from './TypeBadge'
|
import { TypeBadge } from './TypeBadge'
|
||||||
import { formatEvolutionMethod } from '../utils/formatEvolution'
|
import { formatEvolutionMethod } from '../utils/formatEvolution'
|
||||||
@@ -25,24 +21,14 @@ export function StatusChangeModal({
|
|||||||
region,
|
region,
|
||||||
onCreateEncounter,
|
onCreateEncounter,
|
||||||
}: StatusChangeModalProps) {
|
}: StatusChangeModalProps) {
|
||||||
const {
|
const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } = encounter
|
||||||
pokemon,
|
|
||||||
currentPokemon,
|
|
||||||
route,
|
|
||||||
nickname,
|
|
||||||
catchLevel,
|
|
||||||
faintLevel,
|
|
||||||
deathCause,
|
|
||||||
} = encounter
|
|
||||||
const isDead = faintLevel !== null
|
const isDead = faintLevel !== null
|
||||||
const displayPokemon = currentPokemon ?? pokemon
|
const displayPokemon = currentPokemon ?? pokemon
|
||||||
const [showConfirm, setShowConfirm] = useState(false)
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
const [showEvolve, setShowEvolve] = useState(false)
|
const [showEvolve, setShowEvolve] = useState(false)
|
||||||
const [showFormChange, setShowFormChange] = useState(false)
|
const [showFormChange, setShowFormChange] = useState(false)
|
||||||
const [showShedConfirm, setShowShedConfirm] = useState(false)
|
const [showShedConfirm, setShowShedConfirm] = useState(false)
|
||||||
const [pendingEvolutionId, setPendingEvolutionId] = useState<number | null>(
|
const [pendingEvolutionId, setPendingEvolutionId] = useState<number | null>(null)
|
||||||
null
|
|
||||||
)
|
|
||||||
const [shedNickname, setShedNickname] = useState('')
|
const [shedNickname, setShedNickname] = useState('')
|
||||||
const [deathLevel, setDeathLevel] = useState('')
|
const [deathLevel, setDeathLevel] = useState('')
|
||||||
const [cause, setCause] = useState('')
|
const [cause, setCause] = useState('')
|
||||||
@@ -115,12 +101,7 @@ export function StatusChangeModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
<svg
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -142,7 +123,7 @@ export function StatusChangeModal({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
<div className="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
|
||||||
{displayPokemon.name[0].toUpperCase()}
|
{displayPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
@@ -179,29 +160,20 @@ export function StatusChangeModal({
|
|||||||
</div>
|
</div>
|
||||||
{faintLevel !== null && (
|
{faintLevel !== null && (
|
||||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">Level at death:</span>{' '}
|
||||||
Level at death:
|
|
||||||
</span>{' '}
|
|
||||||
{faintLevel}
|
{faintLevel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{deathCause && (
|
{deathCause && (
|
||||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">Cause:</span> {deathCause}
|
||||||
Cause:
|
|
||||||
</span>{' '}
|
|
||||||
{deathCause}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alive pokemon: actions */}
|
{/* Alive pokemon: actions */}
|
||||||
{!isDead &&
|
{!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm && (
|
||||||
!showConfirm &&
|
|
||||||
!showEvolve &&
|
|
||||||
!showFormChange &&
|
|
||||||
!showShedConfirm && (
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -245,14 +217,10 @@ export function StatusChangeModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{evolutionsLoading && (
|
{evolutionsLoading && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">Loading evolutions...</p>
|
||||||
Loading evolutions...
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{!evolutionsLoading && normalEvolutions.length === 0 && (
|
{!evolutionsLoading && normalEvolutions.length === 0 && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions available</p>
|
||||||
No evolutions available
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{!evolutionsLoading && normalEvolutions.length > 0 && (
|
{!evolutionsLoading && normalEvolutions.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -272,7 +240,7 @@ export function StatusChangeModal({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
|
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
|
||||||
{evo.toPokemon.name[0].toUpperCase()}
|
{evo.toPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
@@ -320,16 +288,12 @@ export function StatusChangeModal({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
|
<div className="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
|
||||||
{shedCompanion.toPokemon.name[0].toUpperCase()}
|
{shedCompanion.toPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||||
{displayPokemon.name} shed its shell! Would you also like to
|
{displayPokemon.name} shed its shell! Would you also like to add{' '}
|
||||||
add{' '}
|
<span className="font-semibold">{shedCompanion.toPokemon.name}</span>?
|
||||||
<span className="font-semibold">
|
|
||||||
{shedCompanion.toPokemon.name}
|
|
||||||
</span>
|
|
||||||
?
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,8 +302,7 @@ export function StatusChangeModal({
|
|||||||
htmlFor="shed-nickname"
|
htmlFor="shed-nickname"
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Nickname{' '}
|
Nickname <span className="font-normal text-gray-400">(optional)</span>
|
||||||
<span className="font-normal text-gray-400">(optional)</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="shed-nickname"
|
id="shed-nickname"
|
||||||
@@ -366,9 +329,7 @@ export function StatusChangeModal({
|
|||||||
onClick={() => applyEvolution(true)}
|
onClick={() => applyEvolution(true)}
|
||||||
className="flex-1 px-4 py-2 bg-amber-600 text-white rounded-lg font-medium hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex-1 px-4 py-2 bg-amber-600 text-white rounded-lg font-medium hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{isPending
|
{isPending ? 'Saving...' : `Add ${shedCompanion.toPokemon.name}`}
|
||||||
? 'Saving...'
|
|
||||||
: `Add ${shedCompanion.toPokemon.name}`}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,14 +361,10 @@ export function StatusChangeModal({
|
|||||||
className="w-full flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 hover:border-purple-300 dark:hover:border-purple-600 transition-colors disabled:opacity-50"
|
className="w-full flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 hover:border-purple-300 dark:hover:border-purple-600 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{form.spriteUrl ? (
|
{form.spriteUrl ? (
|
||||||
<img
|
<img src={form.spriteUrl} alt={form.name} className="w-10 h-10" />
|
||||||
src={form.spriteUrl}
|
|
||||||
alt={form.name}
|
|
||||||
className="w-10 h-10"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
|
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
|
||||||
{form.name[0].toUpperCase()}
|
{form.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
@@ -441,8 +398,7 @@ export function StatusChangeModal({
|
|||||||
htmlFor="death-level"
|
htmlFor="death-level"
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Level at Death{' '}
|
Level at Death <span className="font-normal text-gray-400">(optional)</span>
|
||||||
<span className="font-normal text-gray-400">(optional)</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="death-level"
|
id="death-level"
|
||||||
@@ -461,8 +417,7 @@ export function StatusChangeModal({
|
|||||||
htmlFor="death-cause"
|
htmlFor="death-cause"
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
>
|
>
|
||||||
Cause of Death{' '}
|
Cause of Death <span className="font-normal text-gray-400">(optional)</span>
|
||||||
<span className="font-normal text-gray-400">(optional)</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="death-cause"
|
id="death-cause"
|
||||||
@@ -498,11 +453,7 @@ export function StatusChangeModal({
|
|||||||
|
|
||||||
{/* Footer for dead/no-confirm/no-evolve views */}
|
{/* Footer for dead/no-confirm/no-evolve views */}
|
||||||
{(isDead ||
|
{(isDead ||
|
||||||
(!isDead &&
|
(!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm)) && (
|
||||||
!showConfirm &&
|
|
||||||
!showEvolve &&
|
|
||||||
!showFormChange &&
|
|
||||||
!showShedConfirm)) && (
|
|
||||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -20,10 +20,7 @@ export function StepIndicator({
|
|||||||
const isCurrent = step === currentStep
|
const isCurrent = step === currentStep
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li key={label} className={`flex items-center ${i < steps.length - 1 ? 'flex-1' : ''}`}>
|
||||||
key={label}
|
|
||||||
className={`flex items-center ${i < steps.length - 1 ? 'flex-1' : ''}`}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => isCompleted && onStepClick(step)}
|
onClick={() => isCompleted && onStepClick(step)}
|
||||||
@@ -53,11 +50,7 @@ export function StepIndicator({
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
>
|
>
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
step
|
step
|
||||||
@@ -68,9 +61,7 @@ export function StepIndicator({
|
|||||||
{i < steps.length - 1 && (
|
{i < steps.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className={`flex-1 h-0.5 mx-3 ${
|
className={`flex-1 h-0.5 mx-3 ${
|
||||||
step < currentStep
|
step < currentStep ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
? 'bg-blue-600'
|
|
||||||
: 'bg-gray-300 dark:bg-gray-600'
|
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,15 +8,8 @@ interface TransferModalProps {
|
|||||||
isPending: boolean
|
isPending: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransferModal({
|
export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: TransferModalProps) {
|
||||||
hofTeam,
|
const [selected, setSelected] = useState<Set<number>>(() => new Set(hofTeam.map((e) => e.id)))
|
||||||
onSubmit,
|
|
||||||
onSkip,
|
|
||||||
isPending,
|
|
||||||
}: TransferModalProps) {
|
|
||||||
const [selected, setSelected] = useState<Set<number>>(
|
|
||||||
() => new Set(hofTeam.map((e) => e.id))
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggle = (id: number) => {
|
const toggle = (id: number) => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
@@ -39,8 +32,8 @@ export function TransferModal({
|
|||||||
Transfer Pokemon to Next Leg
|
Transfer Pokemon to Next Leg
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Selected Pokemon will be bred down to their base form and appear as
|
Selected Pokemon will be bred down to their base form and appear as level 1 encounters
|
||||||
level 1 encounters in the next leg.
|
in the next leg.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,20 +62,16 @@ export function TransferModal({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
|
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
|
||||||
{displayPokemon.name[0].toUpperCase()}
|
{displayPokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize">
|
||||||
{enc.nickname || displayPokemon.name}
|
{enc.nickname || displayPokemon.name}
|
||||||
</span>
|
</span>
|
||||||
{enc.nickname && (
|
{enc.nickname && (
|
||||||
<span className="text-[10px] text-gray-400">
|
<span className="text-[10px] text-gray-400">{displayPokemon.name}</span>
|
||||||
{displayPokemon.name}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-gray-400 mt-0.5">
|
<span className="text-[10px] text-gray-400 mt-0.5">{enc.route.name}</span>
|
||||||
{enc.route.name}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -5,7 +5,5 @@ interface TypeBadgeProps {
|
|||||||
|
|
||||||
export function TypeBadge({ type, size = 'sm' }: TypeBadgeProps) {
|
export function TypeBadge({ type, size = 'sm' }: TypeBadgeProps) {
|
||||||
const height = size === 'md' ? 'h-5' : 'h-4'
|
const height = size === 'md' ? 'h-5' : 'h-4'
|
||||||
return (
|
return <img src={`/types/${type}.png`} alt={type} className={`${height} w-auto`} />
|
||||||
<img src={`/types/${type}.png`} alt={type} className={`${height} w-auto`} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,10 +79,7 @@ export function AdminTable<T>({
|
|||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td
|
<td key={col.header} className={`px-4 py-3 ${col.className ?? ''}`}>
|
||||||
key={col.header}
|
|
||||||
className={`px-4 py-3 ${col.className ?? ''}`}
|
|
||||||
>
|
|
||||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
@@ -114,9 +111,7 @@ export function AdminTable<T>({
|
|||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={col.header}
|
key={col.header}
|
||||||
onClick={
|
onClick={sortable ? () => handleSort(col.header) : undefined}
|
||||||
sortable ? () => handleSort(col.header) : undefined
|
|
||||||
}
|
|
||||||
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''} ${sortable ? 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200' : ''}`}
|
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''} ${sortable ? 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
@@ -138,9 +133,7 @@ export function AdminTable<T>({
|
|||||||
key={keyFn(row)}
|
key={keyFn(row)}
|
||||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
className={
|
className={
|
||||||
onRowClick
|
onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''
|
||||||
? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800'
|
|
||||||
: ''
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { type FormEvent, useState } from 'react'
|
import { type FormEvent, useState } from 'react'
|
||||||
import { FormModal } from './FormModal'
|
import { FormModal } from './FormModal'
|
||||||
import type { BossBattle, Game, Route } from '../../types/game'
|
import type { BossBattle, Game, Route } from '../../types/game'
|
||||||
import type {
|
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
|
||||||
CreateBossBattleInput,
|
|
||||||
UpdateBossBattleInput,
|
|
||||||
} from '../../types/admin'
|
|
||||||
|
|
||||||
interface BossBattleFormModalProps {
|
interface BossBattleFormModalProps {
|
||||||
boss?: BossBattle
|
boss?: BossBattle
|
||||||
@@ -70,9 +67,7 @@ export function BossBattleFormModal({
|
|||||||
const [badgeImageUrl, setBadgeImageUrl] = useState(boss?.badgeImageUrl ?? '')
|
const [badgeImageUrl, setBadgeImageUrl] = useState(boss?.badgeImageUrl ?? '')
|
||||||
const [levelCap, setLevelCap] = useState(String(boss?.levelCap ?? ''))
|
const [levelCap, setLevelCap] = useState(String(boss?.levelCap ?? ''))
|
||||||
const [order, setOrder] = useState(String(boss?.order ?? nextOrder))
|
const [order, setOrder] = useState(String(boss?.order ?? nextOrder))
|
||||||
const [afterRouteId, setAfterRouteId] = useState(
|
const [afterRouteId, setAfterRouteId] = useState(String(boss?.afterRouteId ?? ''))
|
||||||
String(boss?.afterRouteId ?? '')
|
|
||||||
)
|
|
||||||
const [location, setLocation] = useState(boss?.location ?? '')
|
const [location, setLocation] = useState(boss?.location ?? '')
|
||||||
const [section, setSection] = useState(boss?.section ?? '')
|
const [section, setSection] = useState(boss?.section ?? '')
|
||||||
const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '')
|
const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '')
|
||||||
@@ -212,9 +207,7 @@ export function BossBattleFormModal({
|
|||||||
</div>
|
</div>
|
||||||
{games && games.length > 1 && (
|
{games && games.length > 1 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">Game (version exclusive)</label>
|
||||||
Game (version exclusive)
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={gameId}
|
value={gameId}
|
||||||
onChange={(e) => setGameId(e.target.value)}
|
onChange={(e) => setGameId(e.target.value)}
|
||||||
@@ -232,9 +225,7 @@ export function BossBattleFormModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">Position After Route</label>
|
||||||
Position After Route
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={afterRouteId}
|
value={afterRouteId}
|
||||||
onChange={(e) => setAfterRouteId(e.target.value)}
|
onChange={(e) => setAfterRouteId(e.target.value)}
|
||||||
@@ -261,9 +252,7 @@ export function BossBattleFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">Badge Image URL</label>
|
||||||
Badge Image URL
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={badgeImageUrl}
|
value={badgeImageUrl}
|
||||||
|
|||||||
@@ -53,34 +53,22 @@ function groupByVariant(boss: BossBattle): Variant[] {
|
|||||||
map.delete(null)
|
map.delete(null)
|
||||||
}
|
}
|
||||||
// Then alphabetical
|
// Then alphabetical
|
||||||
const remaining = [...map.entries()].sort((a, b) =>
|
const remaining = [...map.entries()].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? ''))
|
||||||
(a[0] ?? '').localeCompare(b[0] ?? '')
|
|
||||||
)
|
|
||||||
for (const [label, pokemon] of remaining) {
|
for (const [label, pokemon] of remaining) {
|
||||||
variants.push({ label, pokemon })
|
variants.push({ label, pokemon })
|
||||||
}
|
}
|
||||||
return variants
|
return variants
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BossTeamEditor({
|
export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEditorProps) {
|
||||||
boss,
|
const [variants, setVariants] = useState<Variant[]>(() => groupByVariant(boss))
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
isSaving,
|
|
||||||
}: BossTeamEditorProps) {
|
|
||||||
const [variants, setVariants] = useState<Variant[]>(() =>
|
|
||||||
groupByVariant(boss)
|
|
||||||
)
|
|
||||||
const [activeTab, setActiveTab] = useState(0)
|
const [activeTab, setActiveTab] = useState(0)
|
||||||
const [newVariantName, setNewVariantName] = useState('')
|
const [newVariantName, setNewVariantName] = useState('')
|
||||||
const [showAddVariant, setShowAddVariant] = useState(false)
|
const [showAddVariant, setShowAddVariant] = useState(false)
|
||||||
|
|
||||||
const activeVariant = variants[activeTab] ?? variants[0]
|
const activeVariant = variants[activeTab] ?? variants[0]
|
||||||
|
|
||||||
const updateVariant = (
|
const updateVariant = (tabIndex: number, updater: (v: Variant) => Variant) => {
|
||||||
tabIndex: number,
|
|
||||||
updater: (v: Variant) => Variant
|
|
||||||
) => {
|
|
||||||
setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v)))
|
setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,16 +96,10 @@ export function BossTeamEditor({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSlot = (
|
const updateSlot = (index: number, field: string, value: number | string | null) => {
|
||||||
index: number,
|
|
||||||
field: string,
|
|
||||||
value: number | string | null
|
|
||||||
) => {
|
|
||||||
updateVariant(activeTab, (v) => ({
|
updateVariant(activeTab, (v) => ({
|
||||||
...v,
|
...v,
|
||||||
pokemon: v.pokemon.map((item, i) =>
|
pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
|
||||||
i === index ? { ...item, [field]: value } : item
|
|
||||||
),
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +120,9 @@ export function BossTeamEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeVariant = (tabIndex: number) => {
|
const removeVariant = (tabIndex: number) => {
|
||||||
if (variants[tabIndex].label === null) return
|
const variant = variants[tabIndex]
|
||||||
if (!window.confirm(`Remove variant "${variants[tabIndex].label}"?`)) return
|
if (!variant || variant.label === null) return
|
||||||
|
if (!window.confirm(`Remove variant "${variant.label}"?`)) return
|
||||||
setVariants((prev) => prev.filter((_, i) => i !== tabIndex))
|
setVariants((prev) => prev.filter((_, i) => i !== tabIndex))
|
||||||
setActiveTab((prev) => Math.min(prev, variants.length - 2))
|
setActiveTab((prev) => Math.min(prev, variants.length - 2))
|
||||||
}
|
}
|
||||||
@@ -148,15 +131,14 @@ export function BossTeamEditor({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const allPokemon: BossPokemonInput[] = []
|
const allPokemon: BossPokemonInput[] = []
|
||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
const conditionLabel =
|
const conditionLabel = variants.length === 1 && variant.label === null ? null : variant.label
|
||||||
variants.length === 1 && variant.label === null ? null : variant.label
|
const validPokemon = variant.pokemon.filter((t) => t.pokemonId != null && t.level)
|
||||||
const validPokemon = variant.pokemon.filter(
|
|
||||||
(t) => t.pokemonId != null && t.level
|
|
||||||
)
|
|
||||||
for (let i = 0; i < validPokemon.length; i++) {
|
for (let i = 0; i < validPokemon.length; i++) {
|
||||||
|
const p = validPokemon[i]
|
||||||
|
if (!p?.pokemonId) continue
|
||||||
allPokemon.push({
|
allPokemon.push({
|
||||||
pokemonId: validPokemon[i].pokemonId!,
|
pokemonId: p.pokemonId,
|
||||||
level: Number(validPokemon[i].level),
|
level: Number(p.level),
|
||||||
order: i + 1,
|
order: i + 1,
|
||||||
conditionLabel,
|
conditionLabel,
|
||||||
})
|
})
|
||||||
@@ -247,11 +229,8 @@ export function BossTeamEditor({
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="px-6 py-4 space-y-3">
|
<div className="px-6 py-4 space-y-3">
|
||||||
{activeVariant.pokemon.map((slot, index) => (
|
{activeVariant?.pokemon.map((slot, index) => (
|
||||||
<div
|
<div key={`${activeTab}-${index}`} className="flex items-end gap-2">
|
||||||
key={`${activeTab}-${index}`}
|
|
||||||
className="flex items-end gap-2"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<PokemonSelector
|
<PokemonSelector
|
||||||
label={`Pokemon ${index + 1}`}
|
label={`Pokemon ${index + 1}`}
|
||||||
@@ -261,9 +240,7 @@ export function BossTeamEditor({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-20">
|
<div className="w-20">
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">Level</label>
|
||||||
Level
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
@@ -284,7 +261,7 @@ export function BossTeamEditor({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{activeVariant.pokemon.length < 6 && (
|
{activeVariant && activeVariant.pokemon.length < 6 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addSlot}
|
onClick={addSlot}
|
||||||
|
|||||||
@@ -60,9 +60,7 @@ export function BulkImportModal({
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="px-6 py-4 space-y-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">JSON Data</label>
|
||||||
JSON Data
|
|
||||||
</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
rows={12}
|
rows={12}
|
||||||
value={json}
|
value={json}
|
||||||
@@ -81,8 +79,7 @@ export function BulkImportModal({
|
|||||||
{result && (
|
{result && (
|
||||||
<div className="p-3 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-md text-sm">
|
<div className="p-3 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-md text-sm">
|
||||||
<p>
|
<p>
|
||||||
{createdLabel}: {result.created}, {updatedLabel}:{' '}
|
{createdLabel}: {result.created}, {updatedLabel}: {result.updated}
|
||||||
{result.updated}
|
|
||||||
</p>
|
</p>
|
||||||
{result.errors.length > 0 && (
|
{result.errors.length > 0 && (
|
||||||
<ul className="mt-2 list-disc list-inside text-red-600 dark:text-red-400">
|
<ul className="mt-2 list-disc list-inside text-red-600 dark:text-red-400">
|
||||||
|
|||||||
@@ -20,17 +20,9 @@ export function DeleteConfirmModal({
|
|||||||
<div className="fixed inset-0 bg-black/50" onClick={onCancel} />
|
<div className="fixed inset-0 bg-black/50" onClick={onCancel} />
|
||||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-red-600 dark:text-red-400">
|
<h2 className="text-lg font-semibold text-red-600 dark:text-red-400">{title}</h2>
|
||||||
{title}
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">{message}</p>
|
||||||
</h2>
|
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
{error && (
|
|
||||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { type FormEvent, useState } from 'react'
|
import { type FormEvent, useState } from 'react'
|
||||||
import { FormModal } from './FormModal'
|
import { FormModal } from './FormModal'
|
||||||
import { PokemonSelector } from './PokemonSelector'
|
import { PokemonSelector } from './PokemonSelector'
|
||||||
import type {
|
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
|
||||||
EvolutionAdmin,
|
|
||||||
CreateEvolutionInput,
|
|
||||||
UpdateEvolutionInput,
|
|
||||||
} from '../../types'
|
|
||||||
|
|
||||||
interface EvolutionFormModalProps {
|
interface EvolutionFormModalProps {
|
||||||
evolution?: EvolutionAdmin
|
evolution?: EvolutionAdmin
|
||||||
@@ -29,9 +25,7 @@ export function EvolutionFormModal({
|
|||||||
const [fromPokemonId, setFromPokemonId] = useState<number | null>(
|
const [fromPokemonId, setFromPokemonId] = useState<number | null>(
|
||||||
evolution?.fromPokemonId ?? null
|
evolution?.fromPokemonId ?? null
|
||||||
)
|
)
|
||||||
const [toPokemonId, setToPokemonId] = useState<number | null>(
|
const [toPokemonId, setToPokemonId] = useState<number | null>(evolution?.toPokemonId ?? null)
|
||||||
evolution?.toPokemonId ?? null
|
|
||||||
)
|
|
||||||
const [trigger, setTrigger] = useState(evolution?.trigger ?? 'level-up')
|
const [trigger, setTrigger] = useState(evolution?.trigger ?? 'level-up')
|
||||||
const [minLevel, setMinLevel] = useState(String(evolution?.minLevel ?? ''))
|
const [minLevel, setMinLevel] = useState(String(evolution?.minLevel ?? ''))
|
||||||
const [item, setItem] = useState(evolution?.item ?? '')
|
const [item, setItem] = useState(evolution?.item ?? '')
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ interface FormModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (e: FormEvent) => void
|
onSubmit: (e: FormEvent) => void
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
submitLabel?: string
|
submitLabel?: string | undefined
|
||||||
isSubmitting?: boolean
|
isSubmitting?: boolean | undefined
|
||||||
onDelete?: () => void
|
onDelete?: (() => void) | undefined
|
||||||
isDeleting?: boolean
|
isDeleting?: boolean | undefined
|
||||||
headerExtra?: ReactNode
|
headerExtra?: ReactNode | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormModal({
|
export function FormModal({
|
||||||
@@ -55,11 +55,7 @@ export function FormModal({
|
|||||||
onBlur={() => setConfirmingDelete(false)}
|
onBlur={() => setConfirmingDelete(false)}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium rounded-md text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isDeleting
|
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
|
||||||
? 'Deleting...'
|
|
||||||
: confirmingDelete
|
|
||||||
? 'Confirm?'
|
|
||||||
: 'Delete'}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ export function GameFormModal({
|
|||||||
const [generation, setGeneration] = useState(String(game?.generation ?? ''))
|
const [generation, setGeneration] = useState(String(game?.generation ?? ''))
|
||||||
const [region, setRegion] = useState(game?.region ?? '')
|
const [region, setRegion] = useState(game?.region ?? '')
|
||||||
const [boxArtUrl, setBoxArtUrl] = useState(game?.boxArtUrl ?? '')
|
const [boxArtUrl, setBoxArtUrl] = useState(game?.boxArtUrl ?? '')
|
||||||
const [releaseYear, setReleaseYear] = useState(
|
const [releaseYear, setReleaseYear] = useState(game?.releaseYear ? String(game.releaseYear) : '')
|
||||||
game?.releaseYear ? String(game.releaseYear) : ''
|
|
||||||
)
|
|
||||||
const [autoSlug, setAutoSlug] = useState(!game)
|
const [autoSlug, setAutoSlug] = useState(!game)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,10 +63,7 @@ export function GameFormModal({
|
|||||||
isDeleting={isDeleting}
|
isDeleting={isDeleting}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
detailUrl ? (
|
detailUrl ? (
|
||||||
<Link
|
<Link to={detailUrl} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
to={detailUrl}
|
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
View Routes & Bosses
|
View Routes & Bosses
|
||||||
</Link>
|
</Link>
|
||||||
) : undefined
|
) : undefined
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ import type {
|
|||||||
EvolutionAdmin,
|
EvolutionAdmin,
|
||||||
UpdateEvolutionInput,
|
UpdateEvolutionInput,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import {
|
import { usePokemonEncounterLocations, usePokemonEvolutionChain } from '../../hooks/usePokemon'
|
||||||
usePokemonEncounterLocations,
|
|
||||||
usePokemonEvolutionChain,
|
|
||||||
} from '../../hooks/usePokemon'
|
|
||||||
import { useUpdateEvolution, useDeleteEvolution } from '../../hooks/useAdmin'
|
import { useUpdateEvolution, useDeleteEvolution } from '../../hooks/useAdmin'
|
||||||
import { formatEvolutionMethod } from '../../utils/formatEvolution'
|
import { formatEvolutionMethod } from '../../utils/formatEvolution'
|
||||||
|
|
||||||
@@ -36,23 +33,19 @@ export function PokemonFormModal({
|
|||||||
isDeleting,
|
isDeleting,
|
||||||
}: PokemonFormModalProps) {
|
}: PokemonFormModalProps) {
|
||||||
const [pokeapiId, setPokeapiId] = useState(String(pokemon?.pokeapiId ?? ''))
|
const [pokeapiId, setPokeapiId] = useState(String(pokemon?.pokeapiId ?? ''))
|
||||||
const [nationalDex, setNationalDex] = useState(
|
const [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? ''))
|
||||||
String(pokemon?.nationalDex ?? '')
|
|
||||||
)
|
|
||||||
const [name, setName] = useState(pokemon?.name ?? '')
|
const [name, setName] = useState(pokemon?.name ?? '')
|
||||||
const [types, setTypes] = useState(pokemon?.types.join(', ') ?? '')
|
const [types, setTypes] = useState(pokemon?.types.join(', ') ?? '')
|
||||||
const [spriteUrl, setSpriteUrl] = useState(pokemon?.spriteUrl ?? '')
|
const [spriteUrl, setSpriteUrl] = useState(pokemon?.spriteUrl ?? '')
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('details')
|
const [activeTab, setActiveTab] = useState<Tab>('details')
|
||||||
const [editingEvolution, setEditingEvolution] =
|
const [editingEvolution, setEditingEvolution] = useState<EvolutionAdmin | null>(null)
|
||||||
useState<EvolutionAdmin | null>(null)
|
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||||
|
|
||||||
const isEdit = !!pokemon
|
const isEdit = !!pokemon
|
||||||
const pokemonId = pokemon?.id ?? null
|
const pokemonId = pokemon?.id ?? null
|
||||||
const { data: encounterLocations, isLoading: encountersLoading } =
|
const { data: encounterLocations, isLoading: encountersLoading } =
|
||||||
usePokemonEncounterLocations(pokemonId)
|
usePokemonEncounterLocations(pokemonId)
|
||||||
const { data: evolutionChain, isLoading: evolutionsLoading } =
|
const { data: evolutionChain, isLoading: evolutionsLoading } = usePokemonEvolutionChain(pokemonId)
|
||||||
usePokemonEvolutionChain(pokemonId)
|
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const updateEvolution = useUpdateEvolution()
|
const updateEvolution = useUpdateEvolution()
|
||||||
@@ -103,9 +96,7 @@ export function PokemonFormModal({
|
|||||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] flex flex-col">
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">{pokemon ? 'Edit Pokemon' : 'Add Pokemon'}</h2>
|
||||||
{pokemon ? 'Edit Pokemon' : 'Add Pokemon'}
|
|
||||||
</h2>
|
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<div className="flex gap-1 mt-2">
|
<div className="flex gap-1 mt-2">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
@@ -124,15 +115,10 @@ export function PokemonFormModal({
|
|||||||
|
|
||||||
{/* Details tab (form) */}
|
{/* Details tab (form) */}
|
||||||
{activeTab === 'details' && (
|
{activeTab === 'details' && (
|
||||||
<form
|
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="flex flex-col min-h-0 flex-1"
|
|
||||||
>
|
|
||||||
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">PokeAPI ID</label>
|
||||||
PokeAPI ID
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
@@ -143,9 +129,7 @@ export function PokemonFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">National Dex #</label>
|
||||||
National Dex #
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
@@ -166,9 +150,7 @@ export function PokemonFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">Types (comma-separated)</label>
|
||||||
Types (comma-separated)
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@@ -179,9 +161,7 @@ export function PokemonFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">Sprite URL</label>
|
||||||
Sprite URL
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={spriteUrl}
|
value={spriteUrl}
|
||||||
@@ -206,11 +186,7 @@ export function PokemonFormModal({
|
|||||||
onBlur={() => setConfirmingDelete(false)}
|
onBlur={() => setConfirmingDelete(false)}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
|
className="px-4 py-2 text-sm font-medium rounded-md text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isDeleting
|
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
|
||||||
? 'Deleting...'
|
|
||||||
: confirmingDelete
|
|
||||||
? 'Confirm?'
|
|
||||||
: 'Delete'}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
@@ -237,19 +213,12 @@ export function PokemonFormModal({
|
|||||||
<div className="flex flex-col min-h-0 flex-1">
|
<div className="flex flex-col min-h-0 flex-1">
|
||||||
<div className="px-6 py-4 overflow-y-auto">
|
<div className="px-6 py-4 overflow-y-auto">
|
||||||
{evolutionsLoading && (
|
{evolutionsLoading && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||||
Loading...
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{!evolutionsLoading &&
|
{!evolutionsLoading && (!evolutionChain || evolutionChain.length === 0) && (
|
||||||
(!evolutionChain || evolutionChain.length === 0) && (
|
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
No evolutions
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{!evolutionsLoading &&
|
{!evolutionsLoading && evolutionChain && evolutionChain.length > 0 && (
|
||||||
evolutionChain &&
|
|
||||||
evolutionChain.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{evolutionChain.map((evo) => (
|
{evolutionChain.map((evo) => (
|
||||||
<button
|
<button
|
||||||
@@ -284,19 +253,12 @@ export function PokemonFormModal({
|
|||||||
<div className="flex flex-col min-h-0 flex-1">
|
<div className="flex flex-col min-h-0 flex-1">
|
||||||
<div className="px-6 py-4 overflow-y-auto">
|
<div className="px-6 py-4 overflow-y-auto">
|
||||||
{encountersLoading && (
|
{encountersLoading && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||||
Loading...
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{!encountersLoading &&
|
{!encountersLoading && (!encounterLocations || encounterLocations.length === 0) && (
|
||||||
(!encounterLocations || encounterLocations.length === 0) && (
|
<p className="text-sm text-gray-500 dark:text-gray-400">No encounters</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
No encounters
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{!encountersLoading &&
|
{!encountersLoading && encounterLocations && encounterLocations.length > 0 && (
|
||||||
encounterLocations &&
|
|
||||||
encounterLocations.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{encounterLocations.map((game) => (
|
{encounterLocations.map((game) => (
|
||||||
<div key={game.gameId}>
|
<div key={game.gameId}>
|
||||||
@@ -316,8 +278,7 @@ export function PokemonFormModal({
|
|||||||
{enc.routeName}
|
{enc.routeName}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-gray-400 dark:text-gray-500">
|
<span className="text-gray-400 dark:text-gray-500">
|
||||||
— {enc.encounterMethod}, Lv. {enc.minLevel}–
|
— {enc.encounterMethod}, Lv. {enc.minLevel}–{enc.maxLevel}
|
||||||
{enc.maxLevel}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { usePokemonList } from '../../hooks/useAdmin'
|
|||||||
interface PokemonSelectorProps {
|
interface PokemonSelectorProps {
|
||||||
label: string
|
label: string
|
||||||
selectedId: number | null
|
selectedId: number | null
|
||||||
initialName?: string
|
initialName?: string | undefined
|
||||||
onChange: (id: number | null) => void
|
onChange: (id: number | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,9 +46,7 @@ export function PokemonSelector({
|
|||||||
placeholder="Search pokemon..."
|
placeholder="Search pokemon..."
|
||||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
{selectedId && (
|
{selectedId && <input type="hidden" name={label} value={selectedId} required />}
|
||||||
<input type="hidden" name={label} value={selectedId} required />
|
|
||||||
)}
|
|
||||||
{open && pokemon.length > 0 && (
|
{open && pokemon.length > 0 && (
|
||||||
<ul className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg max-h-48 overflow-y-auto">
|
<ul className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||||
{pokemon.map((p) => (
|
{pokemon.map((p) => (
|
||||||
@@ -63,9 +61,7 @@ export function PokemonSelector({
|
|||||||
p.id === selectedId ? 'bg-blue-50 dark:bg-blue-900/30' : ''
|
p.id === selectedId ? 'bg-blue-50 dark:bg-blue-900/30' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{p.spriteUrl && (
|
{p.spriteUrl && <img src={p.spriteUrl} alt="" className="w-6 h-6" />}
|
||||||
<img src={p.spriteUrl} alt="" className="w-6 h-6" />
|
|
||||||
)}
|
|
||||||
<span>
|
<span>
|
||||||
#{p.nationalDex} {p.name}
|
#{p.nationalDex} {p.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { type FormEvent, useState } from 'react'
|
import { type FormEvent, useState } from 'react'
|
||||||
import { FormModal } from './FormModal'
|
import { FormModal } from './FormModal'
|
||||||
import { PokemonSelector } from './PokemonSelector'
|
import { PokemonSelector } from './PokemonSelector'
|
||||||
import {
|
import { METHOD_ORDER, METHOD_CONFIG, getMethodLabel } from '../EncounterMethodBadge'
|
||||||
METHOD_ORDER,
|
|
||||||
METHOD_CONFIG,
|
|
||||||
getMethodLabel,
|
|
||||||
} from '../EncounterMethodBadge'
|
|
||||||
import type {
|
import type {
|
||||||
RouteEncounterDetail,
|
RouteEncounterDetail,
|
||||||
CreateRouteEncounterInput,
|
CreateRouteEncounterInput,
|
||||||
@@ -14,9 +10,7 @@ import type {
|
|||||||
|
|
||||||
interface RouteEncounterFormModalProps {
|
interface RouteEncounterFormModalProps {
|
||||||
encounter?: RouteEncounterDetail
|
encounter?: RouteEncounterDetail
|
||||||
onSubmit: (
|
onSubmit: (data: CreateRouteEncounterInput | UpdateRouteEncounterInput) => void
|
||||||
data: CreateRouteEncounterInput | UpdateRouteEncounterInput
|
|
||||||
) => void
|
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
isSubmitting?: boolean
|
isSubmitting?: boolean
|
||||||
onDelete?: () => void
|
onDelete?: () => void
|
||||||
@@ -38,15 +32,10 @@ export function RouteEncounterFormModal({
|
|||||||
const [selectedMethod, setSelectedMethod] = useState(
|
const [selectedMethod, setSelectedMethod] = useState(
|
||||||
isKnownMethod ? initialMethod : initialMethod ? 'other' : ''
|
isKnownMethod ? initialMethod : initialMethod ? 'other' : ''
|
||||||
)
|
)
|
||||||
const [customMethod, setCustomMethod] = useState(
|
const [customMethod, setCustomMethod] = useState(isKnownMethod ? '' : initialMethod)
|
||||||
isKnownMethod ? '' : initialMethod
|
const encounterMethod = selectedMethod === 'other' ? customMethod : selectedMethod
|
||||||
)
|
|
||||||
const encounterMethod =
|
|
||||||
selectedMethod === 'other' ? customMethod : selectedMethod
|
|
||||||
|
|
||||||
const [encounterRate, setEncounterRate] = useState(
|
const [encounterRate, setEncounterRate] = useState(String(encounter?.encounterRate ?? ''))
|
||||||
String(encounter?.encounterRate ?? '')
|
|
||||||
)
|
|
||||||
const [minLevel, setMinLevel] = useState(String(encounter?.minLevel ?? ''))
|
const [minLevel, setMinLevel] = useState(String(encounter?.minLevel ?? ''))
|
||||||
const [maxLevel, setMaxLevel] = useState(String(encounter?.maxLevel ?? ''))
|
const [maxLevel, setMaxLevel] = useState(String(encounter?.maxLevel ?? ''))
|
||||||
|
|
||||||
@@ -87,9 +76,7 @@ export function RouteEncounterFormModal({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">Encounter Method</label>
|
||||||
Encounter Method
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
value={selectedMethod}
|
value={selectedMethod}
|
||||||
@@ -126,9 +113,7 @@ export function RouteEncounterFormModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">Encounter Rate (%)</label>
|
||||||
Encounter Rate (%)
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -49,10 +49,7 @@ export function RouteFormModal({
|
|||||||
isDeleting={isDeleting}
|
isDeleting={isDeleting}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
detailUrl ? (
|
detailUrl ? (
|
||||||
<Link
|
<Link to={detailUrl} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
to={detailUrl}
|
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
View Encounters
|
View Encounters
|
||||||
</Link>
|
</Link>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -90,8 +87,7 @@ export function RouteFormModal({
|
|||||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Routes in the same zone share an encounter when the Pinwheel Clause is
|
Routes in the same zone share an encounter when the Pinwheel Clause is active
|
||||||
active
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|||||||
@@ -23,12 +23,7 @@ import type {
|
|||||||
|
|
||||||
// --- Queries ---
|
// --- Queries ---
|
||||||
|
|
||||||
export function usePokemonList(
|
export function usePokemonList(search?: string, limit = 50, offset = 0, type?: string) {
|
||||||
search?: string,
|
|
||||||
limit = 50,
|
|
||||||
offset = 0,
|
|
||||||
type?: string
|
|
||||||
) {
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['pokemon', { search, limit, offset, type }],
|
queryKey: ['pokemon', { search, limit, offset, type }],
|
||||||
queryFn: () => adminApi.listPokemon(search, limit, offset, type),
|
queryFn: () => adminApi.listPokemon(search, limit, offset, type),
|
||||||
@@ -92,13 +87,8 @@ export function useCreateRoute(gameId: number) {
|
|||||||
export function useUpdateRoute(gameId: number) {
|
export function useUpdateRoute(gameId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({ routeId, data }: { routeId: number; data: UpdateRouteInput }) =>
|
||||||
routeId,
|
adminApi.updateRoute(gameId, routeId, data),
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
routeId: number
|
|
||||||
data: UpdateRouteInput
|
|
||||||
}) => adminApi.updateRoute(gameId, routeId, data),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
||||||
@@ -124,8 +114,7 @@ export function useDeleteRoute(gameId: number) {
|
|||||||
export function useReorderRoutes(gameId: number) {
|
export function useReorderRoutes(gameId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (routes: RouteReorderItem[]) =>
|
mutationFn: (routes: RouteReorderItem[]) => adminApi.reorderRoutes(gameId, routes),
|
||||||
adminApi.reorderRoutes(gameId, routes),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
||||||
@@ -188,9 +177,7 @@ export function useBulkImportPokemon() {
|
|||||||
) => adminApi.bulkImportPokemon(items),
|
) => adminApi.bulkImportPokemon(items),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
qc.invalidateQueries({ queryKey: ['pokemon'] })
|
qc.invalidateQueries({ queryKey: ['pokemon'] })
|
||||||
toast.success(
|
toast.success(`Import complete: ${result.created} created, ${result.updated} updated`)
|
||||||
`Import complete: ${result.created} created, ${result.updated} updated`
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
||||||
})
|
})
|
||||||
@@ -202,9 +189,7 @@ export function useBulkImportEvolutions() {
|
|||||||
mutationFn: (items: unknown[]) => adminApi.bulkImportEvolutions(items),
|
mutationFn: (items: unknown[]) => adminApi.bulkImportEvolutions(items),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
qc.invalidateQueries({ queryKey: ['evolutions'] })
|
qc.invalidateQueries({ queryKey: ['evolutions'] })
|
||||||
toast.success(
|
toast.success(`Import complete: ${result.created} created, ${result.updated} updated`)
|
||||||
`Import complete: ${result.created} created, ${result.updated} updated`
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
||||||
})
|
})
|
||||||
@@ -217,9 +202,7 @@ export function useBulkImportRoutes(gameId: number) {
|
|||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
||||||
toast.success(
|
toast.success(`Import complete: ${result.created} routes, ${result.updated} encounters`)
|
||||||
`Import complete: ${result.created} routes, ${result.updated} encounters`
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
||||||
})
|
})
|
||||||
@@ -239,12 +222,7 @@ export function useBulkImportBosses(gameId: number) {
|
|||||||
|
|
||||||
// --- Evolution Queries & Mutations ---
|
// --- Evolution Queries & Mutations ---
|
||||||
|
|
||||||
export function useEvolutionList(
|
export function useEvolutionList(search?: string, limit = 50, offset = 0, trigger?: string) {
|
||||||
search?: string,
|
|
||||||
limit = 50,
|
|
||||||
offset = 0,
|
|
||||||
trigger?: string
|
|
||||||
) {
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['evolutions', { search, limit, offset, trigger }],
|
queryKey: ['evolutions', { search, limit, offset, trigger }],
|
||||||
queryFn: () => adminApi.listEvolutions(search, limit, offset, trigger),
|
queryFn: () => adminApi.listEvolutions(search, limit, offset, trigger),
|
||||||
@@ -293,8 +271,7 @@ export function useDeleteEvolution() {
|
|||||||
export function useAddRouteEncounter(routeId: number) {
|
export function useAddRouteEncounter(routeId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: CreateRouteEncounterInput) =>
|
mutationFn: (data: CreateRouteEncounterInput) => adminApi.addRouteEncounter(routeId, data),
|
||||||
adminApi.addRouteEncounter(routeId, data),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
|
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
|
||||||
toast.success('Encounter added')
|
toast.success('Encounter added')
|
||||||
@@ -306,13 +283,8 @@ export function useAddRouteEncounter(routeId: number) {
|
|||||||
export function useUpdateRouteEncounter(routeId: number) {
|
export function useUpdateRouteEncounter(routeId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({ encounterId, data }: { encounterId: number; data: UpdateRouteEncounterInput }) =>
|
||||||
encounterId,
|
adminApi.updateRouteEncounter(routeId, encounterId, data),
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
encounterId: number
|
|
||||||
data: UpdateRouteEncounterInput
|
|
||||||
}) => adminApi.updateRouteEncounter(routeId, encounterId, data),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
|
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
|
||||||
toast.success('Encounter updated')
|
toast.success('Encounter updated')
|
||||||
@@ -324,8 +296,7 @@ export function useUpdateRouteEncounter(routeId: number) {
|
|||||||
export function useRemoveRouteEncounter(routeId: number) {
|
export function useRemoveRouteEncounter(routeId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (encounterId: number) =>
|
mutationFn: (encounterId: number) => adminApi.removeRouteEncounter(routeId, encounterId),
|
||||||
adminApi.removeRouteEncounter(routeId, encounterId),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
|
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
|
||||||
toast.success('Encounter removed')
|
toast.success('Encounter removed')
|
||||||
@@ -339,41 +310,32 @@ export function useRemoveRouteEncounter(routeId: number) {
|
|||||||
export function useCreateBossBattle(gameId: number) {
|
export function useCreateBossBattle(gameId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: CreateBossBattleInput) =>
|
mutationFn: (data: CreateBossBattleInput) => adminApi.createBossBattle(gameId, data),
|
||||||
adminApi.createBossBattle(gameId, data),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
|
||||||
toast.success('Boss battle created')
|
toast.success('Boss battle created')
|
||||||
},
|
},
|
||||||
onError: (err) =>
|
onError: (err) => toast.error(`Failed to create boss battle: ${err.message}`),
|
||||||
toast.error(`Failed to create boss battle: ${err.message}`),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateBossBattle(gameId: number) {
|
export function useUpdateBossBattle(gameId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({ bossId, data }: { bossId: number; data: UpdateBossBattleInput }) =>
|
||||||
bossId,
|
adminApi.updateBossBattle(gameId, bossId, data),
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
bossId: number
|
|
||||||
data: UpdateBossBattleInput
|
|
||||||
}) => adminApi.updateBossBattle(gameId, bossId, data),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
|
||||||
toast.success('Boss battle updated')
|
toast.success('Boss battle updated')
|
||||||
},
|
},
|
||||||
onError: (err) =>
|
onError: (err) => toast.error(`Failed to update boss battle: ${err.message}`),
|
||||||
toast.error(`Failed to update boss battle: ${err.message}`),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useReorderBosses(gameId: number) {
|
export function useReorderBosses(gameId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (bosses: BossReorderItem[]) =>
|
mutationFn: (bosses: BossReorderItem[]) => adminApi.reorderBosses(gameId, bosses),
|
||||||
adminApi.reorderBosses(gameId, bosses),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
|
||||||
toast.success('Bosses reordered')
|
toast.success('Bosses reordered')
|
||||||
@@ -390,16 +352,14 @@ export function useDeleteBossBattle(gameId: number) {
|
|||||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
|
||||||
toast.success('Boss battle deleted')
|
toast.success('Boss battle deleted')
|
||||||
},
|
},
|
||||||
onError: (err) =>
|
onError: (err) => toast.error(`Failed to delete boss battle: ${err.message}`),
|
||||||
toast.error(`Failed to delete boss battle: ${err.message}`),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSetBossTeam(gameId: number, bossId: number) {
|
export function useSetBossTeam(gameId: number, bossId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (team: BossPokemonInput[]) =>
|
mutationFn: (team: BossPokemonInput[]) => adminApi.setBossTeam(gameId, bossId, team),
|
||||||
adminApi.setBossTeam(gameId, bossId, team),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
|
||||||
toast.success('Boss team updated')
|
toast.success('Boss team updated')
|
||||||
@@ -438,8 +398,7 @@ export function useDeleteGenlocke() {
|
|||||||
export function useAddGenlockeLeg(genlockeId: number) {
|
export function useAddGenlockeLeg(genlockeId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: AddGenlockeLegInput) =>
|
mutationFn: (data: AddGenlockeLegInput) => adminApi.addGenlockeLeg(genlockeId, data),
|
||||||
adminApi.addGenlockeLeg(genlockeId, data),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['genlockes'] })
|
qc.invalidateQueries({ queryKey: ['genlockes'] })
|
||||||
qc.invalidateQueries({ queryKey: ['genlockes', genlockeId] })
|
qc.invalidateQueries({ queryKey: ['genlockes', genlockeId] })
|
||||||
@@ -452,8 +411,7 @@ export function useAddGenlockeLeg(genlockeId: number) {
|
|||||||
export function useDeleteGenlockeLeg(genlockeId: number) {
|
export function useDeleteGenlockeLeg(genlockeId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (legId: number) =>
|
mutationFn: (legId: number) => adminApi.deleteGenlockeLeg(genlockeId, legId),
|
||||||
adminApi.deleteGenlockeLeg(genlockeId, legId),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['genlockes'] })
|
qc.invalidateQueries({ queryKey: ['genlockes'] })
|
||||||
qc.invalidateQueries({ queryKey: ['genlockes', genlockeId] })
|
qc.invalidateQueries({ queryKey: ['genlockes', genlockeId] })
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import { getGameBosses, getBossResults, createBossResult, deleteBossResult } from '../api/bosses'
|
||||||
getGameBosses,
|
|
||||||
getBossResults,
|
|
||||||
createBossResult,
|
|
||||||
deleteBossResult,
|
|
||||||
} from '../api/bosses'
|
|
||||||
import type { CreateBossResultInput } from '../types/game'
|
import type { CreateBossResultInput } from '../types/game'
|
||||||
|
|
||||||
export function useGameBosses(gameId: number | null, all?: boolean) {
|
export function useGameBosses(gameId: number | null, all?: boolean) {
|
||||||
|
|||||||
@@ -57,11 +57,7 @@ export function useCreateGenlocke() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLegSurvivors(
|
export function useLegSurvivors(genlockeId: number, legOrder: number, enabled: boolean) {
|
||||||
genlockeId: number,
|
|
||||||
legOrder: number,
|
|
||||||
enabled: boolean
|
|
||||||
) {
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['genlockes', genlockeId, 'legs', legOrder, 'survivors'],
|
queryKey: ['genlockes', genlockeId, 'legs', legOrder, 'survivors'],
|
||||||
queryFn: () => getLegSurvivors(genlockeId, legOrder),
|
queryFn: () => getLegSurvivors(genlockeId, legOrder),
|
||||||
@@ -81,11 +77,7 @@ export function useAdvanceLeg() {
|
|||||||
legOrder: number
|
legOrder: number
|
||||||
transferEncounterIds?: number[]
|
transferEncounterIds?: number[]
|
||||||
}) =>
|
}) =>
|
||||||
advanceLeg(
|
advanceLeg(genlockeId, legOrder, transferEncounterIds ? { transferEncounterIds } : undefined),
|
||||||
genlockeId,
|
|
||||||
legOrder,
|
|
||||||
transferEncounterIds ? { transferEncounterIds } : undefined
|
|
||||||
),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['runs'] })
|
queryClient.invalidateQueries({ queryKey: ['runs'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['genlockes'] })
|
queryClient.invalidateQueries({ queryKey: ['genlockes'] })
|
||||||
|
|||||||
@@ -68,10 +68,7 @@ export function useNamingCategories() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNameSuggestions(
|
export function useNameSuggestions(runId: number | null, pokemonId?: number | null) {
|
||||||
runId: number | null,
|
|
||||||
pokemonId?: number | null
|
|
||||||
) {
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['name-suggestions', runId, pokemonId ?? null],
|
queryKey: ['name-suggestions', runId, pokemonId ?? null],
|
||||||
queryFn: () => getNameSuggestions(runId!, 10, pokemonId ?? undefined),
|
queryFn: () => getNameSuggestions(runId!, 10, pokemonId ?? undefined),
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { useGenlocke } from '../hooks/useGenlockes'
|
import { useGenlocke } from '../hooks/useGenlockes'
|
||||||
import { usePokemonFamilies } from '../hooks/usePokemon'
|
import { usePokemonFamilies } from '../hooks/usePokemon'
|
||||||
import {
|
import { GenlockeGraveyard, GenlockeLineage, StatCard, RuleBadges } from '../components'
|
||||||
GenlockeGraveyard,
|
|
||||||
GenlockeLineage,
|
|
||||||
StatCard,
|
|
||||||
RuleBadges,
|
|
||||||
} from '../components'
|
|
||||||
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
|
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
@@ -23,8 +18,7 @@ const statusRing: Record<RunStatus, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusStyles: Record<RunStatus, string> = {
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
active:
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
|
||||||
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||||
}
|
}
|
||||||
@@ -48,19 +42,14 @@ function LegIndicator({ leg }: { leg: GenlockeLegDetail }) {
|
|||||||
{leg.game.name}
|
{leg.game.name}
|
||||||
</span>
|
</span>
|
||||||
{status && (
|
{status && (
|
||||||
<span className="text-[10px] text-gray-500 dark:text-gray-400 capitalize">
|
<span className="text-[10px] text-gray-500 dark:text-gray-400 capitalize">{status}</span>
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (hasRun) {
|
if (hasRun) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link to={`/runs/${leg.runId}`} className="hover:opacity-80 transition-opacity">
|
||||||
to={`/runs/${leg.runId}`}
|
|
||||||
className="hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
{content}
|
{content}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
@@ -86,7 +75,7 @@ function PokemonSprite({ pokemon }: { pokemon: RetiredPokemon }) {
|
|||||||
className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-sm font-bold"
|
className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-sm font-bold"
|
||||||
title={pokemon.name}
|
title={pokemon.name}
|
||||||
>
|
>
|
||||||
{pokemon.name[0].toUpperCase()}
|
{pokemon.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -116,9 +105,7 @@ export function GenlockeDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return genlocke.legs
|
return genlocke.legs
|
||||||
.filter(
|
.filter((leg) => leg.retiredPokemonIds && leg.retiredPokemonIds.length > 0)
|
||||||
(leg) => leg.retiredPokemonIds && leg.retiredPokemonIds.length > 0
|
|
||||||
)
|
|
||||||
.map((leg) => {
|
.map((leg) => {
|
||||||
// Find base Pokemon (lowest ID) for each family in this leg's retired list
|
// Find base Pokemon (lowest ID) for each family in this leg's retired list
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
@@ -170,16 +157,11 @@ export function GenlockeDetail() {
|
|||||||
<div className="max-w-4xl mx-auto p-8 space-y-8">
|
<div className="max-w-4xl mx-auto p-8 space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link to="/genlockes" className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
to="/genlockes"
|
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
← Back to Genlockes
|
← Back to Genlockes
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{genlocke.name}</h1>
|
||||||
{genlocke.name}
|
|
||||||
</h1>
|
|
||||||
<span
|
<span
|
||||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${statusStyles[genlocke.status]}`}
|
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${statusStyles[genlocke.status]}`}
|
||||||
>
|
>
|
||||||
@@ -190,9 +172,7 @@ export function GenlockeDetail() {
|
|||||||
|
|
||||||
{/* Progress Timeline */}
|
{/* Progress Timeline */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Progress</h2>
|
||||||
Progress
|
|
||||||
</h2>
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
<div className="flex items-start gap-2 overflow-x-auto pb-2">
|
<div className="flex items-start gap-2 overflow-x-auto pb-2">
|
||||||
{genlocke.legs.map((leg, i) => (
|
{genlocke.legs.map((leg, i) => (
|
||||||
@@ -201,9 +181,7 @@ export function GenlockeDetail() {
|
|||||||
{i < genlocke.legs.length - 1 && (
|
{i < genlocke.legs.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className={`h-0.5 w-6 mx-1 mt-[-16px] ${
|
className={`h-0.5 w-6 mx-1 mt-[-16px] ${
|
||||||
leg.runStatus === 'completed'
|
leg.runStatus === 'completed' ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
? 'bg-blue-500'
|
|
||||||
: 'bg-gray-300 dark:bg-gray-600'
|
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -219,16 +197,8 @@ export function GenlockeDetail() {
|
|||||||
Cumulative Stats
|
Cumulative Stats
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<StatCard label="Encounters" value={genlocke.stats.totalEncounters} color="blue" />
|
||||||
label="Encounters"
|
<StatCard label="Deaths" value={genlocke.stats.totalDeaths} color="red" />
|
||||||
value={genlocke.stats.totalEncounters}
|
|
||||||
color="blue"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Deaths"
|
|
||||||
value={genlocke.stats.totalDeaths}
|
|
||||||
color="red"
|
|
||||||
/>
|
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Legs Completed"
|
label="Legs Completed"
|
||||||
value={genlocke.stats.legsCompleted}
|
value={genlocke.stats.legsCompleted}
|
||||||
@@ -278,10 +248,7 @@ export function GenlockeDetail() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{retiredByLeg.map((leg) => (
|
{retiredByLeg.map((leg) => (
|
||||||
<div
|
<div key={leg.legOrder} className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||||
key={leg.legOrder}
|
|
||||||
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4"
|
|
||||||
>
|
|
||||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||||
Leg {leg.legOrder} — {leg.gameName}
|
Leg {leg.legOrder} — {leg.gameName}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { useGenlockes } from '../hooks/useGenlockes'
|
|||||||
import type { RunStatus } from '../types'
|
import type { RunStatus } from '../types'
|
||||||
|
|
||||||
const statusStyles: Record<RunStatus, string> = {
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
active:
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
|
||||||
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||||
}
|
}
|
||||||
@@ -15,9 +14,7 @@ export function GenlockeList() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-8">
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Your Genlockes</h1>
|
||||||
Your Genlockes
|
|
||||||
</h1>
|
|
||||||
<Link
|
<Link
|
||||||
to="/genlockes/new"
|
to="/genlockes/new"
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { useRuns } from '../hooks/useRuns'
|
|||||||
import type { RunStatus } from '../types'
|
import type { RunStatus } from '../types'
|
||||||
|
|
||||||
const statusStyles: Record<RunStatus, string> = {
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
active:
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
|
||||||
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||||
}
|
}
|
||||||
@@ -75,10 +74,7 @@ export function Home() {
|
|||||||
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||||
Recent Runs
|
Recent Runs
|
||||||
</h2>
|
</h2>
|
||||||
<Link
|
<Link to="/runs" className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
to="/runs"
|
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
View all
|
View all
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,9 +87,7 @@ export function Home() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
<h3 className="font-medium text-gray-900 dark:text-gray-100">{run.name}</h3>
|
||||||
{run.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ interface LegEntry {
|
|||||||
|
|
||||||
type PresetType = 'true' | 'normal' | 'custom' | null
|
type PresetType = 'true' | 'normal' | 'custom' | null
|
||||||
|
|
||||||
function buildLegsFromPreset(
|
function buildLegsFromPreset(regions: Region[], preset: 'true' | 'normal'): LegEntry[] {
|
||||||
regions: Region[],
|
|
||||||
preset: 'true' | 'normal'
|
|
||||||
): LegEntry[] {
|
|
||||||
const legs: LegEntry[] = []
|
const legs: LegEntry[] = []
|
||||||
for (const region of regions) {
|
for (const region of regions) {
|
||||||
const targetSlug =
|
const targetSlug =
|
||||||
@@ -45,8 +42,7 @@ export function NewGenlocke() {
|
|||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [legs, setLegs] = useState<LegEntry[]>([])
|
const [legs, setLegs] = useState<LegEntry[]>([])
|
||||||
const [preset, setPreset] = useState<PresetType>(null)
|
const [preset, setPreset] = useState<PresetType>(null)
|
||||||
const [nuzlockeRules, setNuzlockeRules] =
|
const [nuzlockeRules, setNuzlockeRules] = useState<NuzlockeRules>(DEFAULT_RULES)
|
||||||
useState<NuzlockeRules>(DEFAULT_RULES)
|
|
||||||
const [genlockeRules, setGenlockeRules] = useState<GenlockeRules>({
|
const [genlockeRules, setGenlockeRules] = useState<GenlockeRules>({
|
||||||
retireHoF: false,
|
retireHoF: false,
|
||||||
})
|
})
|
||||||
@@ -64,9 +60,7 @@ export function NewGenlocke() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGameChange = (index: number, game: Game) => {
|
const handleGameChange = (index: number, game: Game) => {
|
||||||
setLegs((prev) =>
|
setLegs((prev) => prev.map((leg, i) => (i === index ? { ...leg, game } : leg)))
|
||||||
prev.map((leg, i) => (i === index ? { ...leg, game } : leg))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveLeg = (index: number) => {
|
const handleRemoveLeg = (index: number) => {
|
||||||
@@ -75,8 +69,7 @@ export function NewGenlocke() {
|
|||||||
|
|
||||||
const handleAddLeg = (region: Region) => {
|
const handleAddLeg = (region: Region) => {
|
||||||
const defaultSlug = region.genlockeDefaults.normalGenlocke
|
const defaultSlug = region.genlockeDefaults.normalGenlocke
|
||||||
const game =
|
const game = region.games.find((g) => g.slug === defaultSlug) ?? region.games[0]
|
||||||
region.games.find((g) => g.slug === defaultSlug) ?? region.games[0]
|
|
||||||
if (game) {
|
if (game) {
|
||||||
setLegs((prev) => [...prev, { region: region.name, game }])
|
setLegs((prev) => [...prev, { region: region.name, game }])
|
||||||
}
|
}
|
||||||
@@ -87,7 +80,7 @@ export function NewGenlocke() {
|
|||||||
if (target < 0 || target >= legs.length) return
|
if (target < 0 || target >= legs.length) return
|
||||||
setLegs((prev) => {
|
setLegs((prev) => {
|
||||||
const next = [...prev]
|
const next = [...prev]
|
||||||
;[next[index], next[target]] = [next[target], next[index]]
|
;[next[index], next[target]] = [next[target]!, next[index]!]
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -115,23 +108,16 @@ export function NewGenlocke() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledRuleCount = RULE_DEFINITIONS.filter(
|
const enabledRuleCount = RULE_DEFINITIONS.filter((r) => nuzlockeRules[r.key]).length
|
||||||
(r) => nuzlockeRules[r.key]
|
|
||||||
).length
|
|
||||||
const totalRuleCount = RULE_DEFINITIONS.length
|
const totalRuleCount = RULE_DEFINITIONS.length
|
||||||
|
|
||||||
// Regions not yet used in legs (for "add leg" picker)
|
// Regions not yet used in legs (for "add leg" picker)
|
||||||
const availableRegions =
|
const availableRegions = regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? []
|
||||||
regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? []
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-8">
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">New Genlocke</h1>
|
||||||
New Genlocke
|
<p className="text-gray-600 dark:text-gray-400 mb-6">Set up your generational challenge.</p>
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
|
||||||
Set up your generational challenge.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<StepIndicator currentStep={step} onStepClick={setStep} steps={STEPS} />
|
<StepIndicator currentStep={step} onStepClick={setStep} steps={STEPS} />
|
||||||
|
|
||||||
@@ -250,15 +236,9 @@ export function NewGenlocke() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Also allow adding extra regions for presets */}
|
{/* Also allow adding extra regions for presets */}
|
||||||
{preset &&
|
{preset && preset !== 'custom' && availableRegions.length > 0 && legs.length > 0 && (
|
||||||
preset !== 'custom' &&
|
|
||||||
availableRegions.length > 0 &&
|
|
||||||
legs.length > 0 && (
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<AddLegDropdown
|
<AddLegDropdown regions={availableRegions} onAdd={handleAddLeg} />
|
||||||
regions={availableRegions}
|
|
||||||
onAdd={handleAddLeg}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -285,10 +265,7 @@ export function NewGenlocke() {
|
|||||||
{/* Step 3: Rules */}
|
{/* Step 3: Rules */}
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<div>
|
<div>
|
||||||
<RulesConfiguration
|
<RulesConfiguration rules={nuzlockeRules} onChange={setNuzlockeRules} />
|
||||||
rules={nuzlockeRules}
|
|
||||||
onChange={setNuzlockeRules}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Genlocke-specific rules */}
|
{/* Genlocke-specific rules */}
|
||||||
<div className="mt-6 bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="mt-6 bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
@@ -319,8 +296,7 @@ export function NewGenlocke() {
|
|||||||
Keep Hall of Fame
|
Keep Hall of Fame
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Pokemon that beat the Elite Four can continue to the
|
Pokemon that beat the Elite Four can continue to the next leg
|
||||||
next leg
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -337,8 +313,8 @@ export function NewGenlocke() {
|
|||||||
Retire Hall of Fame
|
Retire Hall of Fame
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Pokemon that beat the Elite Four are retired and cannot
|
Pokemon that beat the Elite Four are retired and cannot be used in the next
|
||||||
be used in the next leg
|
leg
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -354,8 +330,8 @@ export function NewGenlocke() {
|
|||||||
Naming Scheme
|
Naming Scheme
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Get nickname suggestions from a themed word list when catching
|
Get nickname suggestions from a themed word list when catching Pokemon. Applied to
|
||||||
Pokemon. Applied to all legs.
|
all legs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-4">
|
<div className="px-4 py-4">
|
||||||
@@ -402,12 +378,8 @@ export function NewGenlocke() {
|
|||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-4">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Name</h3>
|
||||||
Name
|
<p className="text-gray-900 dark:text-gray-100 font-medium">{name}</p>
|
||||||
</h3>
|
|
||||||
<p className="text-gray-900 dark:text-gray-100 font-medium">
|
|
||||||
{name}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
@@ -426,8 +398,7 @@ export function NewGenlocke() {
|
|||||||
{leg.game.name}
|
{leg.game.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400 ml-2">
|
<span className="text-sm text-gray-500 dark:text-gray-400 ml-2">
|
||||||
{leg.region.charAt(0).toUpperCase() +
|
{leg.region.charAt(0).toUpperCase() + leg.region.slice(1)}
|
||||||
leg.region.slice(1)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -436,34 +407,25 @@ export function NewGenlocke() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Rules</h3>
|
||||||
Rules
|
|
||||||
</h3>
|
|
||||||
<dl className="space-y-1 text-sm">
|
<dl className="space-y-1 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-gray-600 dark:text-gray-400">
|
<dt className="text-gray-600 dark:text-gray-400">Nuzlocke Rules</dt>
|
||||||
Nuzlocke Rules
|
|
||||||
</dt>
|
|
||||||
<dd className="text-gray-900 dark:text-gray-100 font-medium">
|
<dd className="text-gray-900 dark:text-gray-100 font-medium">
|
||||||
{enabledRuleCount} of {totalRuleCount} enabled
|
{enabledRuleCount} of {totalRuleCount} enabled
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-gray-600 dark:text-gray-400">
|
<dt className="text-gray-600 dark:text-gray-400">Hall of Fame</dt>
|
||||||
Hall of Fame
|
|
||||||
</dt>
|
|
||||||
<dd className="text-gray-900 dark:text-gray-100 font-medium">
|
<dd className="text-gray-900 dark:text-gray-100 font-medium">
|
||||||
{genlockeRules.retireHoF ? 'Retire' : 'Keep'}
|
{genlockeRules.retireHoF ? 'Retire' : 'Keep'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-gray-600 dark:text-gray-400">
|
<dt className="text-gray-600 dark:text-gray-400">Naming Scheme</dt>
|
||||||
Naming Scheme
|
|
||||||
</dt>
|
|
||||||
<dd className="text-gray-900 dark:text-gray-100 font-medium">
|
<dd className="text-gray-900 dark:text-gray-100 font-medium">
|
||||||
{namingScheme
|
{namingScheme
|
||||||
? namingScheme.charAt(0).toUpperCase() +
|
? namingScheme.charAt(0).toUpperCase() + namingScheme.slice(1)
|
||||||
namingScheme.slice(1)
|
|
||||||
: 'None'}
|
: 'None'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -548,9 +510,7 @@ function LegRow({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-gray-900 dark:text-gray-100 font-medium">
|
<div className="text-gray-900 dark:text-gray-100 font-medium">{leg.game.name}</div>
|
||||||
{leg.game.name}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
@@ -568,11 +528,7 @@ function LegRow({
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M5 15l7-7 7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -589,11 +545,7 @@ function LegRow({
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -609,11 +561,7 @@ function LegRow({
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -644,11 +592,7 @@ function AddLegDropdown({
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
Add Region
|
Add Region
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -51,20 +51,14 @@ export function NewRun() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleRuleKeys = RULE_DEFINITIONS.filter(
|
const visibleRuleKeys = RULE_DEFINITIONS.filter((r) => !hiddenRules?.has(r.key)).map((r) => r.key)
|
||||||
(r) => !hiddenRules?.has(r.key)
|
|
||||||
).map((r) => r.key)
|
|
||||||
const enabledRuleCount = visibleRuleKeys.filter((k) => rules[k]).length
|
const enabledRuleCount = visibleRuleKeys.filter((k) => rules[k]).length
|
||||||
const totalRuleCount = visibleRuleKeys.length
|
const totalRuleCount = visibleRuleKeys.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-8">
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">New Nuzlocke Run</h1>
|
||||||
New Nuzlocke Run
|
<p className="text-gray-600 dark:text-gray-400 mb-6">Set up your run in a few steps.</p>
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
|
||||||
Set up your run in a few steps.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<StepIndicator currentStep={step} onStepClick={setStep} />
|
<StepIndicator currentStep={step} onStepClick={setStep} />
|
||||||
|
|
||||||
@@ -84,8 +78,7 @@ export function NewRun() {
|
|||||||
{selectedGame.name}
|
{selectedGame.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{selectedGame.region.charAt(0).toUpperCase() +
|
{selectedGame.region.charAt(0).toUpperCase() + selectedGame.region.slice(1)}
|
||||||
selectedGame.region.slice(1)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,11 +131,7 @@ export function NewRun() {
|
|||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div>
|
<div>
|
||||||
<RulesConfiguration
|
<RulesConfiguration rules={rules} onChange={setRules} hiddenRules={hiddenRules} />
|
||||||
rules={rules}
|
|
||||||
onChange={setRules}
|
|
||||||
hiddenRules={hiddenRules}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-between">
|
<div className="mt-6 flex justify-between">
|
||||||
<button
|
<button
|
||||||
@@ -209,16 +198,13 @@ export function NewRun() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
Get nickname suggestions from a themed word list when catching
|
Get nickname suggestions from a themed word list when catching Pokemon.
|
||||||
Pokemon.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Summary</h3>
|
||||||
Summary
|
|
||||||
</h3>
|
|
||||||
<dl className="space-y-1 text-sm">
|
<dl className="space-y-1 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-gray-600 dark:text-gray-400">Game</dt>
|
<dt className="text-gray-600 dark:text-gray-400">Game</dt>
|
||||||
@@ -230,8 +216,7 @@ export function NewRun() {
|
|||||||
<dt className="text-gray-600 dark:text-gray-400">Region</dt>
|
<dt className="text-gray-600 dark:text-gray-400">Region</dt>
|
||||||
<dd className="text-gray-900 dark:text-gray-100 font-medium">
|
<dd className="text-gray-900 dark:text-gray-100 font-medium">
|
||||||
{selectedGame &&
|
{selectedGame &&
|
||||||
selectedGame.region.charAt(0).toUpperCase() +
|
selectedGame.region.charAt(0).toUpperCase() + selectedGame.region.slice(1)}
|
||||||
selectedGame.region.slice(1)}
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
@@ -241,13 +226,10 @@ export function NewRun() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-gray-600 dark:text-gray-400">
|
<dt className="text-gray-600 dark:text-gray-400">Naming Scheme</dt>
|
||||||
Naming Scheme
|
|
||||||
</dt>
|
|
||||||
<dd className="text-gray-900 dark:text-gray-100 font-medium">
|
<dd className="text-gray-900 dark:text-gray-100 font-medium">
|
||||||
{namingScheme
|
{namingScheme
|
||||||
? namingScheme.charAt(0).toUpperCase() +
|
? namingScheme.charAt(0).toUpperCase() + namingScheme.slice(1)
|
||||||
namingScheme.slice(1)
|
|
||||||
: 'None'}
|
: 'None'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,21 +3,12 @@ import { useParams, Link } from 'react-router-dom'
|
|||||||
import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns'
|
import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns'
|
||||||
import { useGameRoutes } from '../hooks/useGames'
|
import { useGameRoutes } from '../hooks/useGames'
|
||||||
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
||||||
import {
|
import { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
|
||||||
StatCard,
|
|
||||||
PokemonCard,
|
|
||||||
RuleBadges,
|
|
||||||
StatusChangeModal,
|
|
||||||
EndRunModal,
|
|
||||||
} from '../components'
|
|
||||||
import type { RunStatus, EncounterDetail } from '../types'
|
import type { RunStatus, EncounterDetail } from '../types'
|
||||||
|
|
||||||
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
|
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
|
||||||
|
|
||||||
function sortEncounters(
|
function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
|
||||||
encounters: EncounterDetail[],
|
|
||||||
key: TeamSortKey
|
|
||||||
): EncounterDetail[] {
|
|
||||||
return [...encounters].sort((a, b) => {
|
return [...encounters].sort((a, b) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'route':
|
case 'route':
|
||||||
@@ -31,8 +22,7 @@ function sortEncounters(
|
|||||||
}
|
}
|
||||||
case 'dex':
|
case 'dex':
|
||||||
return (
|
return (
|
||||||
(a.currentPokemon ?? a.pokemon).nationalDex -
|
(a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
|
||||||
(b.currentPokemon ?? b.pokemon).nationalDex
|
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
return 0
|
return 0
|
||||||
@@ -41,8 +31,7 @@ function sortEncounters(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusStyles: Record<RunStatus, string> = {
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
active:
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
|
||||||
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||||
}
|
}
|
||||||
@@ -64,8 +53,7 @@ export function RunDashboard() {
|
|||||||
const updateEncounter = useUpdateEncounter(runIdNum)
|
const updateEncounter = useUpdateEncounter(runIdNum)
|
||||||
const updateRun = useUpdateRun(runIdNum)
|
const updateRun = useUpdateRun(runIdNum)
|
||||||
const { data: namingCategories } = useNamingCategories()
|
const { data: namingCategories } = useNamingCategories()
|
||||||
const [selectedEncounter, setSelectedEncounter] =
|
const [selectedEncounter, setSelectedEncounter] = useState<EncounterDetail | null>(null)
|
||||||
useState<EncounterDetail | null>(null)
|
|
||||||
const [showEndRun, setShowEndRun] = useState(false)
|
const [showEndRun, setShowEndRun] = useState(false)
|
||||||
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
|
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
|
||||||
|
|
||||||
@@ -73,9 +61,7 @@ export function RunDashboard() {
|
|||||||
const alive = useMemo(
|
const alive = useMemo(
|
||||||
() =>
|
() =>
|
||||||
sortEncounters(
|
sortEncounters(
|
||||||
encounters.filter(
|
encounters.filter((e) => e.status === 'caught' && e.faintLevel === null),
|
||||||
(e) => e.status === 'caught' && e.faintLevel === null
|
|
||||||
),
|
|
||||||
teamSort
|
teamSort
|
||||||
),
|
),
|
||||||
[encounters, teamSort]
|
[encounters, teamSort]
|
||||||
@@ -83,9 +69,7 @@ export function RunDashboard() {
|
|||||||
const dead = useMemo(
|
const dead = useMemo(
|
||||||
() =>
|
() =>
|
||||||
sortEncounters(
|
sortEncounters(
|
||||||
encounters.filter(
|
encounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
|
||||||
(e) => e.status === 'caught' && e.faintLevel !== null
|
|
||||||
),
|
|
||||||
teamSort
|
teamSort
|
||||||
),
|
),
|
||||||
[encounters, teamSort]
|
[encounters, teamSort]
|
||||||
@@ -105,10 +89,7 @@ export function RunDashboard() {
|
|||||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
||||||
Failed to load run. It may not exist.
|
Failed to load run. It may not exist.
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link to="/runs" className="inline-block mt-4 text-blue-600 hover:underline">
|
||||||
to="/runs"
|
|
||||||
className="inline-block mt-4 text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
Back to runs
|
Back to runs
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,14 +112,10 @@ export function RunDashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{run.name}</h1>
|
||||||
{run.name}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
{run.game.name} ·{' '}
|
{run.game.name} ·{' '}
|
||||||
{run.game.region.charAt(0).toUpperCase() +
|
{run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} · Started{' '}
|
||||||
run.game.region.slice(1)}{' '}
|
|
||||||
· Started{' '}
|
|
||||||
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -204,26 +181,15 @@ export function RunDashboard() {
|
|||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||||
<StatCard
|
<StatCard label="Encounters" value={run.encounters.length} color="blue" />
|
||||||
label="Encounters"
|
|
||||||
value={run.encounters.length}
|
|
||||||
color="blue"
|
|
||||||
/>
|
|
||||||
<StatCard label="Alive" value={alive.length} color="green" />
|
<StatCard label="Alive" value={alive.length} color="green" />
|
||||||
<StatCard label="Deaths" value={dead.length} color="red" />
|
<StatCard label="Deaths" value={dead.length} color="red" />
|
||||||
<StatCard
|
<StatCard label="Routes Visited" value={visitedRoutes} total={totalRoutes} color="purple" />
|
||||||
label="Routes Visited"
|
|
||||||
value={visitedRoutes}
|
|
||||||
total={totalRoutes}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rules */}
|
{/* Rules */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Active Rules</h2>
|
||||||
Active Rules
|
|
||||||
</h2>
|
|
||||||
<RuleBadges rules={run.rules} />
|
<RuleBadges rules={run.rules} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -236,9 +202,7 @@ export function RunDashboard() {
|
|||||||
{isActive ? (
|
{isActive ? (
|
||||||
<select
|
<select
|
||||||
value={run.namingScheme ?? ''}
|
value={run.namingScheme ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateRun.mutate({ namingScheme: e.target.value || null })}
|
||||||
updateRun.mutate({ namingScheme: e.target.value || null })
|
|
||||||
}
|
|
||||||
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
@@ -251,8 +215,7 @@ export function RunDashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||||
{run.namingScheme
|
{run.namingScheme
|
||||||
? run.namingScheme.charAt(0).toUpperCase() +
|
? run.namingScheme.charAt(0).toUpperCase() + run.namingScheme.slice(1)
|
||||||
run.namingScheme.slice(1)
|
|
||||||
: 'None'}
|
: 'None'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -280,8 +243,7 @@ export function RunDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
{alive.length === 0 ? (
|
{alive.length === 0 ? (
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
No pokemon caught yet — head to encounters to start building your
|
No pokemon caught yet — head to encounters to start building your team!
|
||||||
team!
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
@@ -299,9 +261,7 @@ export function RunDashboard() {
|
|||||||
{/* Graveyard */}
|
{/* Graveyard */}
|
||||||
{dead.length > 0 && (
|
{dead.length > 0 && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Graveyard</h2>
|
||||||
Graveyard
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
{dead.map((enc) => (
|
{dead.map((enc) => (
|
||||||
<PokemonCard
|
<PokemonCard
|
||||||
@@ -357,10 +317,7 @@ export function RunDashboard() {
|
|||||||
{showEndRun && (
|
{showEndRun && (
|
||||||
<EndRunModal
|
<EndRunModal
|
||||||
onConfirm={(status) => {
|
onConfirm={(status) => {
|
||||||
updateRun.mutate(
|
updateRun.mutate({ status }, { onSuccess: () => setShowEndRun(false) })
|
||||||
{ status },
|
|
||||||
{ onSuccess: () => setShowEndRun(false) }
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
onClose={() => setShowEndRun(false)}
|
onClose={() => setShowEndRun(false)}
|
||||||
isPending={updateRun.isPending}
|
isPending={updateRun.isPending}
|
||||||
|
|||||||
@@ -3,17 +3,9 @@ import { useParams, Link, useNavigate } from 'react-router-dom'
|
|||||||
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
import { useRun, useUpdateRun } from '../hooks/useRuns'
|
||||||
import { useAdvanceLeg } from '../hooks/useGenlockes'
|
import { useAdvanceLeg } from '../hooks/useGenlockes'
|
||||||
import { useGameRoutes } from '../hooks/useGames'
|
import { useGameRoutes } from '../hooks/useGames'
|
||||||
import {
|
import { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hooks/useEncounters'
|
||||||
useCreateEncounter,
|
|
||||||
useUpdateEncounter,
|
|
||||||
useBulkRandomize,
|
|
||||||
} from '../hooks/useEncounters'
|
|
||||||
import { usePokemonFamilies } from '../hooks/usePokemon'
|
import { usePokemonFamilies } from '../hooks/usePokemon'
|
||||||
import {
|
import { useGameBosses, useBossResults, useCreateBossResult } from '../hooks/useBosses'
|
||||||
useGameBosses,
|
|
||||||
useBossResults,
|
|
||||||
useCreateBossResult,
|
|
||||||
} from '../hooks/useBosses'
|
|
||||||
import {
|
import {
|
||||||
EggEncounterModal,
|
EggEncounterModal,
|
||||||
EncounterModal,
|
EncounterModal,
|
||||||
@@ -43,10 +35,7 @@ import type {
|
|||||||
|
|
||||||
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
|
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
|
||||||
|
|
||||||
function sortEncounters(
|
function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
|
||||||
encounters: EncounterDetail[],
|
|
||||||
key: TeamSortKey
|
|
||||||
): EncounterDetail[] {
|
|
||||||
return [...encounters].sort((a, b) => {
|
return [...encounters].sort((a, b) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'route':
|
case 'route':
|
||||||
@@ -60,8 +49,7 @@ function sortEncounters(
|
|||||||
}
|
}
|
||||||
case 'dex':
|
case 'dex':
|
||||||
return (
|
return (
|
||||||
(a.currentPokemon ?? a.pokemon).nationalDex -
|
(a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
|
||||||
(b.currentPokemon ?? b.pokemon).nationalDex
|
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
return 0
|
return 0
|
||||||
@@ -70,8 +58,7 @@ function sortEncounters(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusStyles: Record<RunStatus, string> = {
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
active:
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
|
||||||
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||||
}
|
}
|
||||||
@@ -91,10 +78,7 @@ function getRouteStatus(encounter?: EncounterDetail): RouteStatus {
|
|||||||
return encounter.status
|
return encounter.status
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusIndicator: Record<
|
const statusIndicator: Record<RouteStatus, { dot: string; label: string; bg: string }> = {
|
||||||
RouteStatus,
|
|
||||||
{ dot: string; label: string; bg: string }
|
|
||||||
> = {
|
|
||||||
caught: {
|
caught: {
|
||||||
dot: 'bg-green-500',
|
dot: 'bg-green-500',
|
||||||
label: 'Caught',
|
label: 'Caught',
|
||||||
@@ -186,14 +170,11 @@ function countDistinctZones(group: RouteWithChildren): number {
|
|||||||
return zones.size
|
return zones.size
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchVariant(
|
function matchVariant(labels: string[], starterName?: string | null): string | null {
|
||||||
labels: string[],
|
|
||||||
starterName?: string | null
|
|
||||||
): string | null {
|
|
||||||
if (!starterName || labels.length === 0) return null
|
if (!starterName || labels.length === 0) return null
|
||||||
const lower = starterName.toLowerCase()
|
const lower = starterName.toLowerCase()
|
||||||
const matches = labels.filter((l) => l.toLowerCase().includes(lower))
|
const matches = labels.filter((l) => l.toLowerCase().includes(lower))
|
||||||
return matches.length === 1 ? matches[0] : null
|
return matches.length === 1 ? (matches[0] ?? null) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function BossTeamPreview({
|
function BossTeamPreview({
|
||||||
@@ -218,14 +199,13 @@ function BossTeamPreview({
|
|||||||
)
|
)
|
||||||
const showPills = hasVariants && autoMatch === null
|
const showPills = hasVariants && autoMatch === null
|
||||||
const [selectedVariant, setSelectedVariant] = useState<string | null>(
|
const [selectedVariant, setSelectedVariant] = useState<string | null>(
|
||||||
autoMatch ?? (hasVariants ? variantLabels[0] : null)
|
autoMatch ?? (hasVariants ? (variantLabels[0] ?? null) : null)
|
||||||
)
|
)
|
||||||
|
|
||||||
const displayed = useMemo(() => {
|
const displayed = useMemo(() => {
|
||||||
if (!hasVariants) return pokemon
|
if (!hasVariants) return pokemon
|
||||||
return pokemon.filter(
|
return pokemon.filter(
|
||||||
(bp) =>
|
(bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null
|
||||||
bp.conditionLabel === selectedVariant || bp.conditionLabel === null
|
|
||||||
)
|
)
|
||||||
}, [pokemon, hasVariants, selectedVariant])
|
}, [pokemon, hasVariants, selectedVariant])
|
||||||
|
|
||||||
@@ -255,17 +235,11 @@ function BossTeamPreview({
|
|||||||
.map((bp) => (
|
.map((bp) => (
|
||||||
<div key={bp.id} className="flex items-center gap-1">
|
<div key={bp.id} className="flex items-center gap-1">
|
||||||
{bp.pokemon.spriteUrl ? (
|
{bp.pokemon.spriteUrl ? (
|
||||||
<img
|
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
|
||||||
src={bp.pokemon.spriteUrl}
|
|
||||||
alt={bp.pokemon.name}
|
|
||||||
className="w-20 h-20"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
<span className="text-xs text-gray-500 dark:text-gray-400">Lvl {bp.level}</span>
|
||||||
Lvl {bp.level}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -294,9 +268,7 @@ function RouteGroup({
|
|||||||
}: RouteGroupProps) {
|
}: RouteGroupProps) {
|
||||||
const groupEncounter = getGroupEncounter(group, encounterByRoute)
|
const groupEncounter = getGroupEncounter(group, encounterByRoute)
|
||||||
const usePinwheel = pinwheelClause && groupHasZones(group)
|
const usePinwheel = pinwheelClause && groupHasZones(group)
|
||||||
const zoneEncounters = usePinwheel
|
const zoneEncounters = usePinwheel ? getZoneEncounters(group, encounterByRoute) : null
|
||||||
? getZoneEncounters(group, encounterByRoute)
|
|
||||||
: null
|
|
||||||
|
|
||||||
// For pinwheel groups, determine status from all zone statuses
|
// For pinwheel groups, determine status from all zone statuses
|
||||||
let groupStatus: RouteStatus
|
let groupStatus: RouteStatus
|
||||||
@@ -354,28 +326,19 @@ function RouteGroup({
|
|||||||
{groupEncounter.nickname ?? groupEncounter.pokemon.name}
|
{groupEncounter.nickname ?? groupEncounter.pokemon.name}
|
||||||
{groupEncounter.status === 'caught' &&
|
{groupEncounter.status === 'caught' &&
|
||||||
groupEncounter.faintLevel !== null &&
|
groupEncounter.faintLevel !== null &&
|
||||||
(groupEncounter.deathCause
|
(groupEncounter.deathCause ? ` — ${groupEncounter.deathCause}` : ' (dead)')}
|
||||||
? ` — ${groupEncounter.deathCause}`
|
|
||||||
: ' (dead)')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">{si.label}</span>
|
||||||
{si.label}
|
|
||||||
</span>
|
|
||||||
<svg
|
<svg
|
||||||
className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -409,13 +372,9 @@ function RouteGroup({
|
|||||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
|
||||||
} ${childSi.bg}`}
|
} ${childSi.bg}`}
|
||||||
>
|
>
|
||||||
<span
|
<span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} />
|
||||||
className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
<div className="text-sm text-gray-700 dark:text-gray-300">{child.name}</div>
|
||||||
{child.name}
|
|
||||||
</div>
|
|
||||||
{!childEncounter && child.encounterMethods.length > 0 && (
|
{!childEncounter && child.encounterMethods.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
{child.encounterMethods.map((m) => (
|
{child.encounterMethods.map((m) => (
|
||||||
@@ -425,14 +384,10 @@ function RouteGroup({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{childEncounter && (
|
{childEncounter && (
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
<span className="text-xs text-gray-400 dark:text-gray-500">{childSi.label}</span>
|
||||||
{childSi.label}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{isDisabled && (
|
{isDisabled && (
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
|
<span className="text-xs text-gray-400 dark:text-gray-500 italic">(locked)</span>
|
||||||
(locked)
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -450,9 +405,7 @@ export function RunEncounters() {
|
|||||||
const { data: run, isLoading, error } = useRun(runIdNum)
|
const { data: run, isLoading, error } = useRun(runIdNum)
|
||||||
const advanceLeg = useAdvanceLeg()
|
const advanceLeg = useAdvanceLeg()
|
||||||
const [showTransferModal, setShowTransferModal] = useState(false)
|
const [showTransferModal, setShowTransferModal] = useState(false)
|
||||||
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
|
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(run?.gameId ?? null)
|
||||||
run?.gameId ?? null
|
|
||||||
)
|
|
||||||
const createEncounter = useCreateEncounter(runIdNum)
|
const createEncounter = useCreateEncounter(runIdNum)
|
||||||
const updateEncounter = useUpdateEncounter(runIdNum)
|
const updateEncounter = useUpdateEncounter(runIdNum)
|
||||||
const bulkRandomize = useBulkRandomize(runIdNum)
|
const bulkRandomize = useBulkRandomize(runIdNum)
|
||||||
@@ -464,10 +417,8 @@ export function RunEncounters() {
|
|||||||
|
|
||||||
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
|
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
|
||||||
const [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null)
|
const [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null)
|
||||||
const [editingEncounter, setEditingEncounter] =
|
const [editingEncounter, setEditingEncounter] = useState<EncounterDetail | null>(null)
|
||||||
useState<EncounterDetail | null>(null)
|
const [selectedTeamEncounter, setSelectedTeamEncounter] = useState<EncounterDetail | null>(null)
|
||||||
const [selectedTeamEncounter, setSelectedTeamEncounter] =
|
|
||||||
useState<EncounterDetail | null>(null)
|
|
||||||
const [showEndRun, setShowEndRun] = useState(false)
|
const [showEndRun, setShowEndRun] = useState(false)
|
||||||
const [showHofModal, setShowHofModal] = useState(false)
|
const [showHofModal, setShowHofModal] = useState(false)
|
||||||
const [showShinyModal, setShowShinyModal] = useState(false)
|
const [showShinyModal, setShowShinyModal] = useState(false)
|
||||||
@@ -511,8 +462,7 @@ export function RunEncounters() {
|
|||||||
[run?.transferEncounterIds]
|
[run?.transferEncounterIds]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { normalEncounters, shinyEncounters, transferEncounters } =
|
const { normalEncounters, shinyEncounters, transferEncounters } = useMemo(() => {
|
||||||
useMemo(() => {
|
|
||||||
if (!run)
|
if (!run)
|
||||||
return {
|
return {
|
||||||
normalEncounters: [],
|
normalEncounters: [],
|
||||||
@@ -638,9 +588,7 @@ export function RunEncounters() {
|
|||||||
const currentLevelCap = useMemo(() => {
|
const currentLevelCap = useMemo(() => {
|
||||||
if (!nextBoss) {
|
if (!nextBoss) {
|
||||||
// All defeated — no cap (or use last boss's level)
|
// All defeated — no cap (or use last boss's level)
|
||||||
return sortedBosses.length > 0
|
return sortedBosses.length > 0 ? sortedBosses[sortedBosses.length - 1]!.levelCap : null
|
||||||
? sortedBosses[sortedBosses.length - 1].levelCap
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
return nextBoss.levelCap
|
return nextBoss.levelCap
|
||||||
}, [nextBoss, sortedBosses])
|
}, [nextBoss, sortedBosses])
|
||||||
@@ -650,8 +598,8 @@ export function RunEncounters() {
|
|||||||
const sectionDividerAfterBoss = useMemo(() => {
|
const sectionDividerAfterBoss = useMemo(() => {
|
||||||
const map = new Map<number, string>()
|
const map = new Map<number, string>()
|
||||||
for (let i = 0; i < sortedBosses.length - 1; i++) {
|
for (let i = 0; i < sortedBosses.length - 1; i++) {
|
||||||
const current = sortedBosses[i]
|
const current = sortedBosses[i]!
|
||||||
const next = sortedBosses[i + 1]
|
const next = sortedBosses[i + 1]!
|
||||||
if (next.section != null && current.section !== next.section) {
|
if (next.section != null && current.section !== next.section) {
|
||||||
map.set(current.id, next.section)
|
map.set(current.id, next.section)
|
||||||
}
|
}
|
||||||
@@ -677,8 +625,7 @@ export function RunEncounters() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (organizedRoutes.length === 0 || expandedGroups.size > 0) return
|
if (organizedRoutes.length === 0 || expandedGroups.size > 0) return
|
||||||
const firstUnvisited = organizedRoutes.find(
|
const firstUnvisited = organizedRoutes.find(
|
||||||
(r) =>
|
(r) => r.children.length > 0 && getGroupEncounter(r, encounterByRoute) === null
|
||||||
r.children.length > 0 && getGroupEncounter(r, encounterByRoute) === null
|
|
||||||
)
|
)
|
||||||
if (firstUnvisited) {
|
if (firstUnvisited) {
|
||||||
updateExpandedGroups(() => new Set([firstUnvisited.id]))
|
updateExpandedGroups(() => new Set([firstUnvisited.id]))
|
||||||
@@ -699,9 +646,7 @@ export function RunEncounters() {
|
|||||||
const dead = useMemo(
|
const dead = useMemo(
|
||||||
() =>
|
() =>
|
||||||
sortEncounters(
|
sortEncounters(
|
||||||
normalEncounters.filter(
|
normalEncounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
|
||||||
(e) => e.status === 'caught' && e.faintLevel !== null
|
|
||||||
),
|
|
||||||
teamSort
|
teamSort
|
||||||
),
|
),
|
||||||
[normalEncounters, teamSort]
|
[normalEncounters, teamSort]
|
||||||
@@ -728,10 +673,7 @@ export function RunEncounters() {
|
|||||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
||||||
Failed to load run.
|
Failed to load run.
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link to="/runs" className="inline-block mt-4 text-blue-600 hover:underline">
|
||||||
to="/runs"
|
|
||||||
className="inline-block mt-4 text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
Back to runs
|
Back to runs
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -803,10 +745,10 @@ export function RunEncounters() {
|
|||||||
const handleUpdate = (data: {
|
const handleUpdate = (data: {
|
||||||
id: number
|
id: number
|
||||||
data: {
|
data: {
|
||||||
nickname?: string
|
nickname?: string | undefined
|
||||||
status?: EncounterStatus
|
status?: EncounterStatus | undefined
|
||||||
faintLevel?: number
|
faintLevel?: number | undefined
|
||||||
deathCause?: string
|
deathCause?: string | undefined
|
||||||
}
|
}
|
||||||
}) => {
|
}) => {
|
||||||
updateEncounter.mutate(data, {
|
updateEncounter.mutate(data, {
|
||||||
@@ -852,14 +794,10 @@ export function RunEncounters() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{run.name}</h1>
|
||||||
{run.name}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
{run.game.name} ·{' '}
|
{run.game.name} ·{' '}
|
||||||
{run.game.region.charAt(0).toUpperCase() +
|
{run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} · Started{' '}
|
||||||
run.game.region.slice(1)}{' '}
|
|
||||||
· Started{' '}
|
|
||||||
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -959,9 +897,7 @@ export function RunEncounters() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{run.status === 'completed' &&
|
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (
|
||||||
run.genlocke &&
|
|
||||||
!run.genlocke.isFinalLeg && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (hofTeam && hofTeam.length > 0) {
|
if (hofTeam && hofTeam.length > 0) {
|
||||||
@@ -988,9 +924,7 @@ export function RunEncounters() {
|
|||||||
disabled={advanceLeg.isPending}
|
disabled={advanceLeg.isPending}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{advanceLeg.isPending
|
{advanceLeg.isPending ? 'Advancing...' : 'Advance to Next Leg'}
|
||||||
? 'Advancing...'
|
|
||||||
: 'Advance to Next Leg'}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1016,14 +950,10 @@ export function RunEncounters() {
|
|||||||
return (
|
return (
|
||||||
<div key={enc.id} className="flex flex-col items-center">
|
<div key={enc.id} className="flex flex-col items-center">
|
||||||
{dp.spriteUrl ? (
|
{dp.spriteUrl ? (
|
||||||
<img
|
<img src={dp.spriteUrl} alt={dp.name} className="w-12 h-12" />
|
||||||
src={dp.spriteUrl}
|
|
||||||
alt={dp.name}
|
|
||||||
className="w-12 h-12"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-sm font-bold">
|
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-sm font-bold">
|
||||||
{dp.name[0].toUpperCase()}
|
{dp.name[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-blue-600 dark:text-blue-400 capitalize mt-0.5">
|
<span className="text-[10px] text-blue-600 dark:text-blue-400 capitalize mt-0.5">
|
||||||
@@ -1045,19 +975,10 @@ export function RunEncounters() {
|
|||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||||
<StatCard
|
<StatCard label="Encounters" value={normalEncounters.length} color="blue" />
|
||||||
label="Encounters"
|
|
||||||
value={normalEncounters.length}
|
|
||||||
color="blue"
|
|
||||||
/>
|
|
||||||
<StatCard label="Alive" value={alive.length} color="green" />
|
<StatCard label="Alive" value={alive.length} color="green" />
|
||||||
<StatCard label="Deaths" value={dead.length} color="red" />
|
<StatCard label="Deaths" value={dead.length} color="red" />
|
||||||
<StatCard
|
<StatCard label="Routes" value={completedCount} total={totalLocations} color="purple" />
|
||||||
label="Routes"
|
|
||||||
value={completedCount}
|
|
||||||
total={totalLocations}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Level Cap Bar */}
|
{/* Level Cap Bar */}
|
||||||
@@ -1123,9 +1044,7 @@ export function RunEncounters() {
|
|||||||
|
|
||||||
{/* Rules */}
|
{/* Rules */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Active Rules</h2>
|
||||||
Active Rules
|
|
||||||
</h2>
|
|
||||||
<RuleBadges rules={run.rules} />
|
<RuleBadges rules={run.rules} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1180,11 +1099,7 @@ export function RunEncounters() {
|
|||||||
<PokemonCard
|
<PokemonCard
|
||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
onClick={
|
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||||
isActive
|
|
||||||
? () => setSelectedTeamEncounter(enc)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1200,11 +1115,7 @@ export function RunEncounters() {
|
|||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
showFaintLevel
|
showFaintLevel
|
||||||
onClick={
|
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||||
isActive
|
|
||||||
? () => setSelectedTeamEncounter(enc)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1220,9 +1131,7 @@ export function RunEncounters() {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<ShinyBox
|
<ShinyBox
|
||||||
encounters={shinyEncounters}
|
encounters={shinyEncounters}
|
||||||
onEncounterClick={
|
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
|
||||||
isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1238,9 +1147,7 @@ export function RunEncounters() {
|
|||||||
<PokemonCard
|
<PokemonCard
|
||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
onClick={
|
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||||
isActive ? () => setSelectedTeamEncounter(enc) : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1251,9 +1158,7 @@ export function RunEncounters() {
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Encounters</h2>
|
||||||
Encounters
|
|
||||||
</h2>
|
|
||||||
{isActive && completedCount < totalLocations && (
|
{isActive && completedCount < totalLocations && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1261,9 +1166,7 @@ export function RunEncounters() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const remaining = totalLocations - completedCount
|
const remaining = totalLocations - completedCount
|
||||||
if (
|
if (
|
||||||
window.confirm(
|
window.confirm(`Randomize encounters for all ${remaining} remaining locations?`)
|
||||||
`Randomize encounters for all ${remaining} remaining locations?`
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
bulkRandomize.mutate()
|
bulkRandomize.mutate()
|
||||||
}
|
}
|
||||||
@@ -1325,9 +1228,7 @@ export function RunEncounters() {
|
|||||||
{filteredRoutes.map((route) => {
|
{filteredRoutes.map((route) => {
|
||||||
// Collect all route IDs to check for boss cards after
|
// Collect all route IDs to check for boss cards after
|
||||||
const routeIds: number[] =
|
const routeIds: number[] =
|
||||||
route.children.length > 0
|
route.children.length > 0 ? [route.id, ...route.children.map((c) => c.id)] : [route.id]
|
||||||
? [route.id, ...route.children.map((c) => c.id)]
|
|
||||||
: [route.id]
|
|
||||||
|
|
||||||
// Find boss battles positioned after this route (or any of its children)
|
// Find boss battles positioned after this route (or any of its children)
|
||||||
const bossesHere: BossBattle[] = []
|
const bossesHere: BossBattle[] = []
|
||||||
@@ -1361,9 +1262,7 @@ export function RunEncounters() {
|
|||||||
onClick={() => handleRouteClick(route)}
|
onClick={() => handleRouteClick(route)}
|
||||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
|
||||||
>
|
>
|
||||||
<span
|
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
|
||||||
className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{route.name}
|
{route.name}
|
||||||
@@ -1381,20 +1280,14 @@ export function RunEncounters() {
|
|||||||
{encounter.nickname ?? encounter.pokemon.name}
|
{encounter.nickname ?? encounter.pokemon.name}
|
||||||
{encounter.status === 'caught' &&
|
{encounter.status === 'caught' &&
|
||||||
encounter.faintLevel !== null &&
|
encounter.faintLevel !== null &&
|
||||||
(encounter.deathCause
|
(encounter.deathCause ? ` — ${encounter.deathCause}` : ' (dead)')}
|
||||||
? ` — ${encounter.deathCause}`
|
|
||||||
: ' (dead)')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
route.encounterMethods.length > 0 && (
|
route.encounterMethods.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
{route.encounterMethods.map((m) => (
|
{route.encounterMethods.map((m) => (
|
||||||
<EncounterMethodBadge
|
<EncounterMethodBadge key={m} method={m} size="xs" />
|
||||||
key={m}
|
|
||||||
method={m}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1449,7 +1342,7 @@ export function RunEncounters() {
|
|||||||
return (
|
return (
|
||||||
<div key={`boss-${boss.id}`}>
|
<div key={`boss-${boss.id}`}>
|
||||||
<div
|
<div
|
||||||
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors.other} ${
|
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
|
||||||
isDefeated
|
isDefeated
|
||||||
? 'bg-green-50/50 dark:bg-green-900/10'
|
? 'bg-green-50/50 dark:bg-green-900/10'
|
||||||
: 'bg-white dark:bg-gray-800'
|
: 'bg-white dark:bg-gray-800'
|
||||||
@@ -1467,18 +1360,10 @@ export function RunEncounters() {
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
{boss.spriteUrl && (
|
{boss.spriteUrl && (
|
||||||
<img
|
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
|
||||||
src={boss.spriteUrl}
|
|
||||||
alt={boss.name}
|
|
||||||
className="h-10 w-auto"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1488,13 +1373,10 @@ export function RunEncounters() {
|
|||||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||||
{bossTypeLabel[boss.bossType] ?? boss.bossType}
|
{bossTypeLabel[boss.bossType] ?? boss.bossType}
|
||||||
</span>
|
</span>
|
||||||
{boss.specialtyType && (
|
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
|
||||||
<TypeBadge type={boss.specialtyType} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{boss.location} · Level Cap:{' '}
|
{boss.location} · Level Cap: {boss.levelCap}
|
||||||
{boss.levelCap}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1515,10 +1397,7 @@ export function RunEncounters() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Boss pokemon team */}
|
{/* Boss pokemon team */}
|
||||||
{isBossExpanded && boss.pokemon.length > 0 && (
|
{isBossExpanded && boss.pokemon.length > 0 && (
|
||||||
<BossTeamPreview
|
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
|
||||||
pokemon={boss.pokemon}
|
|
||||||
starterName={starterName}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{sectionAfter && (
|
{sectionAfter && (
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { useRuns } from '../hooks/useRuns'
|
|||||||
import type { RunStatus } from '../types'
|
import type { RunStatus } from '../types'
|
||||||
|
|
||||||
const statusStyles: Record<RunStatus, string> = {
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
active:
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
|
||||||
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||||
}
|
}
|
||||||
@@ -15,9 +14,7 @@ export function RunList() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-8">
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Your Runs</h1>
|
||||||
Your Runs
|
|
||||||
</h1>
|
|
||||||
<Link
|
<Link
|
||||||
to="/runs/new"
|
to="/runs/new"
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||||
|
|||||||
@@ -34,47 +34,27 @@ function pct(value: number | null): string {
|
|||||||
return `${(value * 100).toFixed(1)}%`
|
return `${(value * 100).toFixed(1)}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
function PokemonList({
|
function PokemonList({ title, pokemon }: { title: string; pokemon: PokemonRanking[] }) {
|
||||||
title,
|
|
||||||
pokemon,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
pokemon: PokemonRanking[]
|
|
||||||
}) {
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const visible = expanded ? pokemon : pokemon.slice(0, 5)
|
const visible = expanded ? pokemon : pokemon.slice(0, 5)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">{title}</h3>
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
{pokemon.length === 0 ? (
|
{pokemon.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">No data</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">No data</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{visible.map((p, i) => (
|
{visible.map((p, i) => (
|
||||||
<div
|
<div key={p.pokemonId} className="flex items-center gap-2 text-sm">
|
||||||
key={p.pokemonId}
|
<span className="text-gray-400 dark:text-gray-500 w-5 text-right">{i + 1}.</span>
|
||||||
className="flex items-center gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<span className="text-gray-400 dark:text-gray-500 w-5 text-right">
|
|
||||||
{i + 1}.
|
|
||||||
</span>
|
|
||||||
{p.spriteUrl ? (
|
{p.spriteUrl ? (
|
||||||
<img
|
<img src={p.spriteUrl} alt={p.name} className="w-6 h-6" loading="lazy" />
|
||||||
src={p.spriteUrl}
|
|
||||||
alt={p.name}
|
|
||||||
className="w-6 h-6"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded" />
|
<div className="w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
)}
|
)}
|
||||||
<span className="capitalize text-gray-800 dark:text-gray-200">
|
<span className="capitalize text-gray-800 dark:text-gray-200">{p.name}</span>
|
||||||
{p.name}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto text-gray-500 dark:text-gray-400 font-medium">
|
<span className="ml-auto text-gray-500 dark:text-gray-400 font-medium">
|
||||||
{p.count}
|
{p.count}
|
||||||
</span>
|
</span>
|
||||||
@@ -130,14 +110,10 @@ function HorizontalBar({
|
|||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={`absolute inset-0 flex items-center px-3 text-xs font-medium capitalize truncate ${
|
className={`absolute inset-0 flex items-center px-3 text-xs font-medium capitalize truncate ${
|
||||||
isLight
|
isLight ? 'text-gray-900 dark:text-gray-900' : 'text-gray-700 dark:text-gray-200'
|
||||||
? 'text-gray-900 dark:text-gray-900'
|
|
||||||
: 'text-gray-700 dark:text-gray-200'
|
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
textShadow: isLight
|
textShadow: isLight ? '0 0 4px rgba(255,255,255,0.8)' : '0 0 4px rgba(0,0,0,0.3)',
|
||||||
? '0 0 4px rgba(255,255,255,0.8)'
|
|
||||||
: '0 0 4px rgba(0,0,0,0.3)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -150,18 +126,10 @@ function HorizontalBar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<section className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-4">
|
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-4">{title}</h2>
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
@@ -178,19 +146,13 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
|
||||||
<StatCard label="Total Runs" value={stats.totalRuns} color="blue" />
|
<StatCard label="Total Runs" value={stats.totalRuns} color="blue" />
|
||||||
<StatCard label="Active" value={stats.activeRuns} color="green" />
|
<StatCard label="Active" value={stats.activeRuns} color="green" />
|
||||||
<StatCard
|
<StatCard label="Completed" value={stats.completedRuns} color="blue" />
|
||||||
label="Completed"
|
|
||||||
value={stats.completedRuns}
|
|
||||||
color="blue"
|
|
||||||
/>
|
|
||||||
<StatCard label="Failed" value={stats.failedRuns} color="red" />
|
<StatCard label="Failed" value={stats.failedRuns} color="red" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<span>
|
<span>
|
||||||
Win Rate:{' '}
|
Win Rate:{' '}
|
||||||
<strong className="text-gray-800 dark:text-gray-200">
|
<strong className="text-gray-800 dark:text-gray-200">{pct(stats.winRate)}</strong>
|
||||||
{pct(stats.winRate)}
|
|
||||||
</strong>
|
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Avg Duration:{' '}
|
Avg Duration:{' '}
|
||||||
@@ -211,8 +173,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
|
|||||||
label={g.gameName}
|
label={g.gameName}
|
||||||
value={g.count}
|
value={g.count}
|
||||||
max={gameMax}
|
max={gameMax}
|
||||||
colorHex={g.gameColor ?? undefined}
|
{...(g.gameColor ? { colorHex: g.gameColor } : { color: 'bg-blue-500' })}
|
||||||
color={g.gameColor ? undefined : 'bg-blue-500'}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -244,9 +205,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
|
|||||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<span>
|
<span>
|
||||||
Catch Rate:{' '}
|
Catch Rate:{' '}
|
||||||
<strong className="text-gray-800 dark:text-gray-200">
|
<strong className="text-gray-800 dark:text-gray-200">{pct(stats.catchRate)}</strong>
|
||||||
{pct(stats.catchRate)}
|
|
||||||
</strong>
|
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Avg per Run:{' '}
|
Avg per Run:{' '}
|
||||||
@@ -261,44 +220,31 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
|
|||||||
<Section title="Pokemon Rankings">
|
<Section title="Pokemon Rankings">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
<PokemonList title="Most Caught" pokemon={stats.topCaughtPokemon} />
|
<PokemonList title="Most Caught" pokemon={stats.topCaughtPokemon} />
|
||||||
<PokemonList
|
<PokemonList title="Most Encountered" pokemon={stats.topEncounteredPokemon} />
|
||||||
title="Most Encountered"
|
|
||||||
pokemon={stats.topEncounteredPokemon}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Team & Deaths */}
|
{/* Team & Deaths */}
|
||||||
<Section title="Team & Deaths">
|
<Section title="Team & Deaths">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
|
||||||
<StatCard
|
<StatCard label="Total Deaths" value={stats.totalDeaths} color="red" />
|
||||||
label="Total Deaths"
|
|
||||||
value={stats.totalDeaths}
|
|
||||||
color="red"
|
|
||||||
/>
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-amber-500">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-amber-500">
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
{pct(stats.mortalityRate)}
|
{pct(stats.mortalityRate)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="text-sm text-gray-600 dark:text-gray-400">Mortality Rate</div>
|
||||||
Mortality Rate
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-blue-500">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-blue-500">
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
{fmt(stats.avgCatchLevel)}
|
{fmt(stats.avgCatchLevel)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="text-sm text-gray-600 dark:text-gray-400">Avg Catch Lv.</div>
|
||||||
Avg Catch Lv.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-purple-500">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-purple-500">
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
{fmt(stats.avgFaintLevel)}
|
{fmt(stats.avgFaintLevel)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="text-sm text-gray-600 dark:text-gray-400">Avg Faint Lv.</div>
|
||||||
Avg Faint Lv.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -310,12 +256,8 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{stats.topDeathCauses.map((d, i) => (
|
{stats.topDeathCauses.map((d, i) => (
|
||||||
<div key={d.cause} className="flex items-center gap-2 text-sm">
|
<div key={d.cause} className="flex items-center gap-2 text-sm">
|
||||||
<span className="text-gray-400 dark:text-gray-500 w-5 text-right">
|
<span className="text-gray-400 dark:text-gray-500 w-5 text-right">{i + 1}.</span>
|
||||||
{i + 1}.
|
<span className="text-gray-800 dark:text-gray-200">{d.cause}</span>
|
||||||
</span>
|
|
||||||
<span className="text-gray-800 dark:text-gray-200">
|
|
||||||
{d.cause}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto text-gray-500 dark:text-gray-400 font-medium">
|
<span className="ml-auto text-gray-500 dark:text-gray-400 font-medium">
|
||||||
{d.count}
|
{d.count}
|
||||||
</span>
|
</span>
|
||||||
@@ -351,9 +293,7 @@ export function Stats() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-8">
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-6">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-6">Stats</h1>
|
||||||
Stats
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
@@ -370,9 +310,7 @@ export function Stats() {
|
|||||||
{stats && stats.totalRuns === 0 && (
|
{stats && stats.totalRuns === 0 && (
|
||||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
<p className="text-lg mb-2">No data yet</p>
|
<p className="text-lg mb-2">No data yet</p>
|
||||||
<p className="text-sm">
|
<p className="text-sm">Start a Nuzlocke run to see your stats here.</p>
|
||||||
Start a Nuzlocke run to see your stats here.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import {
|
|||||||
} from '../../hooks/useAdmin'
|
} from '../../hooks/useAdmin'
|
||||||
import { exportEvolutions } from '../../api/admin'
|
import { exportEvolutions } from '../../api/admin'
|
||||||
import { downloadJson } from '../../utils/download'
|
import { downloadJson } from '../../utils/download'
|
||||||
import type {
|
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
|
||||||
EvolutionAdmin,
|
|
||||||
CreateEvolutionInput,
|
|
||||||
UpdateEvolutionInput,
|
|
||||||
} from '../../types'
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
@@ -67,9 +63,7 @@ export function AdminEvolutions() {
|
|||||||
header: 'To',
|
header: 'To',
|
||||||
accessor: (e) => (
|
accessor: (e) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{e.toPokemon.spriteUrl && (
|
{e.toPokemon.spriteUrl && <img src={e.toPokemon.spriteUrl} alt="" className="w-6 h-6" />}
|
||||||
<img src={e.toPokemon.spriteUrl} alt="" className="w-6 h-6" />
|
|
||||||
)}
|
|
||||||
<span>{e.toPokemon.name}</span>
|
<span>{e.toPokemon.name}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -163,8 +157,7 @@ export function AdminEvolutions() {
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div className="mt-4 flex items-center justify-between">
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of{' '}
|
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of {total}
|
||||||
{total}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -45,10 +45,7 @@ import type {
|
|||||||
UpdateRouteInput,
|
UpdateRouteInput,
|
||||||
BossBattle,
|
BossBattle,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import type {
|
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
|
||||||
CreateBossBattleInput,
|
|
||||||
UpdateBossBattleInput,
|
|
||||||
} from '../../types/admin'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organize flat routes into hierarchical structure.
|
* Organize flat routes into hierarchical structure.
|
||||||
@@ -85,14 +82,9 @@ function SortableRouteGroup({
|
|||||||
gameId: number
|
gameId: number
|
||||||
onClick: (r: GameRoute) => void
|
onClick: (r: GameRoute) => void
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
attributes,
|
id: group.id,
|
||||||
listeners,
|
})
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: group.id })
|
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -127,9 +119,7 @@ function SortableRouteGroup({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">
|
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">{group.order}</td>
|
||||||
{group.order}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{group.name}</td>
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{group.name}</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-center">
|
<td className="px-4 py-3 text-sm whitespace-nowrap text-center">
|
||||||
{group.pinwheelZone != null ? group.pinwheelZone : '\u2014'}
|
{group.pinwheelZone != null ? group.pinwheelZone : '\u2014'}
|
||||||
@@ -155,9 +145,7 @@ function SortableRouteGroup({
|
|||||||
{child.order}
|
{child.order}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap pl-8 text-gray-600 dark:text-gray-400">
|
<td className="px-4 py-3 text-sm whitespace-nowrap pl-8 text-gray-600 dark:text-gray-400">
|
||||||
<span className="text-gray-300 dark:text-gray-600 mr-1.5">
|
<span className="text-gray-300 dark:text-gray-600 mr-1.5">{'\u2514'}</span>
|
||||||
{'\u2514'}
|
|
||||||
</span>
|
|
||||||
{child.name}
|
{child.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-center">
|
<td className="px-4 py-3 text-sm whitespace-nowrap text-center">
|
||||||
@@ -191,14 +179,9 @@ function SortableBossRow({
|
|||||||
onPositionChange: (bossId: number, afterRouteId: number | null) => void
|
onPositionChange: (bossId: number, afterRouteId: number | null) => void
|
||||||
onClick: (b: BossBattle) => void
|
onClick: (b: BossBattle) => void
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
attributes,
|
id: boss.id,
|
||||||
listeners,
|
})
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: boss.id })
|
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -247,15 +230,9 @@ function SortableBossRow({
|
|||||||
{boss.bossType.replace('_', ' ')}
|
{boss.bossType.replace('_', ' ')}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||||
{boss.specialtyType ? (
|
{boss.specialtyType ? <TypeBadge type={boss.specialtyType} /> : '\u2014'}
|
||||||
<TypeBadge type={boss.specialtyType} />
|
|
||||||
) : (
|
|
||||||
'\u2014'
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
|
||||||
{boss.section ?? '\u2014'}
|
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.section ?? '\u2014'}</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.location}</td>
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.location}</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||||
<select
|
<select
|
||||||
@@ -276,9 +253,7 @@ function SortableBossRow({
|
|||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.levelCap}</td>
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.levelCap}</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.pokemon.length}</td>
|
||||||
{boss.pokemon.length}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -315,16 +290,12 @@ export function AdminGameDetail() {
|
|||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading) return <div className="py-8 text-center text-gray-500">Loading...</div>
|
||||||
return <div className="py-8 text-center text-gray-500">Loading...</div>
|
if (!game) return <div className="py-8 text-center text-gray-500">Game not found</div>
|
||||||
if (!game)
|
|
||||||
return <div className="py-8 text-center text-gray-500">Game not found</div>
|
|
||||||
|
|
||||||
const routes = game.routes ?? []
|
const routes = game.routes ?? []
|
||||||
const routeGroups = organizeRoutes(routes)
|
const routeGroups = organizeRoutes(routes)
|
||||||
const versionGroupGames = (allGames ?? []).filter(
|
const versionGroupGames = (allGames ?? []).filter((g) => g.versionGroupId === game.versionGroupId)
|
||||||
(g) => g.versionGroupId === game.versionGroupId
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
@@ -336,7 +307,7 @@ export function AdminGameDetail() {
|
|||||||
|
|
||||||
const reordered = [...routeGroups]
|
const reordered = [...routeGroups]
|
||||||
const [moved] = reordered.splice(oldIndex, 1)
|
const [moved] = reordered.splice(oldIndex, 1)
|
||||||
reordered.splice(newIndex, 0, moved)
|
reordered.splice(newIndex, 0, moved!)
|
||||||
|
|
||||||
// Flatten groups back to individual routes with sequential order numbers
|
// Flatten groups back to individual routes with sequential order numbers
|
||||||
let order = 1
|
let order = 1
|
||||||
@@ -361,7 +332,7 @@ export function AdminGameDetail() {
|
|||||||
|
|
||||||
const reordered = [...bosses]
|
const reordered = [...bosses]
|
||||||
const [moved] = reordered.splice(oldIndex, 1)
|
const [moved] = reordered.splice(oldIndex, 1)
|
||||||
reordered.splice(newIndex, 0, moved)
|
reordered.splice(newIndex, 0, moved!)
|
||||||
|
|
||||||
const newOrders = reordered.map((b, i) => ({
|
const newOrders = reordered.map((b, i) => ({
|
||||||
id: b.id,
|
id: b.id,
|
||||||
@@ -383,8 +354,8 @@ export function AdminGameDetail() {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-xl font-semibold">{game.name}</h2>
|
<h2 className="text-xl font-semibold">{game.name}</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{game.region.charAt(0).toUpperCase() + game.region.slice(1)} ·
|
{game.region.charAt(0).toUpperCase() + game.region.slice(1)} · Gen{' '}
|
||||||
Gen {game.generation}
|
{game.generation}
|
||||||
{game.releaseYear ? ` \u00b7 ${game.releaseYear}` : ''}
|
{game.releaseYear ? ` \u00b7 ${game.releaseYear}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -500,11 +471,7 @@ export function AdminGameDetail() {
|
|||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<RouteFormModal
|
<RouteFormModal
|
||||||
nextOrder={
|
nextOrder={routes.length > 0 ? Math.max(...routes.map((r) => r.order)) + 1 : 1}
|
||||||
routes.length > 0
|
|
||||||
? Math.max(...routes.map((r) => r.order)) + 1
|
|
||||||
: 1
|
|
||||||
}
|
|
||||||
onSubmit={(data) =>
|
onSubmit={(data) =>
|
||||||
createRoute.mutate(data as CreateRouteInput, {
|
createRoute.mutate(data as CreateRouteInput, {
|
||||||
onSuccess: () => setShowCreate(false),
|
onSuccess: () => setShowCreate(false),
|
||||||
@@ -655,9 +622,7 @@ export function AdminGameDetail() {
|
|||||||
<BossBattleFormModal
|
<BossBattleFormModal
|
||||||
routes={routes}
|
routes={routes}
|
||||||
games={versionGroupGames}
|
games={versionGroupGames}
|
||||||
nextOrder={
|
nextOrder={bosses ? Math.max(0, ...bosses.map((b) => b.order)) + 1 : 1}
|
||||||
bosses ? Math.max(0, ...bosses.map((b) => b.order)) + 1 : 1
|
|
||||||
}
|
|
||||||
onSubmit={(data) =>
|
onSubmit={(data) =>
|
||||||
createBoss.mutate(data as CreateBossBattleInput, {
|
createBoss.mutate(data as CreateBossBattleInput, {
|
||||||
onSuccess: () => setShowCreateBoss(false),
|
onSuccess: () => setShowCreateBoss(false),
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import { useState, useMemo } from 'react'
|
|||||||
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
||||||
import { GameFormModal } from '../../components/admin/GameFormModal'
|
import { GameFormModal } from '../../components/admin/GameFormModal'
|
||||||
import { useGames } from '../../hooks/useGames'
|
import { useGames } from '../../hooks/useGames'
|
||||||
import {
|
import { useCreateGame, useUpdateGame, useDeleteGame } from '../../hooks/useAdmin'
|
||||||
useCreateGame,
|
|
||||||
useUpdateGame,
|
|
||||||
useDeleteGame,
|
|
||||||
} from '../../hooks/useAdmin'
|
|
||||||
import { exportGames } from '../../api/admin'
|
import { exportGames } from '../../api/admin'
|
||||||
import { downloadJson } from '../../utils/download'
|
import { downloadJson } from '../../utils/download'
|
||||||
import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
|
import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
|
||||||
@@ -22,10 +18,7 @@ export function AdminGames() {
|
|||||||
const [regionFilter, setRegionFilter] = useState('')
|
const [regionFilter, setRegionFilter] = useState('')
|
||||||
const [genFilter, setGenFilter] = useState('')
|
const [genFilter, setGenFilter] = useState('')
|
||||||
|
|
||||||
const regions = useMemo(
|
const regions = useMemo(() => [...new Set(games.map((g) => g.region))].sort(), [games])
|
||||||
() => [...new Set(games.map((g) => g.region))].sort(),
|
|
||||||
[games]
|
|
||||||
)
|
|
||||||
const generations = useMemo(
|
const generations = useMemo(
|
||||||
() => [...new Set(games.map((g) => g.generation))].sort((a, b) => a - b),
|
() => [...new Set(games.map((g) => g.generation))].sort((a, b) => a - b),
|
||||||
[games]
|
[games]
|
||||||
@@ -34,8 +27,7 @@ export function AdminGames() {
|
|||||||
const filteredGames = useMemo(() => {
|
const filteredGames = useMemo(() => {
|
||||||
let result = games
|
let result = games
|
||||||
if (regionFilter) result = result.filter((g) => g.region === regionFilter)
|
if (regionFilter) result = result.filter((g) => g.region === regionFilter)
|
||||||
if (genFilter)
|
if (genFilter) result = result.filter((g) => g.generation === Number(genFilter))
|
||||||
result = result.filter((g) => g.generation === Number(genFilter))
|
|
||||||
return result
|
return result
|
||||||
}, [games, regionFilter, genFilter])
|
}, [games, regionFilter, genFilter])
|
||||||
|
|
||||||
|
|||||||
@@ -28,23 +28,18 @@ export function AdminGenlockeDetail() {
|
|||||||
const [addingLeg, setAddingLeg] = useState(false)
|
const [addingLeg, setAddingLeg] = useState(false)
|
||||||
const [selectedGameId, setSelectedGameId] = useState<number | ''>('')
|
const [selectedGameId, setSelectedGameId] = useState<number | ''>('')
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading) return <div className="py-8 text-center text-gray-500">Loading...</div>
|
||||||
return <div className="py-8 text-center text-gray-500">Loading...</div>
|
if (!genlocke) return <div className="py-8 text-center text-gray-500">Genlocke not found</div>
|
||||||
if (!genlocke)
|
|
||||||
return (
|
|
||||||
<div className="py-8 text-center text-gray-500">Genlocke not found</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const editName = name ?? genlocke.name
|
const editName = name ?? genlocke.name
|
||||||
const editStatus = status ?? genlocke.status
|
const editStatus = status ?? genlocke.status
|
||||||
|
|
||||||
const hasChanges =
|
const hasChanges = editName !== genlocke.name || editStatus !== genlocke.status
|
||||||
editName !== genlocke.name || editStatus !== genlocke.status
|
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const data: Record<string, string> = {}
|
const data: Record<string, string> = {}
|
||||||
if (editName !== genlocke.name) data.name = editName
|
if (editName !== genlocke.name) data['name'] = editName
|
||||||
if (editStatus !== genlocke.status) data.status = editStatus
|
if (editStatus !== genlocke.status) data['status'] = editStatus
|
||||||
if (Object.keys(data).length === 0) return
|
if (Object.keys(data).length === 0) return
|
||||||
updateGenlocke.mutate(
|
updateGenlocke.mutate(
|
||||||
{ id, data },
|
{ id, data },
|
||||||
@@ -77,9 +72,7 @@ export function AdminGenlockeDetail() {
|
|||||||
Genlockes
|
Genlockes
|
||||||
</Link>
|
</Link>
|
||||||
{' / '}
|
{' / '}
|
||||||
<span className="text-gray-900 dark:text-gray-100">
|
<span className="text-gray-900 dark:text-gray-100">{genlocke.name}</span>
|
||||||
{genlocke.name}
|
|
||||||
</span>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -131,22 +124,16 @@ export function AdminGenlockeDetail() {
|
|||||||
|
|
||||||
{/* Rules (read-only) */}
|
{/* Rules (read-only) */}
|
||||||
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
|
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Rules</h3>
|
||||||
Rules
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">Genlocke rules:</span>
|
||||||
Genlocke rules:
|
|
||||||
</span>
|
|
||||||
<pre className="mt-1 text-xs bg-white dark:bg-gray-900 p-2 rounded border dark:border-gray-700 overflow-x-auto">
|
<pre className="mt-1 text-xs bg-white dark:bg-gray-900 p-2 rounded border dark:border-gray-700 overflow-x-auto">
|
||||||
{JSON.stringify(genlocke.genlockeRules, null, 2)}
|
{JSON.stringify(genlocke.genlockeRules, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">Nuzlocke rules:</span>
|
||||||
Nuzlocke rules:
|
|
||||||
</span>
|
|
||||||
<pre className="mt-1 text-xs bg-white dark:bg-gray-900 p-2 rounded border dark:border-gray-700 overflow-x-auto">
|
<pre className="mt-1 text-xs bg-white dark:bg-gray-900 p-2 rounded border dark:border-gray-700 overflow-x-auto">
|
||||||
{JSON.stringify(genlocke.nuzlockeRules, null, 2)}
|
{JSON.stringify(genlocke.nuzlockeRules, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
@@ -157,9 +144,7 @@ export function AdminGenlockeDetail() {
|
|||||||
{/* Legs */}
|
{/* Legs */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">Legs ({genlocke.legs.length})</h3>
|
||||||
Legs ({genlocke.legs.length})
|
|
||||||
</h3>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setAddingLeg(!addingLeg)}
|
onClick={() => setAddingLeg(!addingLeg)}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
||||||
@@ -172,9 +157,7 @@ export function AdminGenlockeDetail() {
|
|||||||
<div className="mb-4 flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<div className="mb-4 flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
<select
|
<select
|
||||||
value={selectedGameId}
|
value={selectedGameId}
|
||||||
onChange={(e) =>
|
onChange={(e) => setSelectedGameId(e.target.value ? Number(e.target.value) : '')}
|
||||||
setSelectedGameId(e.target.value ? Number(e.target.value) : '')
|
|
||||||
}
|
|
||||||
className="flex-1 px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
className="flex-1 px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
>
|
>
|
||||||
<option value="">Select a game...</option>
|
<option value="">Select a game...</option>
|
||||||
@@ -239,12 +222,8 @@ export function AdminGenlockeDetail() {
|
|||||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{genlocke.legs.map((leg) => (
|
{genlocke.legs.map((leg) => (
|
||||||
<tr key={leg.id}>
|
<tr key={leg.id}>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.legOrder}</td>
|
||||||
{leg.legOrder}
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.game.name}</td>
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
|
||||||
{leg.game.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||||
{leg.runId ? (
|
{leg.runId ? (
|
||||||
<Link
|
<Link
|
||||||
@@ -274,12 +253,8 @@ export function AdminGenlockeDetail() {
|
|||||||
<span className="text-gray-400">—</span>
|
<span className="text-gray-400">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.encounterCount}</td>
|
||||||
{leg.encounterCount}
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.deathCount}</td>
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
|
||||||
{leg.deathCount}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-right">
|
<td className="px-4 py-3 text-sm whitespace-nowrap text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteLeg.mutate(leg.id)}
|
onClick={() => deleteLeg.mutate(leg.id)}
|
||||||
@@ -305,9 +280,7 @@ export function AdminGenlockeDetail() {
|
|||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
|
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Stats</h3>
|
||||||
Stats
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">Legs</span>
|
<span className="text-gray-500 dark:text-gray-400">Legs</span>
|
||||||
@@ -317,20 +290,14 @@ export function AdminGenlockeDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">Encounters</span>
|
<span className="text-gray-500 dark:text-gray-400">Encounters</span>
|
||||||
<p className="text-lg font-semibold">
|
<p className="text-lg font-semibold">{genlocke.stats.totalEncounters}</p>
|
||||||
{genlocke.stats.totalEncounters}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">Deaths</span>
|
<span className="text-gray-500 dark:text-gray-400">Deaths</span>
|
||||||
<p className="text-lg font-semibold">
|
<p className="text-lg font-semibold">{genlocke.stats.totalDeaths}</p>
|
||||||
{genlocke.stats.totalDeaths}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">Survival Rate</span>
|
||||||
Survival Rate
|
|
||||||
</span>
|
|
||||||
<p className="text-lg font-semibold">
|
<p className="text-lg font-semibold">
|
||||||
{genlocke.stats.totalEncounters > 0
|
{genlocke.stats.totalEncounters > 0
|
||||||
? `${Math.round(((genlocke.stats.totalEncounters - genlocke.stats.totalDeaths) / genlocke.stats.totalEncounters) * 100)}%`
|
? `${Math.round(((genlocke.stats.totalEncounters - genlocke.stats.totalDeaths) / genlocke.stats.totalEncounters) * 100)}%`
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import {
|
|||||||
} from '../../hooks/useAdmin'
|
} from '../../hooks/useAdmin'
|
||||||
import { exportPokemon } from '../../api/admin'
|
import { exportPokemon } from '../../api/admin'
|
||||||
import { downloadJson } from '../../utils/download'
|
import { downloadJson } from '../../utils/download'
|
||||||
import type {
|
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types'
|
||||||
Pokemon,
|
|
||||||
CreatePokemonInput,
|
|
||||||
UpdatePokemonInput,
|
|
||||||
} from '../../types'
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
@@ -164,8 +160,7 @@ export function AdminPokemon() {
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div className="mt-4 flex items-center justify-between">
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of{' '}
|
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of {total}
|
||||||
{total}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -220,9 +215,7 @@ export function AdminPokemon() {
|
|||||||
title="Bulk Import Pokemon"
|
title="Bulk Import Pokemon"
|
||||||
example={`[\n { "pokeapi_id": 1, "national_dex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] }\n]`}
|
example={`[\n { "pokeapi_id": 1, "national_dex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] }\n]`}
|
||||||
onSubmit={(items) =>
|
onSubmit={(items) =>
|
||||||
bulkImport.mutateAsync(
|
bulkImport.mutateAsync(items as Parameters<typeof bulkImport.mutateAsync>[0])
|
||||||
items as Parameters<typeof bulkImport.mutateAsync>[0]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onClose={() => setShowBulkImport(false)}
|
onClose={() => setShowBulkImport(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ export function AdminRouteDetail() {
|
|||||||
)
|
)
|
||||||
const currentIndex = sortedRoutes.findIndex((r) => r.id === rId)
|
const currentIndex = sortedRoutes.findIndex((r) => r.id === rId)
|
||||||
const route = currentIndex >= 0 ? sortedRoutes[currentIndex] : undefined
|
const route = currentIndex >= 0 ? sortedRoutes[currentIndex] : undefined
|
||||||
const prevRoute =
|
const prevRoute = currentIndex > 0 ? sortedRoutes[currentIndex - 1] : undefined
|
||||||
currentIndex > 0 ? sortedRoutes[currentIndex - 1] : undefined
|
|
||||||
const nextRoute =
|
const nextRoute =
|
||||||
currentIndex >= 0 && currentIndex < sortedRoutes.length - 1
|
currentIndex >= 0 && currentIndex < sortedRoutes.length - 1
|
||||||
? sortedRoutes[currentIndex + 1]
|
? sortedRoutes[currentIndex + 1]
|
||||||
@@ -55,9 +54,7 @@ export function AdminRouteDetail() {
|
|||||||
|
|
||||||
const childRoutes = useMemo(
|
const childRoutes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(game?.routes ?? [])
|
(game?.routes ?? []).filter((r) => r.parentRouteId === rId).sort((a, b) => a.order - b.order),
|
||||||
.filter((r) => r.parentRouteId === rId)
|
|
||||||
.sort((a, b) => a.order - b.order),
|
|
||||||
[game?.routes, rId]
|
[game?.routes, rId]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,11 +69,7 @@ export function AdminRouteDetail() {
|
|||||||
accessor: (e) => (
|
accessor: (e) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{e.pokemon.spriteUrl ? (
|
{e.pokemon.spriteUrl ? (
|
||||||
<img
|
<img src={e.pokemon.spriteUrl} alt={e.pokemon.name} className="w-6 h-6" />
|
||||||
src={e.pokemon.spriteUrl}
|
|
||||||
alt={e.pokemon.name}
|
|
||||||
className="w-6 h-6"
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
<span>
|
<span>
|
||||||
#{e.pokemon.nationalDex} {e.pokemon.name}
|
#{e.pokemon.nationalDex} {e.pokemon.name}
|
||||||
@@ -89,9 +82,7 @@ export function AdminRouteDetail() {
|
|||||||
{
|
{
|
||||||
header: 'Levels',
|
header: 'Levels',
|
||||||
accessor: (e) =>
|
accessor: (e) =>
|
||||||
e.minLevel === e.maxLevel
|
e.minLevel === e.maxLevel ? `Lv ${e.minLevel}` : `Lv ${e.minLevel}-${e.maxLevel}`,
|
||||||
? `Lv ${e.minLevel}`
|
|
||||||
: `Lv ${e.minLevel}-${e.maxLevel}`,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -109,9 +100,7 @@ export function AdminRouteDetail() {
|
|||||||
<select
|
<select
|
||||||
className="text-gray-900 dark:text-gray-100 bg-transparent font-medium cursor-pointer hover:underline border-none p-0 text-sm"
|
className="text-gray-900 dark:text-gray-100 bg-transparent font-medium cursor-pointer hover:underline border-none p-0 text-sm"
|
||||||
value={rId}
|
value={rId}
|
||||||
onChange={(e) =>
|
onChange={(e) => navigate(`/admin/games/${gId}/routes/${e.target.value}`)}
|
||||||
navigate(`/admin/games/${gId}/routes/${e.target.value}`)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{sortedRoutes.map((r) => (
|
{sortedRoutes.map((r) => (
|
||||||
<option key={r.id} value={r.id}>
|
<option key={r.id} value={r.id}>
|
||||||
@@ -175,12 +164,9 @@ export function AdminRouteDetail() {
|
|||||||
{showCreate && (
|
{showCreate && (
|
||||||
<RouteEncounterFormModal
|
<RouteEncounterFormModal
|
||||||
onSubmit={(data) =>
|
onSubmit={(data) =>
|
||||||
addEncounter.mutate(
|
addEncounter.mutate({ ...data, gameId: gId } as CreateRouteEncounterInput, {
|
||||||
{ ...data, gameId: gId } as CreateRouteEncounterInput,
|
|
||||||
{
|
|
||||||
onSuccess: () => setShowCreate(false),
|
onSuccess: () => setShowCreate(false),
|
||||||
}
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onClose={() => setShowCreate(false)}
|
onClose={() => setShowCreate(false)}
|
||||||
isSubmitting={addEncounter.isPending}
|
isSubmitting={addEncounter.isPending}
|
||||||
@@ -213,9 +199,7 @@ export function AdminRouteDetail() {
|
|||||||
{/* Sub-areas */}
|
{/* Sub-areas */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">Sub-areas ({childRoutes.length})</h3>
|
||||||
Sub-areas ({childRoutes.length})
|
|
||||||
</h3>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateChild(true)}
|
onClick={() => setShowCreateChild(true)}
|
||||||
className="px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
className="px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
||||||
@@ -224,16 +208,11 @@ export function AdminRouteDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{childRoutes.length === 0 ? (
|
{childRoutes.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">No sub-areas for this route.</p>
|
||||||
No sub-areas for this route.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded-md dark:border-gray-700 divide-y dark:divide-gray-700">
|
<div className="border rounded-md dark:border-gray-700 divide-y dark:divide-gray-700">
|
||||||
{childRoutes.map((child) => (
|
{childRoutes.map((child) => (
|
||||||
<div
|
<div key={child.id} className="flex items-center justify-between px-4 py-2">
|
||||||
key={child.id}
|
|
||||||
className="flex items-center justify-between px-4 py-2"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
to={`/admin/games/${gId}/routes/${child.id}`}
|
to={`/admin/games/${gId}/routes/${child.id}`}
|
||||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
@@ -256,10 +235,9 @@ export function AdminRouteDetail() {
|
|||||||
<RouteFormModal
|
<RouteFormModal
|
||||||
nextOrder={nextChildOrder}
|
nextOrder={nextChildOrder}
|
||||||
onSubmit={(data) =>
|
onSubmit={(data) =>
|
||||||
createRoute.mutate(
|
createRoute.mutate({ ...data, parentRouteId: rId } as CreateRouteInput, {
|
||||||
{ ...data, parentRouteId: rId } as CreateRouteInput,
|
onSuccess: () => setShowCreateChild(false),
|
||||||
{ onSuccess: () => setShowCreateChild(false) }
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onClose={() => setShowCreateChild(false)}
|
onClose={() => setShowCreateChild(false)}
|
||||||
isSubmitting={createRoute.isPending}
|
isSubmitting={createRoute.isPending}
|
||||||
|
|||||||
@@ -14,16 +14,12 @@ export function AdminRuns() {
|
|||||||
const [statusFilter, setStatusFilter] = useState('')
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
const [gameFilter, setGameFilter] = useState('')
|
const [gameFilter, setGameFilter] = useState('')
|
||||||
|
|
||||||
const gameMap = useMemo(
|
const gameMap = useMemo(() => new Map(games.map((g) => [g.id, g.name])), [games])
|
||||||
() => new Map(games.map((g) => [g.id, g.name])),
|
|
||||||
[games]
|
|
||||||
)
|
|
||||||
|
|
||||||
const filteredRuns = useMemo(() => {
|
const filteredRuns = useMemo(() => {
|
||||||
let result = runs
|
let result = runs
|
||||||
if (statusFilter) result = result.filter((r) => r.status === statusFilter)
|
if (statusFilter) result = result.filter((r) => r.status === statusFilter)
|
||||||
if (gameFilter)
|
if (gameFilter) result = result.filter((r) => r.gameId === Number(gameFilter))
|
||||||
result = result.filter((r) => r.gameId === Number(gameFilter))
|
|
||||||
return result
|
return result
|
||||||
}, [runs, statusFilter, gameFilter])
|
}, [runs, statusFilter, gameFilter])
|
||||||
|
|
||||||
@@ -31,10 +27,7 @@ export function AdminRuns() {
|
|||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
...new Map(
|
...new Map(
|
||||||
runs.map((r) => [
|
runs.map((r) => [r.gameId, gameMap.get(r.gameId) ?? `Game #${r.gameId}`])
|
||||||
r.gameId,
|
|
||||||
gameMap.get(r.gameId) ?? `Game #${r.gameId}`,
|
|
||||||
])
|
|
||||||
).entries(),
|
).entries(),
|
||||||
].sort((a, b) => a[1].localeCompare(b[1])),
|
].sort((a, b) => a[1].localeCompare(b[1])),
|
||||||
[runs, gameMap]
|
[runs, gameMap]
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
export type GameCategory =
|
export type GameCategory = 'original' | 'remake' | 'enhanced' | 'sequel' | 'spinoff'
|
||||||
| 'original'
|
|
||||||
| 'remake'
|
|
||||||
| 'enhanced'
|
|
||||||
| 'sequel'
|
|
||||||
| 'spinoff'
|
|
||||||
|
|
||||||
export interface Game {
|
export interface Game {
|
||||||
id: number
|
id: number
|
||||||
@@ -152,19 +147,19 @@ export interface UpdateRunInput {
|
|||||||
export interface CreateEncounterInput {
|
export interface CreateEncounterInput {
|
||||||
routeId: number
|
routeId: number
|
||||||
pokemonId: number
|
pokemonId: number
|
||||||
nickname?: string
|
nickname?: string | undefined
|
||||||
status: EncounterStatus
|
status: EncounterStatus
|
||||||
catchLevel?: number
|
catchLevel?: number | undefined
|
||||||
isShiny?: boolean
|
isShiny?: boolean | undefined
|
||||||
origin?: string
|
origin?: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateEncounterInput {
|
export interface UpdateEncounterInput {
|
||||||
nickname?: string
|
nickname?: string | undefined
|
||||||
status?: EncounterStatus
|
status?: EncounterStatus | undefined
|
||||||
faintLevel?: number
|
faintLevel?: number | undefined
|
||||||
deathCause?: string
|
deathCause?: string | undefined
|
||||||
currentPokemonId?: number
|
currentPokemonId?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boss battles
|
// Boss battles
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
|
|||||||
{
|
{
|
||||||
key: 'nicknameRequired',
|
key: 'nicknameRequired',
|
||||||
name: 'Nickname Required',
|
name: 'Nickname Required',
|
||||||
description:
|
description: 'All caught Pokémon must be given a nickname to form a stronger bond.',
|
||||||
'All caught Pokémon must be given a nickname to form a stronger bond.',
|
|
||||||
category: 'core',
|
category: 'core',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -90,8 +89,7 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
|
|||||||
{
|
{
|
||||||
key: 'hardcoreMode',
|
key: 'hardcoreMode',
|
||||||
name: 'Hardcore Mode',
|
name: 'Hardcore Mode',
|
||||||
description:
|
description: 'No items may be used during battle. Held items are still allowed.',
|
||||||
'No items may be used during battle. Held items are still allowed.',
|
|
||||||
category: 'difficulty',
|
category: 'difficulty',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,15 +11,11 @@ export function formatEvolutionMethod(evo: {
|
|||||||
} else if (evo.trigger === 'level-up') {
|
} else if (evo.trigger === 'level-up') {
|
||||||
parts.push('Level up')
|
parts.push('Level up')
|
||||||
} else if (evo.trigger === 'use-item' && evo.item) {
|
} else if (evo.trigger === 'use-item' && evo.item) {
|
||||||
parts.push(
|
parts.push(evo.item.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()))
|
||||||
evo.item.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
)
|
|
||||||
} else if (evo.trigger === 'trade') {
|
} else if (evo.trigger === 'trade') {
|
||||||
parts.push('Trade')
|
parts.push('Trade')
|
||||||
} else {
|
} else {
|
||||||
parts.push(
|
parts.push(evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()))
|
||||||
evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (evo.heldItem) {
|
if (evo.heldItem) {
|
||||||
parts.push(
|
parts.push(
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="vitest/config" />
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
@@ -5,6 +6,9 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
Reference in New Issue
Block a user