Add user authentication with login/signup/protected routes, boss pokemon detail fields and result team tracking, moves and abilities selector components and API, run ownership and visibility controls, and various UI improvements across encounters, run list, and journal pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
import { Link, useParams } from 'react-router-dom'
|
|
import { useGenlocke } from '../hooks/useGenlockes'
|
|
import { usePokemonFamilies } from '../hooks/usePokemon'
|
|
import {
|
|
CustomRulesDisplay,
|
|
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-900/40 text-green-300 light:bg-green-100 light:text-green-800',
|
|
completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
|
|
failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
|
|
}
|
|
|
|
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-surface-0 ${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">
|
|
← 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-900/40 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} />
|
|
<CustomRulesDisplay customRules={genlocke.nuzlockeRules?.customRules ?? ''} />
|
|
</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} — {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 light:text-text-primary 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 light:text-text-primary 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>
|
|
)
|
|
}
|