feat: add auth system, boss pokemon details, moves/abilities API, and run ownership
Some checks failed
CI / backend-tests (push) Failing after 1m16s
CI / frontend-tests (push) Successful in 57s

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>
This commit is contained in:
2026-03-20 21:41:38 +01:00
parent a6cb309b8b
commit 0a519e356e
69 changed files with 3574 additions and 693 deletions

View File

@@ -2,14 +2,17 @@ import { Routes, Route, Navigate } from 'react-router-dom'
import { Layout } from './components'
import { AdminLayout } from './components/admin'
import {
AuthCallback,
GenlockeDetail,
GenlockeList,
Home,
JournalEntryPage,
Login,
NewGenlocke,
NewRun,
RunList,
RunEncounters,
Signup,
Stats,
} from './pages'
import {
@@ -28,6 +31,9 @@ function App() {
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="login" element={<Login />} />
<Route path="signup" element={<Signup />} />
<Route path="auth/callback" element={<AuthCallback />} />
<Route path="runs" element={<RunList />} />
<Route path="runs/new" element={<NewRun />} />
<Route path="runs/:runId" element={<RunEncounters />} />

View File

@@ -1,3 +1,5 @@
import { supabase } from '../lib/supabase'
const API_BASE = import.meta.env['VITE_API_URL'] ?? ''
export class ApiError extends Error {
@@ -10,11 +12,21 @@ export class ApiError extends Error {
}
}
async function getAuthHeaders(): Promise<Record<string, string>> {
const { data } = await supabase.auth.getSession()
if (data.session?.access_token) {
return { Authorization: `Bearer ${data.session.access_token}` }
}
return {}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const authHeaders = await getAuthHeaders()
const res = await fetch(`${API_BASE}/api/v1${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...authHeaders,
...options?.headers,
},
})

View File

@@ -5,10 +5,7 @@ import type {
UpdateJournalEntryInput,
} from '../types/journal'
export function getJournalEntries(
runId: number,
bossResultId?: number
): Promise<JournalEntry[]> {
export function getJournalEntries(runId: number, bossResultId?: number): Promise<JournalEntry[]> {
const params = bossResultId != null ? `?boss_result_id=${bossResultId}` : ''
return api.get(`/runs/${runId}/journal${params}`)
}

30
frontend/src/api/moves.ts Normal file
View File

@@ -0,0 +1,30 @@
import { api } from './client'
import type { MoveRef, AbilityRef } from '../types/game'
export interface PaginatedMoves {
items: MoveRef[]
total: number
limit: number
offset: number
}
export interface PaginatedAbilities {
items: AbilityRef[]
total: number
limit: number
offset: number
}
export function searchMoves(search: string, limit = 20): Promise<PaginatedMoves> {
const params = new URLSearchParams()
if (search) params.set('search', search)
params.set('limit', String(limit))
return api.get(`/moves?${params}`)
}
export function searchAbilities(search: string, limit = 20): Promise<PaginatedAbilities> {
const params = new URLSearchParams()
if (search) params.set('search', search)
params.set('limit', String(limit))
return api.get(`/abilities?${params}`)
}

View File

@@ -1,9 +1,15 @@
import { type FormEvent, useMemo, useState } from 'react'
import type { BossBattle, CreateBossResultInput } from '../types/game'
import type {
BossBattle,
BossResultTeamMemberInput,
CreateBossResultInput,
EncounterDetail,
} from '../types/game'
import { ConditionBadge } from './ConditionBadge'
interface BossDefeatModalProps {
boss: BossBattle
aliveEncounters: EncounterDetail[]
onSubmit: (data: CreateBossResultInput) => void
onClose: () => void
isPending?: boolean
@@ -17,14 +23,43 @@ function matchVariant(labels: string[], starterName?: string | null): string | n
return matches.length === 1 ? (matches[0] ?? null) : null
}
interface TeamSelection {
encounterId: number
level: number
}
export function BossDefeatModal({
boss,
aliveEncounters,
onSubmit,
onClose,
isPending,
starterName,
}: BossDefeatModalProps) {
const [selectedTeam, setSelectedTeam] = useState<Map<number, TeamSelection>>(new Map())
const toggleTeamMember = (enc: EncounterDetail) => {
setSelectedTeam((prev) => {
const next = new Map(prev)
if (next.has(enc.id)) {
next.delete(enc.id)
} else {
next.set(enc.id, { encounterId: enc.id, level: enc.catchLevel ?? 1 })
}
return next
})
}
const updateLevel = (encounterId: number, level: number) => {
setSelectedTeam((prev) => {
const next = new Map(prev)
const existing = next.get(encounterId)
if (existing) {
next.set(encounterId, { ...existing, level })
}
return next
})
}
const variantLabels = useMemo(() => {
const labels = new Set<string>()
for (const bp of boss.pokemon) {
@@ -52,10 +87,12 @@ export function BossDefeatModal({
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
const team: BossResultTeamMemberInput[] = Array.from(selectedTeam.values())
onSubmit({
bossBattleId: boss.id,
result: 'won',
attempts: 1,
team,
})
}
@@ -92,18 +129,93 @@ export function BossDefeatModal({
<div className="flex flex-wrap gap-3">
{[...displayedPokemon]
.sort((a, b) => a.order - b.order)
.map((bp) => (
<div key={bp.id} className="flex flex-col items-center">
{bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
.map((bp) => {
const moves = [bp.move1, bp.move2, bp.move3, bp.move4].filter(Boolean)
return (
<div key={bp.id} className="flex flex-col items-center">
{bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 bg-surface-3 rounded-full" />
)}
<span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span>
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
<ConditionBadge condition={bp.conditionLabel} size="xs" />
{bp.ability && (
<span className="text-[10px] text-text-muted">{bp.ability.name}</span>
)}
{bp.heldItem && (
<span className="text-[10px] text-yellow-500/80">{bp.heldItem}</span>
)}
{moves.length > 0 && (
<div className="text-[9px] text-text-muted text-center leading-tight max-w-[80px]">
{moves.map((m) => m!.name).join(', ')}
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* Team selection */}
{aliveEncounters.length > 0 && (
<div className="px-6 py-3 border-b border-border-default">
<p className="text-sm font-medium text-text-secondary mb-2">Your team (optional)</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{aliveEncounters.map((enc) => {
const isSelected = selectedTeam.has(enc.id)
const selection = selectedTeam.get(enc.id)
const displayPokemon = enc.currentPokemon ?? enc.pokemon
return (
<div
key={enc.id}
className={`flex items-center gap-2 p-2 rounded border cursor-pointer transition-colors ${
isSelected
? 'border-accent-500 bg-accent-500/10'
: 'border-border-default hover:bg-surface-2'
}`}
onClick={() => toggleTeamMember(enc)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleTeamMember(enc)}
className="sr-only"
/>
{displayPokemon.spriteUrl ? (
<img
src={displayPokemon.spriteUrl}
alt={displayPokemon.name}
className="w-8 h-8"
/>
) : (
<div className="w-10 h-10 bg-surface-3 rounded-full" />
<div className="w-8 h-8 bg-surface-3 rounded-full" />
)}
<span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span>
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
<ConditionBadge condition={bp.conditionLabel} size="xs" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">
{enc.nickname ?? displayPokemon.name}
</p>
{isSelected && (
<input
type="number"
min={1}
max={100}
value={selection?.level ?? enc.catchLevel ?? 1}
onChange={(e) => {
e.stopPropagation()
updateLevel(enc.id, Number.parseInt(e.target.value, 10) || 1)
}}
onClick={(e) => e.stopPropagation()}
className="w-14 text-xs px-1 py-0.5 mt-1 rounded border border-border-default bg-surface-1"
placeholder="Lv"
/>
)}
</div>
</div>
))}
)
})}
</div>
</div>
)}

View File

@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { Layout } from './Layout'
import { AuthProvider } from '../contexts/AuthContext'
vi.mock('../hooks/useTheme', () => ({
useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }),
@@ -10,7 +11,9 @@ vi.mock('../hooks/useTheme', () => ({
function renderLayout(initialPath = '/') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<Layout />
<AuthProvider>
<Layout />
</AuthProvider>
</MemoryRouter>
)
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Link, Outlet, useLocation } from 'react-router-dom'
import { useTheme } from '../hooks/useTheme'
import { useAuth } from '../contexts/AuthContext'
const navLinks = [
{ to: '/runs/new', label: 'New Run' },
@@ -71,6 +72,67 @@ function ThemeToggle() {
)
}
function UserMenu({ onAction }: { onAction?: () => void }) {
const { user, loading, signOut } = useAuth()
const [open, setOpen] = useState(false)
if (loading) {
return <div className="w-8 h-8 rounded-full bg-surface-3 animate-pulse" />
}
if (!user) {
return (
<Link
to="/login"
onClick={onAction}
className="px-3 py-2 rounded-md text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
>
Sign in
</Link>
)
}
const email = user.email ?? ''
const initials = email.charAt(0).toUpperCase()
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center gap-2 p-1 rounded-full hover:bg-surface-3 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-accent-600 flex items-center justify-center text-white text-sm font-medium">
{initials}
</div>
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 mt-2 w-48 bg-surface-2 border border-border-default rounded-lg shadow-lg z-50">
<div className="px-4 py-3 border-b border-border-default">
<p className="text-sm text-text-primary truncate">{email}</p>
</div>
<div className="py-1">
<button
type="button"
onClick={async () => {
setOpen(false)
onAction?.()
await signOut()
}}
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
>
Sign out
</button>
</div>
</div>
</>
)}
</div>
)
}
export function Layout() {
const [menuOpen, setMenuOpen] = useState(false)
const location = useLocation()
@@ -103,6 +165,7 @@ export function Layout() {
</NavLink>
))}
<ThemeToggle />
<UserMenu />
</div>
{/* Mobile hamburger */}
<div className="flex items-center gap-1 sm:hidden">
@@ -149,6 +212,9 @@ export function Layout() {
{link.label}
</NavLink>
))}
<div className="pt-2 border-t border-border-default mt-2">
<UserMenu onAction={() => setMenuOpen(false)} />
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,21 @@
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
const location = useLocation()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500" />
</div>
)
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View File

@@ -0,0 +1,69 @@
import { useState, useRef, useEffect } from 'react'
import { useSearchAbilities } from '../../hooks/useMoves'
interface AbilitySelectorProps {
label: string
selectedId: number | null
initialName?: string
onChange: (id: number | null, name: string) => void
}
export function AbilitySelector({
label,
selectedId,
initialName,
onChange,
}: AbilitySelectorProps) {
const [search, setSearch] = useState(initialName ?? '')
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const { data } = useSearchAbilities(search)
const abilities = data?.items ?? []
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
return (
<div ref={ref} className="relative">
<label className="block text-xs font-medium mb-0.5 text-text-secondary">{label}</label>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value)
setOpen(true)
if (!e.target.value) onChange(null, '')
}}
onFocus={() => search && setOpen(true)}
placeholder="Search ability..."
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
/>
{open && abilities.length > 0 && (
<ul className="absolute z-20 mt-1 w-full bg-surface-1 border border-border-default rounded shadow-lg max-h-40 overflow-y-auto">
{abilities.map((a) => (
<li
key={a.id}
onClick={() => {
onChange(a.id, a.name)
setSearch(a.name)
setOpen(false)
}}
className={`px-2 py-1.5 cursor-pointer hover:bg-surface-2 text-sm ${
a.id === selectedId ? 'bg-accent-900/30' : ''
}`}
>
{a.name}
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -1,8 +1,38 @@
import { type FormEvent, useState } from 'react'
import { PokemonSelector } from './PokemonSelector'
import { MoveSelector } from './MoveSelector'
import { AbilitySelector } from './AbilitySelector'
import type { BossBattle } from '../../types/game'
import type { BossPokemonInput } from '../../types/admin'
const NATURES = [
'Hardy',
'Lonely',
'Brave',
'Adamant',
'Naughty',
'Bold',
'Docile',
'Relaxed',
'Impish',
'Lax',
'Timid',
'Hasty',
'Serious',
'Jolly',
'Naive',
'Modest',
'Mild',
'Quiet',
'Bashful',
'Rash',
'Calm',
'Gentle',
'Sassy',
'Careful',
'Quirky',
]
interface BossTeamEditorProps {
boss: BossBattle
onSave: (team: BossPokemonInput[]) => void
@@ -15,6 +45,19 @@ interface PokemonSlot {
pokemonName: string
level: string
order: number
// Detail fields
abilityId: number | null
abilityName: string
heldItem: string
nature: string
move1Id: number | null
move1Name: string
move2Id: number | null
move2Name: string
move3Id: number | null
move3Name: string
move4Id: number | null
move4Name: string
}
interface Variant {
@@ -22,6 +65,27 @@ interface Variant {
pokemon: PokemonSlot[]
}
function createEmptySlot(order: number): PokemonSlot {
return {
pokemonId: null,
pokemonName: '',
level: '',
order,
abilityId: null,
abilityName: '',
heldItem: '',
nature: '',
move1Id: null,
move1Name: '',
move2Id: null,
move2Name: '',
move3Id: null,
move3Name: '',
move4Id: null,
move4Name: '',
}
}
function groupByVariant(boss: BossBattle): Variant[] {
const sorted = [...boss.pokemon].sort((a, b) => a.order - b.order)
const map = new Map<string | null, PokemonSlot[]>()
@@ -34,25 +98,30 @@ function groupByVariant(boss: BossBattle): Variant[] {
pokemonName: bp.pokemon.name,
level: String(bp.level),
order: bp.order,
abilityId: bp.abilityId,
abilityName: bp.ability?.name ?? '',
heldItem: bp.heldItem ?? '',
nature: bp.nature ?? '',
move1Id: bp.move1Id,
move1Name: bp.move1?.name ?? '',
move2Id: bp.move2Id,
move2Name: bp.move2?.name ?? '',
move3Id: bp.move3Id,
move3Name: bp.move3?.name ?? '',
move4Id: bp.move4Id,
move4Name: bp.move4?.name ?? '',
})
}
if (map.size === 0) {
return [
{
label: null,
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
},
]
return [{ label: null, pokemon: [createEmptySlot(1)] }]
}
const variants: Variant[] = []
// null (default) first
if (map.has(null)) {
variants.push({ label: null, pokemon: map.get(null)! })
map.delete(null)
}
// Then alphabetical
const remaining = [...map.entries()].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? ''))
for (const [label, pokemon] of remaining) {
variants.push({ label, pokemon })
@@ -65,9 +134,19 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
const [activeTab, setActiveTab] = useState(0)
const [newVariantName, setNewVariantName] = useState('')
const [showAddVariant, setShowAddVariant] = useState(false)
const [expandedSlots, setExpandedSlots] = useState<Set<string>>(new Set())
const activeVariant = variants[activeTab] ?? variants[0]
const toggleExpanded = (key: string) => {
setExpandedSlots((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
const updateVariant = (tabIndex: number, updater: (v: Variant) => Variant) => {
setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v)))
}
@@ -75,15 +154,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
const addSlot = () => {
updateVariant(activeTab, (v) => ({
...v,
pokemon: [
...v.pokemon,
{
pokemonId: null,
pokemonName: '',
level: '',
order: v.pokemon.length + 1,
},
],
pokemon: [...v.pokemon, createEmptySlot(v.pokemon.length + 1)],
}))
}
@@ -96,10 +167,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
}))
}
const updateSlot = (index: number, field: string, value: number | string | null) => {
const updateSlot = (index: number, updates: Partial<PokemonSlot>) => {
updateVariant(activeTab, (v) => ({
...v,
pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, ...updates } : item)),
}))
}
@@ -107,13 +178,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
const name = newVariantName.trim()
if (!name) return
if (variants.some((v) => v.label === name)) return
setVariants((prev) => [
...prev,
{
label: name,
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
},
])
setVariants((prev) => [...prev, { label: name, pokemon: [createEmptySlot(1)] }])
setActiveTab(variants.length)
setNewVariantName('')
setShowAddVariant(false)
@@ -141,6 +206,13 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
level: Number(p.level),
order: i + 1,
conditionLabel,
abilityId: p.abilityId,
heldItem: p.heldItem || null,
nature: p.nature || null,
move1Id: p.move1Id,
move2Id: p.move2Id,
move3Id: p.move3Id,
move4Id: p.move4Id,
})
}
}
@@ -150,7 +222,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-border-default">
<h2 className="text-lg font-semibold">{boss.name}'s Team</h2>
</div>
@@ -209,11 +281,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
className="px-2 py-1 text-sm border rounded bg-surface-2 border-border-default w-40"
autoFocus
/>
<button
type="button"
onClick={addVariant}
className="px-2 py-1 text-sm text-text-link"
>
<button type="button" onClick={addVariant} className="px-2 py-1 text-sm text-text-link">
Add
</button>
<button
@@ -228,38 +296,149 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
</div>
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-3">
{activeVariant?.pokemon.map((slot, index) => (
<div key={`${activeTab}-${index}`} className="flex items-end gap-2">
<div className="flex-1">
<PokemonSelector
label={`Pokemon ${index + 1}`}
selectedId={slot.pokemonId}
initialName={slot.pokemonName}
onChange={(id) => updateSlot(index, 'pokemonId', id)}
/>
</div>
<div className="w-20">
<label className="block text-sm font-medium mb-1">Level</label>
<input
type="number"
min={1}
max={100}
value={slot.level}
onChange={(e) => updateSlot(index, 'level', e.target.value)}
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/>
</div>
<button
type="button"
onClick={() => removeSlot(index)}
className="px-2 py-2 text-red-500 hover:text-red-700 text-sm"
title="Remove"
<div className="px-6 py-4 space-y-4">
{activeVariant?.pokemon.map((slot, index) => {
const slotKey = `${activeTab}-${index}`
const isExpanded = expandedSlots.has(slotKey)
const hasDetails =
slot.abilityId ||
slot.heldItem ||
slot.nature ||
slot.move1Id ||
slot.move2Id ||
slot.move3Id ||
slot.move4Id
return (
<div
key={slotKey}
className="border border-border-default rounded-lg p-3 bg-surface-0"
>
&#10005;
</button>
</div>
))}
{/* Main row: Pokemon + Level */}
<div className="flex items-end gap-2">
<div className="flex-1">
<PokemonSelector
label={`Pokemon ${index + 1}`}
selectedId={slot.pokemonId}
initialName={slot.pokemonName}
onChange={(id) => updateSlot(index, { pokemonId: id })}
/>
</div>
<div className="w-20">
<label className="block text-sm font-medium mb-1">Level</label>
<input
type="number"
min={1}
max={100}
value={slot.level}
onChange={(e) => updateSlot(index, { level: e.target.value })}
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/>
</div>
<button
type="button"
onClick={() => toggleExpanded(slotKey)}
className={`px-2 py-2 text-sm transition-colors ${
hasDetails ? 'text-accent-500' : 'text-text-tertiary hover:text-text-secondary'
}`}
title={isExpanded ? 'Hide details' : 'Show details'}
>
{isExpanded ? '' : ''}
</button>
<button
type="button"
onClick={() => removeSlot(index)}
className="px-2 py-2 text-red-500 hover:text-red-700 text-sm"
title="Remove"
>
&#10005;
</button>
</div>
{/* Expandable details */}
{isExpanded && (
<div className="mt-3 pt-3 border-t border-border-default space-y-3">
{/* Row 1: Ability, Held Item, Nature */}
<div className="grid grid-cols-3 gap-3">
<AbilitySelector
label="Ability"
selectedId={slot.abilityId}
initialName={slot.abilityName}
onChange={(id, name) =>
updateSlot(index, { abilityId: id, abilityName: name })
}
/>
<div>
<label className="block text-xs font-medium mb-0.5 text-text-secondary">
Held Item
</label>
<input
type="text"
value={slot.heldItem}
onChange={(e) => updateSlot(index, { heldItem: e.target.value })}
placeholder="e.g. Leftovers"
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
/>
</div>
<div>
<label className="block text-xs font-medium mb-0.5 text-text-secondary">
Nature
</label>
<select
value={slot.nature}
onChange={(e) => updateSlot(index, { nature: e.target.value })}
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
>
<option value="">—</option>
{NATURES.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
</div>
{/* Row 2: Moves */}
<div className="grid grid-cols-2 gap-3">
<MoveSelector
label="Move 1"
selectedId={slot.move1Id}
initialName={slot.move1Name}
onChange={(id, name) =>
updateSlot(index, { move1Id: id, move1Name: name })
}
/>
<MoveSelector
label="Move 2"
selectedId={slot.move2Id}
initialName={slot.move2Name}
onChange={(id, name) =>
updateSlot(index, { move2Id: id, move2Name: name })
}
/>
<MoveSelector
label="Move 3"
selectedId={slot.move3Id}
initialName={slot.move3Name}
onChange={(id, name) =>
updateSlot(index, { move3Id: id, move3Name: name })
}
/>
<MoveSelector
label="Move 4"
selectedId={slot.move4Id}
initialName={slot.move4Name}
onChange={(id, name) =>
updateSlot(index, { move4Id: id, move4Name: name })
}
/>
</div>
</div>
)}
</div>
)
})}
{activeVariant && activeVariant.pokemon.length < 6 && (
<button

View File

@@ -0,0 +1,64 @@
import { useState, useRef, useEffect } from 'react'
import { useSearchMoves } from '../../hooks/useMoves'
interface MoveSelectorProps {
label: string
selectedId: number | null
initialName?: string
onChange: (id: number | null, name: string) => void
}
export function MoveSelector({ label, selectedId, initialName, onChange }: MoveSelectorProps) {
const [search, setSearch] = useState(initialName ?? '')
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const { data } = useSearchMoves(search)
const moves = data?.items ?? []
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
return (
<div ref={ref} className="relative">
<label className="block text-xs font-medium mb-0.5 text-text-secondary">{label}</label>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value)
setOpen(true)
if (!e.target.value) onChange(null, '')
}}
onFocus={() => search && setOpen(true)}
placeholder="Search move..."
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
/>
{open && moves.length > 0 && (
<ul className="absolute z-20 mt-1 w-full bg-surface-1 border border-border-default rounded shadow-lg max-h-40 overflow-y-auto">
{moves.map((m) => (
<li
key={m.id}
onClick={() => {
onChange(m.id, m.name)
setSearch(m.name)
setOpen(false)
}}
className={`px-2 py-1.5 cursor-pointer hover:bg-surface-2 text-sm ${
m.id === selectedId ? 'bg-accent-900/30' : ''
}`}
>
{m.name}
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -1,4 +1,5 @@
export { CustomRulesDisplay } from './CustomRulesDisplay'
export { ProtectedRoute } from './ProtectedRoute'
export { EggEncounterModal } from './EggEncounterModal'
export { EncounterMethodBadge } from './EncounterMethodBadge'
export { EncounterModal } from './EncounterModal'

View File

@@ -6,8 +6,8 @@ import type { BossResult, BossBattle } from '../../types/game'
interface JournalEditorProps {
entry?: JournalEntry | null
bossResults?: BossResult[]
bosses?: BossBattle[]
bossResults?: BossResult[] | undefined
bosses?: BossBattle[] | undefined
onSave: (data: { title: string; body: string; bossResultId: number | null }) => void
onDelete?: () => void
onCancel: () => void
@@ -67,7 +67,10 @@ export function JournalEditor({
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="journal-title" className="block text-sm font-medium text-text-secondary mb-1">
<label
htmlFor="journal-title"
className="block text-sm font-medium text-text-secondary mb-1"
>
Title
</label>
<input
@@ -82,7 +85,10 @@ export function JournalEditor({
</div>
<div>
<label htmlFor="journal-boss" className="block text-sm font-medium text-text-secondary mb-1">
<label
htmlFor="journal-boss"
className="block text-sm font-medium text-text-secondary mb-1"
>
Linked Boss Battle (optional)
</label>
<select

View File

@@ -5,8 +5,8 @@ import type { BossResult, BossBattle } from '../../types/game'
interface JournalEntryViewProps {
entry: JournalEntry
bossResult?: BossResult | null
boss?: BossBattle | null
bossResult?: BossResult | null | undefined
boss?: BossBattle | null | undefined
onEdit?: () => void
onBack?: () => void
}
@@ -38,7 +38,12 @@ export function JournalEntryView({
className="text-text-secondary hover:text-text-primary transition-colors flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Back to Journal
</button>

View File

@@ -19,7 +19,10 @@ function formatDate(dateString: string): string {
}
function getPreviewSnippet(body: string, maxLength = 120): string {
const stripped = body.replace(/[#*_`~[\]]/g, '').replace(/\n+/g, ' ').trim()
const stripped = body
.replace(/[#*_`~[\]]/g, '')
.replace(/\n+/g, ' ')
.trim()
if (stripped.length <= maxLength) return stripped
return stripped.slice(0, maxLength).trim() + '...'
}

View File

@@ -6,8 +6,8 @@ import type { BossResult, BossBattle } from '../../types/game'
interface JournalSectionProps {
runId: number
bossResults?: BossResult[]
bosses?: BossBattle[]
bossResults?: BossResult[] | undefined
bosses?: BossBattle[] | undefined
}
type Mode = 'list' | 'new'

View File

@@ -0,0 +1,93 @@
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
import type { User, Session, AuthError } from '@supabase/supabase-js'
import { supabase } from '../lib/supabase'
interface AuthState {
user: User | null
session: Session | null
loading: boolean
}
interface AuthContextValue extends AuthState {
signInWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
signUpWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
signInWithGoogle: () => Promise<{ error: AuthError | null }>
signInWithDiscord: () => Promise<{ error: AuthError | null }>
signOut: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>({
user: null,
session: null,
loading: true,
})
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setState({ user: session?.user ?? null, session, loading: false })
})
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setState({ user: session?.user ?? null, session, loading: false })
})
return () => subscription.unsubscribe()
}, [])
const signInWithEmail = useCallback(async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password })
return { error }
}, [])
const signUpWithEmail = useCallback(async (email: string, password: string) => {
const { error } = await supabase.auth.signUp({ email, password })
return { error }
}, [])
const signInWithGoogle = useCallback(async () => {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: `${window.location.origin}/auth/callback` },
})
return { error }
}, [])
const signInWithDiscord = useCallback(async () => {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'discord',
options: { redirectTo: `${window.location.origin}/auth/callback` },
})
return { error }
}, [])
const signOut = useCallback(async () => {
await supabase.auth.signOut()
}, [])
const value = useMemo(
() => ({
...state,
signInWithEmail,
signUpWithEmail,
signInWithGoogle,
signInWithDiscord,
signOut,
}),
[state, signInWithEmail, signUpWithEmail, signInWithGoogle, signInWithDiscord, signOut]
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

View File

@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query'
import { searchMoves, searchAbilities } from '../api/moves'
export function useSearchMoves(search: string, limit = 20) {
return useQuery({
queryKey: ['moves', 'search', search, limit],
queryFn: () => searchMoves(search, limit),
enabled: search.length > 0,
staleTime: 60_000,
})
}
export function useSearchAbilities(search: string, limit = 20) {
return useQuery({
queryKey: ['abilities', 'search', search, limit],
queryFn: () => searchAbilities(search, limit),
enabled: search.length > 0,
staleTime: 60_000,
})
}

View File

@@ -0,0 +1,14 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env['VITE_SUPABASE_URL'] ?? ''
const supabaseAnonKey = import.meta.env['VITE_SUPABASE_ANON_KEY'] ?? ''
function createSupabaseClient(): SupabaseClient {
if (!supabaseUrl || !supabaseAnonKey) {
// Return a stub client for tests/dev without Supabase configured
return createClient('http://localhost:54321', 'stub-key')
}
return createClient(supabaseUrl, supabaseAnonKey)
}
export const supabase = createSupabaseClient()

View File

@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'sonner'
import { AuthProvider } from './contexts/AuthContext'
import './index.css'
import App from './App.tsx'
@@ -19,8 +20,10 @@ createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster position="bottom-right" richColors />
<AuthProvider>
<App />
<Toaster position="bottom-right" richColors />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>

View File

@@ -0,0 +1,24 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { supabase } from '../lib/supabase'
export function AuthCallback() {
const navigate = useNavigate()
useEffect(() => {
supabase.auth.onAuthStateChange((event) => {
if (event === 'SIGNED_IN') {
navigate('/', { replace: true })
}
})
}, [navigate])
return (
<div className="min-h-[80vh] flex items-center justify-center">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500 mx-auto" />
<p className="text-text-secondary">Completing sign in...</p>
</div>
</div>
)
}

View File

@@ -1,7 +1,13 @@
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 {
CustomRulesDisplay,
GenlockeGraveyard,
GenlockeLineage,
StatCard,
RuleBadges,
} from '../components'
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
import { useMemo, useState } from 'react'

View File

@@ -2,11 +2,7 @@ import { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useRun } from '../hooks/useRuns'
import { useBossResults, useGameBosses } from '../hooks/useBosses'
import {
useJournalEntry,
useUpdateJournalEntry,
useDeleteJournalEntry,
} from '../hooks/useJournal'
import { useJournalEntry, useUpdateJournalEntry, useDeleteJournalEntry } from '../hooks/useJournal'
import { JournalEntryView } from '../components/journal/JournalEntryView'
import { JournalEditor } from '../components/journal/JournalEditor'

View File

@@ -0,0 +1,154 @@
import { useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
export function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const { signInWithEmail, signInWithGoogle, signInWithDiscord } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? '/'
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
const { error } = await signInWithEmail(email, password)
setLoading(false)
if (error) {
setError(error.message)
} else {
navigate(from, { replace: true })
}
}
async function handleGoogleLogin() {
setError(null)
const { error } = await signInWithGoogle()
if (error) setError(error.message)
}
async function handleDiscordLogin() {
setError(null)
const { error } = await signInWithDiscord()
if (error) setError(error.message)
}
return (
<div className="min-h-[80vh] flex items-center justify-center px-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<h1 className="text-2xl font-bold">Welcome back</h1>
<p className="text-text-secondary mt-1">Sign in to your account</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-text-secondary mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-text-secondary mb-1"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border-default" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-surface-0 text-text-tertiary">Or continue with</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={handleGoogleLogin}
className="flex items-center justify-center gap-2 py-2 px-4 bg-surface-2 hover:bg-surface-3 border border-border-default rounded-lg transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Google
</button>
<button
type="button"
onClick={handleDiscordLogin}
className="flex items-center justify-center gap-2 py-2 px-4 bg-surface-2 hover:bg-surface-3 border border-border-default rounded-lg transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Discord
</button>
</div>
<p className="text-center text-sm text-text-secondary">
Don&apos;t have an account?{' '}
<Link to="/signup" className="text-accent-400 hover:text-accent-300">
Sign up
</Link>
</p>
</div>
</div>
)
}

View File

@@ -115,8 +115,8 @@ export function NewGenlocke() {
// In preset modes, filter out regions already used.
const availableRegions =
preset === 'custom'
? regions ?? []
: regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? []
? (regions ?? [])
: (regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? [])
const usedRegionNames = new Set(legs.map((l) => l.region))

View File

@@ -3,7 +3,7 @@ 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 } from '../types'
import type { Game, NuzlockeRules, RunVisibility } from '../types'
import { DEFAULT_RULES } from '../types'
import { RULE_DEFINITIONS } from '../types/rules'
@@ -21,6 +21,7 @@ export function NewRun() {
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(() => {
@@ -46,7 +47,7 @@ export function NewRun() {
const handleCreate = () => {
if (!selectedGame) return
createRun.mutate(
{ gameId: selectedGame.id, name: runName, rules, namingScheme },
{ gameId: selectedGame.id, name: runName, rules, namingScheme, visibility },
{ onSuccess: (data) => navigate(`/runs/${data.id}`) }
)
}
@@ -195,6 +196,29 @@ export function NewRun() {
</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">
@@ -223,6 +247,10 @@ export function NewRun() {
: '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>

View File

@@ -1,10 +1,18 @@
import { useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns'
import { useGameRoutes } from '../hooks/useGames'
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
import { CustomRulesDisplay, StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
import type { RunStatus, EncounterDetail } from '../types'
import {
CustomRulesDisplay,
StatCard,
PokemonCard,
RuleBadges,
StatusChangeModal,
EndRunModal,
} from '../components'
import type { RunStatus, EncounterDetail, RunVisibility } from '../types'
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
@@ -49,6 +57,7 @@ export function RunDashboard() {
const runIdNum = Number(runId)
const { data: run, isLoading, error } = useRun(runIdNum)
const { data: routes } = useGameRoutes(run?.gameId ?? null)
const { user } = useAuth()
const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum)
const updateRun = useUpdateRun(runIdNum)
@@ -57,6 +66,9 @@ export function RunDashboard() {
const [showEndRun, setShowEndRun] = useState(false)
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
const isOwner = user && run?.owner?.id === user.id
const canEdit = isOwner || !run?.owner
const encounters = run?.encounters ?? []
const alive = useMemo(
() =>
@@ -190,11 +202,31 @@ export function RunDashboard() {
<CustomRulesDisplay customRules={run.rules?.customRules ?? ''} />
</div>
{/* Visibility */}
{canEdit && (
<div className="mb-6">
<h2 className="text-sm font-medium text-text-tertiary mb-2">Visibility</h2>
<select
value={run.visibility}
onChange={(e) => updateRun.mutate({ visibility: e.target.value as RunVisibility })}
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
>
<option value="public">Public</option>
<option value="private">Private</option>
</select>
<p className="mt-1 text-xs text-text-tertiary">
{run.visibility === 'private'
? 'Only you can see this run'
: 'Anyone can view this run'}
</p>
</div>
)}
{/* Naming Scheme */}
{namingCategories && namingCategories.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-text-tertiary mb-2">Naming Scheme</h2>
{isActive ? (
{isActive && canEdit ? (
<select
value={run.namingScheme ?? ''}
onChange={(e) => updateRun.mutate({ namingScheme: e.target.value || null })}
@@ -246,7 +278,7 @@ export function RunDashboard() {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
onClick={isActive && canEdit ? () => setSelectedEncounter(enc) : undefined}
/>
))}
</div>
@@ -263,7 +295,7 @@ export function RunDashboard() {
key={enc.id}
encounter={enc}
showFaintLevel
onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
onClick={isActive && canEdit ? () => setSelectedEncounter(enc) : undefined}
/>
))}
</div>
@@ -272,7 +304,7 @@ export function RunDashboard() {
{/* Quick Actions */}
<div className="mt-8 flex gap-3">
{isActive && (
{isActive && canEdit && (
<>
<Link
to={`/runs/${runId}/encounters`}

View File

@@ -246,19 +246,33 @@ function BossTeamPreview({
<div className="flex gap-2 flex-wrap">
{[...displayed]
.sort((a, b) => a.order - b.order)
.map((bp) => (
<div key={bp.id} className="flex items-center gap-1">
{bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
) : (
<div className="w-20 h-20 bg-surface-3 rounded-full" />
)}
<div className="flex flex-col items-start gap-0.5">
<span className="text-xs text-text-tertiary">Lvl {bp.level}</span>
<ConditionBadge condition={bp.conditionLabel} size="xs" />
.map((bp) => {
const moves = [bp.move1, bp.move2, bp.move3, bp.move4].filter(Boolean)
return (
<div key={bp.id} className="flex items-center gap-1">
{bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
) : (
<div className="w-20 h-20 bg-surface-3 rounded-full" />
)}
<div className="flex flex-col items-start gap-0.5">
<span className="text-xs text-text-tertiary">Lvl {bp.level}</span>
<ConditionBadge condition={bp.conditionLabel} size="xs" />
{bp.ability && (
<span className="text-[10px] text-text-muted">{bp.ability.name}</span>
)}
{bp.heldItem && (
<span className="text-[10px] text-yellow-500/80">{bp.heldItem}</span>
)}
{moves.length > 0 && (
<div className="text-[9px] text-text-muted leading-tight">
{moves.map((m) => m!.name).join(', ')}
</div>
)}
</div>
</div>
</div>
))}
)
})}
</div>
</div>
)
@@ -663,6 +677,28 @@ export function RunEncounters() {
return set
}, [bossResults])
// Map encounter ID to encounter detail for team display
const encounterById = useMemo(() => {
const map = new Map<number, EncounterDetail>()
if (run) {
for (const enc of run.encounters) {
map.set(enc.id, enc)
}
}
return map
}, [run])
// Map boss battle ID to result for team snapshot
const bossResultByBattleId = useMemo(() => {
const map = new Map<number, (typeof bossResults)[number]>()
if (bossResults) {
for (const r of bossResults) {
map.set(r.bossBattleId, r)
}
}
return map
}, [bossResults])
const sortedBosses = useMemo(() => {
if (!bosses) return []
return [...bosses].sort((a, b) => a.order - b.order)
@@ -1174,238 +1210,258 @@ export function RunEncounters() {
{activeTab === 'encounters' && (
<>
{/* Team Section */}
{(alive.length > 0 || dead.length > 0) && (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={() => setShowTeam(!showTeam)}
className="flex items-center gap-2 group"
>
<h2 className="text-lg font-semibold text-text-primary">
{isActive ? 'Team' : 'Final Team'}
</h2>
<span className="text-xs text-text-muted">
{alive.length} alive
{dead.length > 0 ? `, ${dead.length} dead` : ''}
</span>
<svg
className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{showTeam && alive.length > 1 && (
<select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
>
<option value="route">Route Order</option>
<option value="level">Catch Level</option>
<option value="species">Species Name</option>
<option value="dex">National Dex</option>
</select>
)}
</div>
{showTeam && (
<>
{alive.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
{alive.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
{(alive.length > 0 || dead.length > 0) && (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={() => setShowTeam(!showTeam)}
className="flex items-center gap-2 group"
>
<h2 className="text-lg font-semibold text-text-primary">
{isActive ? 'Team' : 'Final Team'}
</h2>
<span className="text-xs text-text-muted">
{alive.length} alive
{dead.length > 0 ? `, ${dead.length} dead` : ''}
</span>
<svg
className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
))}
</div>
)}
{dead.length > 0 && (
</svg>
</button>
{showTeam && alive.length > 1 && (
<select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
>
<option value="route">Route Order</option>
<option value="level">Catch Level</option>
<option value="species">Species Name</option>
<option value="dex">National Dex</option>
</select>
)}
</div>
{showTeam && (
<>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{dead.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
showFaintLevel
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
{alive.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
{alive.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
)}
{dead.length > 0 && (
<>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{dead.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
showFaintLevel
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
</>
)}
</>
)}
</>
</div>
)}
</div>
)}
{/* Shiny Box */}
{run.rules?.shinyClause && shinyEncounters.length > 0 && (
<div className="mb-6">
<ShinyBox
encounters={shinyEncounters}
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
/>
</div>
)}
{/* Transfer Encounters */}
{transferEncounters.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-indigo-400 mb-2">Transferred Pokemon</h2>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{transferEncounters.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
{/* Shiny Box */}
{run.rules?.shinyClause && shinyEncounters.length > 0 && (
<div className="mb-6">
<ShinyBox
encounters={shinyEncounters}
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
/>
</div>
)}
{/* Transfer Encounters */}
{transferEncounters.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-indigo-400 mb-2">Transferred Pokemon</h2>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{transferEncounters.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
</div>
)}
{/* Progress bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2>
{isActive && completedCount < totalLocations && (
<button
type="button"
disabled={bulkRandomize.isPending}
onClick={() => {
const remaining = totalLocations - completedCount
if (
window.confirm(
`Randomize encounters for all ${remaining} remaining locations?`
)
) {
bulkRandomize.mutate()
}
}}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
</button>
)}
</div>
<span className="text-sm text-text-tertiary">
{completedCount} / {totalLocations} locations
</span>
</div>
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
}}
/>
</div>
</div>
{/* Filter tabs */}
<div className="flex gap-2 mb-4 flex-wrap">
{(
[
{ key: 'all', label: 'All' },
{ key: 'none', label: 'Unvisited' },
{ key: 'caught', label: 'Caught' },
{ key: 'fainted', label: 'Fainted' },
{ key: 'missed', label: 'Missed' },
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filter === key
? 'bg-blue-600 text-white'
: 'bg-surface-2 text-text-secondary hover:bg-surface-3'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
{/* Progress bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2>
{isActive && completedCount < totalLocations && (
<button
type="button"
disabled={bulkRandomize.isPending}
onClick={() => {
const remaining = totalLocations - completedCount
if (
window.confirm(`Randomize encounters for all ${remaining} remaining locations?`)
) {
bulkRandomize.mutate()
}
}}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
</button>
{/* Route list */}
<div className="space-y-1">
{filteredRoutes.length === 0 && (
<p className="text-text-tertiary text-sm py-4 text-center">
{filter === 'all'
? 'Click a route to log your first encounter'
: 'No routes match this filter — try a different one'}
</p>
)}
</div>
<span className="text-sm text-text-tertiary">
{completedCount} / {totalLocations} locations
</span>
</div>
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
}}
/>
</div>
</div>
{filteredRoutes.map((route) => {
// Collect all route IDs to check for boss cards after
const routeIds: number[] =
route.children.length > 0
? [route.id, ...route.children.map((c) => c.id)]
: [route.id]
{/* Filter tabs */}
<div className="flex gap-2 mb-4 flex-wrap">
{(
[
{ key: 'all', label: 'All' },
{ key: 'none', label: 'Unvisited' },
{ key: 'caught', label: 'Caught' },
{ key: 'fainted', label: 'Fainted' },
{ key: 'missed', label: 'Missed' },
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filter === key
? 'bg-blue-600 text-white'
: 'bg-surface-2 text-text-secondary hover:bg-surface-3'
}`}
>
{label}
</button>
))}
</div>
// Find boss battles positioned after this route (or any of its children)
const bossesHere: BossBattle[] = []
for (const rid of routeIds) {
const b = bossesAfterRoute.get(rid)
if (b) bossesHere.push(...b)
}
{/* Route list */}
<div className="space-y-1">
{filteredRoutes.length === 0 && (
<p className="text-text-tertiary text-sm py-4 text-center">
{filter === 'all'
? 'Click a route to log your first encounter'
: 'No routes match this filter — try a different one'}
</p>
)}
{filteredRoutes.map((route) => {
// Collect all route IDs to check for boss cards after
const routeIds: number[] =
route.children.length > 0 ? [route.id, ...route.children.map((c) => c.id)] : [route.id]
// Find boss battles positioned after this route (or any of its children)
const bossesHere: BossBattle[] = []
for (const rid of routeIds) {
const b = bossesAfterRoute.get(rid)
if (b) bossesHere.push(...b)
}
const routeElement =
route.children.length > 0 ? (
<RouteGroup
key={route.id}
group={route}
encounterByRoute={encounterByRoute}
giftEncounterByRoute={giftEncounterByRoute}
isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={handleRouteClick}
filter={filter}
pinwheelClause={pinwheelClause}
/>
) : (
(() => {
const encounter = encounterByRoute.get(route.id)
const giftEncounter = giftEncounterByRoute.get(route.id)
const displayEncounter = encounter ?? giftEncounter
const rs = getRouteStatus(displayEncounter)
const si = statusIndicator[rs]
return (
<button
const routeElement =
route.children.length > 0 ? (
<RouteGroup
key={route.id}
type="button"
onClick={() => handleRouteClick(route)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-surface-2/50 ${si.bg}`}
>
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary">{route.name}</div>
{encounter ? (
<div className="flex items-center gap-2 mt-0.5">
{encounter.pokemon.spriteUrl && (
<img
src={encounter.pokemon.spriteUrl}
alt={encounter.pokemon.name}
className="w-10 h-10"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{encounter.nickname ?? encounter.pokemon.name}
{encounter.status === 'caught' &&
encounter.faintLevel !== null &&
(encounter.deathCause ? `${encounter.deathCause}` : ' (dead)')}
</span>
{giftEncounter && (
<>
group={route}
encounterByRoute={encounterByRoute}
giftEncounterByRoute={giftEncounterByRoute}
isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={handleRouteClick}
filter={filter}
pinwheelClause={pinwheelClause}
/>
) : (
(() => {
const encounter = encounterByRoute.get(route.id)
const giftEncounter = giftEncounterByRoute.get(route.id)
const displayEncounter = encounter ?? giftEncounter
const rs = getRouteStatus(displayEncounter)
const si = statusIndicator[rs]
return (
<button
key={route.id}
type="button"
onClick={() => handleRouteClick(route)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-surface-2/50 ${si.bg}`}
>
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary">{route.name}</div>
{encounter ? (
<div className="flex items-center gap-2 mt-0.5">
{encounter.pokemon.spriteUrl && (
<img
src={encounter.pokemon.spriteUrl}
alt={encounter.pokemon.name}
className="w-10 h-10"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{encounter.nickname ?? encounter.pokemon.name}
{encounter.status === 'caught' &&
encounter.faintLevel !== null &&
(encounter.deathCause ? `${encounter.deathCause}` : ' (dead)')}
</span>
{giftEncounter && (
<>
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</>
)}
</div>
) : giftEncounter ? (
<div className="flex items-center gap-2 mt-0.5">
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
@@ -1417,176 +1473,194 @@ export function RunEncounters() {
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</>
)}
</div>
) : giftEncounter ? (
<div className="flex items-center gap-2 mt-0.5">
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</div>
) : (
route.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{route.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" />
))}
</div>
)
)}
</div>
<span className="text-xs text-text-muted shrink-0">{si.label}</span>
</button>
)
})()
)
return (
<div key={route.id}>
{routeElement}
{/* Boss battle cards after this route */}
{bossesHere.map((boss) => {
const isDefeated = defeatedBossIds.has(boss.id)
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
const bossTypeLabel: Record<string, string> = {
gym_leader: 'Gym Leader',
elite_four: 'Elite Four',
champion: 'Champion',
rival: 'Rival',
evil_team: 'Evil Team',
kahuna: 'Kahuna',
totem: 'Totem',
other: 'Boss',
}
const bossTypeColors: Record<string, string> = {
gym_leader: 'border-yellow-600',
elite_four: 'border-purple-600',
champion: 'border-red-600',
rival: 'border-blue-600',
evil_team: 'border-gray-400',
kahuna: 'border-orange-600',
totem: 'border-teal-600',
other: 'border-gray-500',
}
const isBossExpanded = expandedBosses.has(boss.id)
const toggleBoss = () => {
setExpandedBosses((prev) => {
const next = new Set(prev)
if (next.has(boss.id)) next.delete(boss.id)
else next.add(boss.id)
return next
})
}
return (
<div key={`boss-${boss.id}`}>
<div
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
isDefeated ? 'bg-green-900/10' : 'bg-surface-1'
} px-4 py-3`}
>
<div
className="flex items-start justify-between cursor-pointer select-none"
onClick={toggleBoss}
>
<div className="flex items-center gap-3">
<svg
className={`w-4 h-4 text-text-tertiary transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
{boss.spriteUrl && (
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
)}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary">
{boss.name}
</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
</div>
<p className="text-xs text-text-tertiary">
{boss.location} &middot; Level Cap: {boss.levelCap}
</p>
</div>
) : (
route.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{route.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" />
))}
</div>
)
)}
</div>
<div onClick={(e) => e.stopPropagation()}>
{isDefeated ? (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800">
Defeated &#10003;
</span>
) : isActive ? (
<button
onClick={() => setSelectedBoss(boss)}
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors"
>
Battle
</button>
) : null}
</div>
</div>
{/* Boss pokemon team */}
{isBossExpanded && boss.pokemon.length > 0 && (
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
)}
</div>
{sectionAfter && (
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-surface-3" />
<span className="text-sm font-semibold text-text-tertiary uppercase tracking-wider">
{sectionAfter}
</span>
<div className="flex-1 h-px bg-surface-3" />
</div>
)}
</div>
<span className="text-xs text-text-muted shrink-0">{si.label}</span>
</button>
)
})()
)
})}
</div>
)
})}
</div>
{/* Encounter Modal */}
{selectedRoute && (
<EncounterModal
route={selectedRoute}
gameId={run!.gameId}
runId={runIdNum}
namingScheme={run!.namingScheme}
isGenlocke={!!run!.genlocke}
existing={editingEncounter ?? undefined}
dupedPokemonIds={dupedPokemonIds}
retiredPokemonIds={retiredPokemonIds}
onSubmit={handleCreate}
onUpdate={handleUpdate}
onClose={() => {
setSelectedRoute(null)
setEditingEncounter(null)
}}
isPending={createEncounter.isPending || updateEncounter.isPending}
useAllPokemon={useAllPokemon}
staticClause={run?.rules?.staticClause ?? true}
allowedTypes={rulesAllowedTypes.length ? rulesAllowedTypes : undefined}
/>
)}
return (
<div key={route.id}>
{routeElement}
{/* Boss battle cards after this route */}
{bossesHere.map((boss) => {
const isDefeated = defeatedBossIds.has(boss.id)
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
const bossTypeLabel: Record<string, string> = {
gym_leader: 'Gym Leader',
elite_four: 'Elite Four',
champion: 'Champion',
rival: 'Rival',
evil_team: 'Evil Team',
kahuna: 'Kahuna',
totem: 'Totem',
other: 'Boss',
}
const bossTypeColors: Record<string, string> = {
gym_leader: 'border-yellow-600',
elite_four: 'border-purple-600',
champion: 'border-red-600',
rival: 'border-blue-600',
evil_team: 'border-gray-400',
kahuna: 'border-orange-600',
totem: 'border-teal-600',
other: 'border-gray-500',
}
const isBossExpanded = expandedBosses.has(boss.id)
const toggleBoss = () => {
setExpandedBosses((prev) => {
const next = new Set(prev)
if (next.has(boss.id)) next.delete(boss.id)
else next.add(boss.id)
return next
})
}
return (
<div key={`boss-${boss.id}`}>
<div
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
isDefeated ? 'bg-green-900/10' : 'bg-surface-1'
} px-4 py-3`}
>
<div
className="flex items-start justify-between cursor-pointer select-none"
onClick={toggleBoss}
>
<div className="flex items-center gap-3">
<svg
className={`w-4 h-4 text-text-tertiary transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
{boss.spriteUrl && (
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
)}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary">
{boss.name}
</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
</div>
<p className="text-xs text-text-tertiary">
{boss.location} &middot; Level Cap: {boss.levelCap}
</p>
</div>
</div>
<div onClick={(e) => e.stopPropagation()}>
{isDefeated ? (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800">
Defeated &#10003;
</span>
) : isActive ? (
<button
onClick={() => setSelectedBoss(boss)}
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors"
>
Battle
</button>
) : null}
</div>
</div>
{/* Boss pokemon team */}
{isBossExpanded && boss.pokemon.length > 0 && (
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
)}
{/* Player team snapshot */}
{isDefeated && (() => {
const result = bossResultByBattleId.get(boss.id)
if (!result || result.team.length === 0) return null
return (
<div className="mt-3 pt-3 border-t border-border-default">
<p className="text-xs font-medium text-text-secondary mb-2">Your Team</p>
<div className="flex gap-2 flex-wrap">
{result.team.map((tm) => {
const enc = encounterById.get(tm.encounterId)
if (!enc) return null
const dp = enc.currentPokemon ?? enc.pokemon
return (
<div key={tm.id} className="flex flex-col items-center">
{dp.spriteUrl ? (
<img src={dp.spriteUrl} alt={dp.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 bg-surface-3 rounded-full" />
)}
<span className="text-[10px] text-text-tertiary capitalize">
{enc.nickname ?? dp.name}
</span>
<span className="text-[10px] text-text-muted">Lv.{tm.level}</span>
</div>
)
})}
</div>
</div>
)
})()}
</div>
{sectionAfter && (
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-surface-3" />
<span className="text-sm font-semibold text-text-tertiary uppercase tracking-wider">
{sectionAfter}
</span>
<div className="flex-1 h-px bg-surface-3" />
</div>
)}
</div>
)
})}
</div>
)
})}
</div>
{/* Encounter Modal */}
{selectedRoute && (
<EncounterModal
route={selectedRoute}
gameId={run!.gameId}
runId={runIdNum}
namingScheme={run!.namingScheme}
isGenlocke={!!run!.genlocke}
existing={editingEncounter ?? undefined}
dupedPokemonIds={dupedPokemonIds}
retiredPokemonIds={retiredPokemonIds}
onSubmit={handleCreate}
onUpdate={handleUpdate}
onClose={() => {
setSelectedRoute(null)
setEditingEncounter(null)
}}
isPending={createEncounter.isPending || updateEncounter.isPending}
useAllPokemon={useAllPokemon}
staticClause={run?.rules?.staticClause ?? true}
allowedTypes={rulesAllowedTypes.length ? rulesAllowedTypes : undefined}
/>
)}
</>
)}
@@ -1633,6 +1707,7 @@ export function RunEncounters() {
{selectedBoss && (
<BossDefeatModal
boss={selectedBoss}
aliveEncounters={alive}
onSubmit={(data) => {
createBossResult.mutate(data, {
onSuccess: () => setSelectedBoss(null),

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useRuns } from '../hooks/useRuns'
import type { RunStatus } from '../types'
import type { NuzlockeRun, RunStatus } from '../types'
const statusStyles: Record<RunStatus, string> = {
active: 'bg-status-active-bg text-status-active border border-status-active/20',
@@ -8,22 +10,95 @@ const statusStyles: Record<RunStatus, string> = {
failed: 'bg-status-failed-bg text-status-failed border border-status-failed/20',
}
function VisibilityBadge({ visibility }: { visibility: 'public' | 'private' }) {
if (visibility === 'private') {
return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-surface-3 text-text-tertiary border border-border-default">
Private
</span>
)
}
return null
}
function RunCard({ run, isOwned }: { run: NuzlockeRun; isOwned: boolean }) {
return (
<Link
to={`/runs/${run.id}`}
className="block bg-surface-1 rounded-xl border border-border-default hover:border-border-accent transition-all hover:-translate-y-0.5 p-4"
>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-text-primary truncate">{run.name}</h2>
{isOwned && <VisibilityBadge visibility={run.visibility} />}
</div>
<p className="text-sm text-text-secondary">
Started{' '}
{new Date(run.startedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
{!isOwned && run.owner?.displayName && (
<span className="text-text-tertiary"> &middot; by {run.owner.displayName}</span>
)}
</p>
</div>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize flex-shrink-0 ml-2 ${statusStyles[run.status]}`}
>
{run.status}
</span>
</div>
</Link>
)
}
export function RunList() {
const { data: runs, isLoading, error } = useRuns()
const { user, loading: authLoading } = useAuth()
const { myRuns, publicRuns } = useMemo(() => {
if (!runs) return { myRuns: [], publicRuns: [] }
if (!user) {
return { myRuns: [], publicRuns: runs }
}
const owned: NuzlockeRun[] = []
const others: NuzlockeRun[] = []
for (const run of runs) {
if (run.owner?.id === user.id) {
owned.push(run)
} else {
others.push(run)
}
}
return { myRuns: owned, publicRuns: others }
}, [runs, user])
const showLoading = isLoading || authLoading
return (
<div className="max-w-4xl mx-auto p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-text-primary">Your Runs</h1>
<Link
to="/runs/new"
className="px-4 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 focus:outline-none focus:ring-2 focus:ring-accent-400 focus:ring-offset-2 focus:ring-offset-surface-0 transition-all active:scale-[0.98]"
>
Start New Run
</Link>
<h1 className="text-3xl font-bold text-text-primary">
{user ? 'Nuzlocke Runs' : 'Public Runs'}
</h1>
{user && (
<Link
to="/runs/new"
className="px-4 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 focus:outline-none focus:ring-2 focus:ring-accent-400 focus:ring-offset-2 focus:ring-offset-surface-0 transition-all active:scale-[0.98]"
>
Start New Run
</Link>
)}
</div>
{isLoading && (
{showLoading && (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-4 border-accent-400 border-t-transparent rounded-full animate-spin" />
</div>
@@ -35,49 +110,56 @@ export function RunList() {
</div>
)}
{runs && runs.length === 0 && (
{!showLoading && runs && runs.length === 0 && (
<div className="text-center py-16">
<p className="text-lg text-text-secondary mb-4">
No runs yet. Start your first Nuzlocke!
{user ? 'No runs yet. Start your first Nuzlocke!' : 'No public runs available.'}
</p>
<Link
to="/runs/new"
className="inline-block px-6 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 transition-all active:scale-[0.98]"
>
Start New Run
</Link>
{user && (
<Link
to="/runs/new"
className="inline-block px-6 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 transition-all active:scale-[0.98]"
>
Start New Run
</Link>
)}
{!user && (
<Link
to="/login"
className="inline-block px-6 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 transition-all active:scale-[0.98]"
>
Sign In to Create Runs
</Link>
)}
</div>
)}
{runs && runs.length > 0 && (
<div className="space-y-2">
{runs.map((run) => (
<Link
key={run.id}
to={`/runs/${run.id}`}
className="block bg-surface-1 rounded-xl border border-border-default hover:border-border-accent transition-all hover:-translate-y-0.5 p-4"
>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-text-primary">{run.name}</h2>
<p className="text-sm text-text-secondary">
Started{' '}
{new Date(run.startedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</p>
</div>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${statusStyles[run.status]}`}
>
{run.status}
</span>
{!showLoading && runs && runs.length > 0 && (
<>
{user && myRuns.length > 0 && (
<div className="mb-8">
<h2 className="text-lg font-semibold text-text-primary mb-3">My Runs</h2>
<div className="space-y-2">
{myRuns.map((run) => (
<RunCard key={run.id} run={run} isOwned />
))}
</div>
</Link>
))}
</div>
</div>
)}
{publicRuns.length > 0 && (
<div>
{user && myRuns.length > 0 && (
<h2 className="text-lg font-semibold text-text-primary mb-3">Public Runs</h2>
)}
<div className="space-y-2">
{publicRuns.map((run) => (
<RunCard key={run.id} run={run} isOwned={false} />
))}
</div>
</div>
)}
</>
)}
</div>
)

View File

@@ -0,0 +1,218 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
export function Signup() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const { signUpWithEmail, signInWithGoogle, signInWithDiscord } = useAuth()
const navigate = useNavigate()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
if (password.length < 6) {
setError('Password must be at least 6 characters')
return
}
setLoading(true)
const { error } = await signUpWithEmail(email, password)
setLoading(false)
if (error) {
setError(error.message)
} else {
setSuccess(true)
}
}
async function handleGoogleSignup() {
setError(null)
const { error } = await signInWithGoogle()
if (error) setError(error.message)
}
async function handleDiscordSignup() {
setError(null)
const { error } = await signInWithDiscord()
if (error) setError(error.message)
}
if (success) {
return (
<div className="min-h-[80vh] flex items-center justify-center px-4">
<div className="w-full max-w-sm text-center space-y-4">
<div className="w-16 h-16 mx-auto bg-green-500/10 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h1 className="text-2xl font-bold">Check your email</h1>
<p className="text-text-secondary">
We&apos;ve sent a confirmation link to <strong>{email}</strong>. Click the link to
activate your account.
</p>
<button
type="button"
onClick={() => navigate('/login')}
className="text-accent-400 hover:text-accent-300"
>
Back to login
</button>
</div>
</div>
)
}
return (
<div className="min-h-[80vh] flex items-center justify-center px-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<h1 className="text-2xl font-bold">Create an account</h1>
<p className="text-text-secondary mt-1">Start tracking your Nuzlocke runs</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-text-secondary mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-text-secondary mb-1"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
/>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-text-secondary mb-1"
>
Confirm password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border-default" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-surface-0 text-text-tertiary">Or continue with</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={handleGoogleSignup}
className="flex items-center justify-center gap-2 py-2 px-4 bg-surface-2 hover:bg-surface-3 border border-border-default rounded-lg transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Google
</button>
<button
type="button"
onClick={handleDiscordSignup}
className="flex items-center justify-center gap-2 py-2 px-4 bg-surface-2 hover:bg-surface-3 border border-border-default rounded-lg transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Discord
</button>
</div>
<p className="text-center text-sm text-text-secondary">
Already have an account?{' '}
<Link to="/login" className="text-accent-400 hover:text-accent-300">
Sign in
</Link>
</p>
</div>
</div>
)
}

View File

@@ -1,9 +1,12 @@
export { AuthCallback } from './AuthCallback'
export { GenlockeDetail } from './GenlockeDetail'
export { GenlockeList } from './GenlockeList'
export { Home } from './Home'
export { JournalEntryPage } from './JournalEntryPage'
export { Login } from './Login'
export { NewGenlocke } from './NewGenlocke'
export { NewRun } from './NewRun'
export { RunList } from './RunList'
export { RunEncounters } from './RunEncounters'
export { Signup } from './Signup'
export { Stats } from './Stats'

View File

@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, type RenderOptions } from '@testing-library/react'
import { type ReactElement } from 'react'
import { MemoryRouter } from 'react-router-dom'
import { AuthProvider } from '../contexts/AuthContext'
export function createTestQueryClient(): QueryClient {
return new QueryClient({
@@ -16,7 +17,9 @@ function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = createTestQueryClient()
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter>{children}</MemoryRouter>
<MemoryRouter>
<AuthProvider>{children}</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
)
}

View File

@@ -182,6 +182,14 @@ export interface BossPokemonInput {
level: number
order: number
conditionLabel?: string | null
// Detail fields
abilityId?: number | null
heldItem?: string | null
nature?: string | null
move1Id?: number | null
move2Id?: number | null
move3Id?: number | null
move4Id?: number | null
}
// Genlocke admin

View File

@@ -84,6 +84,12 @@ export interface Encounter {
}
export type RunStatus = 'active' | 'completed' | 'failed'
export type RunVisibility = 'public' | 'private'
export interface RunOwner {
id: string
displayName: string | null
}
export interface NuzlockeRun {
id: number
@@ -93,6 +99,8 @@ export interface NuzlockeRun {
rules: NuzlockeRules
hofEncounterIds: number[] | null
namingScheme: string | null
visibility: RunVisibility
owner: RunOwner | null
startedAt: string
completedAt: string | null
}
@@ -136,6 +144,7 @@ export interface CreateRunInput {
name: string
rules?: NuzlockeRules
namingScheme?: string | null
visibility?: RunVisibility
}
export interface UpdateRunInput {
@@ -144,6 +153,7 @@ export interface UpdateRunInput {
rules?: NuzlockeRules
hofEncounterIds?: number[]
namingScheme?: string | null
visibility?: RunVisibility
}
export interface CreateEncounterInput {
@@ -175,6 +185,16 @@ export type BossType =
| 'totem'
| 'other'
export interface MoveRef {
id: number
name: string
}
export interface AbilityRef {
id: number
name: string
}
export interface BossPokemon {
id: number
pokemonId: number
@@ -182,6 +202,19 @@ export interface BossPokemon {
order: number
conditionLabel: string | null
pokemon: Pokemon
// Detail fields
abilityId: number | null
ability: AbilityRef | null
heldItem: string | null
nature: string | null
move1Id: number | null
move2Id: number | null
move3Id: number | null
move4Id: number | null
move1: MoveRef | null
move2: MoveRef | null
move3: MoveRef | null
move4: MoveRef | null
}
export interface BossBattle {
@@ -202,6 +235,12 @@ export interface BossBattle {
pokemon: BossPokemon[]
}
export interface BossResultTeamMember {
id: number
encounterId: number
level: number
}
export interface BossResult {
id: number
runId: number
@@ -209,12 +248,19 @@ export interface BossResult {
result: 'won' | 'lost'
attempts: number
completedAt: string | null
team: BossResultTeamMember[]
}
export interface BossResultTeamMemberInput {
encounterId: number
level: number
}
export interface CreateBossResultInput {
bossBattleId: number
result: 'won' | 'lost'
attempts?: number
team?: BossResultTeamMemberInput[]
}
// Re-export for convenience