ilia 9640627972
Some checks failed
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
feat: Add photo management features, duplicate detection, attempt limits, and admin deletion
- 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
2026-01-02 14:57:30 -05:00

448 lines
16 KiB
TypeScript

"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>
)
}