ilia a8548bddcf
All checks were successful
CI / skip-ci-check (push) Successful in 1m21s
CI / lint-and-type-check (push) Successful in 1m45s
CI / test (push) Successful in 1m49s
CI / build (push) Successful in 1m50s
CI / secret-scanning (push) Successful in 1m22s
CI / dependency-scan (push) Successful in 1m27s
CI / sast-scan (push) Successful in 2m27s
CI / workflow-summary (push) Successful in 1m19s
This PR adds comprehensive photo management features, duplicate detection, attempt limits, penalty system improvements, and admin photo deletion capabilities to the MirrorMatch application. (#1)
# Photo Management and Game Features

## Summary
This PR adds comprehensive photo management features, duplicate detection, attempt limits, penalty system improvements, and admin photo deletion capabilities to the MirrorMatch application.

## Features Added

### 1. Duplicate Photo Detection
- **File-based duplicates**: Calculates SHA256 hash of uploaded files to detect duplicate content
- **URL-based duplicates**: Checks for duplicate photo URLs
- Prevents users from uploading the same photo multiple times
- Returns HTTP 409 (Conflict) with clear error messages

### 2. Maximum Attempts Per Photo
- Uploaders can set a maximum number of guesses allowed per user for each photo
- Default: unlimited (null or 0)
- UI displays remaining attempts counter
- API enforces attempt limits before allowing guesses
- Shows warning message when max attempts reached

### 3. Penalty System Improvements
- **Simplified UI**: Removed checkbox - penalty automatically enabled when penalty points > 0
- **Score protection**: Scores cannot go below 0, even with large penalties
- If penalty would result in negative score, only deducts available points and sets to 0

### 4. Admin Photo Deletion
- Admins can delete photos from:
  - Photos list page (hover to reveal delete icon)
  - Individual photo detail page (delete button in header)
- Deletes associated guesses automatically
- Deletes local uploaded files from filesystem
- Confirmation dialog before deletion
- Proper error handling and user feedback

### 5. Navigation Improvements
- Logout button always visible in side menu (hamburger menu)
- Improved side menu layout with fixed footer for logout button
- Better mobile responsiveness

### 6. Self-Guess Prevention
- Users cannot guess on their own uploaded photos
- Shows informative message with answer for photo owners

## Technical Changes

### Database Schema
- Added `fileHash` field (String?) to Photo model for duplicate detection
- Added `maxAttempts` field (Int?) to Photo model for attempt limits
- Added indexes on `url` and `fileHash` for performance

### API Routes
- `POST /api/photos/upload-multiple`: Enhanced with duplicate checking and maxAttempts
- `POST /api/photos/[photoId]/guess`: Added maxAttempts enforcement and score floor protection
- `DELETE /api/photos/[photoId]`: New route for admin photo deletion

### Components
- `DeletePhotoButton`: New reusable component for photo deletion
- Updated upload form to remove penalty checkbox
- Enhanced photo display pages with attempt counters and admin controls

## Database Migrations
- Run `npm run db:push` to apply schema changes
- Run `npm run db:generate` to regenerate Prisma client

## Testing
- Test duplicate detection with same file and different filenames
- Test duplicate detection with same URL
- Test max attempts enforcement
- Test penalty system with various point values
- Test score floor (cannot go below 0)
- Test admin photo deletion
- Test self-guess prevention

## Breaking Changes
None - all changes are backward compatible. Existing photos will have `null` for `maxAttempts` (unlimited) and `fileHash` (for URL uploads).

Reviewed-on: #1
2026-01-03 10:19:59 -05:00

168 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
// Type assertion for new fields (penaltyEnabled, penaltyPoints, maxAttempts)
type PhotoWithNewFields = typeof photo & {
penaltyEnabled: boolean
penaltyPoints: number
maxAttempts: number | null
}
const photoWithFields = photo as PhotoWithNewFields
// Calculate remaining attempts
const userGuessCount = photo.guesses.length
const maxAttempts = photoWithFields.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>
{photoWithFields.penaltyEnabled && photoWithFields.penaltyPoints > 0 && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
-{photoWithFields.penaltyPoints} {photoWithFields.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>
{photoWithFields.penaltyEnabled && photoWithFields.penaltyPoints > 0 && (
<p className="text-sm text-red-700 mt-1">
You lost <strong>{photoWithFields.penaltyPoints} {photoWithFields.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>
)
}