Files
nuzlocke-tracker/frontend/src/pages/GenlockeDetail.tsx
Julian Tabel 0a519e356e
Some checks failed
CI / backend-tests (push) Failing after 1m16s
CI / frontend-tests (push) Successful in 57s
feat: add auth system, boss pokemon details, moves/abilities API, and run ownership
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>
2026-03-20 21:41:38 +01:00

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">
&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-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} &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 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>
)
}