feat: Add photo management features, duplicate detection, attempt limits, and admin deletion
CI / skip-ci-check (pull_request) Successful in 1m19s
CI / lint-and-type-check (pull_request) Failing after 1m37s
CI / test (pull_request) Successful in 2m16s
CI / build (pull_request) Failing after 1m46s
CI / secret-scanning (pull_request) Successful in 1m20s
CI / dependency-scan (pull_request) Successful in 1m27s
CI / sast-scan (pull_request) Successful in 2m29s
CI / workflow-summary (pull_request) Successful in 1m18s

- Add duplicate photo detection (file hash and URL checking)
- Add max attempts per photo with UI counter
- Simplify penalty system (auto-enable when points > 0)
- Prevent scores from going below 0
- Add admin photo deletion functionality
- Improve navigation with always-visible logout
- Prevent users from guessing their own photos
This commit is contained in:
2026-01-02 14:57:30 -05:00
commit 9640627972
69 changed files with 20858 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { prisma } from "@/lib/prisma"
import AdminUserList from "@/components/AdminUserList"
import CreateUserForm from "@/components/CreateUserForm"
export default async function AdminPage() {
const session = await auth()
if (!session || session.user.role !== "ADMIN") {
redirect("/")
}
const users = await prisma.user.findMany({
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
email: true,
role: true,
points: true,
createdAt: true,
},
})
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Admin Panel</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Create New User</h2>
<CreateUserForm />
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">All Users</h2>
<AdminUserList users={users} />
</div>
</div>
</div>
)
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { hashPassword } from "@/lib/utils"
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ userId: string }> }
) {
try {
const session = await auth()
if (!session || session.user.role !== "ADMIN") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { userId } = await params
const { password } = await req.json()
if (!password || password.length < 6) {
return NextResponse.json(
{ error: "Password must be at least 6 characters" },
{ status: 400 }
)
}
const passwordHash = await hashPassword(password)
await prisma.user.update({
where: { id: userId },
data: { passwordHash },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("Error resetting password:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { hashPassword } from "@/lib/utils"
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session || session.user.role !== "ADMIN") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { name, email, password, role } = await req.json()
if (!name || !email || !password) {
return NextResponse.json(
{ error: "Name, email, and password are required" },
{ status: 400 }
)
}
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email },
})
if (existingUser) {
return NextResponse.json(
{ error: "User with this email already exists" },
{ status: 400 }
)
}
const passwordHash = await hashPassword(password)
const user = await prisma.user.create({
data: {
name,
email,
passwordHash,
role: role || "USER",
},
})
return NextResponse.json(
{
id: user.id,
name: user.name,
email: user.email,
role: user.role,
},
{ status: 201 }
)
} catch (error) {
console.error("Error creating user:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers
+144
View File
@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { normalizeString } from "@/lib/utils"
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ photoId: string }> }
) {
try {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { photoId } = await params
const { guessText } = await req.json()
if (!guessText || !guessText.trim()) {
return NextResponse.json(
{ error: "Guess text is required" },
{ status: 400 }
)
}
const photo = await prisma.photo.findUnique({
where: { id: photoId },
})
if (!photo) {
return NextResponse.json({ error: "Photo not found" }, { status: 404 })
}
// Prevent users from guessing their own photos
if (photo.uploaderId === session.user.id) {
return NextResponse.json(
{ error: "You cannot guess on your own photos" },
{ status: 403 }
)
}
// Check if user already has a correct guess
const existingCorrectGuess = await prisma.guess.findFirst({
where: {
userId: session.user.id,
photoId: photoId,
correct: true,
},
})
if (existingCorrectGuess) {
return NextResponse.json(
{ error: "You already guessed this correctly" },
{ status: 400 }
)
}
// Check max attempts limit
const photoWithMaxAttempts = photo as typeof photo & { maxAttempts: number | null }
if (photoWithMaxAttempts.maxAttempts !== null && photoWithMaxAttempts.maxAttempts > 0) {
const userGuessCount = await prisma.guess.count({
where: {
userId: session.user.id,
photoId: photoId,
},
})
if (userGuessCount >= photoWithMaxAttempts.maxAttempts) {
return NextResponse.json(
{ error: `You have reached the maximum number of attempts (${photoWithMaxAttempts.maxAttempts}) for this photo` },
{ status: 400 }
)
}
}
// Check if guess is correct (case-insensitive, trimmed)
const normalizedGuess = normalizeString(guessText)
const normalizedAnswer = normalizeString(photo.answerName)
const isCorrect = normalizedGuess === normalizedAnswer
// Create the guess
const guess = await prisma.guess.create({
data: {
userId: session.user.id,
photoId: photoId,
guessText: guessText.trim(),
correct: isCorrect,
},
})
// Update user points based on guess result
let pointsChange = 0
const photoWithPenalty = photo as typeof photo & { penaltyEnabled: boolean; penaltyPoints: number }
if (isCorrect) {
// Award points for correct answer
pointsChange = photo.points
await prisma.user.update({
where: { id: session.user.id },
data: {
points: {
increment: photo.points, // Award points based on photo difficulty
},
},
})
} else if (photoWithPenalty.penaltyEnabled && photoWithPenalty.penaltyPoints > 0) {
// Deduct points for wrong answer if penalty is enabled
// First, get current user points to prevent going below 0
const currentUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: { points: true },
})
if (currentUser) {
const currentPoints = currentUser.points
const penaltyAmount = photoWithPenalty.penaltyPoints
const newPoints = Math.max(0, currentPoints - penaltyAmount)
const actualDeduction = currentPoints - newPoints
pointsChange = -actualDeduction
await prisma.user.update({
where: { id: session.user.id },
data: {
points: newPoints,
},
})
}
}
return NextResponse.json({
guess,
correct: isCorrect,
pointsChange
})
} catch (error) {
console.error("Error submitting guess:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { unlink } from "fs/promises"
import { join } from "path"
import { existsSync } from "fs"
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ photoId: string }> }
) {
try {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Only admins can delete photos
if (session.user.role !== "ADMIN") {
return NextResponse.json(
{ error: "Only admins can delete photos" },
{ status: 403 }
)
}
const { photoId } = await params
// Find the photo
const photo = await prisma.photo.findUnique({
where: { id: photoId },
})
if (!photo) {
return NextResponse.json({ error: "Photo not found" }, { status: 404 })
}
// Delete all guesses associated with this photo first
await prisma.guess.deleteMany({
where: { photoId: photoId },
})
// Delete the photo file if it's a local upload (starts with /uploads/)
if (photo.url.startsWith("/uploads/")) {
const filepath = join(process.cwd(), "public", photo.url)
if (existsSync(filepath)) {
try {
await unlink(filepath)
} catch (error) {
console.error(`Failed to delete file ${filepath}:`, error)
// Continue with database deletion even if file deletion fails
}
}
}
// Delete the photo
await prisma.photo.delete({
where: { id: photoId },
})
return NextResponse.json({ success: true, message: "Photo deleted successfully" })
} catch (error) {
console.error("Error deleting photo:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
+88
View File
@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { sendNewPhotoEmail } from "@/lib/email"
// Legacy endpoint for URL-based uploads (kept for backward compatibility)
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { url, answerName, points, maxAttempts } = await req.json()
if (!url || !answerName) {
return NextResponse.json(
{ error: "URL and answer name are required" },
{ status: 400 }
)
}
// Validate points (must be positive integer, default to 1)
const pointsValue = points ? Math.max(1, parseInt(points, 10)) : 1
const maxAttemptsValue = maxAttempts && parseInt(maxAttempts, 10) > 0
? parseInt(maxAttempts, 10)
: null
// Check for duplicate URL
const existingPhoto = await prisma.photo.findFirst({
where: { url },
})
if (existingPhoto) {
return NextResponse.json(
{ error: "This photo URL has already been uploaded (duplicate URL detected)" },
{ status: 409 }
)
}
const photo = await prisma.photo.create({
data: {
uploaderId: session.user.id,
url,
answerName: answerName.trim(),
points: pointsValue,
maxAttempts: maxAttemptsValue,
} as any,
include: {
uploader: {
select: {
name: true,
},
},
},
})
// Send emails to all other users
const allUsers = await prisma.user.findMany({
where: {
id: { not: session.user.id },
},
select: {
id: true,
email: true,
name: true,
},
})
// Send emails asynchronously (don't wait for them)
Promise.all(
allUsers.map((user: { id: string; email: string; name: string }) =>
sendNewPhotoEmail(user.email, user.name, photo.id, photo.uploader.name).catch(
(err) => console.error(`Failed to send email to ${user.email}:`, err)
)
)
)
return NextResponse.json({ photo }, { status: 201 })
} catch (error) {
console.error("Error creating photo:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
+218
View File
@@ -0,0 +1,218 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { sendNewPhotoEmail } from "@/lib/email"
import { writeFile } from "fs/promises"
import { join } from "path"
import { existsSync, mkdirSync } from "fs"
import { createHash } from "crypto"
import type { Photo } from "@prisma/client"
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Verify the user exists in the database
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true },
})
if (!user) {
return NextResponse.json(
{ error: "User not found. Please log out and log back in." },
{ status: 401 }
)
}
const formData = await req.formData()
const count = parseInt(formData.get("count") as string, 10)
if (!count || count < 1) {
return NextResponse.json(
{ error: "Invalid photo count" },
{ status: 400 }
)
}
const uploadsDir = join(process.cwd(), "public", "uploads")
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true })
}
type PhotoWithUploader = Photo & {
uploader: { name: string }
}
const createdPhotos: PhotoWithUploader[] = []
for (let i = 0; i < count; i++) {
const answerName = (formData.get(`photo_${i}_answerName`) as string)?.trim()
const pointsStr = (formData.get(`photo_${i}_points`) as string) || "1"
const pointsValue = Math.max(1, parseInt(pointsStr, 10))
const penaltyEnabled = formData.get(`photo_${i}_penaltyEnabled`) === "true"
const penaltyPointsStr = (formData.get(`photo_${i}_penaltyPoints`) as string) || "0"
const penaltyPointsValue = Math.max(0, parseInt(penaltyPointsStr, 10))
const maxAttemptsStr = (formData.get(`photo_${i}_maxAttempts`) as string)?.trim() || ""
const maxAttemptsValue = maxAttemptsStr && parseInt(maxAttemptsStr, 10) > 0
? parseInt(maxAttemptsStr, 10)
: null
if (!answerName) {
return NextResponse.json(
{ error: `Photo ${i + 1}: Answer name is required` },
{ status: 400 }
)
}
const file = formData.get(`photo_${i}_file`) as File | null
const url = (formData.get(`photo_${i}_url`) as string)?.trim() || null
if (!file && !url) {
return NextResponse.json(
{ error: `Photo ${i + 1}: File or URL is required` },
{ status: 400 }
)
}
let photoUrl: string
let fileHash: string | null = null
if (file) {
// Handle file upload
if (!file.type.startsWith("image/")) {
return NextResponse.json(
{ error: `Photo ${i + 1}: File must be an image` },
{ status: 400 }
)
}
if (file.size > 10 * 1024 * 1024) {
return NextResponse.json(
{ error: `Photo ${i + 1}: File size must be less than 10MB` },
{ status: 400 }
)
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Calculate SHA256 hash for duplicate detection
fileHash = createHash("sha256").update(buffer).digest("hex")
// Check for duplicate file
const existingPhoto = await prisma.photo.findFirst({
where: { fileHash } as any,
})
if (existingPhoto) {
return NextResponse.json(
{ error: `Photo ${i + 1}: This photo has already been uploaded (duplicate file detected)` },
{ status: 409 }
)
}
const timestamp = Date.now()
const randomStr = Math.random().toString(36).substring(2, 15)
const extension = file.name.split(".").pop() || "jpg"
const filename = `${timestamp}-${i}-${randomStr}.${extension}`
const filepath = join(uploadsDir, filename)
await writeFile(filepath, buffer)
photoUrl = `/uploads/${filename}`
} else if (url) {
// Handle URL upload
photoUrl = url
// Check for duplicate URL
const existingPhoto = await prisma.photo.findFirst({
where: { url: photoUrl },
})
if (existingPhoto) {
return NextResponse.json(
{ error: `Photo ${i + 1}: This photo URL has already been uploaded (duplicate URL detected)` },
{ status: 409 }
)
}
} else {
return NextResponse.json(
{ error: `Photo ${i + 1}: File or URL is required` },
{ status: 400 }
)
}
// Create photo record
const photo = await prisma.photo.create({
data: {
uploaderId: session.user.id,
url: photoUrl,
fileHash,
answerName,
points: pointsValue,
penaltyEnabled: penaltyEnabled,
penaltyPoints: penaltyPointsValue,
maxAttempts: maxAttemptsValue,
} as any,
include: {
uploader: {
select: {
name: true,
},
},
},
})
createdPhotos.push(photo as PhotoWithUploader)
}
// Send emails to all other users for all photos
const allUsers = await prisma.user.findMany({
where: {
id: { not: session.user.id },
},
select: {
id: true,
email: true,
name: true,
},
})
// Send emails asynchronously for all photos
Promise.all(
allUsers.map((user: { id: string; email: string; name: string }) =>
Promise.all(
createdPhotos.map((photo: PhotoWithUploader) =>
sendNewPhotoEmail(
user.email,
user.name,
photo.id,
photo.uploader.name
).catch((err) =>
console.error(
`Failed to send email to ${user.email} for photo ${photo.id}:`,
err
)
)
)
)
)
)
return NextResponse.json(
{ photos: createdPhotos, count: createdPhotos.length },
{ status: 201 }
)
} catch (error) {
console.error("Error uploading photos:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
+164
View File
@@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { sendNewPhotoEmail } from "@/lib/email"
import { writeFile } from "fs/promises"
import { join } from "path"
import { existsSync, mkdirSync } from "fs"
import { createHash } from "crypto"
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const formData = await req.formData()
const file = formData.get("file") as File | null
const answerName = formData.get("answerName") as string | null
const pointsStr = formData.get("points") as string | null
const maxAttemptsStr = (formData.get("maxAttempts") as string)?.trim() || ""
if (!answerName) {
return NextResponse.json(
{ error: "Answer name is required" },
{ status: 400 }
)
}
// Validate points (must be positive integer, default to 1)
const pointsValue = pointsStr ? Math.max(1, parseInt(pointsStr, 10)) : 1
const maxAttemptsValue = maxAttemptsStr && parseInt(maxAttemptsStr, 10) > 0
? parseInt(maxAttemptsStr, 10)
: null
let photoUrl: string
let fileHash: string | null = null
if (file) {
// Handle file upload
if (!file.type.startsWith("image/")) {
return NextResponse.json(
{ error: "File must be an image" },
{ status: 400 }
)
}
// Check file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
return NextResponse.json(
{ error: "File size must be less than 10MB" },
{ status: 400 }
)
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Calculate SHA256 hash for duplicate detection
fileHash = createHash("sha256").update(buffer).digest("hex")
// Check for duplicate file
const existingPhoto = await prisma.photo.findFirst({
where: { fileHash } as any,
})
if (existingPhoto) {
return NextResponse.json(
{ error: "This photo has already been uploaded (duplicate file detected)" },
{ status: 409 }
)
}
// Generate unique filename
const timestamp = Date.now()
const randomStr = Math.random().toString(36).substring(2, 15)
const extension = file.name.split(".").pop() || "jpg"
const filename = `${timestamp}-${randomStr}.${extension}`
// Ensure uploads directory exists
const uploadsDir = join(process.cwd(), "public", "uploads")
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true })
}
// Save file
const filepath = join(uploadsDir, filename)
await writeFile(filepath, buffer)
// Set URL to the uploaded file
photoUrl = `/uploads/${filename}`
} else {
// Handle URL upload (fallback)
const url = formData.get("url") as string | null
if (!url) {
return NextResponse.json(
{ error: "Either file or URL is required" },
{ status: 400 }
)
}
photoUrl = url
// Check for duplicate URL
const existingPhoto = await prisma.photo.findFirst({
where: { url: photoUrl },
})
if (existingPhoto) {
return NextResponse.json(
{ error: "This photo URL has already been uploaded (duplicate URL detected)" },
{ status: 409 }
)
}
}
const photo = await prisma.photo.create({
data: {
uploaderId: session.user.id,
url: photoUrl,
fileHash,
answerName: answerName.trim(),
points: pointsValue,
maxAttempts: maxAttemptsValue,
} as any,
include: {
uploader: {
select: {
name: true,
},
},
},
})
// Send emails to all other users
const allUsers = await prisma.user.findMany({
where: {
id: { not: session.user.id },
},
select: {
id: true,
email: true,
name: true,
},
})
// Send emails asynchronously (don't wait for them)
Promise.all(
allUsers.map((user: { id: string; email: string; name: string }) =>
sendNewPhotoEmail(user.email, user.name, photo.id, photo.uploader.name).catch(
(err) => console.error(`Failed to send email to ${user.email}:`, err)
)
)
)
return NextResponse.json({ photo }, { status: 201 })
} catch (error) {
console.error("Error uploading photo:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import bcrypt from "bcryptjs"
import { hashPassword } from "@/lib/utils"
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { currentPassword, newPassword } = await req.json()
if (!currentPassword || !newPassword) {
return NextResponse.json(
{ error: "Current password and new password are required" },
{ status: 400 }
)
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
})
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 })
}
const isValid = await bcrypt.compare(currentPassword, user.passwordHash)
if (!isValid) {
return NextResponse.json({ error: "Current password is incorrect" }, { status: 400 })
}
const newPasswordHash = await hashPassword(newPassword)
await prisma.user.update({
where: { id: session.user.id },
data: { passwordHash: newPasswordHash },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("Error changing password:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
BIN
View File
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;
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Providers from "@/components/Providers";
import Navigation from "@/components/Navigation";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "MirrorMatch",
description: "A photo guessing game",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-x-hidden`}
>
<Providers>
<Navigation />
<main className="min-h-screen bg-gray-50 overflow-x-hidden">{children}</main>
</Providers>
</body>
</html>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { prisma } from "@/lib/prisma"
// Enable caching for this page
export const revalidate = 30 // Revalidate every 30 seconds
export default async function LeaderboardPage() {
const session = await auth()
if (!session) {
redirect("/login")
}
const users = await prisma.user.findMany({
orderBy: { points: "desc" },
select: {
id: true,
name: true,
email: true,
points: true,
role: true,
},
})
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">Leaderboard</h1>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gradient-to-r from-purple-600 to-indigo-600">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Rank
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Points
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user: { id: string; name: string; email: string; points: number; role: string }, index: number) => (
<tr
key={user.id}
className={user.id === session.user.id ? "bg-purple-50" : ""}
>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `#${index + 1}`}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.name}
{user.role === "ADMIN" && (
<span className="ml-2 px-2 py-1 text-xs bg-purple-100 text-purple-800 rounded-full">
Admin
</span>
)}
{user.id === session.user.id && (
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
You
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-purple-600">
{user.points}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+102
View File
@@ -0,0 +1,102 @@
"use client"
import { useState } from "react"
import { signIn } from "next-auth/react"
import { useRouter } from "next/navigation"
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setLoading(true)
try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
})
if (result?.error) {
setError("Invalid email or password")
} else {
router.push("/photos")
router.refresh()
}
} catch {
setError("An error occurred. Please try again.")
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 to-indigo-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 MirrorMatch
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<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
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 bg-white text-gray-900 rounded-t-md focus:outline-none focus:ring-purple-500 focus:border-purple-500 focus:z-10 sm:text-sm"
placeholder="Email address"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 bg-white text-gray-900 rounded-b-md focus:outline-none focus:ring-purple-500 focus:border-purple-500 focus:z-10 sm:text-sm"
placeholder="Password"
/>
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Signing in..." : "Sign in"}
</button>
</div>
</form>
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function Home() {
redirect("/photos")
}
+160
View File
@@ -0,0 +1,160 @@
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { prisma } from "@/lib/prisma"
import GuessForm from "@/components/GuessForm"
import PhotoImage from "@/components/PhotoImage"
import DeletePhotoButton from "@/components/DeletePhotoButton"
// Enable caching for this page
export const revalidate = 60 // Revalidate every 60 seconds
export default async function PhotoPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth()
if (!session) {
redirect("/login")
}
const { id } = await params
const photo = await prisma.photo.findUnique({
where: { id },
include: {
uploader: {
select: {
name: true,
},
},
guesses: {
where: {
userId: session.user.id,
},
orderBy: {
createdAt: "desc",
},
},
},
})
if (!photo) {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="bg-white rounded-lg shadow-md p-6">
<p className="text-gray-500">Photo not found</p>
</div>
</div>
)
}
const userGuess = photo.guesses[0]
const hasCorrectGuess = userGuess?.correct || false
const isOwner = photo.uploaderId === session.user.id
// Calculate remaining attempts
const photoWithMaxAttempts = photo as typeof photo & { maxAttempts: number | null | undefined }
const userGuessCount = photo.guesses.length
const maxAttempts = photoWithMaxAttempts.maxAttempts ?? null
const remainingAttempts = maxAttempts !== null && maxAttempts > 0
? Math.max(0, maxAttempts - userGuessCount)
: null
const hasReachedMaxAttempts = maxAttempts !== null && maxAttempts > 0 && userGuessCount >= maxAttempts
return (
<div className="max-w-7xl mx-auto px-4 py-8 overflow-x-hidden">
<div className="bg-white rounded-lg shadow-md p-4 sm:p-6">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-gray-900">Guess Who!</h1>
{session.user.role === "ADMIN" && (
<DeletePhotoButton photoId={photo.id} variant="button" />
)}
</div>
<div className="flex flex-wrap items-center gap-4 mb-2">
<p className="text-sm text-gray-500">
Uploaded by {photo.uploader.name} on{" "}
{new Date(photo.createdAt).toLocaleDateString()}
</p>
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800">
+{photo.points} {photo.points === 1 ? "point" : "points"} if correct
</span>
{(photo as any).penaltyEnabled && (photo as any).penaltyPoints > 0 && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
-{(photo as any).penaltyPoints} {(photo as any).penaltyPoints === 1 ? "point" : "points"} if wrong
</span>
)}
{maxAttempts !== null && maxAttempts > 0 && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
{maxAttempts} {maxAttempts === 1 ? "attempt" : "attempts"} max
</span>
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div className="bg-gray-200 rounded-lg overflow-hidden w-full" style={{ maxHeight: "70vh", minHeight: "300px" }}>
<div className="relative w-full h-full" style={{ minHeight: "300px", maxHeight: "70vh" }}>
<PhotoImage src={photo.url} alt="Photo to guess" />
</div>
</div>
<div className="flex flex-col w-full min-w-0">
{hasCorrectGuess ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-800 font-semibold">
✅ Correct! You guessed it right!
</p>
<p className="text-sm text-green-700 mt-1">
The answer was: <strong>{photo.answerName}</strong>
</p>
<p className="text-sm text-green-700 mt-1">
You earned <strong>{photo.points} {photo.points === 1 ? "point" : "points"}</strong>!
</p>
</div>
) : userGuess ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-800 font-semibold">❌ Wrong guess. Try again!</p>
<p className="text-sm text-red-700 mt-1">
Your last guess: <strong>{userGuess.guessText}</strong>
</p>
{(photo as any).penaltyEnabled && (photo as any).penaltyPoints > 0 && (
<p className="text-sm text-red-700 mt-1">
You lost <strong>{(photo as any).penaltyPoints} {(photo as any).penaltyPoints === 1 ? "point" : "points"}</strong> for this wrong guess.
</p>
)}
</div>
) : null}
{!isOwner && !hasCorrectGuess && remainingAttempts !== null && remainingAttempts > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
<p className="text-sm text-blue-700">
<strong>Remaining attempts:</strong> {remainingAttempts} of {maxAttempts}
</p>
</div>
)}
{!isOwner && !hasCorrectGuess && hasReachedMaxAttempts && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 font-semibold">⚠️ Maximum attempts reached</p>
<p className="text-sm text-yellow-700 mt-1">
You have used all {maxAttempts} {maxAttempts === 1 ? "attempt" : "attempts"} for this photo.
</p>
</div>
)}
{isOwner ? (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-blue-800 font-semibold">📸 This is your photo</p>
<p className="text-sm text-blue-700 mt-1">
You cannot guess on photos you uploaded. The answer is: <strong>{photo.answerName}</strong>
</p>
</div>
) : !hasCorrectGuess && !hasReachedMaxAttempts ? (
<GuessForm photoId={photo.id} />
) : null}
</div>
</div>
</div>
</div>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { prisma } from "@/lib/prisma"
import Link from "next/link"
import PhotoThumbnail from "@/components/PhotoThumbnail"
import DeletePhotoButton from "@/components/DeletePhotoButton"
// Enable caching for this page
export const revalidate = 60 // Revalidate every 60 seconds
export default async function PhotosPage() {
const session = await auth()
if (!session) {
redirect("/login")
}
// Limit to 50 photos per page for performance
const photos = await prisma.photo.findMany({
take: 50,
orderBy: { createdAt: "desc" },
include: {
uploader: {
select: {
name: true,
},
},
},
})
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">All Photos</h1>
{photos.length === 0 ? (
<div className="bg-white rounded-lg shadow-md p-8 text-center">
<p className="text-gray-500">No photos yet. Be the first to upload one!</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{photos.map((photo: { id: string; url: string; answerName: string; points: number; createdAt: Date; uploader: { name: string } }) => (
<div
key={photo.id}
className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition relative group"
>
<Link href={`/photos/${photo.id}`}>
<div className="aspect-square bg-gray-200 relative">
<PhotoThumbnail src={photo.url} alt="Photo" />
</div>
<div className="p-4">
<div className="flex items-center justify-between mb-1">
<p className="text-sm text-gray-500">Uploaded by {photo.uploader.name}</p>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
{photo.points} {photo.points === 1 ? "pt" : "pts"}
</span>
</div>
<p className="text-xs text-gray-400 mt-1">
{new Date(photo.createdAt).toLocaleDateString()}
</p>
</div>
</Link>
{session.user.role === "ADMIN" && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<DeletePhotoButton photoId={photo.id} />
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { prisma } from "@/lib/prisma"
import ChangePasswordForm from "@/components/ChangePasswordForm"
export default async function ProfilePage() {
const session = await auth()
if (!session) {
redirect("/login")
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
name: true,
email: true,
points: true,
role: true,
},
})
if (!user) {
redirect("/login")
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-3xl font-bold text-gray-900 mb-6">Profile</h1>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<p className="mt-1 text-lg text-gray-900">{user.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<p className="mt-1 text-lg text-gray-900">{user.email}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role</label>
<p className="mt-1 text-lg text-gray-900">{user.role}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Points</label>
<p className="mt-1 text-3xl font-bold text-purple-600">{user.points}</p>
</div>
<div className="border-t pt-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Change Password</h2>
<ChangePasswordForm />
</div>
</div>
</div>
</div>
)
}
+447
View File
@@ -0,0 +1,447 @@
"use client"
import { useState, useRef, DragEvent } from "react"
import { useRouter } from "next/navigation"
interface PhotoData {
file: File | null
url: string
answerName: string
points: string
penaltyEnabled: boolean
penaltyPoints: string
maxAttempts: string
preview: string | null
}
export default function UploadPage() {
const router = useRouter()
const fileInputRef = useRef<HTMLInputElement>(null)
const [photos, setPhotos] = useState<PhotoData[]>([])
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const [loading, setLoading] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files).filter((file) =>
file.type.startsWith("image/")
)
if (files.length === 0) {
setError("Please drop image files")
return
}
handleFiles(files)
}
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length > 0) {
handleFiles(files)
}
}
const handleFiles = (files: File[]) => {
const newPhotos: PhotoData[] = []
files.forEach((file) => {
if (file.size > 10 * 1024 * 1024) {
setError(`File ${file.name} is too large (max 10MB)`)
return
}
const reader = new FileReader()
reader.onloadend = () => {
const photoData: PhotoData = {
file,
url: "",
answerName: "",
points: "1",
penaltyEnabled: false,
penaltyPoints: "0",
maxAttempts: "",
preview: reader.result as string,
}
newPhotos.push(photoData)
// Update state when all files are processed
if (newPhotos.length === files.length) {
setPhotos((prev) => [...prev, ...newPhotos])
setError("")
}
}
reader.readAsDataURL(file)
})
}
const updatePhoto = (index: number, updates: Partial<PhotoData>) => {
setPhotos((prev) =>
prev.map((photo, i) => (i === index ? { ...photo, ...updates } : photo))
)
}
const removePhoto = (index: number) => {
setPhotos((prev) => prev.filter((_, i) => i !== index))
}
const addUrlPhoto = () => {
setPhotos((prev) => [
...prev,
{
file: null,
url: "",
answerName: "",
points: "1",
penaltyEnabled: false,
penaltyPoints: "0",
maxAttempts: "",
preview: null,
},
])
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setSuccess("")
// Validate all photos have required fields
for (let i = 0; i < photos.length; i++) {
const photo = photos[i]
if (!photo.answerName.trim()) {
setError(`Photo ${i + 1}: Answer name is required`)
return
}
if (!photo.file && !photo.url) {
setError(`Photo ${i + 1}: Please provide a file or URL`)
return
}
}
if (photos.length === 0) {
setError("Please add at least one photo")
return
}
setLoading(true)
try {
const formData = new FormData()
// Add all photos data with indexed keys to preserve order
photos.forEach((photo, index) => {
if (photo.file) {
formData.append(`photo_${index}_file`, photo.file)
} else if (photo.url) {
formData.append(`photo_${index}_url`, photo.url)
}
formData.append(`photo_${index}_answerName`, photo.answerName.trim())
formData.append(`photo_${index}_points`, photo.points)
// Auto-enable penalty if penaltyPoints has a value > 0
const penaltyPointsValue = parseInt(photo.penaltyPoints || "0", 10)
formData.append(`photo_${index}_penaltyEnabled`, penaltyPointsValue > 0 ? "true" : "false")
formData.append(`photo_${index}_penaltyPoints`, photo.penaltyPoints || "0")
formData.append(`photo_${index}_maxAttempts`, photo.maxAttempts || "")
})
formData.append("count", photos.length.toString())
const response = await fetch("/api/photos/upload-multiple", {
method: "POST",
body: formData,
})
const data = await response.json()
if (!response.ok) {
setError(data.error || "Failed to upload photos")
} else {
setSuccess(
`Successfully uploaded ${data.photos.length} photo(s)! Emails sent to all users.`
)
setPhotos([])
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
setTimeout(() => {
router.push("/photos")
}, 2000)
}
} catch {
setError("An error occurred. Please try again.")
} finally {
setLoading(false)
}
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-3xl font-bold text-gray-900 mb-6">Upload Photos</h1>
<form onSubmit={handleSubmit} className="space-y-6">
{/* File Upload Section */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Upload Photos (Multiple files supported)
</label>
{/* Drag and Drop Area */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isDragging
? "border-purple-500 bg-purple-50"
: "border-gray-300 hover:border-purple-400"
}`}
>
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div className="mt-4">
<label
htmlFor="file-upload"
className="cursor-pointer inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
>
<span>Select files</span>
<input
id="file-upload"
ref={fileInputRef}
name="file-upload"
type="file"
accept="image/*"
multiple
className="sr-only"
onChange={handleFileInputChange}
/>
</label>
<p className="mt-2 text-sm text-gray-600">
or drag and drop multiple images
</p>
<p className="mt-1 text-xs text-gray-500">
PNG, JPG, GIF up to 10MB each
</p>
</div>
</div>
</div>
{/* Add URL Photo Button */}
<div>
<button
type="button"
onClick={addUrlPhoto}
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
>
+ Add Photo by URL
</button>
</div>
{/* Photos List */}
{photos.length > 0 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">
Photos ({photos.length})
</h2>
{photos.map((photo, index) => (
<div
key={index}
className="border border-gray-200 rounded-lg p-4 space-y-4"
>
<div className="flex items-start justify-between">
<h3 className="text-sm font-medium text-gray-700">
Photo {index + 1}
</h3>
<button
type="button"
onClick={() => removePhoto(index)}
className="text-red-600 hover:text-red-800 text-sm font-medium"
>
Remove
</button>
</div>
{/* Preview */}
{photo.preview && (
<div className="flex justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.preview}
alt={`Preview ${index + 1}`}
className="max-h-32 rounded-lg shadow-md"
/>
</div>
)}
{/* URL Input (if no file) */}
{!photo.file && (
<div>
<label
htmlFor={`url-${index}`}
className="block text-sm font-medium text-gray-700"
>
Photo URL
</label>
<input
id={`url-${index}`}
type="url"
value={photo.url}
onChange={(e) =>
updatePhoto(index, { url: e.target.value })
}
placeholder="https://example.com/photo.jpg"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
/>
</div>
)}
{/* Answer Name */}
<div>
<label
htmlFor={`answerName-${index}`}
className="block text-sm font-medium text-gray-700"
>
Answer Name *
</label>
<input
id={`answerName-${index}`}
type="text"
required
value={photo.answerName}
onChange={(e) =>
updatePhoto(index, { answerName: e.target.value })
}
placeholder="Enter the correct answer"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
/>
</div>
{/* Points */}
<div>
<label
htmlFor={`points-${index}`}
className="block text-sm font-medium text-gray-700"
>
Points Value (for correct answer)
</label>
<input
id={`points-${index}`}
type="number"
min="1"
required
value={photo.points}
onChange={(e) =>
updatePhoto(index, { points: e.target.value })
}
placeholder="1"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
/>
</div>
{/* Penalty Points */}
<div className="border-t pt-4">
<label
htmlFor={`penaltyPoints-${index}`}
className="block text-sm font-medium text-gray-700"
>
Penalty Points (deducted for wrong answer)
</label>
<input
id={`penaltyPoints-${index}`}
type="number"
min="0"
value={photo.penaltyPoints}
onChange={(e) =>
updatePhoto(index, { penaltyPoints: e.target.value })
}
placeholder="0"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
/>
<p className="mt-1 text-xs text-gray-500">
Leave empty or set to 0 to disable point deduction. If set to a value greater than 0, users will lose these points for each incorrect guess.
</p>
</div>
{/* Max Attempts */}
<div>
<label
htmlFor={`maxAttempts-${index}`}
className="block text-sm font-medium text-gray-700"
>
Max Attempts (per user)
</label>
<input
id={`maxAttempts-${index}`}
type="number"
min="0"
value={photo.maxAttempts}
onChange={(e) =>
updatePhoto(index, { maxAttempts: e.target.value })
}
placeholder="Unlimited (leave empty or 0)"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
/>
<p className="mt-1 text-xs text-gray-500">
Maximum number of guesses allowed per user. Leave empty or 0 for unlimited attempts.
</p>
</div>
</div>
))}
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
{success && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">{success}</p>
</div>
)}
<button
type="submit"
disabled={loading || photos.length === 0}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
>
{loading
? `Uploading ${photos.length} photo(s)...`
: `Upload ${photos.length} Photo(s)`}
</button>
</form>
</div>
</div>
)
}