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
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:
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth"
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function Home() {
|
||||
redirect("/photos")
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user