2026-02-09 10:39:59 +01:00
|
|
|
import { Link, useParams } from 'react-router-dom'
|
|
|
|
|
import { useGenlocke } from '../hooks/useGenlockes'
|
|
|
|
|
import { usePokemonFamilies } from '../hooks/usePokemon'
|
2026-02-14 16:41:24 +01:00
|
|
|
import {
|
|
|
|
|
GenlockeGraveyard,
|
|
|
|
|
GenlockeLineage,
|
|
|
|
|
StatCard,
|
|
|
|
|
RuleBadges,
|
|
|
|
|
} from '../components'
|
2026-02-09 10:39:59 +01:00
|
|
|
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
|
2026-02-09 11:00:37 +01:00
|
|
|
import { useMemo, useState } from 'react'
|
2026-02-09 10:39:59 +01:00
|
|
|
|
|
|
|
|
const statusColors: Record<RunStatus, string> = {
|
|
|
|
|
completed: 'bg-blue-500',
|
|
|
|
|
active: 'bg-green-500',
|
|
|
|
|
failed: 'bg-red-500',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const statusRing: Record<RunStatus, string> = {
|
|
|
|
|
completed: 'ring-blue-500',
|
|
|
|
|
active: 'ring-green-500 animate-pulse',
|
|
|
|
|
failed: 'ring-red-500',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const statusStyles: Record<RunStatus, string> = {
|
2026-02-14 16:41:24 +01:00
|
|
|
active:
|
|
|
|
|
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
2026-02-09 10:39:59 +01:00
|
|
|
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 ? (
|
2026-02-14 16:41:24 +01:00
|
|
|
<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]}`}
|
|
|
|
|
/>
|
2026-02-09 10:39:59 +01:00
|
|
|
) : (
|
|
|
|
|
<div className="w-4 h-4 rounded-full bg-gray-300 dark:bg-gray-600" />
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const content = (
|
|
|
|
|
<div className="flex flex-col items-center gap-1 min-w-[80px]">
|
|
|
|
|
{dot}
|
|
|
|
|
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 text-center leading-tight">
|
|
|
|
|
{leg.game.name}
|
|
|
|
|
</span>
|
|
|
|
|
{status && (
|
|
|
|
|
<span className="text-[10px] text-gray-500 dark:text-gray-400 capitalize">
|
|
|
|
|
{status}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (hasRun) {
|
|
|
|
|
return (
|
2026-02-14 16:41:24 +01:00
|
|
|
<Link
|
|
|
|
|
to={`/runs/${leg.runId}`}
|
|
|
|
|
className="hover:opacity-80 transition-opacity"
|
|
|
|
|
>
|
2026-02-09 10:39:59 +01:00
|
|
|
{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-gray-200 dark:bg-gray-600 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()
|
|
|
|
|
|
2026-02-09 11:00:37 +01:00
|
|
|
const [showGraveyard, setShowGraveyard] = useState(false)
|
2026-02-09 11:58:38 +01:00
|
|
|
const [showLineage, setShowLineage] = useState(false)
|
2026-02-09 11:00:37 +01:00
|
|
|
|
2026-02-09 10:39:59 +01:00
|
|
|
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
|
2026-02-14 16:41:24 +01:00
|
|
|
.filter(
|
|
|
|
|
(leg) => leg.retiredPokemonIds && leg.retiredPokemonIds.length > 0
|
|
|
|
|
)
|
2026-02-09 10:39:59 +01:00
|
|
|
.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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 16:41:24 +01:00
|
|
|
return {
|
|
|
|
|
legOrder: leg.legOrder,
|
|
|
|
|
gameName: leg.game.name,
|
|
|
|
|
pokemonIds: bases.sort((a, b) => a - b),
|
|
|
|
|
}
|
2026-02-09 10:39:59 +01:00
|
|
|
})
|
|
|
|
|
}, [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-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
|
|
|
|
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-blue-600 dark:text-blue-400 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
← Back to Genlockes
|
|
|
|
|
</Link>
|
|
|
|
|
<div className="flex items-center gap-3 mt-2">
|
|
|
|
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
|
|
|
|
{genlocke.name}
|
|
|
|
|
</h1>
|
|
|
|
|
<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-gray-900 dark:text-gray-100 mb-4">
|
|
|
|
|
Progress
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
|
|
|
<div className="flex items-start gap-2 overflow-x-auto pb-2">
|
|
|
|
|
{genlocke.legs.map((leg, i) => (
|
|
|
|
|
<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-gray-300 dark:bg-gray-600'
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* Cumulative Stats */}
|
|
|
|
|
<section>
|
|
|
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
|
|
|
|
Cumulative Stats
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
2026-02-14 16:41:24 +01:00
|
|
|
<StatCard
|
|
|
|
|
label="Encounters"
|
|
|
|
|
value={genlocke.stats.totalEncounters}
|
|
|
|
|
color="blue"
|
|
|
|
|
/>
|
|
|
|
|
<StatCard
|
|
|
|
|
label="Deaths"
|
|
|
|
|
value={genlocke.stats.totalDeaths}
|
|
|
|
|
color="red"
|
|
|
|
|
/>
|
2026-02-09 10:39:59 +01:00
|
|
|
<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-gray-900 dark:text-gray-100 mb-4">
|
|
|
|
|
Configuration
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-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-gray-500 dark:text-gray-400">
|
|
|
|
|
No genlocke-specific rules enabled
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 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-gray-900 dark:text-gray-100 mb-4">
|
|
|
|
|
Retired Families
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{retiredByLeg.map((leg) => (
|
|
|
|
|
<div
|
|
|
|
|
key={leg.legOrder}
|
|
|
|
|
className="bg-white dark:bg-gray-800 rounded-lg shadow p-4"
|
|
|
|
|
>
|
|
|
|
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
|
|
|
|
Leg {leg.legOrder} — {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-gray-900 dark:text-gray-100 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
|
2026-02-09 11:00:37 +01:00
|
|
|
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-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
|
|
|
|
}`}
|
2026-02-09 10:39:59 +01:00
|
|
|
>
|
|
|
|
|
Graveyard
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
2026-02-09 11:58:38 +01:00
|
|
|
onClick={() => setShowLineage((v) => !v)}
|
|
|
|
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
|
|
|
|
showLineage
|
|
|
|
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
|
|
|
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
|
|
|
|
}`}
|
2026-02-09 10:39:59 +01:00
|
|
|
>
|
|
|
|
|
Lineage
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-02-09 11:00:37 +01:00
|
|
|
|
|
|
|
|
{/* Graveyard */}
|
|
|
|
|
{showGraveyard && (
|
|
|
|
|
<section>
|
|
|
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
|
|
|
|
Cumulative Graveyard
|
|
|
|
|
</h2>
|
|
|
|
|
<GenlockeGraveyard genlockeId={id} />
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
2026-02-09 11:58:38 +01:00
|
|
|
|
|
|
|
|
{/* Lineage */}
|
|
|
|
|
{showLineage && (
|
|
|
|
|
<section>
|
|
|
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
|
|
|
|
Pokemon Lineages
|
|
|
|
|
</h2>
|
|
|
|
|
<GenlockeLineage genlockeId={id} />
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
2026-02-09 10:39:59 +01:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|