feat: initialize Next.js project with Prisma, authentication, and rankings page

This commit is contained in:
2026-03-27 14:50:00 -07:00
parent 96f7cb86d3
commit 66e4baa643
208 changed files with 8522 additions and 9035 deletions
+137
View File
@@ -0,0 +1,137 @@
"use client"
import { useState } from "react"
import { signIn } from "next-auth/react"
import { useRouter } from "next/navigation"
import Link from "next/link"
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setIsLoading(true)
try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
})
if (result?.error) {
setError(result.error)
} else {
router.push("/")
router.refresh()
}
} catch (err) {
setError("An error occurred during login")
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{" "}
<Link
href="/auth/register"
className="font-medium text-green-600 hover:text-green-500"
>
create a new account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 text-green-600 focus:ring-green-500 border-gray-300 rounded"
/>
<label
htmlFor="remember-me"
className="ml-2 block text-sm text-gray-900"
>
Remember me
</label>
</div>
<div className="text-sm">
<Link
href="/auth/password-reset"
className="font-medium text-green-600 hover:text-green-500"
>
Forgot your password?
</Link>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md 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 ? "Signing in..." : "Sign in"}
</button>
</div>
</form>
</div>
</div>
)
}
+201
View File
@@ -0,0 +1,201 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { prisma } from "@/lib/prisma"
import bcrypt from "bcryptjs"
export default function RegisterPage() {
const router = useRouter()
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
confirmPassword: "",
})
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const [isLoading, setIsLoading] = useState(false)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setSuccess("")
// Validate passwords match
if (formData.password !== formData.confirmPassword) {
setError("Passwords do not match")
return
}
// Validate password strength
if (formData.password.length < 8) {
setError("Password must be at least 8 characters")
return
}
setIsLoading(true)
try {
// Check if email already exists
const existingUser = await prisma.user.findUnique({
where: { email: formData.email },
})
if (existingUser) {
setError("Email already registered")
setIsLoading(false)
return
}
// Hash password
const passwordHash = await bcrypt.hash(formData.password, 12)
// Create player
const player = await prisma.player.create({
data: {
name: formData.name,
},
})
// Create user
await prisma.user.create({
data: {
playerId: player.id,
email: formData.email,
passwordDigest: passwordHash,
role: "player",
confirmed: false,
},
})
setSuccess("Registration successful! Please check your email to confirm your account.")
setIsLoading(false)
// Redirect to login after 2 seconds
setTimeout(() => {
router.push("/auth/login")
}, 2000)
} catch (err) {
setError("An error occurred during registration. Please try again.")
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Already have an account?{" "}
<Link
href="/auth/login"
className="font-medium text-green-600 hover:text-green-500"
>
Sign in
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{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="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="name" className="sr-only">
Full Name
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
placeholder="Full Name"
value={formData.name}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
placeholder="Email address"
value={formData.email}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={formData.password}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="sr-only">
Confirm Password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
placeholder="Confirm Password"
value={formData.confirmPassword}
onChange={handleChange}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md 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 account..." : "Create Account"}
</button>
</div>
</form>
</div>
</div>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+26
View File
@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { SessionProvider } from "@/components/SessionProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "EuchreCamp - Tournament Management & Partnership Analytics",
description: "Track your Euchre games, tournaments, and partnership performance",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<SessionProvider>
{children}
</SessionProvider>
</body>
</html>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation"
export default function Home() {
// Redirect to rankings as the main entry point
redirect("/rankings")
}
+93
View File
@@ -0,0 +1,93 @@
import { prisma } from "@/lib/prisma"
import Navigation from "@/components/Navigation"
import Link from "next/link"
export default async function RankingsPage() {
// Fetch players ordered by Elo rating
const players = await prisma.player.findMany({
orderBy: { currentElo: "desc" },
take: 50,
include: {
partnershipStats: true,
},
})
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<main className="max-w-7xl 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">
Player Rankings
</h1>
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rank
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Player
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Elo Rating
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Games Played
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Win Rate
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{players.map((player, index) => (
<tr key={player.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{index + 1}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Link
href={`/players/${player.id}/profile`}
className="text-green-600 hover:text-green-900"
>
{player.name}
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{player.currentElo}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{player.partnershipStats.reduce(
(sum, stat) => sum + stat.gamesPlayed,
0
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{(() => {
const totalGames = player.partnershipStats.reduce(
(sum, stat) => sum + stat.gamesPlayed,
0
)
const totalWins = player.partnershipStats.reduce(
(sum, stat) => sum + stat.wins,
0
)
return totalGames > 0
? `${((totalWins / totalGames) * 100).toFixed(1)}%`
: "N/A"
})()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</main>
</div>
)
}