Align repo config with global development standards
Some checks failed
CI / backend-lint (push) Failing after 1m4s
CI / actions-lint (push) Failing after 6s
CI / frontend-lint (push) Successful in 59s

- Add missing tsconfig strictness flags (noUncheckedIndexedAccess,
  exactOptionalPropertyTypes, noImplicitOverride,
  noPropertyAccessFromIndexSignature) and fix all resulting type errors
- Replace ESLint/Prettier with oxlint 1.48.0 and oxfmt 0.33.0
- Pin all frontend and backend dependencies to exact versions
- Pin GitHub Actions to SHA hashes with persist-credentials: false
- Fix CI Python version mismatch (3.12 -> 3.14) and ruff target-version
- Add vitest 4.0.18 with jsdom environment for frontend testing
- Add ty 0.0.17 for Python type checking (non-blocking in CI)
- Add actionlint and zizmor CI job for workflow linting and security audit
- Add Dependabot config for npm, pip, and github-actions
- Update CLAUDE.md and pre-commit hooks to reflect new tooling
- Ignore Claude Code sandbox artifacts in gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 20:39:41 +01:00
parent e4814250db
commit 3a64661760
91 changed files with 2073 additions and 3215 deletions

9
frontend/.oxlintrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"plugins": ["typescript", "import", "unicorn", "react"],
"rules": {
"react/exhaustive-deps": "warn",
"react/rules-of-hooks": "error",
"unicorn/no-null": "off"
},
"ignorePatterns": ["dist"]
}

View File

@@ -1,33 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import eslintConfigPrettier from 'eslint-config-prettier'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
eslintConfigPrettier,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react-hooks/set-state-in-effect': 'off',
'react-hooks/preserve-manual-memoization': 'off',
},
},
])

File diff suppressed because it is too large Load Diff

View File

@@ -6,35 +6,34 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"lint": "oxlint src/",
"format": "oxfmt --write src/",
"format:check": "oxfmt --check src/",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.90.20",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"sonner": "^2.0.7"
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@tanstack/react-query": "5.90.20",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-router-dom": "7.13.0",
"sonner": "2.0.7"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"@tailwindcss/vite": "4.1.18",
"@types/node": "24.10.10",
"@types/react": "19.2.11",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.3",
"oxfmt": "0.33.0",
"oxlint": "1.48.0",
"tailwindcss": "4.1.18",
"typescript": "5.9.3",
"vite": "7.3.1",
"vitest": "4.0.18"
}
}

View File

@@ -42,18 +42,12 @@ function App() {
<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

@@ -30,11 +30,9 @@ import type {
import type { Genlocke } from '../types/game'
// Games
export const createGame = (data: CreateGameInput) =>
api.post<Game>('/games', data)
export const createGame = (data: CreateGameInput) => api.post<Game>('/games', data)
export const updateGame = (id: number, data: UpdateGameInput) =>
api.put<Game>(`/games/${id}`, data)
export const updateGame = (id: number, data: UpdateGameInput) => api.put<Game>(`/games/${id}`, data)
export const deleteGame = (id: number) => api.del(`/games/${id}`)
@@ -42,11 +40,8 @@ export const deleteGame = (id: number) => api.del(`/games/${id}`)
export const createRoute = (gameId: number, data: CreateRouteInput) =>
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}`)
@@ -55,12 +50,7 @@ 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)
@@ -69,8 +59,7 @@ export const listPokemon = (
return api.get<PaginatedPokemon>(`/pokemon?${params}`)
}
export const createPokemon = (data: CreatePokemonInput) =>
api.post<Pokemon>('/pokemon', data)
export const createPokemon = (data: CreatePokemonInput) => api.post<Pokemon>('/pokemon', data)
export const updatePokemon = (id: number, data: UpdatePokemonInput) =>
api.put<Pokemon>(`/pokemon/${id}`, data)
@@ -97,12 +86,7 @@ export const bulkImportBosses = (gameId: number, items: unknown[]) =>
api.post<BulkImportResult>(`/games/${gameId}/bosses/bulk-import`, items)
// 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)
@@ -120,8 +104,7 @@ export const updateEvolution = (id: number, data: UpdateEvolutionInput) =>
export const deleteEvolution = (id: number) => api.del(`/evolutions/${id}`)
// Export
export const exportGames = () =>
api.get<Record<string, unknown>[]>('/export/games')
export const exportGames = () => api.get<Record<string, unknown>[]>('/export/games')
export const exportGameRoutes = (gameId: number) =>
api.get<{ filename: string; data: unknown }>(`/export/games/${gameId}/routes`)
@@ -129,27 +112,19 @@ export const exportGameRoutes = (gameId: number) =>
export const exportGameBosses = (gameId: number) =>
api.get<{ filename: string; data: unknown }>(`/export/games/${gameId}/bosses`)
export const exportPokemon = () =>
api.get<Record<string, unknown>[]>('/export/pokemon')
export const exportPokemon = () => api.get<Record<string, unknown>[]>('/export/pokemon')
export const exportEvolutions = () =>
api.get<Record<string, unknown>[]>('/export/evolutions')
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
)
) => api.put<RouteEncounterDetail>(`/routes/${routeId}/pokemon/${encounterId}`, data)
export const removeRouteEncounter = (routeId: number, encounterId: number) =>
api.del(`/routes/${routeId}/pokemon/${encounterId}`)
@@ -158,11 +133,8 @@ 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}`)
@@ -170,11 +142,8 @@ 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) =>

View File

@@ -1,14 +1,7 @@
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}`)
}
@@ -17,16 +10,10 @@ 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

@@ -1,4 +1,4 @@
const API_BASE = import.meta.env.VITE_API_URL ?? ''
const API_BASE = import.meta.env['VITE_API_URL'] ?? ''
export class ApiError extends Error {
status: number

View File

@@ -14,10 +14,7 @@ export function createEncounter(
return api.post(`/runs/${runId}/encounters`, data)
}
export function updateEncounter(
id: number,
data: UpdateEncounterInput
): Promise<EncounterDetail> {
export function updateEncounter(id: number, data: UpdateEncounterInput): Promise<EncounterDetail> {
return api.patch(`/encounters/${id}`, data)
}
@@ -25,10 +22,7 @@ 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}`)
}

View File

@@ -19,10 +19,7 @@ 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

@@ -47,8 +47,5 @@ export function advanceLeg(
legOrder: number,
data?: AdvanceLegInput
): Promise<Genlocke> {
return api.post(
`/genlockes/${genlockeId}/legs/${legOrder}/advance`,
data ?? {}
)
return api.post(`/genlockes/${genlockeId}/legs/${legOrder}/advance`, data ?? {})
}

View File

@@ -16,8 +16,6 @@ export function fetchPokemonEncounterLocations(
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

@@ -1,10 +1,5 @@
import { api } from './client'
import type {
NuzlockeRun,
RunDetail,
CreateRunInput,
UpdateRunInput,
} from '../types/game'
import type { NuzlockeRun, RunDetail, CreateRunInput, UpdateRunInput } from '../types/game'
export function getRuns(): Promise<NuzlockeRun[]> {
return api.get('/runs')
@@ -18,10 +13,7 @@ export function createRun(data: CreateRunInput): Promise<NuzlockeRun> {
return api.post('/runs', data)
}
export function updateRun(
id: number,
data: UpdateRunInput
): Promise<NuzlockeRun> {
export function updateRun(id: number, data: UpdateRunInput): Promise<NuzlockeRun> {
return api.patch(`/runs/${id}`, data)
}

View File

@@ -10,14 +10,11 @@ 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
return matches.length === 1 ? (matches[0] ?? null) : null
}
export function BossDefeatModal({
@@ -46,14 +43,13 @@ export function BossDefeatModal({
)
const showPills = hasVariants && autoMatch === null
const [selectedVariant, setSelectedVariant] = useState<string | null>(
autoMatch ?? (hasVariants ? variantLabels[0] : null)
autoMatch ?? (hasVariants ? (variantLabels[0] ?? null) : 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])
@@ -72,9 +68,7 @@ export function BossDefeatModal({
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="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 */}
@@ -104,11 +98,7 @@ export function BossDefeatModal({
.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" />
)}
@@ -158,9 +148,7 @@ export function BossDefeatModal({
{!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

@@ -7,9 +7,9 @@ interface EggEncounterModalProps {
onSubmit: (data: {
routeId: number
pokemonId: number
nickname?: string
nickname?: string | undefined
status: 'caught'
catchLevel?: number
catchLevel?: number | undefined
origin: 'egg'
}) => void
onClose: () => void
@@ -87,12 +87,7 @@ export function EggEncounterModal({
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -142,7 +137,7 @@ export function EggEncounterModal({
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
{selectedPokemon.name[0].toUpperCase()}
{selectedPokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="font-medium text-gray-900 dark:text-gray-100 capitalize">
@@ -183,14 +178,10 @@ export function EggEncounterModal({
className="flex flex-col items-center p-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 text-center transition-colors"
>
{p.spriteUrl ? (
<img
src={p.spriteUrl}
alt={p.name}
className="w-10 h-10"
/>
<img src={p.spriteUrl} alt={p.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
{p.name[0].toUpperCase()}
{p.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
@@ -200,13 +191,9 @@ export function EggEncounterModal({
))}
</div>
)}
{search.length >= 2 &&
!isSearching &&
searchResults.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">
No pokemon found
</p>
)}
{search.length >= 2 && !isSearching && searchResults.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">No pokemon found</p>
)}
</>
)}
</div>

View File

@@ -1,8 +1,7 @@
export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
starter: {
label: 'Starter',
color:
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
},
gift: {
label: 'Gift',
@@ -10,18 +9,15 @@ export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
},
fossil: {
label: 'Fossil',
color:
'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
},
trade: {
label: 'Trade',
color:
'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
},
walk: {
label: 'Grass',
color:
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
color: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
},
headbutt: {
label: 'Headbutt',
@@ -33,8 +29,7 @@ export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
},
'rock-smash': {
label: 'Rock Smash',
color:
'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
},
'old-rod': {
label: 'Old Rod',
@@ -46,8 +41,7 @@ export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
},
'super-rod': {
label: 'Super Rod',
color:
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
},
}
@@ -75,8 +69,7 @@ export function getMethodLabel(method: string): string {
export function getMethodColor(method: string): string {
return (
METHOD_CONFIG[method]?.color ??
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
METHOD_CONFIG[method]?.color ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
)
}
@@ -89,12 +82,9 @@ export function EncounterMethodBadge({
}) {
const config = METHOD_CONFIG[method]
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}`}
>
<span className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}>
{config.label}
</span>
)

View File

@@ -1,43 +1,36 @@
import { useState, useEffect, useMemo } from 'react'
import { useRoutePokemon } from '../hooks/useGames'
import { useNameSuggestions } from '../hooks/useRuns'
import {
EncounterMethodBadge,
getMethodLabel,
METHOD_ORDER,
} from './EncounterMethodBadge'
import type {
Route,
EncounterDetail,
EncounterStatus,
RouteEncounterDetail,
} from '../types'
import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge'
import type { Route, EncounterDetail, EncounterStatus, RouteEncounterDetail } from '../types'
interface EncounterModalProps {
route: Route
gameId: number
runId: number
namingScheme?: string | null
isGenlocke?: boolean
existing?: EncounterDetail
dupedPokemonIds?: Set<number>
retiredPokemonIds?: Set<number>
namingScheme?: string | null | undefined
isGenlocke?: boolean | undefined
existing?: EncounterDetail | undefined
dupedPokemonIds?: Set<number> | undefined
retiredPokemonIds?: Set<number> | undefined
onSubmit: (data: {
routeId: number
pokemonId: number
nickname?: string
nickname?: string | undefined
status: EncounterStatus
catchLevel?: number
}) => void
onUpdate?: (data: {
id: number
data: {
nickname?: string
status?: EncounterStatus
faintLevel?: number
deathCause?: string
}
catchLevel?: number | undefined
}) => void
onUpdate?:
| ((data: {
id: number
data: {
nickname?: string | undefined
status?: EncounterStatus | undefined
faintLevel?: number | undefined
deathCause?: string | undefined
}
}) => void)
| undefined
onClose: () => void
isPending: boolean
}
@@ -91,11 +84,9 @@ function pickRandomPokemon(
pokemon: RouteEncounterDetail[],
dupedIds?: Set<number>
): RouteEncounterDetail | null {
const eligible = dupedIds
? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId))
: pokemon
const eligible = dupedIds ? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId)) : pokemon
if (eligible.length === 0) return null
return eligible[Math.floor(Math.random() * eligible.length)]
return eligible[Math.floor(Math.random() * eligible.length)] ?? null
}
export function EncounterModal({
@@ -112,20 +103,12 @@ export function EncounterModal({
onClose,
isPending,
}: EncounterModalProps) {
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
route.id,
gameId
)
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(route.id, gameId)
const [selectedPokemon, setSelectedPokemon] =
useState<RouteEncounterDetail | null>(null)
const [status, setStatus] = useState<EncounterStatus>(
existing?.status ?? 'caught'
)
const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null)
const [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught')
const [nickname, setNickname] = useState(existing?.nickname ?? '')
const [catchLevel, setCatchLevel] = useState<string>(
existing?.catchLevel?.toString() ?? ''
)
const [catchLevel, setCatchLevel] = useState<string>(existing?.catchLevel?.toString() ?? '')
const [faintLevel, setFaintLevel] = useState<string>('')
const [deathCause, setDeathCause] = useState('')
const [search, setSearch] = useState('')
@@ -133,8 +116,7 @@ export function EncounterModal({
const isEditing = !!existing
const showSuggestions = !!namingScheme && status === 'caught' && !isEditing
const lineagePokemonId =
isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
const lineagePokemonId = isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
const {
data: suggestions,
refetch: regenerate,
@@ -144,9 +126,7 @@ export function EncounterModal({
// Pre-select pokemon when editing
useEffect(() => {
if (existing && routePokemon) {
const match = routePokemon.find(
(rp) => rp.pokemonId === existing.pokemonId
)
const match = routePokemon.find((rp) => rp.pokemonId === existing.pokemonId)
if (match) setSelectedPokemon(match)
}
}, [existing, routePokemon])
@@ -198,12 +178,7 @@ export function EncounterModal({
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -213,9 +188,7 @@ export function EncounterModal({
</svg>
</button>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{route.name}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{route.name}</p>
</div>
<div className="px-6 py-4 space-y-4">
@@ -233,16 +206,12 @@ 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)
)
setSelectedPokemon(pickRandomPokemon(routePokemon, dupedPokemonIds))
}
}}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-300 dark:border-purple-600 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@@ -279,15 +248,12 @@ 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
@@ -305,7 +271,7 @@ export function EncounterModal({
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
{rp.pokemon.name[0].toUpperCase()}
{rp.pokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
@@ -318,19 +284,13 @@ export function EncounterModal({
: '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>
@@ -360,7 +320,7 @@ export function EncounterModal({
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
{existing.pokemon.name[0].toUpperCase()}
{existing.pokemon.name[0]?.toUpperCase()}
</div>
)}
<div>
@@ -477,53 +437,45 @@ export function EncounterModal({
)}
{/* Faint Level + Death Cause (only when editing a caught pokemon to mark dead) */}
{isEditing &&
existing?.status === 'caught' &&
existing?.faintLevel === null && (
<>
<div>
<label
htmlFor="faint-level"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Faint Level{' '}
<span className="font-normal text-gray-400">
(mark as dead)
</span>
</label>
<input
id="faint-level"
type="number"
min={1}
max={100}
value={faintLevel}
onChange={(e) => setFaintLevel(e.target.value)}
placeholder="Leave empty if still alive"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label
htmlFor="death-cause"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Cause of Death{' '}
<span className="font-normal text-gray-400">
(optional)
</span>
</label>
<input
id="death-cause"
type="text"
maxLength={100}
value={deathCause}
onChange={(e) => setDeathCause(e.target.value)}
placeholder="e.g. Crit from rival's Charizard"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</>
)}
{isEditing && existing?.status === 'caught' && existing?.faintLevel === null && (
<>
<div>
<label
htmlFor="faint-level"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Faint Level <span className="font-normal text-gray-400">(mark as dead)</span>
</label>
<input
id="faint-level"
type="number"
min={1}
max={100}
value={faintLevel}
onChange={(e) => setFaintLevel(e.target.value)}
placeholder="Leave empty if still alive"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label
htmlFor="death-cause"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Cause of Death <span className="font-normal text-gray-400">(optional)</span>
</label>
<input
id="death-cause"
type="text"
maxLength={100}
value={deathCause}
onChange={(e) => setDeathCause(e.target.value)}
placeholder="e.g. Crit from rival's Charizard"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</>
)}
</div>
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex justify-end gap-3">

View File

@@ -7,12 +7,7 @@ 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!'
@@ -31,9 +26,7 @@ export function EndRunModal({
<h2 className="text-lg font-semibold">End Run</h2>
</div>
<div className="px-6 py-6">
<p className="text-gray-600 dark:text-gray-400 mb-6">
How did your run end?
</p>
<p className="text-gray-600 dark:text-gray-400 mb-6">How did your run end?</p>
<div className="flex flex-col gap-3">
<button
onClick={() => onConfirm('completed')}

View File

@@ -32,27 +32,20 @@ export function GameCard({ game, selected, onSelect }: GameCardProps) {
/>
</div>
) : (
<div
className="w-full h-48 flex items-center justify-center"
style={{ backgroundColor }}
>
<div className="w-full h-48 flex items-center justify-center" style={{ backgroundColor }}>
<span className="text-white text-2xl font-bold text-center px-4 drop-shadow-md">
{game.name.replace('Pokemon ', '')}
</span>
</div>
)}
<div className="p-3 bg-white dark:bg-gray-800 text-left">
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
{game.name}
</h3>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{game.name}</h3>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{game.region.charAt(0).toUpperCase() + game.region.slice(1)}
</span>
{game.releaseYear && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{game.releaseYear}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">{game.releaseYear}</span>
)}
</div>
</div>
@@ -65,11 +58,7 @@ export function GameCard({ game, selected, onSelect }: GameCardProps) {
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
)}

View File

@@ -18,7 +18,7 @@ interface GameGridProps {
games: Game[]
selectedId: number | null
onSelect: (game: Game) => void
runs?: NuzlockeRun[]
runs?: NuzlockeRun[] | undefined
}
export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
@@ -27,38 +27,26 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
const [hideWithActiveRun, setHideWithActiveRun] = useState(false)
const [hideCompleted, setHideCompleted] = useState(false)
const generations = useMemo(
() => [...new Set(games.map((g) => g.generation))].sort(),
[games]
)
const generations = useMemo(() => [...new Set(games.map((g) => g.generation))].sort(), [games])
const regions = useMemo(
() => [...new Set(games.map((g) => g.region))].sort(),
[games]
)
const regions = useMemo(() => [...new Set(games.map((g) => g.region))].sort(), [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,
@@ -91,9 +79,7 @@ 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)}
@@ -114,9 +100,7 @@ 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

@@ -24,7 +24,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
/>
) : (
<div className="w-25 h-25 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
{displayPokemon.name[0].toUpperCase()}
{displayPokemon.name[0]?.toUpperCase()}
</div>
)}
@@ -35,9 +35,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
</span>
</div>
{entry.nickname && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{displayPokemon.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{displayPokemon.name}</div>
)}
<div className="flex flex-col items-center gap-0.5 mt-1">
@@ -50,9 +48,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
Lv. {entry.catchLevel} &rarr; {entry.faintLevel}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{entry.routeName}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{entry.routeName}</div>
<div className="text-[10px] text-purple-600 dark:text-purple-400 mt-0.5 font-medium">
Leg {entry.legOrder} &mdash; {entry.gameName}
@@ -134,8 +130,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>
@@ -144,9 +140,7 @@ 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

@@ -38,21 +38,13 @@ 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.faintLevel !== null && (
<div className="text-red-300">Died Lv. {leg.faintLevel}</div>
)}
{leg.deathCause && (
<div className="text-red-300 italic">{leg.deathCause}</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
@@ -84,8 +76,8 @@ function TimelineGrid({
allLegOrders: number[]
}) {
const legMap = new Map(lineage.legs.map((l) => [l.legOrder, l]))
const minLeg = lineage.legs[0].legOrder
const maxLeg = lineage.legs[lineage.legs.length - 1].legOrder
const minLeg = lineage.legs[0]!.legOrder
const maxLeg = lineage.legs[lineage.legs.length - 1]!.legOrder
return (
<div
@@ -97,18 +89,12 @@ function TimelineGrid({
{allLegOrders.map((legOrder, i) => {
const leg = legMap.get(legOrder)
const inRange = legOrder >= minLeg && legOrder <= maxLeg
const showLeftLine = inRange && i > 0 && allLegOrders[i - 1] >= minLeg
const showLeftLine = inRange && i > 0 && (allLegOrders[i - 1] ?? 0) >= minLeg
const showRightLine =
inRange &&
i < allLegOrders.length - 1 &&
allLegOrders[i + 1] <= maxLeg
inRange && i < allLegOrders.length - 1 && (allLegOrders[i + 1] ?? 0) <= maxLeg
return (
<div
key={legOrder}
className="flex justify-center relative"
style={{ height: '20px' }}
>
<div key={legOrder} className="flex justify-center relative" style={{ height: '20px' }}>
{/* Left half connector */}
{showLeftLine && (
<div className="absolute top-[9px] left-0 right-1/2 h-0.5 bg-gray-300 dark:bg-gray-600" />
@@ -132,14 +118,8 @@ function TimelineGrid({
)
}
function LineageCard({
lineage,
allLegOrders,
}: {
lineage: LineageEntry
allLegOrders: number[]
}) {
const firstLeg = lineage.legs[0]
function LineageCard({ lineage, allLegOrders }: { lineage: LineageEntry; allLegOrders: number[] }) {
const firstLeg = lineage.legs[0]!
const displayPokemon = firstLeg.currentPokemon ?? firstLeg.pokemon
return (
@@ -155,7 +135,7 @@ function LineageCard({
/>
) : (
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
{displayPokemon.name[0].toUpperCase()}
{displayPokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 mt-1 text-center">
@@ -194,11 +174,9 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
const allLegOrders = useMemo(() => {
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(() => {
@@ -241,8 +219,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>
@@ -276,7 +254,7 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
<div className="space-y-3">
{data.lineages.map((lineage) => (
<LineageCard
key={lineage.legs[0].encounterId}
key={lineage.legs[0]!.encounterId}
lineage={lineage}
allLegOrders={allLegOrders}
/>

View File

@@ -8,12 +8,7 @@ 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))
@@ -74,16 +69,14 @@ export function HofTeamModal({
/>
) : (
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
{displayPokemon.name[0].toUpperCase()}
{displayPokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize">
{enc.nickname || displayPokemon.name}
</span>
{enc.nickname && (
<span className="text-[10px] text-gray-400">
{displayPokemon.name}
</span>
<span className="text-[10px] text-gray-400">{displayPokemon.name}</span>
)}
</button>
)

View File

@@ -55,12 +55,7 @@ export function Layout() {
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Toggle menu"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{menuOpen ? (
<path
strokeLinecap="round"

View File

@@ -1,26 +1,14 @@
import type { EncounterDetail } from '../types'
import { TypeBadge } from './TypeBadge'
interface PokemonCardProps {
export interface PokemonCardProps {
encounter: EncounterDetail
showFaintLevel?: boolean
onClick?: () => void
showFaintLevel?: boolean | undefined
onClick?: (() => void) | undefined
}
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
@@ -33,14 +21,10 @@ export function PokemonCard({
} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-blue-400 transition-shadow' : ''}`}
>
{displayPokemon.spriteUrl ? (
<img
src={displayPokemon.spriteUrl}
alt={displayPokemon.name}
className="w-25 h-25"
/>
<img src={displayPokemon.spriteUrl} alt={displayPokemon.name} className="w-25 h-25" />
) : (
<div className="w-25 h-25 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
{displayPokemon.name[0].toUpperCase()}
{displayPokemon.name[0]?.toUpperCase()}
</div>
)}
@@ -53,9 +37,7 @@ export function PokemonCard({
</span>
</div>
{nickname && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{displayPokemon.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{displayPokemon.name}</div>
)}
<div className="flex flex-col items-center gap-0.5 mt-1">
@@ -70,9 +52,7 @@ export function PokemonCard({
: `Lv. ${catchLevel ?? '?'}`}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{route.name}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{route.name}</div>
{isEvolved && (
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">

View File

@@ -9,11 +9,7 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key])
if (enabledRules.length === 0) {
return (
<span className="text-sm text-gray-500 dark:text-gray-400">
No rules enabled
</span>
)
return <span className="text-sm text-gray-500 dark:text-gray-400">No rules enabled</span>
}
return (

View File

@@ -7,21 +7,14 @@ interface RuleToggleProps {
onChange: (enabled: boolean) => void
}
export function RuleToggle({
name,
description,
enabled,
onChange,
}: RuleToggleProps) {
export function RuleToggle({ name, description, enabled, onChange }: RuleToggleProps) {
const [showTooltip, setShowTooltip] = useState(false)
return (
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700 last:border-0">
<div className="flex-1 pr-4">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-gray-100">
{name}
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{name}</span>
<button
type="button"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
@@ -30,12 +23,7 @@ export function RuleToggle({
onClick={() => setShowTooltip(!showTooltip)}
aria-label={`Info about ${name}`}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -46,9 +34,7 @@ export function RuleToggle({
</button>
</div>
{showTooltip && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>
)}
</div>
<button

View File

@@ -5,8 +5,8 @@ import { RuleToggle } from './RuleToggle'
interface RulesConfigurationProps {
rules: NuzlockeRules
onChange: (rules: NuzlockeRules) => void
onReset?: () => void
hiddenRules?: Set<keyof NuzlockeRules>
onReset?: (() => void) | undefined
hiddenRules?: Set<keyof NuzlockeRules> | undefined
}
export function RulesConfiguration({
@@ -19,12 +19,8 @@ export function RulesConfiguration({
? RULE_DEFINITIONS.filter((r) => !hiddenRules.has(r.key))
: RULE_DEFINITIONS
const coreRules = visibleRules.filter((r) => r.category === 'core')
const difficultyRules = visibleRules.filter(
(r) => r.category === 'difficulty'
)
const completionRules = visibleRules.filter(
(r) => r.category === 'completion'
)
const difficultyRules = visibleRules.filter((r) => r.category === 'difficulty')
const completionRules = visibleRules.filter((r) => r.category === 'completion')
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
onChange({ ...rules, [key]: value })
@@ -60,9 +56,7 @@ export function RulesConfiguration({
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Core Rules
</h3>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Core Rules</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
The fundamental rules of a Nuzlocke challenge
</p>
@@ -105,9 +99,7 @@ export function RulesConfiguration({
{completionRules.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Completion
</h3>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Completion</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
When is the run considered complete
</p>

View File

@@ -3,7 +3,7 @@ import type { EncounterDetail } from '../types'
interface ShinyBoxProps {
encounters: EncounterDetail[]
onEncounterClick?: (encounter: EncounterDetail) => void
onEncounterClick?: ((encounter: EncounterDetail) => void) | undefined
}
export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
@@ -22,9 +22,7 @@ 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

@@ -1,10 +1,6 @@
import { useState, useMemo } from 'react'
import { useRoutePokemon } from '../hooks/useGames'
import {
EncounterMethodBadge,
getMethodLabel,
METHOD_ORDER,
} from './EncounterMethodBadge'
import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge'
import type { Route, RouteEncounterDetail } from '../types'
interface ShinyEncounterModalProps {
@@ -13,9 +9,9 @@ interface ShinyEncounterModalProps {
onSubmit: (data: {
routeId: number
pokemonId: number
nickname?: string
nickname?: string | undefined
status: 'caught'
catchLevel?: number
catchLevel?: number | undefined
isShiny: true
}) => void
onClose: () => void
@@ -50,13 +46,9 @@ export function ShinyEncounterModal({
isPending,
}: ShinyEncounterModalProps) {
const [selectedRouteId, setSelectedRouteId] = useState<number | null>(null)
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
selectedRouteId,
gameId
)
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(selectedRouteId, gameId)
const [selectedPokemon, setSelectedPokemon] =
useState<RouteEncounterDetail | null>(null)
const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null)
const [nickname, setNickname] = useState('')
const [catchLevel, setCatchLevel] = useState<string>('')
const [search, setSearch] = useState('')
@@ -111,12 +103,7 @@ export function ShinyEncounterModal({
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -203,21 +190,18 @@ export function ShinyEncounterModal({
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
{rp.pokemon.name[0].toUpperCase()}
{rp.pokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
{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,7 +1,7 @@
interface StatCardProps {
label: string
value: number
total?: number
total?: number | undefined
color: string
}
@@ -22,10 +22,7 @@ export function StatCard({ label, value, total, color }: StatCardProps) {
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{value}
{total !== undefined && (
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
{' '}
/ {total}
</span>
<span className="text-sm font-normal text-gray-500 dark:text-gray-400"> / {total}</span>
)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">{label}</div>

View File

@@ -1,9 +1,5 @@
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'
@@ -25,24 +21,14 @@ 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('')
@@ -115,12 +101,7 @@ export function StatusChangeModal({
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -142,7 +123,7 @@ export function StatusChangeModal({
/>
) : (
<div className="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
{displayPokemon.name[0].toUpperCase()}
{displayPokemon.name[0]?.toUpperCase()}
</div>
)}
<div>
@@ -179,55 +160,46 @@ export function StatusChangeModal({
</div>
{faintLevel !== null && (
<div className="text-sm text-gray-700 dark:text-gray-300">
<span className="text-gray-500 dark:text-gray-400">
Level at death:
</span>{' '}
<span className="text-gray-500 dark:text-gray-400">Level at death:</span>{' '}
{faintLevel}
</div>
)}
{deathCause && (
<div className="text-sm text-gray-700 dark:text-gray-300">
<span className="text-gray-500 dark:text-gray-400">
Cause:
</span>{' '}
{deathCause}
<span className="text-gray-500 dark:text-gray-400">Cause:</span> {deathCause}
</div>
)}
</div>
)}
{/* Alive pokemon: actions */}
{!isDead &&
!showConfirm &&
!showEvolve &&
!showFormChange &&
!showShedConfirm && (
<div className="flex gap-3">
{!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm && (
<div className="flex gap-3">
<button
type="button"
onClick={() => setShowEvolve(true)}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
Evolve
</button>
{forms && forms.length > 0 && (
<button
type="button"
onClick={() => setShowEvolve(true)}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
onClick={() => setShowFormChange(true)}
className="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors"
>
Evolve
Change Form
</button>
{forms && forms.length > 0 && (
<button
type="button"
onClick={() => setShowFormChange(true)}
className="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors"
>
Change Form
</button>
)}
<button
type="button"
onClick={() => setShowConfirm(true)}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
>
Mark as Dead
</button>
</div>
)}
)}
<button
type="button"
onClick={() => setShowConfirm(true)}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
>
Mark as Dead
</button>
</div>
)}
{/* Evolution selection */}
{!isDead && showEvolve && (
@@ -245,14 +217,10 @@ 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">
@@ -272,7 +240,7 @@ export function StatusChangeModal({
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
{evo.toPokemon.name[0].toUpperCase()}
{evo.toPokemon.name[0]?.toUpperCase()}
</div>
)}
<div className="text-left">
@@ -320,16 +288,12 @@ export function StatusChangeModal({
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
{shedCompanion.toPokemon.name[0].toUpperCase()}
{shedCompanion.toPokemon.name[0]?.toUpperCase()}
</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>
@@ -338,8 +302,7 @@ export function StatusChangeModal({
htmlFor="shed-nickname"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Nickname{' '}
<span className="font-normal text-gray-400">(optional)</span>
Nickname <span className="font-normal text-gray-400">(optional)</span>
</label>
<input
id="shed-nickname"
@@ -366,9 +329,7 @@ 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>
@@ -400,14 +361,10 @@ export function StatusChangeModal({
className="w-full flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 hover:border-purple-300 dark:hover:border-purple-600 transition-colors disabled:opacity-50"
>
{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()}
{form.name[0]?.toUpperCase()}
</div>
)}
<div className="text-left">
@@ -441,8 +398,7 @@ export function StatusChangeModal({
htmlFor="death-level"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Level at Death{' '}
<span className="font-normal text-gray-400">(optional)</span>
Level at Death <span className="font-normal text-gray-400">(optional)</span>
</label>
<input
id="death-level"
@@ -461,8 +417,7 @@ export function StatusChangeModal({
htmlFor="death-cause"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Cause of Death{' '}
<span className="font-normal text-gray-400">(optional)</span>
Cause of Death <span className="font-normal text-gray-400">(optional)</span>
</label>
<input
id="death-cause"
@@ -498,11 +453,7 @@ export function StatusChangeModal({
{/* Footer for dead/no-confirm/no-evolve views */}
{(isDead ||
(!isDead &&
!showConfirm &&
!showEvolve &&
!showFormChange &&
!showShedConfirm)) && (
(!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

@@ -20,10 +20,7 @@ export function StepIndicator({
const isCurrent = step === currentStep
return (
<li
key={label}
className={`flex items-center ${i < steps.length - 1 ? 'flex-1' : ''}`}
>
<li key={label} className={`flex items-center ${i < steps.length - 1 ? 'flex-1' : ''}`}>
<button
type="button"
onClick={() => isCompleted && onStepClick(step)}
@@ -53,11 +50,7 @@ export function StepIndicator({
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
step
@@ -68,9 +61,7 @@ export function StepIndicator({
{i < steps.length - 1 && (
<div
className={`flex-1 h-0.5 mx-3 ${
step < currentStep
? 'bg-blue-600'
: 'bg-gray-300 dark:bg-gray-600'
step < currentStep ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
/>
)}

View File

@@ -8,15 +8,8 @@ interface TransferModalProps {
isPending: boolean
}
export function TransferModal({
hofTeam,
onSubmit,
onSkip,
isPending,
}: TransferModalProps) {
const [selected, setSelected] = useState<Set<number>>(
() => new Set(hofTeam.map((e) => e.id))
)
export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: TransferModalProps) {
const [selected, setSelected] = useState<Set<number>>(() => new Set(hofTeam.map((e) => e.id)))
const toggle = (id: number) => {
setSelected((prev) => {
@@ -39,8 +32,8 @@ export function TransferModal({
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>
@@ -69,20 +62,16 @@ export function TransferModal({
/>
) : (
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
{displayPokemon.name[0].toUpperCase()}
{displayPokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize">
{enc.nickname || displayPokemon.name}
</span>
{enc.nickname && (
<span className="text-[10px] text-gray-400">
{displayPokemon.name}
</span>
<span className="text-[10px] text-gray-400">{displayPokemon.name}</span>
)}
<span className="text-[10px] text-gray-400 mt-0.5">
{enc.route.name}
</span>
<span className="text-[10px] text-gray-400 mt-0.5">{enc.route.name}</span>
</button>
)
})}

View File

@@ -5,7 +5,5 @@ 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`} />
)
return <img src={`/types/${type}.png`} alt={type} className={`${height} w-auto`} />
}

View File

@@ -79,10 +79,7 @@ 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>
))}
@@ -114,9 +111,7 @@ 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">
@@ -138,9 +133,7 @@ export function AdminTable<T>({
key={keyFn(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={
onRowClick
? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800'
: ''
onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''
}
>
{columns.map((col) => (

View File

@@ -1,10 +1,7 @@
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
@@ -70,9 +67,7 @@ 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 ?? '')
@@ -212,9 +207,7 @@ 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)}
@@ -232,9 +225,7 @@ 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)}
@@ -261,9 +252,7 @@ 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

@@ -53,34 +53,22 @@ 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)))
}
@@ -108,16 +96,10 @@ export function BossTeamEditor({
}))
}
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)),
}))
}
@@ -138,8 +120,9 @@ export function BossTeamEditor({
}
const removeVariant = (tabIndex: number) => {
if (variants[tabIndex].label === null) return
if (!window.confirm(`Remove variant "${variants[tabIndex].label}"?`)) return
const variant = variants[tabIndex]
if (!variant || variant.label === null) return
if (!window.confirm(`Remove variant "${variant.label}"?`)) return
setVariants((prev) => prev.filter((_, i) => i !== tabIndex))
setActiveTab((prev) => Math.min(prev, variants.length - 2))
}
@@ -148,15 +131,14 @@ export function BossTeamEditor({
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++) {
const p = validPokemon[i]
if (!p?.pokemonId) continue
allPokemon.push({
pokemonId: validPokemon[i].pokemonId!,
level: Number(validPokemon[i].level),
pokemonId: p.pokemonId,
level: Number(p.level),
order: i + 1,
conditionLabel,
})
@@ -247,11 +229,8 @@ export function BossTeamEditor({
<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"
>
{activeVariant?.pokemon.map((slot, index) => (
<div key={`${activeTab}-${index}`} className="flex items-end gap-2">
<div className="flex-1">
<PokemonSelector
label={`Pokemon ${index + 1}`}
@@ -261,9 +240,7 @@ export function BossTeamEditor({
/>
</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}
@@ -284,7 +261,7 @@ export function BossTeamEditor({
</div>
))}
{activeVariant.pokemon.length < 6 && (
{activeVariant && activeVariant.pokemon.length < 6 && (
<button
type="button"
onClick={addSlot}

View File

@@ -60,9 +60,7 @@ export function BulkImportModal({
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-1">
JSON Data
</label>
<label className="block text-sm font-medium mb-1">JSON Data</label>
<textarea
rows={12}
value={json}
@@ -81,8 +79,7 @@ export function BulkImportModal({
{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}
{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">

View File

@@ -20,17 +20,9 @@ export function DeleteConfirmModal({
<div className="fixed inset-0 bg-black/50" onClick={onCancel} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4">
<h2 className="text-lg font-semibold text-red-600 dark:text-red-400">
{title}
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
{message}
</p>
{error && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
<h2 className="text-lg font-semibold text-red-600 dark:text-red-400">{title}</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">{message}</p>
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button

View File

@@ -1,11 +1,7 @@
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
@@ -29,9 +25,7 @@ export function EvolutionFormModal({
const [fromPokemonId, setFromPokemonId] = useState<number | null>(
evolution?.fromPokemonId ?? null
)
const [toPokemonId, setToPokemonId] = useState<number | null>(
evolution?.toPokemonId ?? null
)
const [toPokemonId, setToPokemonId] = useState<number | null>(evolution?.toPokemonId ?? null)
const [trigger, setTrigger] = useState(evolution?.trigger ?? 'level-up')
const [minLevel, setMinLevel] = useState(String(evolution?.minLevel ?? ''))
const [item, setItem] = useState(evolution?.item ?? '')

View File

@@ -5,11 +5,11 @@ interface FormModalProps {
onClose: () => void
onSubmit: (e: FormEvent) => void
children: ReactNode
submitLabel?: string
isSubmitting?: boolean
onDelete?: () => void
isDeleting?: boolean
headerExtra?: ReactNode
submitLabel?: string | undefined
isSubmitting?: boolean | undefined
onDelete?: (() => void) | undefined
isDeleting?: boolean | undefined
headerExtra?: ReactNode | undefined
}
export function FormModal({
@@ -55,11 +55,7 @@ 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

@@ -34,9 +34,7 @@ export function GameFormModal({
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(() => {
@@ -65,10 +63,7 @@ export function GameFormModal({
isDeleting={isDeleting}
headerExtra={
detailUrl ? (
<Link
to={detailUrl}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
<Link to={detailUrl} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
View Routes & Bosses
</Link>
) : undefined

View File

@@ -9,10 +9,7 @@ import type {
EvolutionAdmin,
UpdateEvolutionInput,
} from '../../types'
import {
usePokemonEncounterLocations,
usePokemonEvolutionChain,
} from '../../hooks/usePokemon'
import { usePokemonEncounterLocations, usePokemonEvolutionChain } from '../../hooks/usePokemon'
import { useUpdateEvolution, useDeleteEvolution } from '../../hooks/useAdmin'
import { formatEvolutionMethod } from '../../utils/formatEvolution'
@@ -36,23 +33,19 @@ export function PokemonFormModal({
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: evolutionChain, isLoading: evolutionsLoading } = usePokemonEvolutionChain(pokemonId)
const queryClient = useQueryClient()
const updateEvolution = useUpdateEvolution()
@@ -103,9 +96,7 @@ export function PokemonFormModal({
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] flex flex-col">
{/* 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) => (
@@ -124,15 +115,10 @@ export function PokemonFormModal({
{/* 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
@@ -143,9 +129,7 @@ export function PokemonFormModal({
/>
</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
@@ -166,9 +150,7 @@ export function PokemonFormModal({
/>
</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
@@ -179,9 +161,7 @@ export function PokemonFormModal({
/>
</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}
@@ -206,11 +186,7 @@ export function PokemonFormModal({
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" />
@@ -237,35 +213,28 @@ export function PokemonFormModal({
<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 && (
<div className="space-y-1">
{evolutionChain.map((evo) => (
<button
key={evo.id}
type="button"
onClick={() => setEditingEvolution(evo)}
className="w-full text-left text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-2 py-1.5 -mx-2 transition-colors"
>
{evo.fromPokemon.name} {evo.toPokemon.name}{' '}
<span className="text-gray-400 dark:text-gray-500">
({formatEvolutionMethod(evo)})
</span>
</button>
))}
</div>
)}
{!evolutionsLoading &&
(!evolutionChain || evolutionChain.length === 0) && (
<p className="text-sm text-gray-500 dark:text-gray-400">
No evolutions
</p>
)}
{!evolutionsLoading &&
evolutionChain &&
evolutionChain.length > 0 && (
<div className="space-y-1">
{evolutionChain.map((evo) => (
<button
key={evo.id}
type="button"
onClick={() => setEditingEvolution(evo)}
className="w-full text-left text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-2 py-1.5 -mx-2 transition-colors"
>
{evo.fromPokemon.name} {evo.toPokemon.name}{' '}
<span className="text-gray-400 dark:text-gray-500">
({formatEvolutionMethod(evo)})
</span>
</button>
))}
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
<button
@@ -284,48 +253,40 @@ export function PokemonFormModal({
<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 && (
<div className="space-y-3">
{encounterLocations.map((game) => (
<div key={game.gameId}>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{game.gameName}
</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"
>
<Link
to={`/admin/games/${game.gameId}/routes/${enc.routeId}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{enc.routeName}
</Link>
<span className="text-gray-400 dark:text-gray-500">
{enc.encounterMethod}, Lv. {enc.minLevel}
{enc.maxLevel}
</span>
</div>
))}
</div>
{!encountersLoading && (!encounterLocations || encounterLocations.length === 0) && (
<p className="text-sm text-gray-500 dark:text-gray-400">No encounters</p>
)}
{!encountersLoading && encounterLocations && encounterLocations.length > 0 && (
<div className="space-y-3">
{encounterLocations.map((game) => (
<div key={game.gameId}>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{game.gameName}
</div>
))}
</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"
>
<Link
to={`/admin/games/${game.gameId}/routes/${enc.routeId}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{enc.routeName}
</Link>
<span className="text-gray-400 dark:text-gray-500">
{enc.encounterMethod}, Lv. {enc.minLevel}{enc.maxLevel}
</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
<button

View File

@@ -4,7 +4,7 @@ import { usePokemonList } from '../../hooks/useAdmin'
interface PokemonSelectorProps {
label: string
selectedId: number | null
initialName?: string
initialName?: string | undefined
onChange: (id: number | null) => void
}
@@ -46,9 +46,7 @@ export function PokemonSelector({
placeholder="Search pokemon..."
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
{selectedId && (
<input type="hidden" name={label} value={selectedId} required />
)}
{selectedId && <input type="hidden" name={label} value={selectedId} required />}
{open && pokemon.length > 0 && (
<ul className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg max-h-48 overflow-y-auto">
{pokemon.map((p) => (
@@ -63,9 +61,7 @@ export function PokemonSelector({
p.id === selectedId ? 'bg-blue-50 dark:bg-blue-900/30' : ''
}`}
>
{p.spriteUrl && (
<img src={p.spriteUrl} alt="" className="w-6 h-6" />
)}
{p.spriteUrl && <img src={p.spriteUrl} alt="" className="w-6 h-6" />}
<span>
#{p.nationalDex} {p.name}
</span>

View File

@@ -1,11 +1,7 @@
import { type FormEvent, useState } from 'react'
import { FormModal } from './FormModal'
import { PokemonSelector } from './PokemonSelector'
import {
METHOD_ORDER,
METHOD_CONFIG,
getMethodLabel,
} from '../EncounterMethodBadge'
import { METHOD_ORDER, METHOD_CONFIG, getMethodLabel } from '../EncounterMethodBadge'
import type {
RouteEncounterDetail,
CreateRouteEncounterInput,
@@ -14,9 +10,7 @@ import type {
interface RouteEncounterFormModalProps {
encounter?: RouteEncounterDetail
onSubmit: (
data: CreateRouteEncounterInput | UpdateRouteEncounterInput
) => void
onSubmit: (data: CreateRouteEncounterInput | UpdateRouteEncounterInput) => void
onClose: () => void
isSubmitting?: boolean
onDelete?: () => void
@@ -38,15 +32,10 @@ export function RouteEncounterFormModal({
const [selectedMethod, setSelectedMethod] = useState(
isKnownMethod ? initialMethod : initialMethod ? 'other' : ''
)
const [customMethod, setCustomMethod] = useState(
isKnownMethod ? '' : initialMethod
)
const encounterMethod =
selectedMethod === 'other' ? customMethod : selectedMethod
const [customMethod, setCustomMethod] = useState(isKnownMethod ? '' : initialMethod)
const encounterMethod = selectedMethod === 'other' ? customMethod : selectedMethod
const [encounterRate, setEncounterRate] = useState(
String(encounter?.encounterRate ?? '')
)
const [encounterRate, setEncounterRate] = useState(String(encounter?.encounterRate ?? ''))
const [minLevel, setMinLevel] = useState(String(encounter?.minLevel ?? ''))
const [maxLevel, setMaxLevel] = useState(String(encounter?.maxLevel ?? ''))
@@ -87,9 +76,7 @@ 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}
@@ -126,9 +113,7 @@ 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

@@ -49,10 +49,7 @@ export function RouteFormModal({
isDeleting={isDeleting}
headerExtra={
detailUrl ? (
<Link
to={detailUrl}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
<Link to={detailUrl} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
View Encounters
</Link>
) : undefined
@@ -90,8 +87,7 @@ export function RouteFormModal({
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
<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,12 +23,7 @@ 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),
@@ -92,13 +87,8 @@ 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'] })
@@ -124,8 +114,7 @@ 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'] })
@@ -188,9 +177,7 @@ export function useBulkImportPokemon() {
) => 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}`),
})
@@ -202,9 +189,7 @@ 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}`),
})
@@ -217,9 +202,7 @@ 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}`),
})
@@ -239,12 +222,7 @@ 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),
@@ -293,8 +271,7 @@ export function useDeleteEvolution() {
export function useAddRouteEncounter(routeId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: CreateRouteEncounterInput) =>
adminApi.addRouteEncounter(routeId, data),
mutationFn: (data: CreateRouteEncounterInput) => adminApi.addRouteEncounter(routeId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
toast.success('Encounter added')
@@ -306,13 +283,8 @@ 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')
@@ -324,8 +296,7 @@ export function useUpdateRouteEncounter(routeId: number) {
export function useRemoveRouteEncounter(routeId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: (encounterId: number) =>
adminApi.removeRouteEncounter(routeId, encounterId),
mutationFn: (encounterId: number) => adminApi.removeRouteEncounter(routeId, encounterId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
toast.success('Encounter removed')
@@ -339,41 +310,32 @@ 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')
@@ -390,16 +352,14 @@ 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')
@@ -438,8 +398,7 @@ 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] })
@@ -452,8 +411,7 @@ 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,11 +1,6 @@
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

@@ -57,11 +57,7 @@ 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),
@@ -81,11 +77,7 @@ export function useAdvanceLeg() {
legOrder: number
transferEncounterIds?: number[]
}) =>
advanceLeg(
genlockeId,
legOrder,
transferEncounterIds ? { transferEncounterIds } : undefined
),
advanceLeg(genlockeId, legOrder, transferEncounterIds ? { transferEncounterIds } : undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs'] })
queryClient.invalidateQueries({ queryKey: ['genlockes'] })

View File

@@ -68,10 +68,7 @@ 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,12 +1,7 @@
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'
@@ -23,8 +18,7 @@ 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',
}
@@ -48,19 +42,14 @@ function LegIndicator({ leg }: { leg: GenlockeLegDetail }) {
{leg.game.name}
</span>
{status && (
<span className="text-[10px] text-gray-500 dark:text-gray-400 capitalize">
{status}
</span>
<span className="text-[10px] text-gray-500 dark:text-gray-400 capitalize">{status}</span>
)}
</div>
)
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>
)
@@ -86,7 +75,7 @@ function PokemonSprite({ pokemon }: { pokemon: RetiredPokemon }) {
className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-sm font-bold"
title={pokemon.name}
>
{pokemon.name[0].toUpperCase()}
{pokemon.name[0]?.toUpperCase()}
</div>
)
}
@@ -116,9 +105,7 @@ 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>()
@@ -170,16 +157,11 @@ export function GenlockeDetail() {
<div className="max-w-4xl mx-auto p-8 space-y-8">
{/* Header */}
<div>
<Link
to="/genlockes"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
<Link to="/genlockes" className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
&larr; Back to Genlockes
</Link>
<div className="flex items-center gap-3 mt-2">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{genlocke.name}
</h1>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{genlocke.name}</h1>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${statusStyles[genlocke.status]}`}
>
@@ -190,9 +172,7 @@ export function GenlockeDetail() {
{/* Progress Timeline */}
<section>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Progress
</h2>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Progress</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-start gap-2 overflow-x-auto pb-2">
{genlocke.legs.map((leg, i) => (
@@ -201,9 +181,7 @@ export function GenlockeDetail() {
{i < genlocke.legs.length - 1 && (
<div
className={`h-0.5 w-6 mx-1 mt-[-16px] ${
leg.runStatus === 'completed'
? 'bg-blue-500'
: 'bg-gray-300 dark:bg-gray-600'
leg.runStatus === 'completed' ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
/>
)}
@@ -219,16 +197,8 @@ 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}
@@ -278,10 +248,7 @@ export function GenlockeDetail() {
</h2>
<div className="space-y-3">
{retiredByLeg.map((leg) => (
<div
key={leg.legOrder}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4"
>
<div key={leg.legOrder} className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Leg {leg.legOrder} &mdash; {leg.gameName}
</h3>

View File

@@ -3,8 +3,7 @@ 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',
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',
}
@@ -15,9 +14,7 @@ export function GenlockeList() {
return (
<div className="max-w-4xl mx-auto p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
Your Genlockes
</h1>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Your Genlockes</h1>
<Link
to="/genlockes/new"
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"

View File

@@ -3,8 +3,7 @@ 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',
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',
}
@@ -75,10 +74,7 @@ export function Home() {
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
Recent Runs
</h2>
<Link
to="/runs"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
<Link to="/runs" className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
View all
</Link>
</div>
@@ -91,9 +87,7 @@ export function Home() {
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{run.name}
</h3>
<h3 className="font-medium text-gray-900 dark:text-gray-100">{run.name}</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{new Date(run.startedAt).toLocaleDateString(undefined, {
year: 'numeric',

View File

@@ -18,10 +18,7 @@ interface LegEntry {
type PresetType = 'true' | 'normal' | 'custom' | null
function buildLegsFromPreset(
regions: Region[],
preset: 'true' | 'normal'
): LegEntry[] {
function buildLegsFromPreset(regions: Region[], preset: 'true' | 'normal'): LegEntry[] {
const legs: LegEntry[] = []
for (const region of regions) {
const targetSlug =
@@ -45,8 +42,7 @@ 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 [nuzlockeRules, setNuzlockeRules] = useState<NuzlockeRules>(DEFAULT_RULES)
const [genlockeRules, setGenlockeRules] = useState<GenlockeRules>({
retireHoF: false,
})
@@ -64,9 +60,7 @@ 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) => {
@@ -75,8 +69,7 @@ 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 }])
}
@@ -87,7 +80,7 @@ export function NewGenlocke() {
if (target < 0 || target >= legs.length) return
setLegs((prev) => {
const next = [...prev]
;[next[index], next[target]] = [next[target], next[index]]
;[next[index], next[target]] = [next[target]!, next[index]!]
return next
})
}
@@ -115,23 +108,16 @@ export function NewGenlocke() {
)
}
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">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
New Genlocke
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Set up your generational challenge.
</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">New Genlocke</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">Set up your generational challenge.</p>
<StepIndicator currentStep={step} onStepClick={setStep} steps={STEPS} />
@@ -250,17 +236,11 @@ export function NewGenlocke() {
)}
{/* Also allow adding extra regions for presets */}
{preset &&
preset !== 'custom' &&
availableRegions.length > 0 &&
legs.length > 0 && (
<div className="mt-4">
<AddLegDropdown
regions={availableRegions}
onAdd={handleAddLeg}
/>
</div>
)}
{preset && preset !== 'custom' && availableRegions.length > 0 && legs.length > 0 && (
<div className="mt-4">
<AddLegDropdown regions={availableRegions} onAdd={handleAddLeg} />
</div>
)}
<div className="mt-6 flex justify-between">
<button
@@ -285,10 +265,7 @@ 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">
@@ -319,8 +296,7 @@ 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>
@@ -337,8 +313,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>
@@ -354,8 +330,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">
@@ -402,12 +378,8 @@ export function NewGenlocke() {
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-4">
<div>
<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>
<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>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
@@ -426,8 +398,7 @@ 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>
@@ -436,34 +407,25 @@ export function NewGenlocke() {
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
Rules
</h3>
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Rules</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>
@@ -548,9 +510,7 @@ function LegRow({
))}
</select>
) : (
<div className="text-gray-900 dark:text-gray-100 font-medium">
{leg.game.name}
</div>
<div className="text-gray-900 dark:text-gray-100 font-medium">{leg.game.name}</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
@@ -568,11 +528,7 @@ function LegRow({
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 15l7-7 7 7"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
@@ -589,11 +545,7 @@ function LegRow({
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 9l-7 7-7-7"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<button
@@ -609,11 +561,7 @@ function LegRow({
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@@ -644,11 +592,7 @@ function AddLegDropdown({
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4v16m8-8H4"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add Region
</button>

View File

@@ -51,20 +51,14 @@ export function NewRun() {
)
}
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
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
New Nuzlocke Run
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Set up your run in a few steps.
</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">New Nuzlocke Run</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">Set up your run in a few steps.</p>
<StepIndicator currentStep={step} onStepClick={setStep} />
@@ -84,8 +78,7 @@ 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>
@@ -138,11 +131,7 @@ 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
@@ -209,16 +198,13 @@ 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>
)}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Summary
</h3>
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Summary</h3>
<dl className="space-y-1 text-sm">
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Game</dt>
@@ -230,8 +216,7 @@ export function NewRun() {
<dt className="text-gray-600 dark:text-gray-400">Region</dt>
<dd className="text-gray-900 dark:text-gray-100 font-medium">
{selectedGame &&
selectedGame.region.charAt(0).toUpperCase() +
selectedGame.region.slice(1)}
selectedGame.region.charAt(0).toUpperCase() + selectedGame.region.slice(1)}
</dd>
</div>
<div className="flex justify-between">
@@ -241,13 +226,10 @@ 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,21 +3,12 @@ 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':
@@ -31,8 +22,7 @@ function sortEncounters(
}
case 'dex':
return (
(a.currentPokemon ?? a.pokemon).nationalDex -
(b.currentPokemon ?? b.pokemon).nationalDex
(a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
)
default:
return 0
@@ -41,8 +31,7 @@ function sortEncounters(
}
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',
}
@@ -64,8 +53,7 @@ export function RunDashboard() {
const updateEncounter = useUpdateEncounter(runIdNum)
const updateRun = useUpdateRun(runIdNum)
const { data: namingCategories } = useNamingCategories()
const [selectedEncounter, setSelectedEncounter] =
useState<EncounterDetail | null>(null)
const [selectedEncounter, setSelectedEncounter] = useState<EncounterDetail | null>(null)
const [showEndRun, setShowEndRun] = useState(false)
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
@@ -73,9 +61,7 @@ export function RunDashboard() {
const alive = useMemo(
() =>
sortEncounters(
encounters.filter(
(e) => e.status === 'caught' && e.faintLevel === null
),
encounters.filter((e) => e.status === 'caught' && e.faintLevel === null),
teamSort
),
[encounters, teamSort]
@@ -83,9 +69,7 @@ export function RunDashboard() {
const dead = useMemo(
() =>
sortEncounters(
encounters.filter(
(e) => e.status === 'caught' && e.faintLevel !== null
),
encounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
teamSort
),
[encounters, teamSort]
@@ -105,10 +89,7 @@ export function RunDashboard() {
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
Failed to load run. It may not exist.
</div>
<Link
to="/runs"
className="inline-block mt-4 text-blue-600 hover:underline"
>
<Link to="/runs" className="inline-block mt-4 text-blue-600 hover:underline">
Back to runs
</Link>
</div>
@@ -131,14 +112,10 @@ export function RunDashboard() {
</Link>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{run.name}
</h1>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{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.region.charAt(0).toUpperCase() + run.game.region.slice(1)} &middot; Started{' '}
{new Date(run.startedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
@@ -204,26 +181,15 @@ export function RunDashboard() {
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<StatCard
label="Encounters"
value={run.encounters.length}
color="blue"
/>
<StatCard label="Encounters" value={run.encounters.length} color="blue" />
<StatCard label="Alive" value={alive.length} color="green" />
<StatCard label="Deaths" value={dead.length} color="red" />
<StatCard
label="Routes Visited"
value={visitedRoutes}
total={totalRoutes}
color="purple"
/>
<StatCard label="Routes Visited" value={visitedRoutes} total={totalRoutes} color="purple" />
</div>
{/* Rules */}
<div className="mb-6">
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Active Rules
</h2>
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Active Rules</h2>
<RuleBadges rules={run.rules} />
</div>
@@ -236,9 +202,7 @@ export function RunDashboard() {
{isActive ? (
<select
value={run.namingScheme ?? ''}
onChange={(e) =>
updateRun.mutate({ namingScheme: e.target.value || null })
}
onChange={(e) => updateRun.mutate({ namingScheme: e.target.value || null })}
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">None</option>
@@ -251,8 +215,7 @@ 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>
)}
@@ -280,8 +243,7 @@ export function RunDashboard() {
</div>
{alive.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">
No pokemon caught yet head to encounters to start building your
team!
No pokemon caught yet head to encounters to start building your team!
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
@@ -299,9 +261,7 @@ export function RunDashboard() {
{/* Graveyard */}
{dead.length > 0 && (
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Graveyard
</h2>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Graveyard</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
{dead.map((enc) => (
<PokemonCard
@@ -357,10 +317,7 @@ export function RunDashboard() {
{showEndRun && (
<EndRunModal
onConfirm={(status) => {
updateRun.mutate(
{ status },
{ onSuccess: () => setShowEndRun(false) }
)
updateRun.mutate({ status }, { onSuccess: () => setShowEndRun(false) })
}}
onClose={() => setShowEndRun(false)}
isPending={updateRun.isPending}

View File

@@ -3,17 +3,9 @@ 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,
@@ -43,10 +35,7 @@ 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':
@@ -60,8 +49,7 @@ function sortEncounters(
}
case 'dex':
return (
(a.currentPokemon ?? a.pokemon).nationalDex -
(b.currentPokemon ?? b.pokemon).nationalDex
(a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
)
default:
return 0
@@ -70,8 +58,7 @@ function sortEncounters(
}
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',
}
@@ -91,10 +78,7 @@ function getRouteStatus(encounter?: EncounterDetail): RouteStatus {
return encounter.status
}
const statusIndicator: Record<
RouteStatus,
{ dot: string; label: string; bg: string }
> = {
const statusIndicator: Record<RouteStatus, { dot: string; label: string; bg: string }> = {
caught: {
dot: 'bg-green-500',
label: 'Caught',
@@ -186,14 +170,11 @@ 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
return matches.length === 1 ? (matches[0] ?? null) : null
}
function BossTeamPreview({
@@ -218,14 +199,13 @@ function BossTeamPreview({
)
const showPills = hasVariants && autoMatch === null
const [selectedVariant, setSelectedVariant] = useState<string | null>(
autoMatch ?? (hasVariants ? variantLabels[0] : null)
autoMatch ?? (hasVariants ? (variantLabels[0] ?? null) : 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])
@@ -255,17 +235,11 @@ function BossTeamPreview({
.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" />
)}
<span className="text-xs text-gray-500 dark:text-gray-400">
Lvl {bp.level}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">Lvl {bp.level}</span>
</div>
))}
</div>
@@ -294,9 +268,7 @@ function RouteGroup({
}: RouteGroupProps) {
const groupEncounter = getGroupEncounter(group, encounterByRoute)
const usePinwheel = pinwheelClause && groupHasZones(group)
const zoneEncounters = usePinwheel
? getZoneEncounters(group, encounterByRoute)
: null
const zoneEncounters = usePinwheel ? getZoneEncounters(group, encounterByRoute) : null
// For pinwheel groups, determine status from all zone statuses
let groupStatus: RouteStatus
@@ -354,28 +326,19 @@ function RouteGroup({
{groupEncounter.nickname ?? groupEncounter.pokemon.name}
{groupEncounter.status === 'caught' &&
groupEncounter.faintLevel !== null &&
(groupEncounter.deathCause
? `${groupEncounter.deathCause}`
: ' (dead)')}
(groupEncounter.deathCause ? `${groupEncounter.deathCause}` : ' (dead)')}
</span>
</div>
)}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
{si.label}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">{si.label}</span>
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
@@ -409,13 +372,9 @@ function RouteGroup({
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
} ${childSi.bg}`}
>
<span
className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`}
/>
<span className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`} />
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-700 dark:text-gray-300">
{child.name}
</div>
<div className="text-sm text-gray-700 dark:text-gray-300">{child.name}</div>
{!childEncounter && child.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{child.encounterMethods.map((m) => (
@@ -425,14 +384,10 @@ function RouteGroup({
)}
</div>
{childEncounter && (
<span className="text-xs text-gray-400 dark:text-gray-500">
{childSi.label}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{childSi.label}</span>
)}
{isDisabled && (
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
(locked)
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 italic">(locked)</span>
)}
</button>
)
@@ -450,9 +405,7 @@ export function RunEncounters() {
const { data: run, isLoading, error } = useRun(runIdNum)
const advanceLeg = useAdvanceLeg()
const [showTransferModal, setShowTransferModal] = useState(false)
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
run?.gameId ?? null
)
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(run?.gameId ?? null)
const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum)
const bulkRandomize = useBulkRandomize(runIdNum)
@@ -464,10 +417,8 @@ export function RunEncounters() {
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
const [selectedBoss, setSelectedBoss] = useState<BossBattle | null>(null)
const [editingEncounter, setEditingEncounter] =
useState<EncounterDetail | null>(null)
const [selectedTeamEncounter, setSelectedTeamEncounter] =
useState<EncounterDetail | null>(null)
const [editingEncounter, setEditingEncounter] = useState<EncounterDetail | null>(null)
const [selectedTeamEncounter, setSelectedTeamEncounter] = useState<EncounterDetail | null>(null)
const [showEndRun, setShowEndRun] = useState(false)
const [showHofModal, setShowHofModal] = useState(false)
const [showShinyModal, setShowShinyModal] = useState(false)
@@ -511,32 +462,31 @@ export function RunEncounters() {
[run?.transferEncounterIds]
)
const { normalEncounters, shinyEncounters, transferEncounters } =
useMemo(() => {
if (!run)
return {
normalEncounters: [],
shinyEncounters: [],
transferEncounters: [],
}
const normal: EncounterDetail[] = []
const shiny: EncounterDetail[] = []
const transfer: EncounterDetail[] = []
for (const enc of run.encounters) {
if (transferIdSet.has(enc.id)) {
transfer.push(enc)
} else if (enc.isShiny) {
shiny.push(enc)
} else {
normal.push(enc)
}
}
const { normalEncounters, shinyEncounters, transferEncounters } = useMemo(() => {
if (!run)
return {
normalEncounters: normal,
shinyEncounters: shiny,
transferEncounters: transfer,
normalEncounters: [],
shinyEncounters: [],
transferEncounters: [],
}
}, [run, transferIdSet])
const normal: EncounterDetail[] = []
const shiny: EncounterDetail[] = []
const transfer: EncounterDetail[] = []
for (const enc of run.encounters) {
if (transferIdSet.has(enc.id)) {
transfer.push(enc)
} else if (enc.isShiny) {
shiny.push(enc)
} else {
normal.push(enc)
}
}
return {
normalEncounters: normal,
shinyEncounters: shiny,
transferEncounters: transfer,
}
}, [run, transferIdSet])
// Map routeId → encounter for quick lookup (normal encounters only)
const encounterByRoute = useMemo(() => {
@@ -638,9 +588,7 @@ export function RunEncounters() {
const currentLevelCap = useMemo(() => {
if (!nextBoss) {
// All defeated — no cap (or use last boss's level)
return sortedBosses.length > 0
? sortedBosses[sortedBosses.length - 1].levelCap
: null
return sortedBosses.length > 0 ? sortedBosses[sortedBosses.length - 1]!.levelCap : null
}
return nextBoss.levelCap
}, [nextBoss, sortedBosses])
@@ -650,8 +598,8 @@ export function RunEncounters() {
const sectionDividerAfterBoss = useMemo(() => {
const map = new Map<number, string>()
for (let i = 0; i < sortedBosses.length - 1; i++) {
const current = sortedBosses[i]
const next = sortedBosses[i + 1]
const current = sortedBosses[i]!
const next = sortedBosses[i + 1]!
if (next.section != null && current.section !== next.section) {
map.set(current.id, next.section)
}
@@ -677,8 +625,7 @@ export function RunEncounters() {
useEffect(() => {
if (organizedRoutes.length === 0 || expandedGroups.size > 0) return
const firstUnvisited = organizedRoutes.find(
(r) =>
r.children.length > 0 && getGroupEncounter(r, encounterByRoute) === null
(r) => r.children.length > 0 && getGroupEncounter(r, encounterByRoute) === null
)
if (firstUnvisited) {
updateExpandedGroups(() => new Set([firstUnvisited.id]))
@@ -699,9 +646,7 @@ export function RunEncounters() {
const dead = useMemo(
() =>
sortEncounters(
normalEncounters.filter(
(e) => e.status === 'caught' && e.faintLevel !== null
),
normalEncounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
teamSort
),
[normalEncounters, teamSort]
@@ -728,10 +673,7 @@ export function RunEncounters() {
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
Failed to load run.
</div>
<Link
to="/runs"
className="inline-block mt-4 text-blue-600 hover:underline"
>
<Link to="/runs" className="inline-block mt-4 text-blue-600 hover:underline">
Back to runs
</Link>
</div>
@@ -803,10 +745,10 @@ export function RunEncounters() {
const handleUpdate = (data: {
id: number
data: {
nickname?: string
status?: EncounterStatus
faintLevel?: number
deathCause?: string
nickname?: string | undefined
status?: EncounterStatus | undefined
faintLevel?: number | undefined
deathCause?: string | undefined
}
}) => {
updateEncounter.mutate(data, {
@@ -852,14 +794,10 @@ export function RunEncounters() {
</Link>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{run.name}
</h1>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{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.region.charAt(0).toUpperCase() + run.game.region.slice(1)} &middot; Started{' '}
{new Date(run.startedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
@@ -959,40 +897,36 @@ export function RunEncounters() {
</p>
</div>
</div>
{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,
{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,
},
{
onSuccess: (genlocke) => {
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run.genlocke!.legOrder + 1
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
},
{
onSuccess: (genlocke) => {
const nextLeg = genlocke.legs.find(
(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'}
</button>
)}
}
)
}
}}
disabled={advanceLeg.isPending}
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{advanceLeg.isPending ? 'Advancing...' : 'Advance to Next Leg'}
</button>
)}
</div>
{/* HoF Team Display */}
{run.status === 'completed' && (
@@ -1016,14 +950,10 @@ 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()}
{dp.name[0]?.toUpperCase()}
</div>
)}
<span className="text-[10px] text-blue-600 dark:text-blue-400 capitalize mt-0.5">
@@ -1045,19 +975,10 @@ export function RunEncounters() {
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<StatCard
label="Encounters"
value={normalEncounters.length}
color="blue"
/>
<StatCard label="Encounters" value={normalEncounters.length} color="blue" />
<StatCard label="Alive" value={alive.length} color="green" />
<StatCard label="Deaths" value={dead.length} color="red" />
<StatCard
label="Routes"
value={completedCount}
total={totalLocations}
color="purple"
/>
<StatCard label="Routes" value={completedCount} total={totalLocations} color="purple" />
</div>
{/* Level Cap Bar */}
@@ -1123,9 +1044,7 @@ export function RunEncounters() {
{/* Rules */}
<div className="mb-6">
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Active Rules
</h2>
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Active Rules</h2>
<RuleBadges rules={run.rules} />
</div>
@@ -1180,11 +1099,7 @@ export function RunEncounters() {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={
isActive
? () => setSelectedTeamEncounter(enc)
: undefined
}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
@@ -1200,11 +1115,7 @@ export function RunEncounters() {
key={enc.id}
encounter={enc}
showFaintLevel
onClick={
isActive
? () => setSelectedTeamEncounter(enc)
: undefined
}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
@@ -1220,9 +1131,7 @@ export function RunEncounters() {
<div className="mb-6">
<ShinyBox
encounters={shinyEncounters}
onEncounterClick={
isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined
}
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
/>
</div>
)}
@@ -1238,9 +1147,7 @@ export function RunEncounters() {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={
isActive ? () => setSelectedTeamEncounter(enc) : undefined
}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
@@ -1251,9 +1158,7 @@ export function RunEncounters() {
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Encounters
</h2>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Encounters</h2>
{isActive && completedCount < totalLocations && (
<button
type="button"
@@ -1261,9 +1166,7 @@ export function RunEncounters() {
onClick={() => {
const remaining = totalLocations - completedCount
if (
window.confirm(
`Randomize encounters for all ${remaining} remaining locations?`
)
window.confirm(`Randomize encounters for all ${remaining} remaining locations?`)
) {
bulkRandomize.mutate()
}
@@ -1325,9 +1228,7 @@ export function RunEncounters() {
{filteredRoutes.map((route) => {
// Collect all route IDs to check for boss cards after
const routeIds: number[] =
route.children.length > 0
? [route.id, ...route.children.map((c) => c.id)]
: [route.id]
route.children.length > 0 ? [route.id, ...route.children.map((c) => c.id)] : [route.id]
// Find boss battles positioned after this route (or any of its children)
const bossesHere: BossBattle[] = []
@@ -1361,9 +1262,7 @@ export function RunEncounters() {
onClick={() => handleRouteClick(route)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
>
<span
className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`}
/>
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{route.name}
@@ -1381,20 +1280,14 @@ export function RunEncounters() {
{encounter.nickname ?? encounter.pokemon.name}
{encounter.status === 'caught' &&
encounter.faintLevel !== null &&
(encounter.deathCause
? `${encounter.deathCause}`
: ' (dead)')}
(encounter.deathCause ? `${encounter.deathCause}` : ' (dead)')}
</span>
</div>
) : (
route.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{route.encounterMethods.map((m) => (
<EncounterMethodBadge
key={m}
method={m}
size="xs"
/>
<EncounterMethodBadge key={m} method={m} size="xs" />
))}
</div>
)
@@ -1449,7 +1342,7 @@ export function RunEncounters() {
return (
<div key={`boss-${boss.id}`}>
<div
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors.other} ${
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
isDefeated
? 'bg-green-50/50 dark:bg-green-900/10'
: 'bg-white dark:bg-gray-800'
@@ -1467,18 +1360,10 @@ 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">
@@ -1488,13 +1373,10 @@ export function RunEncounters() {
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && (
<TypeBadge type={boss.specialtyType} />
)}
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
</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>
@@ -1515,10 +1397,7 @@ 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 && (

View File

@@ -3,8 +3,7 @@ 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',
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',
}
@@ -15,9 +14,7 @@ export function RunList() {
return (
<div className="max-w-4xl mx-auto p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
Your Runs
</h1>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Your Runs</h1>
<Link
to="/runs/new"
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"

View File

@@ -34,47 +34,27 @@ function pct(value: number | null): string {
return `${(value * 100).toFixed(1)}%`
}
function PokemonList({
title,
pokemon,
}: {
title: string
pokemon: PokemonRanking[]
}) {
function PokemonList({ title, pokemon }: { title: string; pokemon: PokemonRanking[] }) {
const [expanded, setExpanded] = useState(false)
const visible = expanded ? pokemon : pokemon.slice(0, 5)
return (
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{title}
</h3>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">{title}</h3>
{pokemon.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">No data</p>
) : (
<>
<div className="space-y-1.5">
{visible.map((p, i) => (
<div
key={p.pokemonId}
className="flex items-center gap-2 text-sm"
>
<span className="text-gray-400 dark:text-gray-500 w-5 text-right">
{i + 1}.
</span>
<div key={p.pokemonId} className="flex items-center gap-2 text-sm">
<span className="text-gray-400 dark:text-gray-500 w-5 text-right">{i + 1}.</span>
{p.spriteUrl ? (
<img
src={p.spriteUrl}
alt={p.name}
className="w-6 h-6"
loading="lazy"
/>
<img src={p.spriteUrl} alt={p.name} className="w-6 h-6" loading="lazy" />
) : (
<div className="w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded" />
)}
<span className="capitalize text-gray-800 dark:text-gray-200">
{p.name}
</span>
<span className="capitalize text-gray-800 dark:text-gray-200">{p.name}</span>
<span className="ml-auto text-gray-500 dark:text-gray-400 font-medium">
{p.count}
</span>
@@ -130,14 +110,10 @@ function HorizontalBar({
/>
<span
className={`absolute inset-0 flex items-center px-3 text-xs font-medium capitalize truncate ${
isLight
? 'text-gray-900 dark:text-gray-900'
: 'text-gray-700 dark:text-gray-200'
isLight ? 'text-gray-900 dark:text-gray-900' : 'text-gray-700 dark:text-gray-200'
}`}
style={{
textShadow: isLight
? '0 0 4px rgba(255,255,255,0.8)'
: '0 0 4px rgba(0,0,0,0.3)',
textShadow: isLight ? '0 0 4px rgba(255,255,255,0.8)' : '0 0 4px rgba(0,0,0,0.3)',
}}
>
{label}
@@ -150,18 +126,10 @@ function HorizontalBar({
)
}
function Section({
title,
children,
}: {
title: string
children: React.ReactNode
}) {
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-4">
{title}
</h2>
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-4">{title}</h2>
{children}
</section>
)
@@ -178,19 +146,13 @@ 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>
<strong className="text-gray-800 dark:text-gray-200">{pct(stats.winRate)}</strong>
</span>
<span>
Avg Duration:{' '}
@@ -211,8 +173,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
label={g.gameName}
value={g.count}
max={gameMax}
colorHex={g.gameColor ?? undefined}
color={g.gameColor ? undefined : 'bg-blue-500'}
{...(g.gameColor ? { colorHex: g.gameColor } : { color: 'bg-blue-500' })}
/>
))}
</div>
@@ -244,9 +205,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
<span>
Catch Rate:{' '}
<strong className="text-gray-800 dark:text-gray-200">
{pct(stats.catchRate)}
</strong>
<strong className="text-gray-800 dark:text-gray-200">{pct(stats.catchRate)}</strong>
</span>
<span>
Avg per Run:{' '}
@@ -261,44 +220,31 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
<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 Encountered"
pokemon={stats.topEncounteredPokemon}
/>
<PokemonList title="Most Encountered" pokemon={stats.topEncounteredPokemon} />
</div>
</Section>
{/* 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>
@@ -310,12 +256,8 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
<div className="space-y-1.5">
{stats.topDeathCauses.map((d, i) => (
<div key={d.cause} className="flex items-center gap-2 text-sm">
<span className="text-gray-400 dark:text-gray-500 w-5 text-right">
{i + 1}.
</span>
<span className="text-gray-800 dark:text-gray-200">
{d.cause}
</span>
<span className="text-gray-400 dark:text-gray-500 w-5 text-right">{i + 1}.</span>
<span className="text-gray-800 dark:text-gray-200">{d.cause}</span>
<span className="ml-auto text-gray-500 dark:text-gray-400 font-medium">
{d.count}
</span>
@@ -351,9 +293,7 @@ export function Stats() {
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-6">
Stats
</h1>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-6">Stats</h1>
{isLoading && (
<div className="flex justify-center py-12">
@@ -370,9 +310,7 @@ 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,11 +11,7 @@ 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
@@ -67,9 +63,7 @@ export function AdminEvolutions() {
header: 'To',
accessor: (e) => (
<div className="flex items-center gap-2">
{e.toPokemon.spriteUrl && (
<img src={e.toPokemon.spriteUrl} alt="" className="w-6 h-6" />
)}
{e.toPokemon.spriteUrl && <img src={e.toPokemon.spriteUrl} alt="" className="w-6 h-6" />}
<span>{e.toPokemon.name}</span>
</div>
),
@@ -163,8 +157,7 @@ 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

View File

@@ -45,10 +45,7 @@ import type {
UpdateRouteInput,
BossBattle,
} from '../../types'
import type {
CreateBossBattleInput,
UpdateBossBattleInput,
} from '../../types/admin'
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
/**
* Organize flat routes into hierarchical structure.
@@ -85,14 +82,9 @@ 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),
@@ -127,9 +119,7 @@ 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'}
@@ -155,9 +145,7 @@ 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">
@@ -191,14 +179,9 @@ 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),
@@ -247,15 +230,9 @@ function SortableBossRow({
{boss.bossType.replace('_', ' ')}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{boss.specialtyType ? (
<TypeBadge type={boss.specialtyType} />
) : (
'\u2014'
)}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{boss.section ?? '\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.location}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
<select
@@ -276,9 +253,7 @@ 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>
)
}
@@ -315,16 +290,12 @@ export function AdminGameDetail() {
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
)
const versionGroupGames = (allGames ?? []).filter((g) => g.versionGroupId === game.versionGroupId)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
@@ -336,7 +307,7 @@ export function AdminGameDetail() {
const reordered = [...routeGroups]
const [moved] = reordered.splice(oldIndex, 1)
reordered.splice(newIndex, 0, moved)
reordered.splice(newIndex, 0, moved!)
// Flatten groups back to individual routes with sequential order numbers
let order = 1
@@ -361,7 +332,7 @@ export function AdminGameDetail() {
const reordered = [...bosses]
const [moved] = reordered.splice(oldIndex, 1)
reordered.splice(newIndex, 0, moved)
reordered.splice(newIndex, 0, moved!)
const newOrders = reordered.map((b, i) => ({
id: b.id,
@@ -383,8 +354,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>
@@ -500,11 +471,7 @@ 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),
@@ -655,9 +622,7 @@ 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),

View File

@@ -2,11 +2,7 @@ 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'
@@ -22,10 +18,7 @@ export function AdminGames() {
const [regionFilter, setRegionFilter] = useState('')
const [genFilter, setGenFilter] = useState('')
const regions = useMemo(
() => [...new Set(games.map((g) => g.region))].sort(),
[games]
)
const regions = useMemo(() => [...new Set(games.map((g) => g.region))].sort(), [games])
const generations = useMemo(
() => [...new Set(games.map((g) => g.generation))].sort((a, b) => a - b),
[games]
@@ -34,8 +27,7 @@ export function AdminGames() {
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])

View File

@@ -28,23 +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> = {}
if (editName !== genlocke.name) data.name = editName
if (editStatus !== genlocke.status) data.status = editStatus
if (editName !== genlocke.name) data['name'] = editName
if (editStatus !== genlocke.status) data['status'] = editStatus
if (Object.keys(data).length === 0) return
updateGenlocke.mutate(
{ id, data },
@@ -77,9 +72,7 @@ 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 */}
@@ -131,22 +124,16 @@ 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>
@@ -157,9 +144,7 @@ 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"
@@ -172,9 +157,7 @@ export function AdminGenlockeDetail() {
<div className="mb-4 flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<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>
@@ -239,12 +222,8 @@ 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
@@ -274,12 +253,8 @@ 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)}
@@ -305,9 +280,7 @@ 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>
@@ -317,20 +290,14 @@ export function AdminGenlockeDetail() {
</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,11 +11,7 @@ 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
@@ -164,8 +160,7 @@ 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
@@ -220,9 +215,7 @@ export function AdminPokemon() {
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]
)
bulkImport.mutateAsync(items as Parameters<typeof bulkImport.mutateAsync>[0])
}
onClose={() => setShowBulkImport(false)}
/>

View File

@@ -46,8 +46,7 @@ export function AdminRouteDetail() {
)
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]
@@ -55,9 +54,7 @@ export function AdminRouteDetail() {
const childRoutes = useMemo(
() =>
(game?.routes ?? [])
.filter((r) => r.parentRouteId === rId)
.sort((a, b) => a.order - b.order),
(game?.routes ?? []).filter((r) => r.parentRouteId === rId).sort((a, b) => a.order - b.order),
[game?.routes, rId]
)
@@ -72,11 +69,7 @@ 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}
@@ -89,9 +82,7 @@ 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}`,
},
]
@@ -109,9 +100,7 @@ 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}>
@@ -175,12 +164,9 @@ export function AdminRouteDetail() {
{showCreate && (
<RouteEncounterFormModal
onSubmit={(data) =>
addEncounter.mutate(
{ ...data, gameId: gId } as CreateRouteEncounterInput,
{
onSuccess: () => setShowCreate(false),
}
)
addEncounter.mutate({ ...data, gameId: gId } as CreateRouteEncounterInput, {
onSuccess: () => setShowCreate(false),
})
}
onClose={() => setShowCreate(false)}
isSubmitting={addEncounter.isPending}
@@ -213,9 +199,7 @@ 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"
@@ -224,16 +208,11 @@ 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"
@@ -256,10 +235,9 @@ export function AdminRouteDetail() {
<RouteFormModal
nextOrder={nextChildOrder}
onSubmit={(data) =>
createRoute.mutate(
{ ...data, parentRouteId: rId } as CreateRouteInput,
{ onSuccess: () => setShowCreateChild(false) }
)
createRoute.mutate({ ...data, parentRouteId: rId } as CreateRouteInput, {
onSuccess: () => setShowCreateChild(false),
})
}
onClose={() => setShowCreateChild(false)}
isSubmitting={createRoute.isPending}

View File

@@ -14,16 +14,12 @@ export function AdminRuns() {
const [statusFilter, setStatusFilter] = useState('')
const [gameFilter, setGameFilter] = useState('')
const gameMap = useMemo(
() => new Map(games.map((g) => [g.id, g.name])),
[games]
)
const gameMap = useMemo(() => new Map(games.map((g) => [g.id, g.name])), [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])
@@ -31,10 +27,7 @@ export function AdminRuns() {
() =>
[
...new Map(
runs.map((r) => [
r.gameId,
gameMap.get(r.gameId) ?? `Game #${r.gameId}`,
])
runs.map((r) => [r.gameId, gameMap.get(r.gameId) ?? `Game #${r.gameId}`])
).entries(),
].sort((a, b) => a[1].localeCompare(b[1])),
[runs, gameMap]

View File

@@ -1,9 +1,4 @@
export type GameCategory =
| 'original'
| 'remake'
| 'enhanced'
| 'sequel'
| 'spinoff'
export type GameCategory = 'original' | 'remake' | 'enhanced' | 'sequel' | 'spinoff'
export interface Game {
id: number
@@ -152,19 +147,19 @@ export interface UpdateRunInput {
export interface CreateEncounterInput {
routeId: number
pokemonId: number
nickname?: string
nickname?: string | undefined
status: EncounterStatus
catchLevel?: number
isShiny?: boolean
origin?: string
catchLevel?: number | undefined
isShiny?: boolean | undefined
origin?: string | undefined
}
export interface UpdateEncounterInput {
nickname?: string
status?: EncounterStatus
faintLevel?: number
deathCause?: string
currentPokemonId?: number
nickname?: string | undefined
status?: EncounterStatus | undefined
faintLevel?: number | undefined
deathCause?: string | undefined
currentPokemonId?: number | undefined
}
// Boss battles

View File

@@ -60,8 +60,7 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
{
key: 'nicknameRequired',
name: 'Nickname Required',
description:
'All caught Pokémon must be given a nickname to form a stronger bond.',
description: 'All caught Pokémon must be given a nickname to form a stronger bond.',
category: 'core',
},
{
@@ -90,8 +89,7 @@ export const RULE_DEFINITIONS: RuleDefinition[] = [
{
key: 'hardcoreMode',
name: 'Hardcore Mode',
description:
'No items may be used during battle. Held items are still allowed.',
description: 'No items may be used during battle. Held items are still allowed.',
category: 'difficulty',
},
{

View File

@@ -11,15 +11,11 @@ export function formatEvolutionMethod(evo: {
} 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(

View File

@@ -18,6 +18,10 @@
/* Linting */
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,

View File

@@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
@@ -5,6 +6,9 @@ import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
test: {
environment: 'jsdom',
},
server: {
proxy: {
'/api': {