392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect } from "react"
|
||
import { useRouter } from "next/navigation"
|
||
import Navigation from "@/components/Navigation"
|
||
|
||
interface Player {
|
||
id: number
|
||
name: string
|
||
currentElo: number
|
||
}
|
||
|
||
interface TournamentFormData {
|
||
name: string
|
||
description: string
|
||
eventDate: string
|
||
format: string
|
||
maxParticipants: string
|
||
participants: number[] // Array of player IDs
|
||
}
|
||
|
||
export default function NewTournamentPage() {
|
||
const router = useRouter()
|
||
const [step, setStep] = useState(1)
|
||
const [formData, setFormData] = useState<TournamentFormData>({
|
||
name: "",
|
||
description: "",
|
||
eventDate: "",
|
||
format: "round_robin",
|
||
maxParticipants: "",
|
||
participants: [],
|
||
})
|
||
const [error, setError] = useState("")
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
|
||
// Player search state
|
||
const [searchQuery, setSearchQuery] = useState("")
|
||
const [searchResults, setSearchResults] = useState<Player[]>([])
|
||
const [selectedPlayers, setSelectedPlayers] = useState<Player[]>([])
|
||
const [isSearching, setIsSearching] = useState(false)
|
||
|
||
// Search for players as user types
|
||
useEffect(() => {
|
||
const searchPlayers = async () => {
|
||
if (searchQuery.length < 2) {
|
||
setSearchResults([])
|
||
return
|
||
}
|
||
|
||
setIsSearching(true)
|
||
try {
|
||
const response = await fetch(`/api/players/search?q=${encodeURIComponent(searchQuery)}`)
|
||
const data = await response.json()
|
||
if (response.ok) {
|
||
// Filter out already selected players
|
||
const availablePlayers = data.players.filter(
|
||
(p: Player) => !selectedPlayers.find(sp => sp.id === p.id)
|
||
)
|
||
setSearchResults(availablePlayers)
|
||
}
|
||
} catch (err) {
|
||
console.error("Search failed:", err)
|
||
} finally {
|
||
setIsSearching(false)
|
||
}
|
||
}
|
||
|
||
const debounceTimer = setTimeout(searchPlayers, 300)
|
||
return () => clearTimeout(debounceTimer)
|
||
}, [searchQuery, selectedPlayers])
|
||
|
||
const addPlayer = (player: Player) => {
|
||
setSelectedPlayers([...selectedPlayers, player])
|
||
setSearchQuery("")
|
||
setSearchResults([])
|
||
}
|
||
|
||
const removePlayer = (playerId: number) => {
|
||
setSelectedPlayers(selectedPlayers.filter(p => p.id !== playerId))
|
||
}
|
||
|
||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||
setFormData({
|
||
...formData,
|
||
[e.target.name]: e.target.value,
|
||
})
|
||
}
|
||
|
||
const nextStep = () => {
|
||
if (step === 1) {
|
||
if (!formData.name.trim()) {
|
||
setError("Tournament name is required")
|
||
return
|
||
}
|
||
if (selectedPlayers.length < 2) {
|
||
setError("At least 2 participants are required")
|
||
return
|
||
}
|
||
}
|
||
setError("")
|
||
setStep(step + 1)
|
||
}
|
||
|
||
const prevStep = () => {
|
||
setStep(step - 1)
|
||
setError("")
|
||
}
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
setError("")
|
||
setIsLoading(true)
|
||
|
||
try {
|
||
// First create the tournament
|
||
const tournamentResponse = await fetch("/api/tournaments", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
name: formData.name,
|
||
description: formData.description,
|
||
eventDate: formData.eventDate || null,
|
||
format: formData.format,
|
||
maxParticipants: formData.maxParticipants ? parseInt(formData.maxParticipants) : null,
|
||
}),
|
||
})
|
||
|
||
const tournamentData = await tournamentResponse.json()
|
||
|
||
if (!tournamentResponse.ok) {
|
||
throw new Error(tournamentData.error || "Failed to create tournament")
|
||
}
|
||
|
||
const tournamentId = tournamentData.tournament.id
|
||
|
||
// Add participants to the tournament
|
||
if (selectedPlayers.length > 0) {
|
||
await fetch(`/api/tournaments/${tournamentId}/participants`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
playerIds: selectedPlayers.map(p => p.id),
|
||
}),
|
||
})
|
||
}
|
||
|
||
// Redirect to game entry page
|
||
router.push(`/admin/tournaments/${tournamentId}/entry`)
|
||
} catch (err: any) {
|
||
setError(err.message)
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50">
|
||
<Navigation />
|
||
|
||
<main className="max-w-4xl mx-auto py-6 sm:px-6 lg:px-8">
|
||
<div className="px-4 py-6 sm:px-0">
|
||
<h1 className="text-3xl font-bold text-gray-900 mb-6">
|
||
Create New Tournament
|
||
</h1>
|
||
|
||
{/* Step Indicator */}
|
||
<div className="mb-8">
|
||
<div className="flex items-center">
|
||
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${step >= 1 ? 'bg-green-600 text-white' : 'bg-gray-300 text-gray-600'}`}>
|
||
1
|
||
</div>
|
||
<div className={`flex-1 h-1 ${step >= 2 ? 'bg-green-600' : 'bg-gray-300'}`} />
|
||
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${step >= 2 ? 'bg-green-600 text-white' : 'bg-gray-300 text-gray-600'}`}>
|
||
2
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-between mt-2 text-sm text-gray-600">
|
||
<span>Tournament Details</span>
|
||
<span>Participants</span>
|
||
</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-6">
|
||
{error && (
|
||
<div className="rounded-md bg-red-50 p-4">
|
||
<div className="text-sm text-red-700">{error}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 1: Tournament Details */}
|
||
{step === 1 && (
|
||
<>
|
||
<div>
|
||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||
Tournament Name *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="name"
|
||
id="name"
|
||
required
|
||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||
value={formData.name}
|
||
onChange={handleChange}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||
Description
|
||
</label>
|
||
<textarea
|
||
name="description"
|
||
id="description"
|
||
rows={3}
|
||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||
value={formData.description}
|
||
onChange={handleChange}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||
<div>
|
||
<label htmlFor="eventDate" className="block text-sm font-medium text-gray-700">
|
||
Event Date
|
||
</label>
|
||
<input
|
||
type="date"
|
||
name="eventDate"
|
||
id="eventDate"
|
||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||
value={formData.eventDate}
|
||
onChange={handleChange}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="format" className="block text-sm font-medium text-gray-700">
|
||
Format *
|
||
</label>
|
||
<select
|
||
name="format"
|
||
id="format"
|
||
required
|
||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||
value={formData.format}
|
||
onChange={handleChange}
|
||
>
|
||
<option value="round_robin">Round Robin</option>
|
||
<option value="single_elimination">Single Elimination</option>
|
||
<option value="double_elimination">Double Elimination</option>
|
||
<option value="swiss">Swiss</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="maxParticipants" className="block text-sm font-medium text-gray-700">
|
||
Max Participants
|
||
</label>
|
||
<input
|
||
type="number"
|
||
name="maxParticipants"
|
||
id="maxParticipants"
|
||
min="2"
|
||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||
value={formData.maxParticipants}
|
||
onChange={handleChange}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Step 2: Participants */}
|
||
{step === 2 && (
|
||
<>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Add Participants
|
||
</label>
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
placeholder="Search for players..."
|
||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
/>
|
||
{isSearching && (
|
||
<div className="absolute right-3 top-3">
|
||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{searchResults.length > 0 && (
|
||
<div className="mt-2 border border-gray-300 rounded-md shadow-sm max-h-48 overflow-y-auto">
|
||
{searchResults.map(player => (
|
||
<div
|
||
key={player.id}
|
||
className="px-3 py-2 hover:bg-gray-100 cursor-pointer flex justify-between items-center"
|
||
onClick={() => addPlayer(player)}
|
||
>
|
||
<span>{player.name}</span>
|
||
<span className="text-sm text-gray-500">Elo: {player.currentElo}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Selected Participants ({selectedPlayers.length})
|
||
</label>
|
||
{selectedPlayers.length > 0 ? (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||
{selectedPlayers.map(player => (
|
||
<div
|
||
key={player.id}
|
||
className="bg-green-50 border border-green-200 rounded-md px-3 py-2 flex justify-between items-center"
|
||
>
|
||
<span className="text-sm">{player.name}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => removePlayer(player.id)}
|
||
className="text-red-600 hover:text-red-800 ml-2"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-gray-500 text-sm">No participants added yet</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Navigation Buttons */}
|
||
<div className="flex justify-between pt-4">
|
||
<div>
|
||
{step > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={prevStep}
|
||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||
>
|
||
← Back
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="space-x-3">
|
||
{step < 2 ? (
|
||
<button
|
||
type="button"
|
||
onClick={nextStep}
|
||
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||
>
|
||
Next →
|
||
</button>
|
||
) : (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => router.back()}
|
||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={isLoading}
|
||
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
|
||
>
|
||
{isLoading ? "Creating..." : "Create Tournament & Enter Games"}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|