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

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]