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
+140
View File
@@ -0,0 +1,140 @@
"use client"
import { useState } from "react"
interface User {
id: string
name: string
email: string
role: string
points: number
createdAt: Date
}
export default function AdminUserList({ users }: { users: User[] }) {
const [resettingUserId, setResettingUserId] = useState<string | null>(null)
const [newPassword, setNewPassword] = useState<Record<string, string>>({})
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const handleResetPassword = async (userId: string) => {
const password = newPassword[userId]
if (!password || password.length < 6) {
setError("Password must be at least 6 characters")
return
}
setError("")
setResettingUserId(userId)
try {
const response = await fetch(`/api/admin/users/${userId}/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || "Failed to reset password")
} else {
setSuccess("Password reset successfully!")
setNewPassword({ ...newPassword, [userId]: "" })
setTimeout(() => {
setSuccess("")
}, 3000)
}
} catch {
setError("An error occurred. Please try again.")
} finally {
setResettingUserId(null)
}
}
return (
<div className="space-y-4">
{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>
)}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Points
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id}>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{user.name}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{user.email}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.role === "ADMIN"
? "bg-purple-100 text-purple-800"
: "bg-gray-100 text-gray-800"
}`}
>
{user.role}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{user.points}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<div className="flex items-center space-x-2">
<input
type="password"
placeholder="New password"
value={newPassword[user.id] || ""}
onChange={(e) =>
setNewPassword({ ...newPassword, [user.id]: e.target.value })
}
className="px-2 py-1 border border-gray-300 rounded text-xs w-32 bg-white text-gray-900"
/>
<button
onClick={() => handleResetPassword(user.id)}
disabled={resettingUserId === user.id}
className="px-3 py-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600 disabled:opacity-50"
>
{resettingUserId === user.id ? "Resetting..." : "Reset"}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+125
View File
@@ -0,0 +1,125 @@
"use client"
import { useState } from "react"
export default function ChangePasswordForm() {
const [currentPassword, setCurrentPassword] = useState("")
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setSuccess("")
if (newPassword !== confirmPassword) {
setError("New passwords do not match")
return
}
if (newPassword.length < 6) {
setError("Password must be at least 6 characters")
return
}
setLoading(true)
try {
const response = await fetch("/api/profile/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword,
newPassword,
}),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || "Failed to change password")
} else {
setSuccess("Password changed successfully!")
setCurrentPassword("")
setNewPassword("")
setConfirmPassword("")
setTimeout(() => {
setSuccess("")
}, 3000)
}
} catch {
setError("An error occurred. Please try again.")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700">
Current Password
</label>
<input
id="currentPassword"
type="password"
required
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
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>
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700">
New Password
</label>
<input
id="newPassword"
type="password"
required
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
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>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm New Password
</label>
<input
id="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
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>
{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}
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 ? "Changing..." : "Change Password"}
</button>
</form>
)
}
+131
View File
@@ -0,0 +1,131 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
export default function CreateUserForm() {
const router = useRouter()
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [role, setRole] = useState<"ADMIN" | "USER">("USER")
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setSuccess("")
setLoading(true)
try {
const response = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password, role }),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || "Failed to create user")
} else {
setSuccess("User created successfully!")
setName("")
setEmail("")
setPassword("")
setRole("USER")
router.refresh()
setTimeout(() => {
setSuccess("")
}, 3000)
}
} catch {
setError("An error occurred. Please try again.")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
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>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
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>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Temporary Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
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>
<div>
<label htmlFor="role" className="block text-sm font-medium text-gray-700">
Role
</label>
<select
id="role"
value={role}
onChange={(e) => setRole(e.target.value as "ADMIN" | "USER")}
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"
>
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
</select>
</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}
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 ? "Creating..." : "Create User"}
</button>
</form>
)
}
+102
View File
@@ -0,0 +1,102 @@
"use client"
import { useState } from "react"
import { useRouter, usePathname } from "next/navigation"
interface DeletePhotoButtonProps {
photoId: string
onDelete?: () => void
variant?: "icon" | "button"
}
export default function DeletePhotoButton({
photoId,
onDelete,
variant = "icon"
}: DeletePhotoButtonProps) {
const router = useRouter()
const pathname = usePathname()
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const handleDelete = async () => {
if (!confirm("Are you sure you want to delete this photo? This action cannot be undone.")) {
return
}
setLoading(true)
setError("")
try {
const response = await fetch(`/api/photos/${photoId}`, {
method: "DELETE",
})
const data = await response.json()
if (!response.ok) {
setError(data.error || "Failed to delete photo")
return
}
// Call optional callback
if (onDelete) {
onDelete()
} else {
// If we're on a photo detail page, redirect to photos list
if (pathname?.startsWith("/photos/") && pathname !== "/photos") {
router.push("/photos")
} else {
// Otherwise just refresh the current page
router.refresh()
}
}
} catch (err) {
setError("An error occurred. Please try again.")
console.error("Delete error:", err)
} finally {
setLoading(false)
}
}
if (variant === "button") {
return (
<div>
<button
onClick={handleDelete}
disabled={loading}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
>
<svg className="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{loading ? "Deleting..." : "Delete Photo"}
</button>
{error && (
<p className="mt-2 text-sm text-red-600">{error}</p>
)}
</div>
)
}
return (
<div className="relative">
<button
onClick={handleDelete}
disabled={loading}
className="p-2 text-red-600 hover:text-red-700 hover:bg-red-50 rounded-md transition disabled:opacity-50"
aria-label="Delete photo"
title="Delete photo"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{error && (
<p className="absolute top-full left-0 mt-1 text-xs text-red-600 whitespace-nowrap bg-white p-1 rounded shadow">
{error}
</p>
)}
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
export default function GuessForm({ photoId }: { photoId: string }) {
const router = useRouter()
const [guessText, setGuessText] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (!guessText.trim()) {
setError("Please enter a guess")
return
}
setLoading(true)
try {
const response = await fetch(`/api/photos/${photoId}/guess`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ guessText: guessText.trim() }),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || "Failed to submit guess")
} else {
router.refresh()
}
} catch {
setError("An error occurred. Please try again.")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="guessText" className="block text-sm font-medium text-gray-700">
Your Guess
</label>
<input
id="guessText"
type="text"
required
value={guessText}
onChange={(e) => setGuessText(e.target.value)}
placeholder="Enter your guess..."
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>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
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 ? "Submitting..." : "Submit Guess"}
</button>
</form>
)
}
+163
View File
@@ -0,0 +1,163 @@
"use client"
import Link from "next/link"
import { useSession, signOut } from "next-auth/react"
import { useState, useEffect } from "react"
export default function Navigation() {
const { data: session } = useSession()
const [sideMenuOpen, setSideMenuOpen] = useState(false)
// Close side menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (sideMenuOpen && !target.closest('.side-menu') && !target.closest('.menu-button')) {
setSideMenuOpen(false)
}
}
if (sideMenuOpen) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
}, [sideMenuOpen])
if (!session) {
return null
}
return (
<>
<nav className="sticky top-0 z-50 bg-gradient-to-r from-purple-600 to-indigo-600 text-white shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo and Menu Button */}
<div className="flex items-center space-x-4">
<button
onClick={() => setSideMenuOpen(!sideMenuOpen)}
className="menu-button p-2 rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-white"
aria-label="Toggle menu"
>
<svg
className="h-6 w-6"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<Link href="/" className="text-xl font-bold">
MirrorMatch
</Link>
</div>
{/* Main Actions - Always Visible */}
<div className="flex items-center space-x-4">
<Link
href="/upload"
className="hover:bg-purple-700 px-3 py-2 rounded-md text-sm font-medium transition whitespace-nowrap"
>
Upload
</Link>
<Link
href="/leaderboard"
className="hover:bg-purple-700 px-3 py-2 rounded-md text-sm font-medium transition whitespace-nowrap"
>
Leaderboard
</Link>
</div>
{/* User Info */}
<div className="flex items-center">
<span className="text-sm">Hello, {session.user.name}</span>
</div>
</div>
</div>
</nav>
{/* Side Menu */}
<div
className={`side-menu fixed top-16 left-0 h-[calc(100vh-4rem)] w-64 bg-white shadow-xl z-40 transform transition-transform duration-300 ease-in-out flex flex-col ${
sideMenuOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="p-4 border-b border-gray-200 flex-shrink-0">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Menu</h2>
<button
onClick={() => setSideMenuOpen(false)}
className="p-1 rounded-md hover:bg-gray-100 text-gray-500"
aria-label="Close menu"
>
<svg
className="h-5 w-5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<nav className="p-4 flex-1 overflow-y-auto">
<div className="flex flex-col space-y-2">
<Link
href="/photos"
className="px-4 py-3 rounded-md text-gray-700 hover:bg-purple-50 hover:text-purple-700 transition font-medium"
onClick={() => setSideMenuOpen(false)}
>
Photos
</Link>
<Link
href="/profile"
className="px-4 py-3 rounded-md text-gray-700 hover:bg-purple-50 hover:text-purple-700 transition font-medium"
onClick={() => setSideMenuOpen(false)}
>
Profile
</Link>
{session.user.role === "ADMIN" && (
<Link
href="/admin"
className="px-4 py-3 rounded-md text-gray-700 hover:bg-purple-50 hover:text-purple-700 transition font-medium"
onClick={() => setSideMenuOpen(false)}
>
Admin
</Link>
)}
</div>
</nav>
{/* Logout button in side menu - always visible at bottom */}
<div className="p-4 border-t border-gray-200 flex-shrink-0 bg-white">
<button
onClick={() => {
setSideMenuOpen(false)
signOut({ callbackUrl: "/login" })
}}
className="w-full bg-red-500 hover:bg-red-600 px-4 py-2 rounded-md text-sm font-medium text-white transition"
>
Logout
</button>
</div>
</div>
{/* Overlay for mobile */}
{sideMenuOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-30 sm:hidden"
onClick={() => setSideMenuOpen(false)}
/>
)}
</>
)
}
+37
View File
@@ -0,0 +1,37 @@
"use client"
import Image from "next/image"
export default function PhotoImage({ src, alt }: { src: string; alt: string }) {
// Handle external URLs and local paths
const isExternal = src.startsWith("http://") || src.startsWith("https://")
if (isExternal) {
return (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={src}
alt={alt}
className="w-full h-full object-contain"
style={{ maxHeight: "100%" }}
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='600'%3E%3Crect fill='%23ddd' width='800' height='600'/%3E%3Ctext fill='%23999' font-family='sans-serif' font-size='20' dy='10.5' font-weight='bold' x='50%25' y='50%25' text-anchor='middle'%3EImage not found%3C/text%3E%3C/svg%3E"
}}
/>
)
}
return (
<Image
src={src}
alt={alt}
fill
className="object-contain"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 600px"
loading="lazy"
unoptimized={src.startsWith("/uploads/")}
/>
)
}
+36
View File
@@ -0,0 +1,36 @@
"use client"
import Image from "next/image"
export default function PhotoThumbnail({ src, alt }: { src: string; alt: string }) {
// Handle external URLs and local paths
const isExternal = src.startsWith("http://") || src.startsWith("https://")
return (
<div className="w-full h-full relative">
{isExternal ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={src}
alt={alt}
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Crect fill='%23ddd' width='400' height='400'/%3E%3Ctext fill='%23999' font-family='sans-serif' font-size='20' dy='10.5' font-weight='bold' x='50%25' y='50%25' text-anchor='middle'%3EImage not found%3C/text%3E%3C/svg%3E"
}}
/>
) : (
<Image
src={src}
alt={alt}
fill
className="object-cover"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
loading="lazy"
unoptimized={src.startsWith("/uploads/")}
/>
)}
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
"use client"
import { SessionProvider } from "next-auth/react"
export default function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}