Add pre-commit hooks for linting and formatting
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:
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} · {run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} · Started{' '}
|
||||
{run.game.name} ·{' '}
|
||||
{run.game.region.charAt(0).toUpperCase() +
|
||||
run.game.region.slice(1)}{' '}
|
||||
· 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)}
|
||||
|
||||
@@ -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} · {run.game.region.charAt(0).toUpperCase() + run.game.region.slice(1)} · Started{' '}
|
||||
{run.game.name} ·{' '}
|
||||
{run.game.region.charAt(0).toUpperCase() +
|
||||
run.game.region.slice(1)}{' '}
|
||||
· 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} — {run.genlocke.genlockeName}
|
||||
Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} —{' '}
|
||||
{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} · Level Cap:{' '}
|
||||
{boss.levelCap}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{boss.location} · 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 ✓
|
||||
</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 ✓
|
||||
</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}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)} · Gen {game.generation}
|
||||
{game.region.charAt(0).toUpperCase() + game.region.slice(1)} ·
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">—</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)}%`
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user