Add pre-commit hooks for linting and formatting
Set up pre-commit framework with ruff (backend) and ESLint/Prettier/tsc (frontend) hooks to catch issues locally before CI. Auto-format all frontend files with Prettier to comply with the new check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
# nuzlocke-tracker-k4u8
|
||||||
|
title: Implement pre-commit hooks for linting
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-02-14T15:37:32Z
|
||||||
|
updated_at: 2026-02-14T15:40:44Z
|
||||||
|
---
|
||||||
|
|
||||||
|
Set up pre-commit framework with hooks for ruff (backend), ESLint/Prettier/tsc (frontend). Add pre-commit to dev deps, update CI with Prettier check, document in CLAUDE.md.
|
||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -45,6 +45,9 @@ jobs:
|
|||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
- name: Check formatting
|
||||||
|
run: npx prettier --check "src/**/*.{ts,tsx,css,json}"
|
||||||
|
working-directory: frontend
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npx tsc -b
|
run: npx tsc -b
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|||||||
34
.pre-commit-config.yaml
Normal file
34
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
repos:
|
||||||
|
# Backend (Python) — ruff linting + formatting
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.15.0
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
files: ^backend/
|
||||||
|
- id: ruff-format
|
||||||
|
files: ^backend/
|
||||||
|
|
||||||
|
# Frontend (TypeScript/React) — local hooks using project node_modules
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: eslint
|
||||||
|
name: eslint
|
||||||
|
entry: npx eslint
|
||||||
|
language: system
|
||||||
|
files: ^frontend/src/.*\.(ts|tsx)$
|
||||||
|
pass_filenames: true
|
||||||
|
|
||||||
|
- id: prettier
|
||||||
|
name: prettier
|
||||||
|
entry: npx prettier --check
|
||||||
|
language: system
|
||||||
|
files: ^frontend/src/.*\.(ts|tsx|css|json)$
|
||||||
|
pass_filenames: true
|
||||||
|
|
||||||
|
- id: tsc
|
||||||
|
name: tsc
|
||||||
|
entry: bash -c 'cd frontend && npx tsc -b'
|
||||||
|
language: system
|
||||||
|
files: ^frontend/src/.*\.(ts|tsx)$
|
||||||
|
pass_filenames: false
|
||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -8,6 +8,18 @@
|
|||||||
- **Merge commit** `develop` into `main` (marks deploy points).
|
- **Merge commit** `develop` into `main` (marks deploy points).
|
||||||
- Always `git pull` the target branch before merging into it.
|
- Always `git pull` the target branch before merging into it.
|
||||||
|
|
||||||
|
# Pre-commit Hooks
|
||||||
|
|
||||||
|
This project uses [pre-commit](https://pre-commit.com/) to run linting and formatting checks before each commit.
|
||||||
|
|
||||||
|
**Setup:** `pip install pre-commit && pre-commit install`
|
||||||
|
|
||||||
|
**Hooks configured:**
|
||||||
|
- **Backend:** `ruff check --fix` and `ruff format` on Python files under `backend/`
|
||||||
|
- **Frontend:** `eslint`, `prettier --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`).
|
||||||
|
|
||||||
# Instructions
|
# Instructions
|
||||||
|
|
||||||
- After completing a task, always ask the user if they'd like to commit the changes.
|
- After completing a task, always ask the user if they'd like to commit the changes.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ dependencies = [
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff>=0.9.0",
|
"ruff>=0.9.0",
|
||||||
|
"pre-commit>=4.0.0",
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
"pytest-asyncio>=0.25.0",
|
"pytest-asyncio>=0.25.0",
|
||||||
"httpx>=0.28.0",
|
"httpx>=0.28.0",
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { Layout } from './components'
|
import { Layout } from './components'
|
||||||
import { AdminLayout } from './components/admin'
|
import { AdminLayout } from './components/admin'
|
||||||
import { GenlockeDetail, GenlockeList, Home, NewGenlocke, NewRun, RunList, RunEncounters, Stats } from './pages'
|
import {
|
||||||
|
GenlockeDetail,
|
||||||
|
GenlockeList,
|
||||||
|
Home,
|
||||||
|
NewGenlocke,
|
||||||
|
NewRun,
|
||||||
|
RunList,
|
||||||
|
RunEncounters,
|
||||||
|
Stats,
|
||||||
|
} from './pages'
|
||||||
import {
|
import {
|
||||||
AdminGames,
|
AdminGames,
|
||||||
AdminGameDetail,
|
AdminGameDetail,
|
||||||
@@ -25,17 +34,26 @@ function App() {
|
|||||||
<Route path="genlockes/new" element={<NewGenlocke />} />
|
<Route path="genlockes/new" element={<NewGenlocke />} />
|
||||||
<Route path="genlockes/:genlockeId" element={<GenlockeDetail />} />
|
<Route path="genlockes/:genlockeId" element={<GenlockeDetail />} />
|
||||||
<Route path="stats" element={<Stats />} />
|
<Route path="stats" element={<Stats />} />
|
||||||
<Route path="runs/:runId/encounters" element={<Navigate to=".." relative="path" replace />} />
|
<Route
|
||||||
|
path="runs/:runId/encounters"
|
||||||
|
element={<Navigate to=".." relative="path" replace />}
|
||||||
|
/>
|
||||||
<Route path="admin" element={<AdminLayout />}>
|
<Route path="admin" element={<AdminLayout />}>
|
||||||
<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 path="games/:gameId/routes/:routeId" element={<AdminRouteDetail />} />
|
<Route
|
||||||
|
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 path="genlockes/:genlockeId" element={<AdminGenlockeDetail />} />
|
<Route
|
||||||
|
path="genlockes/:genlockeId"
|
||||||
|
element={<AdminGenlockeDetail />}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -36,15 +36,17 @@ export const createGame = (data: CreateGameInput) =>
|
|||||||
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) =>
|
export const deleteGame = (id: number) => api.del(`/games/${id}`)
|
||||||
api.del(`/games/${id}`)
|
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
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 = (gameId: number, routeId: number, data: UpdateRouteInput) =>
|
export const updateRoute = (
|
||||||
api.put<Route>(`/games/${gameId}/routes/${routeId}`, data)
|
gameId: number,
|
||||||
|
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}`)
|
||||||
@@ -53,7 +55,12 @@ 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 = (search?: string, limit = 50, offset = 0, type?: string) => {
|
export const listPokemon = (
|
||||||
|
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)
|
||||||
@@ -68,11 +75,17 @@ export const createPokemon = (data: CreatePokemonInput) =>
|
|||||||
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)
|
||||||
|
|
||||||
export const deletePokemon = (id: number) =>
|
export const deletePokemon = (id: number) => api.del(`/pokemon/${id}`)
|
||||||
api.del(`/pokemon/${id}`)
|
|
||||||
|
|
||||||
export const bulkImportPokemon = (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
|
export const bulkImportPokemon = (
|
||||||
api.post<BulkImportResult>('/pokemon/bulk-import', items)
|
items: Array<{
|
||||||
|
pokeapiId: number
|
||||||
|
nationalDex: number
|
||||||
|
name: string
|
||||||
|
types: string[]
|
||||||
|
spriteUrl?: string | null
|
||||||
|
}>
|
||||||
|
) => api.post<BulkImportResult>('/pokemon/bulk-import', items)
|
||||||
|
|
||||||
export const bulkImportEvolutions = (items: unknown[]) =>
|
export const bulkImportEvolutions = (items: unknown[]) =>
|
||||||
api.post<BulkImportResult>('/evolutions/bulk-import', items)
|
api.post<BulkImportResult>('/evolutions/bulk-import', items)
|
||||||
@@ -84,7 +97,12 @@ 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 = (search?: string, limit = 50, offset = 0, trigger?: string) => {
|
export const listEvolutions = (
|
||||||
|
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)
|
||||||
@@ -99,8 +117,7 @@ export const createEvolution = (data: CreateEvolutionInput) =>
|
|||||||
export const updateEvolution = (id: number, data: UpdateEvolutionInput) =>
|
export const updateEvolution = (id: number, data: UpdateEvolutionInput) =>
|
||||||
api.put<EvolutionAdmin>(`/evolutions/${id}`, data)
|
api.put<EvolutionAdmin>(`/evolutions/${id}`, data)
|
||||||
|
|
||||||
export const deleteEvolution = (id: number) =>
|
export const deleteEvolution = (id: number) => api.del(`/evolutions/${id}`)
|
||||||
api.del(`/evolutions/${id}`)
|
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
export const exportGames = () =>
|
export const exportGames = () =>
|
||||||
@@ -119,11 +136,20 @@ export const exportEvolutions = () =>
|
|||||||
api.get<Record<string, unknown>[]>('/export/evolutions')
|
api.get<Record<string, unknown>[]>('/export/evolutions')
|
||||||
|
|
||||||
// Route Encounters
|
// Route Encounters
|
||||||
export const addRouteEncounter = (routeId: number, data: CreateRouteEncounterInput) =>
|
export const addRouteEncounter = (
|
||||||
api.post<RouteEncounterDetail>(`/routes/${routeId}/pokemon`, data)
|
routeId: number,
|
||||||
|
data: CreateRouteEncounterInput
|
||||||
|
) => api.post<RouteEncounterDetail>(`/routes/${routeId}/pokemon`, data)
|
||||||
|
|
||||||
export const updateRouteEncounter = (routeId: number, encounterId: number, data: UpdateRouteEncounterInput) =>
|
export const updateRouteEncounter = (
|
||||||
api.put<RouteEncounterDetail>(`/routes/${routeId}/pokemon/${encounterId}`, data)
|
routeId: number,
|
||||||
|
encounterId: number,
|
||||||
|
data: UpdateRouteEncounterInput
|
||||||
|
) =>
|
||||||
|
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}`)
|
||||||
@@ -132,8 +158,11 @@ 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 = (gameId: number, bossId: number, data: UpdateBossBattleInput) =>
|
export const updateBossBattle = (
|
||||||
api.put<BossBattle>(`/games/${gameId}/bosses/${bossId}`, data)
|
gameId: number,
|
||||||
|
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}`)
|
||||||
@@ -141,15 +170,17 @@ 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 = (gameId: number, bossId: number, team: BossPokemonInput[]) =>
|
export const setBossTeam = (
|
||||||
api.put<BossBattle>(`/games/${gameId}/bosses/${bossId}/pokemon`, team)
|
gameId: number,
|
||||||
|
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) =>
|
||||||
api.patch<Genlocke>(`/genlockes/${id}`, data)
|
api.patch<Genlocke>(`/genlockes/${id}`, data)
|
||||||
|
|
||||||
export const deleteGenlocke = (id: number) =>
|
export const deleteGenlocke = (id: number) => api.del(`/genlockes/${id}`)
|
||||||
api.del(`/genlockes/${id}`)
|
|
||||||
|
|
||||||
export const addGenlockeLeg = (genlockeId: number, data: AddGenlockeLegInput) =>
|
export const addGenlockeLeg = (genlockeId: number, data: AddGenlockeLegInput) =>
|
||||||
api.post<Genlocke>(`/genlockes/${genlockeId}/legs`, data)
|
api.post<Genlocke>(`/genlockes/${genlockeId}/legs`, data)
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
import type { BossBattle, BossResult, CreateBossResultInput } from '../types/game'
|
import type {
|
||||||
|
BossBattle,
|
||||||
|
BossResult,
|
||||||
|
CreateBossResultInput,
|
||||||
|
} from '../types/game'
|
||||||
|
|
||||||
export function getGameBosses(gameId: number, all?: boolean): Promise<BossBattle[]> {
|
export function getGameBosses(
|
||||||
|
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}`)
|
||||||
}
|
}
|
||||||
@@ -10,10 +17,16 @@ export function getBossResults(runId: number): Promise<BossResult[]> {
|
|||||||
return api.get(`/runs/${runId}/boss-results`)
|
return api.get(`/runs/${runId}/boss-results`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBossResult(runId: number, data: CreateBossResultInput): Promise<BossResult> {
|
export function createBossResult(
|
||||||
|
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(runId: number, resultId: number): Promise<void> {
|
export function deleteBossResult(
|
||||||
|
runId: number,
|
||||||
|
resultId: number
|
||||||
|
): Promise<void> {
|
||||||
return api.del(`/runs/${runId}/boss-results/${resultId}`)
|
return api.del(`/runs/${runId}/boss-results/${resultId}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
path: string,
|
|
||||||
options?: RequestInit,
|
|
||||||
): Promise<T> {
|
|
||||||
const res = await fetch(`${API_BASE}/api/v1${path}`, {
|
const res = await fetch(`${API_BASE}/api/v1${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -52,6 +49,5 @@ export const api = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
del: <T = void>(path: string) =>
|
del: <T = void>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
request<T>(path, { method: 'DELETE' }),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import type {
|
|||||||
|
|
||||||
export function createEncounter(
|
export function createEncounter(
|
||||||
runId: number,
|
runId: number,
|
||||||
data: CreateEncounterInput,
|
data: CreateEncounterInput
|
||||||
): Promise<EncounterDetail> {
|
): Promise<EncounterDetail> {
|
||||||
return api.post(`/runs/${runId}/encounters`, data)
|
return api.post(`/runs/${runId}/encounters`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateEncounter(
|
export function updateEncounter(
|
||||||
id: number,
|
id: number,
|
||||||
data: UpdateEncounterInput,
|
data: UpdateEncounterInput
|
||||||
): Promise<EncounterDetail> {
|
): Promise<EncounterDetail> {
|
||||||
return api.patch(`/encounters/${id}`, data)
|
return api.patch(`/encounters/${id}`, data)
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,10 @@ export function deleteEncounter(id: number): Promise<void> {
|
|||||||
return api.del(`/encounters/${id}`)
|
return api.del(`/encounters/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchEvolutions(pokemonId: number, region?: string): Promise<Evolution[]> {
|
export function fetchEvolutions(
|
||||||
|
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}`)
|
||||||
}
|
}
|
||||||
@@ -34,6 +37,8 @@ export function fetchForms(pokemonId: number): Promise<Pokemon[]> {
|
|||||||
return api.get(`/pokemon/${pokemonId}/forms`)
|
return api.get(`/pokemon/${pokemonId}/forms`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bulkRandomizeEncounters(runId: number): Promise<{ created: unknown[]; skippedRoutes: number }> {
|
export function bulkRandomizeEncounters(
|
||||||
|
runId: number
|
||||||
|
): Promise<{ created: unknown[]; skippedRoutes: number }> {
|
||||||
return api.post(`/runs/${runId}/encounters/bulk-randomize`, {})
|
return api.post(`/runs/${runId}/encounters/bulk-randomize`, {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ 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(routeId: number, gameId?: number): Promise<RouteEncounterDetail[]> {
|
export function getRoutePokemon(
|
||||||
|
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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
import type { Genlocke, GenlockeListItem, GenlockeDetail, GenlockeGraveyard, GenlockeLineage, CreateGenlockeInput, Region, SurvivorEncounter, AdvanceLegInput } from '../types/game'
|
import type {
|
||||||
|
Genlocke,
|
||||||
|
GenlockeListItem,
|
||||||
|
GenlockeDetail,
|
||||||
|
GenlockeGraveyard,
|
||||||
|
GenlockeLineage,
|
||||||
|
CreateGenlockeInput,
|
||||||
|
Region,
|
||||||
|
SurvivorEncounter,
|
||||||
|
AdvanceLegInput,
|
||||||
|
} from '../types/game'
|
||||||
|
|
||||||
export function getGenlockes(): Promise<GenlockeListItem[]> {
|
export function getGenlockes(): Promise<GenlockeListItem[]> {
|
||||||
return api.get('/genlockes')
|
return api.get('/genlockes')
|
||||||
@@ -25,10 +35,20 @@ export function getGenlockeLineages(id: number): Promise<GenlockeLineage> {
|
|||||||
return api.get(`/genlockes/${id}/lineages`)
|
return api.get(`/genlockes/${id}/lineages`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLegSurvivors(genlockeId: number, legOrder: number): Promise<SurvivorEncounter[]> {
|
export function getLegSurvivors(
|
||||||
|
genlockeId: number,
|
||||||
|
legOrder: number
|
||||||
|
): Promise<SurvivorEncounter[]> {
|
||||||
return api.get(`/genlockes/${genlockeId}/legs/${legOrder}/survivors`)
|
return api.get(`/genlockes/${genlockeId}/legs/${legOrder}/survivors`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function advanceLeg(genlockeId: number, legOrder: number, data?: AdvanceLegInput): Promise<Genlocke> {
|
export function advanceLeg(
|
||||||
return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, data ?? {})
|
genlockeId: number,
|
||||||
|
legOrder: number,
|
||||||
|
data?: AdvanceLegInput
|
||||||
|
): Promise<Genlocke> {
|
||||||
|
return api.post(
|
||||||
|
`/genlockes/${genlockeId}/legs/${legOrder}/advance`,
|
||||||
|
data ?? {}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ export function fetchPokemonFamilies(): Promise<{ families: number[][] }> {
|
|||||||
return api.get('/pokemon/families')
|
return api.get('/pokemon/families')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchPokemonEncounterLocations(pokemonId: number): Promise<PokemonEncounterLocation[]> {
|
export function fetchPokemonEncounterLocations(
|
||||||
|
pokemonId: number
|
||||||
|
): Promise<PokemonEncounterLocation[]> {
|
||||||
return api.get(`/pokemon/${pokemonId}/encounter-locations`)
|
return api.get(`/pokemon/${pokemonId}/encounter-locations`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchPokemonEvolutionChain(pokemonId: number): Promise<EvolutionAdmin[]> {
|
export function fetchPokemonEvolutionChain(
|
||||||
|
pokemonId: number
|
||||||
|
): Promise<EvolutionAdmin[]> {
|
||||||
return api.get(`/pokemon/${pokemonId}/evolution-chain`)
|
return api.get(`/pokemon/${pokemonId}/evolution-chain`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function createRun(data: CreateRunInput): Promise<NuzlockeRun> {
|
|||||||
|
|
||||||
export function updateRun(
|
export function updateRun(
|
||||||
id: number,
|
id: number,
|
||||||
data: UpdateRunInput,
|
data: UpdateRunInput
|
||||||
): Promise<NuzlockeRun> {
|
): Promise<NuzlockeRun> {
|
||||||
return api.patch(`/runs/${id}`, data)
|
return api.patch(`/runs/${id}`, data)
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,11 @@ export function getNamingCategories(): Promise<string[]> {
|
|||||||
return api.get('/runs/naming-categories')
|
return api.get('/runs/naming-categories')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNameSuggestions(runId: number, count = 10, pokemonId?: number): Promise<string[]> {
|
export function getNameSuggestions(
|
||||||
|
runId: number,
|
||||||
|
count = 10,
|
||||||
|
pokemonId?: number
|
||||||
|
): Promise<string[]> {
|
||||||
let url = `/runs/${runId}/name-suggestions?count=${count}`
|
let url = `/runs/${runId}/name-suggestions?count=${count}`
|
||||||
if (pokemonId != null) {
|
if (pokemonId != null) {
|
||||||
url += `&pokemon_id=${pokemonId}`
|
url += `&pokemon_id=${pokemonId}`
|
||||||
|
|||||||
@@ -10,14 +10,24 @@ interface BossDefeatModalProps {
|
|||||||
starterName?: string | null
|
starterName?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchVariant(labels: string[], starterName?: string | null): string | null {
|
function matchVariant(
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMode, starterName }: BossDefeatModalProps) {
|
export function BossDefeatModal({
|
||||||
|
boss,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
isPending,
|
||||||
|
hardcoreMode,
|
||||||
|
starterName,
|
||||||
|
}: BossDefeatModalProps) {
|
||||||
const [result, setResult] = useState<'won' | 'lost'>('won')
|
const [result, setResult] = useState<'won' | 'lost'>('won')
|
||||||
const [attempts, setAttempts] = useState('1')
|
const [attempts, setAttempts] = useState('1')
|
||||||
|
|
||||||
@@ -30,16 +40,20 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
|
|||||||
}, [boss.pokemon])
|
}, [boss.pokemon])
|
||||||
|
|
||||||
const hasVariants = variantLabels.length > 0
|
const hasVariants = variantLabels.length > 0
|
||||||
const autoMatch = useMemo(() => matchVariant(variantLabels, starterName), [variantLabels, starterName])
|
const autoMatch = useMemo(
|
||||||
|
() => matchVariant(variantLabels, starterName),
|
||||||
|
[variantLabels, starterName]
|
||||||
|
)
|
||||||
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)
|
||||||
)
|
)
|
||||||
|
|
||||||
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.conditionLabel === selectedVariant || bp.conditionLabel === null,
|
(bp) =>
|
||||||
|
bp.conditionLabel === selectedVariant || bp.conditionLabel === null
|
||||||
)
|
)
|
||||||
}, [boss.pokemon, hasVariants, selectedVariant])
|
}, [boss.pokemon, hasVariants, selectedVariant])
|
||||||
|
|
||||||
@@ -58,7 +72,9 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
|
|||||||
<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">{boss.location}</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{boss.location}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Boss team preview */}
|
{/* Boss team preview */}
|
||||||
@@ -88,7 +104,11 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
|
|||||||
.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 src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
|
<img
|
||||||
|
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" />
|
||||||
)}
|
)}
|
||||||
@@ -138,7 +158,9 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
|
|||||||
|
|
||||||
{!hardcoreMode && (
|
{!hardcoreMode && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Attempts</label>
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Attempts
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ export function EggEncounterModal({
|
|||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
|
||||||
// Only show leaf routes (no children)
|
// Only show leaf routes (no children)
|
||||||
const parentIds = new Set(routes.filter(r => r.parentRouteId !== null).map(r => r.parentRouteId))
|
const parentIds = new Set(
|
||||||
const leafRoutes = routes.filter(r => !parentIds.has(r.id))
|
routes.filter((r) => r.parentRouteId !== null).map((r) => r.parentRouteId)
|
||||||
|
)
|
||||||
|
const leafRoutes = routes.filter((r) => !parentIds.has(r.id))
|
||||||
|
|
||||||
// Debounced pokemon search
|
// Debounced pokemon search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,7 +46,9 @@ export function EggEncounterModal({
|
|||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
setIsSearching(true)
|
setIsSearching(true)
|
||||||
try {
|
try {
|
||||||
const data = await api.get<{ items: Pokemon[] }>(`/pokemon?search=${encodeURIComponent(search)}&limit=20`)
|
const data = await api.get<{ items: Pokemon[] }>(
|
||||||
|
`/pokemon?search=${encodeURIComponent(search)}&limit=20`
|
||||||
|
)
|
||||||
setSearchResults(data.items)
|
setSearchResults(data.items)
|
||||||
} catch {
|
} catch {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
@@ -196,11 +200,13 @@ export function EggEncounterModal({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{search.length >= 2 && !isSearching && searchResults.length === 0 && (
|
{search.length >= 2 &&
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">
|
!isSearching &&
|
||||||
No pokemon found
|
searchResults.length === 0 && (
|
||||||
</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">
|
||||||
)}
|
No pokemon found
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,14 +69,15 @@ export const METHOD_ORDER = [
|
|||||||
export function getMethodLabel(method: string): string {
|
export function getMethodLabel(method: string): string {
|
||||||
return (
|
return (
|
||||||
METHOD_CONFIG[method]?.label ??
|
METHOD_CONFIG[method]?.label ??
|
||||||
method
|
method.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
.replace(/-/g, ' ')
|
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMethodColor(method: string): string {
|
export function getMethodColor(method: string): string {
|
||||||
return METHOD_CONFIG[method]?.color ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
return (
|
||||||
|
METHOD_CONFIG[method]?.color ??
|
||||||
|
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EncounterMethodBadge({
|
export function EncounterMethodBadge({
|
||||||
@@ -88,7 +89,8 @@ export function EncounterMethodBadge({
|
|||||||
}) {
|
}) {
|
||||||
const config = METHOD_CONFIG[method]
|
const config = METHOD_CONFIG[method]
|
||||||
if (!config) return null
|
if (!config) return null
|
||||||
const sizeClass = size === 'xs' ? 'text-[8px] px-1 py-0' : 'text-[9px] px-1.5 py-0.5'
|
const sizeClass =
|
||||||
|
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}`}
|
||||||
|
|||||||
@@ -42,31 +42,36 @@ interface EncounterModalProps {
|
|||||||
isPending: boolean
|
isPending: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusOptions: { value: EncounterStatus; label: string; color: string }[] =
|
const statusOptions: {
|
||||||
[
|
value: EncounterStatus
|
||||||
{
|
label: string
|
||||||
value: 'caught',
|
color: string
|
||||||
label: 'Caught',
|
}[] = [
|
||||||
color:
|
{
|
||||||
'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700',
|
value: 'caught',
|
||||||
},
|
label: 'Caught',
|
||||||
{
|
color:
|
||||||
value: 'fainted',
|
'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700',
|
||||||
label: 'Fainted',
|
},
|
||||||
color:
|
{
|
||||||
'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700',
|
value: 'fainted',
|
||||||
},
|
label: 'Fainted',
|
||||||
{
|
color:
|
||||||
value: 'missed',
|
'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700',
|
||||||
label: 'Missed / Ran',
|
},
|
||||||
color:
|
{
|
||||||
'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600',
|
value: 'missed',
|
||||||
},
|
label: 'Missed / Ran',
|
||||||
]
|
color:
|
||||||
|
'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
|
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
|
||||||
|
|
||||||
function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokemon: RouteEncounterDetail[] }[] {
|
function groupByMethod(
|
||||||
|
pokemon: RouteEncounterDetail[]
|
||||||
|
): { method: string; pokemon: RouteEncounterDetail[] }[] {
|
||||||
const groups = new Map<string, RouteEncounterDetail[]>()
|
const groups = new Map<string, RouteEncounterDetail[]>()
|
||||||
for (const rp of pokemon) {
|
for (const rp of pokemon) {
|
||||||
const list = groups.get(rp.encounterMethod) ?? []
|
const list = groups.get(rp.encounterMethod) ?? []
|
||||||
@@ -84,7 +89,7 @@ function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokem
|
|||||||
|
|
||||||
function pickRandomPokemon(
|
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.filter((rp) => !dupedIds.has(rp.pokemonId))
|
||||||
@@ -109,17 +114,17 @@ export function EncounterModal({
|
|||||||
}: EncounterModalProps) {
|
}: EncounterModalProps) {
|
||||||
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
|
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
|
||||||
route.id,
|
route.id,
|
||||||
gameId,
|
gameId
|
||||||
)
|
)
|
||||||
|
|
||||||
const [selectedPokemon, setSelectedPokemon] =
|
const [selectedPokemon, setSelectedPokemon] =
|
||||||
useState<RouteEncounterDetail | null>(null)
|
useState<RouteEncounterDetail | null>(null)
|
||||||
const [status, setStatus] = useState<EncounterStatus>(
|
const [status, setStatus] = useState<EncounterStatus>(
|
||||||
existing?.status ?? 'caught',
|
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('')
|
||||||
@@ -128,27 +133,31 @@ export function EncounterModal({
|
|||||||
const isEditing = !!existing
|
const isEditing = !!existing
|
||||||
|
|
||||||
const showSuggestions = !!namingScheme && status === 'caught' && !isEditing
|
const showSuggestions = !!namingScheme && status === 'caught' && !isEditing
|
||||||
const lineagePokemonId = isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
|
const lineagePokemonId =
|
||||||
const { data: suggestions, refetch: regenerate, isFetching: loadingSuggestions } =
|
isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
|
||||||
useNameSuggestions(showSuggestions ? runId : null, lineagePokemonId)
|
const {
|
||||||
|
data: suggestions,
|
||||||
|
refetch: regenerate,
|
||||||
|
isFetching: loadingSuggestions,
|
||||||
|
} = useNameSuggestions(showSuggestions ? runId : null, lineagePokemonId)
|
||||||
|
|
||||||
// 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])
|
||||||
|
|
||||||
const filteredPokemon = routePokemon?.filter((rp) =>
|
const filteredPokemon = routePokemon?.filter((rp) =>
|
||||||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
|
rp.pokemon.name.toLowerCase().includes(search.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
const groupedPokemon = useMemo(
|
const groupedPokemon = useMemo(
|
||||||
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
|
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
|
||||||
[filteredPokemon],
|
[filteredPokemon]
|
||||||
)
|
)
|
||||||
const hasMultipleGroups = groupedPokemon.length > 1
|
const hasMultipleGroups = groupedPokemon.length > 1
|
||||||
|
|
||||||
@@ -224,13 +233,15 @@ export function EncounterModal({
|
|||||||
loadingPokemon ||
|
loadingPokemon ||
|
||||||
!routePokemon ||
|
!routePokemon ||
|
||||||
(dupedPokemonIds
|
(dupedPokemonIds
|
||||||
? routePokemon.every((rp) => dupedPokemonIds.has(rp.pokemonId))
|
? routePokemon.every((rp) =>
|
||||||
|
dupedPokemonIds.has(rp.pokemonId)
|
||||||
|
)
|
||||||
: false)
|
: false)
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (routePokemon) {
|
if (routePokemon) {
|
||||||
setSelectedPokemon(
|
setSelectedPokemon(
|
||||||
pickRandomPokemon(routePokemon, dupedPokemonIds),
|
pickRandomPokemon(routePokemon, dupedPokemonIds)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -268,12 +279,15 @@ 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 = dupedPokemonIds?.has(rp.pokemonId) ?? false
|
const isDuped =
|
||||||
|
dupedPokemonIds?.has(rp.pokemonId) ?? false
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={rp.id}
|
key={rp.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !isDuped && setSelectedPokemon(rp)}
|
onClick={() =>
|
||||||
|
!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
|
||||||
@@ -299,16 +313,24 @@ export function EncounterModal({
|
|||||||
</span>
|
</span>
|
||||||
{isDuped && (
|
{isDuped && (
|
||||||
<span className="text-[10px] text-gray-400 italic">
|
<span className="text-[10px] text-gray-400 italic">
|
||||||
{retiredPokemonIds?.has(rp.pokemonId) ? 'retired (HoF)' : 'already caught'}
|
{retiredPokemonIds?.has(rp.pokemonId)
|
||||||
|
? 'retired (HoF)'
|
||||||
|
: 'already caught'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
{!isDuped &&
|
||||||
<EncounterMethodBadge method={rp.encounterMethod} />
|
SPECIAL_METHODS.includes(
|
||||||
)}
|
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.maxLevel !== rp.minLevel &&
|
||||||
|
`–${rp.maxLevel}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -518,11 +540,7 @@ export function EncounterModal({
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{isPending
|
{isPending ? 'Saving...' : isEditing ? 'Update' : 'Log Encounter'}
|
||||||
? 'Saving...'
|
|
||||||
: isEditing
|
|
||||||
? 'Update'
|
|
||||||
: 'Log Encounter'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ interface EndRunModalProps {
|
|||||||
genlockeContext?: RunGenlockeContext | null
|
genlockeContext?: RunGenlockeContext | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EndRunModal({ onConfirm, onClose, isPending, genlockeContext }: EndRunModalProps) {
|
export function EndRunModal({
|
||||||
|
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!'
|
||||||
|
|||||||
@@ -29,32 +29,46 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
|
|||||||
|
|
||||||
const generations = useMemo(
|
const generations = useMemo(
|
||||||
() => [...new Set(games.map((g) => g.generation))].sort(),
|
() => [...new Set(games.map((g) => g.generation))].sort(),
|
||||||
[games],
|
[games]
|
||||||
)
|
)
|
||||||
|
|
||||||
const regions = useMemo(
|
const regions = useMemo(
|
||||||
() => [...new Set(games.map((g) => g.region))].sort(),
|
() => [...new Set(games.map((g) => g.region))].sort(),
|
||||||
[games],
|
[games]
|
||||||
)
|
)
|
||||||
|
|
||||||
const activeRunGameIds = useMemo(() => {
|
const activeRunGameIds = useMemo(() => {
|
||||||
if (!runs) return new Set<number>()
|
if (!runs) return new Set<number>()
|
||||||
return new Set(runs.filter((r) => r.status === 'active').map((r) => r.gameId))
|
return new Set(
|
||||||
|
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(runs.filter((r) => r.status === 'completed').map((r) => r.gameId))
|
return new Set(
|
||||||
|
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) result = result.filter((g) => !activeRunGameIds.has(g.id))
|
if (hideWithActiveRun)
|
||||||
if (hideCompleted) result = result.filter((g) => !completedRunGameIds.has(g.id))
|
result = result.filter((g) => !activeRunGameIds.has(g.id))
|
||||||
|
if (hideCompleted)
|
||||||
|
result = result.filter((g) => !completedRunGameIds.has(g.id))
|
||||||
return result
|
return result
|
||||||
}, [games, filter, regionFilter, hideWithActiveRun, hideCompleted, activeRunGameIds, completedRunGameIds])
|
}, [
|
||||||
|
games,
|
||||||
|
filter,
|
||||||
|
regionFilter,
|
||||||
|
hideWithActiveRun,
|
||||||
|
hideCompleted,
|
||||||
|
activeRunGameIds,
|
||||||
|
completedRunGameIds,
|
||||||
|
])
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const groups: Record<number, Game[]> = {}
|
const groups: Record<number, Game[]> = {}
|
||||||
@@ -77,7 +91,9 @@ 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">Gen:</span>
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">
|
||||||
|
Gen:
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFilter(null)}
|
onClick={() => setFilter(null)}
|
||||||
@@ -98,7 +114,9 @@ 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">Region:</span>
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">
|
||||||
|
Region:
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRegionFilter(null)}
|
onClick={() => setRegionFilter(null)}
|
||||||
|
|||||||
@@ -134,7 +134,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} — {data.deadliestLeg.gameName} ({data.deadliestLeg.deathCount})
|
Deadliest: Leg {data.deadliestLeg.legOrder} —{' '}
|
||||||
|
{data.deadliestLeg.gameName} ({data.deadliestLeg.deathCount})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +144,9 @@ 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) => setFilterLeg(e.target.value ? Number(e.target.value) : null)}
|
onChange={(e) =>
|
||||||
|
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>
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ function LegDot({ leg }: { leg: LineageLegEntry }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex flex-col items-center">
|
<div className="group relative flex flex-col items-center">
|
||||||
<div className={`w-4 h-4 rounded-full ${color} ring-2 ring-offset-1 ring-offset-white dark:ring-offset-gray-800 ring-gray-200 dark:ring-gray-600`} />
|
<div
|
||||||
|
className={`w-4 h-4 rounded-full ${color} ring-2 ring-offset-1 ring-offset-white dark:ring-offset-gray-800 ring-gray-200 dark:ring-gray-600`}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Tooltip */}
|
{/* Tooltip */}
|
||||||
<div className="absolute bottom-full mb-2 hidden group-hover:flex flex-col items-center z-10">
|
<div className="absolute bottom-full mb-2 hidden group-hover:flex flex-col items-center z-10">
|
||||||
@@ -36,25 +38,32 @@ 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 src={displayPokemon.spriteUrl} alt={displayPokemon.name} className="w-6 h-6" />
|
<img
|
||||||
|
src={displayPokemon.spriteUrl}
|
||||||
|
alt={displayPokemon.name}
|
||||||
|
className="w-6 h-6"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<span>{displayPokemon.name}</span>
|
<span>{displayPokemon.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{leg.catchLevel !== null && (
|
{leg.catchLevel !== null && <div>Caught Lv. {leg.catchLevel}</div>}
|
||||||
<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 && (
|
{leg.deathCause && (
|
||||||
<div className="text-red-300 italic">{leg.deathCause}</div>
|
<div className="text-red-300 italic">{leg.deathCause}</div>
|
||||||
)}
|
)}
|
||||||
<div className={`font-medium ${
|
<div
|
||||||
leg.faintLevel !== null ? 'text-red-300' :
|
className={`font-medium ${
|
||||||
leg.wasTransferred ? 'text-blue-300' :
|
leg.faintLevel !== null
|
||||||
leg.enteredHof ? 'text-yellow-300' :
|
? 'text-red-300'
|
||||||
'text-green-300'
|
: leg.wasTransferred
|
||||||
}`}>
|
? 'text-blue-300'
|
||||||
|
: leg.enteredHof
|
||||||
|
? 'text-yellow-300'
|
||||||
|
: 'text-green-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
{leg.enteredHof && leg.faintLevel === null && (
|
{leg.enteredHof && leg.faintLevel === null && (
|
||||||
@@ -185,9 +194,11 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
|
|||||||
|
|
||||||
const allLegOrders = useMemo(() => {
|
const allLegOrders = useMemo(() => {
|
||||||
if (!data) return []
|
if (!data) return []
|
||||||
return [...new Set(data.lineages.flatMap((l) => l.legs.map((leg) => leg.legOrder)))].sort(
|
return [
|
||||||
(a, b) => a - b
|
...new Set(
|
||||||
)
|
data.lineages.flatMap((l) => l.legs.map((leg) => leg.legOrder))
|
||||||
|
),
|
||||||
|
].sort((a, b) => a - b)
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const legGameNames = useMemo(() => {
|
const legGameNames = useMemo(() => {
|
||||||
@@ -230,8 +241,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' : ''} across{' '}
|
{data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''}{' '}
|
||||||
{allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
|
across {allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ interface HofTeamModalProps {
|
|||||||
isPending: boolean
|
isPending: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HofTeamModal({ alive, onSubmit, onSkip, isPending }: HofTeamModalProps) {
|
export function HofTeamModal({
|
||||||
|
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))
|
||||||
|
|||||||
@@ -7,8 +7,20 @@ interface PokemonCardProps {
|
|||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardProps) {
|
export function PokemonCard({
|
||||||
const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } = encounter
|
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
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
|
|||||||
<PokemonCard
|
<PokemonCard
|
||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
onClick={onEncounterClick ? () => onEncounterClick(enc) : undefined}
|
onClick={
|
||||||
|
onEncounterClick ? () => onEncounterClick(enc) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ interface ShinyEncounterModalProps {
|
|||||||
|
|
||||||
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
|
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
|
||||||
|
|
||||||
function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokemon: RouteEncounterDetail[] }[] {
|
function groupByMethod(
|
||||||
|
pokemon: RouteEncounterDetail[]
|
||||||
|
): { method: string; pokemon: RouteEncounterDetail[] }[] {
|
||||||
const groups = new Map<string, RouteEncounterDetail[]>()
|
const groups = new Map<string, RouteEncounterDetail[]>()
|
||||||
for (const rp of pokemon) {
|
for (const rp of pokemon) {
|
||||||
const list = groups.get(rp.encounterMethod) ?? []
|
const list = groups.get(rp.encounterMethod) ?? []
|
||||||
@@ -50,7 +52,7 @@ export function ShinyEncounterModal({
|
|||||||
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,
|
selectedRouteId,
|
||||||
gameId,
|
gameId
|
||||||
)
|
)
|
||||||
|
|
||||||
const [selectedPokemon, setSelectedPokemon] =
|
const [selectedPokemon, setSelectedPokemon] =
|
||||||
@@ -60,12 +62,12 @@ export function ShinyEncounterModal({
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
const filteredPokemon = routePokemon?.filter((rp) =>
|
const filteredPokemon = routePokemon?.filter((rp) =>
|
||||||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
|
rp.pokemon.name.toLowerCase().includes(search.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
const groupedPokemon = useMemo(
|
const groupedPokemon = useMemo(
|
||||||
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
|
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
|
||||||
[filteredPokemon],
|
[filteredPokemon]
|
||||||
)
|
)
|
||||||
const hasMultipleGroups = groupedPokemon.length > 1
|
const hasMultipleGroups = groupedPokemon.length > 1
|
||||||
|
|
||||||
@@ -90,8 +92,10 @@ export function ShinyEncounterModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only show leaf routes (no children, i.e. routes that aren't parents)
|
// Only show leaf routes (no children, i.e. routes that aren't parents)
|
||||||
const parentIds = new Set(routes.filter(r => r.parentRouteId !== null).map(r => r.parentRouteId))
|
const parentIds = new Set(
|
||||||
const leafRoutes = routes.filter(r => !parentIds.has(r.id))
|
routes.filter((r) => r.parentRouteId !== null).map((r) => r.parentRouteId)
|
||||||
|
)
|
||||||
|
const leafRoutes = routes.filter((r) => !parentIds.has(r.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
@@ -206,11 +210,14 @@ export function ShinyEncounterModal({
|
|||||||
{rp.pokemon.name}
|
{rp.pokemon.name}
|
||||||
</span>
|
</span>
|
||||||
{SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
{SPECIAL_METHODS.includes(rp.encounterMethod) && (
|
||||||
<EncounterMethodBadge method={rp.encounterMethod} />
|
<EncounterMethodBadge
|
||||||
|
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.maxLevel !== rp.minLevel &&
|
||||||
|
`–${rp.maxLevel}`}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import type { EncounterDetail, UpdateEncounterInput, CreateEncounterInput } from '../types'
|
import type {
|
||||||
|
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'
|
||||||
|
|
||||||
interface StatusChangeModalProps {
|
interface StatusChangeModalProps {
|
||||||
encounter: EncounterDetail
|
encounter: EncounterDetail
|
||||||
onUpdate: (data: {
|
onUpdate: (data: { id: number; data: UpdateEncounterInput }) => void
|
||||||
id: number
|
|
||||||
data: UpdateEncounterInput
|
|
||||||
}) => void
|
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
isPending: boolean
|
isPending: boolean
|
||||||
region?: string
|
region?: string
|
||||||
@@ -24,15 +25,24 @@ export function StatusChangeModal({
|
|||||||
region,
|
region,
|
||||||
onCreateEncounter,
|
onCreateEncounter,
|
||||||
}: StatusChangeModalProps) {
|
}: StatusChangeModalProps) {
|
||||||
const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } =
|
const {
|
||||||
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>(null)
|
const [pendingEvolutionId, setPendingEvolutionId] = useState<number | 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('')
|
||||||
@@ -40,15 +50,15 @@ export function StatusChangeModal({
|
|||||||
const activePokemonId = currentPokemon?.id ?? pokemon.id
|
const activePokemonId = currentPokemon?.id ?? pokemon.id
|
||||||
const { data: evolutions, isLoading: evolutionsLoading } = useEvolutions(
|
const { data: evolutions, isLoading: evolutionsLoading } = useEvolutions(
|
||||||
showEvolve || showShedConfirm ? activePokemonId : null,
|
showEvolve || showShedConfirm ? activePokemonId : null,
|
||||||
region,
|
region
|
||||||
)
|
)
|
||||||
const { data: forms } = useForms(isDead ? null : activePokemonId)
|
const { data: forms } = useForms(isDead ? null : activePokemonId)
|
||||||
|
|
||||||
const { normalEvolutions, shedCompanion } = useMemo(() => {
|
const { normalEvolutions, shedCompanion } = useMemo(() => {
|
||||||
if (!evolutions) return { normalEvolutions: [], shedCompanion: null }
|
if (!evolutions) return { normalEvolutions: [], shedCompanion: null }
|
||||||
return {
|
return {
|
||||||
normalEvolutions: evolutions.filter(e => e.trigger !== 'shed'),
|
normalEvolutions: evolutions.filter((e) => e.trigger !== 'shed'),
|
||||||
shedCompanion: evolutions.find(e => e.trigger === 'shed') ?? null,
|
shedCompanion: evolutions.find((e) => e.trigger === 'shed') ?? null,
|
||||||
}
|
}
|
||||||
}, [evolutions])
|
}, [evolutions])
|
||||||
|
|
||||||
@@ -187,33 +197,37 @@ export function StatusChangeModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alive pokemon: actions */}
|
{/* Alive pokemon: actions */}
|
||||||
{!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm && (
|
{!isDead &&
|
||||||
<div className="flex gap-3">
|
!showConfirm &&
|
||||||
<button
|
!showEvolve &&
|
||||||
type="button"
|
!showFormChange &&
|
||||||
onClick={() => setShowEvolve(true)}
|
!showShedConfirm && (
|
||||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
<div className="flex gap-3">
|
||||||
>
|
|
||||||
Evolve
|
|
||||||
</button>
|
|
||||||
{forms && forms.length > 0 && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowFormChange(true)}
|
onClick={() => setShowEvolve(true)}
|
||||||
className="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors"
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
Change Form
|
Evolve
|
||||||
</button>
|
</button>
|
||||||
)}
|
{forms && forms.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowConfirm(true)}
|
onClick={() => setShowFormChange(true)}
|
||||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
className="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors"
|
||||||
>
|
>
|
||||||
Mark as Dead
|
Change Form
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Mark as Dead
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Evolution selection */}
|
{/* Evolution selection */}
|
||||||
{!isDead && showEvolve && (
|
{!isDead && showEvolve && (
|
||||||
@@ -231,10 +245,14 @@ export function StatusChangeModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{evolutionsLoading && (
|
{evolutionsLoading && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">Loading evolutions...</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Loading evolutions...
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{!evolutionsLoading && normalEvolutions.length === 0 && (
|
{!evolutionsLoading && normalEvolutions.length === 0 && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions available</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No evolutions available
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{!evolutionsLoading && normalEvolutions.length > 0 && (
|
{!evolutionsLoading && normalEvolutions.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -247,7 +265,11 @@ 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-blue-50 dark:hover:bg-blue-900/20 hover:border-blue-300 dark:hover:border-blue-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-blue-50 dark:hover:bg-blue-900/20 hover:border-blue-300 dark:hover:border-blue-600 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{evo.toPokemon.spriteUrl ? (
|
{evo.toPokemon.spriteUrl ? (
|
||||||
<img src={evo.toPokemon.spriteUrl} alt={evo.toPokemon.name} className="w-10 h-10" />
|
<img
|
||||||
|
src={evo.toPokemon.spriteUrl}
|
||||||
|
alt={evo.toPokemon.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">
|
||||||
{evo.toPokemon.name[0].toUpperCase()}
|
{evo.toPokemon.name[0].toUpperCase()}
|
||||||
@@ -302,8 +324,12 @@ export function StatusChangeModal({
|
|||||||
</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 add{' '}
|
{displayPokemon.name} shed its shell! Would you also like to
|
||||||
<span className="font-semibold">{shedCompanion.toPokemon.name}</span>?
|
add{' '}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{shedCompanion.toPokemon.name}
|
||||||
|
</span>
|
||||||
|
?
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,7 +366,9 @@ 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 ? 'Saving...' : `Add ${shedCompanion.toPokemon.name}`}
|
{isPending
|
||||||
|
? 'Saving...'
|
||||||
|
: `Add ${shedCompanion.toPokemon.name}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -372,7 +400,11 @@ 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 src={form.spriteUrl} alt={form.name} className="w-10 h-10" />
|
<img
|
||||||
|
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()}
|
||||||
@@ -465,7 +497,12 @@ export function StatusChangeModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer for dead/no-confirm/no-evolve views */}
|
{/* Footer for dead/no-confirm/no-evolve views */}
|
||||||
{(isDead || (!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm)) && (
|
{(isDead ||
|
||||||
|
(!isDead &&
|
||||||
|
!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"
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ interface StepIndicatorProps {
|
|||||||
steps?: string[]
|
steps?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StepIndicator({ currentStep, onStepClick, steps = DEFAULT_STEPS }: StepIndicatorProps) {
|
export function StepIndicator({
|
||||||
|
currentStep,
|
||||||
|
onStepClick,
|
||||||
|
steps = DEFAULT_STEPS,
|
||||||
|
}: StepIndicatorProps) {
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Progress" className="mb-8">
|
<nav aria-label="Progress" className="mb-8">
|
||||||
<ol className="flex items-center">
|
<ol className="flex items-center">
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ interface TransferModalProps {
|
|||||||
isPending: boolean
|
isPending: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: TransferModalProps) {
|
export function TransferModal({
|
||||||
|
hofTeam,
|
||||||
|
onSubmit,
|
||||||
|
onSkip,
|
||||||
|
isPending,
|
||||||
|
}: TransferModalProps) {
|
||||||
const [selected, setSelected] = useState<Set<number>>(
|
const [selected, setSelected] = useState<Set<number>>(
|
||||||
() => new Set(hofTeam.map((e) => e.id)),
|
() => new Set(hofTeam.map((e) => e.id))
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggle = (id: number) => {
|
const toggle = (id: number) => {
|
||||||
@@ -34,7 +39,8 @@ export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: Transfer
|
|||||||
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 level 1 encounters in the next leg.
|
Selected Pokemon will be bred down to their base form and appear as
|
||||||
|
level 1 encounters in the next leg.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ 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
|
<img src={`/types/${type}.png`} alt={type} className={`${height} w-auto`} />
|
||||||
src={`/types/${type}.png`}
|
|
||||||
alt={type}
|
|
||||||
className={`${height} w-auto`}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ 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 key={col.header} className={`px-4 py-3 ${col.className ?? ''}`}>
|
<td
|
||||||
|
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>
|
||||||
))}
|
))}
|
||||||
@@ -111,7 +114,9 @@ export function AdminTable<T>({
|
|||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={col.header}
|
key={col.header}
|
||||||
onClick={sortable ? () => handleSort(col.header) : undefined}
|
onClick={
|
||||||
|
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">
|
||||||
@@ -132,7 +137,11 @@ export function AdminTable<T>({
|
|||||||
<tr
|
<tr
|
||||||
key={keyFn(row)}
|
key={keyFn(row)}
|
||||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
className={onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''}
|
className={
|
||||||
|
onRowClick
|
||||||
|
? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td
|
<td
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
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 { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
|
import type {
|
||||||
|
CreateBossBattleInput,
|
||||||
|
UpdateBossBattleInput,
|
||||||
|
} from '../../types/admin'
|
||||||
|
|
||||||
interface BossBattleFormModalProps {
|
interface BossBattleFormModalProps {
|
||||||
boss?: BossBattle
|
boss?: BossBattle
|
||||||
@@ -17,9 +20,24 @@ interface BossBattleFormModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const POKEMON_TYPES = [
|
const POKEMON_TYPES = [
|
||||||
'normal', 'fire', 'water', 'electric', 'grass', 'ice',
|
'normal',
|
||||||
'fighting', 'poison', 'ground', 'flying', 'psychic', 'bug',
|
'fire',
|
||||||
'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy',
|
'water',
|
||||||
|
'electric',
|
||||||
|
'grass',
|
||||||
|
'ice',
|
||||||
|
'fighting',
|
||||||
|
'poison',
|
||||||
|
'ground',
|
||||||
|
'flying',
|
||||||
|
'psychic',
|
||||||
|
'bug',
|
||||||
|
'rock',
|
||||||
|
'ghost',
|
||||||
|
'dragon',
|
||||||
|
'dark',
|
||||||
|
'steel',
|
||||||
|
'fairy',
|
||||||
]
|
]
|
||||||
|
|
||||||
const BOSS_TYPES = [
|
const BOSS_TYPES = [
|
||||||
@@ -52,7 +70,9 @@ 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(String(boss?.afterRouteId ?? ''))
|
const [afterRouteId, setAfterRouteId] = useState(
|
||||||
|
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 ?? '')
|
||||||
@@ -87,15 +107,17 @@ export function BossBattleFormModal({
|
|||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
isDeleting={isDeleting}
|
isDeleting={isDeleting}
|
||||||
headerExtra={onEditTeam ? (
|
headerExtra={
|
||||||
<button
|
onEditTeam ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={onEditTeam}
|
type="button"
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
onClick={onEditTeam}
|
||||||
>
|
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
Edit Team ({boss?.pokemon.length ?? 0})
|
>
|
||||||
</button>
|
Edit Team ({boss?.pokemon.length ?? 0})
|
||||||
) : undefined}
|
</button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -190,7 +212,9 @@ export function BossBattleFormModal({
|
|||||||
</div>
|
</div>
|
||||||
{games && games.length > 1 && (
|
{games && games.length > 1 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Game (version exclusive)</label>
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Game (version exclusive)
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={gameId}
|
value={gameId}
|
||||||
onChange={(e) => setGameId(e.target.value)}
|
onChange={(e) => setGameId(e.target.value)}
|
||||||
@@ -208,7 +232,9 @@ export function BossBattleFormModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Position After Route</label>
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Position After Route
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={afterRouteId}
|
value={afterRouteId}
|
||||||
onChange={(e) => setAfterRouteId(e.target.value)}
|
onChange={(e) => setAfterRouteId(e.target.value)}
|
||||||
@@ -235,7 +261,9 @@ export function BossBattleFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Badge Image URL</label>
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Badge Image URL
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={badgeImageUrl}
|
value={badgeImageUrl}
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ function groupByVariant(boss: BossBattle): Variant[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (map.size === 0) {
|
if (map.size === 0) {
|
||||||
return [{ label: null, pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }] }]
|
return [
|
||||||
|
{
|
||||||
|
label: null,
|
||||||
|
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const variants: Variant[] = []
|
const variants: Variant[] = []
|
||||||
@@ -48,43 +53,71 @@ function groupByVariant(boss: BossBattle): Variant[] {
|
|||||||
map.delete(null)
|
map.delete(null)
|
||||||
}
|
}
|
||||||
// Then alphabetical
|
// Then alphabetical
|
||||||
const remaining = [...map.entries()].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? ''))
|
const remaining = [...map.entries()].sort((a, b) =>
|
||||||
|
(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({ boss, onSave, onClose, isSaving }: BossTeamEditorProps) {
|
export function BossTeamEditor({
|
||||||
const [variants, setVariants] = useState<Variant[]>(() => groupByVariant(boss))
|
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 = (tabIndex: number, updater: (v: Variant) => Variant) => {
|
const updateVariant = (
|
||||||
|
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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const addSlot = () => {
|
const addSlot = () => {
|
||||||
updateVariant(activeTab, (v) => ({
|
updateVariant(activeTab, (v) => ({
|
||||||
...v,
|
...v,
|
||||||
pokemon: [...v.pokemon, { pokemonId: null, pokemonName: '', level: '', order: v.pokemon.length + 1 }],
|
pokemon: [
|
||||||
|
...v.pokemon,
|
||||||
|
{
|
||||||
|
pokemonId: null,
|
||||||
|
pokemonName: '',
|
||||||
|
level: '',
|
||||||
|
order: v.pokemon.length + 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeSlot = (index: number) => {
|
const removeSlot = (index: number) => {
|
||||||
updateVariant(activeTab, (v) => ({
|
updateVariant(activeTab, (v) => ({
|
||||||
...v,
|
...v,
|
||||||
pokemon: v.pokemon.filter((_, i) => i !== index).map((item, i) => ({ ...item, order: i + 1 })),
|
pokemon: v.pokemon
|
||||||
|
.filter((_, i) => i !== index)
|
||||||
|
.map((item, i) => ({ ...item, order: i + 1 })),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSlot = (index: number, field: string, value: number | string | null) => {
|
const updateSlot = (
|
||||||
|
index: number,
|
||||||
|
field: string,
|
||||||
|
value: number | string | null
|
||||||
|
) => {
|
||||||
updateVariant(activeTab, (v) => ({
|
updateVariant(activeTab, (v) => ({
|
||||||
...v,
|
...v,
|
||||||
pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
|
pokemon: v.pokemon.map((item, i) =>
|
||||||
|
i === index ? { ...item, [field]: value } : item
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +125,13 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
const name = newVariantName.trim()
|
const name = newVariantName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
if (variants.some((v) => v.label === name)) return
|
if (variants.some((v) => v.label === name)) return
|
||||||
setVariants((prev) => [...prev, { label: name, pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }] }])
|
setVariants((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
label: name,
|
||||||
|
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
|
||||||
|
},
|
||||||
|
])
|
||||||
setActiveTab(variants.length)
|
setActiveTab(variants.length)
|
||||||
setNewVariantName('')
|
setNewVariantName('')
|
||||||
setShowAddVariant(false)
|
setShowAddVariant(false)
|
||||||
@@ -109,8 +148,11 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const allPokemon: BossPokemonInput[] = []
|
const allPokemon: BossPokemonInput[] = []
|
||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
const conditionLabel = variants.length === 1 && variant.label === null ? null : variant.label
|
const conditionLabel =
|
||||||
const validPokemon = variant.pokemon.filter((t) => t.pokemonId != null && t.level)
|
variants.length === 1 && variant.label === null ? null : variant.label
|
||||||
|
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++) {
|
||||||
allPokemon.push({
|
allPokemon.push({
|
||||||
pokemonId: validPokemon[i].pokemonId!,
|
pokemonId: validPokemon[i].pokemonId!,
|
||||||
@@ -147,7 +189,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
{v.label ?? 'Default'}
|
{v.label ?? 'Default'}
|
||||||
{v.label !== null && (
|
{v.label !== null && (
|
||||||
<span
|
<span
|
||||||
onClick={(e) => { e.stopPropagation(); removeVariant(i) }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
removeVariant(i)
|
||||||
|
}}
|
||||||
className="ml-1.5 text-gray-400 hover:text-red-500 cursor-pointer"
|
className="ml-1.5 text-gray-400 hover:text-red-500 cursor-pointer"
|
||||||
title="Remove variant"
|
title="Remove variant"
|
||||||
>
|
>
|
||||||
@@ -171,13 +216,31 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
type="text"
|
type="text"
|
||||||
value={newVariantName}
|
value={newVariantName}
|
||||||
onChange={(e) => setNewVariantName(e.target.value)}
|
onChange={(e) => setNewVariantName(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addVariant() } if (e.key === 'Escape') setShowAddVariant(false) }}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
addVariant()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') setShowAddVariant(false)
|
||||||
|
}}
|
||||||
placeholder="Variant name..."
|
placeholder="Variant name..."
|
||||||
className="px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 w-40"
|
className="px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 w-40"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={addVariant} className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400">Add</button>
|
<button
|
||||||
<button type="button" onClick={() => setShowAddVariant(false)} className="px-1 py-1 text-sm text-gray-400">✕</button>
|
type="button"
|
||||||
|
onClick={addVariant}
|
||||||
|
className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAddVariant(false)}
|
||||||
|
className="px-1 py-1 text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -185,7 +248,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
<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 key={`${activeTab}-${index}`} className="flex items-end gap-2">
|
<div
|
||||||
|
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}`}
|
||||||
@@ -195,7 +261,9 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-20">
|
<div className="w-20">
|
||||||
<label className="block text-sm font-medium mb-1">Level</label>
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Level
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
|||||||
@@ -12,7 +12,14 @@ interface BulkImportModalProps {
|
|||||||
updatedLabel?: string
|
updatedLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BulkImportModal({ title, example, onSubmit, onClose, createdLabel = 'Created', updatedLabel = 'Updated' }: BulkImportModalProps) {
|
export function BulkImportModal({
|
||||||
|
title,
|
||||||
|
example,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
createdLabel = 'Created',
|
||||||
|
updatedLabel = 'Updated',
|
||||||
|
}: BulkImportModalProps) {
|
||||||
const [json, setJson] = useState('')
|
const [json, setJson] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [result, setResult] = useState<BulkImportResult | null>(null)
|
const [result, setResult] = useState<BulkImportResult | null>(null)
|
||||||
@@ -73,7 +80,10 @@ export function BulkImportModal({ title, example, onSubmit, onClose, createdLabe
|
|||||||
|
|
||||||
{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>{createdLabel}: {result.created}, {updatedLabel}: {result.updated}</p>
|
<p>
|
||||||
|
{createdLabel}: {result.created}, {updatedLabel}:{' '}
|
||||||
|
{result.updated}
|
||||||
|
</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">
|
||||||
{result.errors.map((err, i) => (
|
{result.errors.map((err, i) => (
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
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 { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
|
import type {
|
||||||
|
EvolutionAdmin,
|
||||||
|
CreateEvolutionInput,
|
||||||
|
UpdateEvolutionInput,
|
||||||
|
} from '../../types'
|
||||||
|
|
||||||
interface EvolutionFormModalProps {
|
interface EvolutionFormModalProps {
|
||||||
evolution?: EvolutionAdmin
|
evolution?: EvolutionAdmin
|
||||||
@@ -23,10 +27,10 @@ export function EvolutionFormModal({
|
|||||||
isDeleting,
|
isDeleting,
|
||||||
}: EvolutionFormModalProps) {
|
}: EvolutionFormModalProps) {
|
||||||
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 ?? ''))
|
||||||
|
|||||||
@@ -55,7 +55,11 @@ 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 ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
|
{isDeleting
|
||||||
|
? 'Deleting...'
|
||||||
|
: confirmingDelete
|
||||||
|
? 'Confirm?'
|
||||||
|
: 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|||||||
@@ -20,13 +20,23 @@ function slugify(name: string) {
|
|||||||
.replace(/^-|-$/g, '')
|
.replace(/^-|-$/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameFormModal({ game, onSubmit, onClose, isSubmitting, onDelete, isDeleting, detailUrl }: GameFormModalProps) {
|
export function GameFormModal({
|
||||||
|
game,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
isSubmitting,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
detailUrl,
|
||||||
|
}: GameFormModalProps) {
|
||||||
const [name, setName] = useState(game?.name ?? '')
|
const [name, setName] = useState(game?.name ?? '')
|
||||||
const [slug, setSlug] = useState(game?.slug ?? '')
|
const [slug, setSlug] = useState(game?.slug ?? '')
|
||||||
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(game?.releaseYear ? String(game.releaseYear) : '')
|
const [releaseYear, setReleaseYear] = useState(
|
||||||
|
game?.releaseYear ? String(game.releaseYear) : ''
|
||||||
|
)
|
||||||
const [autoSlug, setAutoSlug] = useState(!game)
|
const [autoSlug, setAutoSlug] = useState(!game)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,14 +63,16 @@ export function GameFormModal({ game, onSubmit, onClose, isSubmitting, onDelete,
|
|||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
isDeleting={isDeleting}
|
isDeleting={isDeleting}
|
||||||
headerExtra={detailUrl ? (
|
headerExtra={
|
||||||
<Link
|
detailUrl ? (
|
||||||
to={detailUrl}
|
<Link
|
||||||
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
|
>
|
||||||
</Link>
|
View Routes & Bosses
|
||||||
) : undefined}
|
</Link>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Name</label>
|
<label className="block text-sm font-medium mb-1">Name</label>
|
||||||
|
|||||||
@@ -2,8 +2,17 @@ import { type FormEvent, useState, useEffect } from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { EvolutionFormModal } from './EvolutionFormModal'
|
import { EvolutionFormModal } from './EvolutionFormModal'
|
||||||
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput, EvolutionAdmin, UpdateEvolutionInput } from '../../types'
|
import type {
|
||||||
import { usePokemonEncounterLocations, usePokemonEvolutionChain } from '../../hooks/usePokemon'
|
Pokemon,
|
||||||
|
CreatePokemonInput,
|
||||||
|
UpdatePokemonInput,
|
||||||
|
EvolutionAdmin,
|
||||||
|
UpdateEvolutionInput,
|
||||||
|
} from '../../types'
|
||||||
|
import {
|
||||||
|
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'
|
||||||
|
|
||||||
@@ -18,20 +27,32 @@ interface PokemonFormModalProps {
|
|||||||
|
|
||||||
type Tab = 'details' | 'evolutions' | 'encounters'
|
type Tab = 'details' | 'evolutions' | 'encounters'
|
||||||
|
|
||||||
export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onDelete, isDeleting }: PokemonFormModalProps) {
|
export function PokemonFormModal({
|
||||||
|
pokemon,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
isSubmitting,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
}: PokemonFormModalProps) {
|
||||||
const [pokeapiId, setPokeapiId] = useState(String(pokemon?.pokeapiId ?? ''))
|
const [pokeapiId, setPokeapiId] = useState(String(pokemon?.pokeapiId ?? ''))
|
||||||
const [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? ''))
|
const [nationalDex, setNationalDex] = useState(
|
||||||
|
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] = useState<EvolutionAdmin | null>(null)
|
const [editingEvolution, setEditingEvolution] =
|
||||||
|
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 } = usePokemonEncounterLocations(pokemonId)
|
const { data: encounterLocations, isLoading: encountersLoading } =
|
||||||
const { data: evolutionChain, isLoading: evolutionsLoading } = usePokemonEvolutionChain(pokemonId)
|
usePokemonEncounterLocations(pokemonId)
|
||||||
|
const { data: evolutionChain, isLoading: evolutionsLoading } =
|
||||||
|
usePokemonEvolutionChain(pokemonId)
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const updateEvolution = useUpdateEvolution()
|
const updateEvolution = useUpdateEvolution()
|
||||||
@@ -42,7 +63,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
|||||||
}, [onDelete])
|
}, [onDelete])
|
||||||
|
|
||||||
const invalidateChain = () => {
|
const invalidateChain = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['pokemon', pokemonId, 'evolution-chain'] })
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['pokemon', pokemonId, 'evolution-chain'],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: FormEvent) => {
|
const handleSubmit = (e: FormEvent) => {
|
||||||
@@ -80,7 +103,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
|||||||
<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">{pokemon ? 'Edit Pokemon' : 'Add Pokemon'}</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
|
{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) => (
|
||||||
@@ -99,10 +124,15 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
|||||||
|
|
||||||
{/* Details tab (form) */}
|
{/* Details tab (form) */}
|
||||||
{activeTab === 'details' && (
|
{activeTab === 'details' && (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
|
<form
|
||||||
|
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">PokeAPI ID</label>
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
PokeAPI ID
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
@@ -113,7 +143,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">National Dex #</label>
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
National Dex #
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
@@ -134,7 +166,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Types (comma-separated)</label>
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Types (comma-separated)
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@@ -145,7 +179,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Sprite URL</label>
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Sprite URL
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={spriteUrl}
|
value={spriteUrl}
|
||||||
@@ -170,7 +206,11 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
|||||||
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 ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
|
{isDeleting
|
||||||
|
? 'Deleting...'
|
||||||
|
: confirmingDelete
|
||||||
|
? 'Confirm?'
|
||||||
|
: 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
@@ -197,28 +237,35 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
|||||||
<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">Loading...</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
)}
|
Loading...
|
||||||
{!evolutionsLoading && (!evolutionChain || evolutionChain.length === 0) && (
|
</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions</p>
|
|
||||||
)}
|
|
||||||
{!evolutionsLoading && evolutionChain && evolutionChain.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{evolutionChain.map((evo) => (
|
|
||||||
<button
|
|
||||||
key={evo.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditingEvolution(evo)}
|
|
||||||
className="w-full text-left text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-2 py-1.5 -mx-2 transition-colors"
|
|
||||||
>
|
|
||||||
{evo.fromPokemon.name} → {evo.toPokemon.name}{' '}
|
|
||||||
<span className="text-gray-400 dark:text-gray-500">
|
|
||||||
({formatEvolutionMethod(evo)})
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{!evolutionsLoading &&
|
||||||
|
(!evolutionChain || evolutionChain.length === 0) && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No evolutions
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!evolutionsLoading &&
|
||||||
|
evolutionChain &&
|
||||||
|
evolutionChain.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{evolutionChain.map((evo) => (
|
||||||
|
<button
|
||||||
|
key={evo.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingEvolution(evo)}
|
||||||
|
className="w-full text-left text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-2 py-1.5 -mx-2 transition-colors"
|
||||||
|
>
|
||||||
|
{evo.fromPokemon.name} → {evo.toPokemon.name}{' '}
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">
|
||||||
|
({formatEvolutionMethod(evo)})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
|
||||||
<button
|
<button
|
||||||
@@ -237,37 +284,48 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
|||||||
<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">Loading...</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Loading...
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{!encountersLoading && (!encounterLocations || encounterLocations.length === 0) && (
|
{!encountersLoading &&
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">No encounters</p>
|
(!encounterLocations || encounterLocations.length === 0) && (
|
||||||
)}
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{!encountersLoading && encounterLocations && encounterLocations.length > 0 && (
|
No encounters
|
||||||
<div className="space-y-3">
|
</p>
|
||||||
{encounterLocations.map((game) => (
|
)}
|
||||||
<div key={game.gameId}>
|
{!encountersLoading &&
|
||||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
encounterLocations &&
|
||||||
{game.gameName}
|
encounterLocations.length > 0 && (
|
||||||
</div>
|
<div className="space-y-3">
|
||||||
<div className="space-y-0.5 pl-2">
|
{encounterLocations.map((game) => (
|
||||||
{game.encounters.map((enc, i) => (
|
<div key={game.gameId}>
|
||||||
<div key={i} className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
<Link
|
{game.gameName}
|
||||||
to={`/admin/games/${game.gameId}/routes/${enc.routeId}`}
|
</div>
|
||||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
<div className="space-y-0.5 pl-2">
|
||||||
|
{game.encounters.map((enc, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{enc.routeName}
|
<Link
|
||||||
</Link>
|
to={`/admin/games/${game.gameId}/routes/${enc.routeId}`}
|
||||||
<span className="text-gray-400 dark:text-gray-500">
|
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
— {enc.encounterMethod}, Lv. {enc.minLevel}–{enc.maxLevel}
|
>
|
||||||
</span>
|
{enc.routeName}
|
||||||
</div>
|
</Link>
|
||||||
))}
|
<span className="text-gray-400 dark:text-gray-500">
|
||||||
|
— {enc.encounterMethod}, Lv. {enc.minLevel}–
|
||||||
|
{enc.maxLevel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
|
||||||
<button
|
<button
|
||||||
@@ -294,7 +352,7 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
|||||||
setEditingEvolution(null)
|
setEditingEvolution(null)
|
||||||
invalidateChain()
|
invalidateChain()
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClose={() => setEditingEvolution(null)}
|
onClose={() => setEditingEvolution(null)}
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
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 { METHOD_ORDER, METHOD_CONFIG, getMethodLabel } from '../EncounterMethodBadge'
|
import {
|
||||||
import type { RouteEncounterDetail, CreateRouteEncounterInput, UpdateRouteEncounterInput } from '../../types'
|
METHOD_ORDER,
|
||||||
|
METHOD_CONFIG,
|
||||||
|
getMethodLabel,
|
||||||
|
} from '../EncounterMethodBadge'
|
||||||
|
import type {
|
||||||
|
RouteEncounterDetail,
|
||||||
|
CreateRouteEncounterInput,
|
||||||
|
UpdateRouteEncounterInput,
|
||||||
|
} from '../../types'
|
||||||
|
|
||||||
interface RouteEncounterFormModalProps {
|
interface RouteEncounterFormModalProps {
|
||||||
encounter?: RouteEncounterDetail
|
encounter?: RouteEncounterDetail
|
||||||
onSubmit: (data: CreateRouteEncounterInput | UpdateRouteEncounterInput) => void
|
onSubmit: (
|
||||||
|
data: CreateRouteEncounterInput | UpdateRouteEncounterInput
|
||||||
|
) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
isSubmitting?: boolean
|
isSubmitting?: boolean
|
||||||
onDelete?: () => void
|
onDelete?: () => void
|
||||||
@@ -25,11 +35,18 @@ export function RouteEncounterFormModal({
|
|||||||
|
|
||||||
const initialMethod = encounter?.encounterMethod ?? ''
|
const initialMethod = encounter?.encounterMethod ?? ''
|
||||||
const isKnownMethod = METHOD_ORDER.includes(initialMethod)
|
const isKnownMethod = METHOD_ORDER.includes(initialMethod)
|
||||||
const [selectedMethod, setSelectedMethod] = useState(isKnownMethod ? initialMethod : initialMethod ? 'other' : '')
|
const [selectedMethod, setSelectedMethod] = useState(
|
||||||
const [customMethod, setCustomMethod] = useState(isKnownMethod ? '' : initialMethod)
|
isKnownMethod ? initialMethod : initialMethod ? 'other' : ''
|
||||||
const encounterMethod = selectedMethod === 'other' ? customMethod : selectedMethod
|
)
|
||||||
|
const [customMethod, setCustomMethod] = useState(
|
||||||
|
isKnownMethod ? '' : initialMethod
|
||||||
|
)
|
||||||
|
const encounterMethod =
|
||||||
|
selectedMethod === 'other' ? customMethod : selectedMethod
|
||||||
|
|
||||||
const [encounterRate, setEncounterRate] = useState(String(encounter?.encounterRate ?? ''))
|
const [encounterRate, setEncounterRate] = useState(
|
||||||
|
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 ?? ''))
|
||||||
|
|
||||||
@@ -70,7 +87,9 @@ export function RouteEncounterFormModal({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Encounter Method</label>
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Encounter Method
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
value={selectedMethod}
|
value={selectedMethod}
|
||||||
@@ -107,7 +126,9 @@ export function RouteEncounterFormModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Encounter Rate (%)</label>
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Encounter Rate (%)
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -14,7 +14,16 @@ interface RouteFormModalProps {
|
|||||||
detailUrl?: string
|
detailUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitting, onDelete, isDeleting, detailUrl }: RouteFormModalProps) {
|
export function RouteFormModal({
|
||||||
|
route,
|
||||||
|
nextOrder,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
isSubmitting,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
detailUrl,
|
||||||
|
}: RouteFormModalProps) {
|
||||||
const [name, setName] = useState(route?.name ?? '')
|
const [name, setName] = useState(route?.name ?? '')
|
||||||
const [order, setOrder] = useState(String(route?.order ?? nextOrder ?? 0))
|
const [order, setOrder] = useState(String(route?.order ?? nextOrder ?? 0))
|
||||||
const [pinwheelZone, setPinwheelZone] = useState(
|
const [pinwheelZone, setPinwheelZone] = useState(
|
||||||
@@ -38,14 +47,16 @@ export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitti
|
|||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
isDeleting={isDeleting}
|
isDeleting={isDeleting}
|
||||||
headerExtra={detailUrl ? (
|
headerExtra={
|
||||||
<Link
|
detailUrl ? (
|
||||||
to={detailUrl}
|
<Link
|
||||||
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
|
>
|
||||||
</Link>
|
View Encounters
|
||||||
) : undefined}
|
</Link>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Name</label>
|
<label className="block text-sm font-medium mb-1">Name</label>
|
||||||
@@ -79,7 +90,8 @@ export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitti
|
|||||||
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 active
|
Routes in the same zone share an encounter when the Pinwheel Clause is
|
||||||
|
active
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ import type {
|
|||||||
|
|
||||||
// --- Queries ---
|
// --- Queries ---
|
||||||
|
|
||||||
export function usePokemonList(search?: string, limit = 50, offset = 0, type?: string) {
|
export function usePokemonList(
|
||||||
|
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),
|
||||||
@@ -87,8 +92,13 @@ 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: ({ routeId, data }: { routeId: number; data: UpdateRouteInput }) =>
|
mutationFn: ({
|
||||||
adminApi.updateRoute(gameId, routeId, data),
|
routeId,
|
||||||
|
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'] })
|
||||||
@@ -114,7 +124,8 @@ 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[]) => adminApi.reorderRoutes(gameId, routes),
|
mutationFn: (routes: RouteReorderItem[]) =>
|
||||||
|
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'] })
|
||||||
@@ -166,11 +177,20 @@ export function useDeletePokemon() {
|
|||||||
export function useBulkImportPokemon() {
|
export function useBulkImportPokemon() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
|
mutationFn: (
|
||||||
adminApi.bulkImportPokemon(items),
|
items: Array<{
|
||||||
|
pokeapiId: number
|
||||||
|
nationalDex: number
|
||||||
|
name: string
|
||||||
|
types: string[]
|
||||||
|
spriteUrl?: string | null
|
||||||
|
}>
|
||||||
|
) => adminApi.bulkImportPokemon(items),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
qc.invalidateQueries({ queryKey: ['pokemon'] })
|
qc.invalidateQueries({ queryKey: ['pokemon'] })
|
||||||
toast.success(`Import complete: ${result.created} created, ${result.updated} updated`)
|
toast.success(
|
||||||
|
`Import complete: ${result.created} created, ${result.updated} updated`
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
||||||
})
|
})
|
||||||
@@ -182,7 +202,9 @@ 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(`Import complete: ${result.created} created, ${result.updated} updated`)
|
toast.success(
|
||||||
|
`Import complete: ${result.created} created, ${result.updated} updated`
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
||||||
})
|
})
|
||||||
@@ -195,7 +217,9 @@ 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(`Import complete: ${result.created} routes, ${result.updated} encounters`)
|
toast.success(
|
||||||
|
`Import complete: ${result.created} routes, ${result.updated} encounters`
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
||||||
})
|
})
|
||||||
@@ -215,7 +239,12 @@ export function useBulkImportBosses(gameId: number) {
|
|||||||
|
|
||||||
// --- Evolution Queries & Mutations ---
|
// --- Evolution Queries & Mutations ---
|
||||||
|
|
||||||
export function useEvolutionList(search?: string, limit = 50, offset = 0, trigger?: string) {
|
export function useEvolutionList(
|
||||||
|
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),
|
||||||
@@ -277,8 +306,13 @@ 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: ({ encounterId, data }: { encounterId: number; data: UpdateRouteEncounterInput }) =>
|
mutationFn: ({
|
||||||
adminApi.updateRouteEncounter(routeId, encounterId, data),
|
encounterId,
|
||||||
|
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')
|
||||||
@@ -305,32 +339,41 @@ 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) => adminApi.createBossBattle(gameId, data),
|
mutationFn: (data: CreateBossBattleInput) =>
|
||||||
|
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) => toast.error(`Failed to create boss battle: ${err.message}`),
|
onError: (err) =>
|
||||||
|
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: ({ bossId, data }: { bossId: number; data: UpdateBossBattleInput }) =>
|
mutationFn: ({
|
||||||
adminApi.updateBossBattle(gameId, bossId, data),
|
bossId,
|
||||||
|
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) => toast.error(`Failed to update boss battle: ${err.message}`),
|
onError: (err) =>
|
||||||
|
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[]) => adminApi.reorderBosses(gameId, bosses),
|
mutationFn: (bosses: BossReorderItem[]) =>
|
||||||
|
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')
|
||||||
@@ -347,14 +390,16 @@ 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) => toast.error(`Failed to delete boss battle: ${err.message}`),
|
onError: (err) =>
|
||||||
|
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[]) => adminApi.setBossTeam(gameId, bossId, team),
|
mutationFn: (team: BossPokemonInput[]) =>
|
||||||
|
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')
|
||||||
@@ -393,7 +438,8 @@ 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) => adminApi.addGenlockeLeg(genlockeId, data),
|
mutationFn: (data: AddGenlockeLegInput) =>
|
||||||
|
adminApi.addGenlockeLeg(genlockeId, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['genlockes'] })
|
qc.invalidateQueries({ queryKey: ['genlockes'] })
|
||||||
qc.invalidateQueries({ queryKey: ['genlockes', genlockeId] })
|
qc.invalidateQueries({ queryKey: ['genlockes', genlockeId] })
|
||||||
@@ -406,7 +452,8 @@ 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) => adminApi.deleteGenlockeLeg(genlockeId, legId),
|
mutationFn: (legId: number) =>
|
||||||
|
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,6 +1,11 @@
|
|||||||
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 { getGameBosses, getBossResults, createBossResult, deleteBossResult } from '../api/bosses'
|
import {
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -22,13 +22,8 @@ export function useCreateEncounter(runId: number) {
|
|||||||
export function useUpdateEncounter(runId: number) {
|
export function useUpdateEncounter(runId: number) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({ id, data }: { id: number; data: UpdateEncounterInput }) =>
|
||||||
id,
|
updateEncounter(id, data),
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
id: number
|
|
||||||
data: UpdateEncounterInput
|
|
||||||
}) => updateEncounter(id, data),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['runs', runId] })
|
queryClient.invalidateQueries({ queryKey: ['runs', runId] })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { advanceLeg, createGenlocke, getGamesByRegion, getGenlockes, getGenlocke, getGenlockeGraveyard, getGenlockeLineages, getLegSurvivors } from '../api/genlockes'
|
import {
|
||||||
|
advanceLeg,
|
||||||
|
createGenlocke,
|
||||||
|
getGamesByRegion,
|
||||||
|
getGenlockes,
|
||||||
|
getGenlocke,
|
||||||
|
getGenlockeGraveyard,
|
||||||
|
getGenlockeLineages,
|
||||||
|
getLegSurvivors,
|
||||||
|
} from '../api/genlockes'
|
||||||
import type { CreateGenlockeInput } from '../types/game'
|
import type { CreateGenlockeInput } from '../types/game'
|
||||||
|
|
||||||
export function useGenlockes() {
|
export function useGenlockes() {
|
||||||
@@ -48,7 +57,11 @@ export function useCreateGenlocke() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLegSurvivors(genlockeId: number, legOrder: number, enabled: boolean) {
|
export function useLegSurvivors(
|
||||||
|
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),
|
||||||
@@ -59,8 +72,20 @@ export function useLegSurvivors(genlockeId: number, legOrder: number, enabled: b
|
|||||||
export function useAdvanceLeg() {
|
export function useAdvanceLeg() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ genlockeId, legOrder, transferEncounterIds }: { genlockeId: number; legOrder: number; transferEncounterIds?: number[] }) =>
|
mutationFn: ({
|
||||||
advanceLeg(genlockeId, legOrder, transferEncounterIds ? { transferEncounterIds } : undefined),
|
genlockeId,
|
||||||
|
legOrder,
|
||||||
|
transferEncounterIds,
|
||||||
|
}: {
|
||||||
|
genlockeId: number
|
||||||
|
legOrder: number
|
||||||
|
transferEncounterIds?: number[]
|
||||||
|
}) =>
|
||||||
|
advanceLeg(
|
||||||
|
genlockeId,
|
||||||
|
legOrder,
|
||||||
|
transferEncounterIds ? { transferEncounterIds } : undefined
|
||||||
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['runs'] })
|
queryClient.invalidateQueries({ queryKey: ['runs'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['genlockes'] })
|
queryClient.invalidateQueries({ queryKey: ['genlockes'] })
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getPokemon, fetchPokemonFamilies, fetchPokemonEncounterLocations, fetchPokemonEvolutionChain } from '../api/pokemon'
|
import {
|
||||||
|
getPokemon,
|
||||||
|
fetchPokemonFamilies,
|
||||||
|
fetchPokemonEncounterLocations,
|
||||||
|
fetchPokemonEvolutionChain,
|
||||||
|
} from '../api/pokemon'
|
||||||
|
|
||||||
export function usePokemon(id: number | null) {
|
export function usePokemon(id: number | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
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 { getRuns, getRun, createRun, updateRun, deleteRun, getNamingCategories, getNameSuggestions } from '../api/runs'
|
import {
|
||||||
|
getRuns,
|
||||||
|
getRun,
|
||||||
|
createRun,
|
||||||
|
updateRun,
|
||||||
|
deleteRun,
|
||||||
|
getNamingCategories,
|
||||||
|
getNameSuggestions,
|
||||||
|
} from '../api/runs'
|
||||||
import type { CreateRunInput, UpdateRunInput } from '../types/game'
|
import type { CreateRunInput, UpdateRunInput } from '../types/game'
|
||||||
|
|
||||||
export function useRuns() {
|
export function useRuns() {
|
||||||
@@ -60,7 +68,10 @@ export function useNamingCategories() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNameSuggestions(runId: number | null, pokemonId?: number | null) {
|
export function useNameSuggestions(
|
||||||
|
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 +1 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
|||||||
@@ -23,5 +23,5 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Toaster position="bottom-right" richColors />
|
<Toaster position="bottom-right" richColors />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
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 { GenlockeGraveyard, GenlockeLineage, StatCard, RuleBadges } from '../components'
|
import {
|
||||||
|
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'
|
||||||
|
|
||||||
@@ -18,7 +23,8 @@ const statusRing: Record<RunStatus, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusStyles: Record<RunStatus, string> = {
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
active:
|
||||||
|
'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',
|
||||||
}
|
}
|
||||||
@@ -28,7 +34,9 @@ function LegIndicator({ leg }: { leg: GenlockeLegDetail }) {
|
|||||||
const status = leg.runStatus as RunStatus | null
|
const status = leg.runStatus as RunStatus | null
|
||||||
|
|
||||||
const dot = status ? (
|
const dot = status ? (
|
||||||
<div className={`w-4 h-4 rounded-full ${statusColors[status]} ring-2 ring-offset-2 ring-offset-white dark:ring-offset-gray-900 ${statusRing[status]}`} />
|
<div
|
||||||
|
className={`w-4 h-4 rounded-full ${statusColors[status]} ring-2 ring-offset-2 ring-offset-white dark:ring-offset-gray-900 ${statusRing[status]}`}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-4 h-4 rounded-full bg-gray-300 dark:bg-gray-600" />
|
<div className="w-4 h-4 rounded-full bg-gray-300 dark:bg-gray-600" />
|
||||||
)
|
)
|
||||||
@@ -49,7 +57,10 @@ function LegIndicator({ leg }: { leg: GenlockeLegDetail }) {
|
|||||||
|
|
||||||
if (hasRun) {
|
if (hasRun) {
|
||||||
return (
|
return (
|
||||||
<Link to={`/runs/${leg.runId}`} className="hover:opacity-80 transition-opacity">
|
<Link
|
||||||
|
to={`/runs/${leg.runId}`}
|
||||||
|
className="hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
@@ -105,7 +116,9 @@ export function GenlockeDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return genlocke.legs
|
return genlocke.legs
|
||||||
.filter((leg) => leg.retiredPokemonIds && leg.retiredPokemonIds.length > 0)
|
.filter(
|
||||||
|
(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>()
|
||||||
@@ -118,7 +131,11 @@ export function GenlockeDetail() {
|
|||||||
bases.push(family ? Math.min(...family) : pid)
|
bases.push(family ? Math.min(...family) : pid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { legOrder: leg.legOrder, gameName: leg.game.name, pokemonIds: bases.sort((a, b) => a - b) }
|
return {
|
||||||
|
legOrder: leg.legOrder,
|
||||||
|
gameName: leg.game.name,
|
||||||
|
pokemonIds: bases.sort((a, b) => a - b),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}, [genlocke, familiesData])
|
}, [genlocke, familiesData])
|
||||||
|
|
||||||
@@ -202,8 +219,16 @@ 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 label="Encounters" value={genlocke.stats.totalEncounters} color="blue" />
|
<StatCard
|
||||||
<StatCard label="Deaths" value={genlocke.stats.totalDeaths} color="red" />
|
label="Encounters"
|
||||||
|
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}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ 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: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
active:
|
||||||
completed:
|
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
'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',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ 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:
|
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
'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',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type PresetType = 'true' | 'normal' | 'custom' | null
|
|||||||
|
|
||||||
function buildLegsFromPreset(
|
function buildLegsFromPreset(
|
||||||
regions: Region[],
|
regions: Region[],
|
||||||
preset: 'true' | 'normal',
|
preset: 'true' | 'normal'
|
||||||
): LegEntry[] {
|
): LegEntry[] {
|
||||||
const legs: LegEntry[] = []
|
const legs: LegEntry[] = []
|
||||||
for (const region of regions) {
|
for (const region of regions) {
|
||||||
@@ -45,8 +45,11 @@ 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] = useState<NuzlockeRules>(DEFAULT_RULES)
|
const [nuzlockeRules, setNuzlockeRules] =
|
||||||
const [genlockeRules, setGenlockeRules] = useState<GenlockeRules>({ retireHoF: false })
|
useState<NuzlockeRules>(DEFAULT_RULES)
|
||||||
|
const [genlockeRules, setGenlockeRules] = useState<GenlockeRules>({
|
||||||
|
retireHoF: false,
|
||||||
|
})
|
||||||
const [namingScheme, setNamingScheme] = useState<string | null>(null)
|
const [namingScheme, setNamingScheme] = useState<string | null>(null)
|
||||||
const { data: namingCategories } = useNamingCategories()
|
const { data: namingCategories } = useNamingCategories()
|
||||||
|
|
||||||
@@ -61,7 +64,9 @@ export function NewGenlocke() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGameChange = (index: number, game: Game) => {
|
const handleGameChange = (index: number, game: Game) => {
|
||||||
setLegs((prev) => prev.map((leg, i) => (i === index ? { ...leg, game } : leg)))
|
setLegs((prev) =>
|
||||||
|
prev.map((leg, i) => (i === index ? { ...leg, game } : leg))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveLeg = (index: number) => {
|
const handleRemoveLeg = (index: number) => {
|
||||||
@@ -70,7 +75,8 @@ export function NewGenlocke() {
|
|||||||
|
|
||||||
const handleAddLeg = (region: Region) => {
|
const handleAddLeg = (region: Region) => {
|
||||||
const defaultSlug = region.genlockeDefaults.normalGenlocke
|
const defaultSlug = region.genlockeDefaults.normalGenlocke
|
||||||
const game = region.games.find((g) => g.slug === defaultSlug) ?? region.games[0]
|
const game =
|
||||||
|
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 }])
|
||||||
}
|
}
|
||||||
@@ -105,17 +111,18 @@ export function NewGenlocke() {
|
|||||||
navigate('/runs')
|
navigate('/runs')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledRuleCount = RULE_DEFINITIONS.filter((r) => nuzlockeRules[r.key]).length
|
const enabledRuleCount = RULE_DEFINITIONS.filter(
|
||||||
|
(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 = regions?.filter(
|
const availableRegions =
|
||||||
(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">
|
||||||
@@ -198,7 +205,9 @@ export function NewGenlocke() {
|
|||||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`font-medium ${isActive ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-gray-100'}`}>
|
<div
|
||||||
|
className={`font-medium ${isActive ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-gray-100'}`}
|
||||||
|
>
|
||||||
{labels[type]}
|
{labels[type]}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
@@ -241,11 +250,17 @@ export function NewGenlocke() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Also allow adding extra regions for presets */}
|
{/* Also allow adding extra regions for presets */}
|
||||||
{preset && preset !== 'custom' && availableRegions.length > 0 && legs.length > 0 && (
|
{preset &&
|
||||||
<div className="mt-4">
|
preset !== 'custom' &&
|
||||||
<AddLegDropdown regions={availableRegions} onAdd={handleAddLeg} />
|
availableRegions.length > 0 &&
|
||||||
</div>
|
legs.length > 0 && (
|
||||||
)}
|
<div className="mt-4">
|
||||||
|
<AddLegDropdown
|
||||||
|
regions={availableRegions}
|
||||||
|
onAdd={handleAddLeg}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-6 flex justify-between">
|
<div className="mt-6 flex justify-between">
|
||||||
<button
|
<button
|
||||||
@@ -270,7 +285,10 @@ export function NewGenlocke() {
|
|||||||
{/* Step 3: Rules */}
|
{/* Step 3: Rules */}
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<div>
|
<div>
|
||||||
<RulesConfiguration rules={nuzlockeRules} onChange={setNuzlockeRules} />
|
<RulesConfiguration
|
||||||
|
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">
|
||||||
@@ -301,7 +319,8 @@ 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 next leg
|
Pokemon that beat the Elite Four can continue to the
|
||||||
|
next leg
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -318,7 +337,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 be used in the next leg
|
Pokemon that beat the Elite Four are retired and cannot
|
||||||
|
be used in the next leg
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -334,7 +354,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 Pokemon. Applied to all legs.
|
Get nickname suggestions from a themed word list when catching
|
||||||
|
Pokemon. Applied to all legs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-4">
|
<div className="px-4 py-4">
|
||||||
@@ -384,7 +405,9 @@ export function NewGenlocke() {
|
|||||||
<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
|
Name
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-900 dark:text-gray-100 font-medium">{name}</p>
|
<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">
|
||||||
@@ -403,7 +426,8 @@ 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.slice(1)}
|
{leg.region.charAt(0).toUpperCase() +
|
||||||
|
leg.region.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -417,22 +441,29 @@ export function NewGenlocke() {
|
|||||||
</h3>
|
</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">Nuzlocke Rules</dt>
|
<dt className="text-gray-600 dark:text-gray-400">
|
||||||
|
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">Hall of Fame</dt>
|
<dt className="text-gray-600 dark:text-gray-400">
|
||||||
|
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">Naming Scheme</dt>
|
<dt className="text-gray-600 dark:text-gray-400">
|
||||||
|
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.slice(1)
|
? namingScheme.charAt(0).toUpperCase() +
|
||||||
|
namingScheme.slice(1)
|
||||||
: 'None'}
|
: 'None'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -530,8 +561,18 @@ function LegRow({
|
|||||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
title="Move up"
|
title="Move up"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5 15l7-7 7 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -541,8 +582,18 @@ function LegRow({
|
|||||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
title="Move down"
|
title="Move down"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -551,8 +602,18 @@ function LegRow({
|
|||||||
className="p-1 text-red-400 hover:text-red-600 dark:hover:text-red-300"
|
className="p-1 text-red-400 hover:text-red-600 dark:hover:text-red-300"
|
||||||
title="Remove leg"
|
title="Remove leg"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -576,8 +637,18 @@ function AddLegDropdown({
|
|||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
className="flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
className="flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Add Region
|
Add Region
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ export function NewRun() {
|
|||||||
if (!selectedGame) return
|
if (!selectedGame) return
|
||||||
createRun.mutate(
|
createRun.mutate(
|
||||||
{ gameId: selectedGame.id, name: runName, rules, namingScheme },
|
{ gameId: selectedGame.id, name: runName, rules, namingScheme },
|
||||||
{ onSuccess: (data) => navigate(`/runs/${data.id}`) },
|
{ onSuccess: (data) => navigate(`/runs/${data.id}`) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleRuleKeys = RULE_DEFINITIONS
|
const visibleRuleKeys = RULE_DEFINITIONS.filter(
|
||||||
.filter((r) => !hiddenRules?.has(r.key))
|
(r) => !hiddenRules?.has(r.key)
|
||||||
.map((r) => 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
|
||||||
|
|
||||||
@@ -84,7 +84,8 @@ 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.slice(1)}
|
{selectedGame.region.charAt(0).toUpperCase() +
|
||||||
|
selectedGame.region.slice(1)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +138,11 @@ export function NewRun() {
|
|||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div>
|
<div>
|
||||||
<RulesConfiguration rules={rules} onChange={setRules} hiddenRules={hiddenRules} />
|
<RulesConfiguration
|
||||||
|
rules={rules}
|
||||||
|
onChange={setRules}
|
||||||
|
hiddenRules={hiddenRules}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-between">
|
<div className="mt-6 flex justify-between">
|
||||||
<button
|
<button
|
||||||
@@ -204,7 +209,8 @@ 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 Pokemon.
|
Get nickname suggestions from a themed word list when catching
|
||||||
|
Pokemon.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -223,7 +229,9 @@ export function NewRun() {
|
|||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<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.region.charAt(0).toUpperCase() + selectedGame.region.slice(1))}
|
{selectedGame &&
|
||||||
|
selectedGame.region.charAt(0).toUpperCase() +
|
||||||
|
selectedGame.region.slice(1)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
@@ -233,10 +241,13 @@ 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">Naming Scheme</dt>
|
<dt className="text-gray-600 dark:text-gray-400">
|
||||||
|
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.slice(1)
|
? namingScheme.charAt(0).toUpperCase() +
|
||||||
|
namingScheme.slice(1)
|
||||||
: 'None'}
|
: 'None'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,21 @@ 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 { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
|
import {
|
||||||
|
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(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
|
function sortEncounters(
|
||||||
|
encounters: EncounterDetail[],
|
||||||
|
key: TeamSortKey
|
||||||
|
): EncounterDetail[] {
|
||||||
return [...encounters].sort((a, b) => {
|
return [...encounters].sort((a, b) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'route':
|
case 'route':
|
||||||
@@ -21,7 +30,10 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
|
|||||||
return nameA.localeCompare(nameB)
|
return nameA.localeCompare(nameB)
|
||||||
}
|
}
|
||||||
case 'dex':
|
case 'dex':
|
||||||
return (a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
|
return (
|
||||||
|
(a.currentPokemon ?? a.pokemon).nationalDex -
|
||||||
|
(b.currentPokemon ?? b.pokemon).nationalDex
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -29,9 +41,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusStyles: Record<RunStatus, string> = {
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
active:
|
||||||
completed:
|
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
'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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +71,24 @@ export function RunDashboard() {
|
|||||||
|
|
||||||
const encounters = run?.encounters ?? []
|
const encounters = run?.encounters ?? []
|
||||||
const alive = useMemo(
|
const alive = useMemo(
|
||||||
() => sortEncounters(encounters.filter((e) => e.status === 'caught' && e.faintLevel === null), teamSort),
|
() =>
|
||||||
[encounters, teamSort],
|
sortEncounters(
|
||||||
|
encounters.filter(
|
||||||
|
(e) => e.status === 'caught' && e.faintLevel === null
|
||||||
|
),
|
||||||
|
teamSort
|
||||||
|
),
|
||||||
|
[encounters, teamSort]
|
||||||
)
|
)
|
||||||
const dead = useMemo(
|
const dead = useMemo(
|
||||||
() => sortEncounters(encounters.filter((e) => e.status === 'caught' && e.faintLevel !== null), teamSort),
|
() =>
|
||||||
[encounters, teamSort],
|
sortEncounters(
|
||||||
|
encounters.filter(
|
||||||
|
(e) => e.status === 'caught' && e.faintLevel !== null
|
||||||
|
),
|
||||||
|
teamSort
|
||||||
|
),
|
||||||
|
[encounters, teamSort]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -111,7 +135,10 @@ export function RunDashboard() {
|
|||||||
{run.name}
|
{run.name}
|
||||||
</h1>
|
</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.region.charAt(0).toUpperCase() + run.game.region.slice(1)} · Started{' '}
|
{run.game.name} ·{' '}
|
||||||
|
{run.game.region.charAt(0).toUpperCase() +
|
||||||
|
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',
|
||||||
@@ -137,7 +164,9 @@ export function RunDashboard() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl">{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}</span>
|
<span className="text-2xl">
|
||||||
|
{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
className={`font-semibold ${
|
className={`font-semibold ${
|
||||||
@@ -222,7 +251,8 @@ 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.slice(1)
|
? run.namingScheme.charAt(0).toUpperCase() +
|
||||||
|
run.namingScheme.slice(1)
|
||||||
: 'None'}
|
: 'None'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -329,7 +359,7 @@ export function RunDashboard() {
|
|||||||
onConfirm={(status) => {
|
onConfirm={(status) => {
|
||||||
updateRun.mutate(
|
updateRun.mutate(
|
||||||
{ status },
|
{ status },
|
||||||
{ onSuccess: () => setShowEndRun(false) },
|
{ onSuccess: () => setShowEndRun(false) }
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
onClose={() => setShowEndRun(false)}
|
onClose={() => setShowEndRun(false)}
|
||||||
|
|||||||
@@ -3,9 +3,17 @@ 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 { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hooks/useEncounters'
|
import {
|
||||||
|
useCreateEncounter,
|
||||||
|
useUpdateEncounter,
|
||||||
|
useBulkRandomize,
|
||||||
|
} from '../hooks/useEncounters'
|
||||||
import { usePokemonFamilies } from '../hooks/usePokemon'
|
import { usePokemonFamilies } from '../hooks/usePokemon'
|
||||||
import { useGameBosses, useBossResults, useCreateBossResult } from '../hooks/useBosses'
|
import {
|
||||||
|
useGameBosses,
|
||||||
|
useBossResults,
|
||||||
|
useCreateBossResult,
|
||||||
|
} from '../hooks/useBosses'
|
||||||
import {
|
import {
|
||||||
EggEncounterModal,
|
EggEncounterModal,
|
||||||
EncounterModal,
|
EncounterModal,
|
||||||
@@ -35,7 +43,10 @@ import type {
|
|||||||
|
|
||||||
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
|
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
|
||||||
|
|
||||||
function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
|
function sortEncounters(
|
||||||
|
encounters: EncounterDetail[],
|
||||||
|
key: TeamSortKey
|
||||||
|
): EncounterDetail[] {
|
||||||
return [...encounters].sort((a, b) => {
|
return [...encounters].sort((a, b) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'route':
|
case 'route':
|
||||||
@@ -48,7 +59,10 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
|
|||||||
return nameA.localeCompare(nameB)
|
return nameA.localeCompare(nameB)
|
||||||
}
|
}
|
||||||
case 'dex':
|
case 'dex':
|
||||||
return (a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
|
return (
|
||||||
|
(a.currentPokemon ?? a.pokemon).nationalDex -
|
||||||
|
(b.currentPokemon ?? b.pokemon).nationalDex
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -56,9 +70,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusStyles: Record<RunStatus, string> = {
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
active:
|
||||||
completed:
|
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
'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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +143,7 @@ function organizeRoutes(routes: Route[]): RouteWithChildren[] {
|
|||||||
*/
|
*/
|
||||||
function getGroupEncounter(
|
function getGroupEncounter(
|
||||||
group: RouteWithChildren,
|
group: RouteWithChildren,
|
||||||
encounterByRoute: Map<number, EncounterDetail>,
|
encounterByRoute: Map<number, EncounterDetail>
|
||||||
): EncounterDetail | null {
|
): EncounterDetail | null {
|
||||||
for (const child of group.children) {
|
for (const child of group.children) {
|
||||||
const enc = encounterByRoute.get(child.id)
|
const enc = encounterByRoute.get(child.id)
|
||||||
@@ -154,7 +168,7 @@ function effectiveZone(route: Route): number {
|
|||||||
*/
|
*/
|
||||||
function getZoneEncounters(
|
function getZoneEncounters(
|
||||||
group: RouteWithChildren,
|
group: RouteWithChildren,
|
||||||
encounterByRoute: Map<number, EncounterDetail>,
|
encounterByRoute: Map<number, EncounterDetail>
|
||||||
): Map<number, EncounterDetail> {
|
): Map<number, EncounterDetail> {
|
||||||
const zoneMap = new Map<number, EncounterDetail>()
|
const zoneMap = new Map<number, EncounterDetail>()
|
||||||
for (const child of group.children) {
|
for (const child of group.children) {
|
||||||
@@ -172,14 +186,23 @@ function countDistinctZones(group: RouteWithChildren): number {
|
|||||||
return zones.size
|
return zones.size
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchVariant(labels: string[], starterName?: string | null): string | null {
|
function matchVariant(
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
function BossTeamPreview({ pokemon, starterName }: { pokemon: BossPokemon[]; starterName?: string | null }) {
|
function BossTeamPreview({
|
||||||
|
pokemon,
|
||||||
|
starterName,
|
||||||
|
}: {
|
||||||
|
pokemon: BossPokemon[]
|
||||||
|
starterName?: string | null
|
||||||
|
}) {
|
||||||
const variantLabels = useMemo(() => {
|
const variantLabels = useMemo(() => {
|
||||||
const labels = new Set<string>()
|
const labels = new Set<string>()
|
||||||
for (const bp of pokemon) {
|
for (const bp of pokemon) {
|
||||||
@@ -189,16 +212,20 @@ function BossTeamPreview({ pokemon, starterName }: { pokemon: BossPokemon[]; sta
|
|||||||
}, [pokemon])
|
}, [pokemon])
|
||||||
|
|
||||||
const hasVariants = variantLabels.length > 0
|
const hasVariants = variantLabels.length > 0
|
||||||
const autoMatch = useMemo(() => matchVariant(variantLabels, starterName), [variantLabels, starterName])
|
const autoMatch = useMemo(
|
||||||
|
() => matchVariant(variantLabels, starterName),
|
||||||
|
[variantLabels, starterName]
|
||||||
|
)
|
||||||
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)
|
||||||
)
|
)
|
||||||
|
|
||||||
const displayed = useMemo(() => {
|
const displayed = useMemo(() => {
|
||||||
if (!hasVariants) return pokemon
|
if (!hasVariants) return pokemon
|
||||||
return pokemon.filter(
|
return pokemon.filter(
|
||||||
(bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null,
|
(bp) =>
|
||||||
|
bp.conditionLabel === selectedVariant || bp.conditionLabel === null
|
||||||
)
|
)
|
||||||
}, [pokemon, hasVariants, selectedVariant])
|
}, [pokemon, hasVariants, selectedVariant])
|
||||||
|
|
||||||
@@ -228,7 +255,11 @@ function BossTeamPreview({ pokemon, starterName }: { pokemon: BossPokemon[]; sta
|
|||||||
.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 src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
|
<img
|
||||||
|
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" />
|
||||||
)}
|
)}
|
||||||
@@ -420,7 +451,7 @@ export function RunEncounters() {
|
|||||||
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)
|
||||||
@@ -451,7 +482,9 @@ export function RunEncounters() {
|
|||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(storageKey)
|
const saved = localStorage.getItem(storageKey)
|
||||||
if (saved) return new Set(JSON.parse(saved) as number[])
|
if (saved) return new Set(JSON.parse(saved) as number[])
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
return new Set<number>()
|
return new Set<number>()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -463,7 +496,7 @@ export function RunEncounters() {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[storageKey],
|
[storageKey]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Organize routes into hierarchical structure
|
// Organize routes into hierarchical structure
|
||||||
@@ -475,25 +508,35 @@ export function RunEncounters() {
|
|||||||
// Split encounters into normal (non-shiny) and shiny
|
// Split encounters into normal (non-shiny) and shiny
|
||||||
const transferIdSet = useMemo(
|
const transferIdSet = useMemo(
|
||||||
() => new Set(run?.transferEncounterIds ?? []),
|
() => new Set(run?.transferEncounterIds ?? []),
|
||||||
[run?.transferEncounterIds],
|
[run?.transferEncounterIds]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { normalEncounters, shinyEncounters, transferEncounters } = useMemo(() => {
|
const { normalEncounters, shinyEncounters, transferEncounters } =
|
||||||
if (!run) return { normalEncounters: [], shinyEncounters: [], transferEncounters: [] }
|
useMemo(() => {
|
||||||
const normal: EncounterDetail[] = []
|
if (!run)
|
||||||
const shiny: EncounterDetail[] = []
|
return {
|
||||||
const transfer: EncounterDetail[] = []
|
normalEncounters: [],
|
||||||
for (const enc of run.encounters) {
|
shinyEncounters: [],
|
||||||
if (transferIdSet.has(enc.id)) {
|
transferEncounters: [],
|
||||||
transfer.push(enc)
|
}
|
||||||
} else if (enc.isShiny) {
|
const normal: EncounterDetail[] = []
|
||||||
shiny.push(enc)
|
const shiny: EncounterDetail[] = []
|
||||||
} else {
|
const transfer: EncounterDetail[] = []
|
||||||
normal.push(enc)
|
for (const enc of run.encounters) {
|
||||||
|
if (transferIdSet.has(enc.id)) {
|
||||||
|
transfer.push(enc)
|
||||||
|
} else if (enc.isShiny) {
|
||||||
|
shiny.push(enc)
|
||||||
|
} else {
|
||||||
|
normal.push(enc)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return {
|
||||||
return { normalEncounters: normal, shinyEncounters: shiny, transferEncounters: transfer }
|
normalEncounters: normal,
|
||||||
}, [run, transferIdSet])
|
shinyEncounters: shiny,
|
||||||
|
transferEncounters: transfer,
|
||||||
|
}
|
||||||
|
}, [run, transferIdSet])
|
||||||
|
|
||||||
// Map routeId → encounter for quick lookup (normal encounters only)
|
// Map routeId → encounter for quick lookup (normal encounters only)
|
||||||
const encounterByRoute = useMemo(() => {
|
const encounterByRoute = useMemo(() => {
|
||||||
@@ -635,8 +678,7 @@ export function RunEncounters() {
|
|||||||
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 &&
|
r.children.length > 0 && getGroupEncounter(r, encounterByRoute) === null
|
||||||
getGroupEncounter(r, encounterByRoute) === null,
|
|
||||||
)
|
)
|
||||||
if (firstUnvisited) {
|
if (firstUnvisited) {
|
||||||
updateExpandedGroups(() => new Set([firstUnvisited.id]))
|
updateExpandedGroups(() => new Set([firstUnvisited.id]))
|
||||||
@@ -644,21 +686,25 @@ export function RunEncounters() {
|
|||||||
}, [organizedRoutes, encounterByRoute]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [organizedRoutes, encounterByRoute]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const alive = useMemo(
|
const alive = useMemo(
|
||||||
() => sortEncounters(
|
() =>
|
||||||
[...normalEncounters, ...transferEncounters, ...shinyEncounters].filter(
|
sortEncounters(
|
||||||
(e) => e.status === 'caught' && e.faintLevel === null,
|
[...normalEncounters, ...transferEncounters, ...shinyEncounters].filter(
|
||||||
|
(e) => e.status === 'caught' && e.faintLevel === null
|
||||||
|
),
|
||||||
|
teamSort
|
||||||
),
|
),
|
||||||
teamSort,
|
[normalEncounters, transferEncounters, shinyEncounters, teamSort]
|
||||||
),
|
|
||||||
[normalEncounters, transferEncounters, shinyEncounters, teamSort],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const dead = useMemo(
|
const dead = useMemo(
|
||||||
() => sortEncounters(
|
() =>
|
||||||
normalEncounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
|
sortEncounters(
|
||||||
teamSort,
|
normalEncounters.filter(
|
||||||
),
|
(e) => e.status === 'caught' && e.faintLevel !== null
|
||||||
[normalEncounters, teamSort],
|
),
|
||||||
|
teamSort
|
||||||
|
),
|
||||||
|
[normalEncounters, teamSort]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resolve HoF team encounters from IDs
|
// Resolve HoF team encounters from IDs
|
||||||
@@ -810,7 +856,10 @@ export function RunEncounters() {
|
|||||||
{run.name}
|
{run.name}
|
||||||
</h1>
|
</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.region.charAt(0).toUpperCase() + run.game.region.slice(1)} · Started{' '}
|
{run.game.name} ·{' '}
|
||||||
|
{run.game.region.charAt(0).toUpperCase() +
|
||||||
|
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',
|
||||||
@@ -819,7 +868,8 @@ export function RunEncounters() {
|
|||||||
</p>
|
</p>
|
||||||
{run.genlocke && (
|
{run.genlocke && (
|
||||||
<p className="text-sm text-purple-600 dark:text-purple-400 mt-1 font-medium">
|
<p className="text-sm text-purple-600 dark:text-purple-400 mt-1 font-medium">
|
||||||
Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} — {run.genlocke.genlockeName}
|
Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} —{' '}
|
||||||
|
{run.genlocke.genlockeName}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -868,7 +918,9 @@ export function RunEncounters() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl">{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}</span>
|
<span className="text-2xl">
|
||||||
|
{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
className={`font-semibold ${
|
className={`font-semibold ${
|
||||||
@@ -907,33 +959,40 @@ export function RunEncounters() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (
|
{run.status === 'completed' &&
|
||||||
<button
|
run.genlocke &&
|
||||||
onClick={() => {
|
!run.genlocke.isFinalLeg && (
|
||||||
if (hofTeam && hofTeam.length > 0) {
|
<button
|
||||||
setShowTransferModal(true)
|
onClick={() => {
|
||||||
} else {
|
if (hofTeam && hofTeam.length > 0) {
|
||||||
advanceLeg.mutate(
|
setShowTransferModal(true)
|
||||||
{ genlockeId: run.genlocke!.genlockeId, legOrder: run.genlocke!.legOrder },
|
} else {
|
||||||
{
|
advanceLeg.mutate(
|
||||||
onSuccess: (genlocke) => {
|
{
|
||||||
const nextLeg = genlocke.legs.find(
|
genlockeId: run.genlocke!.genlockeId,
|
||||||
(l) => l.legOrder === run.genlocke!.legOrder + 1,
|
legOrder: run.genlocke!.legOrder,
|
||||||
)
|
|
||||||
if (nextLeg?.runId) {
|
|
||||||
navigate(`/runs/${nextLeg.runId}`)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
)
|
onSuccess: (genlocke) => {
|
||||||
}
|
const nextLeg = genlocke.legs.find(
|
||||||
}}
|
(l) => l.legOrder === run.genlocke!.legOrder + 1
|
||||||
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"
|
if (nextLeg?.runId) {
|
||||||
>
|
navigate(`/runs/${nextLeg.runId}`)
|
||||||
{advanceLeg.isPending ? 'Advancing...' : 'Advance to Next Leg'}
|
}
|
||||||
</button>
|
},
|
||||||
)}
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{advanceLeg.isPending
|
||||||
|
? 'Advancing...'
|
||||||
|
: 'Advance to Next Leg'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* HoF Team Display */}
|
{/* HoF Team Display */}
|
||||||
{run.status === 'completed' && (
|
{run.status === 'completed' && (
|
||||||
@@ -957,7 +1016,11 @@ 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 src={dp.spriteUrl} alt={dp.name} className="w-12 h-12" />
|
<img
|
||||||
|
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()}
|
||||||
@@ -1040,11 +1103,13 @@ export function RunEncounters() {
|
|||||||
className="w-6 h-6"
|
className="w-6 h-6"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
|
<div
|
||||||
earned
|
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
|
||||||
? 'border-yellow-500 bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300'
|
earned
|
||||||
: 'border-gray-300 dark:border-gray-600 text-gray-400'
|
? 'border-yellow-500 bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300'
|
||||||
}`}>
|
: 'border-gray-300 dark:border-gray-600 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{boss.order}
|
{boss.order}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1077,7 +1142,8 @@ export function RunEncounters() {
|
|||||||
{isActive ? 'Team' : 'Final Team'}
|
{isActive ? 'Team' : 'Final Team'}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
{alive.length} alive{dead.length > 0 ? `, ${dead.length} dead` : ''}
|
{alive.length} alive
|
||||||
|
{dead.length > 0 ? `, ${dead.length} dead` : ''}
|
||||||
</span>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
className={`w-4 h-4 text-gray-400 transition-transform ${showTeam ? 'rotate-180' : ''}`}
|
className={`w-4 h-4 text-gray-400 transition-transform ${showTeam ? 'rotate-180' : ''}`}
|
||||||
@@ -1114,7 +1180,11 @@ export function RunEncounters() {
|
|||||||
<PokemonCard
|
<PokemonCard
|
||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
onClick={
|
||||||
|
isActive
|
||||||
|
? () => setSelectedTeamEncounter(enc)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1130,7 +1200,11 @@ export function RunEncounters() {
|
|||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
showFaintLevel
|
showFaintLevel
|
||||||
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
onClick={
|
||||||
|
isActive
|
||||||
|
? () => setSelectedTeamEncounter(enc)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1146,7 +1220,9 @@ export function RunEncounters() {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<ShinyBox
|
<ShinyBox
|
||||||
encounters={shinyEncounters}
|
encounters={shinyEncounters}
|
||||||
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
|
onEncounterClick={
|
||||||
|
isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1162,7 +1238,9 @@ export function RunEncounters() {
|
|||||||
<PokemonCard
|
<PokemonCard
|
||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
onClick={
|
||||||
|
isActive ? () => setSelectedTeamEncounter(enc) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1182,7 +1260,11 @@ export function RunEncounters() {
|
|||||||
disabled={bulkRandomize.isPending}
|
disabled={bulkRandomize.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const remaining = totalLocations - completedCount
|
const remaining = totalLocations - completedCount
|
||||||
if (window.confirm(`Randomize encounters for all ${remaining} remaining locations?`)) {
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Randomize encounters for all ${remaining} remaining locations?`
|
||||||
|
)
|
||||||
|
) {
|
||||||
bulkRandomize.mutate()
|
bulkRandomize.mutate()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -1242,9 +1324,10 @@ 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[] = route.children.length > 0
|
const routeIds: number[] =
|
||||||
? [route.id, ...route.children.map((c) => c.id)]
|
route.children.length > 0
|
||||||
: [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[] = []
|
||||||
@@ -1253,68 +1336,77 @@ export function RunEncounters() {
|
|||||||
if (b) bossesHere.push(...b)
|
if (b) bossesHere.push(...b)
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeElement = route.children.length > 0 ? (
|
const routeElement =
|
||||||
<RouteGroup
|
route.children.length > 0 ? (
|
||||||
key={route.id}
|
<RouteGroup
|
||||||
group={route}
|
|
||||||
encounterByRoute={encounterByRoute}
|
|
||||||
isExpanded={expandedGroups.has(route.id)}
|
|
||||||
onToggleExpand={() => toggleGroup(route.id)}
|
|
||||||
onRouteClick={handleRouteClick}
|
|
||||||
filter={filter}
|
|
||||||
pinwheelClause={pinwheelClause}
|
|
||||||
/>
|
|
||||||
) : (() => {
|
|
||||||
const encounter = encounterByRoute.get(route.id)
|
|
||||||
const rs = getRouteStatus(encounter)
|
|
||||||
const si = statusIndicator[rs]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={route.id}
|
key={route.id}
|
||||||
type="button"
|
group={route}
|
||||||
onClick={() => handleRouteClick(route)}
|
encounterByRoute={encounterByRoute}
|
||||||
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}`}
|
isExpanded={expandedGroups.has(route.id)}
|
||||||
>
|
onToggleExpand={() => toggleGroup(route.id)}
|
||||||
<span
|
onRouteClick={handleRouteClick}
|
||||||
className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`}
|
filter={filter}
|
||||||
/>
|
pinwheelClause={pinwheelClause}
|
||||||
<div className="flex-1 min-w-0">
|
/>
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
) : (
|
||||||
{route.name}
|
(() => {
|
||||||
</div>
|
const encounter = encounterByRoute.get(route.id)
|
||||||
{encounter ? (
|
const rs = getRouteStatus(encounter)
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
const si = statusIndicator[rs]
|
||||||
{encounter.pokemon.spriteUrl && (
|
|
||||||
<img
|
return (
|
||||||
src={encounter.pokemon.spriteUrl}
|
<button
|
||||||
alt={encounter.pokemon.name}
|
key={route.id}
|
||||||
className="w-10 h-10"
|
type="button"
|
||||||
/>
|
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}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{route.name}
|
||||||
|
</div>
|
||||||
|
{encounter ? (
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{encounter.pokemon.spriteUrl && (
|
||||||
|
<img
|
||||||
|
src={encounter.pokemon.spriteUrl}
|
||||||
|
alt={encounter.pokemon.name}
|
||||||
|
className="w-10 h-10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||||
|
{encounter.nickname ?? encounter.pokemon.name}
|
||||||
|
{encounter.status === 'caught' &&
|
||||||
|
encounter.faintLevel !== null &&
|
||||||
|
(encounter.deathCause
|
||||||
|
? ` — ${encounter.deathCause}`
|
||||||
|
: ' (dead)')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
route.encounterMethods.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
|
{route.encounterMethods.map((m) => (
|
||||||
|
<EncounterMethodBadge
|
||||||
|
key={m}
|
||||||
|
method={m}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
|
||||||
{encounter.nickname ?? encounter.pokemon.name}
|
|
||||||
{encounter.status === 'caught' &&
|
|
||||||
encounter.faintLevel !== null &&
|
|
||||||
(encounter.deathCause
|
|
||||||
? ` — ${encounter.deathCause}`
|
|
||||||
: ' (dead)')}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : route.encounterMethods.length > 0 && (
|
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
||||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
{si.label}
|
||||||
{route.encounterMethods.map((m) => (
|
</span>
|
||||||
<EncounterMethodBadge key={m} method={m} size="xs" />
|
</button>
|
||||||
))}
|
)
|
||||||
</div>
|
})()
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
|
||||||
{si.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
})()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={route.id}>
|
<div key={route.id}>
|
||||||
@@ -1358,67 +1450,83 @@ export function RunEncounters() {
|
|||||||
<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 ? 'bg-green-50/50 dark:bg-green-900/10' : 'bg-white dark:bg-gray-800'
|
isDefeated
|
||||||
|
? 'bg-green-50/50 dark:bg-green-900/10'
|
||||||
|
: 'bg-white dark:bg-gray-800'
|
||||||
} px-4 py-3`}
|
} px-4 py-3`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-start justify-between cursor-pointer select-none"
|
className="flex items-start justify-between cursor-pointer select-none"
|
||||||
onClick={toggleBoss}
|
onClick={toggleBoss}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<svg
|
<svg
|
||||||
className={`w-4 h-4 text-gray-400 transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
|
className={`w-4 h-4 text-gray-400 transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
<path
|
||||||
</svg>
|
strokeLinecap="round"
|
||||||
{boss.spriteUrl && (
|
strokeLinejoin="round"
|
||||||
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
|
d="M9 5l7 7-7 7"
|
||||||
)}
|
/>
|
||||||
<div>
|
</svg>
|
||||||
<div className="flex items-center gap-2">
|
{boss.spriteUrl && (
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<img
|
||||||
{boss.name}
|
src={boss.spriteUrl}
|
||||||
</span>
|
alt={boss.name}
|
||||||
<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">
|
className="h-10 w-auto"
|
||||||
{bossTypeLabel[boss.bossType] ?? boss.bossType}
|
/>
|
||||||
</span>
|
)}
|
||||||
{boss.specialtyType && (
|
<div>
|
||||||
<TypeBadge type={boss.specialtyType} />
|
<div className="flex items-center gap-2">
|
||||||
)}
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{boss.name}
|
||||||
|
</span>
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
{boss.specialtyType && (
|
||||||
|
<TypeBadge type={boss.specialtyType} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{boss.location} · Level Cap:{' '}
|
||||||
|
{boss.levelCap}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
</div>
|
||||||
{boss.location} · Level Cap: {boss.levelCap}
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
</p>
|
{isDefeated ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300">
|
||||||
|
Defeated ✓
|
||||||
|
</span>
|
||||||
|
) : isActive ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedBoss(boss)}
|
||||||
|
className="px-3 py-1 text-xs font-medium rounded-full bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Battle
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
{/* Boss pokemon team */}
|
||||||
{isDefeated ? (
|
{isBossExpanded && boss.pokemon.length > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300">
|
<BossTeamPreview
|
||||||
Defeated ✓
|
pokemon={boss.pokemon}
|
||||||
</span>
|
starterName={starterName}
|
||||||
) : isActive ? (
|
/>
|
||||||
<button
|
)}
|
||||||
onClick={() => setSelectedBoss(boss)}
|
|
||||||
className="px-3 py-1 text-xs font-medium rounded-full bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Battle
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Boss pokemon team */}
|
|
||||||
{isBossExpanded && boss.pokemon.length > 0 && (
|
|
||||||
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{sectionAfter && (
|
{sectionAfter && (
|
||||||
<div className="flex items-center gap-3 my-4">
|
<div className="flex items-center gap-3 my-4">
|
||||||
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
|
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
|
||||||
<span className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{sectionAfter}</span>
|
<span className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{sectionAfter}
|
||||||
|
</span>
|
||||||
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
|
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1519,7 +1627,7 @@ export function RunEncounters() {
|
|||||||
setShowHofModal(true)
|
setShowHofModal(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
onClose={() => setShowEndRun(false)}
|
onClose={() => setShowEndRun(false)}
|
||||||
@@ -1535,7 +1643,7 @@ export function RunEncounters() {
|
|||||||
onSubmit={(encounterIds) => {
|
onSubmit={(encounterIds) => {
|
||||||
updateRun.mutate(
|
updateRun.mutate(
|
||||||
{ hofEncounterIds: encounterIds },
|
{ hofEncounterIds: encounterIds },
|
||||||
{ onSuccess: () => setShowHofModal(false) },
|
{ onSuccess: () => setShowHofModal(false) }
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
onSkip={() => setShowHofModal(false)}
|
onSkip={() => setShowHofModal(false)}
|
||||||
@@ -1558,13 +1666,13 @@ export function RunEncounters() {
|
|||||||
onSuccess: (genlocke) => {
|
onSuccess: (genlocke) => {
|
||||||
setShowTransferModal(false)
|
setShowTransferModal(false)
|
||||||
const nextLeg = genlocke.legs.find(
|
const nextLeg = genlocke.legs.find(
|
||||||
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
|
(l) => l.legOrder === run!.genlocke!.legOrder + 1
|
||||||
)
|
)
|
||||||
if (nextLeg?.runId) {
|
if (nextLeg?.runId) {
|
||||||
navigate(`/runs/${nextLeg.runId}`)
|
navigate(`/runs/${nextLeg.runId}`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
onSkip={() => {
|
onSkip={() => {
|
||||||
@@ -1577,13 +1685,13 @@ export function RunEncounters() {
|
|||||||
onSuccess: (genlocke) => {
|
onSuccess: (genlocke) => {
|
||||||
setShowTransferModal(false)
|
setShowTransferModal(false)
|
||||||
const nextLeg = genlocke.legs.find(
|
const nextLeg = genlocke.legs.find(
|
||||||
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
|
(l) => l.legOrder === run!.genlocke!.legOrder + 1
|
||||||
)
|
)
|
||||||
if (nextLeg?.runId) {
|
if (nextLeg?.runId) {
|
||||||
navigate(`/runs/${nextLeg.runId}`)
|
navigate(`/runs/${nextLeg.runId}`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
isPending={advanceLeg.isPending}
|
isPending={advanceLeg.isPending}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ 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: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
active:
|
||||||
completed:
|
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
'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',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,15 +178,25 @@ 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 label="Completed" value={stats.completedRuns} color="blue" />
|
<StatCard
|
||||||
|
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: <strong className="text-gray-800 dark:text-gray-200">{pct(stats.winRate)}</strong>
|
Win Rate:{' '}
|
||||||
|
<strong className="text-gray-800 dark:text-gray-200">
|
||||||
|
{pct(stats.winRate)}
|
||||||
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Avg Duration: <strong className="text-gray-800 dark:text-gray-200">{fmt(stats.avgDurationDays, ' days')}</strong>
|
Avg Duration:{' '}
|
||||||
|
<strong className="text-gray-800 dark:text-gray-200">
|
||||||
|
{fmt(stats.avgDurationDays, ' days')}
|
||||||
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -233,10 +243,16 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
|
|||||||
</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>
|
||||||
Catch Rate: <strong className="text-gray-800 dark:text-gray-200">{pct(stats.catchRate)}</strong>
|
Catch Rate:{' '}
|
||||||
|
<strong className="text-gray-800 dark:text-gray-200">
|
||||||
|
{pct(stats.catchRate)}
|
||||||
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Avg per Run: <strong className="text-gray-800 dark:text-gray-200">{fmt(stats.avgEncountersPerRun)}</strong>
|
Avg per Run:{' '}
|
||||||
|
<strong className="text-gray-800 dark:text-gray-200">
|
||||||
|
{fmt(stats.avgEncountersPerRun)}
|
||||||
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -244,10 +260,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
|
|||||||
{/* Pokemon Rankings */}
|
{/* Pokemon Rankings */}
|
||||||
<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
|
<PokemonList title="Most Caught" pokemon={stats.topCaughtPokemon} />
|
||||||
title="Most Caught"
|
|
||||||
pokemon={stats.topCaughtPokemon}
|
|
||||||
/>
|
|
||||||
<PokemonList
|
<PokemonList
|
||||||
title="Most Encountered"
|
title="Most Encountered"
|
||||||
pokemon={stats.topEncounteredPokemon}
|
pokemon={stats.topEncounteredPokemon}
|
||||||
@@ -258,24 +271,34 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
|
|||||||
{/* 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 label="Total Deaths" value={stats.totalDeaths} color="red" />
|
<StatCard
|
||||||
|
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">Mortality Rate</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
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">Avg Catch Lv.</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
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">Avg Faint Lv.</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Avg Faint Lv.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -347,7 +370,9 @@ 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">Start a Nuzlocke run to see your stats here.</p>
|
<p className="text-sm">
|
||||||
|
Start a Nuzlocke run to see your stats here.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ 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 { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
|
import type {
|
||||||
|
EvolutionAdmin,
|
||||||
|
CreateEvolutionInput,
|
||||||
|
UpdateEvolutionInput,
|
||||||
|
} from '../../types'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
@@ -28,7 +32,12 @@ export function AdminEvolutions() {
|
|||||||
const [triggerFilter, setTriggerFilter] = useState('')
|
const [triggerFilter, setTriggerFilter] = useState('')
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const offset = page * PAGE_SIZE
|
const offset = page * PAGE_SIZE
|
||||||
const { data, isLoading } = useEvolutionList(search || undefined, PAGE_SIZE, offset, triggerFilter || undefined)
|
const { data, isLoading } = useEvolutionList(
|
||||||
|
search || undefined,
|
||||||
|
PAGE_SIZE,
|
||||||
|
offset,
|
||||||
|
triggerFilter || undefined
|
||||||
|
)
|
||||||
const evolutions = data?.items ?? []
|
const evolutions = data?.items ?? []
|
||||||
const total = data?.total ?? 0
|
const total = data?.total ?? 0
|
||||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||||
@@ -120,12 +129,18 @@ export function AdminEvolutions() {
|
|||||||
>
|
>
|
||||||
<option value="">All triggers</option>
|
<option value="">All triggers</option>
|
||||||
{EVOLUTION_TRIGGERS.map((t) => (
|
{EVOLUTION_TRIGGERS.map((t) => (
|
||||||
<option key={t.value} value={t.value}>{t.label}</option>
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{(search || triggerFilter) && (
|
{(search || triggerFilter) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSearch(''); setTriggerFilter(''); setPage(0) }}
|
onClick={() => {
|
||||||
|
setSearch('')
|
||||||
|
setTriggerFilter('')
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
Clear filters
|
Clear filters
|
||||||
@@ -148,7 +163,8 @@ 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 {total}
|
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of{' '}
|
||||||
|
{total}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -213,7 +229,7 @@ export function AdminEvolutions() {
|
|||||||
onSubmit={(data) =>
|
onSubmit={(data) =>
|
||||||
updateEvolution.mutate(
|
updateEvolution.mutate(
|
||||||
{ id: editing.id, data: data as UpdateEvolutionInput },
|
{ id: editing.id, data: data as UpdateEvolutionInput },
|
||||||
{ onSuccess: () => setEditing(null) },
|
{ onSuccess: () => setEditing(null) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClose={() => setEditing(null)}
|
onClose={() => setEditing(null)}
|
||||||
|
|||||||
@@ -38,8 +38,17 @@ import {
|
|||||||
} from '../../hooks/useAdmin'
|
} from '../../hooks/useAdmin'
|
||||||
import { exportGameRoutes, exportGameBosses } from '../../api/admin'
|
import { exportGameRoutes, exportGameBosses } from '../../api/admin'
|
||||||
import { downloadJson } from '../../utils/download'
|
import { downloadJson } from '../../utils/download'
|
||||||
import type { Route as GameRoute, RouteWithChildren, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types'
|
import type {
|
||||||
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
|
Route as GameRoute,
|
||||||
|
RouteWithChildren,
|
||||||
|
CreateRouteInput,
|
||||||
|
UpdateRouteInput,
|
||||||
|
BossBattle,
|
||||||
|
} from '../../types'
|
||||||
|
import type {
|
||||||
|
CreateBossBattleInput,
|
||||||
|
UpdateBossBattleInput,
|
||||||
|
} from '../../types/admin'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Organize flat routes into hierarchical structure.
|
* Organize flat routes into hierarchical structure.
|
||||||
@@ -76,8 +85,14 @@ function SortableRouteGroup({
|
|||||||
gameId: number
|
gameId: number
|
||||||
onClick: (r: GameRoute) => void
|
onClick: (r: GameRoute) => void
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
const {
|
||||||
useSortable({ id: group.id })
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: group.id })
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -112,7 +127,9 @@ function SortableRouteGroup({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">{group.order}</td>
|
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">
|
||||||
|
{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'}
|
||||||
@@ -138,7 +155,9 @@ 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">{'\u2514'}</span>
|
<span className="text-gray-300 dark:text-gray-600 mr-1.5">
|
||||||
|
{'\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">
|
||||||
@@ -172,8 +191,14 @@ 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 { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
const {
|
||||||
useSortable({ id: boss.id })
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: boss.id })
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -208,22 +233,29 @@ function SortableBossRow({
|
|||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.order}</td>
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.order}</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap font-medium">
|
<td className="px-4 py-3 text-sm whitespace-nowrap font-medium">
|
||||||
{boss.name}
|
{boss.name}
|
||||||
{boss.gameId != null && (() => {
|
{boss.gameId != null &&
|
||||||
const g = games.find((g) => g.id === boss.gameId)
|
(() => {
|
||||||
return g ? (
|
const g = games.find((g) => g.id === boss.gameId)
|
||||||
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
|
return g ? (
|
||||||
{g.name}
|
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
|
||||||
</span>
|
{g.name}
|
||||||
) : null
|
</span>
|
||||||
})()}
|
) : null
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap capitalize">
|
<td className="px-4 py-3 text-sm whitespace-nowrap capitalize">
|
||||||
{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 ? <TypeBadge type={boss.specialtyType} /> : '\u2014'}
|
{boss.specialtyType ? (
|
||||||
|
<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
|
||||||
@@ -244,7 +276,9 @@ 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">{boss.pokemon.length}</td>
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||||
|
{boss.pokemon.length}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -278,16 +312,18 @@ export function AdminGameDetail() {
|
|||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isLoading) return <div className="py-8 text-center text-gray-500">Loading...</div>
|
if (isLoading)
|
||||||
if (!game) return <div className="py-8 text-center text-gray-500">Game not found</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>
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -347,7 +383,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)} · Gen {game.generation}
|
{game.region.charAt(0).toUpperCase() + game.region.slice(1)} ·
|
||||||
|
Gen {game.generation}
|
||||||
{game.releaseYear ? ` \u00b7 ${game.releaseYear}` : ''}
|
{game.releaseYear ? ` \u00b7 ${game.releaseYear}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -463,7 +500,11 @@ export function AdminGameDetail() {
|
|||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<RouteFormModal
|
<RouteFormModal
|
||||||
nextOrder={routes.length > 0 ? Math.max(...routes.map((r) => r.order)) + 1 : 1}
|
nextOrder={
|
||||||
|
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),
|
||||||
@@ -480,7 +521,7 @@ export function AdminGameDetail() {
|
|||||||
onSubmit={(data) =>
|
onSubmit={(data) =>
|
||||||
updateRoute.mutate(
|
updateRoute.mutate(
|
||||||
{ routeId: editing.id, data: data as UpdateRouteInput },
|
{ routeId: editing.id, data: data as UpdateRouteInput },
|
||||||
{ onSuccess: () => setEditing(null) },
|
{ onSuccess: () => setEditing(null) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClose={() => setEditing(null)}
|
onClose={() => setEditing(null)}
|
||||||
@@ -614,7 +655,9 @@ export function AdminGameDetail() {
|
|||||||
<BossBattleFormModal
|
<BossBattleFormModal
|
||||||
routes={routes}
|
routes={routes}
|
||||||
games={versionGroupGames}
|
games={versionGroupGames}
|
||||||
nextOrder={bosses ? Math.max(0, ...bosses.map((b) => b.order)) + 1 : 1}
|
nextOrder={
|
||||||
|
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),
|
||||||
@@ -634,7 +677,7 @@ export function AdminGameDetail() {
|
|||||||
onSubmit={(data) =>
|
onSubmit={(data) =>
|
||||||
updateBoss.mutate(
|
updateBoss.mutate(
|
||||||
{ bossId: editingBoss.id, data: data as UpdateBossBattleInput },
|
{ bossId: editingBoss.id, data: data as UpdateBossBattleInput },
|
||||||
{ onSuccess: () => setEditingBoss(null) },
|
{ onSuccess: () => setEditingBoss(null) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClose={() => setEditingBoss(null)}
|
onClose={() => setEditingBoss(null)}
|
||||||
@@ -676,9 +719,7 @@ function BossTeamEditorWrapper({
|
|||||||
return (
|
return (
|
||||||
<BossTeamEditor
|
<BossTeamEditor
|
||||||
boss={boss}
|
boss={boss}
|
||||||
onSave={(team) =>
|
onSave={(team) => setBossTeam.mutate(team, { onSuccess: onClose })}
|
||||||
setBossTeam.mutate(team, { onSuccess: onClose })
|
|
||||||
}
|
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
isSaving={setBossTeam.isPending}
|
isSaving={setBossTeam.isPending}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ 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 { useCreateGame, useUpdateGame, useDeleteGame } from '../../hooks/useAdmin'
|
import {
|
||||||
|
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'
|
||||||
@@ -20,17 +24,18 @@ export function AdminGames() {
|
|||||||
|
|
||||||
const regions = useMemo(
|
const regions = useMemo(
|
||||||
() => [...new Set(games.map((g) => g.region))].sort(),
|
() => [...new Set(games.map((g) => g.region))].sort(),
|
||||||
[games],
|
[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]
|
||||||
)
|
)
|
||||||
|
|
||||||
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) result = result.filter((g) => g.generation === Number(genFilter))
|
if (genFilter)
|
||||||
|
result = result.filter((g) => g.generation === Number(genFilter))
|
||||||
return result
|
return result
|
||||||
}, [games, regionFilter, genFilter])
|
}, [games, regionFilter, genFilter])
|
||||||
|
|
||||||
@@ -38,8 +43,16 @@ export function AdminGames() {
|
|||||||
{ header: 'Name', accessor: (g) => g.name },
|
{ header: 'Name', accessor: (g) => g.name },
|
||||||
{ header: 'Slug', accessor: (g) => g.slug },
|
{ header: 'Slug', accessor: (g) => g.slug },
|
||||||
{ header: 'Region', accessor: (g) => g.region, sortKey: (g) => g.region },
|
{ header: 'Region', accessor: (g) => g.region, sortKey: (g) => g.region },
|
||||||
{ header: 'Gen', accessor: (g) => g.generation, sortKey: (g) => g.generation },
|
{
|
||||||
{ header: 'Year', accessor: (g) => g.releaseYear ?? '-', sortKey: (g) => g.releaseYear ?? 0 },
|
header: 'Gen',
|
||||||
|
accessor: (g) => g.generation,
|
||||||
|
sortKey: (g) => g.generation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Year',
|
||||||
|
accessor: (g) => g.releaseYear ?? '-',
|
||||||
|
sortKey: (g) => g.releaseYear ?? 0,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,7 +86,9 @@ export function AdminGames() {
|
|||||||
>
|
>
|
||||||
<option value="">All regions</option>
|
<option value="">All regions</option>
|
||||||
{regions.map((r) => (
|
{regions.map((r) => (
|
||||||
<option key={r} value={r}>{r}</option>
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
@@ -83,12 +98,17 @@ export function AdminGames() {
|
|||||||
>
|
>
|
||||||
<option value="">All generations</option>
|
<option value="">All generations</option>
|
||||||
{generations.map((g) => (
|
{generations.map((g) => (
|
||||||
<option key={g} value={g}>Gen {g}</option>
|
<option key={g} value={g}>
|
||||||
|
Gen {g}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{(regionFilter || genFilter) && (
|
{(regionFilter || genFilter) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setRegionFilter(''); setGenFilter('') }}
|
onClick={() => {
|
||||||
|
setRegionFilter('')
|
||||||
|
setGenFilter('')
|
||||||
|
}}
|
||||||
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
Clear filters
|
Clear filters
|
||||||
@@ -126,7 +146,7 @@ export function AdminGames() {
|
|||||||
onSubmit={(data) =>
|
onSubmit={(data) =>
|
||||||
updateGame.mutate(
|
updateGame.mutate(
|
||||||
{ id: editing.id, data: data as UpdateGameInput },
|
{ id: editing.id, data: data as UpdateGameInput },
|
||||||
{ onSuccess: () => setEditing(null) },
|
{ onSuccess: () => setEditing(null) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClose={() => setEditing(null)}
|
onClose={() => setEditing(null)}
|
||||||
|
|||||||
@@ -28,13 +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) return <div className="py-8 text-center text-gray-500">Loading...</div>
|
if (isLoading)
|
||||||
if (!genlocke) return <div className="py-8 text-center text-gray-500">Genlocke not found</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>
|
||||||
|
)
|
||||||
|
|
||||||
const editName = name ?? genlocke.name
|
const editName = name ?? genlocke.name
|
||||||
const editStatus = status ?? genlocke.status
|
const editStatus = status ?? genlocke.status
|
||||||
|
|
||||||
const hasChanges = editName !== genlocke.name || editStatus !== genlocke.status
|
const hasChanges =
|
||||||
|
editName !== genlocke.name || editStatus !== genlocke.status
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const data: Record<string, string> = {}
|
const data: Record<string, string> = {}
|
||||||
@@ -48,7 +53,7 @@ export function AdminGenlockeDetail() {
|
|||||||
setName(null)
|
setName(null)
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +66,7 @@ export function AdminGenlockeDetail() {
|
|||||||
setAddingLeg(false)
|
setAddingLeg(false)
|
||||||
setSelectedGameId('')
|
setSelectedGameId('')
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +77,9 @@ export function AdminGenlockeDetail() {
|
|||||||
Genlockes
|
Genlockes
|
||||||
</Link>
|
</Link>
|
||||||
{' / '}
|
{' / '}
|
||||||
<span className="text-gray-900 dark:text-gray-100">{genlocke.name}</span>
|
<span className="text-gray-900 dark:text-gray-100">
|
||||||
|
{genlocke.name}
|
||||||
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -124,16 +131,22 @@ 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">Rules</h3>
|
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
|
||||||
|
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">Genlocke rules:</span>
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
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">Nuzlocke rules:</span>
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
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>
|
||||||
@@ -144,7 +157,9 @@ 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">Legs ({genlocke.legs.length})</h3>
|
<h3 className="text-lg font-semibold">
|
||||||
|
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"
|
||||||
@@ -157,7 +172,9 @@ 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) => setSelectedGameId(e.target.value ? Number(e.target.value) : '')}
|
onChange={(e) =>
|
||||||
|
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>
|
||||||
@@ -222,8 +239,12 @@ 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">{leg.legOrder}</td>
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.game.name}</td>
|
{leg.legOrder}
|
||||||
|
</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
|
||||||
@@ -253,13 +274,21 @@ 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">{leg.encounterCount}</td>
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.deathCount}</td>
|
{leg.encounterCount}
|
||||||
|
</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)}
|
||||||
disabled={leg.runId !== null || deleteLeg.isPending}
|
disabled={leg.runId !== null || deleteLeg.isPending}
|
||||||
title={leg.runId !== null ? 'Cannot remove a leg with a linked run' : 'Remove leg'}
|
title={
|
||||||
|
leg.runId !== null
|
||||||
|
? 'Cannot remove a leg with a linked run'
|
||||||
|
: 'Remove leg'
|
||||||
|
}
|
||||||
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
@@ -276,22 +305,32 @@ 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">Stats</h3>
|
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
|
||||||
|
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>
|
||||||
<p className="text-lg font-semibold">{genlocke.stats.legsCompleted} / {genlocke.stats.totalLegs}</p>
|
<p className="text-lg font-semibold">
|
||||||
|
{genlocke.stats.legsCompleted} / {genlocke.stats.totalLegs}
|
||||||
|
</p>
|
||||||
</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">{genlocke.stats.totalEncounters}</p>
|
<p className="text-lg font-semibold">
|
||||||
|
{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">{genlocke.stats.totalDeaths}</p>
|
<p className="text-lg font-semibold">
|
||||||
|
{genlocke.stats.totalDeaths}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">Survival Rate</span>
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
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,14 +11,33 @@ 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 { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types'
|
import type {
|
||||||
|
Pokemon,
|
||||||
|
CreatePokemonInput,
|
||||||
|
UpdatePokemonInput,
|
||||||
|
} from '../../types'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
const POKEMON_TYPES = [
|
const POKEMON_TYPES = [
|
||||||
'bug', 'dark', 'dragon', 'electric', 'fairy', 'fighting', 'fire', 'flying',
|
'bug',
|
||||||
'ghost', 'grass', 'ground', 'ice', 'normal', 'poison', 'psychic', 'rock',
|
'dark',
|
||||||
'steel', 'water',
|
'dragon',
|
||||||
|
'electric',
|
||||||
|
'fairy',
|
||||||
|
'fighting',
|
||||||
|
'fire',
|
||||||
|
'flying',
|
||||||
|
'ghost',
|
||||||
|
'grass',
|
||||||
|
'ground',
|
||||||
|
'ice',
|
||||||
|
'normal',
|
||||||
|
'poison',
|
||||||
|
'psychic',
|
||||||
|
'rock',
|
||||||
|
'steel',
|
||||||
|
'water',
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AdminPokemon() {
|
export function AdminPokemon() {
|
||||||
@@ -26,7 +45,12 @@ export function AdminPokemon() {
|
|||||||
const [typeFilter, setTypeFilter] = useState('')
|
const [typeFilter, setTypeFilter] = useState('')
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const offset = page * PAGE_SIZE
|
const offset = page * PAGE_SIZE
|
||||||
const { data, isLoading } = usePokemonList(search || undefined, PAGE_SIZE, offset, typeFilter || undefined)
|
const { data, isLoading } = usePokemonList(
|
||||||
|
search || undefined,
|
||||||
|
PAGE_SIZE,
|
||||||
|
offset,
|
||||||
|
typeFilter || undefined
|
||||||
|
)
|
||||||
const pokemon = data?.items ?? []
|
const pokemon = data?.items ?? []
|
||||||
const total = data?.total ?? 0
|
const total = data?.total ?? 0
|
||||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||||
@@ -105,12 +129,18 @@ export function AdminPokemon() {
|
|||||||
>
|
>
|
||||||
<option value="">All types</option>
|
<option value="">All types</option>
|
||||||
{POKEMON_TYPES.map((t) => (
|
{POKEMON_TYPES.map((t) => (
|
||||||
<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>
|
<option key={t} value={t}>
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{(search || typeFilter) && (
|
{(search || typeFilter) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSearch(''); setTypeFilter(''); setPage(0) }}
|
onClick={() => {
|
||||||
|
setSearch('')
|
||||||
|
setTypeFilter('')
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
Clear filters
|
Clear filters
|
||||||
@@ -134,7 +164,8 @@ 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 {total}
|
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of{' '}
|
||||||
|
{total}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -188,7 +219,11 @@ export function AdminPokemon() {
|
|||||||
<BulkImportModal
|
<BulkImportModal
|
||||||
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) => bulkImport.mutateAsync(items as Parameters<typeof bulkImport.mutateAsync>[0])}
|
onSubmit={(items) =>
|
||||||
|
bulkImport.mutateAsync(
|
||||||
|
items as Parameters<typeof bulkImport.mutateAsync>[0]
|
||||||
|
)
|
||||||
|
}
|
||||||
onClose={() => setShowBulkImport(false)}
|
onClose={() => setShowBulkImport(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -199,7 +234,7 @@ export function AdminPokemon() {
|
|||||||
onSubmit={(data) =>
|
onSubmit={(data) =>
|
||||||
updatePokemon.mutate(
|
updatePokemon.mutate(
|
||||||
{ id: editing.id, data: data as UpdatePokemonInput },
|
{ id: editing.id, data: data as UpdatePokemonInput },
|
||||||
{ onSuccess: () => setEditing(null) },
|
{ onSuccess: () => setEditing(null) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClose={() => setEditing(null)}
|
onClose={() => setEditing(null)}
|
||||||
|
|||||||
@@ -42,24 +42,29 @@ export function AdminRouteDetail() {
|
|||||||
|
|
||||||
const sortedRoutes = useMemo(
|
const sortedRoutes = useMemo(
|
||||||
() => [...(game?.routes ?? [])].sort((a, b) => a.order - b.order),
|
() => [...(game?.routes ?? [])].sort((a, b) => a.order - b.order),
|
||||||
[game?.routes],
|
[game?.routes]
|
||||||
)
|
)
|
||||||
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 = currentIndex > 0 ? sortedRoutes[currentIndex - 1] : undefined
|
const prevRoute =
|
||||||
|
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]
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const childRoutes = useMemo(
|
const childRoutes = useMemo(
|
||||||
() => (game?.routes ?? []).filter((r) => r.parentRouteId === rId).sort((a, b) => a.order - b.order),
|
() =>
|
||||||
[game?.routes, rId],
|
(game?.routes ?? [])
|
||||||
|
.filter((r) => r.parentRouteId === rId)
|
||||||
|
.sort((a, b) => a.order - b.order),
|
||||||
|
[game?.routes, rId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const nextChildOrder = childRoutes.length > 0
|
const nextChildOrder =
|
||||||
? Math.max(...childRoutes.map((r) => r.order)) + 1
|
childRoutes.length > 0
|
||||||
: (route?.order ?? 0) * 10 + 1
|
? Math.max(...childRoutes.map((r) => r.order)) + 1
|
||||||
|
: (route?.order ?? 0) * 10 + 1
|
||||||
|
|
||||||
const columns: Column<RouteEncounterDetail>[] = [
|
const columns: Column<RouteEncounterDetail>[] = [
|
||||||
{
|
{
|
||||||
@@ -67,7 +72,11 @@ 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 src={e.pokemon.spriteUrl} alt={e.pokemon.name} className="w-6 h-6" />
|
<img
|
||||||
|
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}
|
||||||
@@ -80,7 +89,9 @@ export function AdminRouteDetail() {
|
|||||||
{
|
{
|
||||||
header: 'Levels',
|
header: 'Levels',
|
||||||
accessor: (e) =>
|
accessor: (e) =>
|
||||||
e.minLevel === e.maxLevel ? `Lv ${e.minLevel}` : `Lv ${e.minLevel}-${e.maxLevel}`,
|
e.minLevel === e.maxLevel
|
||||||
|
? `Lv ${e.minLevel}`
|
||||||
|
: `Lv ${e.minLevel}-${e.maxLevel}`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -98,7 +109,9 @@ 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) => navigate(`/admin/games/${gId}/routes/${e.target.value}`)}
|
onChange={(e) =>
|
||||||
|
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}>
|
||||||
@@ -162,9 +175,12 @@ export function AdminRouteDetail() {
|
|||||||
{showCreate && (
|
{showCreate && (
|
||||||
<RouteEncounterFormModal
|
<RouteEncounterFormModal
|
||||||
onSubmit={(data) =>
|
onSubmit={(data) =>
|
||||||
addEncounter.mutate({ ...data, gameId: gId } as CreateRouteEncounterInput, {
|
addEncounter.mutate(
|
||||||
onSuccess: () => setShowCreate(false),
|
{ ...data, gameId: gId } as CreateRouteEncounterInput,
|
||||||
})
|
{
|
||||||
|
onSuccess: () => setShowCreate(false),
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onClose={() => setShowCreate(false)}
|
onClose={() => setShowCreate(false)}
|
||||||
isSubmitting={addEncounter.isPending}
|
isSubmitting={addEncounter.isPending}
|
||||||
@@ -176,8 +192,11 @@ export function AdminRouteDetail() {
|
|||||||
encounter={editing}
|
encounter={editing}
|
||||||
onSubmit={(data) =>
|
onSubmit={(data) =>
|
||||||
updateEncounter.mutate(
|
updateEncounter.mutate(
|
||||||
{ encounterId: editing.id, data: data as UpdateRouteEncounterInput },
|
{
|
||||||
{ onSuccess: () => setEditing(null) },
|
encounterId: editing.id,
|
||||||
|
data: data as UpdateRouteEncounterInput,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setEditing(null) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClose={() => setEditing(null)}
|
onClose={() => setEditing(null)}
|
||||||
@@ -194,7 +213,9 @@ 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">Sub-areas ({childRoutes.length})</h3>
|
<h3 className="text-lg font-semibold">
|
||||||
|
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"
|
||||||
@@ -203,11 +224,16 @@ export function AdminRouteDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{childRoutes.length === 0 ? (
|
{childRoutes.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">No sub-areas for this route.</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
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 key={child.id} className="flex items-center justify-between px-4 py-2">
|
<div
|
||||||
|
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"
|
||||||
@@ -232,7 +258,7 @@ export function AdminRouteDetail() {
|
|||||||
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)}
|
||||||
|
|||||||
@@ -16,19 +16,28 @@ export function AdminRuns() {
|
|||||||
|
|
||||||
const gameMap = useMemo(
|
const gameMap = useMemo(
|
||||||
() => new Map(games.map((g) => [g.id, g.name])),
|
() => new Map(games.map((g) => [g.id, g.name])),
|
||||||
[games],
|
[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) result = result.filter((r) => r.gameId === Number(gameFilter))
|
if (gameFilter)
|
||||||
|
result = result.filter((r) => r.gameId === Number(gameFilter))
|
||||||
return result
|
return result
|
||||||
}, [runs, statusFilter, gameFilter])
|
}, [runs, statusFilter, gameFilter])
|
||||||
|
|
||||||
const runGames = useMemo(
|
const runGames = useMemo(
|
||||||
() => [...new Map(runs.map((r) => [r.gameId, gameMap.get(r.gameId) ?? `Game #${r.gameId}`])).entries()].sort((a, b) => a[1].localeCompare(b[1])),
|
() =>
|
||||||
[runs, gameMap],
|
[
|
||||||
|
...new Map(
|
||||||
|
runs.map((r) => [
|
||||||
|
r.gameId,
|
||||||
|
gameMap.get(r.gameId) ?? `Game #${r.gameId}`,
|
||||||
|
])
|
||||||
|
).entries(),
|
||||||
|
].sort((a, b) => a[1].localeCompare(b[1])),
|
||||||
|
[runs, gameMap]
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns: Column<NuzlockeRun>[] = [
|
const columns: Column<NuzlockeRun>[] = [
|
||||||
@@ -86,12 +95,17 @@ export function AdminRuns() {
|
|||||||
>
|
>
|
||||||
<option value="">All games</option>
|
<option value="">All games</option>
|
||||||
{runGames.map(([id, name]) => (
|
{runGames.map(([id, name]) => (
|
||||||
<option key={id} value={id}>{name}</option>
|
<option key={id} value={id}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{(statusFilter || gameFilter) && (
|
{(statusFilter || gameFilter) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setStatusFilter(''); setGameFilter('') }}
|
onClick={() => {
|
||||||
|
setStatusFilter('')
|
||||||
|
setGameFilter('')
|
||||||
|
}}
|
||||||
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
Clear filters
|
Clear filters
|
||||||
@@ -120,7 +134,10 @@ export function AdminRuns() {
|
|||||||
onSuccess: () => setDeleting(null),
|
onSuccess: () => setDeleting(null),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onCancel={() => { setDeleting(null); deleteRun.reset() }}
|
onCancel={() => {
|
||||||
|
setDeleting(null)
|
||||||
|
deleteRun.reset()
|
||||||
|
}}
|
||||||
isDeleting={deleteRun.isPending}
|
isDeleting={deleteRun.isPending}
|
||||||
error={deleteRun.error?.message ?? null}
|
error={deleteRun.error?.message ?? null}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
export type GameCategory = 'original' | 'remake' | 'enhanced' | 'sequel' | 'spinoff'
|
export type GameCategory =
|
||||||
|
| 'original'
|
||||||
|
| 'remake'
|
||||||
|
| 'enhanced'
|
||||||
|
| 'sequel'
|
||||||
|
| 'spinoff'
|
||||||
|
|
||||||
export interface Game {
|
export interface Game {
|
||||||
id: number
|
id: number
|
||||||
@@ -14,8 +19,8 @@ export interface Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GenlockeDefaults {
|
export interface GenlockeDefaults {
|
||||||
trueGenlocke: string // game slug
|
trueGenlocke: string // game slug
|
||||||
normalGenlocke: string // game slug
|
normalGenlocke: string // game slug
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Region {
|
export interface Region {
|
||||||
@@ -163,7 +168,15 @@ export interface UpdateEncounterInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Boss battles
|
// Boss battles
|
||||||
export type BossType = 'gym_leader' | 'elite_four' | 'champion' | 'rival' | 'evil_team' | 'kahuna' | 'totem' | 'other'
|
export type BossType =
|
||||||
|
| 'gym_leader'
|
||||||
|
| 'elite_four'
|
||||||
|
| 'champion'
|
||||||
|
| 'rival'
|
||||||
|
| 'evil_team'
|
||||||
|
| 'kahuna'
|
||||||
|
| 'totem'
|
||||||
|
| 'other'
|
||||||
|
|
||||||
export interface BossPokemon {
|
export interface BossPokemon {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export function downloadJson(data: unknown, filename: string) {
|
export function downloadJson(data: unknown, filename: string) {
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||||
|
type: 'application/json',
|
||||||
|
})
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
export function formatEvolutionMethod(evo: { trigger: string; minLevel: number | null; item: string | null; heldItem: string | null; condition: string | null }): string {
|
export function formatEvolutionMethod(evo: {
|
||||||
|
trigger: string
|
||||||
|
minLevel: number | null
|
||||||
|
item: string | null
|
||||||
|
heldItem: string | null
|
||||||
|
condition: string | null
|
||||||
|
}): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (evo.trigger === 'level-up' && evo.minLevel) {
|
if (evo.trigger === 'level-up' && evo.minLevel) {
|
||||||
parts.push(`Level ${evo.minLevel}`)
|
parts.push(`Level ${evo.minLevel}`)
|
||||||
} 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(evo.item.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
|
parts.push(
|
||||||
|
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(evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
|
parts.push(
|
||||||
|
evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (evo.heldItem) {
|
if (evo.heldItem) {
|
||||||
parts.push(`holding ${evo.heldItem.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`)
|
parts.push(
|
||||||
|
`holding ${evo.heldItem.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (evo.condition) {
|
if (evo.condition) {
|
||||||
parts.push(evo.condition)
|
parts.push(evo.condition)
|
||||||
|
|||||||
Reference in New Issue
Block a user