feat: Add photo management features, duplicate detection, attempt limits, and admin deletion
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

- 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:
ilia 2026-01-02 14:57:30 -05:00
commit 9640627972
69 changed files with 20858 additions and 0 deletions

View File

@ -0,0 +1,191 @@
# MirrorMatch Project Rules
## Project Overview
MirrorMatch is a photo guessing game where users upload photos and other users guess who is in the picture to earn points. The app features admin user management, email notifications, and a leaderboard system.
**Core Goals:**
- Simple, fun guessing game with photos
- Admin-controlled user creation (no public signup)
- Point-based scoring system
- Email notifications for new photos
- Clean, maintainable codebase
## Tech Stack & Constraints
### Required Technologies
- **Next.js 16+** with App Router (not Pages Router)
- **TypeScript** for all code
- **PostgreSQL** as the only database
- **Prisma** as the only ORM (no raw SQL or other ORMs)
- **NextAuth.js** with credentials provider (email + password)
- **Tailwind CSS** for styling
- **Nodemailer** for email sending
### Constraints
- **No other databases or ORMs** - PostgreSQL + Prisma only
- **No other auth providers** - NextAuth credentials only
- **No file upload libraries** - use URL-based photo storage for now
- **Server components by default** - use client components only when necessary
## Architecture & Code Style
### Next.js App Router Conventions
- Use `app/` directory for all routes
- Prefer **server components** by default
- Use `"use client"` directive only when needed (forms, interactivity, hooks)
- Use route handlers (`app/api/*/route.ts`) for API endpoints
- Use server actions for mutations when appropriate
### Database Access
- **Always use Prisma** via `lib/prisma.ts` helper
- Never write raw SQL queries
- Never use other ORMs or database libraries
- All database access should go through the Prisma client instance
### Code Organization
```
app/ # Next.js App Router routes
api/ # API route handlers
[pages]/ # Page components (server components by default)
components/ # Reusable React components
lib/ # Utility functions and helpers
prisma.ts # Prisma client instance (singleton)
auth.ts # NextAuth configuration
email.ts # Email utilities
utils.ts # General utilities
prisma/ # Prisma schema and migrations
types/ # TypeScript type definitions
```
### Component Guidelines
- **Small, composable components** - prefer many small components over large ones
- **Server components by default** - only use client components for:
- Forms with state
- Interactive UI (buttons, modals)
- React hooks (useState, useEffect, etc.)
- Browser APIs
- **Business logic in server actions or route handlers** - not in React components
- Keep components focused on presentation
### Validation
- Use simple validation for forms and API inputs
- Consider adding Zod for schema validation if needed
- Always validate user input on the server side
- Sanitize inputs before database operations
## Security Guidelines
### Password Handling
- **Never expose password hashes** in API responses or logs
- **Always hash passwords** using bcrypt (via `lib/utils.ts` `hashPassword` function)
- **Never store plain text passwords**
- **Never log passwords** or password hashes
### Authentication & Authorization
- **Protect admin-only routes** with role checks in middleware and route handlers
- **Validate and authorize all mutations**:
- Photo uploads (must be logged in)
- Guess submissions (must be logged in, can't guess own photos)
- User updates (users can only update themselves, admins can update anyone)
- **Always check session** in API routes before processing requests
- **Use NextAuth session** for user identity, never trust client-provided user IDs
### Route Protection
- Admin routes (`/admin/*`) require `role === "ADMIN"`
- All app routes except `/login` require authentication
- API routes should check authorization before processing
### Data Validation
- Validate all user inputs
- Sanitize data before database operations
- Use Prisma's type safety to prevent SQL injection
- Never trust client-side validation alone
## Behavioral Rules for AI Tools (Cursor)
### Before Making Changes
1. **Read core documentation first:**
- `README.md` - Setup and usage
- `ARCHITECTURE.md` - System design and data flow
- `.cursor/rules/mirrormatch.mdc` (this file) - Project rules
- `CONTRIBUTING.md` - Coding conventions
- `SECURITY.md` - Security practices
2. **Understand the existing codebase:**
- Review related files before modifying
- Understand data models in `prisma/schema.prisma`
- Check existing patterns in similar features
3. **Follow established patterns:**
- Use existing component patterns
- Follow the same structure for new API routes
- Maintain consistency with existing code style
### When Adding Features
- **Update documentation** when behavior or APIs change
- **Keep docs in sync** - if you change architecture, update ARCHITECTURE.md
- **Add comments** for complex logic
- **Use TypeScript types** - avoid `any` types
- **Follow naming conventions** - camelCase for variables, PascalCase for components
### When Something is Ambiguous
- **Pick a sensible default** that aligns with existing patterns
- **Document the decision** in relevant MD files
- **Ask for clarification** only if multiple valid approaches exist and the choice significantly impacts the system
### Code Quality
- **Prefer incremental changes** - small, focused commits
- **Write clear commit messages**
- **Keep functions focused** - single responsibility
- **Use meaningful variable names**
- **Add error handling** for all async operations
## Data Model Summary
### User
- `id`, `name`, `email` (unique), `passwordHash`, `role` (ADMIN | USER), `points`, `createdAt`
- Relations: `uploadedPhotos`, `guesses`
### Photo
- `id`, `uploaderId`, `url`, `answerName`, `createdAt`
- Relations: `uploader` (User), `guesses`
### Guess
- `id`, `userId`, `photoId`, `guessText`, `correct` (boolean), `createdAt`
- Relations: `user` (User), `photo` (Photo)
- Indexed on `userId` and `photoId`
## Key Workflows
### User Creation (Admin Only)
1. Admin creates user via `/admin` page
2. User receives temporary password
3. User logs in and can change password
### Photo Upload Flow
1. User uploads photo (URL) with `answerName`
2. Photo record created in database
3. Email notifications sent to all other users
4. Users can view and guess
### Guess Flow
1. User views photo at `/photos/[id]`
2. User submits guess
3. System checks if guess matches `answerName` (case-insensitive, trimmed)
4. If correct: create Guess with `correct: true`, increment user points
5. If wrong: create Guess with `correct: false`
6. User sees feedback immediately
## Important Reminders
- **Always read documentation before making large changes**
- **Keep documentation updated when behavior changes**
- **Follow security guidelines strictly**
- **Use Prisma for all database access**
- **Prefer server components, use client components only when needed**
- **Validate and authorize all user actions**
---
**Last Updated:** When architecture or rules change, update this file and notify the team.

251
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,251 @@
name: CI
on:
push:
branches: [master, main]
pull_request:
types: [opened, synchronize, reopened]
jobs:
# Check if CI should be skipped based on branch name or commit message
skip-ci-check:
runs-on: ubuntu-latest
outputs:
should-skip: ${{ steps.check.outputs.skip }}
steps:
- name: Check out code (for commit message)
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check if CI should be skipped
id: check
run: |
# Simple skip pattern: @skipci (case-insensitive)
SKIP_PATTERN="@skipci"
# Get branch name (works for both push and PR)
BRANCH_NAME="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
# Get commit message (works for both push and PR)
COMMIT_MSG="${GITHUB_EVENT_HEAD_COMMIT_MESSAGE:-}"
if [ -z "$COMMIT_MSG" ]; then
COMMIT_MSG="${GITHUB_EVENT_PULL_REQUEST_HEAD_COMMIT_MESSAGE:-}"
fi
if [ -z "$COMMIT_MSG" ]; then
COMMIT_MSG=$(git log -1 --pretty=%B 2>/dev/null || echo "")
fi
SKIP=0
# Check branch name (case-insensitive)
if echo "$BRANCH_NAME" | grep -qiF "$SKIP_PATTERN"; then
echo "Skipping CI: branch name contains '$SKIP_PATTERN'"
SKIP=1
fi
# Check commit message (case-insensitive)
if [ $SKIP -eq 0 ] && [ -n "$COMMIT_MSG" ]; then
if echo "$COMMIT_MSG" | grep -qiF "$SKIP_PATTERN"; then
echo "Skipping CI: commit message contains '$SKIP_PATTERN'"
SKIP=1
fi
fi
echo "skip=$SKIP" >> $GITHUB_OUTPUT
echo "Branch: $BRANCH_NAME"
echo "Commit: ${COMMIT_MSG:0:50}..."
echo "Skip CI: $SKIP"
lint-and-type-check:
needs: skip-ci-check
runs-on: ubuntu-latest
if: needs.skip-ci-check.outputs.should-skip != '1'
container:
image: node:20-bullseye
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Type check
run: npm run type-check
test:
needs: skip-ci-check
runs-on: ubuntu-latest
if: needs.skip-ci-check.outputs.should-skip != '1'
container:
image: node:20-bullseye
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: mirrormatch_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
env:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/mirrormatch_test?schema=public
NEXTAUTH_SECRET: test-secret-key-for-ci
NEXTAUTH_URL: http://localhost:3000
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npm run db:generate
- name: Run database migrations
run: npm run db:push
- name: Run tests
run: npm run test:ci
- name: Upload coverage reports
uses: codecov/codecov-action@v4
with:
files: ./coverage/coverage-final.json
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
continue-on-error: true
build:
needs: skip-ci-check
runs-on: ubuntu-latest
if: needs.skip-ci-check.outputs.should-skip != '1'
container:
image: node:20-bullseye
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npm run db:generate
- name: Build application
run: npm run build
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/mirrormatch?schema=public
NEXTAUTH_SECRET: test-secret-key-for-ci
NEXTAUTH_URL: http://localhost:3000
secret-scanning:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: ubuntu-latest
container:
image: zricethezav/gitleaks:latest
steps:
- name: Install Node.js for checkout action
run: |
apk add --no-cache nodejs npm curl
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Scan for secrets
run: gitleaks detect --source . --no-banner --redact --exit-code 0
continue-on-error: true
dependency-scan:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: ubuntu-latest
container:
image: aquasec/trivy:latest
steps:
- name: Install Node.js for checkout action
run: |
apk add --no-cache nodejs npm curl
- name: Check out code
uses: actions/checkout@v4
- name: Dependency vulnerability scan (Trivy)
run: |
trivy fs \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--timeout 10m \
--skip-dirs .git,node_modules \
--exit-code 0 \
.
- name: Secret scan (Trivy)
run: |
trivy fs \
--scanners secret \
--timeout 10m \
--skip-dirs .git,node_modules \
--exit-code 0 \
.
sast-scan:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: ubuntu-latest
container:
image: ubuntu:22.04
steps:
- name: Install Node.js for checkout action
run: |
apt-get update && apt-get install -y curl
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
- name: Check out code
uses: actions/checkout@v4
- name: Install Semgrep
run: |
apt-get update && apt-get install -y python3 python3-pip
pip3 install semgrep
- name: Run Semgrep scan
run: semgrep --config=auto --error
continue-on-error: true
workflow-summary:
runs-on: ubuntu-latest
needs: [lint-and-type-check, test, build, secret-scanning, dependency-scan, sast-scan]
if: always()
steps:
- name: Generate workflow summary
run: |
echo "## 🔍 CI Workflow Summary" >> $GITHUB_STEP_SUMMARY || true
echo "" >> $GITHUB_STEP_SUMMARY || true
echo "### Job Results" >> $GITHUB_STEP_SUMMARY || true
echo "" >> $GITHUB_STEP_SUMMARY || true
echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY || true
echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY || true
echo "| 📝 Lint & Type Check | ${{ needs.lint-and-type-check.result }} |" >> $GITHUB_STEP_SUMMARY || true
echo "| 🧪 Tests | ${{ needs.test.result }} |" >> $GITHUB_STEP_SUMMARY || true
echo "| 🏗️ Build | ${{ needs.build.result }} |" >> $GITHUB_STEP_SUMMARY || true
echo "| 🔐 Secret Scanning | ${{ needs.secret-scanning.result }} |" >> $GITHUB_STEP_SUMMARY || true
echo "| 📦 Dependency Scan | ${{ needs.dependency-scan.result }} |" >> $GITHUB_STEP_SUMMARY || true
echo "| 🔍 SAST Scan | ${{ needs.sast-scan.result }} |" >> $GITHUB_STEP_SUMMARY || true
echo "" >> $GITHUB_STEP_SUMMARY || true
echo "### 📊 Summary" >> $GITHUB_STEP_SUMMARY || true
echo "" >> $GITHUB_STEP_SUMMARY || true
echo "All security and validation checks have completed." >> $GITHUB_STEP_SUMMARY || true

251
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,251 @@
name: CI
on:
push:
branches: [master, main]
pull_request:
types: [opened, synchronize, reopened]
jobs:
# Check if CI should be skipped based on branch name or commit message
skip-ci-check:
runs-on: ubuntu-latest
outputs:
should-skip: ${{ steps.check.outputs.skip }}
steps:
- name: Check out code (for commit message)
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check if CI should be skipped
id: check
run: |
# Simple skip pattern: @skipci (case-insensitive)
SKIP_PATTERN="@skipci"
# Get branch name (works for both push and PR)
BRANCH_NAME="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
# Get commit message (works for both push and PR)
COMMIT_MSG="${GITHUB_EVENT_HEAD_COMMIT_MESSAGE:-}"
if [ -z "$COMMIT_MSG" ]; then
COMMIT_MSG="${GITHUB_EVENT_PULL_REQUEST_HEAD_COMMIT_MESSAGE:-}"
fi
if [ -z "$COMMIT_MSG" ]; then
COMMIT_MSG=$(git log -1 --pretty=%B 2>/dev/null || echo "")
fi
SKIP=0
# Check branch name (case-insensitive)
if echo "$BRANCH_NAME" | grep -qiF "$SKIP_PATTERN"; then
echo "Skipping CI: branch name contains '$SKIP_PATTERN'"
SKIP=1
fi
# Check commit message (case-insensitive)
if [ $SKIP -eq 0 ] && [ -n "$COMMIT_MSG" ]; then
if echo "$COMMIT_MSG" | grep -qiF "$SKIP_PATTERN"; then
echo "Skipping CI: commit message contains '$SKIP_PATTERN'"
SKIP=1
fi
fi
echo "skip=$SKIP" >> $GITHUB_OUTPUT
echo "Branch: $BRANCH_NAME"
echo "Commit: ${COMMIT_MSG:0:50}..."
echo "Skip CI: $SKIP"
lint-and-type-check:
needs: skip-ci-check
runs-on: ubuntu-latest
if: needs.skip-ci-check.outputs.should-skip != '1'
container:
image: node:20-bullseye
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Type check
run: npm run type-check
test:
needs: skip-ci-check
runs-on: ubuntu-latest
if: needs.skip-ci-check.outputs.should-skip != '1'
container:
image: node:20-bullseye
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: mirrormatch_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
env:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/mirrormatch_test?schema=public
NEXTAUTH_SECRET: test-secret-key-for-ci
NEXTAUTH_URL: http://localhost:3000
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npm run db:generate
- name: Run database migrations
run: npm run db:push
- name: Run tests
run: npm run test:ci
- name: Upload coverage reports
uses: codecov/codecov-action@v4
with:
files: ./coverage/coverage-final.json
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
continue-on-error: true
build:
needs: skip-ci-check
runs-on: ubuntu-latest
if: needs.skip-ci-check.outputs.should-skip != '1'
container:
image: node:20-bullseye
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npm run db:generate
- name: Build application
run: npm run build
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/mirrormatch?schema=public
NEXTAUTH_SECRET: test-secret-key-for-ci
NEXTAUTH_URL: http://localhost:3000
secret-scanning:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: ubuntu-latest
container:
image: zricethezav/gitleaks:latest
steps:
- name: Install Node.js for checkout action
run: |
apk add --no-cache nodejs npm curl
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Scan for secrets
run: gitleaks detect --source . --no-banner --redact --exit-code 0
continue-on-error: true
dependency-scan:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: ubuntu-latest
container:
image: aquasec/trivy:latest
steps:
- name: Install Node.js for checkout action
run: |
apk add --no-cache nodejs npm curl
- name: Check out code
uses: actions/checkout@v4
- name: Dependency vulnerability scan (Trivy)
run: |
trivy fs \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--timeout 10m \
--skip-dirs .git,node_modules \
--exit-code 0 \
.
- name: Secret scan (Trivy)
run: |
trivy fs \
--scanners secret \
--timeout 10m \
--skip-dirs .git,node_modules \
--exit-code 0 \
.
sast-scan:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: ubuntu-latest
container:
image: ubuntu:22.04
steps:
- name: Install Node.js for checkout action
run: |
apt-get update && apt-get install -y curl
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
- name: Check out code
uses: actions/checkout@v4
- name: Install Semgrep
run: |
apt-get update && apt-get install -y python3 python3-pip
pip3 install semgrep
- name: Run Semgrep scan
run: semgrep --config=auto --error
continue-on-error: true
workflow-summary:
runs-on: ubuntu-latest
needs: [lint-and-type-check, test, build, secret-scanning, dependency-scan, sast-scan]
if: always()
steps:
- name: Generate workflow summary
run: |
echo "## 🔍 CI Workflow Summary" >> $GITHUB_STEP_SUMMARY || true
echo "" >> $GITHUB_STEP_SUMMARY || true
echo "### Job Results" >> $GITHUB_STEP_SUMMARY || true
echo "" >> $GITHUB_STEP_SUMMARY || true
echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY || true
echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY || true
echo "| 📝 Lint & Type Check | ${{ needs.lint-and-type-check.result }} |" >> $GITHUB_STEP_SUMMARY || true
echo "| 🧪 Tests | ${{ needs.test.result }} |" >> $GITHUB_STEP_SUMMARY || true
echo "| 🏗️ Build | ${{ needs.build.result }} |" >> $GITHUB_STEP_SUMMARY || true
echo "| 🔐 Secret Scanning | ${{ needs.secret-scanning.result }} |" >> $GITHUB_STEP_SUMMARY || true
echo "| 📦 Dependency Scan | ${{ needs.dependency-scan.result }} |" >> $GITHUB_STEP_SUMMARY || true
echo "| 🔍 SAST Scan | ${{ needs.sast-scan.result }} |" >> $GITHUB_STEP_SUMMARY || true
echo "" >> $GITHUB_STEP_SUMMARY || true
echo "### 📊 Summary" >> $GITHUB_STEP_SUMMARY || true
echo "" >> $GITHUB_STEP_SUMMARY || true
echo "All security and validation checks have completed." >> $GITHUB_STEP_SUMMARY || true

52
.gitignore vendored Normal file
View File

@ -0,0 +1,52 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/app/generated/prisma
# Uploaded files
/public/uploads/*
!/public/uploads/.gitkeep
# Test coverage
/coverage
/.nyc_output

1
.prisma/client Symbolic link
View File

@ -0,0 +1 @@
../node_modules/.prisma/client

366
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,366 @@
# MirrorMatch Architecture
## Overview
MirrorMatch is a photo guessing game built with Next.js App Router, PostgreSQL, and NextAuth. Users upload photos with answer names, and other users guess to earn points.
## System Architecture
### Application Structure
```
mirrormatch/
├── app/ # Next.js App Router
│ ├── api/ # API route handlers (Next.js route handlers)
│ │ ├── admin/ # Admin-only API endpoints
│ │ ├── auth/ # NextAuth routes
│ │ ├── photos/ # Photo-related APIs
│ │ └── profile/ # User profile APIs
│ ├── admin/ # Admin panel (server component)
│ ├── leaderboard/ # Leaderboard page (server component)
│ ├── login/ # Login page (client component)
│ ├── photos/ # Photo listing and detail pages
│ ├── profile/ # User profile page (server component)
│ └── upload/ # Photo upload page (client component)
├── components/ # Reusable React components
│ ├── Navigation.tsx # Navigation bar (client component)
│ └── [others] # Form components, UI components
├── lib/ # Utility libraries and helpers
│ ├── prisma.ts # Prisma client singleton
│ ├── auth.ts # NextAuth configuration
│ ├── email.ts # Email sending utilities
│ └── utils.ts # Helper functions (hashing, etc.)
├── prisma/ # Database schema and migrations
│ ├── schema.prisma # Prisma schema definition
│ └── seed.ts # Database seeding script
├── types/ # TypeScript type definitions
│ └── next-auth.d.ts # NextAuth type extensions
└── middleware.ts # Next.js middleware for route protection
```
## Data Model
### Database Schema
#### User Model
```prisma
model User {
id String @id @default(cuid())
name String
email String @unique
passwordHash String
role Role @default(USER)
points Int @default(0)
createdAt DateTime @default(now())
uploadedPhotos Photo[] @relation("PhotoUploader")
guesses Guess[]
}
```
**Fields:**
- `id`: Unique identifier (CUID)
- `name`: User's display name
- `email`: Unique email address (used for login)
- `passwordHash`: Bcrypt-hashed password (never exposed)
- `role`: Either "ADMIN" or "USER"
- `points`: Accumulated points from correct guesses
- `createdAt`: Account creation timestamp
**Relations:**
- `uploadedPhotos`: All photos uploaded by this user
- `guesses`: All guesses made by this user
#### Photo Model
```prisma
model Photo {
id String @id @default(cuid())
uploaderId String
uploader User @relation("PhotoUploader", fields: [uploaderId], references: [id])
url String
answerName String
createdAt DateTime @default(now())
guesses Guess[]
}
```
**Fields:**
- `id`: Unique identifier (CUID)
- `uploaderId`: Foreign key to User who uploaded
- `url`: URL to the photo image
- `answerName`: The correct answer users should guess
- `createdAt`: Upload timestamp
**Relations:**
- `uploader`: The User who uploaded this photo
- `guesses`: All guesses made for this photo
#### Guess Model
```prisma
model Guess {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
photoId String
photo Photo @relation(fields: [photoId], references: [id])
guessText String
correct Boolean @default(false)
createdAt DateTime @default(now())
@@index([userId])
@@index([photoId])
}
```
**Fields:**
- `id`: Unique identifier (CUID)
- `userId`: Foreign key to User who made the guess
- `photoId`: Foreign key to Photo being guessed
- `guessText`: The user's guess text
- `correct`: Whether the guess matches the answer (case-insensitive)
- `createdAt`: Guess timestamp
**Relations:**
- `user`: The User who made this guess
- `photo`: The Photo being guessed
**Indexes:**
- Indexed on `userId` for fast user guess queries
- Indexed on `photoId` for fast photo guess queries
## Authentication Flow
### NextAuth Configuration
**Location:** `lib/auth.ts`
**Provider:** Credentials Provider (email + password)
**Flow:**
1. User submits email and password on `/login`
2. NextAuth calls `authorize` function
3. System looks up user by email in database
4. Compares provided password with stored `passwordHash` using bcrypt
5. If valid, creates JWT session with user data (id, email, name, role)
6. Session stored in JWT (no database session table)
**Session Data:**
- `id`: User ID
- `email`: User email
- `name`: User name
- `role`: User role (ADMIN | USER)
### Route Protection
**Location:** `middleware.ts`
**Public Routes:**
- `/login`
- `/api/auth/*` (NextAuth endpoints)
**Protected Routes:**
- All other routes require authentication
- `/admin/*` routes additionally require `role === "ADMIN"`
**Implementation:**
- Uses NextAuth `withAuth` middleware
- Checks JWT token on each request
- Redirects unauthenticated users to `/login`
- Redirects non-admin users trying to access admin routes to home
## Application Flows
### 1. Admin Creates User
**Flow:**
1. Admin navigates to `/admin`
2. Fills out user creation form (name, email, password, role)
3. Form submits to `POST /api/admin/users`
4. API route:
- Verifies admin session
- Checks if email already exists
- Hashes password with bcrypt
- Creates User record in database
5. Admin sees new user in user list
**API Route:** `app/api/admin/users/route.ts`
**Component:** `components/CreateUserForm.tsx`
### 2. User Login
**Flow:**
1. User navigates to `/login`
2. Enters email and password
3. Client calls NextAuth `signIn("credentials", ...)`
4. NextAuth validates credentials via `lib/auth.ts`
5. On success, redirects to `/photos`
6. Session stored in JWT cookie
**Page:** `app/login/page.tsx` (client component)
### 3. User Changes Password
**Flow:**
1. User navigates to `/profile`
2. Enters current password and new password
3. Form submits to `POST /api/profile/change-password`
4. API route:
- Verifies session
- Validates current password against stored hash
- Hashes new password
- Updates User record
5. User sees success message
**API Route:** `app/api/profile/change-password/route.ts`
**Component:** `components/ChangePasswordForm.tsx`
### 4. Photo Upload
**Flow:**
1. User navigates to `/upload`
2. Enters photo URL and answer name
3. Form submits to `POST /api/photos`
4. API route:
- Verifies session
- Creates Photo record with `uploaderId`, `url`, `answerName`
- Queries all other users (excluding uploader)
- Sends email notifications to all other users (async, non-blocking)
5. User redirected to photo detail page
**API Route:** `app/api/photos/route.ts`
**Email:** `lib/email.ts` - `sendNewPhotoEmail()`
**Page:** `app/upload/page.tsx` (client component)
### 5. Email Notifications
**Implementation:** `lib/email.ts`
**Development Mode:**
- Uses Ethereal Email (test SMTP service)
- Provides preview URLs in console
- Falls back to console transport if Ethereal unavailable
**Production Mode:**
- Uses SMTP server (configured via env vars)
- Sends HTML and plaintext emails
- Includes link to photo guess page
**Email Content:**
- Subject: "New Photo Ready to Guess!"
- Body: Includes uploader name, link to `/photos/[id]`
- Sent asynchronously (doesn't block photo creation)
### 6. Guess Submission
**Flow:**
1. User views photo at `/photos/[id]`
2. User enters guess text
3. Form submits to `POST /api/photos/[photoId]/guess`
4. API route:
- Verifies session
- Checks if user already has correct guess (prevent duplicate points)
- Normalizes guess text and answer (trim, lowercase)
- Compares normalized strings
- Creates Guess record with `correct` boolean
- If correct: increments user's points by 1
5. Page refreshes to show feedback
**API Route:** `app/api/photos/[photoId]/guess/route.ts`
**Component:** `components/GuessForm.tsx`
**Page:** `app/photos/[id]/page.tsx` (server component)
**Guess Matching:**
- Case-insensitive comparison
- Trims whitespace
- Exact match required (no fuzzy matching)
### 7. Leaderboard
**Flow:**
1. User navigates to `/leaderboard`
2. Server component queries all users
3. Orders by `points DESC`
4. Renders table with rank, name, email, points
5. Highlights current user's row
**Page:** `app/leaderboard/page.tsx` (server component)
**Query:** `prisma.user.findMany({ orderBy: { points: "desc" } })`
## Code Organization Guidelines
### Where to Put Code
**Server Actions:**
- Use Next.js route handlers (`app/api/*/route.ts`) for API endpoints
- Consider server actions (`app/actions.ts`) for form submissions if preferred pattern
**Route Handlers:**
- All API endpoints in `app/api/*/route.ts`
- Use `GET`, `POST`, `PUT`, `DELETE` exports as needed
- Always verify session and authorization
- Return JSON responses
**Shared Utilities:**
- Database access: `lib/prisma.ts` (Prisma client singleton)
- Auth config: `lib/auth.ts`
- Email: `lib/email.ts`
- General helpers: `lib/utils.ts` (hashing, string normalization, etc.)
**Components:**
- Reusable UI components: `components/`
- Page-specific components can live in `app/[page]/` if not reused
- Prefer server components, use `"use client"` only when needed
**Type Definitions:**
- NextAuth types: `types/next-auth.d.ts`
- Shared types: `types/` directory
- Component prop types: inline or in component file
## Database Access Pattern
**Always use Prisma:**
```typescript
import { prisma } from "@/lib/prisma"
// Example query
const user = await prisma.user.findUnique({
where: { email: "user@example.com" }
})
```
**Never:**
- Use raw SQL queries
- Use other ORMs
- Access database directly without Prisma
## Security Considerations
### Password Security
- All passwords hashed with bcrypt (10 rounds)
- Password hashes never exposed in API responses
- Password changes require current password verification
### Authorization
- All mutations check user session
- Admin routes verify `role === "ADMIN"`
- Users can only modify their own data (except admins)
- Photo uploads require authentication
- Guess submissions require authentication
### Input Validation
- Validate all user inputs
- Sanitize before database operations
- Use Prisma's type safety to prevent SQL injection
- Normalize guess text before comparison
## Important Notes
- **Always read this document and README.md before making architectural changes**
- **Update this document when adding new features or changing data flows**
- **Keep documentation in sync with code changes**
- **Follow established patterns for consistency**
---
**Last Updated:** When architecture changes, update this file and notify the team.

266
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,266 @@
# Contributing to MirrorMatch
Thank you for your interest in contributing to MirrorMatch! This document outlines the coding conventions, development workflow, and expectations for contributors.
## Getting Started
### Local Development Setup
1. **Clone the repository** (if applicable) or navigate to the project directory
2. **Install dependencies:**
```bash
npm install
```
3. **Set up environment variables:**
- Copy `env.example` to `.env`
- Configure `DATABASE_URL`, `NEXTAUTH_SECRET`, and email settings
- See `README.md` for detailed setup instructions
4. **Set up the database:**
```bash
npm run db:generate
npm run db:migrate
npm run db:seed
```
5. **Start the development server:**
```bash
npm run dev
```
6. **Open the app:**
- Navigate to [http://localhost:3000](http://localhost:3000)
- Login with default admin: `admin@mirrormatch.com` / `admin123`
## Coding Conventions
### TypeScript
- **Use TypeScript for all code** - no JavaScript files
- **Strict mode enabled** - follow TypeScript best practices
- **Avoid `any` types** - use proper types or `unknown` when necessary
- **Define types explicitly** - don't rely on type inference when it reduces clarity
### Code Style
- **Follow existing patterns** - maintain consistency with the codebase
- **Use meaningful names** - variables, functions, and components should be self-documenting
- **Keep functions focused** - single responsibility principle
- **Prefer small components** - break down large components into smaller, composable pieces
### Folder Structure
Follow the established structure:
```
app/ # Next.js App Router routes
components/ # Reusable React components
lib/ # Utility functions and helpers
prisma/ # Database schema and migrations
types/ # TypeScript type definitions
```
### Naming Conventions
- **Components:** PascalCase (e.g., `Navigation.tsx`, `GuessForm.tsx`)
- **Files:** Match component/function names (e.g., `lib/utils.ts`)
- **Variables/Functions:** camelCase (e.g., `getUserById`, `photoUrl`)
- **Constants:** UPPER_SNAKE_CASE (e.g., `MAX_POINTS`)
- **Types/Interfaces:** PascalCase (e.g., `User`, `PhotoData`)
### Linting and Formatting
- **ESLint** is configured - run `npm run lint` to check for issues
- **Fix linting errors** before committing
- **Follow Next.js ESLint rules** - the project uses `eslint-config-next`
## Development Workflow
### Before Making Changes
1. **Read the documentation:**
- `README.md` - Project overview and setup
- `ARCHITECTURE.md` - System design and data flows
- `.cursor/rules/mirrormatch.mdc` - Project rules and guidelines
- `CONTRIBUTING.md` (this file) - Coding conventions
- `SECURITY.md` - Security practices
2. **Understand the codebase:**
- Review related files before modifying
- Understand the data model in `prisma/schema.prisma`
- Check existing patterns for similar features
3. **Plan your changes:**
- Break down large changes into smaller, incremental steps
- Consider impact on existing features
- Think about security implications
### Making Changes
1. **Create a focused change:**
- One feature or fix per commit when possible
- Keep changes small and reviewable
- Test your changes locally
2. **Follow established patterns:**
- Use existing component patterns
- Follow the same structure for new API routes
- Maintain consistency with existing code style
3. **Write clear code:**
- Add comments for complex logic
- Use descriptive variable names
- Keep functions and components focused
### Adding New Features
When adding new features:
1. **Update documentation:**
- Update `README.md` if user-facing behavior changes
- Update `ARCHITECTURE.md` if system design changes
- Update `.cursor/rules/mirrormatch.mdc` if rules change
- Update `SECURITY.md` if security practices change
2. **Follow security guidelines:**
- Validate all user inputs
- Check authorization for all mutations
- Never expose sensitive data
- See `SECURITY.md` for details
3. **Test your changes:**
- Test the feature manually
- Verify error handling
- Check edge cases
- Ensure no regressions
4. **Update types:**
- Add TypeScript types for new data structures
- Update Prisma schema if database changes needed
- Run `npm run db:generate` after schema changes
### Commit Messages
Write clear, descriptive commit messages:
- **Format:** Use imperative mood (e.g., "Add photo upload validation")
- **Be specific:** Describe what changed and why
- **Reference issues:** If applicable, reference issue numbers
**Examples:**
- ✅ "Add password strength validation to change password form"
- ✅ "Fix case-insensitive guess matching bug"
- ✅ "Update README with new environment variables"
- ❌ "Fixed stuff"
- ❌ "Updates"
### Code Review Checklist
Before submitting changes, ensure:
- [ ] Code follows TypeScript and style conventions
- [ ] All linting errors are fixed
- [ ] Documentation is updated (if needed)
- [ ] Security considerations are addressed
- [ ] Changes are tested locally
- [ ] Commit messages are clear and descriptive
## Testing
### Manual Testing
- Test all user flows:
- User login and authentication
- Photo upload and email notifications
- Guess submission and point awarding
- Admin user management
- Password changes
- Test edge cases:
- Invalid inputs
- Unauthorized access attempts
- Missing data
- Network errors
### Database Testing
- Test with fresh database migrations
- Verify seed script works correctly
- Test with various data states
## Adding Tests (Future)
If tests are added to the project:
- **Unit tests:** Test utility functions and helpers
- **Integration tests:** Test API routes and database operations
- **E2E tests:** Test complete user flows
- **Update test coverage** when adding new features
## Documentation Updates
### When to Update Documentation
- **README.md:** When setup steps, features, or usage changes
- **ARCHITECTURE.md:** When system design, data flows, or patterns change
- **CONTRIBUTING.md:** When coding conventions or workflow changes
- **SECURITY.md:** When security practices or vulnerabilities change
- **.cursor/rules:** When project rules or guidelines change
### Documentation Standards
- **Keep docs up to date** - outdated docs are worse than no docs
- **Be clear and concise** - use examples when helpful
- **Cross-reference** - link to related documentation
- **Update all relevant docs** - don't update just one file
## Guidelines for AI Tools (Cursor)
If you're using AI tools like Cursor to help with development:
1. **Respect these conventions:**
- Follow the coding style and patterns outlined here
- Use the established folder structure
- Maintain consistency with existing code
2. **Prefer incremental changes:**
- Make small, focused changes rather than large refactors
- Test each change before moving to the next
- Keep commits focused and reviewable
3. **Update documentation:**
- When AI generates code that changes behavior, update relevant docs
- Keep architecture docs in sync with code changes
- Document any new patterns or conventions
4. **Security awareness:**
- Never generate code that exposes sensitive data
- Always include authorization checks
- Validate all user inputs
- Follow security guidelines in `SECURITY.md`
5. **Read before generating:**
- Always read relevant documentation first
- Understand existing patterns before creating new code
- Check similar features for consistency
## Questions?
If you have questions about:
- **Setup issues:** Check `README.md`
- **Architecture questions:** Check `ARCHITECTURE.md`
- **Security concerns:** Check `SECURITY.md`
- **Coding conventions:** Check this file and `.cursor/rules/mirrormatch.mdc`
## Important Reminders
- **Always read documentation before making large changes**
- **Keep documentation updated when behavior changes**
- **Follow security guidelines strictly**
- **Test your changes before committing**
- **Write clear commit messages**
- **Maintain consistency with existing code**
---
**Last Updated:** When conventions or workflow change, update this file and notify contributors.

295
README.md Normal file
View File

@ -0,0 +1,295 @@
# MirrorMatch
A photo guessing game where users upload photos and other users guess who is in the picture to earn points. Built with Next.js, PostgreSQL, and NextAuth.
## 📚 Important: Read Documentation First
**Before making code changes, please read:**
- **`.cursor/rules/mirrormatch.mdc`** - Project rules and guidelines
- **`ARCHITECTURE.md`** - System design and data flows
- **`CONTRIBUTING.md`** - Coding conventions and workflow
- **`SECURITY.md`** - Security practices
- **This README** - Setup and usage
**Keep documentation updated:** When you modify code that changes behavior or architecture, update the relevant documentation files to keep them in sync.
## Features
- **User Management**: Admin can create users with email/password authentication
- **Photo Upload**: Users can upload photos with answer names
- **Guessing Game**: Users guess who is in photos to earn points
- **Email Notifications**: Users receive emails when new photos are uploaded
- **Leaderboard**: Track user points and rankings
- **Profile Management**: Users can view their points and change passwords
## Tech Stack
- **Framework**: Next.js 16 (App Router)
- **Language**: TypeScript
- **Database**: PostgreSQL
- **ORM**: Prisma
- **Authentication**: NextAuth.js (Credentials Provider)
- **Styling**: Tailwind CSS
- **Email**: Nodemailer (Ethereal in dev, SMTP in production)
## Prerequisites
- Node.js 18+ and npm
- PostgreSQL database (local or remote)
- For production: SMTP email server credentials
## Installation
1. **Clone the repository** (if applicable) or navigate to the project directory:
```bash
cd mirrormatch
```
2. **Install dependencies**:
```bash
npm install
```
3. **Set up environment variables**:
Create a `.env` file in the root directory with the following variables:
```env
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/mirrormatch?schema=public"
# NextAuth
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32"
# Email Configuration (for production)
SMTP_HOST="smtp.example.com"
SMTP_PORT="587"
SMTP_USER="your-email@example.com"
SMTP_PASSWORD="your-email-password"
SMTP_FROM="MirrorMatch <noreply@mirrormatch.com>"
```
**Generate NEXTAUTH_SECRET**:
```bash
openssl rand -base64 32
```
**Note**: In development, emails will use Ethereal (test emails) or console logging. No SMTP config is needed for dev mode.
4. **Set up the database**:
```bash
# Generate Prisma Client
npm run db:generate
# Run migrations
npm run db:migrate
# Or push schema directly (for development)
npm run db:push
```
5. **Seed the database** (creates default admin user):
```bash
npm run db:seed
```
This creates an admin user with:
- Email: `admin@mirrormatch.com`
- Password: `admin123`
- **⚠️ Important**: Change this password after first login!
## Basic Usage
### Workflow Overview
1. **Admin creates users:**
- Admin logs in and navigates to `/admin`
- Creates new users with email, password, and role
- Users receive temporary passwords
2. **Users log in:**
- Users navigate to `/login`
- Enter email and password
- Access the main application
3. **Users upload photos:**
- Navigate to `/upload`
- Enter photo URL and answer name (the correct name to guess)
- Photo is saved and emails are sent to all other users
4. **Users guess photos:**
- View photos on `/photos` page
- Click a photo to view details
- Submit guesses for who is in the photo
- Earn points for correct guesses (case-insensitive matching)
5. **Leaderboard:**
- View rankings on `/leaderboard` page
- See all users sorted by points
- Track your own position
### Key Features
- **Admin Panel:** Create and manage users, reset passwords
- **Photo Upload:** Upload photos with answer names
- **Guessing System:** Submit guesses and earn points
- **Email Notifications:** Get notified when new photos are uploaded
- **Leaderboard:** Track user rankings by points
- **Profile Management:** View points and change password
## Running the Application
### Development
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
### Production
```bash
npm run build
npm start
```
## Database Commands
- `npm run db:generate` - Generate Prisma Client
- `npm run db:migrate` - Run database migrations
- `npm run db:push` - Push schema changes to database (dev only)
- `npm run db:studio` - Open Prisma Studio (database GUI)
- `npm run db:seed` - Seed database with initial admin user
## Creating the First Admin User
The seed script automatically creates an admin user. If you need to create one manually:
1. Run the seed script: `npm run db:seed`
2. Or use Prisma Studio: `npm run db:studio` and create a user with `role: "ADMIN"`
## Project Structure
```
mirrormatch/
├── app/ # Next.js App Router pages
│ ├── api/ # API routes
│ │ ├── admin/ # Admin API routes
│ │ ├── auth/ # NextAuth routes
│ │ ├── photos/ # Photo-related API routes
│ │ └── profile/ # Profile API routes
│ ├── admin/ # Admin panel page
│ ├── leaderboard/ # Leaderboard page
│ ├── login/ # Login page
│ ├── photos/ # Photos listing and detail pages
│ ├── profile/ # User profile page
│ └── upload/ # Photo upload page
├── components/ # React components
├── lib/ # Utility libraries
│ ├── auth.ts # NextAuth configuration
│ ├── email.ts # Email sending utilities
│ ├── prisma.ts # Prisma client instance
│ └── utils.ts # Helper functions
├── prisma/ # Prisma schema and migrations
│ ├── schema.prisma # Database schema
│ └── seed.ts # Database seed script
└── types/ # TypeScript type definitions
```
## Features Overview
### Authentication
- Email/password login via NextAuth
- Protected routes with middleware
- Role-based access control (ADMIN/USER)
### Admin Panel (`/admin`)
- View all users
- Create new users
- Reset user passwords
- View user points and roles
### Photo Upload (`/upload`)
- Upload photos via URL
- Set answer name for guessing
- Automatically sends email notifications to all other users
### Photo Guessing (`/photos/[id]`)
- View photo and uploader info
- Submit guesses
- Get instant feedback (correct/wrong)
- Earn points for correct guesses
- Case-insensitive matching
### Leaderboard (`/leaderboard`)
- View all users ranked by points
- See your own ranking highlighted
### Profile (`/profile`)
- View your points and account info
- Change password (requires current password)
## Email Configuration
### Development
In development mode, the app uses:
- **Ethereal Email** (if available) - provides test email accounts with preview URLs
- **Console transport** (fallback) - logs emails to console
Check the console for email preview URLs when using Ethereal.
### Production
Set up SMTP credentials in `.env`:
- `SMTP_HOST` - Your SMTP server hostname
- `SMTP_PORT` - SMTP port (usually 587 for TLS, 465 for SSL)
- `SMTP_USER` - SMTP username
- `SMTP_PASSWORD` - SMTP password
- `SMTP_FROM` - From address for emails
## Security Notes
- Passwords are hashed using bcrypt
- NextAuth handles session management
- Middleware protects routes
- Admin routes are restricted to ADMIN role
- SQL injection protection via Prisma
## Troubleshooting
### Database Connection Issues
- Verify `DATABASE_URL` is correct
- Ensure PostgreSQL is running
- Check database exists and user has permissions
### Email Not Sending
- In dev: Check console for Ethereal preview URLs
- In production: Verify SMTP credentials
- Check email service logs
### Authentication Issues
- Verify `NEXTAUTH_SECRET` is set
- Check `NEXTAUTH_URL` matches your app URL
- Clear browser cookies if needed
## Documentation
This project maintains comprehensive documentation:
- **README.md** (this file) - Setup, installation, and basic usage
- **ARCHITECTURE.md** - System architecture, data models, and application flows
- **CONTRIBUTING.md** - Coding conventions and development workflow
- **SECURITY.md** - Security practices and vulnerability reporting
- **.cursor/rules/mirrormatch.mdc** - Project rules for AI tools and developers
**For Developers and AI Tools:**
**⚠️ Important:** Before making code changes, read `.cursor/rules/mirrormatch.mdc`, `ARCHITECTURE.md`, `CONTRIBUTING.md`, `SECURITY.md`, and this README. Keep them updated when behavior or architecture changes.
- Always read the relevant documentation before making changes
- Update documentation when behavior or architecture changes
- Keep all documentation files in sync with code changes
- Follow the guidelines in each document
## License
MIT

203
SECURITY.md Normal file
View File

@ -0,0 +1,203 @@
# Security Policy
## Supported Versions
We actively support security updates for the current version of MirrorMatch. Please report vulnerabilities for the latest codebase.
## Reporting a Vulnerability
If you discover a security vulnerability in MirrorMatch, please report it responsibly:
**Email:** [Add your security contact email here]
**Subject:** Security Vulnerability in MirrorMatch
Please include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if available)
We will acknowledge receipt within 48 hours and provide an update on the status of the vulnerability within 7 days.
**Please do not:**
- Open public GitHub issues for security vulnerabilities
- Discuss vulnerabilities publicly until they are resolved
- Use security vulnerabilities for malicious purposes
## Security Practices
### Secret Management
**Never commit secrets to the repository:**
- Database connection strings
- API keys
- Passwords
- NEXTAUTH_SECRET
- SMTP credentials
- Any other sensitive information
**Use environment variables:**
- Store all secrets in `.env` file (not committed)
- Use `env.example` as a template (no real secrets)
- Never log secrets or include them in error messages
### Password Security
**Password Hashing:**
- All passwords are hashed using **bcrypt** with 10 rounds
- Never store plain text passwords
- Never log passwords or password hashes
- Use the `hashPassword()` function from `lib/utils.ts` for all password hashing
**Password Validation:**
- Require minimum password length (currently 6 characters)
- Consider adding strength requirements in the future
- Always validate passwords on the server side
**Password Exposure:**
- Never return password hashes in API responses
- Never include passwords in logs or error messages
- Use secure password reset flows (if implemented)
### Authentication & Authorization
**Session Management:**
- Use NextAuth.js for session management
- Sessions stored in JWT tokens (no database session table)
- JWT tokens include user ID, email, name, and role
- Never trust client-provided user IDs - always use session data
**Route Protection:**
- All app routes (except `/login`) require authentication
- Admin routes (`/admin/*`) require `role === "ADMIN"`
- Middleware enforces authentication and authorization
- API routes must verify session before processing requests
**Authorization Checks:**
- Users can only modify their own data (except admins)
- Admins can modify any user data
- Photo uploads require authentication
- Guess submissions require authentication
- Always verify user identity from session, not from request body
### Input Validation
**Validate All Inputs:**
- Validate all user inputs on the server side
- Never trust client-side validation alone
- Sanitize inputs before database operations
- Use Prisma's type safety to prevent SQL injection
**Input Sanitization:**
- Trim whitespace from text inputs
- Normalize strings before comparison (case-insensitive matching)
- Validate email formats
- Validate URL formats for photo uploads
- Check for required fields
### Database Security
**SQL Injection Prevention:**
- Use Prisma ORM exclusively - never write raw SQL
- Prisma provides parameterized queries automatically
- Never concatenate user input into SQL queries
**Database Access:**
- Use the Prisma client singleton from `lib/prisma.ts`
- Never expose database credentials
- Use connection pooling (handled by Prisma)
- Limit database user permissions in production
### API Security
**API Route Protection:**
- All API routes must verify user session
- Check authorization before processing mutations
- Return appropriate error messages (don't leak information)
- Use HTTP status codes correctly (401 for unauthorized, 403 for forbidden)
**Error Handling:**
- Don't expose internal errors to clients
- Log errors server-side for debugging
- Return generic error messages to clients
- Never include stack traces in production responses
### Email Security
**Email Content:**
- Don't include sensitive information in emails
- Use secure email transport (TLS/SSL) in production
- Validate email addresses before sending
- Don't expose user emails unnecessarily
### Data Privacy
**User Data:**
- Only collect necessary user data
- Don't expose user emails or personal info unnecessarily
- Respect user privacy in leaderboards and public views
- Consider GDPR compliance for production deployments
**Password Hashes:**
- Never expose password hashes
- Never return password hashes in API responses
- Never log password hashes
## Security Checklist for New Features
When adding new features, ensure:
- [ ] All user inputs are validated
- [ ] Authorization is checked for all mutations
- [ ] Passwords are hashed (if applicable)
- [ ] Secrets are not exposed
- [ ] Error messages don't leak information
- [ ] Database queries use Prisma (no raw SQL)
- [ ] Session is verified for protected routes
- [ ] Role checks are in place for admin features
## Security Updates
**When security practices change:**
- Update this document
- Update `ARCHITECTURE.md` if architecture changes
- Notify the team of security-related changes
- Document any new security requirements
## Known Security Considerations
**Current Implementation:**
- ✅ Passwords hashed with bcrypt
- ✅ Session management via NextAuth
- ✅ Route protection via middleware
- ✅ Role-based access control
- ✅ Input validation on server side
- ✅ SQL injection prevention via Prisma
**Future Considerations:**
- Consider rate limiting for API routes
- Consider CSRF protection for forms
- Consider adding password strength requirements
- Consider adding account lockout after failed login attempts
- Consider adding email verification for new users
- Consider adding audit logging for admin actions
## Important Reminders
- **Always read this document and ARCHITECTURE.md before making security-related changes**
- **Update this document when security practices change**
- **Never commit secrets to the repository**
- **Always validate and authorize user actions**
- **Use Prisma for all database access**
- **Follow authentication and authorization patterns strictly**
## Resources
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [Next.js Security Best Practices](https://nextjs.org/docs/app/building-your-application/configuring/security-headers)
- [Prisma Security](https://www.prisma.io/docs/guides/performance-and-optimization/connection-management)
- [NextAuth.js Security](https://next-auth.js.org/configuration/options#security)
---
**Last Updated:** When security practices change, update this file and notify the team.

320
TESTING.md Normal file
View File

@ -0,0 +1,320 @@
# Testing Guide for MirrorMatch
This guide will help you set up and test the MirrorMatch application locally.
## Prerequisites Check
Before starting, ensure you have:
- ✅ **Node.js 18+** installed (`node --version`)
- ✅ **npm** installed (`npm --version`)
- ✅ **PostgreSQL** installed and running (`psql --version`)
- ✅ **Git** (if cloning the repository)
## Step-by-Step Setup
### 1. Install Dependencies
```bash
cd mirrormatch
npm install
```
This installs all required packages including Next.js, Prisma, NextAuth, etc.
### 2. Set Up PostgreSQL Database
**Option A: Using Docker (Recommended for Quick Testing)**
```bash
# Start PostgreSQL in Docker
docker run --name mirrormatch-db \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=mirrormatch \
-p 5432:5432 \
-d postgres:15
# Wait a few seconds for PostgreSQL to start
```
**Option B: Local PostgreSQL Installation**
```bash
# Create database (if not exists)
createdb mirrormatch
# Or using psql
psql -U postgres -c "CREATE DATABASE mirrormatch;"
```
### 3. Configure Environment Variables
Create a `.env` file in the project root:
```bash
cp env.example .env
```
Then edit `.env` and update the following:
```env
# Database - Update with your PostgreSQL credentials
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mirrormatch?schema=public"
# NextAuth - Generate a secret key
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-key-here"
# Email (optional for dev - uses Ethereal by default)
# Leave SMTP settings empty for development
```
**Generate NEXTAUTH_SECRET:**
```bash
openssl rand -base64 32
```
Copy the output and paste it as the `NEXTAUTH_SECRET` value in `.env`.
### 4. Set Up Database Schema
```bash
# Generate Prisma Client
npm run db:generate
# Create database tables (choose one method)
npm run db:migrate # Creates migration files (recommended)
# OR
npm run db:push # Pushes schema directly (faster for dev)
```
### 5. Seed Initial Data
```bash
npm run db:seed
```
This creates the default admin user:
- **Email:** `admin@mirrormatch.com`
- **Password:** `admin123`
⚠️ **Important:** Change this password after first login!
### 6. Start the Development Server
```bash
npm run dev
```
The app should start at [http://localhost:3000](http://localhost:3000)
## Testing the Application
### Initial Login
1. Open [http://localhost:3000](http://localhost:3000)
2. You'll be redirected to `/login`
3. Login with:
- Email: `admin@mirrormatch.com`
- Password: `admin123`
### Test Admin Features
1. **Navigate to Admin Panel:**
- Click "Admin" in the navigation bar
- You should see the admin panel
2. **Create a Test User:**
- Fill in the form:
- Name: `Test User`
- Email: `test@example.com`
- Password: `test123`
- Role: `USER`
- Click "Create User"
- Verify the user appears in the user list
3. **Reset Password:**
- In the user list, enter a new password for any user
- Click "Reset"
- Verify success message
### Test User Features
1. **Logout and Login as Test User:**
- Click "Logout"
- Login with `test@example.com` / `test123`
2. **Upload a Photo:**
- Navigate to "Upload" in the navigation
- Enter a photo URL (e.g., `https://via.placeholder.com/800x600`)
- Enter an answer name (e.g., `John Doe`)
- Click "Upload Photo"
- Check the console for email notification logs (dev mode)
3. **View Photos:**
- Navigate to "Photos"
- You should see the uploaded photo
- Click on a photo to view details
4. **Submit a Guess:**
- On a photo detail page, enter a guess
- Try wrong guess first (e.g., `Jane Doe`)
- See "Wrong guess" message
- Try correct guess (e.g., `John Doe`)
- See "Correct!" message and points increment
5. **Check Leaderboard:**
- Navigate to "Leaderboard"
- Verify users are ranked by points
- Your user should be highlighted
6. **Change Password:**
- Navigate to "Profile"
- Scroll to "Change Password"
- Enter current password and new password
- Verify success message
### Test Email Notifications (Development)
In development mode, emails use Ethereal Email:
1. **Upload a photo** as one user
2. **Check the console** for email logs
3. Look for a message like:
```
Email sent (dev mode):
Preview URL: https://ethereal.email/message/...
```
4. **Open the preview URL** to see the email
## Common Issues and Solutions
### Database Connection Error
**Error:** `Can't reach database server`
**Solutions:**
- Verify PostgreSQL is running: `pg_isready` or `docker ps`
- Check `DATABASE_URL` in `.env` matches your PostgreSQL setup
- Ensure database exists: `psql -l | grep mirrormatch`
### Prisma Client Not Generated
**Error:** `@prisma/client did not initialize yet`
**Solution:**
```bash
npm run db:generate
```
### Migration Errors
**Error:** `Migration failed`
**Solutions:**
- Reset database (⚠️ deletes all data):
```bash
npm run db:push -- --force-reset
```
- Or create fresh migration:
```bash
npm run db:migrate
```
### NextAuth Secret Missing
**Error:** `NEXTAUTH_SECRET is not set`
**Solution:**
- Generate secret: `openssl rand -base64 32`
- Add to `.env`: `NEXTAUTH_SECRET="generated-secret"`
### Port Already in Use
**Error:** `Port 3000 is already in use`
**Solutions:**
- Kill the process using port 3000:
```bash
lsof -ti:3000 | xargs kill -9
```
- Or change port in `package.json` dev script
## Testing Checklist
Use this checklist to verify all features:
- [ ] Admin can login
- [ ] Admin can create users
- [ ] Admin can reset user passwords
- [ ] Regular user can login
- [ ] User can upload photos
- [ ] Email notifications are sent (check console)
- [ ] User can view photos list
- [ ] User can view photo details
- [ ] User can submit guesses
- [ ] Wrong guesses show error message
- [ ] Correct guesses award points
- [ ] Leaderboard shows users ranked by points
- [ ] User can change password
- [ ] User can logout
- [ ] Protected routes require authentication
- [ ] Admin routes require ADMIN role
## Database Management
### View Database in Prisma Studio
```bash
npm run db:studio
```
Opens a GUI at [http://localhost:5555](http://localhost:5555) to browse and edit data.
### Reset Database
⚠️ **Warning:** This deletes all data!
```bash
npm run db:push -- --force-reset
npm run db:seed
```
### View Database Schema
```bash
cat prisma/schema.prisma
```
## Development Tips
1. **Hot Reload:** Next.js automatically reloads on file changes
2. **Console Logs:** Check browser console and terminal for errors
3. **Database Changes:** After modifying `schema.prisma`, run:
```bash
npm run db:generate
npm run db:push
```
4. **Email Testing:** In dev mode, emails are logged to console or use Ethereal preview URLs
## Next Steps
After testing locally:
1. Review the codebase structure
2. Read `ARCHITECTURE.md` for system design
3. Read `CONTRIBUTING.md` for coding conventions
4. Read `SECURITY.md` for security practices
5. Start developing new features!
## Getting Help
- Check `README.md` for general setup
- Check `ARCHITECTURE.md` for system design questions
- Check `CONTRIBUTING.md` for development workflow
- Check console/terminal for error messages
---
**Happy Testing! 🎯**

View File

@ -0,0 +1,73 @@
import { render, screen } from '@testing-library/react'
import { useSession } from 'next-auth/react'
import Navigation from '@/components/Navigation'
jest.mock('next-auth/react')
describe('Navigation', () => {
it('should not render when user is not logged in', () => {
;(useSession as jest.Mock).mockReturnValue({
data: null,
status: 'unauthenticated',
})
const { container } = render(<Navigation />)
expect(container.firstChild).toBeNull()
})
it('should render navigation when user is logged in', () => {
;(useSession as jest.Mock).mockReturnValue({
data: {
user: {
name: 'Test User',
email: 'test@example.com',
role: 'USER',
},
},
status: 'authenticated',
})
render(<Navigation />)
expect(screen.getByText('MirrorMatch')).toBeInTheDocument()
expect(screen.getByText('Photos')).toBeInTheDocument()
expect(screen.getByText('Upload')).toBeInTheDocument()
expect(screen.getByText('Leaderboard')).toBeInTheDocument()
expect(screen.getByText('Profile')).toBeInTheDocument()
expect(screen.getByText('Hello, Test User')).toBeInTheDocument()
})
it('should show admin link for ADMIN users', () => {
;(useSession as jest.Mock).mockReturnValue({
data: {
user: {
name: 'Admin User',
email: 'admin@example.com',
role: 'ADMIN',
},
},
status: 'authenticated',
})
render(<Navigation />)
expect(screen.getByText('Admin')).toBeInTheDocument()
})
it('should not show admin link for regular users', () => {
;(useSession as jest.Mock).mockReturnValue({
data: {
user: {
name: 'Test User',
email: 'test@example.com',
role: 'USER',
},
},
status: 'authenticated',
})
render(<Navigation />)
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,65 @@
import { hashPassword, normalizeString } from '@/lib/utils'
import bcrypt from 'bcryptjs'
describe('lib/utils', () => {
describe('hashPassword', () => {
it('should hash a password', async () => {
const password = 'testpassword123'
const hash = await hashPassword(password)
expect(hash).toBeDefined()
expect(hash).not.toBe(password)
expect(hash.length).toBeGreaterThan(0)
})
it('should produce different hashes for the same password', async () => {
const password = 'testpassword123'
const hash1 = await hashPassword(password)
const hash2 = await hashPassword(password)
// Hashes should be different (due to salt)
expect(hash1).not.toBe(hash2)
// But both should verify correctly
expect(await bcrypt.compare(password, hash1)).toBe(true)
expect(await bcrypt.compare(password, hash2)).toBe(true)
})
it('should verify passwords correctly', async () => {
const password = 'testpassword123'
const hash = await hashPassword(password)
expect(await bcrypt.compare(password, hash)).toBe(true)
expect(await bcrypt.compare('wrongpassword', hash)).toBe(false)
})
})
describe('normalizeString', () => {
it('should trim whitespace', () => {
expect(normalizeString(' test ')).toBe('test')
expect(normalizeString(' test')).toBe('test')
expect(normalizeString('test ')).toBe('test')
})
it('should convert to lowercase', () => {
expect(normalizeString('TEST')).toBe('test')
expect(normalizeString('TeSt')).toBe('test')
expect(normalizeString('Test Name')).toBe('test name')
})
it('should handle empty strings', () => {
expect(normalizeString('')).toBe('')
expect(normalizeString(' ')).toBe('')
})
it('should handle special characters', () => {
expect(normalizeString(' Test-Name ')).toBe('test-name')
expect(normalizeString('John Doe')).toBe('john doe')
})
it('should handle mixed case with spaces', () => {
expect(normalizeString(' JOHN DOE ')).toBe('john doe')
expect(normalizeString('jOhN dOe')).toBe('john doe')
})
})
})

43
app/admin/page.tsx Normal file
View File

@ -0,0 +1,43 @@
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { prisma } from "@/lib/prisma"
import AdminUserList from "@/components/AdminUserList"
import CreateUserForm from "@/components/CreateUserForm"
export default async function AdminPage() {
const session = await auth()
if (!session || session.user.role !== "ADMIN") {
redirect("/")
}
const users = await prisma.user.findMany({
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
email: true,
role: true,
points: true,
createdAt: true,
},
})
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Admin Panel</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Create New User</h2>
<CreateUserForm />
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">All Users</h2>
<AdminUserList users={users} />
</div>
</div>
</div>
)
}

View File

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

View File

@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { hashPassword } from "@/lib/utils"
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session || session.user.role !== "ADMIN") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { name, email, password, role } = await req.json()
if (!name || !email || !password) {
return NextResponse.json(
{ error: "Name, email, and password are required" },
{ status: 400 }
)
}
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email },
})
if (existingUser) {
return NextResponse.json(
{ error: "User with this email already exists" },
{ status: 400 }
)
}
const passwordHash = await hashPassword(password)
const user = await prisma.user.create({
data: {
name,
email,
passwordHash,
role: role || "USER",
},
})
return NextResponse.json(
{
id: user.id,
name: user.name,
email: user.email,
role: user.role,
},
{ status: 201 }
)
} catch (error) {
console.error("Error creating user:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers

View File

@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { normalizeString } from "@/lib/utils"
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ photoId: string }> }
) {
try {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { photoId } = await params
const { guessText } = await req.json()
if (!guessText || !guessText.trim()) {
return NextResponse.json(
{ error: "Guess text is required" },
{ status: 400 }
)
}
const photo = await prisma.photo.findUnique({
where: { id: photoId },
})
if (!photo) {
return NextResponse.json({ error: "Photo not found" }, { status: 404 })
}
// Prevent users from guessing their own photos
if (photo.uploaderId === session.user.id) {
return NextResponse.json(
{ error: "You cannot guess on your own photos" },
{ status: 403 }
)
}
// Check if user already has a correct guess
const existingCorrectGuess = await prisma.guess.findFirst({
where: {
userId: session.user.id,
photoId: photoId,
correct: true,
},
})
if (existingCorrectGuess) {
return NextResponse.json(
{ error: "You already guessed this correctly" },
{ status: 400 }
)
}
// Check max attempts limit
const photoWithMaxAttempts = photo as typeof photo & { maxAttempts: number | null }
if (photoWithMaxAttempts.maxAttempts !== null && photoWithMaxAttempts.maxAttempts > 0) {
const userGuessCount = await prisma.guess.count({
where: {
userId: session.user.id,
photoId: photoId,
},
})
if (userGuessCount >= photoWithMaxAttempts.maxAttempts) {
return NextResponse.json(
{ error: `You have reached the maximum number of attempts (${photoWithMaxAttempts.maxAttempts}) for this photo` },
{ status: 400 }
)
}
}
// Check if guess is correct (case-insensitive, trimmed)
const normalizedGuess = normalizeString(guessText)
const normalizedAnswer = normalizeString(photo.answerName)
const isCorrect = normalizedGuess === normalizedAnswer
// Create the guess
const guess = await prisma.guess.create({
data: {
userId: session.user.id,
photoId: photoId,
guessText: guessText.trim(),
correct: isCorrect,
},
})
// Update user points based on guess result
let pointsChange = 0
const photoWithPenalty = photo as typeof photo & { penaltyEnabled: boolean; penaltyPoints: number }
if (isCorrect) {
// Award points for correct answer
pointsChange = photo.points
await prisma.user.update({
where: { id: session.user.id },
data: {
points: {
increment: photo.points, // Award points based on photo difficulty
},
},
})
} else if (photoWithPenalty.penaltyEnabled && photoWithPenalty.penaltyPoints > 0) {
// Deduct points for wrong answer if penalty is enabled
// First, get current user points to prevent going below 0
const currentUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: { points: true },
})
if (currentUser) {
const currentPoints = currentUser.points
const penaltyAmount = photoWithPenalty.penaltyPoints
const newPoints = Math.max(0, currentPoints - penaltyAmount)
const actualDeduction = currentPoints - newPoints
pointsChange = -actualDeduction
await prisma.user.update({
where: { id: session.user.id },
data: {
points: newPoints,
},
})
}
}
return NextResponse.json({
guess,
correct: isCorrect,
pointsChange
})
} catch (error) {
console.error("Error submitting guess:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { unlink } from "fs/promises"
import { join } from "path"
import { existsSync } from "fs"
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ photoId: string }> }
) {
try {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Only admins can delete photos
if (session.user.role !== "ADMIN") {
return NextResponse.json(
{ error: "Only admins can delete photos" },
{ status: 403 }
)
}
const { photoId } = await params
// Find the photo
const photo = await prisma.photo.findUnique({
where: { id: photoId },
})
if (!photo) {
return NextResponse.json({ error: "Photo not found" }, { status: 404 })
}
// Delete all guesses associated with this photo first
await prisma.guess.deleteMany({
where: { photoId: photoId },
})
// Delete the photo file if it's a local upload (starts with /uploads/)
if (photo.url.startsWith("/uploads/")) {
const filepath = join(process.cwd(), "public", photo.url)
if (existsSync(filepath)) {
try {
await unlink(filepath)
} catch (error) {
console.error(`Failed to delete file ${filepath}:`, error)
// Continue with database deletion even if file deletion fails
}
}
}
// Delete the photo
await prisma.photo.delete({
where: { id: photoId },
})
return NextResponse.json({ success: true, message: "Photo deleted successfully" })
} catch (error) {
console.error("Error deleting photo:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

88
app/api/photos/route.ts Normal file
View File

@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { sendNewPhotoEmail } from "@/lib/email"
// Legacy endpoint for URL-based uploads (kept for backward compatibility)
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { url, answerName, points, maxAttempts } = await req.json()
if (!url || !answerName) {
return NextResponse.json(
{ error: "URL and answer name are required" },
{ status: 400 }
)
}
// Validate points (must be positive integer, default to 1)
const pointsValue = points ? Math.max(1, parseInt(points, 10)) : 1
const maxAttemptsValue = maxAttempts && parseInt(maxAttempts, 10) > 0
? parseInt(maxAttempts, 10)
: null
// Check for duplicate URL
const existingPhoto = await prisma.photo.findFirst({
where: { url },
})
if (existingPhoto) {
return NextResponse.json(
{ error: "This photo URL has already been uploaded (duplicate URL detected)" },
{ status: 409 }
)
}
const photo = await prisma.photo.create({
data: {
uploaderId: session.user.id,
url,
answerName: answerName.trim(),
points: pointsValue,
maxAttempts: maxAttemptsValue,
} as any,
include: {
uploader: {
select: {
name: true,
},
},
},
})
// Send emails to all other users
const allUsers = await prisma.user.findMany({
where: {
id: { not: session.user.id },
},
select: {
id: true,
email: true,
name: true,
},
})
// Send emails asynchronously (don't wait for them)
Promise.all(
allUsers.map((user: { id: string; email: string; name: string }) =>
sendNewPhotoEmail(user.email, user.name, photo.id, photo.uploader.name).catch(
(err) => console.error(`Failed to send email to ${user.email}:`, err)
)
)
)
return NextResponse.json({ photo }, { status: 201 })
} catch (error) {
console.error("Error creating photo:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,218 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { sendNewPhotoEmail } from "@/lib/email"
import { writeFile } from "fs/promises"
import { join } from "path"
import { existsSync, mkdirSync } from "fs"
import { createHash } from "crypto"
import type { Photo } from "@prisma/client"
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Verify the user exists in the database
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true },
})
if (!user) {
return NextResponse.json(
{ error: "User not found. Please log out and log back in." },
{ status: 401 }
)
}
const formData = await req.formData()
const count = parseInt(formData.get("count") as string, 10)
if (!count || count < 1) {
return NextResponse.json(
{ error: "Invalid photo count" },
{ status: 400 }
)
}
const uploadsDir = join(process.cwd(), "public", "uploads")
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true })
}
type PhotoWithUploader = Photo & {
uploader: { name: string }
}
const createdPhotos: PhotoWithUploader[] = []
for (let i = 0; i < count; i++) {
const answerName = (formData.get(`photo_${i}_answerName`) as string)?.trim()
const pointsStr = (formData.get(`photo_${i}_points`) as string) || "1"
const pointsValue = Math.max(1, parseInt(pointsStr, 10))
const penaltyEnabled = formData.get(`photo_${i}_penaltyEnabled`) === "true"
const penaltyPointsStr = (formData.get(`photo_${i}_penaltyPoints`) as string) || "0"
const penaltyPointsValue = Math.max(0, parseInt(penaltyPointsStr, 10))
const maxAttemptsStr = (formData.get(`photo_${i}_maxAttempts`) as string)?.trim() || ""
const maxAttemptsValue = maxAttemptsStr && parseInt(maxAttemptsStr, 10) > 0
? parseInt(maxAttemptsStr, 10)
: null
if (!answerName) {
return NextResponse.json(
{ error: `Photo ${i + 1}: Answer name is required` },
{ status: 400 }
)
}
const file = formData.get(`photo_${i}_file`) as File | null
const url = (formData.get(`photo_${i}_url`) as string)?.trim() || null
if (!file && !url) {
return NextResponse.json(
{ error: `Photo ${i + 1}: File or URL is required` },
{ status: 400 }
)
}
let photoUrl: string
let fileHash: string | null = null
if (file) {
// Handle file upload
if (!file.type.startsWith("image/")) {
return NextResponse.json(
{ error: `Photo ${i + 1}: File must be an image` },
{ status: 400 }
)
}
if (file.size > 10 * 1024 * 1024) {
return NextResponse.json(
{ error: `Photo ${i + 1}: File size must be less than 10MB` },
{ status: 400 }
)
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Calculate SHA256 hash for duplicate detection
fileHash = createHash("sha256").update(buffer).digest("hex")
// Check for duplicate file
const existingPhoto = await prisma.photo.findFirst({
where: { fileHash } as any,
})
if (existingPhoto) {
return NextResponse.json(
{ error: `Photo ${i + 1}: This photo has already been uploaded (duplicate file detected)` },
{ status: 409 }
)
}
const timestamp = Date.now()
const randomStr = Math.random().toString(36).substring(2, 15)
const extension = file.name.split(".").pop() || "jpg"
const filename = `${timestamp}-${i}-${randomStr}.${extension}`
const filepath = join(uploadsDir, filename)
await writeFile(filepath, buffer)
photoUrl = `/uploads/${filename}`
} else if (url) {
// Handle URL upload
photoUrl = url
// Check for duplicate URL
const existingPhoto = await prisma.photo.findFirst({
where: { url: photoUrl },
})
if (existingPhoto) {
return NextResponse.json(
{ error: `Photo ${i + 1}: This photo URL has already been uploaded (duplicate URL detected)` },
{ status: 409 }
)
}
} else {
return NextResponse.json(
{ error: `Photo ${i + 1}: File or URL is required` },
{ status: 400 }
)
}
// Create photo record
const photo = await prisma.photo.create({
data: {
uploaderId: session.user.id,
url: photoUrl,
fileHash,
answerName,
points: pointsValue,
penaltyEnabled: penaltyEnabled,
penaltyPoints: penaltyPointsValue,
maxAttempts: maxAttemptsValue,
} as any,
include: {
uploader: {
select: {
name: true,
},
},
},
})
createdPhotos.push(photo as PhotoWithUploader)
}
// Send emails to all other users for all photos
const allUsers = await prisma.user.findMany({
where: {
id: { not: session.user.id },
},
select: {
id: true,
email: true,
name: true,
},
})
// Send emails asynchronously for all photos
Promise.all(
allUsers.map((user: { id: string; email: string; name: string }) =>
Promise.all(
createdPhotos.map((photo: PhotoWithUploader) =>
sendNewPhotoEmail(
user.email,
user.name,
photo.id,
photo.uploader.name
).catch((err) =>
console.error(
`Failed to send email to ${user.email} for photo ${photo.id}:`,
err
)
)
)
)
)
)
return NextResponse.json(
{ photos: createdPhotos, count: createdPhotos.length },
{ status: 201 }
)
} catch (error) {
console.error("Error uploading photos:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { sendNewPhotoEmail } from "@/lib/email"
import { writeFile } from "fs/promises"
import { join } from "path"
import { existsSync, mkdirSync } from "fs"
import { createHash } from "crypto"
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const formData = await req.formData()
const file = formData.get("file") as File | null
const answerName = formData.get("answerName") as string | null
const pointsStr = formData.get("points") as string | null
const maxAttemptsStr = (formData.get("maxAttempts") as string)?.trim() || ""
if (!answerName) {
return NextResponse.json(
{ error: "Answer name is required" },
{ status: 400 }
)
}
// Validate points (must be positive integer, default to 1)
const pointsValue = pointsStr ? Math.max(1, parseInt(pointsStr, 10)) : 1
const maxAttemptsValue = maxAttemptsStr && parseInt(maxAttemptsStr, 10) > 0
? parseInt(maxAttemptsStr, 10)
: null
let photoUrl: string
let fileHash: string | null = null
if (file) {
// Handle file upload
if (!file.type.startsWith("image/")) {
return NextResponse.json(
{ error: "File must be an image" },
{ status: 400 }
)
}
// Check file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
return NextResponse.json(
{ error: "File size must be less than 10MB" },
{ status: 400 }
)
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Calculate SHA256 hash for duplicate detection
fileHash = createHash("sha256").update(buffer).digest("hex")
// Check for duplicate file
const existingPhoto = await prisma.photo.findFirst({
where: { fileHash } as any,
})
if (existingPhoto) {
return NextResponse.json(
{ error: "This photo has already been uploaded (duplicate file detected)" },
{ status: 409 }
)
}
// Generate unique filename
const timestamp = Date.now()
const randomStr = Math.random().toString(36).substring(2, 15)
const extension = file.name.split(".").pop() || "jpg"
const filename = `${timestamp}-${randomStr}.${extension}`
// Ensure uploads directory exists
const uploadsDir = join(process.cwd(), "public", "uploads")
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true })
}
// Save file
const filepath = join(uploadsDir, filename)
await writeFile(filepath, buffer)
// Set URL to the uploaded file
photoUrl = `/uploads/${filename}`
} else {
// Handle URL upload (fallback)
const url = formData.get("url") as string | null
if (!url) {
return NextResponse.json(
{ error: "Either file or URL is required" },
{ status: 400 }
)
}
photoUrl = url
// Check for duplicate URL
const existingPhoto = await prisma.photo.findFirst({
where: { url: photoUrl },
})
if (existingPhoto) {
return NextResponse.json(
{ error: "This photo URL has already been uploaded (duplicate URL detected)" },
{ status: 409 }
)
}
}
const photo = await prisma.photo.create({
data: {
uploaderId: session.user.id,
url: photoUrl,
fileHash,
answerName: answerName.trim(),
points: pointsValue,
maxAttempts: maxAttemptsValue,
} as any,
include: {
uploader: {
select: {
name: true,
},
},
},
})
// Send emails to all other users
const allUsers = await prisma.user.findMany({
where: {
id: { not: session.user.id },
},
select: {
id: true,
email: true,
name: true,
},
})
// Send emails asynchronously (don't wait for them)
Promise.all(
allUsers.map((user: { id: string; email: string; name: string }) =>
sendNewPhotoEmail(user.email, user.name, photo.id, photo.uploader.name).catch(
(err) => console.error(`Failed to send email to ${user.email}:`, err)
)
)
)
return NextResponse.json({ photo }, { status: 201 })
} catch (error) {
console.error("Error uploading photo:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import bcrypt from "bcryptjs"
import { hashPassword } from "@/lib/utils"
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { currentPassword, newPassword } = await req.json()
if (!currentPassword || !newPassword) {
return NextResponse.json(
{ error: "Current password and new password are required" },
{ status: 400 }
)
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
})
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 })
}
const isValid = await bcrypt.compare(currentPassword, user.passwordHash)
if (!isValid) {
return NextResponse.json({ error: "Current password is incorrect" }, { status: 400 })
}
const newPasswordHash = await hashPassword(newPassword)
await prisma.user.update({
where: { id: session.user.id },
data: { passwordHash: newPasswordHash },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("Error changing password:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

26
app/globals.css Normal file
View File

@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

39
app/layout.tsx Normal file
View File

@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Providers from "@/components/Providers";
import Navigation from "@/components/Navigation";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "MirrorMatch",
description: "A photo guessing game",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-x-hidden`}
>
<Providers>
<Navigation />
<main className="min-h-screen bg-gray-50 overflow-x-hidden">{children}</main>
</Providers>
</body>
</html>
);
}

83
app/leaderboard/page.tsx Normal file
View File

@ -0,0 +1,83 @@
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { prisma } from "@/lib/prisma"
// Enable caching for this page
export const revalidate = 30 // Revalidate every 30 seconds
export default async function LeaderboardPage() {
const session = await auth()
if (!session) {
redirect("/login")
}
const users = await prisma.user.findMany({
orderBy: { points: "desc" },
select: {
id: true,
name: true,
email: true,
points: true,
role: true,
},
})
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">Leaderboard</h1>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gradient-to-r from-purple-600 to-indigo-600">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Rank
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
Points
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user: { id: string; name: string; email: string; points: number; role: string }, index: number) => (
<tr
key={user.id}
className={user.id === session.user.id ? "bg-purple-50" : ""}
>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `#${index + 1}`}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.name}
{user.role === "ADMIN" && (
<span className="ml-2 px-2 py-1 text-xs bg-purple-100 text-purple-800 rounded-full">
Admin
</span>
)}
{user.id === session.user.id && (
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
You
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-purple-600">
{user.points}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

102
app/login/page.tsx Normal file
View File

@ -0,0 +1,102 @@
"use client"
import { useState } from "react"
import { signIn } from "next-auth/react"
import { useRouter } from "next/navigation"
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setLoading(true)
try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
})
if (result?.error) {
setError("Invalid email or password")
} else {
router.push("/photos")
router.refresh()
}
} catch {
setError("An error occurred. Please try again.")
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 to-indigo-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to MirrorMatch
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 bg-white text-gray-900 rounded-t-md focus:outline-none focus:ring-purple-500 focus:border-purple-500 focus:z-10 sm:text-sm"
placeholder="Email address"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 bg-white text-gray-900 rounded-b-md focus:outline-none focus:ring-purple-500 focus:border-purple-500 focus:z-10 sm:text-sm"
placeholder="Password"
/>
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Signing in..." : "Sign in"}
</button>
</div>
</form>
</div>
</div>
)
}

5
app/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function Home() {
redirect("/photos")
}

160
app/photos/[id]/page.tsx Normal file
View File

@ -0,0 +1,160 @@
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { prisma } from "@/lib/prisma"
import GuessForm from "@/components/GuessForm"
import PhotoImage from "@/components/PhotoImage"
import DeletePhotoButton from "@/components/DeletePhotoButton"
// Enable caching for this page
export const revalidate = 60 // Revalidate every 60 seconds
export default async function PhotoPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth()
if (!session) {
redirect("/login")
}
const { id } = await params
const photo = await prisma.photo.findUnique({
where: { id },
include: {
uploader: {
select: {
name: true,
},
},
guesses: {
where: {
userId: session.user.id,
},
orderBy: {
createdAt: "desc",
},
},
},
})
if (!photo) {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="bg-white rounded-lg shadow-md p-6">
<p className="text-gray-500">Photo not found</p>
</div>
</div>
)
}
const userGuess = photo.guesses[0]
const hasCorrectGuess = userGuess?.correct || false
const isOwner = photo.uploaderId === session.user.id
// Calculate remaining attempts
const photoWithMaxAttempts = photo as typeof photo & { maxAttempts: number | null | undefined }
const userGuessCount = photo.guesses.length
const maxAttempts = photoWithMaxAttempts.maxAttempts ?? null
const remainingAttempts = maxAttempts !== null && maxAttempts > 0
? Math.max(0, maxAttempts - userGuessCount)
: null
const hasReachedMaxAttempts = maxAttempts !== null && maxAttempts > 0 && userGuessCount >= maxAttempts
return (
<div className="max-w-7xl mx-auto px-4 py-8 overflow-x-hidden">
<div className="bg-white rounded-lg shadow-md p-4 sm:p-6">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-gray-900">Guess Who!</h1>
{session.user.role === "ADMIN" && (
<DeletePhotoButton photoId={photo.id} variant="button" />
)}
</div>
<div className="flex flex-wrap items-center gap-4 mb-2">
<p className="text-sm text-gray-500">
Uploaded by {photo.uploader.name} on{" "}
{new Date(photo.createdAt).toLocaleDateString()}
</p>
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800">
+{photo.points} {photo.points === 1 ? "point" : "points"} if correct
</span>
{(photo as any).penaltyEnabled && (photo as any).penaltyPoints > 0 && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
-{(photo as any).penaltyPoints} {(photo as any).penaltyPoints === 1 ? "point" : "points"} if wrong
</span>
)}
{maxAttempts !== null && maxAttempts > 0 && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
{maxAttempts} {maxAttempts === 1 ? "attempt" : "attempts"} max
</span>
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div className="bg-gray-200 rounded-lg overflow-hidden w-full" style={{ maxHeight: "70vh", minHeight: "300px" }}>
<div className="relative w-full h-full" style={{ minHeight: "300px", maxHeight: "70vh" }}>
<PhotoImage src={photo.url} alt="Photo to guess" />
</div>
</div>
<div className="flex flex-col w-full min-w-0">
{hasCorrectGuess ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-800 font-semibold">
Correct! You guessed it right!
</p>
<p className="text-sm text-green-700 mt-1">
The answer was: <strong>{photo.answerName}</strong>
</p>
<p className="text-sm text-green-700 mt-1">
You earned <strong>{photo.points} {photo.points === 1 ? "point" : "points"}</strong>!
</p>
</div>
) : userGuess ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-800 font-semibold"> Wrong guess. Try again!</p>
<p className="text-sm text-red-700 mt-1">
Your last guess: <strong>{userGuess.guessText}</strong>
</p>
{(photo as any).penaltyEnabled && (photo as any).penaltyPoints > 0 && (
<p className="text-sm text-red-700 mt-1">
You lost <strong>{(photo as any).penaltyPoints} {(photo as any).penaltyPoints === 1 ? "point" : "points"}</strong> for this wrong guess.
</p>
)}
</div>
) : null}
{!isOwner && !hasCorrectGuess && remainingAttempts !== null && remainingAttempts > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
<p className="text-sm text-blue-700">
<strong>Remaining attempts:</strong> {remainingAttempts} of {maxAttempts}
</p>
</div>
)}
{!isOwner && !hasCorrectGuess && hasReachedMaxAttempts && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 font-semibold"> Maximum attempts reached</p>
<p className="text-sm text-yellow-700 mt-1">
You have used all {maxAttempts} {maxAttempts === 1 ? "attempt" : "attempts"} for this photo.
</p>
</div>
)}
{isOwner ? (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-blue-800 font-semibold">📸 This is your photo</p>
<p className="text-sm text-blue-700 mt-1">
You cannot guess on photos you uploaded. The answer is: <strong>{photo.answerName}</strong>
</p>
</div>
) : !hasCorrectGuess && !hasReachedMaxAttempts ? (
<GuessForm photoId={photo.id} />
) : null}
</div>
</div>
</div>
</div>
)
}

73
app/photos/page.tsx Normal file
View File

@ -0,0 +1,73 @@
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { prisma } from "@/lib/prisma"
import Link from "next/link"
import PhotoThumbnail from "@/components/PhotoThumbnail"
import DeletePhotoButton from "@/components/DeletePhotoButton"
// Enable caching for this page
export const revalidate = 60 // Revalidate every 60 seconds
export default async function PhotosPage() {
const session = await auth()
if (!session) {
redirect("/login")
}
// Limit to 50 photos per page for performance
const photos = await prisma.photo.findMany({
take: 50,
orderBy: { createdAt: "desc" },
include: {
uploader: {
select: {
name: true,
},
},
},
})
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">All Photos</h1>
{photos.length === 0 ? (
<div className="bg-white rounded-lg shadow-md p-8 text-center">
<p className="text-gray-500">No photos yet. Be the first to upload one!</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{photos.map((photo: { id: string; url: string; answerName: string; points: number; createdAt: Date; uploader: { name: string } }) => (
<div
key={photo.id}
className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition relative group"
>
<Link href={`/photos/${photo.id}`}>
<div className="aspect-square bg-gray-200 relative">
<PhotoThumbnail src={photo.url} alt="Photo" />
</div>
<div className="p-4">
<div className="flex items-center justify-between mb-1">
<p className="text-sm text-gray-500">Uploaded by {photo.uploader.name}</p>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
{photo.points} {photo.points === 1 ? "pt" : "pts"}
</span>
</div>
<p className="text-xs text-gray-400 mt-1">
{new Date(photo.createdAt).toLocaleDateString()}
</p>
</div>
</Link>
{session.user.role === "ADMIN" && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<DeletePhotoButton photoId={photo.id} />
</div>
)}
</div>
))}
</div>
)}
</div>
)
}

61
app/profile/page.tsx Normal file
View File

@ -0,0 +1,61 @@
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { prisma } from "@/lib/prisma"
import ChangePasswordForm from "@/components/ChangePasswordForm"
export default async function ProfilePage() {
const session = await auth()
if (!session) {
redirect("/login")
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
name: true,
email: true,
points: true,
role: true,
},
})
if (!user) {
redirect("/login")
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-3xl font-bold text-gray-900 mb-6">Profile</h1>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<p className="mt-1 text-lg text-gray-900">{user.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<p className="mt-1 text-lg text-gray-900">{user.email}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role</label>
<p className="mt-1 text-lg text-gray-900">{user.role}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Points</label>
<p className="mt-1 text-3xl font-bold text-purple-600">{user.points}</p>
</div>
<div className="border-t pt-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Change Password</h2>
<ChangePasswordForm />
</div>
</div>
</div>
</div>
)
}

447
app/upload/page.tsx Normal file
View File

@ -0,0 +1,447 @@
"use client"
import { useState, useRef, DragEvent } from "react"
import { useRouter } from "next/navigation"
interface PhotoData {
file: File | null
url: string
answerName: string
points: string
penaltyEnabled: boolean
penaltyPoints: string
maxAttempts: string
preview: string | null
}
export default function UploadPage() {
const router = useRouter()
const fileInputRef = useRef<HTMLInputElement>(null)
const [photos, setPhotos] = useState<PhotoData[]>([])
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const [loading, setLoading] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files).filter((file) =>
file.type.startsWith("image/")
)
if (files.length === 0) {
setError("Please drop image files")
return
}
handleFiles(files)
}
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length > 0) {
handleFiles(files)
}
}
const handleFiles = (files: File[]) => {
const newPhotos: PhotoData[] = []
files.forEach((file) => {
if (file.size > 10 * 1024 * 1024) {
setError(`File ${file.name} is too large (max 10MB)`)
return
}
const reader = new FileReader()
reader.onloadend = () => {
const photoData: PhotoData = {
file,
url: "",
answerName: "",
points: "1",
penaltyEnabled: false,
penaltyPoints: "0",
maxAttempts: "",
preview: reader.result as string,
}
newPhotos.push(photoData)
// Update state when all files are processed
if (newPhotos.length === files.length) {
setPhotos((prev) => [...prev, ...newPhotos])
setError("")
}
}
reader.readAsDataURL(file)
})
}
const updatePhoto = (index: number, updates: Partial<PhotoData>) => {
setPhotos((prev) =>
prev.map((photo, i) => (i === index ? { ...photo, ...updates } : photo))
)
}
const removePhoto = (index: number) => {
setPhotos((prev) => prev.filter((_, i) => i !== index))
}
const addUrlPhoto = () => {
setPhotos((prev) => [
...prev,
{
file: null,
url: "",
answerName: "",
points: "1",
penaltyEnabled: false,
penaltyPoints: "0",
maxAttempts: "",
preview: null,
},
])
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setSuccess("")
// Validate all photos have required fields
for (let i = 0; i < photos.length; i++) {
const photo = photos[i]
if (!photo.answerName.trim()) {
setError(`Photo ${i + 1}: Answer name is required`)
return
}
if (!photo.file && !photo.url) {
setError(`Photo ${i + 1}: Please provide a file or URL`)
return
}
}
if (photos.length === 0) {
setError("Please add at least one photo")
return
}
setLoading(true)
try {
const formData = new FormData()
// Add all photos data with indexed keys to preserve order
photos.forEach((photo, index) => {
if (photo.file) {
formData.append(`photo_${index}_file`, photo.file)
} else if (photo.url) {
formData.append(`photo_${index}_url`, photo.url)
}
formData.append(`photo_${index}_answerName`, photo.answerName.trim())
formData.append(`photo_${index}_points`, photo.points)
// Auto-enable penalty if penaltyPoints has a value > 0
const penaltyPointsValue = parseInt(photo.penaltyPoints || "0", 10)
formData.append(`photo_${index}_penaltyEnabled`, penaltyPointsValue > 0 ? "true" : "false")
formData.append(`photo_${index}_penaltyPoints`, photo.penaltyPoints || "0")
formData.append(`photo_${index}_maxAttempts`, photo.maxAttempts || "")
})
formData.append("count", photos.length.toString())
const response = await fetch("/api/photos/upload-multiple", {
method: "POST",
body: formData,
})
const data = await response.json()
if (!response.ok) {
setError(data.error || "Failed to upload photos")
} else {
setSuccess(
`Successfully uploaded ${data.photos.length} photo(s)! Emails sent to all users.`
)
setPhotos([])
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
setTimeout(() => {
router.push("/photos")
}, 2000)
}
} catch {
setError("An error occurred. Please try again.")
} finally {
setLoading(false)
}
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-3xl font-bold text-gray-900 mb-6">Upload Photos</h1>
<form onSubmit={handleSubmit} className="space-y-6">
{/* File Upload Section */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Upload Photos (Multiple files supported)
</label>
{/* Drag and Drop Area */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isDragging
? "border-purple-500 bg-purple-50"
: "border-gray-300 hover:border-purple-400"
}`}
>
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div className="mt-4">
<label
htmlFor="file-upload"
className="cursor-pointer inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
>
<span>Select files</span>
<input
id="file-upload"
ref={fileInputRef}
name="file-upload"
type="file"
accept="image/*"
multiple
className="sr-only"
onChange={handleFileInputChange}
/>
</label>
<p className="mt-2 text-sm text-gray-600">
or drag and drop multiple images
</p>
<p className="mt-1 text-xs text-gray-500">
PNG, JPG, GIF up to 10MB each
</p>
</div>
</div>
</div>
{/* Add URL Photo Button */}
<div>
<button
type="button"
onClick={addUrlPhoto}
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
>
+ Add Photo by URL
</button>
</div>
{/* Photos List */}
{photos.length > 0 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">
Photos ({photos.length})
</h2>
{photos.map((photo, index) => (
<div
key={index}
className="border border-gray-200 rounded-lg p-4 space-y-4"
>
<div className="flex items-start justify-between">
<h3 className="text-sm font-medium text-gray-700">
Photo {index + 1}
</h3>
<button
type="button"
onClick={() => removePhoto(index)}
className="text-red-600 hover:text-red-800 text-sm font-medium"
>
Remove
</button>
</div>
{/* Preview */}
{photo.preview && (
<div className="flex justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.preview}
alt={`Preview ${index + 1}`}
className="max-h-32 rounded-lg shadow-md"
/>
</div>
)}
{/* URL Input (if no file) */}
{!photo.file && (
<div>
<label
htmlFor={`url-${index}`}
className="block text-sm font-medium text-gray-700"
>
Photo URL
</label>
<input
id={`url-${index}`}
type="url"
value={photo.url}
onChange={(e) =>
updatePhoto(index, { url: e.target.value })
}
placeholder="https://example.com/photo.jpg"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
/>
</div>
)}
{/* Answer Name */}
<div>
<label
htmlFor={`answerName-${index}`}
className="block text-sm font-medium text-gray-700"
>
Answer Name *
</label>
<input
id={`answerName-${index}`}
type="text"
required
value={photo.answerName}
onChange={(e) =>
updatePhoto(index, { answerName: e.target.value })
}
placeholder="Enter the correct answer"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
/>
</div>
{/* Points */}
<div>
<label
htmlFor={`points-${index}`}
className="block text-sm font-medium text-gray-700"
>
Points Value (for correct answer)
</label>
<input
id={`points-${index}`}
type="number"
min="1"
required
value={photo.points}
onChange={(e) =>
updatePhoto(index, { points: e.target.value })
}
placeholder="1"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
/>
</div>
{/* Penalty Points */}
<div className="border-t pt-4">
<label
htmlFor={`penaltyPoints-${index}`}
className="block text-sm font-medium text-gray-700"
>
Penalty Points (deducted for wrong answer)
</label>
<input
id={`penaltyPoints-${index}`}
type="number"
min="0"
value={photo.penaltyPoints}
onChange={(e) =>
updatePhoto(index, { penaltyPoints: e.target.value })
}
placeholder="0"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
/>
<p className="mt-1 text-xs text-gray-500">
Leave empty or set to 0 to disable point deduction. If set to a value greater than 0, users will lose these points for each incorrect guess.
</p>
</div>
{/* Max Attempts */}
<div>
<label
htmlFor={`maxAttempts-${index}`}
className="block text-sm font-medium text-gray-700"
>
Max Attempts (per user)
</label>
<input
id={`maxAttempts-${index}`}
type="number"
min="0"
value={photo.maxAttempts}
onChange={(e) =>
updatePhoto(index, { maxAttempts: e.target.value })
}
placeholder="Unlimited (leave empty or 0)"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
/>
<p className="mt-1 text-xs text-gray-500">
Maximum number of guesses allowed per user. Leave empty or 0 for unlimited attempts.
</p>
</div>
</div>
))}
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
{success && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">{success}</p>
</div>
)}
<button
type="submit"
disabled={loading || photos.length === 0}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
>
{loading
? `Uploading ${photos.length} photo(s)...`
: `Upload ${photos.length} Photo(s)`}
</button>
</form>
</div>
</div>
)
}

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

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

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

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
components/GuessForm.tsx Normal file
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
components/Navigation.tsx Normal file
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
components/PhotoImage.tsx Normal file
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/")}
/>
)
}

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
components/Providers.tsx Normal file
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>
}

16
env.example Normal file
View File

@ -0,0 +1,16 @@
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/mirrormatch?schema=public"
# NextAuth
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32"
# Email Configuration (for production)
SMTP_HOST="smtp.example.com"
SMTP_PORT="587"
SMTP_USER="your-email@example.com"
SMTP_PASSWORD="your-email-password"
SMTP_FROM="MirrorMatch <noreply@mirrormatch.com>"
# In development, emails will be logged to console or use Ethereal
# No SMTP config needed for dev mode

18
eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

40
jest.config.js Normal file
View File

@ -0,0 +1,40 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
const nextJest = require('next/jest')
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
})
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)',
],
collectCoverageFrom: [
'app/**/*.{js,jsx,ts,tsx}',
'components/**/*.{js,jsx,ts,tsx}',
'lib/**/*.{js,jsx,ts,tsx}',
'!**/*.d.ts',
'!**/node_modules/**',
'!**/.next/**',
],
// Coverage threshold disabled for now - will be enabled as test coverage increases
// coverageThreshold: {
// global: {
// branches: 50,
// functions: 50,
// lines: 50,
// statements: 50,
// },
// },
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)

39
jest.setup.js Normal file
View File

@ -0,0 +1,39 @@
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom'
// Polyfill for TextEncoder/TextDecoder
import { TextEncoder, TextDecoder } from 'util'
global.TextEncoder = TextEncoder
global.TextDecoder = TextDecoder
// Mock Next.js router
jest.mock('next/navigation', () => ({
useRouter() {
return {
push: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
back: jest.fn(),
pathname: '/',
query: {},
asPath: '/',
}
},
usePathname() {
return '/'
},
useSearchParams() {
return new URLSearchParams()
},
}))
// Mock next-auth/react
jest.mock('next-auth/react', () => ({
useSession: jest.fn(() => ({
data: null,
status: 'unauthenticated',
})),
signIn: jest.fn(),
signOut: jest.fn(),
SessionProvider: ({ children }) => children,
}))

68
lib/auth.ts Normal file
View File

@ -0,0 +1,68 @@
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { prisma } from "./prisma"
import bcrypt from "bcryptjs"
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null
}
const email = credentials.email as string
const password = credentials.password as string
const user = await prisma.user.findUnique({
where: { email }
})
if (!user || !user.passwordHash) {
return null
}
const isValid = await bcrypt.compare(password, user.passwordHash)
if (!isValid) {
return null
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
}
}
})
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.role = (user as { role: string }).role
}
return token
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
session.user.role = token.role as string
}
return session
}
},
pages: {
signIn: "/login",
},
session: {
strategy: "jwt",
},
secret: process.env.NEXTAUTH_SECRET,
})

113
lib/email.ts Normal file
View File

@ -0,0 +1,113 @@
import nodemailer from "nodemailer"
let transporter: nodemailer.Transporter | null = null
async function getTransporter() {
if (transporter) return transporter
// In development, use Ethereal or console transport
if (process.env.NODE_ENV === "development") {
// Try to use Ethereal for testing
try {
const testAccount = await nodemailer.createTestAccount()
transporter = nodemailer.createTransport({
host: "smtp.ethereal.email",
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
})
return transporter
} catch {
// Fallback to console transport
transporter = nodemailer.createTransport({
streamTransport: true,
newline: "unix",
buffer: true,
})
return transporter
}
}
// Production: use SMTP
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || "587"),
secure: process.env.SMTP_PORT === "465",
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
})
return transporter
}
export async function sendNewPhotoEmail(
recipientEmail: string,
recipientName: string,
photoId: string,
uploaderName: string
) {
const emailTransporter = await getTransporter()
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000"
const photoUrl = `${baseUrl}/photos/${photoId}`
const mailOptions = {
from: process.env.SMTP_FROM || "MirrorMatch <noreply@mirrormatch.com>",
to: recipientEmail,
subject: "New Photo Ready to Guess!",
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0;">MirrorMatch</h1>
</div>
<div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px;">
<h2 style="color: #667eea; margin-top: 0;">New Photo Uploaded!</h2>
<p>Hi ${recipientName},</p>
<p><strong>${uploaderName}</strong> has uploaded a new photo for you to guess!</p>
<p style="margin: 30px 0;">
<a href="${photoUrl}" style="background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">View Photo & Guess</a>
</p>
<p style="color: #666; font-size: 14px; margin-top: 30px;">
Good luck! 🎯
</p>
</div>
</body>
</html>
`,
text: `
Hi ${recipientName},
${uploaderName} has uploaded a new photo for you to guess!
View the photo and submit your guess here: ${photoUrl}
Good luck!
`.trim(),
}
try {
const info = await emailTransporter.sendMail(mailOptions)
if (process.env.NODE_ENV === "development") {
console.log("Email sent (dev mode):")
console.log("Preview URL:", nodemailer.getTestMessageUrl(info) || "Check console")
console.log("To:", recipientEmail)
console.log("Subject:", mailOptions.subject)
}
return info
} catch (error) {
console.error("Error sending email:", error)
throw error
}
}

46
lib/prisma.ts Normal file
View File

@ -0,0 +1,46 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
import { Pool } from 'pg'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
const connectionString = process.env.DATABASE_URL
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is not set')
}
// Handle Prisma Postgres URLs (prisma+postgres://) vs standard PostgreSQL URLs
let pool: Pool
if (connectionString.startsWith('prisma+postgres://')) {
// For Prisma managed Postgres, extract the actual postgres URL from the API key
try {
const urlMatch = connectionString.match(/api_key=([^"&]+)/)
if (urlMatch) {
const apiKey = decodeURIComponent(urlMatch[1])
const decoded = JSON.parse(Buffer.from(apiKey, 'base64').toString())
if (decoded.databaseUrl) {
pool = new Pool({ connectionString: decoded.databaseUrl })
} else if (decoded.shadowDatabaseUrl) {
pool = new Pool({ connectionString: decoded.shadowDatabaseUrl })
} else {
throw new Error('Could not extract database URL from Prisma Postgres connection string')
}
} else {
throw new Error('Invalid Prisma Postgres connection string format')
}
} catch (error) {
console.error('Error parsing Prisma Postgres URL:', error)
throw new Error('Failed to parse Prisma Postgres connection string. Consider using a standard PostgreSQL connection string.')
}
} else {
// Standard PostgreSQL connection
pool = new Pool({ connectionString })
}
const adapter = new PrismaPg(pool)
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter })
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

9
lib/utils.ts Normal file
View File

@ -0,0 +1,9 @@
import bcrypt from "bcryptjs"
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10)
}
export function normalizeString(str: string): string {
return str.trim().toLowerCase()
}

50
next.config.ts Normal file
View File

@ -0,0 +1,50 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Only process specific file extensions
pageExtensions: ["ts", "tsx", "js", "jsx"],
// Image optimization configuration
images: {
remotePatterns: [
{
protocol: "http",
hostname: "**",
},
{
protocol: "https",
hostname: "**",
},
],
unoptimized: false, // Enable optimization for better performance
},
// Configure Turbopack
turbopack: {
resolveExtensions: [
".tsx",
".ts",
".jsx",
".js",
".mjs",
".json",
],
rules: {
"*.md": {
loaders: [],
as: "*.txt",
},
},
},
// Webpack configuration to externalize Prisma
webpack: (config, { isServer }) => {
if (isServer) {
config.externals = config.externals || [];
config.externals.push("@prisma/client");
}
return config;
},
};
export default nextConfig;

15118
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

58
package.json Normal file
View File

@ -0,0 +1,58 @@
{
"name": "mirrormatch",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --webpack",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"type-check": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --maxWorkers=2",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"next": "16.1.1",
"next-auth": "^5.0.0-beta.30",
"nodemailer": "^7.0.12",
"pg": "^8.16.3",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^2.4.6",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/nodemailer": "^7.0.4",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"prisma": "^7.2.0",
"raw-loader": "^4.0.2",
"tailwindcss": "^4",
"ts-jest": "^29.4.6",
"tsx": "^4.21.0",
"typescript": "^5",
"undici": "^7.16.0"
}
}

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

14
prisma.config.ts Normal file
View File

@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

69
prisma/schema.prisma Normal file
View File

@ -0,0 +1,69 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
output = "../node_modules/@prisma/client/.prisma/client"
}
datasource db {
provider = "postgresql"
}
// Seed configuration
// Run with: npm run db:seed
model User {
id String @id @default(cuid())
name String
email String @unique
passwordHash String
role Role @default(USER)
points Int @default(0)
createdAt DateTime @default(now())
uploadedPhotos Photo[] @relation("PhotoUploader")
guesses Guess[]
@@index([points])
}
model Photo {
id String @id @default(cuid())
uploaderId String
uploader User @relation("PhotoUploader", fields: [uploaderId], references: [id])
url String
fileHash String? // SHA256 hash of file content (null for URL uploads)
answerName String
points Int @default(1)
penaltyEnabled Boolean @default(false)
penaltyPoints Int @default(0)
maxAttempts Int? // Maximum number of guesses allowed per user (null = unlimited)
createdAt DateTime @default(now())
guesses Guess[]
@@index([uploaderId])
@@index([createdAt])
@@index([url])
@@index([fileHash])
}
model Guess {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
photoId String
photo Photo @relation(fields: [photoId], references: [id])
guessText String
correct Boolean @default(false)
createdAt DateTime @default(now())
@@index([userId])
@@index([photoId])
}
enum Role {
ADMIN
USER
}

42
prisma/seed.ts Normal file
View File

@ -0,0 +1,42 @@
import "dotenv/config"
import { prisma } from "../lib/prisma"
import bcrypt from "bcryptjs"
async function main() {
// Check if admin already exists
const existingAdmin = await prisma.user.findUnique({
where: { email: "admin@mirrormatch.com" },
})
if (existingAdmin) {
console.log("Admin user already exists. Skipping seed.")
return
}
// Create default admin user
const passwordHash = await bcrypt.hash("admin123", 10)
await prisma.user.create({
data: {
name: "Admin",
email: "admin@mirrormatch.com",
passwordHash,
role: "ADMIN",
points: 0,
},
})
console.log("✅ Created admin user:")
console.log(" Email: admin@mirrormatch.com")
console.log(" Password: admin123")
console.log(" ⚠️ Please change the password after first login!")
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

49
proxy.ts Normal file
View File

@ -0,0 +1,49 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { getToken } from "next-auth/jwt"
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
// Public routes - allow access
if (pathname === "/login" || pathname.startsWith("/api/auth")) {
return NextResponse.next()
}
// Get token (works in Edge runtime)
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET
})
// Protected routes - require authentication
if (!token) {
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("callbackUrl", pathname)
return NextResponse.redirect(loginUrl)
}
// Admin routes - require ADMIN role
if (pathname.startsWith("/admin")) {
if (token.role !== "ADMIN") {
return NextResponse.redirect(new URL("/", request.url))
}
}
return NextResponse.next()
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - _next/rsc (RSC payload requests)
* - _next/webpack (webpack chunks)
* - favicon.ico (favicon file)
* - public folder
*/
"/((?!_next/static|_next/image|_next/rsc|_next/webpack|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
}

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

0
public/uploads/.gitkeep Normal file
View File

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

BIN
test_image/sara.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 KiB

36
tsconfig.json Normal file
View File

@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"],
".prisma/client": ["./node_modules/.prisma/client"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts",
"jest.setup.js"
],
"exclude": ["node_modules"]
}

1
types/jest-dom.d.ts vendored Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom'

27
types/next-auth.d.ts vendored Normal file
View File

@ -0,0 +1,27 @@
import "next-auth"
import "next-auth/jwt"
declare module "next-auth" {
interface Session {
user: {
id: string
email: string
name: string
role: string
}
}
interface User {
id: string
email: string
name: string
role: string
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string
role: string
}
}