Add genlocke creation wizard with backend API and 4-step frontend

Implements the genlocke creation feature end-to-end: Genlocke and
GenlockeLeg models with migration, POST /genlockes endpoint that
creates the genlocke with all legs and auto-starts the first run,
and a 4-step wizard UI (Name, Select Games with preset templates,
Rules, Confirm) at /genlockes/new.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Julian Tabel
2026-02-09 09:23:48 +01:00
parent aaaeb2146e
commit 7851e14c2f
18 changed files with 923 additions and 29 deletions

View File

@@ -73,11 +73,11 @@ A dedicated page showing:
7. **Gauntlet/Retire HoF rule** — Enforce the "retire" mechanic with cumulative dupe list 7. **Gauntlet/Retire HoF rule** — Enforce the "retire" mechanic with cumulative dupe list
## Success Criteria ## Success Criteria
- [ ] A user can create a new genlocke via a multi-step wizard (name, game selection with presets, rules) - [x] A user can create a new genlocke via a multi-step wizard (name, game selection with presets, rules)
- [ ] Games can be selected using True Genlocke, Normal Genlocke, or Custom presets, grouped by region - [x] Games can be selected using True Genlocke, Normal Genlocke, or Custom presets, grouped by region
- [ ] Nuzlocke rules are configured once and applied uniformly to all legs - [x] Nuzlocke rules are configured once and applied uniformly to all legs
- [ ] Genlocke-specific rules (Keep HoF / Retire HoF) can be selected - [x] Genlocke-specific rules (Keep HoF / Retire HoF) can be selected
- [ ] The first leg starts automatically upon genlocke creation - [x] The first leg starts automatically upon genlocke creation
- [ ] Each leg is a full nuzlocke run, tracked identically to standalone runs - [ ] Each leg is a full nuzlocke run, tracked identically to standalone runs
- [ ] Completing a leg triggers a transfer step where surviving Pokemon can be carried forward - [ ] Completing a leg triggers a transfer step where surviving Pokemon can be carried forward
- [ ] Failing a leg marks the entire genlocke as failed - [ ] Failing a leg marks the entire genlocke as failed

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-glh8 # nuzlocke-tracker-glh8
title: Gather generation metadata (games, regions) title: Gather generation metadata (games, regions)
status: in-progress status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-02-08T19:20:49Z created_at: 2026-02-08T19:20:49Z
updated_at: 2026-02-09T08:05:52Z updated_at: 2026-02-09T08:06:19Z
parent: nuzlocke-tracker-25mh parent: nuzlocke-tracker-25mh
blocking: blocking:
- nuzlocke-tracker-kz5g - nuzlocke-tracker-kz5g

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-kz5g # nuzlocke-tracker-kz5g
title: Genlocke creation wizard title: Genlocke creation wizard
status: todo status: in-progress
type: feature type: feature
priority: normal priority: normal
created_at: 2026-02-09T07:42:10Z created_at: 2026-02-09T07:42:10Z
updated_at: 2026-02-09T07:45:34Z updated_at: 2026-02-09T08:10:10Z
parent: nuzlocke-tracker-25mh parent: nuzlocke-tracker-25mh
blocking: blocking:
- nuzlocke-tracker-x4p6 - nuzlocke-tracker-x4p6
@@ -50,16 +50,16 @@ Multi-step wizard UI for creating a new genlocke. This is the entry point for th
- Needs generation/region metadata to power the preset templates (see nuzlocke-tracker-glh8) - Needs generation/region metadata to power the preset templates (see nuzlocke-tracker-glh8)
## Checklist ## Checklist
- [ ] Create `Genlocke` SQLAlchemy model (name, status, genlocke_rules JSONB, nuzlocke_rules JSONB, created_at) - [x] Create `Genlocke` SQLAlchemy model (name, status, genlocke_rules JSONB, nuzlocke_rules JSONB, created_at)
- [ ] Create `GenlockeLeg` SQLAlchemy model (genlocke_id FK, run_id FK nullable, leg_order, game_id FK) - [x] Create `GenlockeLeg` SQLAlchemy model (genlocke_id FK, run_id FK nullable, leg_order, game_id FK)
- [ ] Create Alembic migration for both new tables - [x] Create Alembic migration for both new tables
- [ ] Create Pydantic schemas for genlocke creation request/response - [x] Create Pydantic schemas for genlocke creation request/response
- [ ] Implement `POST /api/v1/genlockes` endpoint (creates genlocke, legs, and first run) - [x] Implement `POST /api/v1/genlockes` endpoint (creates genlocke, legs, and first run)
- [ ] Build the multi-step wizard shell component with back/next navigation and step indicator - [x] Build the multi-step wizard shell component with back/next navigation and step indicator
- [ ] Build Step 1: Name input - [x] Build Step 1: Name input
- [ ] Build Step 2: Preset template selector (True / Normal / Custom) with region-grouped game picker - [x] Build Step 2: Preset template selector (True / Normal / Custom) with region-grouped game picker
- [ ] Build Step 3: Rules configuration (reuse `RulesConfiguration` + genlocke rules radio) - [x] Build Step 3: Rules configuration (reuse `RulesConfiguration` + genlocke rules radio)
- [ ] Build Step 4: Confirmation summary with "Start Genlocke" action - [x] Build Step 4: Confirmation summary with "Start Genlocke" action
- [ ] Add `/genlockes/new` route to the React Router config - [x] Add `/genlockes/new` route to the React Router config
- [ ] Add TypeScript types for genlocke API responses - [x] Add TypeScript types for genlocke API responses
- [ ] Wire up the wizard to call the create endpoint and redirect to the genlocke overview on success - [x] Wire up the wizard to call the create endpoint and redirect to the genlocke overview on success

View File

@@ -0,0 +1,46 @@
"""add genlocke tables
Revision ID: b2c3d4e5f6a8
Revises: a1b2c3d4e5f8, b7c8d9e0f1a2
Create Date: 2026-02-09 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6a8'
down_revision: Union[str, Sequence[str], None] = ('a1b2c3d4e5f8', 'b7c8d9e0f1a2')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'genlockes',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('status', sa.String(20), nullable=False, index=True),
sa.Column('genlocke_rules', JSONB(), nullable=False, server_default='{}'),
sa.Column('nuzlocke_rules', JSONB(), nullable=False, server_default='{}'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
'genlocke_legs',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('genlocke_id', sa.Integer(), sa.ForeignKey('genlockes.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('game_id', sa.Integer(), sa.ForeignKey('games.id'), nullable=False, index=True),
sa.Column('run_id', sa.Integer(), sa.ForeignKey('nuzlocke_runs.id'), nullable=True, index=True),
sa.Column('leg_order', sa.SmallInteger(), nullable=False),
sa.UniqueConstraint('genlocke_id', 'leg_order', name='uq_genlocke_legs_order'),
)
def downgrade() -> None:
op.drop_table('genlocke_legs')
op.drop_table('genlockes')

View File

@@ -0,0 +1,81 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_session
from app.models.game import Game
from app.models.genlocke import Genlocke, GenlockeLeg
from app.models.nuzlocke_run import NuzlockeRun
from app.schemas.genlocke import GenlockeCreate, GenlockeResponse
router = APIRouter()
@router.post("", response_model=GenlockeResponse, status_code=201)
async def create_genlocke(
data: GenlockeCreate, session: AsyncSession = Depends(get_session)
):
if not data.game_ids:
raise HTTPException(status_code=400, detail="At least one game is required")
if not data.name.strip():
raise HTTPException(status_code=400, detail="Name is required")
# Validate all game_ids exist
result = await session.execute(
select(Game).where(Game.id.in_(data.game_ids))
)
found_games = {g.id: g for g in result.scalars().all()}
missing = [gid for gid in data.game_ids if gid not in found_games]
if missing:
raise HTTPException(
status_code=404, detail=f"Games not found: {missing}"
)
# Create genlocke
genlocke = Genlocke(
name=data.name.strip(),
status="active",
genlocke_rules=data.genlocke_rules,
nuzlocke_rules=data.nuzlocke_rules,
)
session.add(genlocke)
await session.flush() # get genlocke.id
# Create legs
legs = []
for i, game_id in enumerate(data.game_ids, start=1):
leg = GenlockeLeg(
genlocke_id=genlocke.id,
game_id=game_id,
leg_order=i,
)
session.add(leg)
legs.append(leg)
# Create the first run
first_game = found_games[data.game_ids[0]]
first_run = NuzlockeRun(
game_id=first_game.id,
name=f"{data.name.strip()} \u2014 Leg 1",
status="active",
rules=data.nuzlocke_rules,
)
session.add(first_run)
await session.flush() # get first_run.id
# Link first leg to the run
legs[0].run_id = first_run.id
await session.commit()
# Reload with relationships
result = await session.execute(
select(Genlocke)
.where(Genlocke.id == genlocke.id)
.options(
selectinload(Genlocke.legs).selectinload(GenlockeLeg.game),
)
)
return result.scalar_one()

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api import bosses, encounters, evolutions, export, games, health, pokemon, runs, stats from app.api import bosses, encounters, evolutions, export, games, genlockes, health, pokemon, runs, stats
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(health.router) api_router.include_router(health.router)
@@ -8,6 +8,7 @@ api_router.include_router(games.router, prefix="/games", tags=["games"])
api_router.include_router(pokemon.router, tags=["pokemon"]) api_router.include_router(pokemon.router, tags=["pokemon"])
api_router.include_router(evolutions.router, tags=["evolutions"]) api_router.include_router(evolutions.router, tags=["evolutions"])
api_router.include_router(runs.router, prefix="/runs", tags=["runs"]) api_router.include_router(runs.router, prefix="/runs", tags=["runs"])
api_router.include_router(genlockes.router, prefix="/genlockes", tags=["genlockes"])
api_router.include_router(encounters.router, tags=["encounters"]) api_router.include_router(encounters.router, tags=["encounters"])
api_router.include_router(stats.router, prefix="/stats", tags=["stats"]) api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
api_router.include_router(bosses.router, tags=["bosses"]) api_router.include_router(bosses.router, tags=["bosses"])

View File

@@ -4,6 +4,7 @@ from app.models.boss_result import BossResult
from app.models.encounter import Encounter from app.models.encounter import Encounter
from app.models.evolution import Evolution from app.models.evolution import Evolution
from app.models.game import Game from app.models.game import Game
from app.models.genlocke import Genlocke, GenlockeLeg
from app.models.nuzlocke_run import NuzlockeRun from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
from app.models.route import Route from app.models.route import Route
@@ -17,6 +18,8 @@ __all__ = [
"Encounter", "Encounter",
"Evolution", "Evolution",
"Game", "Game",
"Genlocke",
"GenlockeLeg",
"NuzlockeRun", "NuzlockeRun",
"Pokemon", "Pokemon",
"Route", "Route",

View File

@@ -0,0 +1,46 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, SmallInteger, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.core.database import Base
class Genlocke(Base):
__tablename__ = "genlockes"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
status: Mapped[str] = mapped_column(String(20), index=True) # active, completed, failed
genlocke_rules: Mapped[dict] = mapped_column(JSONB, default=dict)
nuzlocke_rules: Mapped[dict] = mapped_column(JSONB, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
legs: Mapped[list["GenlockeLeg"]] = relationship(
back_populates="genlocke", order_by="GenlockeLeg.leg_order"
)
class GenlockeLeg(Base):
__tablename__ = "genlocke_legs"
__table_args__ = (
UniqueConstraint("genlocke_id", "leg_order", name="uq_genlocke_legs_order"),
)
id: Mapped[int] = mapped_column(primary_key=True)
genlocke_id: Mapped[int] = mapped_column(
ForeignKey("genlockes.id", ondelete="CASCADE"), index=True
)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True)
run_id: Mapped[int | None] = mapped_column(
ForeignKey("nuzlocke_runs.id"), index=True
)
leg_order: Mapped[int] = mapped_column(SmallInteger)
genlocke: Mapped["Genlocke"] = relationship(back_populates="legs")
game: Mapped["Game"] = relationship()
run: Mapped["NuzlockeRun | None"] = relationship()

View File

@@ -14,6 +14,7 @@ from app.schemas.encounter import (
EncounterResponse, EncounterResponse,
EncounterUpdate, EncounterUpdate,
) )
from app.schemas.genlocke import GenlockeCreate, GenlockeResponse, GenlockeLegResponse
from app.schemas.game import ( from app.schemas.game import (
GameCreate, GameCreate,
GameDetailResponse, GameDetailResponse,
@@ -54,6 +55,9 @@ __all__ = [
"EncounterResponse", "EncounterResponse",
"EncounterUpdate", "EncounterUpdate",
"EvolutionResponse", "EvolutionResponse",
"GenlockeCreate",
"GenlockeLegResponse",
"GenlockeResponse",
"GameCreate", "GameCreate",
"GameDetailResponse", "GameDetailResponse",
"GameResponse", "GameResponse",

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from app.schemas.base import CamelModel
from app.schemas.game import GameResponse
class GenlockeCreate(CamelModel):
name: str
game_ids: list[int]
genlocke_rules: dict = {}
nuzlocke_rules: dict = {}
class GenlockeLegResponse(CamelModel):
id: int
genlocke_id: int
game_id: int
run_id: int | None = None
leg_order: int
game: GameResponse
class GenlockeResponse(CamelModel):
id: int
name: str
status: str
genlocke_rules: dict
nuzlocke_rules: dict
created_at: datetime
legs: list[GenlockeLegResponse] = []

View File

@@ -1,7 +1,7 @@
import { Routes, Route, Navigate } from 'react-router-dom' import { Routes, Route, Navigate } from 'react-router-dom'
import { Layout } from './components' import { Layout } from './components'
import { AdminLayout } from './components/admin' import { AdminLayout } from './components/admin'
import { Home, NewRun, RunList, RunEncounters, Stats } from './pages' import { Home, NewGenlocke, NewRun, RunList, RunEncounters, Stats } from './pages'
import { import {
AdminGames, AdminGames,
AdminGameDetail, AdminGameDetail,
@@ -19,6 +19,7 @@ function App() {
<Route path="runs" element={<RunList />} /> <Route path="runs" element={<RunList />} />
<Route path="runs/new" element={<NewRun />} /> <Route path="runs/new" element={<NewRun />} />
<Route path="runs/:runId" element={<RunEncounters />} /> <Route path="runs/:runId" element={<RunEncounters />} />
<Route path="genlockes/new" element={<NewGenlocke />} />
<Route path="stats" element={<Stats />} /> <Route path="stats" element={<Stats />} />
<Route path="runs/:runId/encounters" element={<Navigate to=".." relative="path" replace />} /> <Route path="runs/:runId/encounters" element={<Navigate to=".." relative="path" replace />} />
<Route path="admin" element={<AdminLayout />}> <Route path="admin" element={<AdminLayout />}>

View File

@@ -0,0 +1,10 @@
import { api } from './client'
import type { Genlocke, CreateGenlockeInput, Region } from '../types/game'
export function createGenlocke(data: CreateGenlockeInput): Promise<Genlocke> {
return api.post('/genlockes', data)
}
export function getGamesByRegion(): Promise<Region[]> {
return api.get('/games/by-region')
}

View File

@@ -28,6 +28,12 @@ export function Layout() {
> >
My Runs My Runs
</Link> </Link>
<Link
to="/genlockes/new"
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Genlockes
</Link>
<Link <Link
to="/stats" to="/stats"
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700" className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
@@ -93,6 +99,13 @@ export function Layout() {
> >
My Runs My Runs
</Link> </Link>
<Link
to="/genlockes/new"
onClick={() => setMenuOpen(false)}
className="block px-3 py-2 rounded-md text-base font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Genlockes
</Link>
<Link <Link
to="/stats" to="/stats"
onClick={() => setMenuOpen(false)} onClick={() => setMenuOpen(false)}

View File

@@ -1,15 +1,16 @@
const STEPS = ['Select Game', 'Configure Rules', 'Create Run'] const DEFAULT_STEPS = ['Select Game', 'Configure Rules', 'Create Run']
interface StepIndicatorProps { interface StepIndicatorProps {
currentStep: number currentStep: number
onStepClick: (step: number) => void onStepClick: (step: number) => void
steps?: string[]
} }
export function StepIndicator({ currentStep, onStepClick }: StepIndicatorProps) { export function StepIndicator({ currentStep, onStepClick, steps = DEFAULT_STEPS }: StepIndicatorProps) {
return ( return (
<nav aria-label="Progress" className="mb-8"> <nav aria-label="Progress" className="mb-8">
<ol className="flex items-center"> <ol className="flex items-center">
{STEPS.map((label, i) => { {steps.map((label, i) => {
const step = i + 1 const step = i + 1
const isCompleted = step < currentStep const isCompleted = step < currentStep
const isCurrent = step === currentStep const isCurrent = step === currentStep
@@ -17,7 +18,7 @@ export function StepIndicator({ currentStep, onStepClick }: StepIndicatorProps)
return ( return (
<li <li
key={label} key={label}
className={`flex items-center ${i < STEPS.length - 1 ? 'flex-1' : ''}`} className={`flex items-center ${i < steps.length - 1 ? 'flex-1' : ''}`}
> >
<button <button
type="button" type="button"
@@ -60,7 +61,7 @@ export function StepIndicator({ currentStep, onStepClick }: StepIndicatorProps)
</span> </span>
<span className="hidden sm:inline">{label}</span> <span className="hidden sm:inline">{label}</span>
</button> </button>
{i < STEPS.length - 1 && ( {i < steps.length - 1 && (
<div <div
className={`flex-1 h-0.5 mx-3 ${ className={`flex-1 h-0.5 mx-3 ${
step < currentStep step < currentStep

View File

@@ -0,0 +1,20 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createGenlocke, getGamesByRegion } from '../api/genlockes'
import type { CreateGenlockeInput } from '../types/game'
export function useRegions() {
return useQuery({
queryKey: ['games', 'by-region'],
queryFn: getGamesByRegion,
})
}
export function useCreateGenlocke() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateGenlockeInput) => createGenlocke(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['runs'] })
},
})
}

View File

@@ -0,0 +1,606 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { RulesConfiguration, StepIndicator } from '../components'
import { useRegions, useCreateGenlocke } from '../hooks/useGenlockes'
import type { Game, GenlockeRules, Region } from '../types'
import { DEFAULT_RULES } from '../types'
import type { NuzlockeRules } from '../types/rules'
import { RULE_DEFINITIONS } from '../types/rules'
const STEPS = ['Name', 'Select Games', 'Rules', 'Confirm']
const DEFAULT_COLOR = '#6366f1'
interface LegEntry {
region: string
game: Game
}
type PresetType = 'true' | 'normal' | 'custom' | null
function buildLegsFromPreset(
regions: Region[],
preset: 'true' | 'normal',
): LegEntry[] {
const legs: LegEntry[] = []
for (const region of regions) {
const targetSlug =
preset === 'true'
? region.genlockeDefaults.trueGenlocke
: region.genlockeDefaults.normalGenlocke
const game = region.games.find((g) => g.slug === targetSlug)
if (game) {
legs.push({ region: region.name, game })
}
}
return legs
}
export function NewGenlocke() {
const navigate = useNavigate()
const { data: regions, isLoading: regionsLoading } = useRegions()
const createGenlocke = useCreateGenlocke()
const [step, setStep] = useState(1)
const [name, setName] = useState('')
const [legs, setLegs] = useState<LegEntry[]>([])
const [preset, setPreset] = useState<PresetType>(null)
const [nuzlockeRules, setNuzlockeRules] = useState<NuzlockeRules>(DEFAULT_RULES)
const [genlockeRules, setGenlockeRules] = useState<GenlockeRules>({ retireHoF: false })
const handlePresetSelect = (type: PresetType) => {
setPreset(type)
if (!regions) return
if (type === 'true' || type === 'normal') {
setLegs(buildLegsFromPreset(regions, type))
} else if (type === 'custom') {
setLegs([])
}
}
const handleGameChange = (index: number, game: Game) => {
setLegs((prev) => prev.map((leg, i) => (i === index ? { ...leg, game } : leg)))
}
const handleRemoveLeg = (index: number) => {
setLegs((prev) => prev.filter((_, i) => i !== index))
}
const handleAddLeg = (region: Region) => {
const defaultSlug = region.genlockeDefaults.normalGenlocke
const game = region.games.find((g) => g.slug === defaultSlug) ?? region.games[0]
if (game) {
setLegs((prev) => [...prev, { region: region.name, game }])
}
}
const handleMoveLeg = (index: number, direction: 'up' | 'down') => {
const target = direction === 'up' ? index - 1 : index + 1
if (target < 0 || target >= legs.length) return
setLegs((prev) => {
const next = [...prev]
;[next[index], next[target]] = [next[target], next[index]]
return next
})
}
const handleCreate = () => {
if (!name.trim() || legs.length === 0) return
createGenlocke.mutate(
{
name: name.trim(),
gameIds: legs.map((l) => l.game.id),
genlockeRules,
nuzlockeRules,
},
{
onSuccess: (data) => {
const firstLeg = data.legs.find((l) => l.legOrder === 1)
if (firstLeg?.runId) {
navigate(`/runs/${firstLeg.runId}`)
} else {
navigate('/runs')
}
},
},
)
}
const enabledRuleCount = RULE_DEFINITIONS.filter((r) => nuzlockeRules[r.key]).length
const totalRuleCount = RULE_DEFINITIONS.length
// Regions not yet used in legs (for "add leg" picker)
const availableRegions = regions?.filter(
(r) => !legs.some((l) => l.region === r.name),
) ?? []
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 Genlocke
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Set up your generational challenge.
</p>
<StepIndicator currentStep={step} onStepClick={setStep} steps={STEPS} />
{/* Step 1: Name */}
{step === 1 && (
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Name Your Genlocke
</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<label
htmlFor="genlocke-name"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Genlocke Name
</label>
<input
id="genlocke-name"
type="text"
value={name}
onChange={(e) => setName(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 Genlocke"
maxLength={100}
autoFocus
/>
</div>
<div className="mt-6 flex justify-end">
<button
type="button"
disabled={!name.trim()}
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 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
)}
{/* Step 2: Game Selection */}
{step === 2 && (
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Select Games
</h2>
{/* Preset buttons */}
<div className="flex gap-3 mb-6">
{(['true', 'normal', 'custom'] as const).map((type) => {
const labels = {
true: 'True Genlocke',
normal: 'Normal Genlocke',
custom: 'Custom',
}
const descriptions = {
true: 'Original releases only',
normal: 'Latest version per region',
custom: 'Build your own',
}
const isActive = preset === type
return (
<button
key={type}
type="button"
onClick={() => handlePresetSelect(type)}
className={`flex-1 p-4 rounded-lg border-2 text-left transition-colors ${
isActive
? 'border-blue-600 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className={`font-medium ${isActive ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-gray-100'}`}>
{labels[type]}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{descriptions[type]}
</div>
</button>
)
})}
</div>
{regionsLoading && (
<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>
)}
{/* Legs list */}
{legs.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow divide-y divide-gray-200 dark:divide-gray-700">
{legs.map((leg, index) => (
<LegRow
key={`${leg.region}-${index}`}
leg={leg}
index={index}
total={legs.length}
regions={regions ?? []}
onGameChange={(game) => handleGameChange(index, game)}
onRemove={() => handleRemoveLeg(index)}
onMove={(dir) => handleMoveLeg(index, dir)}
/>
))}
</div>
)}
{/* Add leg button */}
{preset === 'custom' && availableRegions.length > 0 && (
<div className="mt-4">
<AddLegDropdown regions={availableRegions} onAdd={handleAddLeg} />
</div>
)}
{/* Also allow adding extra regions for presets */}
{preset && preset !== 'custom' && availableRegions.length > 0 && legs.length > 0 && (
<div className="mt-4">
<AddLegDropdown regions={availableRegions} onAdd={handleAddLeg} />
</div>
)}
<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"
disabled={legs.length === 0}
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 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
)}
{/* Step 3: Rules */}
{step === 3 && (
<div>
<RulesConfiguration rules={nuzlockeRules} onChange={setNuzlockeRules} />
{/* Genlocke-specific rules */}
<div className="mt-6 bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Genlocke Rules
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Rules specific to the generational challenge
</p>
</div>
<div className="px-4 py-4">
<fieldset>
<legend className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Hall of Fame Pokemon
</legend>
<div className="space-y-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="hofRule"
checked={!genlockeRules.retireHoF}
onChange={() => setGenlockeRules({ retireHoF: false })}
className="mt-0.5 w-4 h-4 text-blue-600 focus:ring-blue-500"
/>
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">
Keep Hall of Fame
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Pokemon that beat the Elite Four can continue to the next leg
</div>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="hofRule"
checked={genlockeRules.retireHoF}
onChange={() => setGenlockeRules({ retireHoF: true })}
className="mt-0.5 w-4 h-4 text-blue-600 focus:ring-blue-500"
/>
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">
Retire Hall of Fame
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Pokemon that beat the Elite Four are retired and cannot be used in the next leg
</div>
</div>
</label>
</div>
</fieldset>
</div>
</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"
onClick={() => setStep(4)}
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 4: Confirm */}
{step === 4 && (
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Confirm & Start
</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
Name
</h3>
<p className="text-gray-900 dark:text-gray-100 font-medium">{name}</p>
</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">
Legs ({legs.length})
</h3>
<ol className="space-y-2">
{legs.map((leg, i) => (
<li key={i} className="flex items-center gap-3">
<span className="text-sm text-gray-400 dark:text-gray-500 w-6 text-right font-mono">
{i + 1}.
</span>
<GameThumb game={leg.game} />
<div>
<span className="text-gray-900 dark:text-gray-100 font-medium">
{leg.game.name}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400 ml-2">
{leg.region.charAt(0).toUpperCase() + leg.region.slice(1)}
</span>
</div>
</li>
))}
</ol>
</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-1">
Rules
</h3>
<dl className="space-y-1 text-sm">
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Nuzlocke Rules</dt>
<dd className="text-gray-900 dark:text-gray-100 font-medium">
{enabledRuleCount} of {totalRuleCount} enabled
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-600 dark:text-gray-400">Hall of Fame</dt>
<dd className="text-gray-900 dark:text-gray-100 font-medium">
{genlockeRules.retireHoF ? 'Retire' : 'Keep'}
</dd>
</div>
</dl>
</div>
</div>
{createGenlocke.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 genlocke. Please try again.
</div>
)}
<div className="mt-6 flex justify-between">
<button
type="button"
onClick={() => setStep(3)}
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={createGenlocke.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"
>
{createGenlocke.isPending ? 'Creating...' : 'Start Genlocke'}
</button>
</div>
</div>
)}
</div>
)
}
// --- Sub-components ---
function LegRow({
leg,
index,
total,
regions,
onGameChange,
onRemove,
onMove,
}: {
leg: LegEntry
index: number
total: number
regions: Region[]
onGameChange: (game: Game) => void
onRemove: () => void
onMove: (dir: 'up' | 'down') => void
}) {
const region = regions.find((r) => r.name === leg.region)
const games = region?.games ?? []
return (
<div className="flex items-center gap-3 px-4 py-3">
<span className="text-sm text-gray-400 dark:text-gray-500 w-6 text-right font-mono shrink-0">
{index + 1}.
</span>
<GameThumb game={leg.game} />
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-500 dark:text-gray-400">
{leg.region.charAt(0).toUpperCase() + leg.region.slice(1)}
</div>
{games.length > 1 ? (
<select
value={leg.game.id}
onChange={(e) => {
const game = games.find((g) => g.id === Number(e.target.value))
if (game) onGameChange(game)
}}
className="mt-1 w-full max-w-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{games.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
) : (
<div className="text-gray-900 dark:text-gray-100 font-medium">
{leg.game.name}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
disabled={index === 0}
onClick={() => onMove('up')}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed"
title="Move up"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
type="button"
disabled={index === total - 1}
onClick={() => onMove('down')}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed"
title="Move down"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<button
type="button"
onClick={onRemove}
className="p-1 text-red-400 hover:text-red-600 dark:hover:text-red-300"
title="Remove leg"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)
}
function AddLegDropdown({
regions,
onAdd,
}: {
regions: Region[]
onAdd: (region: Region) => void
}) {
const [open, setOpen] = useState(false)
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add Region
</button>
)
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-3">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select a region to add
</div>
<div className="flex flex-wrap gap-2">
{regions.map((region) => (
<button
key={region.name}
type="button"
onClick={() => {
onAdd(region)
setOpen(false)
}}
className="px-3 py-1.5 rounded-md text-sm bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
{region.name.charAt(0).toUpperCase() + region.name.slice(1)}
</button>
))}
<button
type="button"
onClick={() => setOpen(false)}
className="px-3 py-1.5 rounded-md text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Cancel
</button>
</div>
</div>
)
}
function GameThumb({ 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)}
/>
)
}

View File

@@ -1,4 +1,5 @@
export { Home } from './Home' export { Home } from './Home'
export { NewGenlocke } from './NewGenlocke'
export { NewRun } from './NewRun' export { NewRun } from './NewRun'
export { RunList } from './RunList' export { RunList } from './RunList'
export { RunEncounters } from './RunEncounters' export { RunEncounters } from './RunEncounters'

View File

@@ -193,3 +193,34 @@ export interface CreateBossResultInput {
// Re-export for convenience // Re-export for convenience
import type { NuzlockeRules } from './rules' import type { NuzlockeRules } from './rules'
export type { NuzlockeRules } export type { NuzlockeRules }
// Genlocke types
export interface GenlockeRules {
retireHoF: boolean
}
export interface GenlockeLeg {
id: number
genlockeId: number
gameId: number
runId: number | null
legOrder: number
game: Game
}
export interface Genlocke {
id: number
name: string
status: 'active' | 'completed' | 'failed'
genlockeRules: GenlockeRules
nuzlockeRules: NuzlockeRules
createdAt: string
legs: GenlockeLeg[]
}
export interface CreateGenlockeInput {
name: string
gameIds: number[]
genlockeRules: GenlockeRules
nuzlockeRules: NuzlockeRules
}