nextjs-rewrite (#5)

Reviewed-on: #5
Co-authored-by: David Gwilliam <dhgwilliam@gmail.com>
Co-committed-by: David Gwilliam <dhgwilliam@gmail.com>
This commit was merged in pull request #5.
This commit is contained in:
2026-03-30 02:30:13 +00:00
committed by david
parent 1a9b3496e1
commit 123df671f5
160 changed files with 19293 additions and 1180 deletions
+221
View File
@@ -0,0 +1,221 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import type { Event } from "@prisma/client"
interface EditTournamentFormProps {
tournament: Event
}
export default function EditTournamentForm({ tournament }: EditTournamentFormProps) {
const router = useRouter()
const [formData, setFormData] = useState({
name: tournament.name,
description: tournament.description || "",
eventDate: tournament.eventDate ? tournament.eventDate.toISOString().split('T')[0] : "",
eventType: tournament.eventType,
format: tournament.format,
status: tournament.status,
maxParticipants: tournament.maxParticipants?.toString() || "",
})
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const [isLoading, setIsLoading] = useState(false)
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: value,
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setSuccess("")
setIsLoading(true)
try {
const response = await fetch(`/api/tournaments/${tournament.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...formData,
maxParticipants: formData.maxParticipants ? parseInt(formData.maxParticipants) : null,
eventDate: formData.eventDate ? new Date(formData.eventDate).toISOString() : null,
}),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || "Failed to update tournament")
setIsLoading(false)
return
}
setSuccess("Tournament updated successfully!")
setIsLoading(false)
// Redirect to tournament detail after 2 seconds
setTimeout(() => {
router.push(`/admin/tournaments/${tournament.id}`)
}, 2000)
} catch (err) {
setError("An error occurred. Please try again.")
setIsLoading(false)
}
}
return (
<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>
)}
{success && (
<div className="rounded-md bg-green-50 p-4">
<div className="text-sm text-green-700">{success}</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Tournament Name
</label>
<input
type="text"
id="name"
name="name"
required
value={formData.name}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="eventDate" className="block text-sm font-medium text-gray-700">
Date
</label>
<input
type="date"
id="eventDate"
name="eventDate"
value={formData.eventDate}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
/>
</div>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<textarea
id="description"
name="description"
rows={3}
value={formData.description}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label htmlFor="eventType" className="block text-sm font-medium text-gray-700">
Event Type
</label>
<select
id="eventType"
name="eventType"
value={formData.eventType}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="tournament">Tournament</option>
<option value="league">League</option>
<option value="casual">Casual Play</option>
</select>
</div>
<div>
<label htmlFor="format" className="block text-sm font-medium text-gray-700">
Format
</label>
<select
id="format"
name="format"
value={formData.format}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<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>
<label htmlFor="status" className="block text-sm font-medium text-gray-700">
Status
</label>
<select
id="status"
name="status"
value={formData.status}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="planned">Planned</option>
<option value="registration">Registration Open</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
<div>
<label htmlFor="maxParticipants" className="block text-sm font-medium text-gray-700">
Max Participants (optional)
</label>
<input
type="number"
id="maxParticipants"
name="maxParticipants"
min="1"
value={formData.maxParticipants}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
/>
</div>
<div className="flex justify-end space-x-4">
<Link
href={`/admin/tournaments/${tournament.id}`}
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"
>
Cancel
</Link>
<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 disabled:opacity-50"
>
{isLoading ? "Saving..." : "Save Changes"}
</button>
</div>
</form>
)
}
+300
View File
@@ -0,0 +1,300 @@
"use client"
import { useState } from "react"
import type { Player, Match } from "@prisma/client"
interface MatchEditorProps {
tournamentId: number
players: Player[]
matches: Match[]
}
interface MatchData {
team1P1Id: number | null
team1P2Id: number | null
team2P1Id: number | null
team2P2Id: number | null
team1Score: number
team2Score: number
round: number
table: string
}
export default function MatchEditor({ tournamentId, players, matches }: MatchEditorProps) {
const [formData, setFormData] = useState<MatchData>({
team1P1Id: null,
team1P2Id: null,
team2P1Id: null,
team2P2Id: null,
team1Score: 0,
team2Score: 0,
round: 1,
table: "Clubs",
})
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const [isLoading, setIsLoading] = useState(false)
const tables = ["Clubs", "Hearts", "Diamonds", "Spades", "Stars"]
const handleChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: name.includes("Id") ? (value ? parseInt(value) : null) :
name === "round" ? parseInt(value) :
name === "team1Score" || name === "team2Score" ? parseInt(value) :
value,
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setSuccess("")
// Validate that all players are selected
if (!formData.team1P1Id || !formData.team1P2Id || !formData.team2P1Id || !formData.team2P2Id) {
setError("Please select all 4 players")
return
}
// Validate that players are unique
const playerIds = [formData.team1P1Id, formData.team1P2Id, formData.team2P1Id, formData.team2P2Id]
if (new Set(playerIds).size !== 4) {
setError("All players must be unique")
return
}
// Validate scores (at least one team must have points)
if (formData.team1Score === 0 && formData.team2Score === 0) {
setError("At least one team must have points")
return
}
setIsLoading(true)
try {
const response = await fetch("/api/matches", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
eventId: tournamentId,
team1P1Id: formData.team1P1Id,
team1P2Id: formData.team1P2Id,
team2P1Id: formData.team2P1Id,
team2P2Id: formData.team2P2Id,
team1Score: formData.team1Score,
team2Score: formData.team2Score,
round: formData.round,
table: formData.table,
}),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || "Failed to record match")
setIsLoading(false)
return
}
setSuccess("Match recorded successfully!")
setIsLoading(false)
// Reset form
setFormData({
team1P1Id: null,
team1P2Id: null,
team2P1Id: null,
team2P2Id: null,
team1Score: 0,
team2Score: 0,
round: formData.round,
table: formData.table,
})
// Reload the page to show updated matches
setTimeout(() => {
window.location.reload()
}, 1000)
} catch (err) {
setError("An error occurred. Please try again.")
setIsLoading(false)
}
}
return (
<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>
)}
{success && (
<div className="rounded-md bg-green-50 p-4">
<div className="text-sm text-green-700">{success}</div>
</div>
)}
{/* Round and Table Selection */}
<div className="grid grid-cols-2 gap-6">
<div>
<label htmlFor="round" className="block text-sm font-medium text-gray-700">
Round
</label>
<input
type="number"
id="round"
name="round"
min="1"
value={formData.round}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="table" className="block text-sm font-medium text-gray-700">
Table
</label>
<select
id="table"
name="table"
value={formData.table}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
{tables.map((table) => (
<option key={table} value={table}>{table}</option>
))}
</select>
</div>
</div>
{/* Team 1 */}
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">Team 1 (Odds)</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="team1P1Id" className="block text-sm font-medium text-gray-700">
Player 1
</label>
<select
id="team1P1Id"
name="team1P1Id"
value={formData.team1P1Id || ""}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="">Select player...</option>
{players.map((player) => (
<option key={player.id} value={player.id}>{player.name}</option>
))}
</select>
</div>
<div>
<label htmlFor="team1P2Id" className="block text-sm font-medium text-gray-700">
Player 2
</label>
<select
id="team1P2Id"
name="team1P2Id"
value={formData.team1P2Id || ""}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="">Select player...</option>
{players.map((player) => (
<option key={player.id} value={player.id}>{player.name}</option>
))}
</select>
</div>
</div>
<div className="mt-4">
<label htmlFor="team1Score" className="block text-sm font-medium text-gray-700">
Score
</label>
<input
type="number"
id="team1Score"
name="team1Score"
min="0"
max="10"
value={formData.team1Score}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
/>
</div>
</div>
{/* Team 2 */}
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">Team 2 (Evens)</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="team2P1Id" className="block text-sm font-medium text-gray-700">
Player 1
</label>
<select
id="team2P1Id"
name="team2P1Id"
value={formData.team2P1Id || ""}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="">Select player...</option>
{players.map((player) => (
<option key={player.id} value={player.id}>{player.name}</option>
))}
</select>
</div>
<div>
<label htmlFor="team2P2Id" className="block text-sm font-medium text-gray-700">
Player 2
</label>
<select
id="team2P2Id"
name="team2P2Id"
value={formData.team2P2Id || ""}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="">Select player...</option>
{players.map((player) => (
<option key={player.id} value={player.id}>{player.name}</option>
))}
</select>
</div>
</div>
<div className="mt-4">
<label htmlFor="team2Score" className="block text-sm font-medium text-gray-700">
Score
</label>
<input
type="number"
id="team2Score"
name="team2Score"
min="0"
max="10"
value={formData.team2Score}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
/>
</div>
</div>
<div className="flex justify-end">
<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 disabled:opacity-50"
>
{isLoading ? "Recording..." : "Record Match"}
</button>
</div>
</form>
)
}
+98
View File
@@ -0,0 +1,98 @@
"use client"
import Link from "next/link"
import { useSession } from "./SessionProvider"
import { authClient } from "@/lib/auth-client"
import { useEffect, useState } from "react"
export default function Navigation() {
const { session, loading } = useSession()
const [userRole, setUserRole] = useState<string | null>(null)
useEffect(() => {
// Fetch user role from database if session exists
if (session?.user?.id) {
fetch(`/api/users/${session.user.id}/role`)
.then(res => res.json())
.then(data => setUserRole(data.role))
.catch(() => setUserRole(null));
}
}, [session]);
const handleLogout = async () => {
await authClient.signOut()
window.location.href = '/auth/login'
}
return (
<nav className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<Link href="/" className="text-xl font-bold text-gray-900">
EuchreCamp
</Link>
<div className="hidden md:ml-6 md:flex md:space-x-8">
<Link
href="/rankings"
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Rankings
</Link>
{session && (
<>
<Link
href="/admin/tournaments"
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Tournaments
</Link>
{userRole === "club_admin" && (
<Link
href="/admin"
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Admin
</Link>
)}
</>
)}
</div>
</div>
<div className="flex items-center">
{loading ? (
<div className="text-gray-500">Loading...</div>
) : session ? (
<div className="flex items-center space-x-4">
<span className="text-gray-700 text-sm font-medium">
{session.user?.name || session.user?.email}
</span>
<button
onClick={handleLogout}
className="text-gray-500 hover:text-gray-700 text-sm font-medium"
>
Sign out
</button>
</div>
) : (
<div className="flex items-center space-x-4">
<Link
href="/auth/login"
className="text-gray-500 hover:text-gray-700 text-sm font-medium"
>
Sign in
</Link>
<Link
href="/auth/register"
className="bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium hover:bg-green-700"
>
Sign up
</Link>
</div>
)}
</div>
</div>
</div>
</nav>
)
}
+87
View File
@@ -0,0 +1,87 @@
"use client"
import { createContext, useContext, useEffect, useState } from "react"
import { authClient } from "@/lib/auth-client"
interface SessionContextType {
session: { user: any; session: any } | null
loading: boolean
refreshSession: () => Promise<void>
}
const SessionContext = createContext<SessionContextType | undefined>(undefined)
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<{ user: any; session: any } | null>(null)
const [loading, setLoading] = useState(true)
const refreshSession = async () => {
setLoading(true)
try {
const result = await authClient.getSession()
if (result.data) {
setSession(result.data)
} else {
setSession(null)
}
} catch {
setSession(null)
} finally {
setLoading(false)
}
}
// Initial session fetch - only run once on mount
useEffect(() => {
refreshSession()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Listen for session changes from Better Auth
useEffect(() => {
// Better Auth dispatches custom events when session changes
const handleSessionChange = (event: Event) => {
// Check if this is a session change event
const customEvent = event as CustomEvent
if (customEvent.detail?.session !== undefined) {
// Session was updated
if (customEvent.detail.session) {
setSession(customEvent.detail.session)
} else {
setSession(null)
}
} else {
// Fallback: refresh session from server
refreshSession()
}
}
// Listen for Better Auth's session change events
window.addEventListener('better-auth:session', handleSessionChange as EventListener)
// Also listen for storage events for cross-tab communication
window.addEventListener('storage', (e) => {
if (e.key === 'better-auth.session_token') {
refreshSession()
}
})
return () => {
window.removeEventListener('better-auth:session', handleSessionChange as EventListener)
}
}, [])
return (
<SessionContext.Provider value={{ session, loading, refreshSession }}>
{children}
</SessionContext.Provider>
)
}
export function useSession() {
const context = useContext(SessionContext)
if (!context) {
throw new Error("useSession must be used within SessionProvider")
}
return context
}