Files
nuzlocke-tracker/frontend/src/pages/NewRun.tsx
Julian Tabel 8fbf658a27 Hide Pinwheel Clause rule toggle for games without pinwheel zones
Fetches routes for the selected game during run creation and hides
the Pinwheel Clause option when no routes have pinwheel zone data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 10:40:18 +01:00

266 lines
9.9 KiB
TypeScript

import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { GameGrid, RulesConfiguration, StepIndicator } from '../components'
import { useGames, useGameRoutes } from '../hooks/useGames'
import { useCreateRun, useRuns } from '../hooks/useRuns'
import type { Game, NuzlockeRules } from '../types'
import { DEFAULT_RULES } from '../types'
import { RULE_DEFINITIONS } from '../types/rules'
const DEFAULT_COLOR = '#6366f1'
export function NewRun() {
const navigate = useNavigate()
const { data: games, isLoading, error } = useGames()
const { data: runs } = useRuns()
const createRun = useCreateRun()
const [step, setStep] = useState(1)
const [selectedGame, setSelectedGame] = useState<Game | null>(null)
const [rules, setRules] = useState<NuzlockeRules>(DEFAULT_RULES)
const [runName, setRunName] = useState('')
const { data: routes } = useGameRoutes(selectedGame?.id ?? null)
const hiddenRules = useMemo(() => {
const hidden = new Set<keyof NuzlockeRules>()
const hasPinwheelZones = routes?.some((r) => r.pinwheelZone != null)
if (!hasPinwheelZones) {
hidden.add('pinwheelClause')
}
return hidden.size > 0 ? hidden : undefined
}, [routes])
const handleGameSelect = (game: Game) => {
if (selectedGame?.id === game.id) {
setSelectedGame(null)
return
}
setSelectedGame(game)
if (!runName || runName === `${selectedGame?.name} Nuzlocke`) {
setRunName(`${game.name} Nuzlocke`)
}
}
const handleCreate = () => {
if (!selectedGame) return
createRun.mutate(
{ gameId: selectedGame.id, name: runName, rules },
{ onSuccess: (data) => navigate(`/runs/${data.id}`) },
)
}
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
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
New Nuzlocke Run
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Set up your run in a few steps.
</p>
<StepIndicator currentStep={step} onStepClick={setStep} />
{step === 1 && (
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Choose a Game
</h2>
<div className="sticky top-0 z-10 bg-gray-50 dark:bg-gray-900 py-3 mb-4 border-b border-gray-200 dark:border-gray-700">
{selectedGame ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<SelectedGameThumb game={selectedGame} />
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">
{selectedGame.name}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{selectedGame.region.charAt(0).toUpperCase() + selectedGame.region.slice(1)}
</p>
</div>
</div>
<button
type="button"
onClick={() => setStep(2)}
className="px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Next
</button>
</div>
) : (
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500 dark:text-gray-400">
Select a game to continue
</p>
<button
type="button"
disabled
className="px-6 py-2 bg-blue-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</div>
{isLoading && (
<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>
)}
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
Failed to load games. Please try again.
</div>
)}
{games && (
<GameGrid
games={games}
selectedId={selectedGame?.id ?? null}
onSelect={handleGameSelect}
runs={runs}
/>
)}
</div>
)}
{step === 2 && (
<div>
<RulesConfiguration rules={rules} onChange={setRules} hiddenRules={hiddenRules} />
<div className="mt-6 flex justify-between">
<button
type="button"
onClick={() => setStep(1)}
className="px-6 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 transition-colors"
>
Back
</button>
<button
type="button"
onClick={() => setStep(3)}
className="px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Next
</button>
</div>
</div>
)}
{step === 3 && (
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Name Your Run
</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-4">
<div>
<label
htmlFor="run-name"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Run Name
</label>
<input
id="run-name"
type="text"
value={runName}
onChange={(e) => setRunName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="My Nuzlocke Run"
/>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
Summary
</h3>
<dl className="space-y-1 text-sm">
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Game</dt>
<dd className="text-gray-900 dark:text-gray-100 font-medium">
{selectedGame?.name}
</dd>
</div>
<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))}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Rules</dt>
<dd className="text-gray-900 dark:text-gray-100 font-medium">
{enabledRuleCount} of {totalRuleCount} enabled
</dd>
</div>
</dl>
</div>
</div>
{createRun.error && (
<div className="mt-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
Failed to create run. Please try again.
</div>
)}
<div className="mt-6 flex justify-between">
<button
type="button"
onClick={() => setStep(2)}
className="px-6 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 transition-colors"
>
Back
</button>
<button
type="button"
disabled={!runName.trim() || createRun.isPending}
onClick={handleCreate}
className="px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{createRun.isPending ? 'Creating...' : 'Create Run'}
</button>
</div>
</div>
)}
</div>
)
}
function SelectedGameThumb({ game }: { game: Game }) {
const [imgIdx, setImgIdx] = useState(0)
const backgroundColor = game.color ?? DEFAULT_COLOR
const boxArtSrcs = [`/boxart/${game.slug}.png`, `/boxart/${game.slug}.jpg`]
if (imgIdx >= boxArtSrcs.length) {
return (
<div
className="w-10 h-10 rounded flex items-center justify-center flex-shrink-0"
style={{ backgroundColor }}
>
<span className="text-white text-xs font-bold drop-shadow-md">
{game.name.replace('Pokemon ', '').slice(0, 3)}
</span>
</div>
)
}
return (
<img
src={boxArtSrcs[imgIdx]}
alt={game.name}
className="w-10 h-10 rounded object-cover flex-shrink-0"
onError={() => setImgIdx((i) => i + 1)}
/>
)
}