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>
314 lines
12 KiB
TypeScript
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)}
|
|
/>
|
|
)
|
|
}
|