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
run: npm run lint
working-directory: frontend
- name: Check formatting
run: npx prettier --check "src/**/*.{ts,tsx,css,json}"
working-directory: frontend
- name: Type check
run: npx tsc -b
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).
- 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
- 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]
dev = [
"ruff>=0.9.0",
"pre-commit>=4.0.0",
"pytest>=8.0.0",
"pytest-asyncio>=0.25.0",
"httpx>=0.28.0",

View File

@@ -1,7 +1,16 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { Layout } from './components'
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 {
AdminGames,
AdminGameDetail,
@@ -25,17 +34,26 @@ function App() {
<Route path="genlockes/new" element={<NewGenlocke />} />
<Route path="genlockes/:genlockeId" element={<GenlockeDetail />} />
<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 index element={<Navigate to="/admin/games" replace />} />
<Route path="games" element={<AdminGames />} />
<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="evolutions" element={<AdminEvolutions />} />
<Route path="runs" element={<AdminRuns />} />
<Route path="genlockes" element={<AdminGenlockes />} />
<Route path="genlockes/:genlockeId" element={<AdminGenlockeDetail />} />
<Route
path="genlockes/:genlockeId"
element={<AdminGenlockeDetail />}
/>
</Route>
</Route>
</Routes>

View File

@@ -36,15 +36,17 @@ export const createGame = (data: CreateGameInput) =>
export const updateGame = (id: number, data: UpdateGameInput) =>
api.put<Game>(`/games/${id}`, data)
export const deleteGame = (id: number) =>
api.del(`/games/${id}`)
export const deleteGame = (id: number) => api.del(`/games/${id}`)
// Routes
export const createRoute = (gameId: number, data: CreateRouteInput) =>
api.post<Route>(`/games/${gameId}/routes`, data)
export const updateRoute = (gameId: number, routeId: number, data: UpdateRouteInput) =>
api.put<Route>(`/games/${gameId}/routes/${routeId}`, data)
export const updateRoute = (
gameId: number,
routeId: number,
data: UpdateRouteInput
) => api.put<Route>(`/games/${gameId}/routes/${routeId}`, data)
export const deleteRoute = (gameId: number, routeId: number) =>
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 })
// 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()
if (search) params.set('search', search)
if (type) params.set('type', type)
@@ -68,11 +75,17 @@ export const createPokemon = (data: CreatePokemonInput) =>
export const updatePokemon = (id: number, data: UpdatePokemonInput) =>
api.put<Pokemon>(`/pokemon/${id}`, data)
export const deletePokemon = (id: number) =>
api.del(`/pokemon/${id}`)
export const deletePokemon = (id: number) => api.del(`/pokemon/${id}`)
export const bulkImportPokemon = (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
api.post<BulkImportResult>('/pokemon/bulk-import', items)
export const bulkImportPokemon = (
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[]) =>
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)
// 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()
if (search) params.set('search', search)
if (trigger) params.set('trigger', trigger)
@@ -99,8 +117,7 @@ export const createEvolution = (data: CreateEvolutionInput) =>
export const updateEvolution = (id: number, data: UpdateEvolutionInput) =>
api.put<EvolutionAdmin>(`/evolutions/${id}`, data)
export const deleteEvolution = (id: number) =>
api.del(`/evolutions/${id}`)
export const deleteEvolution = (id: number) => api.del(`/evolutions/${id}`)
// Export
export const exportGames = () =>
@@ -119,11 +136,20 @@ export const exportEvolutions = () =>
api.get<Record<string, unknown>[]>('/export/evolutions')
// Route Encounters
export const addRouteEncounter = (routeId: number, data: CreateRouteEncounterInput) =>
api.post<RouteEncounterDetail>(`/routes/${routeId}/pokemon`, data)
export const addRouteEncounter = (
routeId: number,
data: CreateRouteEncounterInput
) => api.post<RouteEncounterDetail>(`/routes/${routeId}/pokemon`, data)
export const updateRouteEncounter = (routeId: number, encounterId: number, data: UpdateRouteEncounterInput) =>
api.put<RouteEncounterDetail>(`/routes/${routeId}/pokemon/${encounterId}`, data)
export const updateRouteEncounter = (
routeId: number,
encounterId: number,
data: UpdateRouteEncounterInput
) =>
api.put<RouteEncounterDetail>(
`/routes/${routeId}/pokemon/${encounterId}`,
data
)
export const removeRouteEncounter = (routeId: number, encounterId: number) =>
api.del(`/routes/${routeId}/pokemon/${encounterId}`)
@@ -132,8 +158,11 @@ export const removeRouteEncounter = (routeId: number, encounterId: number) =>
export const createBossBattle = (gameId: number, data: CreateBossBattleInput) =>
api.post<BossBattle>(`/games/${gameId}/bosses`, data)
export const updateBossBattle = (gameId: number, bossId: number, data: UpdateBossBattleInput) =>
api.put<BossBattle>(`/games/${gameId}/bosses/${bossId}`, data)
export const updateBossBattle = (
gameId: number,
bossId: number,
data: UpdateBossBattleInput
) => api.put<BossBattle>(`/games/${gameId}/bosses/${bossId}`, data)
export const deleteBossBattle = (gameId: number, bossId: number) =>
api.del(`/games/${gameId}/bosses/${bossId}`)
@@ -141,15 +170,17 @@ export const deleteBossBattle = (gameId: number, bossId: number) =>
export const reorderBosses = (gameId: number, bosses: BossReorderItem[]) =>
api.put<BossBattle[]>(`/games/${gameId}/bosses/reorder`, { bosses })
export const setBossTeam = (gameId: number, bossId: number, team: BossPokemonInput[]) =>
api.put<BossBattle>(`/games/${gameId}/bosses/${bossId}/pokemon`, team)
export const setBossTeam = (
gameId: number,
bossId: number,
team: BossPokemonInput[]
) => api.put<BossBattle>(`/games/${gameId}/bosses/${bossId}/pokemon`, team)
// Genlockes
export const updateGenlocke = (id: number, data: UpdateGenlockeInput) =>
api.patch<Genlocke>(`/genlockes/${id}`, data)
export const deleteGenlocke = (id: number) =>
api.del(`/genlockes/${id}`)
export const deleteGenlocke = (id: number) => api.del(`/genlockes/${id}`)
export const addGenlockeLeg = (genlockeId: number, data: AddGenlockeLegInput) =>
api.post<Genlocke>(`/genlockes/${genlockeId}/legs`, data)

View File

@@ -1,7 +1,14 @@
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' : ''
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`)
}
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)
}
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}`)
}

View File

@@ -10,10 +10,7 @@ export class ApiError extends Error {
}
}
async function request<T>(
path: string,
options?: RequestInit,
): Promise<T> {
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}/api/v1${path}`, {
...options,
headers: {
@@ -52,6 +49,5 @@ export const api = {
body: JSON.stringify(body),
}),
del: <T = void>(path: string) =>
request<T>(path, { method: 'DELETE' }),
del: <T = void>(path: string) => request<T>(path, { method: 'DELETE' }),
}

View File

@@ -9,14 +9,14 @@ import type {
export function createEncounter(
runId: number,
data: CreateEncounterInput,
data: CreateEncounterInput
): Promise<EncounterDetail> {
return api.post(`/runs/${runId}/encounters`, data)
}
export function updateEncounter(
id: number,
data: UpdateEncounterInput,
data: UpdateEncounterInput
): Promise<EncounterDetail> {
return api.patch(`/encounters/${id}`, data)
}
@@ -25,7 +25,10 @@ export function deleteEncounter(id: number): Promise<void> {
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)}` : ''
return api.get(`/pokemon/${pokemonId}/evolutions${params}`)
}
@@ -34,6 +37,8 @@ export function fetchForms(pokemonId: number): Promise<Pokemon[]> {
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`, {})
}

View File

@@ -19,7 +19,10 @@ export function getGameRoutes(gameId: number): Promise<Route[]> {
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}` : ''
return api.get(`/routes/${routeId}/pokemon${params}`)
}

View File

@@ -1,5 +1,15 @@
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[]> {
return api.get('/genlockes')
@@ -25,10 +35,20 @@ export function getGenlockeLineages(id: number): Promise<GenlockeLineage> {
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`)
}
export function advanceLeg(genlockeId: number, legOrder: number, data?: AdvanceLegInput): Promise<Genlocke> {
return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, data ?? {})
export function advanceLeg(
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')
}
export function fetchPokemonEncounterLocations(pokemonId: number): Promise<PokemonEncounterLocation[]> {
export function fetchPokemonEncounterLocations(
pokemonId: number
): Promise<PokemonEncounterLocation[]> {
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`)
}

View File

@@ -20,7 +20,7 @@ export function createRun(data: CreateRunInput): Promise<NuzlockeRun> {
export function updateRun(
id: number,
data: UpdateRunInput,
data: UpdateRunInput
): Promise<NuzlockeRun> {
return api.patch(`/runs/${id}`, data)
}
@@ -33,7 +33,11 @@ export function getNamingCategories(): Promise<string[]> {
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}`
if (pokemonId != null) {
url += `&pokemon_id=${pokemonId}`

View File

@@ -10,14 +10,24 @@ interface BossDefeatModalProps {
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
const lower = starterName.toLowerCase()
const matches = labels.filter((l) => l.toLowerCase().includes(lower))
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 [attempts, setAttempts] = useState('1')
@@ -30,16 +40,20 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
}, [boss.pokemon])
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 [selectedVariant, setSelectedVariant] = useState<string | null>(
autoMatch ?? (hasVariants ? variantLabels[0] : null),
autoMatch ?? (hasVariants ? variantLabels[0] : null)
)
const displayedPokemon = useMemo(() => {
if (!hasVariants) return boss.pokemon
return boss.pokemon.filter(
(bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null,
(bp) =>
bp.conditionLabel === selectedVariant || bp.conditionLabel === null
)
}, [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="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<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>
{/* Boss team preview */}
@@ -88,7 +104,11 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
.map((bp) => (
<div key={bp.id} className="flex flex-col items-center">
{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" />
)}
@@ -138,7 +158,9 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
{!hardcoreMode && (
<div>
<label className="block text-sm font-medium mb-1">Attempts</label>
<label className="block text-sm font-medium mb-1">
Attempts
</label>
<input
type="number"
min={1}

View File

@@ -31,8 +31,10 @@ export function EggEncounterModal({
const [isSearching, setIsSearching] = useState(false)
// Only show leaf routes (no children)
const parentIds = new Set(routes.filter(r => r.parentRouteId !== null).map(r => r.parentRouteId))
const leafRoutes = routes.filter(r => !parentIds.has(r.id))
const parentIds = new Set(
routes.filter((r) => r.parentRouteId !== null).map((r) => r.parentRouteId)
)
const leafRoutes = routes.filter((r) => !parentIds.has(r.id))
// Debounced pokemon search
useEffect(() => {
@@ -44,7 +46,9 @@ export function EggEncounterModal({
const timer = setTimeout(async () => {
setIsSearching(true)
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)
} catch {
setSearchResults([])
@@ -196,7 +200,9 @@ export function EggEncounterModal({
))}
</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">
No pokemon found
</p>

View File

@@ -69,14 +69,15 @@ export const METHOD_ORDER = [
export function getMethodLabel(method: string): string {
return (
METHOD_CONFIG[method]?.label ??
method
.replace(/-/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
method.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
)
}
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({
@@ -88,7 +89,8 @@ export function EncounterMethodBadge({
}) {
const config = METHOD_CONFIG[method]
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 (
<span
className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}

View File

@@ -42,8 +42,11 @@ interface EncounterModalProps {
isPending: boolean
}
const statusOptions: { value: EncounterStatus; label: string; color: string }[] =
[
const statusOptions: {
value: EncounterStatus
label: string
color: string
}[] = [
{
value: 'caught',
label: 'Caught',
@@ -62,11 +65,13 @@ const statusOptions: { value: EncounterStatus; label: string; color: string }[]
color:
'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600',
},
]
]
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokemon: RouteEncounterDetail[] }[] {
function groupByMethod(
pokemon: RouteEncounterDetail[]
): { method: string; pokemon: RouteEncounterDetail[] }[] {
const groups = new Map<string, RouteEncounterDetail[]>()
for (const rp of pokemon) {
const list = groups.get(rp.encounterMethod) ?? []
@@ -84,7 +89,7 @@ function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokem
function pickRandomPokemon(
pokemon: RouteEncounterDetail[],
dupedIds?: Set<number>,
dupedIds?: Set<number>
): RouteEncounterDetail | null {
const eligible = dupedIds
? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId))
@@ -109,17 +114,17 @@ export function EncounterModal({
}: EncounterModalProps) {
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
route.id,
gameId,
gameId
)
const [selectedPokemon, setSelectedPokemon] =
useState<RouteEncounterDetail | null>(null)
const [status, setStatus] = useState<EncounterStatus>(
existing?.status ?? 'caught',
existing?.status ?? 'caught'
)
const [nickname, setNickname] = useState(existing?.nickname ?? '')
const [catchLevel, setCatchLevel] = useState<string>(
existing?.catchLevel?.toString() ?? '',
existing?.catchLevel?.toString() ?? ''
)
const [faintLevel, setFaintLevel] = useState<string>('')
const [deathCause, setDeathCause] = useState('')
@@ -128,27 +133,31 @@ export function EncounterModal({
const isEditing = !!existing
const showSuggestions = !!namingScheme && status === 'caught' && !isEditing
const lineagePokemonId = isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
const { data: suggestions, refetch: regenerate, isFetching: loadingSuggestions } =
useNameSuggestions(showSuggestions ? runId : null, lineagePokemonId)
const lineagePokemonId =
isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
const {
data: suggestions,
refetch: regenerate,
isFetching: loadingSuggestions,
} = useNameSuggestions(showSuggestions ? runId : null, lineagePokemonId)
// Pre-select pokemon when editing
useEffect(() => {
if (existing && routePokemon) {
const match = routePokemon.find(
(rp) => rp.pokemonId === existing.pokemonId,
(rp) => rp.pokemonId === existing.pokemonId
)
if (match) setSelectedPokemon(match)
}
}, [existing, routePokemon])
const filteredPokemon = routePokemon?.filter((rp) =>
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
rp.pokemon.name.toLowerCase().includes(search.toLowerCase())
)
const groupedPokemon = useMemo(
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
[filteredPokemon],
[filteredPokemon]
)
const hasMultipleGroups = groupedPokemon.length > 1
@@ -224,13 +233,15 @@ export function EncounterModal({
loadingPokemon ||
!routePokemon ||
(dupedPokemonIds
? routePokemon.every((rp) => dupedPokemonIds.has(rp.pokemonId))
? routePokemon.every((rp) =>
dupedPokemonIds.has(rp.pokemonId)
)
: false)
}
onClick={() => {
if (routePokemon) {
setSelectedPokemon(
pickRandomPokemon(routePokemon, dupedPokemonIds),
pickRandomPokemon(routePokemon, dupedPokemonIds)
)
}
}}
@@ -268,12 +279,15 @@ export function EncounterModal({
)}
<div className="grid grid-cols-3 gap-2">
{pokemon.map((rp) => {
const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
const isDuped =
dupedPokemonIds?.has(rp.pokemonId) ?? false
return (
<button
key={rp.id}
type="button"
onClick={() => !isDuped && setSelectedPokemon(rp)}
onClick={() =>
!isDuped && setSelectedPokemon(rp)
}
disabled={isDuped}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
isDuped
@@ -299,16 +313,24 @@ export function EncounterModal({
</span>
{isDuped && (
<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>
)}
{!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && (
<EncounterMethodBadge method={rp.encounterMethod} />
{!isDuped &&
SPECIAL_METHODS.includes(
rp.encounterMethod
) && (
<EncounterMethodBadge
method={rp.encounterMethod}
/>
)}
{!isDuped && (
<span className="text-[10px] text-gray-400">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
{rp.maxLevel !== rp.minLevel &&
`${rp.maxLevel}`}
</span>
)}
</button>
@@ -518,11 +540,7 @@ export function EncounterModal({
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"
>
{isPending
? 'Saving...'
: isEditing
? 'Update'
: 'Log Encounter'}
{isPending ? 'Saving...' : isEditing ? 'Update' : 'Log Encounter'}
</button>
</div>
</div>

View File

@@ -7,7 +7,12 @@ interface EndRunModalProps {
genlockeContext?: RunGenlockeContext | null
}
export function EndRunModal({ onConfirm, onClose, isPending, genlockeContext }: EndRunModalProps) {
export function EndRunModal({
onConfirm,
onClose,
isPending,
genlockeContext,
}: EndRunModalProps) {
const victoryDescription = genlockeContext
? genlockeContext.isFinalLeg
? 'Complete the final leg of your genlocke!'

View File

@@ -29,32 +29,46 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
const generations = useMemo(
() => [...new Set(games.map((g) => g.generation))].sort(),
[games],
[games]
)
const regions = useMemo(
() => [...new Set(games.map((g) => g.region))].sort(),
[games],
[games]
)
const activeRunGameIds = useMemo(() => {
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])
const completedRunGameIds = useMemo(() => {
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])
const filtered = useMemo(() => {
let result = games
if (filter) result = result.filter((g) => g.generation === filter)
if (regionFilter) result = result.filter((g) => g.region === regionFilter)
if (hideWithActiveRun) result = result.filter((g) => !activeRunGameIds.has(g.id))
if (hideCompleted) result = result.filter((g) => !completedRunGameIds.has(g.id))
if (hideWithActiveRun)
result = result.filter((g) => !activeRunGameIds.has(g.id))
if (hideCompleted)
result = result.filter((g) => !completedRunGameIds.has(g.id))
return result
}, [games, filter, regionFilter, hideWithActiveRun, hideCompleted, activeRunGameIds, completedRunGameIds])
}, [
games,
filter,
regionFilter,
hideWithActiveRun,
hideCompleted,
activeRunGameIds,
completedRunGameIds,
])
const grouped = useMemo(() => {
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-3">
<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
type="button"
onClick={() => setFilter(null)}
@@ -98,7 +114,9 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
</div>
<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
type="button"
onClick={() => setRegionFilter(null)}

View File

@@ -134,7 +134,8 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
</span>
{data.deadliestLeg && (
<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>
)}
</div>
@@ -143,7 +144,9 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
<div className="flex flex-wrap items-center gap-3">
<select
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"
>
<option value="">All Legs</option>

View File

@@ -28,7 +28,9 @@ function LegDot({ leg }: { leg: LineageLegEntry }) {
return (
<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 */}
<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="flex items-center gap-1.5">
{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>
</div>
{leg.catchLevel !== null && (
<div>Caught Lv. {leg.catchLevel}</div>
)}
{leg.catchLevel !== null && <div>Caught Lv. {leg.catchLevel}</div>}
{leg.faintLevel !== null && (
<div className="text-red-300">Died Lv. {leg.faintLevel}</div>
)}
{leg.deathCause && (
<div className="text-red-300 italic">{leg.deathCause}</div>
)}
<div className={`font-medium ${
leg.faintLevel !== null ? 'text-red-300' :
leg.wasTransferred ? 'text-blue-300' :
leg.enteredHof ? 'text-yellow-300' :
'text-green-300'
}`}>
<div
className={`font-medium ${
leg.faintLevel !== null
? 'text-red-300'
: leg.wasTransferred
? 'text-blue-300'
: leg.enteredHof
? 'text-yellow-300'
: 'text-green-300'
}`}
>
{label}
</div>
{leg.enteredHof && leg.faintLevel === null && (
@@ -185,9 +194,11 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
const allLegOrders = useMemo(() => {
if (!data) return []
return [...new Set(data.lineages.flatMap((l) => l.legs.map((leg) => leg.legOrder)))].sort(
(a, b) => a - b
)
return [
...new Set(
data.lineages.flatMap((l) => l.legs.map((leg) => leg.legOrder))
),
].sort((a, b) => a - b)
}, [data])
const legGameNames = useMemo(() => {
@@ -230,8 +241,8 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
{/* Summary bar */}
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="font-semibold text-gray-900 dark:text-gray-100">
{data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''} across{' '}
{allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
{data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''}{' '}
across {allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
</span>
</div>

View File

@@ -8,7 +8,12 @@ interface HofTeamModalProps {
isPending: boolean
}
export function HofTeamModal({ alive, onSubmit, onSkip, isPending }: HofTeamModalProps) {
export function HofTeamModal({
alive,
onSubmit,
onSkip,
isPending,
}: HofTeamModalProps) {
const [selected, setSelected] = useState<Set<number>>(() => {
// Pre-select all if 6 or fewer
if (alive.length <= 6) return new Set(alive.map((e) => e.id))

View File

@@ -7,8 +7,20 @@ interface PokemonCardProps {
onClick?: () => void
}
export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardProps) {
const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } = encounter
export function PokemonCard({
encounter,
showFaintLevel,
onClick,
}: PokemonCardProps) {
const {
pokemon,
currentPokemon,
route,
nickname,
catchLevel,
faintLevel,
deathCause,
} = encounter
const isDead = faintLevel !== null
const displayPokemon = currentPokemon ?? pokemon
const isEvolved = currentPokemon !== null

View File

@@ -22,7 +22,9 @@ export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={onEncounterClick ? () => onEncounterClick(enc) : undefined}
onClick={
onEncounterClick ? () => onEncounterClick(enc) : undefined
}
/>
))}
</div>

View File

@@ -24,7 +24,9 @@ interface ShinyEncounterModalProps {
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[]>()
for (const rp of pokemon) {
const list = groups.get(rp.encounterMethod) ?? []
@@ -50,7 +52,7 @@ export function ShinyEncounterModal({
const [selectedRouteId, setSelectedRouteId] = useState<number | null>(null)
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
selectedRouteId,
gameId,
gameId
)
const [selectedPokemon, setSelectedPokemon] =
@@ -60,12 +62,12 @@ export function ShinyEncounterModal({
const [search, setSearch] = useState('')
const filteredPokemon = routePokemon?.filter((rp) =>
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
rp.pokemon.name.toLowerCase().includes(search.toLowerCase())
)
const groupedPokemon = useMemo(
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
[filteredPokemon],
[filteredPokemon]
)
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)
const parentIds = new Set(routes.filter(r => r.parentRouteId !== null).map(r => r.parentRouteId))
const leafRoutes = routes.filter(r => !parentIds.has(r.id))
const parentIds = new Set(
routes.filter((r) => r.parentRouteId !== null).map((r) => r.parentRouteId)
)
const leafRoutes = routes.filter((r) => !parentIds.has(r.id))
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
@@ -206,11 +210,14 @@ export function ShinyEncounterModal({
{rp.pokemon.name}
</span>
{SPECIAL_METHODS.includes(rp.encounterMethod) && (
<EncounterMethodBadge method={rp.encounterMethod} />
<EncounterMethodBadge
method={rp.encounterMethod}
/>
)}
<span className="text-[10px] text-gray-400">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
{rp.maxLevel !== rp.minLevel &&
`${rp.maxLevel}`}
</span>
</button>
))}

View File

@@ -1,15 +1,16 @@
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 { TypeBadge } from './TypeBadge'
import { formatEvolutionMethod } from '../utils/formatEvolution'
interface StatusChangeModalProps {
encounter: EncounterDetail
onUpdate: (data: {
id: number
data: UpdateEncounterInput
}) => void
onUpdate: (data: { id: number; data: UpdateEncounterInput }) => void
onClose: () => void
isPending: boolean
region?: string
@@ -24,15 +25,24 @@ export function StatusChangeModal({
region,
onCreateEncounter,
}: StatusChangeModalProps) {
const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } =
encounter
const {
pokemon,
currentPokemon,
route,
nickname,
catchLevel,
faintLevel,
deathCause,
} = encounter
const isDead = faintLevel !== null
const displayPokemon = currentPokemon ?? pokemon
const [showConfirm, setShowConfirm] = useState(false)
const [showEvolve, setShowEvolve] = useState(false)
const [showFormChange, setShowFormChange] = 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 [deathLevel, setDeathLevel] = useState('')
const [cause, setCause] = useState('')
@@ -40,15 +50,15 @@ export function StatusChangeModal({
const activePokemonId = currentPokemon?.id ?? pokemon.id
const { data: evolutions, isLoading: evolutionsLoading } = useEvolutions(
showEvolve || showShedConfirm ? activePokemonId : null,
region,
region
)
const { data: forms } = useForms(isDead ? null : activePokemonId)
const { normalEvolutions, shedCompanion } = useMemo(() => {
if (!evolutions) return { normalEvolutions: [], shedCompanion: null }
return {
normalEvolutions: evolutions.filter(e => e.trigger !== 'shed'),
shedCompanion: evolutions.find(e => e.trigger === 'shed') ?? null,
normalEvolutions: evolutions.filter((e) => e.trigger !== 'shed'),
shedCompanion: evolutions.find((e) => e.trigger === 'shed') ?? null,
}
}, [evolutions])
@@ -187,7 +197,11 @@ export function StatusChangeModal({
)}
{/* Alive pokemon: actions */}
{!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm && (
{!isDead &&
!showConfirm &&
!showEvolve &&
!showFormChange &&
!showShedConfirm && (
<div className="flex gap-3">
<button
type="button"
@@ -231,10 +245,14 @@ export function StatusChangeModal({
</button>
</div>
{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 && (
<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 && (
<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"
>
{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">
{evo.toPokemon.name[0].toUpperCase()}
@@ -302,8 +324,12 @@ export function StatusChangeModal({
</div>
)}
<p className="text-sm text-amber-800 dark:text-amber-300">
{displayPokemon.name} shed its shell! Would you also like to add{' '}
<span className="font-semibold">{shedCompanion.toPokemon.name}</span>?
{displayPokemon.name} shed its shell! Would you also like to
add{' '}
<span className="font-semibold">
{shedCompanion.toPokemon.name}
</span>
?
</p>
</div>
</div>
@@ -340,7 +366,9 @@ export function StatusChangeModal({
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"
>
{isPending ? 'Saving...' : `Add ${shedCompanion.toPokemon.name}`}
{isPending
? 'Saving...'
: `Add ${shedCompanion.toPokemon.name}`}
</button>
</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"
>
{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">
{form.name[0].toUpperCase()}
@@ -465,7 +497,12 @@ export function StatusChangeModal({
</div>
{/* 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">
<button
type="button"

View File

@@ -6,7 +6,11 @@ interface StepIndicatorProps {
steps?: string[]
}
export function StepIndicator({ currentStep, onStepClick, steps = DEFAULT_STEPS }: StepIndicatorProps) {
export function StepIndicator({
currentStep,
onStepClick,
steps = DEFAULT_STEPS,
}: StepIndicatorProps) {
return (
<nav aria-label="Progress" className="mb-8">
<ol className="flex items-center">

View File

@@ -8,9 +8,14 @@ interface TransferModalProps {
isPending: boolean
}
export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: TransferModalProps) {
export function TransferModal({
hofTeam,
onSubmit,
onSkip,
isPending,
}: TransferModalProps) {
const [selected, setSelected] = useState<Set<number>>(
() => new Set(hofTeam.map((e) => e.id)),
() => new Set(hofTeam.map((e) => e.id))
)
const toggle = (id: number) => {
@@ -34,7 +39,8 @@ export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: Transfer
Transfer Pokemon to Next Leg
</h2>
<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>
</div>

View File

@@ -6,10 +6,6 @@ interface TypeBadgeProps {
export function TypeBadge({ type, size = 'sm' }: TypeBadgeProps) {
const height = size === 'md' ? 'h-5' : 'h-4'
return (
<img
src={`/types/${type}.png`}
alt={type}
className={`${height} w-auto`}
/>
<img 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) => (
<tr key={i}>
{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" />
</td>
))}
@@ -111,7 +114,9 @@ export function AdminTable<T>({
return (
<th
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' : ''}`}
>
<span className="inline-flex items-center gap-1">
@@ -132,7 +137,11 @@ export function AdminTable<T>({
<tr
key={keyFn(row)}
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) => (
<td

View File

@@ -1,7 +1,10 @@
import { type FormEvent, useState } from 'react'
import { FormModal } from './FormModal'
import type { BossBattle, Game, Route } from '../../types/game'
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
import type {
CreateBossBattleInput,
UpdateBossBattleInput,
} from '../../types/admin'
interface BossBattleFormModalProps {
boss?: BossBattle
@@ -17,9 +20,24 @@ interface BossBattleFormModalProps {
}
const POKEMON_TYPES = [
'normal', 'fire', 'water', 'electric', 'grass', 'ice',
'fighting', 'poison', 'ground', 'flying', 'psychic', 'bug',
'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy',
'normal',
'fire',
'water',
'electric',
'grass',
'ice',
'fighting',
'poison',
'ground',
'flying',
'psychic',
'bug',
'rock',
'ghost',
'dragon',
'dark',
'steel',
'fairy',
]
const BOSS_TYPES = [
@@ -52,7 +70,9 @@ export function BossBattleFormModal({
const [badgeImageUrl, setBadgeImageUrl] = useState(boss?.badgeImageUrl ?? '')
const [levelCap, setLevelCap] = useState(String(boss?.levelCap ?? ''))
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 [section, setSection] = useState(boss?.section ?? '')
const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '')
@@ -87,7 +107,8 @@ export function BossBattleFormModal({
isSubmitting={isSubmitting}
onDelete={onDelete}
isDeleting={isDeleting}
headerExtra={onEditTeam ? (
headerExtra={
onEditTeam ? (
<button
type="button"
onClick={onEditTeam}
@@ -95,7 +116,8 @@ export function BossBattleFormModal({
>
Edit Team ({boss?.pokemon.length ?? 0})
</button>
) : undefined}
) : undefined
}
>
<div className="grid grid-cols-3 gap-4">
<div>
@@ -190,7 +212,9 @@ export function BossBattleFormModal({
</div>
{games && games.length > 1 && (
<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
value={gameId}
onChange={(e) => setGameId(e.target.value)}
@@ -208,7 +232,9 @@ export function BossBattleFormModal({
</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
value={afterRouteId}
onChange={(e) => setAfterRouteId(e.target.value)}
@@ -235,7 +261,9 @@ export function BossBattleFormModal({
/>
</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
type="text"
value={badgeImageUrl}

View File

@@ -38,7 +38,12 @@ function groupByVariant(boss: BossBattle): Variant[] {
}
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[] = []
@@ -48,43 +53,71 @@ function groupByVariant(boss: BossBattle): Variant[] {
map.delete(null)
}
// 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) {
variants.push({ label, pokemon })
}
return variants
}
export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEditorProps) {
const [variants, setVariants] = useState<Variant[]>(() => groupByVariant(boss))
export function BossTeamEditor({
boss,
onSave,
onClose,
isSaving,
}: BossTeamEditorProps) {
const [variants, setVariants] = useState<Variant[]>(() =>
groupByVariant(boss)
)
const [activeTab, setActiveTab] = useState(0)
const [newVariantName, setNewVariantName] = useState('')
const [showAddVariant, setShowAddVariant] = useState(false)
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)))
}
const addSlot = () => {
updateVariant(activeTab, (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) => {
updateVariant(activeTab, (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) => ({
...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()
if (!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)
setNewVariantName('')
setShowAddVariant(false)
@@ -109,8 +148,11 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
e.preventDefault()
const allPokemon: BossPokemonInput[] = []
for (const variant of variants) {
const conditionLabel = variants.length === 1 && variant.label === null ? null : variant.label
const validPokemon = variant.pokemon.filter((t) => t.pokemonId != null && t.level)
const conditionLabel =
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++) {
allPokemon.push({
pokemonId: validPokemon[i].pokemonId!,
@@ -147,7 +189,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
{v.label ?? 'Default'}
{v.label !== null && (
<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"
title="Remove variant"
>
@@ -171,13 +216,31 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
type="text"
value={newVariantName}
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..."
className="px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 w-40"
autoFocus
/>
<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>
<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>
@@ -185,7 +248,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-3">
{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">
<PokemonSelector
label={`Pokemon ${index + 1}`}
@@ -195,7 +261,9 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
/>
</div>
<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
type="number"
min={1}

View File

@@ -12,7 +12,14 @@ interface BulkImportModalProps {
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 [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<BulkImportResult | null>(null)
@@ -73,7 +80,10 @@ export function BulkImportModal({ title, example, onSubmit, onClose, createdLabe
{result && (
<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 && (
<ul className="mt-2 list-disc list-inside text-red-600 dark:text-red-400">
{result.errors.map((err, i) => (

View File

@@ -1,7 +1,11 @@
import { type FormEvent, useState } from 'react'
import { FormModal } from './FormModal'
import { PokemonSelector } from './PokemonSelector'
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
import type {
EvolutionAdmin,
CreateEvolutionInput,
UpdateEvolutionInput,
} from '../../types'
interface EvolutionFormModalProps {
evolution?: EvolutionAdmin
@@ -23,10 +27,10 @@ export function EvolutionFormModal({
isDeleting,
}: EvolutionFormModalProps) {
const [fromPokemonId, setFromPokemonId] = useState<number | null>(
evolution?.fromPokemonId ?? null,
evolution?.fromPokemonId ?? null
)
const [toPokemonId, setToPokemonId] = useState<number | null>(
evolution?.toPokemonId ?? null,
evolution?.toPokemonId ?? null
)
const [trigger, setTrigger] = useState(evolution?.trigger ?? 'level-up')
const [minLevel, setMinLevel] = useState(String(evolution?.minLevel ?? ''))

View File

@@ -55,7 +55,11 @@ export function FormModal({
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"
>
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
{isDeleting
? 'Deleting...'
: confirmingDelete
? 'Confirm?'
: 'Delete'}
</button>
)}
<div className="flex-1" />

View File

@@ -20,13 +20,23 @@ function slugify(name: string) {
.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 [slug, setSlug] = useState(game?.slug ?? '')
const [generation, setGeneration] = useState(String(game?.generation ?? ''))
const [region, setRegion] = useState(game?.region ?? '')
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)
useEffect(() => {
@@ -53,14 +63,16 @@ export function GameFormModal({ game, onSubmit, onClose, isSubmitting, onDelete,
isSubmitting={isSubmitting}
onDelete={onDelete}
isDeleting={isDeleting}
headerExtra={detailUrl ? (
headerExtra={
detailUrl ? (
<Link
to={detailUrl}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View Routes & Bosses
</Link>
) : undefined}
) : undefined
}
>
<div>
<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 { useQueryClient } from '@tanstack/react-query'
import { EvolutionFormModal } from './EvolutionFormModal'
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput, EvolutionAdmin, UpdateEvolutionInput } from '../../types'
import { usePokemonEncounterLocations, usePokemonEvolutionChain } from '../../hooks/usePokemon'
import type {
Pokemon,
CreatePokemonInput,
UpdatePokemonInput,
EvolutionAdmin,
UpdateEvolutionInput,
} from '../../types'
import {
usePokemonEncounterLocations,
usePokemonEvolutionChain,
} from '../../hooks/usePokemon'
import { useUpdateEvolution, useDeleteEvolution } from '../../hooks/useAdmin'
import { formatEvolutionMethod } from '../../utils/formatEvolution'
@@ -18,20 +27,32 @@ interface PokemonFormModalProps {
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 [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? ''))
const [nationalDex, setNationalDex] = useState(
String(pokemon?.nationalDex ?? '')
)
const [name, setName] = useState(pokemon?.name ?? '')
const [types, setTypes] = useState(pokemon?.types.join(', ') ?? '')
const [spriteUrl, setSpriteUrl] = useState(pokemon?.spriteUrl ?? '')
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 isEdit = !!pokemon
const pokemonId = pokemon?.id ?? null
const { data: encounterLocations, isLoading: encountersLoading } = usePokemonEncounterLocations(pokemonId)
const { data: evolutionChain, isLoading: evolutionsLoading } = usePokemonEvolutionChain(pokemonId)
const { data: encounterLocations, isLoading: encountersLoading } =
usePokemonEncounterLocations(pokemonId)
const { data: evolutionChain, isLoading: evolutionsLoading } =
usePokemonEvolutionChain(pokemonId)
const queryClient = useQueryClient()
const updateEvolution = useUpdateEvolution()
@@ -42,7 +63,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
}, [onDelete])
const invalidateChain = () => {
queryClient.invalidateQueries({ queryKey: ['pokemon', pokemonId, 'evolution-chain'] })
queryClient.invalidateQueries({
queryKey: ['pokemon', pokemonId, 'evolution-chain'],
})
}
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">
{/* Header */}
<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 && (
<div className="flex gap-1 mt-2">
{tabs.map((tab) => (
@@ -99,10 +124,15 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
{/* Details tab (form) */}
{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>
<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
type="number"
required
@@ -113,7 +143,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
/>
</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
type="number"
required
@@ -134,7 +166,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
/>
</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
type="text"
required
@@ -145,7 +179,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
/>
</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
type="text"
value={spriteUrl}
@@ -170,7 +206,11 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
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"
>
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
{isDeleting
? 'Deleting...'
: confirmingDelete
? 'Confirm?'
: 'Delete'}
</button>
)}
<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="px-6 py-4 overflow-y-auto">
{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) && (
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions</p>
{!evolutionsLoading &&
(!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">
{evolutionChain.map((evo) => (
<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="px-6 py-4 overflow-y-auto">
{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) && (
<p className="text-sm text-gray-500 dark:text-gray-400">No encounters</p>
{!encountersLoading &&
(!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">
{encounterLocations.map((game) => (
<div key={game.gameId}>
@@ -251,7 +305,10 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
</div>
<div className="space-y-0.5 pl-2">
{game.encounters.map((enc, i) => (
<div key={i} className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1">
<div
key={i}
className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1"
>
<Link
to={`/admin/games/${game.gameId}/routes/${enc.routeId}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
@@ -259,7 +316,8 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
{enc.routeName}
</Link>
<span className="text-gray-400 dark:text-gray-500">
{enc.encounterMethod}, Lv. {enc.minLevel}{enc.maxLevel}
{enc.encounterMethod}, Lv. {enc.minLevel}
{enc.maxLevel}
</span>
</div>
))}
@@ -294,7 +352,7 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
setEditingEvolution(null)
invalidateChain()
},
},
}
)
}
onClose={() => setEditingEvolution(null)}

View File

@@ -1,12 +1,22 @@
import { type FormEvent, useState } from 'react'
import { FormModal } from './FormModal'
import { PokemonSelector } from './PokemonSelector'
import { METHOD_ORDER, METHOD_CONFIG, getMethodLabel } from '../EncounterMethodBadge'
import type { RouteEncounterDetail, CreateRouteEncounterInput, UpdateRouteEncounterInput } from '../../types'
import {
METHOD_ORDER,
METHOD_CONFIG,
getMethodLabel,
} from '../EncounterMethodBadge'
import type {
RouteEncounterDetail,
CreateRouteEncounterInput,
UpdateRouteEncounterInput,
} from '../../types'
interface RouteEncounterFormModalProps {
encounter?: RouteEncounterDetail
onSubmit: (data: CreateRouteEncounterInput | UpdateRouteEncounterInput) => void
onSubmit: (
data: CreateRouteEncounterInput | UpdateRouteEncounterInput
) => void
onClose: () => void
isSubmitting?: boolean
onDelete?: () => void
@@ -25,11 +35,18 @@ export function RouteEncounterFormModal({
const initialMethod = encounter?.encounterMethod ?? ''
const isKnownMethod = METHOD_ORDER.includes(initialMethod)
const [selectedMethod, setSelectedMethod] = useState(isKnownMethod ? initialMethod : initialMethod ? 'other' : '')
const [customMethod, setCustomMethod] = useState(isKnownMethod ? '' : initialMethod)
const encounterMethod = selectedMethod === 'other' ? customMethod : selectedMethod
const [selectedMethod, setSelectedMethod] = useState(
isKnownMethod ? initialMethod : initialMethod ? 'other' : ''
)
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 [maxLevel, setMaxLevel] = useState(String(encounter?.maxLevel ?? ''))
@@ -70,7 +87,9 @@ export function RouteEncounterFormModal({
/>
)}
<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
required
value={selectedMethod}
@@ -107,7 +126,9 @@ export function RouteEncounterFormModal({
)}
</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
type="number"
required

View File

@@ -14,7 +14,16 @@ interface RouteFormModalProps {
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 [order, setOrder] = useState(String(route?.order ?? nextOrder ?? 0))
const [pinwheelZone, setPinwheelZone] = useState(
@@ -38,14 +47,16 @@ export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitti
isSubmitting={isSubmitting}
onDelete={onDelete}
isDeleting={isDeleting}
headerExtra={detailUrl ? (
headerExtra={
detailUrl ? (
<Link
to={detailUrl}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View Encounters
</Link>
) : undefined}
) : undefined
}
>
<div>
<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"
/>
<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>
</div>
</FormModal>

View File

@@ -23,7 +23,12 @@ import type {
// --- 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({
queryKey: ['pokemon', { 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) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ routeId, data }: { routeId: number; data: UpdateRouteInput }) =>
adminApi.updateRoute(gameId, routeId, data),
mutationFn: ({
routeId,
data,
}: {
routeId: number
data: UpdateRouteInput
}) => adminApi.updateRoute(gameId, routeId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['games', gameId] })
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
@@ -114,7 +124,8 @@ export function useDeleteRoute(gameId: number) {
export function useReorderRoutes(gameId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: (routes: RouteReorderItem[]) => adminApi.reorderRoutes(gameId, routes),
mutationFn: (routes: RouteReorderItem[]) =>
adminApi.reorderRoutes(gameId, routes),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['games', gameId] })
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
@@ -166,11 +177,20 @@ export function useDeletePokemon() {
export function useBulkImportPokemon() {
const qc = useQueryClient()
return useMutation({
mutationFn: (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
adminApi.bulkImportPokemon(items),
mutationFn: (
items: Array<{
pokeapiId: number
nationalDex: number
name: string
types: string[]
spriteUrl?: string | null
}>
) => adminApi.bulkImportPokemon(items),
onSuccess: (result) => {
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}`),
})
@@ -182,7 +202,9 @@ export function useBulkImportEvolutions() {
mutationFn: (items: unknown[]) => adminApi.bulkImportEvolutions(items),
onSuccess: (result) => {
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}`),
})
@@ -195,7 +217,9 @@ export function useBulkImportRoutes(gameId: number) {
onSuccess: (result) => {
qc.invalidateQueries({ queryKey: ['games', gameId] })
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}`),
})
@@ -215,7 +239,12 @@ export function useBulkImportBosses(gameId: number) {
// --- 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({
queryKey: ['evolutions', { 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) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ encounterId, data }: { encounterId: number; data: UpdateRouteEncounterInput }) =>
adminApi.updateRouteEncounter(routeId, encounterId, data),
mutationFn: ({
encounterId,
data,
}: {
encounterId: number
data: UpdateRouteEncounterInput
}) => adminApi.updateRouteEncounter(routeId, encounterId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
toast.success('Encounter updated')
@@ -305,32 +339,41 @@ export function useRemoveRouteEncounter(routeId: number) {
export function useCreateBossBattle(gameId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: CreateBossBattleInput) => adminApi.createBossBattle(gameId, data),
mutationFn: (data: CreateBossBattleInput) =>
adminApi.createBossBattle(gameId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
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) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ bossId, data }: { bossId: number; data: UpdateBossBattleInput }) =>
adminApi.updateBossBattle(gameId, bossId, data),
mutationFn: ({
bossId,
data,
}: {
bossId: number
data: UpdateBossBattleInput
}) => adminApi.updateBossBattle(gameId, bossId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
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) {
const qc = useQueryClient()
return useMutation({
mutationFn: (bosses: BossReorderItem[]) => adminApi.reorderBosses(gameId, bosses),
mutationFn: (bosses: BossReorderItem[]) =>
adminApi.reorderBosses(gameId, bosses),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
toast.success('Bosses reordered')
@@ -347,14 +390,16 @@ export function useDeleteBossBattle(gameId: number) {
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
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) {
const qc = useQueryClient()
return useMutation({
mutationFn: (team: BossPokemonInput[]) => adminApi.setBossTeam(gameId, bossId, team),
mutationFn: (team: BossPokemonInput[]) =>
adminApi.setBossTeam(gameId, bossId, team),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
toast.success('Boss team updated')
@@ -393,7 +438,8 @@ export function useDeleteGenlocke() {
export function useAddGenlockeLeg(genlockeId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: AddGenlockeLegInput) => adminApi.addGenlockeLeg(genlockeId, data),
mutationFn: (data: AddGenlockeLegInput) =>
adminApi.addGenlockeLeg(genlockeId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['genlockes'] })
qc.invalidateQueries({ queryKey: ['genlockes', genlockeId] })
@@ -406,7 +452,8 @@ export function useAddGenlockeLeg(genlockeId: number) {
export function useDeleteGenlockeLeg(genlockeId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: (legId: number) => adminApi.deleteGenlockeLeg(genlockeId, legId),
mutationFn: (legId: number) =>
adminApi.deleteGenlockeLeg(genlockeId, legId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['genlockes'] })
qc.invalidateQueries({ queryKey: ['genlockes', genlockeId] })

View File

@@ -1,6 +1,11 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
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'
export function useGameBosses(gameId: number | null, all?: boolean) {

View File

@@ -22,13 +22,8 @@ export function useCreateEncounter(runId: number) {
export function useUpdateEncounter(runId: number) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number
data: UpdateEncounterInput
}) => updateEncounter(id, data),
mutationFn: ({ id, data }: { id: number; data: UpdateEncounterInput }) =>
updateEncounter(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs', runId] })
},

View File

@@ -1,5 +1,14 @@
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'
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({
queryKey: ['genlockes', genlockeId, 'legs', legOrder, 'survivors'],
queryFn: () => getLegSurvivors(genlockeId, legOrder),
@@ -59,8 +72,20 @@ export function useLegSurvivors(genlockeId: number, legOrder: number, enabled: b
export function useAdvanceLeg() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ genlockeId, legOrder, transferEncounterIds }: { genlockeId: number; legOrder: number; transferEncounterIds?: number[] }) =>
advanceLeg(genlockeId, legOrder, transferEncounterIds ? { transferEncounterIds } : undefined),
mutationFn: ({
genlockeId,
legOrder,
transferEncounterIds,
}: {
genlockeId: number
legOrder: number
transferEncounterIds?: number[]
}) =>
advanceLeg(
genlockeId,
legOrder,
transferEncounterIds ? { transferEncounterIds } : undefined
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs'] })
queryClient.invalidateQueries({ queryKey: ['genlockes'] })

View File

@@ -1,5 +1,10 @@
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) {
return useQuery({

View File

@@ -1,6 +1,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
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'
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({
queryKey: ['name-suggestions', runId, pokemonId ?? null],
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 />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
</StrictMode>
)

View File

@@ -1,7 +1,12 @@
import { Link, useParams } from 'react-router-dom'
import { useGenlocke } from '../hooks/useGenlockes'
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 { useMemo, useState } from 'react'
@@ -18,7 +23,8 @@ const statusRing: 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',
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 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" />
)
@@ -49,7 +57,10 @@ function LegIndicator({ leg }: { leg: GenlockeLegDetail }) {
if (hasRun) {
return (
<Link to={`/runs/${leg.runId}`} className="hover:opacity-80 transition-opacity">
<Link
to={`/runs/${leg.runId}`}
className="hover:opacity-80 transition-opacity"
>
{content}
</Link>
)
@@ -105,7 +116,9 @@ export function GenlockeDetail() {
}
return genlocke.legs
.filter((leg) => leg.retiredPokemonIds && leg.retiredPokemonIds.length > 0)
.filter(
(leg) => leg.retiredPokemonIds && leg.retiredPokemonIds.length > 0
)
.map((leg) => {
// Find base Pokemon (lowest ID) for each family in this leg's retired list
const seen = new Set<string>()
@@ -118,7 +131,11 @@ export function GenlockeDetail() {
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])
@@ -202,8 +219,16 @@ export function GenlockeDetail() {
Cumulative Stats
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard label="Encounters" value={genlocke.stats.totalEncounters} color="blue" />
<StatCard label="Deaths" value={genlocke.stats.totalDeaths} color="red" />
<StatCard
label="Encounters"
value={genlocke.stats.totalEncounters}
color="blue"
/>
<StatCard
label="Deaths"
value={genlocke.stats.totalDeaths}
color="red"
/>
<StatCard
label="Legs Completed"
value={genlocke.stats.legsCompleted}

View File

@@ -3,9 +3,9 @@ import { useGenlockes } from '../hooks/useGenlockes'
import type { RunStatus } from '../types'
const statusStyles: Record<RunStatus, string> = {
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',
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',
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> = {
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',
}

View File

@@ -20,7 +20,7 @@ type PresetType = 'true' | 'normal' | 'custom' | null
function buildLegsFromPreset(
regions: Region[],
preset: 'true' | 'normal',
preset: 'true' | 'normal'
): LegEntry[] {
const legs: LegEntry[] = []
for (const region of regions) {
@@ -45,8 +45,11 @@ export function NewGenlocke() {
const [name, setName] = useState('')
const [legs, setLegs] = useState<LegEntry[]>([])
const [preset, setPreset] = useState<PresetType>(null)
const [nuzlockeRules, setNuzlockeRules] = useState<NuzlockeRules>(DEFAULT_RULES)
const [genlockeRules, setGenlockeRules] = useState<GenlockeRules>({ retireHoF: false })
const [nuzlockeRules, setNuzlockeRules] =
useState<NuzlockeRules>(DEFAULT_RULES)
const [genlockeRules, setGenlockeRules] = useState<GenlockeRules>({
retireHoF: false,
})
const [namingScheme, setNamingScheme] = useState<string | null>(null)
const { data: namingCategories } = useNamingCategories()
@@ -61,7 +64,9 @@ export function NewGenlocke() {
}
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) => {
@@ -70,7 +75,8 @@ export function NewGenlocke() {
const handleAddLeg = (region: Region) => {
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) {
setLegs((prev) => [...prev, { region: region.name, game }])
}
@@ -105,17 +111,18 @@ export function NewGenlocke() {
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
// Regions not yet used in legs (for "add leg" picker)
const availableRegions = regions?.filter(
(r) => !legs.some((l) => l.region === r.name),
) ?? []
const availableRegions =
regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? []
return (
<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'
}`}
>
<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]}
</div>
<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 */}
{preset && preset !== 'custom' && availableRegions.length > 0 && legs.length > 0 && (
{preset &&
preset !== 'custom' &&
availableRegions.length > 0 &&
legs.length > 0 && (
<div className="mt-4">
<AddLegDropdown regions={availableRegions} onAdd={handleAddLeg} />
<AddLegDropdown
regions={availableRegions}
onAdd={handleAddLeg}
/>
</div>
)}
@@ -270,7 +285,10 @@ export function NewGenlocke() {
{/* Step 3: Rules */}
{step === 3 && (
<div>
<RulesConfiguration rules={nuzlockeRules} onChange={setNuzlockeRules} />
<RulesConfiguration
rules={nuzlockeRules}
onChange={setNuzlockeRules}
/>
{/* Genlocke-specific rules */}
<div className="mt-6 bg-white dark:bg-gray-800 rounded-lg shadow">
@@ -301,7 +319,8 @@ export function NewGenlocke() {
Keep Hall of Fame
</div>
<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>
</label>
@@ -318,7 +337,8 @@ export function NewGenlocke() {
Retire Hall of Fame
</div>
<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>
</label>
@@ -334,7 +354,8 @@ export function NewGenlocke() {
Naming Scheme
</h3>
<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>
</div>
<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">
Name
</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 className="border-t border-gray-200 dark:border-gray-700 pt-4">
@@ -403,7 +426,8 @@ export function NewGenlocke() {
{leg.game.name}
</span>
<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>
</div>
</li>
@@ -417,22 +441,29 @@ export function NewGenlocke() {
</h3>
<dl className="space-y-1 text-sm">
<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">
{enabledRuleCount} of {totalRuleCount} enabled
</dd>
</div>
<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">
{genlockeRules.retireHoF ? 'Retire' : 'Keep'}
</dd>
</div>
<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">
{namingScheme
? namingScheme.charAt(0).toUpperCase() + namingScheme.slice(1)
? namingScheme.charAt(0).toUpperCase() +
namingScheme.slice(1)
: 'None'}
</dd>
</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"
title="Move up"
>
<svg 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
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>
</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"
title="Move down"
>
<svg 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
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>
</button>
<button
@@ -551,8 +602,18 @@ function LegRow({
className="p-1 text-red-400 hover:text-red-600 dark:hover:text-red-300"
title="Remove leg"
>
<svg 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
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>
</button>
</div>
@@ -576,8 +637,18 @@ function AddLegDropdown({
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"
>
<svg 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
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>
Add Region
</button>

View File

@@ -47,13 +47,13 @@ export function NewRun() {
if (!selectedGame) return
createRun.mutate(
{ gameId: selectedGame.id, name: runName, rules, namingScheme },
{ onSuccess: (data) => navigate(`/runs/${data.id}`) },
{ onSuccess: (data) => navigate(`/runs/${data.id}`) }
)
}
const visibleRuleKeys = RULE_DEFINITIONS
.filter((r) => !hiddenRules?.has(r.key))
.map((r) => r.key)
const visibleRuleKeys = RULE_DEFINITIONS.filter(
(r) => !hiddenRules?.has(r.key)
).map((r) => r.key)
const enabledRuleCount = visibleRuleKeys.filter((k) => rules[k]).length
const totalRuleCount = visibleRuleKeys.length
@@ -84,7 +84,8 @@ export function NewRun() {
{selectedGame.name}
</p>
<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>
</div>
</div>
@@ -137,7 +138,11 @@ export function NewRun() {
{step === 2 && (
<div>
<RulesConfiguration rules={rules} onChange={setRules} hiddenRules={hiddenRules} />
<RulesConfiguration
rules={rules}
onChange={setRules}
hiddenRules={hiddenRules}
/>
<div className="mt-6 flex justify-between">
<button
@@ -204,7 +209,8 @@ export function NewRun() {
))}
</select>
<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>
</div>
)}
@@ -223,7 +229,9 @@ export function NewRun() {
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Region</dt>
<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>
</div>
<div className="flex justify-between">
@@ -233,10 +241,13 @@ export function NewRun() {
</dd>
</div>
<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">
{namingScheme
? namingScheme.charAt(0).toUpperCase() + namingScheme.slice(1)
? namingScheme.charAt(0).toUpperCase() +
namingScheme.slice(1)
: 'None'}
</dd>
</div>

View File

@@ -3,12 +3,21 @@ import { useParams, Link } from 'react-router-dom'
import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns'
import { useGameRoutes } from '../hooks/useGames'
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'
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) => {
switch (key) {
case 'route':
@@ -21,7 +30,10 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
return nameA.localeCompare(nameB)
}
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:
return 0
}
@@ -29,9 +41,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
}
const statusStyles: Record<RunStatus, string> = {
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',
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',
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 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(
() => 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) {
@@ -111,7 +135,10 @@ export function RunDashboard() {
{run.name}
</h1>
<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, {
year: 'numeric',
month: 'short',
@@ -137,7 +164,9 @@ export function RunDashboard() {
}`}
>
<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>
<p
className={`font-semibold ${
@@ -222,7 +251,8 @@ export function RunDashboard() {
) : (
<span className="text-sm text-gray-900 dark:text-gray-100">
{run.namingScheme
? run.namingScheme.charAt(0).toUpperCase() + run.namingScheme.slice(1)
? run.namingScheme.charAt(0).toUpperCase() +
run.namingScheme.slice(1)
: 'None'}
</span>
)}
@@ -329,7 +359,7 @@ export function RunDashboard() {
onConfirm={(status) => {
updateRun.mutate(
{ status },
{ onSuccess: () => setShowEndRun(false) },
{ onSuccess: () => 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 { useAdvanceLeg } from '../hooks/useGenlockes'
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 { useGameBosses, useBossResults, useCreateBossResult } from '../hooks/useBosses'
import {
useGameBosses,
useBossResults,
useCreateBossResult,
} from '../hooks/useBosses'
import {
EggEncounterModal,
EncounterModal,
@@ -35,7 +43,10 @@ import type {
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) => {
switch (key) {
case 'route':
@@ -48,7 +59,10 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
return nameA.localeCompare(nameB)
}
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:
return 0
}
@@ -56,9 +70,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
}
const statusStyles: Record<RunStatus, string> = {
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',
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',
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(
group: RouteWithChildren,
encounterByRoute: Map<number, EncounterDetail>,
encounterByRoute: Map<number, EncounterDetail>
): EncounterDetail | null {
for (const child of group.children) {
const enc = encounterByRoute.get(child.id)
@@ -154,7 +168,7 @@ function effectiveZone(route: Route): number {
*/
function getZoneEncounters(
group: RouteWithChildren,
encounterByRoute: Map<number, EncounterDetail>,
encounterByRoute: Map<number, EncounterDetail>
): Map<number, EncounterDetail> {
const zoneMap = new Map<number, EncounterDetail>()
for (const child of group.children) {
@@ -172,14 +186,23 @@ function countDistinctZones(group: RouteWithChildren): number {
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
const lower = starterName.toLowerCase()
const matches = labels.filter((l) => l.toLowerCase().includes(lower))
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 labels = new Set<string>()
for (const bp of pokemon) {
@@ -189,16 +212,20 @@ function BossTeamPreview({ pokemon, starterName }: { pokemon: BossPokemon[]; sta
}, [pokemon])
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 [selectedVariant, setSelectedVariant] = useState<string | null>(
autoMatch ?? (hasVariants ? variantLabels[0] : null),
autoMatch ?? (hasVariants ? variantLabels[0] : null)
)
const displayed = useMemo(() => {
if (!hasVariants) return pokemon
return pokemon.filter(
(bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null,
(bp) =>
bp.conditionLabel === selectedVariant || bp.conditionLabel === null
)
}, [pokemon, hasVariants, selectedVariant])
@@ -228,7 +255,11 @@ function BossTeamPreview({ pokemon, starterName }: { pokemon: BossPokemon[]; sta
.map((bp) => (
<div key={bp.id} className="flex items-center gap-1">
{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" />
)}
@@ -420,7 +451,7 @@ export function RunEncounters() {
const advanceLeg = useAdvanceLeg()
const [showTransferModal, setShowTransferModal] = useState(false)
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
run?.gameId ?? null,
run?.gameId ?? null
)
const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum)
@@ -451,7 +482,9 @@ export function RunEncounters() {
try {
const saved = localStorage.getItem(storageKey)
if (saved) return new Set(JSON.parse(saved) as number[])
} catch { /* ignore */ }
} catch {
/* ignore */
}
return new Set<number>()
})
@@ -463,7 +496,7 @@ export function RunEncounters() {
return next
})
},
[storageKey],
[storageKey]
)
// Organize routes into hierarchical structure
@@ -475,11 +508,17 @@ export function RunEncounters() {
// Split encounters into normal (non-shiny) and shiny
const transferIdSet = useMemo(
() => new Set(run?.transferEncounterIds ?? []),
[run?.transferEncounterIds],
[run?.transferEncounterIds]
)
const { normalEncounters, shinyEncounters, transferEncounters } = useMemo(() => {
if (!run) return { normalEncounters: [], shinyEncounters: [], transferEncounters: [] }
const { normalEncounters, shinyEncounters, transferEncounters } =
useMemo(() => {
if (!run)
return {
normalEncounters: [],
shinyEncounters: [],
transferEncounters: [],
}
const normal: EncounterDetail[] = []
const shiny: EncounterDetail[] = []
const transfer: EncounterDetail[] = []
@@ -492,7 +531,11 @@ export function RunEncounters() {
normal.push(enc)
}
}
return { normalEncounters: normal, shinyEncounters: shiny, transferEncounters: transfer }
return {
normalEncounters: normal,
shinyEncounters: shiny,
transferEncounters: transfer,
}
}, [run, transferIdSet])
// Map routeId → encounter for quick lookup (normal encounters only)
@@ -635,8 +678,7 @@ export function RunEncounters() {
if (organizedRoutes.length === 0 || expandedGroups.size > 0) return
const firstUnvisited = organizedRoutes.find(
(r) =>
r.children.length > 0 &&
getGroupEncounter(r, encounterByRoute) === null,
r.children.length > 0 && getGroupEncounter(r, encounterByRoute) === null
)
if (firstUnvisited) {
updateExpandedGroups(() => new Set([firstUnvisited.id]))
@@ -644,21 +686,25 @@ export function RunEncounters() {
}, [organizedRoutes, encounterByRoute]) // eslint-disable-line react-hooks/exhaustive-deps
const alive = useMemo(
() => sortEncounters(
() =>
sortEncounters(
[...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(
() => sortEncounters(
normalEncounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
teamSort,
() =>
sortEncounters(
normalEncounters.filter(
(e) => e.status === 'caught' && e.faintLevel !== null
),
[normalEncounters, teamSort],
teamSort
),
[normalEncounters, teamSort]
)
// Resolve HoF team encounters from IDs
@@ -810,7 +856,10 @@ export function RunEncounters() {
{run.name}
</h1>
<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, {
year: 'numeric',
month: 'short',
@@ -819,7 +868,8 @@ export function RunEncounters() {
</p>
{run.genlocke && (
<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>
)}
</div>
@@ -868,7 +918,9 @@ export function RunEncounters() {
>
<div className="flex items-center justify-between">
<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>
<p
className={`font-semibold ${
@@ -907,31 +959,38 @@ export function RunEncounters() {
</p>
</div>
</div>
{run.status === 'completed' && run.genlocke && !run.genlocke.isFinalLeg && (
{run.status === 'completed' &&
run.genlocke &&
!run.genlocke.isFinalLeg && (
<button
onClick={() => {
if (hofTeam && hofTeam.length > 0) {
setShowTransferModal(true)
} else {
advanceLeg.mutate(
{ genlockeId: run.genlocke!.genlockeId, legOrder: run.genlocke!.legOrder },
{
genlockeId: run.genlocke!.genlockeId,
legOrder: run.genlocke!.legOrder,
},
{
onSuccess: (genlocke) => {
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run.genlocke!.legOrder + 1,
(l) => l.legOrder === run.genlocke!.legOrder + 1
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
},
},
}
)
}
}}
disabled={advanceLeg.isPending}
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{advanceLeg.isPending ? 'Advancing...' : 'Advance to Next Leg'}
{advanceLeg.isPending
? 'Advancing...'
: 'Advance to Next Leg'}
</button>
)}
</div>
@@ -957,7 +1016,11 @@ export function RunEncounters() {
return (
<div key={enc.id} className="flex flex-col items-center">
{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">
{dp.name[0].toUpperCase()}
@@ -1040,11 +1103,13 @@ export function RunEncounters() {
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
? 'border-yellow-500 bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300'
: 'border-gray-300 dark:border-gray-600 text-gray-400'
}`}>
}`}
>
{boss.order}
</div>
)}
@@ -1077,7 +1142,8 @@ export function RunEncounters() {
{isActive ? 'Team' : 'Final Team'}
</h2>
<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>
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${showTeam ? 'rotate-180' : ''}`}
@@ -1114,7 +1180,11 @@ export function RunEncounters() {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
onClick={
isActive
? () => setSelectedTeamEncounter(enc)
: undefined
}
/>
))}
</div>
@@ -1130,7 +1200,11 @@ export function RunEncounters() {
key={enc.id}
encounter={enc}
showFaintLevel
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
onClick={
isActive
? () => setSelectedTeamEncounter(enc)
: undefined
}
/>
))}
</div>
@@ -1146,7 +1220,9 @@ export function RunEncounters() {
<div className="mb-6">
<ShinyBox
encounters={shinyEncounters}
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
onEncounterClick={
isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined
}
/>
</div>
)}
@@ -1162,7 +1238,9 @@ export function RunEncounters() {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
onClick={
isActive ? () => setSelectedTeamEncounter(enc) : undefined
}
/>
))}
</div>
@@ -1182,7 +1260,11 @@ export function RunEncounters() {
disabled={bulkRandomize.isPending}
onClick={() => {
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()
}
}}
@@ -1242,7 +1324,8 @@ export function RunEncounters() {
)}
{filteredRoutes.map((route) => {
// 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]
@@ -1253,7 +1336,8 @@ export function RunEncounters() {
if (b) bossesHere.push(...b)
}
const routeElement = route.children.length > 0 ? (
const routeElement =
route.children.length > 0 ? (
<RouteGroup
key={route.id}
group={route}
@@ -1264,7 +1348,8 @@ export function RunEncounters() {
filter={filter}
pinwheelClause={pinwheelClause}
/>
) : (() => {
) : (
(() => {
const encounter = encounterByRoute.get(route.id)
const rs = getRouteStatus(encounter)
const si = statusIndicator[rs]
@@ -1301,12 +1386,18 @@ export function RunEncounters() {
: ' (dead)')}
</span>
</div>
) : route.encounterMethods.length > 0 && (
) : (
route.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{route.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" />
<EncounterMethodBadge
key={m}
method={m}
size="xs"
/>
))}
</div>
)
)}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
@@ -1315,6 +1406,7 @@ export function RunEncounters() {
</button>
)
})()
)
return (
<div key={route.id}>
@@ -1358,7 +1450,9 @@ export function RunEncounters() {
<div key={`boss-${boss.id}`}>
<div
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`}
>
<div
@@ -1373,10 +1467,18 @@ export function RunEncounters() {
stroke="currentColor"
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>
{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 className="flex items-center gap-2">
@@ -1391,7 +1493,8 @@ export function RunEncounters() {
)}
</div>
<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>
</div>
</div>
@@ -1412,13 +1515,18 @@ export function RunEncounters() {
</div>
{/* Boss pokemon team */}
{isBossExpanded && boss.pokemon.length > 0 && (
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
<BossTeamPreview
pokemon={boss.pokemon}
starterName={starterName}
/>
)}
</div>
{sectionAfter && (
<div className="flex items-center gap-3 my-4">
<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>
)}
@@ -1519,7 +1627,7 @@ export function RunEncounters() {
setShowHofModal(true)
}
},
},
}
)
}}
onClose={() => setShowEndRun(false)}
@@ -1535,7 +1643,7 @@ export function RunEncounters() {
onSubmit={(encounterIds) => {
updateRun.mutate(
{ hofEncounterIds: encounterIds },
{ onSuccess: () => setShowHofModal(false) },
{ onSuccess: () => setShowHofModal(false) }
)
}}
onSkip={() => setShowHofModal(false)}
@@ -1558,13 +1666,13 @@ export function RunEncounters() {
onSuccess: (genlocke) => {
setShowTransferModal(false)
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
(l) => l.legOrder === run!.genlocke!.legOrder + 1
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
},
},
}
)
}}
onSkip={() => {
@@ -1577,13 +1685,13 @@ export function RunEncounters() {
onSuccess: (genlocke) => {
setShowTransferModal(false)
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
(l) => l.legOrder === run!.genlocke!.legOrder + 1
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
},
},
}
)
}}
isPending={advanceLeg.isPending}

View File

@@ -3,9 +3,9 @@ import { useRuns } from '../hooks/useRuns'
import type { RunStatus } from '../types'
const statusStyles: Record<RunStatus, string> = {
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',
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',
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">
<StatCard label="Total Runs" value={stats.totalRuns} color="blue" />
<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" />
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
<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>
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>
</div>
</Section>
@@ -233,10 +243,16 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
<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>
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>
</div>
</Section>
@@ -244,10 +260,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
{/* Pokemon Rankings */}
<Section title="Pokemon Rankings">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<PokemonList
title="Most Caught"
pokemon={stats.topCaughtPokemon}
/>
<PokemonList title="Most Caught" pokemon={stats.topCaughtPokemon} />
<PokemonList
title="Most Encountered"
pokemon={stats.topEncounteredPokemon}
@@ -258,24 +271,34 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
{/* Team & Deaths */}
<Section title="Team & Deaths">
<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="text-2xl font-bold text-gray-900 dark:text-gray-100">
{pct(stats.mortalityRate)}
</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 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">
{fmt(stats.avgCatchLevel)}
</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 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">
{fmt(stats.avgFaintLevel)}
</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>
@@ -347,7 +370,9 @@ export function Stats() {
{stats && stats.totalRuns === 0 && (
<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-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>
)}

View File

@@ -11,7 +11,11 @@ import {
} from '../../hooks/useAdmin'
import { exportEvolutions } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
import type {
EvolutionAdmin,
CreateEvolutionInput,
UpdateEvolutionInput,
} from '../../types'
const PAGE_SIZE = 50
@@ -28,7 +32,12 @@ export function AdminEvolutions() {
const [triggerFilter, setTriggerFilter] = useState('')
const [page, setPage] = useState(0)
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 total = data?.total ?? 0
const totalPages = Math.ceil(total / PAGE_SIZE)
@@ -120,12 +129,18 @@ export function AdminEvolutions() {
>
<option value="">All triggers</option>
{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>
{(search || triggerFilter) && (
<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"
>
Clear filters
@@ -148,7 +163,8 @@ export function AdminEvolutions() {
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<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 className="flex items-center gap-2">
<button
@@ -213,7 +229,7 @@ export function AdminEvolutions() {
onSubmit={(data) =>
updateEvolution.mutate(
{ id: editing.id, data: data as UpdateEvolutionInput },
{ onSuccess: () => setEditing(null) },
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}

View File

@@ -38,8 +38,17 @@ import {
} from '../../hooks/useAdmin'
import { exportGameRoutes, exportGameBosses } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { Route as GameRoute, RouteWithChildren, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types'
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
import type {
Route as GameRoute,
RouteWithChildren,
CreateRouteInput,
UpdateRouteInput,
BossBattle,
} from '../../types'
import type {
CreateBossBattleInput,
UpdateBossBattleInput,
} from '../../types/admin'
/**
* Organize flat routes into hierarchical structure.
@@ -76,8 +85,14 @@ function SortableRouteGroup({
gameId: number
onClick: (r: GameRoute) => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: group.id })
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: group.id })
const style = {
transform: CSS.Transform.toString(transform),
@@ -112,7 +127,9 @@ function SortableRouteGroup({
</svg>
</button>
</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 text-center">
{group.pinwheelZone != null ? group.pinwheelZone : '\u2014'}
@@ -138,7 +155,9 @@ function SortableRouteGroup({
{child.order}
</td>
<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}
</td>
<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
onClick: (b: BossBattle) => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: boss.id })
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: boss.id })
const style = {
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 font-medium">
{boss.name}
{boss.gameId != null && (() => {
{boss.gameId != null &&
(() => {
const g = games.find((g) => g.id === boss.gameId)
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">
@@ -221,9 +247,15 @@ function SortableBossRow({
{boss.bossType.replace('_', ' ')}
</td>
<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 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">
<select
@@ -244,7 +276,9 @@ function SortableBossRow({
</select>
</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>
)
}
@@ -278,16 +312,18 @@ export function AdminGameDetail() {
const sensors = useSensors(
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 (!game) return <div className="py-8 text-center text-gray-500">Game not found</div>
if (isLoading)
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 routeGroups = organizeRoutes(routes)
const versionGroupGames = (allGames ?? []).filter(
(g) => g.versionGroupId === game.versionGroupId,
(g) => g.versionGroupId === game.versionGroupId
)
const handleDragEnd = (event: DragEndEvent) => {
@@ -347,7 +383,8 @@ export function AdminGameDetail() {
<div className="mb-6">
<h2 className="text-xl font-semibold">{game.name}</h2>
<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}` : ''}
</p>
</div>
@@ -463,7 +500,11 @@ export function AdminGameDetail() {
{showCreate && (
<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) =>
createRoute.mutate(data as CreateRouteInput, {
onSuccess: () => setShowCreate(false),
@@ -480,7 +521,7 @@ export function AdminGameDetail() {
onSubmit={(data) =>
updateRoute.mutate(
{ routeId: editing.id, data: data as UpdateRouteInput },
{ onSuccess: () => setEditing(null) },
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}
@@ -614,7 +655,9 @@ export function AdminGameDetail() {
<BossBattleFormModal
routes={routes}
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) =>
createBoss.mutate(data as CreateBossBattleInput, {
onSuccess: () => setShowCreateBoss(false),
@@ -634,7 +677,7 @@ export function AdminGameDetail() {
onSubmit={(data) =>
updateBoss.mutate(
{ bossId: editingBoss.id, data: data as UpdateBossBattleInput },
{ onSuccess: () => setEditingBoss(null) },
{ onSuccess: () => setEditingBoss(null) }
)
}
onClose={() => setEditingBoss(null)}
@@ -676,9 +719,7 @@ function BossTeamEditorWrapper({
return (
<BossTeamEditor
boss={boss}
onSave={(team) =>
setBossTeam.mutate(team, { onSuccess: onClose })
}
onSave={(team) => setBossTeam.mutate(team, { onSuccess: onClose })}
onClose={onClose}
isSaving={setBossTeam.isPending}
/>

View File

@@ -2,7 +2,11 @@ import { useState, useMemo } from 'react'
import { AdminTable, type Column } from '../../components/admin/AdminTable'
import { GameFormModal } from '../../components/admin/GameFormModal'
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 { downloadJson } from '../../utils/download'
import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
@@ -20,17 +24,18 @@ export function AdminGames() {
const regions = useMemo(
() => [...new Set(games.map((g) => g.region))].sort(),
[games],
[games]
)
const generations = useMemo(
() => [...new Set(games.map((g) => g.generation))].sort((a, b) => a - b),
[games],
[games]
)
const filteredGames = useMemo(() => {
let result = games
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
}, [games, regionFilter, genFilter])
@@ -38,8 +43,16 @@ export function AdminGames() {
{ header: 'Name', accessor: (g) => g.name },
{ header: 'Slug', accessor: (g) => g.slug },
{ 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 (
@@ -73,7 +86,9 @@ export function AdminGames() {
>
<option value="">All regions</option>
{regions.map((r) => (
<option key={r} value={r}>{r}</option>
<option key={r} value={r}>
{r}
</option>
))}
</select>
<select
@@ -83,12 +98,17 @@ export function AdminGames() {
>
<option value="">All generations</option>
{generations.map((g) => (
<option key={g} value={g}>Gen {g}</option>
<option key={g} value={g}>
Gen {g}
</option>
))}
</select>
{(regionFilter || genFilter) && (
<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"
>
Clear filters
@@ -126,7 +146,7 @@ export function AdminGames() {
onSubmit={(data) =>
updateGame.mutate(
{ id: editing.id, data: data as UpdateGameInput },
{ onSuccess: () => setEditing(null) },
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}

View File

@@ -28,13 +28,18 @@ export function AdminGenlockeDetail() {
const [addingLeg, setAddingLeg] = useState(false)
const [selectedGameId, setSelectedGameId] = useState<number | ''>('')
if (isLoading) return <div className="py-8 text-center text-gray-500">Loading...</div>
if (!genlocke) return <div className="py-8 text-center text-gray-500">Genlocke not found</div>
if (isLoading)
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 editStatus = status ?? genlocke.status
const hasChanges = editName !== genlocke.name || editStatus !== genlocke.status
const hasChanges =
editName !== genlocke.name || editStatus !== genlocke.status
const handleSave = () => {
const data: Record<string, string> = {}
@@ -48,7 +53,7 @@ export function AdminGenlockeDetail() {
setName(null)
setStatus(null)
},
},
}
)
}
@@ -61,7 +66,7 @@ export function AdminGenlockeDetail() {
setAddingLeg(false)
setSelectedGameId('')
},
},
}
)
}
@@ -72,7 +77,9 @@ export function AdminGenlockeDetail() {
Genlockes
</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>
{/* Header */}
@@ -124,16 +131,22 @@ export function AdminGenlockeDetail() {
{/* Rules (read-only) */}
<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>
<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">
{JSON.stringify(genlocke.genlockeRules, null, 2)}
</pre>
</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">
{JSON.stringify(genlocke.nuzlockeRules, null, 2)}
</pre>
@@ -144,7 +157,9 @@ export function AdminGenlockeDetail() {
{/* Legs */}
<div className="mb-6">
<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
onClick={() => setAddingLeg(!addingLeg)}
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">
<select
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"
>
<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">
{genlocke.legs.map((leg) => (
<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">{leg.game.name}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{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">
{leg.runId ? (
<Link
@@ -253,13 +274,21 @@ export function AdminGenlockeDetail() {
<span className="text-gray-400">&mdash;</span>
)}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{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">
{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">
<button
onClick={() => deleteLeg.mutate(leg.id)}
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"
>
Remove
@@ -276,22 +305,32 @@ export function AdminGenlockeDetail() {
{/* Stats */}
<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>
<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>
<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>
<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>
<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">
{genlocke.stats.totalEncounters > 0
? `${Math.round(((genlocke.stats.totalEncounters - genlocke.stats.totalDeaths) / genlocke.stats.totalEncounters) * 100)}%`

View File

@@ -11,14 +11,33 @@ import {
} from '../../hooks/useAdmin'
import { exportPokemon } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types'
import type {
Pokemon,
CreatePokemonInput,
UpdatePokemonInput,
} from '../../types'
const PAGE_SIZE = 50
const POKEMON_TYPES = [
'bug', 'dark', 'dragon', 'electric', 'fairy', 'fighting', 'fire', 'flying',
'ghost', 'grass', 'ground', 'ice', 'normal', 'poison', 'psychic', 'rock',
'steel', 'water',
'bug',
'dark',
'dragon',
'electric',
'fairy',
'fighting',
'fire',
'flying',
'ghost',
'grass',
'ground',
'ice',
'normal',
'poison',
'psychic',
'rock',
'steel',
'water',
]
export function AdminPokemon() {
@@ -26,7 +45,12 @@ export function AdminPokemon() {
const [typeFilter, setTypeFilter] = useState('')
const [page, setPage] = useState(0)
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 total = data?.total ?? 0
const totalPages = Math.ceil(total / PAGE_SIZE)
@@ -105,12 +129,18 @@ export function AdminPokemon() {
>
<option value="">All types</option>
{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>
{(search || typeFilter) && (
<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"
>
Clear filters
@@ -134,7 +164,8 @@ export function AdminPokemon() {
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<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 className="flex items-center gap-2">
<button
@@ -188,7 +219,11 @@ export function AdminPokemon() {
<BulkImportModal
title="Bulk Import Pokemon"
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)}
/>
)}
@@ -199,7 +234,7 @@ export function AdminPokemon() {
onSubmit={(data) =>
updatePokemon.mutate(
{ id: editing.id, data: data as UpdatePokemonInput },
{ onSuccess: () => setEditing(null) },
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}

View File

@@ -42,22 +42,27 @@ export function AdminRouteDetail() {
const sortedRoutes = useMemo(
() => [...(game?.routes ?? [])].sort((a, b) => a.order - b.order),
[game?.routes],
[game?.routes]
)
const currentIndex = sortedRoutes.findIndex((r) => r.id === rId)
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 =
currentIndex >= 0 && currentIndex < sortedRoutes.length - 1
? sortedRoutes[currentIndex + 1]
: undefined
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
: (route?.order ?? 0) * 10 + 1
@@ -67,7 +72,11 @@ export function AdminRouteDetail() {
accessor: (e) => (
<div className="flex items-center gap-2">
{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}
<span>
#{e.pokemon.nationalDex} {e.pokemon.name}
@@ -80,7 +89,9 @@ export function AdminRouteDetail() {
{
header: 'Levels',
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
className="text-gray-900 dark:text-gray-100 bg-transparent font-medium cursor-pointer hover:underline border-none p-0 text-sm"
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) => (
<option key={r.id} value={r.id}>
@@ -162,9 +175,12 @@ export function AdminRouteDetail() {
{showCreate && (
<RouteEncounterFormModal
onSubmit={(data) =>
addEncounter.mutate({ ...data, gameId: gId } as CreateRouteEncounterInput, {
addEncounter.mutate(
{ ...data, gameId: gId } as CreateRouteEncounterInput,
{
onSuccess: () => setShowCreate(false),
})
}
)
}
onClose={() => setShowCreate(false)}
isSubmitting={addEncounter.isPending}
@@ -176,8 +192,11 @@ export function AdminRouteDetail() {
encounter={editing}
onSubmit={(data) =>
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)}
@@ -194,7 +213,9 @@ export function AdminRouteDetail() {
{/* Sub-areas */}
<div className="mt-8">
<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
onClick={() => setShowCreateChild(true)}
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>
</div>
{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">
{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
to={`/admin/games/${gId}/routes/${child.id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
@@ -232,7 +258,7 @@ export function AdminRouteDetail() {
onSubmit={(data) =>
createRoute.mutate(
{ ...data, parentRouteId: rId } as CreateRouteInput,
{ onSuccess: () => setShowCreateChild(false) },
{ onSuccess: () => setShowCreateChild(false) }
)
}
onClose={() => setShowCreateChild(false)}

View File

@@ -16,19 +16,28 @@ export function AdminRuns() {
const gameMap = useMemo(
() => new Map(games.map((g) => [g.id, g.name])),
[games],
[games]
)
const filteredRuns = useMemo(() => {
let result = runs
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
}, [runs, statusFilter, gameFilter])
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>[] = [
@@ -86,12 +95,17 @@ export function AdminRuns() {
>
<option value="">All games</option>
{runGames.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
<option key={id} value={id}>
{name}
</option>
))}
</select>
{(statusFilter || gameFilter) && (
<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"
>
Clear filters
@@ -120,7 +134,10 @@ export function AdminRuns() {
onSuccess: () => setDeleting(null),
})
}
onCancel={() => { setDeleting(null); deleteRun.reset() }}
onCancel={() => {
setDeleting(null)
deleteRun.reset()
}}
isDeleting={deleteRun.isPending}
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 {
id: number
@@ -163,7 +168,15 @@ export interface UpdateEncounterInput {
}
// 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 {
id: number

View File

@@ -1,5 +1,7 @@
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 a = document.createElement('a')
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[] = []
if (evo.trigger === 'level-up' && evo.minLevel) {
parts.push(`Level ${evo.minLevel}`)
} else if (evo.trigger === 'level-up') {
parts.push('Level up')
} 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') {
parts.push('Trade')
} 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) {
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) {
parts.push(evo.condition)