Files
nuzlocke-tracker/frontend/src/pages/NewRun.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

314 lines
12 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, useNamingCategories } from '../hooks/useRuns'
import type { Game, NuzlockeRules, RunVisibility } 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 { data: namingCategories } = useNamingCategories()
const [step, setStep] = useState(1)
const [selectedGame, setSelectedGame] = useState<Game | null>(null)
const [rules, setRules] = useState<NuzlockeRules>(DEFAULT_RULES)
const [runName, setRunName] = useState('')
const [namingScheme, setNamingScheme] = useState<string | null>(null)
const [visibility, setVisibility] = useState<RunVisibility>('public')
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, namingScheme, visibility },
{ 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-text-primary mb-2">New Nuzlocke Run</h1>
<p className="text-text-tertiary 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-text-primary mb-4">Choose a Game</h2>
<div className="sticky top-0 z-10 bg-surface-0 py-3 mb-4 border-b border-border-default">
{selectedGame ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<SelectedGameThumb game={selectedGame} />
<div>
<p className="font-medium text-text-primary">{selectedGame.name}</p>
<p className="text-sm text-text-tertiary">
{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-accent-400 focus:ring-offset-2 transition-colors"
>
Next
</button>
</div>
) : (
<div className="flex items-center justify-between">
<p className="text-sm text-text-tertiary">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-status-failed-bg p-4 text-status-failed">
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-text-secondary bg-surface-2 rounded-lg font-medium hover:bg-surface-3 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-accent-400 focus:ring-offset-2 transition-colors"
>
Next
</button>
</div>
</div>
)}
{step === 3 && (
<div>
<h2 className="text-xl font-semibold text-text-primary mb-4">Name Your Run</h2>
<div className="bg-surface-1 rounded-lg shadow p-6 space-y-4">
<div>
<label
htmlFor="run-name"
className="block text-sm font-medium text-text-secondary 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-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-transparent"
placeholder="My Nuzlocke Run"
/>
</div>
{namingCategories && namingCategories.length > 0 && (
<div>
<label
htmlFor="naming-scheme"
className="block text-sm font-medium text-text-secondary mb-1"
>
Naming Scheme
</label>
<select
id="naming-scheme"
value={namingScheme ?? ''}
onChange={(e) => setNamingScheme(e.target.value || null)}
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-transparent"
>
<option value="">None (manual nicknames)</option>
{namingCategories.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</option>
))}
</select>
<p className="mt-1 text-xs text-text-tertiary">
Get nickname suggestions from a themed word list when catching Pokemon.
</p>
</div>
)}
<div>
<label
htmlFor="visibility"
className="block text-sm font-medium text-text-secondary mb-1"
>
Visibility
</label>
<select
id="visibility"
value={visibility}
onChange={(e) => setVisibility(e.target.value as RunVisibility)}
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-transparent"
>
<option value="public">Public</option>
<option value="private">Private</option>
</select>
<p className="mt-1 text-xs text-text-tertiary">
{visibility === 'private'
? 'Only you will be able to see this run'
: 'Anyone can view this run'}
</p>
</div>
<div className="border-t border-border-default pt-4">
<h3 className="text-sm font-medium text-text-tertiary mb-2">Summary</h3>
<dl className="space-y-1 text-sm">
<div className="flex justify-between">
<dt className="text-text-tertiary">Game</dt>
<dd className="text-text-primary font-medium">{selectedGame?.name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-text-tertiary">Region</dt>
<dd className="text-text-primary font-medium">
{selectedGame &&
selectedGame.region.charAt(0).toUpperCase() + selectedGame.region.slice(1)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-text-tertiary">Rules</dt>
<dd className="text-text-primary font-medium">
{enabledRuleCount} of {totalRuleCount} enabled
</dd>
</div>
<div className="flex justify-between">
<dt className="text-text-tertiary">Naming Scheme</dt>
<dd className="text-text-primary font-medium">
{namingScheme
? namingScheme.charAt(0).toUpperCase() + namingScheme.slice(1)
: 'None'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-text-tertiary">Visibility</dt>
<dd className="text-text-primary font-medium capitalize">{visibility}</dd>
</div>
</dl>
</div>
</div>
{createRun.error && (
<div className="mt-4 rounded-lg bg-status-failed-bg p-4 text-status-failed">
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-text-secondary bg-surface-2 rounded-lg font-medium hover:bg-surface-3 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-accent-400 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)}
/>
)
}