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

Set up pre-commit framework with ruff (backend) and ESLint/Prettier/tsc
(frontend) hooks to catch issues locally before CI. Auto-format all
frontend files with Prettier to comply with the new check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 16:41:24 +01:00
parent b05a75f7f2
commit 2963f16aa4
67 changed files with 1905 additions and 792 deletions

View File

@@ -1,7 +1,12 @@
import { Link, useParams } from 'react-router-dom'
import { useGenlocke } from '../hooks/useGenlockes'
import { usePokemonFamilies } from '../hooks/usePokemon'
import { GenlockeGraveyard, GenlockeLineage, StatCard, RuleBadges } from '../components'
import {
GenlockeGraveyard,
GenlockeLineage,
StatCard,
RuleBadges,
} from '../components'
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
import { useMemo, useState } from 'react'
@@ -18,7 +23,8 @@ const statusRing: Record<RunStatus, string> = {
}
const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
active:
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
}
@@ -28,7 +34,9 @@ function LegIndicator({ leg }: { leg: GenlockeLegDetail }) {
const status = leg.runStatus as RunStatus | null
const dot = status ? (
<div className={`w-4 h-4 rounded-full ${statusColors[status]} ring-2 ring-offset-2 ring-offset-white dark:ring-offset-gray-900 ${statusRing[status]}`} />
<div
className={`w-4 h-4 rounded-full ${statusColors[status]} ring-2 ring-offset-2 ring-offset-white dark:ring-offset-gray-900 ${statusRing[status]}`}
/>
) : (
<div className="w-4 h-4 rounded-full bg-gray-300 dark:bg-gray-600" />
)
@@ -49,7 +57,10 @@ function LegIndicator({ leg }: { leg: GenlockeLegDetail }) {
if (hasRun) {
return (
<Link to={`/runs/${leg.runId}`} className="hover:opacity-80 transition-opacity">
<Link
to={`/runs/${leg.runId}`}
className="hover:opacity-80 transition-opacity"
>
{content}
</Link>
)
@@ -105,7 +116,9 @@ export function GenlockeDetail() {
}
return genlocke.legs
.filter((leg) => leg.retiredPokemonIds && leg.retiredPokemonIds.length > 0)
.filter(
(leg) => leg.retiredPokemonIds && leg.retiredPokemonIds.length > 0
)
.map((leg) => {
// Find base Pokemon (lowest ID) for each family in this leg's retired list
const seen = new Set<string>()
@@ -118,7 +131,11 @@ export function GenlockeDetail() {
bases.push(family ? Math.min(...family) : pid)
}
}
return { legOrder: leg.legOrder, gameName: leg.game.name, pokemonIds: bases.sort((a, b) => a - b) }
return {
legOrder: leg.legOrder,
gameName: leg.game.name,
pokemonIds: bases.sort((a, b) => a - b),
}
})
}, [genlocke, familiesData])
@@ -202,8 +219,16 @@ export function GenlockeDetail() {
Cumulative Stats
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard label="Encounters" value={genlocke.stats.totalEncounters} color="blue" />
<StatCard label="Deaths" value={genlocke.stats.totalDeaths} color="red" />
<StatCard
label="Encounters"
value={genlocke.stats.totalEncounters}
color="blue"
/>
<StatCard
label="Deaths"
value={genlocke.stats.totalDeaths}
color="red"
/>
<StatCard
label="Legs Completed"
value={genlocke.stats.legsCompleted}

View File

@@ -3,9 +3,9 @@ import { useGenlockes } from '../hooks/useGenlockes'
import type { RunStatus } from '../types'
const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed:
'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
active:
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
}

View File

@@ -5,8 +5,7 @@ import type { RunStatus } from '../types'
const statusStyles: Record<RunStatus, string> = {
active:
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed:
'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
}

View File

@@ -20,7 +20,7 @@ type PresetType = 'true' | 'normal' | 'custom' | null
function buildLegsFromPreset(
regions: Region[],
preset: 'true' | 'normal',
preset: 'true' | 'normal'
): LegEntry[] {
const legs: LegEntry[] = []
for (const region of regions) {
@@ -45,8 +45,11 @@ export function NewGenlocke() {
const [name, setName] = useState('')
const [legs, setLegs] = useState<LegEntry[]>([])
const [preset, setPreset] = useState<PresetType>(null)
const [nuzlockeRules, setNuzlockeRules] = useState<NuzlockeRules>(DEFAULT_RULES)
const [genlockeRules, setGenlockeRules] = useState<GenlockeRules>({ retireHoF: false })
const [nuzlockeRules, setNuzlockeRules] =
useState<NuzlockeRules>(DEFAULT_RULES)
const [genlockeRules, setGenlockeRules] = useState<GenlockeRules>({
retireHoF: false,
})
const [namingScheme, setNamingScheme] = useState<string | null>(null)
const { data: namingCategories } = useNamingCategories()
@@ -61,7 +64,9 @@ export function NewGenlocke() {
}
const handleGameChange = (index: number, game: Game) => {
setLegs((prev) => prev.map((leg, i) => (i === index ? { ...leg, game } : leg)))
setLegs((prev) =>
prev.map((leg, i) => (i === index ? { ...leg, game } : leg))
)
}
const handleRemoveLeg = (index: number) => {
@@ -70,7 +75,8 @@ export function NewGenlocke() {
const handleAddLeg = (region: Region) => {
const defaultSlug = region.genlockeDefaults.normalGenlocke
const game = region.games.find((g) => g.slug === defaultSlug) ?? region.games[0]
const game =
region.games.find((g) => g.slug === defaultSlug) ?? region.games[0]
if (game) {
setLegs((prev) => [...prev, { region: region.name, game }])
}
@@ -105,17 +111,18 @@ export function NewGenlocke() {
navigate('/runs')
}
},
},
}
)
}
const enabledRuleCount = RULE_DEFINITIONS.filter((r) => nuzlockeRules[r.key]).length
const enabledRuleCount = RULE_DEFINITIONS.filter(
(r) => nuzlockeRules[r.key]
).length
const totalRuleCount = RULE_DEFINITIONS.length
// Regions not yet used in legs (for "add leg" picker)
const availableRegions = regions?.filter(
(r) => !legs.some((l) => l.region === r.name),
) ?? []
const availableRegions =
regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? []
return (
<div className="max-w-4xl mx-auto p-8">
@@ -198,7 +205,9 @@ export function NewGenlocke() {
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className={`font-medium ${isActive ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-gray-100'}`}>
<div
className={`font-medium ${isActive ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-gray-100'}`}
>
{labels[type]}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
@@ -241,11 +250,17 @@ 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
@@ -270,7 +285,10 @@ export function NewGenlocke() {
{/* Step 3: Rules */}
{step === 3 && (
<div>
<RulesConfiguration rules={nuzlockeRules} onChange={setNuzlockeRules} />
<RulesConfiguration
rules={nuzlockeRules}
onChange={setNuzlockeRules}
/>
{/* Genlocke-specific rules */}
<div className="mt-6 bg-white dark:bg-gray-800 rounded-lg shadow">
@@ -301,7 +319,8 @@ export function NewGenlocke() {
Keep Hall of Fame
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Pokemon that beat the Elite Four can continue to the next leg
Pokemon that beat the Elite Four can continue to the
next leg
</div>
</div>
</label>
@@ -318,7 +337,8 @@ export function NewGenlocke() {
Retire Hall of Fame
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Pokemon that beat the Elite Four are retired and cannot be used in the next leg
Pokemon that beat the Elite Four are retired and cannot
be used in the next leg
</div>
</div>
</label>
@@ -334,7 +354,8 @@ export function NewGenlocke() {
Naming Scheme
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Get nickname suggestions from a themed word list when catching Pokemon. Applied to all legs.
Get nickname suggestions from a themed word list when catching
Pokemon. Applied to all legs.
</p>
</div>
<div className="px-4 py-4">
@@ -384,7 +405,9 @@ export function NewGenlocke() {
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
Name
</h3>
<p className="text-gray-900 dark:text-gray-100 font-medium">{name}</p>
<p className="text-gray-900 dark:text-gray-100 font-medium">
{name}
</p>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
@@ -403,7 +426,8 @@ export function NewGenlocke() {
{leg.game.name}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400 ml-2">
{leg.region.charAt(0).toUpperCase() + leg.region.slice(1)}
{leg.region.charAt(0).toUpperCase() +
leg.region.slice(1)}
</span>
</div>
</li>
@@ -417,22 +441,29 @@ export function NewGenlocke() {
</h3>
<dl className="space-y-1 text-sm">
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Nuzlocke Rules</dt>
<dt className="text-gray-600 dark:text-gray-400">
Nuzlocke Rules
</dt>
<dd className="text-gray-900 dark:text-gray-100 font-medium">
{enabledRuleCount} of {totalRuleCount} enabled
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Hall of Fame</dt>
<dt className="text-gray-600 dark:text-gray-400">
Hall of Fame
</dt>
<dd className="text-gray-900 dark:text-gray-100 font-medium">
{genlockeRules.retireHoF ? 'Retire' : 'Keep'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Naming Scheme</dt>
<dt className="text-gray-600 dark:text-gray-400">
Naming Scheme
</dt>
<dd className="text-gray-900 dark:text-gray-100 font-medium">
{namingScheme
? namingScheme.charAt(0).toUpperCase() + namingScheme.slice(1)
? namingScheme.charAt(0).toUpperCase() +
namingScheme.slice(1)
: 'None'}
</dd>
</div>
@@ -530,8 +561,18 @@ function LegRow({
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed"
title="Move up"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 15l7-7 7 7"
/>
</svg>
</button>
<button
@@ -541,8 +582,18 @@ function LegRow({
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed"
title="Move down"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<button
@@ -551,8 +602,18 @@ function LegRow({
className="p-1 text-red-400 hover:text-red-600 dark:hover:text-red-300"
title="Remove leg"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -576,8 +637,18 @@ function AddLegDropdown({
onClick={() => setOpen(true)}
className="flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4v16m8-8H4"
/>
</svg>
Add Region
</button>

View File

@@ -47,13 +47,13 @@ export function NewRun() {
if (!selectedGame) return
createRun.mutate(
{ gameId: selectedGame.id, name: runName, rules, namingScheme },
{ onSuccess: (data) => navigate(`/runs/${data.id}`) },
{ onSuccess: (data) => navigate(`/runs/${data.id}`) }
)
}
const visibleRuleKeys = RULE_DEFINITIONS
.filter((r) => !hiddenRules?.has(r.key))
.map((r) => r.key)
const visibleRuleKeys = RULE_DEFINITIONS.filter(
(r) => !hiddenRules?.has(r.key)
).map((r) => r.key)
const enabledRuleCount = visibleRuleKeys.filter((k) => rules[k]).length
const totalRuleCount = visibleRuleKeys.length
@@ -84,7 +84,8 @@ export function NewRun() {
{selectedGame.name}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{selectedGame.region.charAt(0).toUpperCase() + selectedGame.region.slice(1)}
{selectedGame.region.charAt(0).toUpperCase() +
selectedGame.region.slice(1)}
</p>
</div>
</div>
@@ -137,7 +138,11 @@ export function NewRun() {
{step === 2 && (
<div>
<RulesConfiguration rules={rules} onChange={setRules} hiddenRules={hiddenRules} />
<RulesConfiguration
rules={rules}
onChange={setRules}
hiddenRules={hiddenRules}
/>
<div className="mt-6 flex justify-between">
<button
@@ -204,7 +209,8 @@ export function NewRun() {
))}
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Get nickname suggestions from a themed word list when catching Pokemon.
Get nickname suggestions from a themed word list when catching
Pokemon.
</p>
</div>
)}
@@ -223,7 +229,9 @@ export function NewRun() {
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Region</dt>
<dd className="text-gray-900 dark:text-gray-100 font-medium">
{selectedGame && (selectedGame.region.charAt(0).toUpperCase() + selectedGame.region.slice(1))}
{selectedGame &&
selectedGame.region.charAt(0).toUpperCase() +
selectedGame.region.slice(1)}
</dd>
</div>
<div className="flex justify-between">
@@ -233,10 +241,13 @@ export function NewRun() {
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Naming Scheme</dt>
<dt className="text-gray-600 dark:text-gray-400">
Naming Scheme
</dt>
<dd className="text-gray-900 dark:text-gray-100 font-medium">
{namingScheme
? namingScheme.charAt(0).toUpperCase() + namingScheme.slice(1)
? namingScheme.charAt(0).toUpperCase() +
namingScheme.slice(1)
: 'None'}
</dd>
</div>

View File

@@ -3,12 +3,21 @@ import { useParams, Link } from 'react-router-dom'
import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns'
import { useGameRoutes } from '../hooks/useGames'
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
import { StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
import {
StatCard,
PokemonCard,
RuleBadges,
StatusChangeModal,
EndRunModal,
} from '../components'
import type { RunStatus, EncounterDetail } from '../types'
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
function sortEncounters(
encounters: EncounterDetail[],
key: TeamSortKey
): EncounterDetail[] {
return [...encounters].sort((a, b) => {
switch (key) {
case 'route':
@@ -21,7 +30,10 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
return nameA.localeCompare(nameB)
}
case 'dex':
return (a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
return (
(a.currentPokemon ?? a.pokemon).nationalDex -
(b.currentPokemon ?? b.pokemon).nationalDex
)
default:
return 0
}
@@ -29,9 +41,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
}
const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed:
'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
active:
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
}
@@ -59,12 +71,24 @@ export function RunDashboard() {
const encounters = run?.encounters ?? []
const alive = useMemo(
() => sortEncounters(encounters.filter((e) => e.status === 'caught' && e.faintLevel === null), teamSort),
[encounters, teamSort],
() =>
sortEncounters(
encounters.filter(
(e) => e.status === 'caught' && e.faintLevel === null
),
teamSort
),
[encounters, teamSort]
)
const dead = useMemo(
() => sortEncounters(encounters.filter((e) => e.status === 'caught' && e.faintLevel !== null), teamSort),
[encounters, teamSort],
() =>
sortEncounters(
encounters.filter(
(e) => e.status === 'caught' && e.faintLevel !== null
),
teamSort
),
[encounters, teamSort]
)
if (isLoading) {
@@ -111,7 +135,10 @@ export function RunDashboard() {
{run.name}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{run.game.name} &middot; {run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} &middot; Started{' '}
{run.game.name} &middot;{' '}
{run.game.region.charAt(0).toUpperCase() +
run.game.region.slice(1)}{' '}
&middot; Started{' '}
{new Date(run.startedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
@@ -137,7 +164,9 @@ export function RunDashboard() {
}`}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}</span>
<span className="text-2xl">
{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}
</span>
<div>
<p
className={`font-semibold ${
@@ -222,7 +251,8 @@ export function RunDashboard() {
) : (
<span className="text-sm text-gray-900 dark:text-gray-100">
{run.namingScheme
? run.namingScheme.charAt(0).toUpperCase() + run.namingScheme.slice(1)
? run.namingScheme.charAt(0).toUpperCase() +
run.namingScheme.slice(1)
: 'None'}
</span>
)}
@@ -329,7 +359,7 @@ export function RunDashboard() {
onConfirm={(status) => {
updateRun.mutate(
{ status },
{ onSuccess: () => setShowEndRun(false) },
{ onSuccess: () => setShowEndRun(false) }
)
}}
onClose={() => setShowEndRun(false)}

View File

@@ -3,9 +3,17 @@ import { useParams, Link, useNavigate } from 'react-router-dom'
import { useRun, useUpdateRun } from '../hooks/useRuns'
import { useAdvanceLeg } from '../hooks/useGenlockes'
import { useGameRoutes } from '../hooks/useGames'
import { useCreateEncounter, useUpdateEncounter, useBulkRandomize } from '../hooks/useEncounters'
import {
useCreateEncounter,
useUpdateEncounter,
useBulkRandomize,
} from '../hooks/useEncounters'
import { usePokemonFamilies } from '../hooks/usePokemon'
import { useGameBosses, useBossResults, useCreateBossResult } from '../hooks/useBosses'
import {
useGameBosses,
useBossResults,
useCreateBossResult,
} from '../hooks/useBosses'
import {
EggEncounterModal,
EncounterModal,
@@ -35,7 +43,10 @@ import type {
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): EncounterDetail[] {
function sortEncounters(
encounters: EncounterDetail[],
key: TeamSortKey
): EncounterDetail[] {
return [...encounters].sort((a, b) => {
switch (key) {
case 'route':
@@ -48,7 +59,10 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
return nameA.localeCompare(nameB)
}
case 'dex':
return (a.currentPokemon ?? a.pokemon).nationalDex - (b.currentPokemon ?? b.pokemon).nationalDex
return (
(a.currentPokemon ?? a.pokemon).nationalDex -
(b.currentPokemon ?? b.pokemon).nationalDex
)
default:
return 0
}
@@ -56,9 +70,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
}
const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed:
'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
active:
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
}
@@ -129,7 +143,7 @@ function organizeRoutes(routes: Route[]): RouteWithChildren[] {
*/
function getGroupEncounter(
group: RouteWithChildren,
encounterByRoute: Map<number, EncounterDetail>,
encounterByRoute: Map<number, EncounterDetail>
): EncounterDetail | null {
for (const child of group.children) {
const enc = encounterByRoute.get(child.id)
@@ -154,7 +168,7 @@ function effectiveZone(route: Route): number {
*/
function getZoneEncounters(
group: RouteWithChildren,
encounterByRoute: Map<number, EncounterDetail>,
encounterByRoute: Map<number, EncounterDetail>
): Map<number, EncounterDetail> {
const zoneMap = new Map<number, EncounterDetail>()
for (const child of group.children) {
@@ -172,14 +186,23 @@ function countDistinctZones(group: RouteWithChildren): number {
return zones.size
}
function matchVariant(labels: string[], starterName?: string | null): string | null {
function matchVariant(
labels: string[],
starterName?: string | null
): string | null {
if (!starterName || labels.length === 0) return null
const lower = starterName.toLowerCase()
const matches = labels.filter((l) => l.toLowerCase().includes(lower))
return matches.length === 1 ? matches[0] : null
}
function BossTeamPreview({ pokemon, starterName }: { pokemon: BossPokemon[]; starterName?: string | null }) {
function BossTeamPreview({
pokemon,
starterName,
}: {
pokemon: BossPokemon[]
starterName?: string | null
}) {
const variantLabels = useMemo(() => {
const labels = new Set<string>()
for (const bp of pokemon) {
@@ -189,16 +212,20 @@ function BossTeamPreview({ pokemon, starterName }: { pokemon: BossPokemon[]; sta
}, [pokemon])
const hasVariants = variantLabels.length > 0
const autoMatch = useMemo(() => matchVariant(variantLabels, starterName), [variantLabels, starterName])
const autoMatch = useMemo(
() => matchVariant(variantLabels, starterName),
[variantLabels, starterName]
)
const showPills = hasVariants && autoMatch === null
const [selectedVariant, setSelectedVariant] = useState<string | null>(
autoMatch ?? (hasVariants ? variantLabels[0] : null),
autoMatch ?? (hasVariants ? variantLabels[0] : null)
)
const displayed = useMemo(() => {
if (!hasVariants) return pokemon
return pokemon.filter(
(bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null,
(bp) =>
bp.conditionLabel === selectedVariant || bp.conditionLabel === null
)
}, [pokemon, hasVariants, selectedVariant])
@@ -228,7 +255,11 @@ function BossTeamPreview({ pokemon, starterName }: { pokemon: BossPokemon[]; sta
.map((bp) => (
<div key={bp.id} className="flex items-center gap-1">
{bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
<img
src={bp.pokemon.spriteUrl}
alt={bp.pokemon.name}
className="w-20 h-20"
/>
) : (
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
)}
@@ -420,7 +451,7 @@ export function RunEncounters() {
const advanceLeg = useAdvanceLeg()
const [showTransferModal, setShowTransferModal] = useState(false)
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
run?.gameId ?? null,
run?.gameId ?? null
)
const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum)
@@ -451,7 +482,9 @@ export function RunEncounters() {
try {
const saved = localStorage.getItem(storageKey)
if (saved) return new Set(JSON.parse(saved) as number[])
} catch { /* ignore */ }
} catch {
/* ignore */
}
return new Set<number>()
})
@@ -463,7 +496,7 @@ export function RunEncounters() {
return next
})
},
[storageKey],
[storageKey]
)
// Organize routes into hierarchical structure
@@ -475,25 +508,35 @@ export function RunEncounters() {
// Split encounters into normal (non-shiny) and shiny
const transferIdSet = useMemo(
() => new Set(run?.transferEncounterIds ?? []),
[run?.transferEncounterIds],
[run?.transferEncounterIds]
)
const { normalEncounters, shinyEncounters, transferEncounters } = useMemo(() => {
if (!run) return { normalEncounters: [], shinyEncounters: [], transferEncounters: [] }
const 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: [],
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)
}
}
}
return { normalEncounters: normal, shinyEncounters: shiny, transferEncounters: transfer }
}, [run, transferIdSet])
return {
normalEncounters: normal,
shinyEncounters: shiny,
transferEncounters: transfer,
}
}, [run, transferIdSet])
// Map routeId → encounter for quick lookup (normal encounters only)
const encounterByRoute = useMemo(() => {
@@ -635,8 +678,7 @@ export function RunEncounters() {
if (organizedRoutes.length === 0 || expandedGroups.size > 0) return
const firstUnvisited = organizedRoutes.find(
(r) =>
r.children.length > 0 &&
getGroupEncounter(r, encounterByRoute) === null,
r.children.length > 0 && getGroupEncounter(r, encounterByRoute) === null
)
if (firstUnvisited) {
updateExpandedGroups(() => new Set([firstUnvisited.id]))
@@ -644,21 +686,25 @@ export function RunEncounters() {
}, [organizedRoutes, encounterByRoute]) // eslint-disable-line react-hooks/exhaustive-deps
const alive = useMemo(
() => sortEncounters(
[...normalEncounters, ...transferEncounters, ...shinyEncounters].filter(
(e) => e.status === 'caught' && e.faintLevel === null,
() =>
sortEncounters(
[...normalEncounters, ...transferEncounters, ...shinyEncounters].filter(
(e) => e.status === 'caught' && e.faintLevel === null
),
teamSort
),
teamSort,
),
[normalEncounters, transferEncounters, shinyEncounters, teamSort],
[normalEncounters, transferEncounters, shinyEncounters, teamSort]
)
const dead = useMemo(
() => sortEncounters(
normalEncounters.filter((e) => e.status === 'caught' && e.faintLevel !== null),
teamSort,
),
[normalEncounters, teamSort],
() =>
sortEncounters(
normalEncounters.filter(
(e) => e.status === 'caught' && e.faintLevel !== null
),
teamSort
),
[normalEncounters, teamSort]
)
// Resolve HoF team encounters from IDs
@@ -810,7 +856,10 @@ export function RunEncounters() {
{run.name}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{run.game.name} &middot; {run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} &middot; Started{' '}
{run.game.name} &middot;{' '}
{run.game.region.charAt(0).toUpperCase() +
run.game.region.slice(1)}{' '}
&middot; Started{' '}
{new Date(run.startedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
@@ -819,7 +868,8 @@ export function RunEncounters() {
</p>
{run.genlocke && (
<p className="text-sm text-purple-600 dark:text-purple-400 mt-1 font-medium">
Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} &mdash; {run.genlocke.genlockeName}
Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} &mdash;{' '}
{run.genlocke.genlockeName}
</p>
)}
</div>
@@ -868,7 +918,9 @@ export function RunEncounters() {
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}</span>
<span className="text-2xl">
{run.status === 'completed' ? '\u{1f3c6}' : '\u{1faa6}'}
</span>
<div>
<p
className={`font-semibold ${
@@ -907,33 +959,40 @@ 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 },
{
onSuccess: (genlocke) => {
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run.genlocke!.legOrder + 1,
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
{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,
},
},
)
}
}}
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>
)}
{
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>
)}
</div>
{/* HoF Team Display */}
{run.status === 'completed' && (
@@ -957,7 +1016,11 @@ export function RunEncounters() {
return (
<div key={enc.id} className="flex flex-col items-center">
{dp.spriteUrl ? (
<img src={dp.spriteUrl} alt={dp.name} className="w-12 h-12" />
<img
src={dp.spriteUrl}
alt={dp.name}
className="w-12 h-12"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-sm font-bold">
{dp.name[0].toUpperCase()}
@@ -1040,11 +1103,13 @@ export function RunEncounters() {
className="w-6 h-6"
/>
) : (
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
earned
? 'border-yellow-500 bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300'
: 'border-gray-300 dark:border-gray-600 text-gray-400'
}`}>
<div
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
earned
? 'border-yellow-500 bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300'
: 'border-gray-300 dark:border-gray-600 text-gray-400'
}`}
>
{boss.order}
</div>
)}
@@ -1077,7 +1142,8 @@ export function RunEncounters() {
{isActive ? 'Team' : 'Final Team'}
</h2>
<span className="text-xs text-gray-400 dark:text-gray-500">
{alive.length} alive{dead.length > 0 ? `, ${dead.length} dead` : ''}
{alive.length} alive
{dead.length > 0 ? `, ${dead.length} dead` : ''}
</span>
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${showTeam ? 'rotate-180' : ''}`}
@@ -1114,7 +1180,11 @@ export function RunEncounters() {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
onClick={
isActive
? () => setSelectedTeamEncounter(enc)
: undefined
}
/>
))}
</div>
@@ -1130,7 +1200,11 @@ export function RunEncounters() {
key={enc.id}
encounter={enc}
showFaintLevel
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
onClick={
isActive
? () => setSelectedTeamEncounter(enc)
: undefined
}
/>
))}
</div>
@@ -1146,7 +1220,9 @@ export function RunEncounters() {
<div className="mb-6">
<ShinyBox
encounters={shinyEncounters}
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
onEncounterClick={
isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined
}
/>
</div>
)}
@@ -1162,7 +1238,9 @@ export function RunEncounters() {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
onClick={
isActive ? () => setSelectedTeamEncounter(enc) : undefined
}
/>
))}
</div>
@@ -1182,7 +1260,11 @@ export function RunEncounters() {
disabled={bulkRandomize.isPending}
onClick={() => {
const remaining = totalLocations - completedCount
if (window.confirm(`Randomize encounters for all ${remaining} remaining locations?`)) {
if (
window.confirm(
`Randomize encounters for all ${remaining} remaining locations?`
)
) {
bulkRandomize.mutate()
}
}}
@@ -1242,9 +1324,10 @@ 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]
const routeIds: number[] =
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[] = []
@@ -1253,68 +1336,77 @@ export function RunEncounters() {
if (b) bossesHere.push(...b)
}
const routeElement = route.children.length > 0 ? (
<RouteGroup
key={route.id}
group={route}
encounterByRoute={encounterByRoute}
isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={handleRouteClick}
filter={filter}
pinwheelClause={pinwheelClause}
/>
) : (() => {
const encounter = encounterByRoute.get(route.id)
const rs = getRouteStatus(encounter)
const si = statusIndicator[rs]
return (
<button
const routeElement =
route.children.length > 0 ? (
<RouteGroup
key={route.id}
type="button"
onClick={() => handleRouteClick(route)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
>
<span
className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{route.name}
</div>
{encounter ? (
<div className="flex items-center gap-2 mt-0.5">
{encounter.pokemon.spriteUrl && (
<img
src={encounter.pokemon.spriteUrl}
alt={encounter.pokemon.name}
className="w-10 h-10"
/>
group={route}
encounterByRoute={encounterByRoute}
isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={handleRouteClick}
filter={filter}
pinwheelClause={pinwheelClause}
/>
) : (
(() => {
const encounter = encounterByRoute.get(route.id)
const rs = getRouteStatus(encounter)
const si = statusIndicator[rs]
return (
<button
key={route.id}
type="button"
onClick={() => handleRouteClick(route)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
>
<span
className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{route.name}
</div>
{encounter ? (
<div className="flex items-center gap-2 mt-0.5">
{encounter.pokemon.spriteUrl && (
<img
src={encounter.pokemon.spriteUrl}
alt={encounter.pokemon.name}
className="w-10 h-10"
/>
)}
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
{encounter.nickname ?? encounter.pokemon.name}
{encounter.status === 'caught' &&
encounter.faintLevel !== null &&
(encounter.deathCause
? `${encounter.deathCause}`
: ' (dead)')}
</span>
</div>
) : (
route.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{route.encounterMethods.map((m) => (
<EncounterMethodBadge
key={m}
method={m}
size="xs"
/>
))}
</div>
)
)}
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
{encounter.nickname ?? encounter.pokemon.name}
{encounter.status === 'caught' &&
encounter.faintLevel !== null &&
(encounter.deathCause
? `${encounter.deathCause}`
: ' (dead)')}
</span>
</div>
) : route.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{route.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" />
))}
</div>
)}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
{si.label}
</span>
</button>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
{si.label}
</span>
</button>
)
})()
)
})()
return (
<div key={route.id}>
@@ -1358,67 +1450,83 @@ export function RunEncounters() {
<div key={`boss-${boss.id}`}>
<div
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors.other} ${
isDefeated ? 'bg-green-50/50 dark:bg-green-900/10' : 'bg-white dark:bg-gray-800'
isDefeated
? 'bg-green-50/50 dark:bg-green-900/10'
: 'bg-white dark:bg-gray-800'
} px-4 py-3`}
>
<div
className="flex items-start justify-between cursor-pointer select-none"
onClick={toggleBoss}
>
<div className="flex items-center gap-3">
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<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" />
)}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{boss.name}
</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && (
<TypeBadge type={boss.specialtyType} />
)}
<div
className="flex items-start justify-between cursor-pointer select-none"
onClick={toggleBoss}
>
<div className="flex items-center gap-3">
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<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"
/>
)}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{boss.name}
</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && (
<TypeBadge type={boss.specialtyType} />
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{boss.location} &middot; Level Cap:{' '}
{boss.levelCap}
</p>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{boss.location} &middot; Level Cap: {boss.levelCap}
</p>
</div>
<div onClick={(e) => e.stopPropagation()}>
{isDefeated ? (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300">
Defeated &#10003;
</span>
) : isActive ? (
<button
onClick={() => setSelectedBoss(boss)}
className="px-3 py-1 text-xs font-medium rounded-full bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
Battle
</button>
) : null}
</div>
</div>
<div onClick={(e) => e.stopPropagation()}>
{isDefeated ? (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300">
Defeated &#10003;
</span>
) : isActive ? (
<button
onClick={() => setSelectedBoss(boss)}
className="px-3 py-1 text-xs font-medium rounded-full bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
Battle
</button>
) : null}
</div>
</div>
{/* Boss pokemon team */}
{isBossExpanded && boss.pokemon.length > 0 && (
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
)}
{/* Boss pokemon team */}
{isBossExpanded && boss.pokemon.length > 0 && (
<BossTeamPreview
pokemon={boss.pokemon}
starterName={starterName}
/>
)}
</div>
{sectionAfter && (
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
<span className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{sectionAfter}</span>
<span className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{sectionAfter}
</span>
<div className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
</div>
)}
@@ -1519,7 +1627,7 @@ export function RunEncounters() {
setShowHofModal(true)
}
},
},
}
)
}}
onClose={() => setShowEndRun(false)}
@@ -1535,7 +1643,7 @@ export function RunEncounters() {
onSubmit={(encounterIds) => {
updateRun.mutate(
{ hofEncounterIds: encounterIds },
{ onSuccess: () => setShowHofModal(false) },
{ onSuccess: () => setShowHofModal(false) }
)
}}
onSkip={() => setShowHofModal(false)}
@@ -1558,13 +1666,13 @@ export function RunEncounters() {
onSuccess: (genlocke) => {
setShowTransferModal(false)
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
(l) => l.legOrder === run!.genlocke!.legOrder + 1
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
},
},
}
)
}}
onSkip={() => {
@@ -1577,13 +1685,13 @@ export function RunEncounters() {
onSuccess: (genlocke) => {
setShowTransferModal(false)
const nextLeg = genlocke.legs.find(
(l) => l.legOrder === run!.genlocke!.legOrder + 1,
(l) => l.legOrder === run!.genlocke!.legOrder + 1
)
if (nextLeg?.runId) {
navigate(`/runs/${nextLeg.runId}`)
}
},
},
}
)
}}
isPending={advanceLeg.isPending}

View File

@@ -3,9 +3,9 @@ import { useRuns } from '../hooks/useRuns'
import type { RunStatus } from '../types'
const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed:
'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
active:
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
completed: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
}

View File

@@ -178,15 +178,25 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
<StatCard label="Total Runs" value={stats.totalRuns} color="blue" />
<StatCard label="Active" value={stats.activeRuns} color="green" />
<StatCard label="Completed" value={stats.completedRuns} color="blue" />
<StatCard
label="Completed"
value={stats.completedRuns}
color="blue"
/>
<StatCard label="Failed" value={stats.failedRuns} color="red" />
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
<span>
Win Rate: <strong className="text-gray-800 dark:text-gray-200">{pct(stats.winRate)}</strong>
Win Rate:{' '}
<strong className="text-gray-800 dark:text-gray-200">
{pct(stats.winRate)}
</strong>
</span>
<span>
Avg Duration: <strong className="text-gray-800 dark:text-gray-200">{fmt(stats.avgDurationDays, ' days')}</strong>
Avg Duration:{' '}
<strong className="text-gray-800 dark:text-gray-200">
{fmt(stats.avgDurationDays, ' days')}
</strong>
</span>
</div>
</Section>
@@ -233,10 +243,16 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
<span>
Catch Rate: <strong className="text-gray-800 dark:text-gray-200">{pct(stats.catchRate)}</strong>
Catch Rate:{' '}
<strong className="text-gray-800 dark:text-gray-200">
{pct(stats.catchRate)}
</strong>
</span>
<span>
Avg per Run: <strong className="text-gray-800 dark:text-gray-200">{fmt(stats.avgEncountersPerRun)}</strong>
Avg per Run:{' '}
<strong className="text-gray-800 dark:text-gray-200">
{fmt(stats.avgEncountersPerRun)}
</strong>
</span>
</div>
</Section>
@@ -244,10 +260,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
{/* Pokemon Rankings */}
<Section title="Pokemon Rankings">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<PokemonList
title="Most Caught"
pokemon={stats.topCaughtPokemon}
/>
<PokemonList title="Most Caught" pokemon={stats.topCaughtPokemon} />
<PokemonList
title="Most Encountered"
pokemon={stats.topEncounteredPokemon}
@@ -258,24 +271,34 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
{/* Team & Deaths */}
<Section title="Team & Deaths">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
<StatCard label="Total Deaths" value={stats.totalDeaths} color="red" />
<StatCard
label="Total Deaths"
value={stats.totalDeaths}
color="red"
/>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-amber-500">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{pct(stats.mortalityRate)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Mortality Rate</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Mortality Rate
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-blue-500">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{fmt(stats.avgCatchLevel)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Avg Catch Lv.</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Avg Catch Lv.
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 border-purple-500">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{fmt(stats.avgFaintLevel)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Avg Faint Lv.</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Avg Faint Lv.
</div>
</div>
</div>
@@ -347,7 +370,9 @@ export function Stats() {
{stats && stats.totalRuns === 0 && (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<p className="text-lg mb-2">No data yet</p>
<p className="text-sm">Start a Nuzlocke run to see your stats here.</p>
<p className="text-sm">
Start a Nuzlocke run to see your stats here.
</p>
</div>
)}

View File

@@ -11,7 +11,11 @@ import {
} from '../../hooks/useAdmin'
import { exportEvolutions } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
import type {
EvolutionAdmin,
CreateEvolutionInput,
UpdateEvolutionInput,
} from '../../types'
const PAGE_SIZE = 50
@@ -28,7 +32,12 @@ export function AdminEvolutions() {
const [triggerFilter, setTriggerFilter] = useState('')
const [page, setPage] = useState(0)
const offset = page * PAGE_SIZE
const { data, isLoading } = useEvolutionList(search || undefined, PAGE_SIZE, offset, triggerFilter || undefined)
const { data, isLoading } = useEvolutionList(
search || undefined,
PAGE_SIZE,
offset,
triggerFilter || undefined
)
const evolutions = data?.items ?? []
const total = data?.total ?? 0
const totalPages = Math.ceil(total / PAGE_SIZE)
@@ -120,12 +129,18 @@ export function AdminEvolutions() {
>
<option value="">All triggers</option>
{EVOLUTION_TRIGGERS.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
{(search || triggerFilter) && (
<button
onClick={() => { setSearch(''); setTriggerFilter(''); setPage(0) }}
onClick={() => {
setSearch('')
setTriggerFilter('')
setPage(0)
}}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear filters
@@ -148,7 +163,8 @@ export function AdminEvolutions() {
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of {total}
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of{' '}
{total}
</div>
<div className="flex items-center gap-2">
<button
@@ -213,7 +229,7 @@ export function AdminEvolutions() {
onSubmit={(data) =>
updateEvolution.mutate(
{ id: editing.id, data: data as UpdateEvolutionInput },
{ onSuccess: () => setEditing(null) },
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}

View File

@@ -38,8 +38,17 @@ import {
} from '../../hooks/useAdmin'
import { exportGameRoutes, exportGameBosses } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { Route as GameRoute, RouteWithChildren, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types'
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
import type {
Route as GameRoute,
RouteWithChildren,
CreateRouteInput,
UpdateRouteInput,
BossBattle,
} from '../../types'
import type {
CreateBossBattleInput,
UpdateBossBattleInput,
} from '../../types/admin'
/**
* Organize flat routes into hierarchical structure.
@@ -76,8 +85,14 @@ function SortableRouteGroup({
gameId: number
onClick: (r: GameRoute) => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: group.id })
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: group.id })
const style = {
transform: CSS.Transform.toString(transform),
@@ -112,7 +127,9 @@ function SortableRouteGroup({
</svg>
</button>
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">{group.order}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">
{group.order}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{group.name}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-center">
{group.pinwheelZone != null ? group.pinwheelZone : '\u2014'}
@@ -138,7 +155,9 @@ function SortableRouteGroup({
{child.order}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap pl-8 text-gray-600 dark:text-gray-400">
<span className="text-gray-300 dark:text-gray-600 mr-1.5">{'\u2514'}</span>
<span className="text-gray-300 dark:text-gray-600 mr-1.5">
{'\u2514'}
</span>
{child.name}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-center">
@@ -172,8 +191,14 @@ function SortableBossRow({
onPositionChange: (bossId: number, afterRouteId: number | null) => void
onClick: (b: BossBattle) => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: boss.id })
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: boss.id })
const style = {
transform: CSS.Transform.toString(transform),
@@ -208,22 +233,29 @@ function SortableBossRow({
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.order}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap font-medium">
{boss.name}
{boss.gameId != null && (() => {
const g = games.find((g) => g.id === boss.gameId)
return g ? (
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
{g.name}
</span>
) : null
})()}
{boss.gameId != null &&
(() => {
const g = games.find((g) => g.id === boss.gameId)
return g ? (
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
{g.name}
</span>
) : null
})()}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap capitalize">
{boss.bossType.replace('_', ' ')}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{boss.specialtyType ? <TypeBadge type={boss.specialtyType} /> : '\u2014'}
{boss.specialtyType ? (
<TypeBadge type={boss.specialtyType} />
) : (
'\u2014'
)}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{boss.section ?? '\u2014'}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.section ?? '\u2014'}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.location}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
<select
@@ -244,7 +276,9 @@ function SortableBossRow({
</select>
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.levelCap}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{boss.pokemon.length}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{boss.pokemon.length}
</td>
</tr>
)
}
@@ -278,16 +312,18 @@ export function AdminGameDetail() {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
if (isLoading) return <div className="py-8 text-center text-gray-500">Loading...</div>
if (!game) return <div className="py-8 text-center text-gray-500">Game not found</div>
if (isLoading)
return <div className="py-8 text-center text-gray-500">Loading...</div>
if (!game)
return <div className="py-8 text-center text-gray-500">Game not found</div>
const routes = game.routes ?? []
const routeGroups = organizeRoutes(routes)
const versionGroupGames = (allGames ?? []).filter(
(g) => g.versionGroupId === game.versionGroupId,
(g) => g.versionGroupId === game.versionGroupId
)
const handleDragEnd = (event: DragEndEvent) => {
@@ -347,7 +383,8 @@ export function AdminGameDetail() {
<div className="mb-6">
<h2 className="text-xl font-semibold">{game.name}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{game.region.charAt(0).toUpperCase() + game.region.slice(1)} &middot; Gen {game.generation}
{game.region.charAt(0).toUpperCase() + game.region.slice(1)} &middot;
Gen {game.generation}
{game.releaseYear ? ` \u00b7 ${game.releaseYear}` : ''}
</p>
</div>
@@ -463,7 +500,11 @@ export function AdminGameDetail() {
{showCreate && (
<RouteFormModal
nextOrder={routes.length > 0 ? Math.max(...routes.map((r) => r.order)) + 1 : 1}
nextOrder={
routes.length > 0
? Math.max(...routes.map((r) => r.order)) + 1
: 1
}
onSubmit={(data) =>
createRoute.mutate(data as CreateRouteInput, {
onSuccess: () => setShowCreate(false),
@@ -480,7 +521,7 @@ export function AdminGameDetail() {
onSubmit={(data) =>
updateRoute.mutate(
{ routeId: editing.id, data: data as UpdateRouteInput },
{ onSuccess: () => setEditing(null) },
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}
@@ -614,7 +655,9 @@ export function AdminGameDetail() {
<BossBattleFormModal
routes={routes}
games={versionGroupGames}
nextOrder={bosses ? Math.max(0, ...bosses.map((b) => b.order)) + 1 : 1}
nextOrder={
bosses ? Math.max(0, ...bosses.map((b) => b.order)) + 1 : 1
}
onSubmit={(data) =>
createBoss.mutate(data as CreateBossBattleInput, {
onSuccess: () => setShowCreateBoss(false),
@@ -634,7 +677,7 @@ export function AdminGameDetail() {
onSubmit={(data) =>
updateBoss.mutate(
{ bossId: editingBoss.id, data: data as UpdateBossBattleInput },
{ onSuccess: () => setEditingBoss(null) },
{ onSuccess: () => setEditingBoss(null) }
)
}
onClose={() => setEditingBoss(null)}
@@ -676,9 +719,7 @@ function BossTeamEditorWrapper({
return (
<BossTeamEditor
boss={boss}
onSave={(team) =>
setBossTeam.mutate(team, { onSuccess: onClose })
}
onSave={(team) => setBossTeam.mutate(team, { onSuccess: onClose })}
onClose={onClose}
isSaving={setBossTeam.isPending}
/>

View File

@@ -2,7 +2,11 @@ import { useState, useMemo } from 'react'
import { AdminTable, type Column } from '../../components/admin/AdminTable'
import { GameFormModal } from '../../components/admin/GameFormModal'
import { useGames } from '../../hooks/useGames'
import { useCreateGame, useUpdateGame, useDeleteGame } from '../../hooks/useAdmin'
import {
useCreateGame,
useUpdateGame,
useDeleteGame,
} from '../../hooks/useAdmin'
import { exportGames } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
@@ -20,17 +24,18 @@ export function AdminGames() {
const regions = useMemo(
() => [...new Set(games.map((g) => g.region))].sort(),
[games],
[games]
)
const generations = useMemo(
() => [...new Set(games.map((g) => g.generation))].sort((a, b) => a - b),
[games],
[games]
)
const filteredGames = useMemo(() => {
let result = games
if (regionFilter) result = result.filter((g) => g.region === regionFilter)
if (genFilter) result = result.filter((g) => g.generation === Number(genFilter))
if (genFilter)
result = result.filter((g) => g.generation === Number(genFilter))
return result
}, [games, regionFilter, genFilter])
@@ -38,8 +43,16 @@ export function AdminGames() {
{ header: 'Name', accessor: (g) => g.name },
{ header: 'Slug', accessor: (g) => g.slug },
{ header: 'Region', accessor: (g) => g.region, sortKey: (g) => g.region },
{ header: 'Gen', accessor: (g) => g.generation, sortKey: (g) => g.generation },
{ header: 'Year', accessor: (g) => g.releaseYear ?? '-', sortKey: (g) => g.releaseYear ?? 0 },
{
header: 'Gen',
accessor: (g) => g.generation,
sortKey: (g) => g.generation,
},
{
header: 'Year',
accessor: (g) => g.releaseYear ?? '-',
sortKey: (g) => g.releaseYear ?? 0,
},
]
return (
@@ -73,7 +86,9 @@ export function AdminGames() {
>
<option value="">All regions</option>
{regions.map((r) => (
<option key={r} value={r}>{r}</option>
<option key={r} value={r}>
{r}
</option>
))}
</select>
<select
@@ -83,12 +98,17 @@ export function AdminGames() {
>
<option value="">All generations</option>
{generations.map((g) => (
<option key={g} value={g}>Gen {g}</option>
<option key={g} value={g}>
Gen {g}
</option>
))}
</select>
{(regionFilter || genFilter) && (
<button
onClick={() => { setRegionFilter(''); setGenFilter('') }}
onClick={() => {
setRegionFilter('')
setGenFilter('')
}}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear filters
@@ -126,7 +146,7 @@ export function AdminGames() {
onSubmit={(data) =>
updateGame.mutate(
{ id: editing.id, data: data as UpdateGameInput },
{ onSuccess: () => setEditing(null) },
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}

View File

@@ -28,13 +28,18 @@ export function AdminGenlockeDetail() {
const [addingLeg, setAddingLeg] = useState(false)
const [selectedGameId, setSelectedGameId] = useState<number | ''>('')
if (isLoading) return <div className="py-8 text-center text-gray-500">Loading...</div>
if (!genlocke) return <div className="py-8 text-center text-gray-500">Genlocke not found</div>
if (isLoading)
return <div className="py-8 text-center text-gray-500">Loading...</div>
if (!genlocke)
return (
<div className="py-8 text-center text-gray-500">Genlocke not found</div>
)
const editName = name ?? genlocke.name
const editStatus = status ?? genlocke.status
const hasChanges = editName !== genlocke.name || editStatus !== genlocke.status
const hasChanges =
editName !== genlocke.name || editStatus !== genlocke.status
const handleSave = () => {
const data: Record<string, string> = {}
@@ -48,7 +53,7 @@ export function AdminGenlockeDetail() {
setName(null)
setStatus(null)
},
},
}
)
}
@@ -61,7 +66,7 @@ export function AdminGenlockeDetail() {
setAddingLeg(false)
setSelectedGameId('')
},
},
}
)
}
@@ -72,7 +77,9 @@ export function AdminGenlockeDetail() {
Genlockes
</Link>
{' / '}
<span className="text-gray-900 dark:text-gray-100">{genlocke.name}</span>
<span className="text-gray-900 dark:text-gray-100">
{genlocke.name}
</span>
</nav>
{/* Header */}
@@ -124,16 +131,22 @@ export function AdminGenlockeDetail() {
{/* Rules (read-only) */}
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Rules</h3>
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
Rules
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">Genlocke rules:</span>
<span className="text-gray-500 dark:text-gray-400">
Genlocke rules:
</span>
<pre className="mt-1 text-xs bg-white dark:bg-gray-900 p-2 rounded border dark:border-gray-700 overflow-x-auto">
{JSON.stringify(genlocke.genlockeRules, null, 2)}
</pre>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Nuzlocke rules:</span>
<span className="text-gray-500 dark:text-gray-400">
Nuzlocke rules:
</span>
<pre className="mt-1 text-xs bg-white dark:bg-gray-900 p-2 rounded border dark:border-gray-700 overflow-x-auto">
{JSON.stringify(genlocke.nuzlockeRules, null, 2)}
</pre>
@@ -144,7 +157,9 @@ export function AdminGenlockeDetail() {
{/* Legs */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Legs ({genlocke.legs.length})</h3>
<h3 className="text-lg font-semibold">
Legs ({genlocke.legs.length})
</h3>
<button
onClick={() => setAddingLeg(!addingLeg)}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
@@ -157,7 +172,9 @@ export function AdminGenlockeDetail() {
<div className="mb-4 flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<select
value={selectedGameId}
onChange={(e) => setSelectedGameId(e.target.value ? Number(e.target.value) : '')}
onChange={(e) =>
setSelectedGameId(e.target.value ? Number(e.target.value) : '')
}
className="flex-1 px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
>
<option value="">Select a game...</option>
@@ -222,8 +239,12 @@ export function AdminGenlockeDetail() {
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{genlocke.legs.map((leg) => (
<tr key={leg.id}>
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.legOrder}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.game.name}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{leg.legOrder}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{leg.game.name}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{leg.runId ? (
<Link
@@ -253,13 +274,21 @@ export function AdminGenlockeDetail() {
<span className="text-gray-400">&mdash;</span>
)}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.encounterCount}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{leg.deathCount}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{leg.encounterCount}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{leg.deathCount}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-right">
<button
onClick={() => deleteLeg.mutate(leg.id)}
disabled={leg.runId !== null || deleteLeg.isPending}
title={leg.runId !== null ? 'Cannot remove a leg with a linked run' : 'Remove leg'}
title={
leg.runId !== null
? 'Cannot remove a leg with a linked run'
: 'Remove leg'
}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed"
>
Remove
@@ -276,22 +305,32 @@ export function AdminGenlockeDetail() {
{/* Stats */}
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Stats</h3>
<h3 className="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
Stats
</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">Legs</span>
<p className="text-lg font-semibold">{genlocke.stats.legsCompleted} / {genlocke.stats.totalLegs}</p>
<p className="text-lg font-semibold">
{genlocke.stats.legsCompleted} / {genlocke.stats.totalLegs}
</p>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Encounters</span>
<p className="text-lg font-semibold">{genlocke.stats.totalEncounters}</p>
<p className="text-lg font-semibold">
{genlocke.stats.totalEncounters}
</p>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Deaths</span>
<p className="text-lg font-semibold">{genlocke.stats.totalDeaths}</p>
<p className="text-lg font-semibold">
{genlocke.stats.totalDeaths}
</p>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Survival Rate</span>
<span className="text-gray-500 dark:text-gray-400">
Survival Rate
</span>
<p className="text-lg font-semibold">
{genlocke.stats.totalEncounters > 0
? `${Math.round(((genlocke.stats.totalEncounters - genlocke.stats.totalDeaths) / genlocke.stats.totalEncounters) * 100)}%`

View File

@@ -11,14 +11,33 @@ import {
} from '../../hooks/useAdmin'
import { exportPokemon } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types'
import type {
Pokemon,
CreatePokemonInput,
UpdatePokemonInput,
} from '../../types'
const PAGE_SIZE = 50
const POKEMON_TYPES = [
'bug', 'dark', 'dragon', 'electric', 'fairy', 'fighting', 'fire', 'flying',
'ghost', 'grass', 'ground', 'ice', 'normal', 'poison', 'psychic', 'rock',
'steel', 'water',
'bug',
'dark',
'dragon',
'electric',
'fairy',
'fighting',
'fire',
'flying',
'ghost',
'grass',
'ground',
'ice',
'normal',
'poison',
'psychic',
'rock',
'steel',
'water',
]
export function AdminPokemon() {
@@ -26,7 +45,12 @@ export function AdminPokemon() {
const [typeFilter, setTypeFilter] = useState('')
const [page, setPage] = useState(0)
const offset = page * PAGE_SIZE
const { data, isLoading } = usePokemonList(search || undefined, PAGE_SIZE, offset, typeFilter || undefined)
const { data, isLoading } = usePokemonList(
search || undefined,
PAGE_SIZE,
offset,
typeFilter || undefined
)
const pokemon = data?.items ?? []
const total = data?.total ?? 0
const totalPages = Math.ceil(total / PAGE_SIZE)
@@ -105,12 +129,18 @@ export function AdminPokemon() {
>
<option value="">All types</option>
{POKEMON_TYPES.map((t) => (
<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
{(search || typeFilter) && (
<button
onClick={() => { setSearch(''); setTypeFilter(''); setPage(0) }}
onClick={() => {
setSearch('')
setTypeFilter('')
setPage(0)
}}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear filters
@@ -134,7 +164,8 @@ export function AdminPokemon() {
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of {total}
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of{' '}
{total}
</div>
<div className="flex items-center gap-2">
<button
@@ -188,7 +219,11 @@ export function AdminPokemon() {
<BulkImportModal
title="Bulk Import Pokemon"
example={`[\n { "pokeapi_id": 1, "national_dex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] }\n]`}
onSubmit={(items) => bulkImport.mutateAsync(items as Parameters<typeof bulkImport.mutateAsync>[0])}
onSubmit={(items) =>
bulkImport.mutateAsync(
items as Parameters<typeof bulkImport.mutateAsync>[0]
)
}
onClose={() => setShowBulkImport(false)}
/>
)}
@@ -199,7 +234,7 @@ export function AdminPokemon() {
onSubmit={(data) =>
updatePokemon.mutate(
{ id: editing.id, data: data as UpdatePokemonInput },
{ onSuccess: () => setEditing(null) },
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}

View File

@@ -42,24 +42,29 @@ export function AdminRouteDetail() {
const sortedRoutes = useMemo(
() => [...(game?.routes ?? [])].sort((a, b) => a.order - b.order),
[game?.routes],
[game?.routes]
)
const currentIndex = sortedRoutes.findIndex((r) => r.id === rId)
const route = currentIndex >= 0 ? sortedRoutes[currentIndex] : undefined
const prevRoute = currentIndex > 0 ? sortedRoutes[currentIndex - 1] : undefined
const prevRoute =
currentIndex > 0 ? sortedRoutes[currentIndex - 1] : undefined
const nextRoute =
currentIndex >= 0 && currentIndex < sortedRoutes.length - 1
? sortedRoutes[currentIndex + 1]
: undefined
const childRoutes = useMemo(
() => (game?.routes ?? []).filter((r) => r.parentRouteId === rId).sort((a, b) => a.order - b.order),
[game?.routes, rId],
() =>
(game?.routes ?? [])
.filter((r) => r.parentRouteId === rId)
.sort((a, b) => a.order - b.order),
[game?.routes, rId]
)
const nextChildOrder = childRoutes.length > 0
? Math.max(...childRoutes.map((r) => r.order)) + 1
: (route?.order ?? 0) * 10 + 1
const nextChildOrder =
childRoutes.length > 0
? Math.max(...childRoutes.map((r) => r.order)) + 1
: (route?.order ?? 0) * 10 + 1
const columns: Column<RouteEncounterDetail>[] = [
{
@@ -67,7 +72,11 @@ export function AdminRouteDetail() {
accessor: (e) => (
<div className="flex items-center gap-2">
{e.pokemon.spriteUrl ? (
<img src={e.pokemon.spriteUrl} alt={e.pokemon.name} className="w-6 h-6" />
<img
src={e.pokemon.spriteUrl}
alt={e.pokemon.name}
className="w-6 h-6"
/>
) : null}
<span>
#{e.pokemon.nationalDex} {e.pokemon.name}
@@ -80,7 +89,9 @@ export function AdminRouteDetail() {
{
header: 'Levels',
accessor: (e) =>
e.minLevel === e.maxLevel ? `Lv ${e.minLevel}` : `Lv ${e.minLevel}-${e.maxLevel}`,
e.minLevel === e.maxLevel
? `Lv ${e.minLevel}`
: `Lv ${e.minLevel}-${e.maxLevel}`,
},
]
@@ -98,7 +109,9 @@ export function AdminRouteDetail() {
<select
className="text-gray-900 dark:text-gray-100 bg-transparent font-medium cursor-pointer hover:underline border-none p-0 text-sm"
value={rId}
onChange={(e) => navigate(`/admin/games/${gId}/routes/${e.target.value}`)}
onChange={(e) =>
navigate(`/admin/games/${gId}/routes/${e.target.value}`)
}
>
{sortedRoutes.map((r) => (
<option key={r.id} value={r.id}>
@@ -162,9 +175,12 @@ export function AdminRouteDetail() {
{showCreate && (
<RouteEncounterFormModal
onSubmit={(data) =>
addEncounter.mutate({ ...data, gameId: gId } as CreateRouteEncounterInput, {
onSuccess: () => setShowCreate(false),
})
addEncounter.mutate(
{ ...data, gameId: gId } as CreateRouteEncounterInput,
{
onSuccess: () => setShowCreate(false),
}
)
}
onClose={() => setShowCreate(false)}
isSubmitting={addEncounter.isPending}
@@ -176,8 +192,11 @@ export function AdminRouteDetail() {
encounter={editing}
onSubmit={(data) =>
updateEncounter.mutate(
{ encounterId: editing.id, data: data as UpdateRouteEncounterInput },
{ onSuccess: () => setEditing(null) },
{
encounterId: editing.id,
data: data as UpdateRouteEncounterInput,
},
{ onSuccess: () => setEditing(null) }
)
}
onClose={() => setEditing(null)}
@@ -194,7 +213,9 @@ export function AdminRouteDetail() {
{/* Sub-areas */}
<div className="mt-8">
<div className="flex justify-between items-center mb-3">
<h3 className="text-lg font-semibold">Sub-areas ({childRoutes.length})</h3>
<h3 className="text-lg font-semibold">
Sub-areas ({childRoutes.length})
</h3>
<button
onClick={() => setShowCreateChild(true)}
className="px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
@@ -203,11 +224,16 @@ export function AdminRouteDetail() {
</button>
</div>
{childRoutes.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">No sub-areas for this route.</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
No sub-areas for this route.
</p>
) : (
<div className="border rounded-md dark:border-gray-700 divide-y dark:divide-gray-700">
{childRoutes.map((child) => (
<div key={child.id} className="flex items-center justify-between px-4 py-2">
<div
key={child.id}
className="flex items-center justify-between px-4 py-2"
>
<Link
to={`/admin/games/${gId}/routes/${child.id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
@@ -232,7 +258,7 @@ export function AdminRouteDetail() {
onSubmit={(data) =>
createRoute.mutate(
{ ...data, parentRouteId: rId } as CreateRouteInput,
{ onSuccess: () => setShowCreateChild(false) },
{ onSuccess: () => setShowCreateChild(false) }
)
}
onClose={() => setShowCreateChild(false)}

View File

@@ -16,19 +16,28 @@ export function AdminRuns() {
const gameMap = useMemo(
() => new Map(games.map((g) => [g.id, g.name])),
[games],
[games]
)
const filteredRuns = useMemo(() => {
let result = runs
if (statusFilter) result = result.filter((r) => r.status === statusFilter)
if (gameFilter) result = result.filter((r) => r.gameId === Number(gameFilter))
if (gameFilter)
result = result.filter((r) => r.gameId === Number(gameFilter))
return result
}, [runs, statusFilter, gameFilter])
const runGames = useMemo(
() => [...new Map(runs.map((r) => [r.gameId, gameMap.get(r.gameId) ?? `Game #${r.gameId}`])).entries()].sort((a, b) => a[1].localeCompare(b[1])),
[runs, gameMap],
() =>
[
...new Map(
runs.map((r) => [
r.gameId,
gameMap.get(r.gameId) ?? `Game #${r.gameId}`,
])
).entries(),
].sort((a, b) => a[1].localeCompare(b[1])),
[runs, gameMap]
)
const columns: Column<NuzlockeRun>[] = [
@@ -86,12 +95,17 @@ export function AdminRuns() {
>
<option value="">All games</option>
{runGames.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
<option key={id} value={id}>
{name}
</option>
))}
</select>
{(statusFilter || gameFilter) && (
<button
onClick={() => { setStatusFilter(''); setGameFilter('') }}
onClick={() => {
setStatusFilter('')
setGameFilter('')
}}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear filters
@@ -120,7 +134,10 @@ export function AdminRuns() {
onSuccess: () => setDeleting(null),
})
}
onCancel={() => { setDeleting(null); deleteRun.reset() }}
onCancel={() => {
setDeleting(null)
deleteRun.reset()
}}
isDeleting={deleteRun.isPending}
error={deleteRun.error?.message ?? null}
/>