Add pre-commit hooks for linting and formatting
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / frontend-lint (push) Successful in 33s

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:
2026-02-14 16:41:24 +01:00
parent b05a75f7f2
commit 2963f16aa4
67 changed files with 1905 additions and 792 deletions

View File

@@ -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.

View File

@@ -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
View 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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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>

View File

@@ -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)

View File

@@ -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}`)
} }

View File

@@ -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' }),
} }

View File

@@ -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`, {})
} }

View File

@@ -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}`)
} }

View File

@@ -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 ?? {}
)
} }

View File

@@ -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`)
} }

View File

@@ -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}`

View File

@@ -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}

View File

@@ -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,7 +200,9 @@ export function EggEncounterModal({
))} ))}
</div> </div>
)} )}
{search.length >= 2 && !isSearching && searchResults.length === 0 && ( {search.length >= 2 &&
!isSearching &&
searchResults.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 py-2"> <p className="text-sm text-gray-500 dark:text-gray-400 py-2">
No pokemon found No pokemon found
</p> </p>

View File

@@ -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}`}

View File

@@ -42,8 +42,11 @@ interface EncounterModalProps {
isPending: boolean isPending: boolean
} }
const statusOptions: { value: EncounterStatus; label: string; color: string }[] = const statusOptions: {
[ value: EncounterStatus
label: string
color: string
}[] = [
{ {
value: 'caught', value: 'caught',
label: 'Caught', label: 'Caught',
@@ -62,11 +65,13 @@ const statusOptions: { value: EncounterStatus; label: string; color: string }[]
color: color:
'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600', '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>

View File

@@ -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!'

View File

@@ -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)}

View File

@@ -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} &mdash; {data.deadliestLeg.gameName} ({data.deadliestLeg.deathCount}) Deadliest: Leg {data.deadliestLeg.legOrder} &mdash;{' '}
{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>

View File

@@ -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>

View File

@@ -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))

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
))} ))}

View File

@@ -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,7 +197,11 @@ export function StatusChangeModal({
)} )}
{/* Alive pokemon: actions */} {/* Alive pokemon: actions */}
{!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm && ( {!isDead &&
!showConfirm &&
!showEvolve &&
!showFormChange &&
!showShedConfirm && (
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
type="button" type="button"
@@ -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"

View File

@@ -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">

View File

@@ -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>

View File

@@ -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`}
/>
) )
} }

View File

@@ -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

View File

@@ -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,7 +107,8 @@ export function BossBattleFormModal({
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onDelete={onDelete} onDelete={onDelete}
isDeleting={isDeleting} isDeleting={isDeleting}
headerExtra={onEditTeam ? ( headerExtra={
onEditTeam ? (
<button <button
type="button" type="button"
onClick={onEditTeam} onClick={onEditTeam}
@@ -95,7 +116,8 @@ export function BossBattleFormModal({
> >
Edit Team ({boss?.pokemon.length ?? 0}) Edit Team ({boss?.pokemon.length ?? 0})
</button> </button>
) : undefined} ) : 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}

View File

@@ -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">&#10005;</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"
>
&#10005;
</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}

View File

@@ -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) => (

View File

@@ -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 ?? ''))

View File

@@ -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" />

View File

@@ -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={
detailUrl ? (
<Link <Link
to={detailUrl} to={detailUrl}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline" className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
> >
View Routes & Bosses View Routes & Bosses
</Link> </Link>
) : undefined} ) : 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>

View File

@@ -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,12 +237,19 @@ 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...
</p>
)} )}
{!evolutionsLoading && (!evolutionChain || evolutionChain.length === 0) && ( {!evolutionsLoading &&
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions</p> (!evolutionChain || evolutionChain.length === 0) && (
<p className="text-sm text-gray-500 dark:text-gray-400">
No evolutions
</p>
)} )}
{!evolutionsLoading && evolutionChain && evolutionChain.length > 0 && ( {!evolutionsLoading &&
evolutionChain &&
evolutionChain.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
{evolutionChain.map((evo) => ( {evolutionChain.map((evo) => (
<button <button
@@ -237,12 +284,19 @@ 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">
No encounters
</p>
)} )}
{!encountersLoading && encounterLocations && encounterLocations.length > 0 && ( {!encountersLoading &&
encounterLocations &&
encounterLocations.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
{encounterLocations.map((game) => ( {encounterLocations.map((game) => (
<div key={game.gameId}> <div key={game.gameId}>
@@ -251,7 +305,10 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
</div> </div>
<div className="space-y-0.5 pl-2"> <div className="space-y-0.5 pl-2">
{game.encounters.map((enc, i) => ( {game.encounters.map((enc, i) => (
<div key={i} className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1"> <div
key={i}
className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1"
>
<Link <Link
to={`/admin/games/${game.gameId}/routes/${enc.routeId}`} to={`/admin/games/${game.gameId}/routes/${enc.routeId}`}
className="text-blue-600 dark:text-blue-400 hover:underline" className="text-blue-600 dark:text-blue-400 hover:underline"
@@ -259,7 +316,8 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
{enc.routeName} {enc.routeName}
</Link> </Link>
<span className="text-gray-400 dark:text-gray-500"> <span className="text-gray-400 dark:text-gray-500">
{enc.encounterMethod}, Lv. {enc.minLevel}{enc.maxLevel} {enc.encounterMethod}, Lv. {enc.minLevel}
{enc.maxLevel}
</span> </span>
</div> </div>
))} ))}
@@ -294,7 +352,7 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
setEditingEvolution(null) setEditingEvolution(null)
invalidateChain() invalidateChain()
}, },
}, }
) )
} }
onClose={() => setEditingEvolution(null)} onClose={() => setEditingEvolution(null)}

View File

@@ -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

View File

@@ -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={
detailUrl ? (
<Link <Link
to={detailUrl} to={detailUrl}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline" className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
> >
View Encounters View Encounters
</Link> </Link>
) : undefined} ) : 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>

View File

@@ -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] })

View File

@@ -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) {

View File

@@ -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] })
}, },

View File

@@ -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'] })

View File

@@ -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({

View File

@@ -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),

View File

@@ -1 +1 @@
@import "tailwindcss"; @import 'tailwindcss';

View File

@@ -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>
) )

View File

@@ -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}

View File

@@ -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',
} }

View File

@@ -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',
} }

View File

@@ -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,9 +250,15 @@ 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 &&
preset !== 'custom' &&
availableRegions.length > 0 &&
legs.length > 0 && (
<div className="mt-4"> <div className="mt-4">
<AddLegDropdown regions={availableRegions} onAdd={handleAddLeg} /> <AddLegDropdown
regions={availableRegions}
onAdd={handleAddLeg}
/>
</div> </div>
)} )}
@@ -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>

View File

@@ -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>

View File

@@ -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} &middot; {run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} &middot; Started{' '} {run.game.name} &middot;{' '}
{run.game.region.charAt(0).toUpperCase() +
run.game.region.slice(1)}{' '}
&middot; 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)}

View File

@@ -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,11 +508,17 @@ 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(() => {
if (!run)
return {
normalEncounters: [],
shinyEncounters: [],
transferEncounters: [],
}
const normal: EncounterDetail[] = [] const normal: EncounterDetail[] = []
const shiny: EncounterDetail[] = [] const shiny: EncounterDetail[] = []
const transfer: EncounterDetail[] = [] const transfer: EncounterDetail[] = []
@@ -492,7 +531,11 @@ export function RunEncounters() {
normal.push(enc) normal.push(enc)
} }
} }
return { normalEncounters: normal, shinyEncounters: shiny, transferEncounters: transfer } return {
normalEncounters: normal,
shinyEncounters: shiny,
transferEncounters: transfer,
}
}, [run, transferIdSet]) }, [run, transferIdSet])
// Map routeId → encounter for quick lookup (normal encounters only) // Map routeId → encounter for quick lookup (normal encounters only)
@@ -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( () =>
sortEncounters(
[...normalEncounters, ...transferEncounters, ...shinyEncounters].filter( [...normalEncounters, ...transferEncounters, ...shinyEncounters].filter(
(e) => e.status === 'caught' && e.faintLevel === null, (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} &middot; {run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} &middot; Started{' '} {run.game.name} &middot;{' '}
{run.game.region.charAt(0).toUpperCase() +
run.game.region.slice(1)}{' '}
&middot; 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} &mdash; {run.genlocke.genlockeName} Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} &mdash;{' '}
{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,31 +959,38 @@ export function RunEncounters() {
</p> </p>
</div> </div>
</div> </div>
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && ( {run.status === 'completed' &&
run.genlocke &&
!run.genlocke.isFinalLeg && (
<button <button
onClick={() => { onClick={() => {
if (hofTeam && hofTeam.length > 0) { if (hofTeam && hofTeam.length > 0) {
setShowTransferModal(true) setShowTransferModal(true)
} else { } else {
advanceLeg.mutate( advanceLeg.mutate(
{ genlockeId: run.genlocke!.genlockeId, legOrder: run.genlocke!.legOrder }, {
genlockeId: run.genlocke!.genlockeId,
legOrder: run.genlocke!.legOrder,
},
{ {
onSuccess: (genlocke) => { onSuccess: (genlocke) => {
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}`)
} }
}, },
}, }
) )
} }
}} }}
disabled={advanceLeg.isPending} disabled={advanceLeg.isPending}
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors" className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
> >
{advanceLeg.isPending ? 'Advancing...' : 'Advance to Next Leg'} {advanceLeg.isPending
? 'Advancing...'
: 'Advance to Next Leg'}
</button> </button>
)} )}
</div> </div>
@@ -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
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
earned earned
? 'border-yellow-500 bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300' ? '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' : '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,7 +1324,8 @@ 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.children.length > 0
? [route.id, ...route.children.map((c) => c.id)] ? [route.id, ...route.children.map((c) => c.id)]
: [route.id] : [route.id]
@@ -1253,7 +1336,8 @@ export function RunEncounters() {
if (b) bossesHere.push(...b) if (b) bossesHere.push(...b)
} }
const routeElement = route.children.length > 0 ? ( const routeElement =
route.children.length > 0 ? (
<RouteGroup <RouteGroup
key={route.id} key={route.id}
group={route} group={route}
@@ -1264,7 +1348,8 @@ export function RunEncounters() {
filter={filter} filter={filter}
pinwheelClause={pinwheelClause} pinwheelClause={pinwheelClause}
/> />
) : (() => { ) : (
(() => {
const encounter = encounterByRoute.get(route.id) const encounter = encounterByRoute.get(route.id)
const rs = getRouteStatus(encounter) const rs = getRouteStatus(encounter)
const si = statusIndicator[rs] const si = statusIndicator[rs]
@@ -1301,12 +1386,18 @@ export function RunEncounters() {
: ' (dead)')} : ' (dead)')}
</span> </span>
</div> </div>
) : route.encounterMethods.length > 0 && ( ) : (
route.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5"> <div className="flex flex-wrap gap-1 mt-0.5">
{route.encounterMethods.map((m) => ( {route.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" /> <EncounterMethodBadge
key={m}
method={m}
size="xs"
/>
))} ))}
</div> </div>
)
)} )}
</div> </div>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0"> <span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
@@ -1315,6 +1406,7 @@ export function RunEncounters() {
</button> </button>
) )
})() })()
)
return ( return (
<div key={route.id}> <div key={route.id}>
@@ -1358,7 +1450,9 @@ 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
@@ -1373,10 +1467,18 @@ export function RunEncounters() {
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth={2}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
</svg> </svg>
{boss.spriteUrl && ( {boss.spriteUrl && (
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" /> <img
src={boss.spriteUrl}
alt={boss.name}
className="h-10 w-auto"
/>
)} )}
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1391,7 +1493,8 @@ export function RunEncounters() {
)} )}
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
{boss.location} &middot; Level Cap: {boss.levelCap} {boss.location} &middot; Level Cap:{' '}
{boss.levelCap}
</p> </p>
</div> </div>
</div> </div>
@@ -1412,13 +1515,18 @@ export function RunEncounters() {
</div> </div>
{/* Boss pokemon team */} {/* Boss pokemon team */}
{isBossExpanded && boss.pokemon.length > 0 && ( {isBossExpanded && boss.pokemon.length > 0 && (
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} /> <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}

View File

@@ -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',
} }

View File

@@ -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>
)} )}

View File

@@ -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)}

View File

@@ -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,7 +233,8 @@ 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) const g = games.find((g) => g.id === boss.gameId)
return g ? ( return g ? (
<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 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">
@@ -221,9 +247,15 @@ function SortableBossRow({
{boss.bossType.replace('_', ' ')} {boss.bossType.replace('_', ' ')}
</td> </td>
<td className="px-4 py-3 text-sm whitespace-nowrap"> <td className="px-4 py-3 text-sm whitespace-nowrap">
{boss.specialtyType ? <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)} &middot; Gen {game.generation} {game.region.charAt(0).toUpperCase() + game.region.slice(1)} &middot;
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}
/> />

View File

@@ -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)}

View File

@@ -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">&mdash;</span> <span className="text-gray-400">&mdash;</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)}%`

View File

@@ -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)}

View File

@@ -42,22 +42,27 @@ 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 =
childRoutes.length > 0
? Math.max(...childRoutes.map((r) => r.order)) + 1 ? Math.max(...childRoutes.map((r) => r.order)) + 1
: (route?.order ?? 0) * 10 + 1 : (route?.order ?? 0) * 10 + 1
@@ -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(
{ ...data, gameId: gId } as CreateRouteEncounterInput,
{
onSuccess: () => setShowCreate(false), 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)}

View File

@@ -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}
/> />

View File

@@ -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
@@ -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

View File

@@ -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

View File

@@ -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)