Files
nuzlocke-tracker/frontend/src/pages/GenlockeDetail.tsx

309 lines
10 KiB
TypeScript
Raw Normal View History

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 type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
import { useMemo, useState } from 'react'
const statusColors: Record<RunStatus, string> = {
completed: 'bg-blue-500',
active: 'bg-green-500',
failed: 'bg-red-500',
}
const statusRing: Record<RunStatus, string> = {
completed: 'ring-accent-500',
active: 'ring-green-500 animate-pulse',
failed: 'ring-red-500',
}
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',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
}
function LegIndicator({ leg }: { leg: GenlockeLegDetail }) {
const hasRun = leg.runId !== null
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 bg-surface-3" />
)
const content = (
<div className="flex flex-col items-center gap-1 min-w-[80px]">
{dot}
<span className="text-xs font-medium text-text-secondary text-center leading-tight">
{leg.game.name}
</span>
{status && <span className="text-[10px] text-text-tertiary capitalize">{status}</span>}
</div>
)
if (hasRun) {
return (
<Link to={`/runs/${leg.runId}`} className="hover:opacity-80 transition-opacity">
{content}
</Link>
)
}
return content
}
function PokemonSprite({ pokemon }: { pokemon: RetiredPokemon }) {
if (pokemon.spriteUrl) {
return (
<img
src={pokemon.spriteUrl}
alt={pokemon.name}
title={pokemon.name}
className="w-10 h-10"
loading="lazy"
/>
)
}
return (
<div
className="w-10 h-10 rounded-full bg-surface-3 flex items-center justify-center text-sm font-bold"
title={pokemon.name}
>
{pokemon.name[0]?.toUpperCase()}
</div>
)
}
export function GenlockeDetail() {
const { genlockeId } = useParams<{ genlockeId: string }>()
const id = Number(genlockeId)
const { data: genlocke, isLoading, error } = useGenlocke(id)
const { data: familiesData } = usePokemonFamilies()
const [showGraveyard, setShowGraveyard] = useState(false)
const [showLineage, setShowLineage] = useState(false)
const activeLeg = useMemo(() => {
if (!genlocke) return null
return genlocke.legs.find((l) => l.runStatus === 'active') ?? null
}, [genlocke])
// Group retired Pokemon by leg, showing only the "base" Pokemon per family
const retiredByLeg = useMemo(() => {
if (!genlocke || !familiesData) return []
const familyMap = new Map<number, number[]>()
for (const family of familiesData.families) {
for (const id of family) {
familyMap.set(id, family)
}
}
return genlocke.legs
.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>()
const bases: number[] = []
for (const pid of leg.retiredPokemonIds!) {
const family = familyMap.get(pid)
const key = family ? family.join(',') : String(pid)
if (!seen.has(key)) {
seen.add(key)
bases.push(family ? Math.min(...family) : pid)
}
}
return {
legOrder: leg.legOrder,
gameName: leg.game.name,
pokemonIds: bases.sort((a, b) => a - b),
}
})
}, [genlocke, familiesData])
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error || !genlocke) {
return (
<div className="max-w-4xl mx-auto p-8">
<div className="rounded-lg bg-status-failed-bg p-4 text-status-failed">
Failed to load genlocke. Please try again.
</div>
</div>
)
}
const survivalRate =
genlocke.stats.totalEncounters > 0
? Math.round(
((genlocke.stats.totalEncounters - genlocke.stats.totalDeaths) /
genlocke.stats.totalEncounters) *
100
)
: 0
return (
<div className="max-w-4xl mx-auto p-8 space-y-8">
{/* Header */}
<div>
<Link to="/genlockes" className="text-sm text-text-link hover:underline">
&larr; Back to Genlockes
</Link>
<div className="flex items-center gap-3 mt-2">
<h1 className="text-3xl font-bold text-text-primary">{genlocke.name}</h1>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${statusStyles[genlocke.status]}`}
>
{genlocke.status}
</span>
</div>
</div>
{/* Progress Timeline */}
<section>
<h2 className="text-lg font-semibold text-text-primary mb-4">Progress</h2>
<div className="bg-surface-1 rounded-lg shadow p-6">
<div className="flex items-start gap-2 overflow-x-auto pb-2">
{genlocke.legs.map((leg, i) => (
<div key={leg.id} className="flex items-center">
<LegIndicator leg={leg} />
{i < genlocke.legs.length - 1 && (
<div
className={`h-0.5 w-6 mx-1 mt-[-16px] ${
leg.runStatus === 'completed' ? 'bg-blue-500' : 'bg-surface-3'
}`}
/>
)}
</div>
))}
</div>
</div>
</section>
{/* Cumulative Stats */}
<section>
<h2 className="text-lg font-semibold text-text-primary mb-4">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="Legs Completed"
value={genlocke.stats.legsCompleted}
total={genlocke.stats.totalLegs}
color="green"
/>
<StatCard label="Survival Rate" value={survivalRate} color="purple" />
</div>
</section>
{/* Configuration */}
<section>
<h2 className="text-lg font-semibold text-text-primary mb-4">Configuration</h2>
<div className="bg-surface-1 rounded-lg shadow p-6 space-y-4">
<div>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Genlocke Rules</h3>
<div className="flex flex-wrap gap-1.5">
{genlocke.genlockeRules.retireHoF ? (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300">
Retire HoF Teams
</span>
) : (
<span className="text-sm text-text-tertiary">
No genlocke-specific rules enabled
</span>
)}
</div>
</div>
<div>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Nuzlocke Rules</h3>
<RuleBadges rules={genlocke.nuzlockeRules} />
</div>
</div>
</section>
{/* Retired Families */}
{genlocke.genlockeRules.retireHoF && retiredByLeg.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-text-primary mb-4">Retired Families</h2>
<div className="space-y-3">
{retiredByLeg.map((leg) => (
<div key={leg.legOrder} className="bg-surface-1 rounded-lg shadow p-4">
<h3 className="text-sm font-medium text-text-tertiary mb-2">
Leg {leg.legOrder} &mdash; {leg.gameName}
</h3>
<div className="flex flex-wrap gap-1">
{leg.pokemonIds.map((pid) => {
const pokemon = genlocke.retiredPokemon[pid]
if (!pokemon) return null
return <PokemonSprite key={pid} pokemon={pokemon} />
})}
</div>
</div>
))}
</div>
</section>
)}
{/* Quick Actions */}
<section>
<h2 className="text-lg font-semibold text-text-primary mb-4">Quick Actions</h2>
<div className="flex flex-wrap gap-3">
{activeLeg && (
<Link
to={`/runs/${activeLeg.runId}`}
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
Go to Active Leg (Leg {activeLeg.legOrder})
</Link>
)}
<button
onClick={() => setShowGraveyard((v) => !v)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
showGraveyard
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-surface-3 text-text-secondary hover:bg-surface-4'
}`}
>
Graveyard
</button>
<button
onClick={() => setShowLineage((v) => !v)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
showLineage
? 'bg-accent-600 text-white hover:bg-accent-500'
: 'bg-surface-3 text-text-secondary hover:bg-surface-4'
}`}
>
Lineage
</button>
</div>
</section>
{/* Graveyard */}
{showGraveyard && (
<section>
<h2 className="text-lg font-semibold text-text-primary mb-4">Cumulative Graveyard</h2>
<GenlockeGraveyard genlockeId={id} />
</section>
)}
{/* Lineage */}
{showLineage && (
<section>
<h2 className="text-lg font-semibold text-text-primary mb-4">Pokemon Lineages</h2>
<GenlockeLineage genlockeId={id} />
</section>
)}
</div>
)
}