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
191
.cursor/rules/mirrormatch.mdc
Normal 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
@ -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
@ -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
@ -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
@ -0,0 +1 @@
|
||||
../node_modules/.prisma/client
|
||||
366
ARCHITECTURE.md
Normal 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
@ -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
@ -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
@ -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
@ -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! 🎯**
|
||||
73
__tests__/components/Navigation.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
65
__tests__/lib/utils.test.ts
Normal 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
@ -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>
|
||||
)
|
||||
}
|
||||
42
app/api/admin/users/[userId]/reset-password/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
62
app/api/admin/users/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth"
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
144
app/api/photos/[photoId]/guess/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
69
app/api/photos/[photoId]/route.ts
Normal 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
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
218
app/api/photos/upload-multiple/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
164
app/api/photos/upload/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
53
app/api/profile/change-password/route.ts
Normal 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
|
After Width: | Height: | Size: 25 KiB |
26
app/globals.css
Normal 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
@ -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
@ -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
@ -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
@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function Home() {
|
||||
redirect("/photos")
|
||||
}
|
||||
160
app/photos/[id]/page.tsx
Normal 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
@ -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
@ -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
@ -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>
|
||||
)
|
||||
}
|
||||
140
components/AdminUserList.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
points: number
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export default function AdminUserList({ users }: { users: User[] }) {
|
||||
const [resettingUserId, setResettingUserId] = useState<string | null>(null)
|
||||
const [newPassword, setNewPassword] = useState<Record<string, string>>({})
|
||||
const [error, setError] = useState("")
|
||||
const [success, setSuccess] = useState("")
|
||||
|
||||
const handleResetPassword = async (userId: string) => {
|
||||
const password = newPassword[userId]
|
||||
if (!password || password.length < 6) {
|
||||
setError("Password must be at least 6 characters")
|
||||
return
|
||||
}
|
||||
|
||||
setError("")
|
||||
setResettingUserId(userId)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}/reset-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || "Failed to reset password")
|
||||
} else {
|
||||
setSuccess("Password reset successfully!")
|
||||
setNewPassword({ ...newPassword, [userId]: "" })
|
||||
setTimeout(() => {
|
||||
setSuccess("")
|
||||
}, 3000)
|
||||
}
|
||||
} catch {
|
||||
setError("An error occurred. Please try again.")
|
||||
} finally {
|
||||
setResettingUserId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-50 p-4">
|
||||
<p className="text-sm text-green-800">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Points
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{user.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
||||
{user.email}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
user.role === "ADMIN"
|
||||
? "bg-purple-100 text-purple-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{user.points}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={newPassword[user.id] || ""}
|
||||
onChange={(e) =>
|
||||
setNewPassword({ ...newPassword, [user.id]: e.target.value })
|
||||
}
|
||||
className="px-2 py-1 border border-gray-300 rounded text-xs w-32 bg-white text-gray-900"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleResetPassword(user.id)}
|
||||
disabled={resettingUserId === user.id}
|
||||
className="px-3 py-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{resettingUserId === user.id ? "Resetting..." : "Reset"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
components/ChangePasswordForm.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
export default function ChangePasswordForm() {
|
||||
const [currentPassword, setCurrentPassword] = useState("")
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [success, setSuccess] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setSuccess("")
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("New passwords do not match")
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError("Password must be at least 6 characters")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/profile/change-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || "Failed to change password")
|
||||
} else {
|
||||
setSuccess("Password changed successfully!")
|
||||
setCurrentPassword("")
|
||||
setNewPassword("")
|
||||
setConfirmPassword("")
|
||||
setTimeout(() => {
|
||||
setSuccess("")
|
||||
}, 3000)
|
||||
}
|
||||
} catch {
|
||||
setError("An error occurred. Please try again.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700">
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
required
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
required
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-50 p-4">
|
||||
<p className="text-sm text-green-800">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Changing..." : "Change Password"}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
131
components/CreateUserForm.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function CreateUserForm() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [role, setRole] = useState<"ADMIN" | "USER">("USER")
|
||||
const [error, setError] = useState("")
|
||||
const [success, setSuccess] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setSuccess("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password, role }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || "Failed to create user")
|
||||
} else {
|
||||
setSuccess("User created successfully!")
|
||||
setName("")
|
||||
setEmail("")
|
||||
setPassword("")
|
||||
setRole("USER")
|
||||
router.refresh()
|
||||
setTimeout(() => {
|
||||
setSuccess("")
|
||||
}, 3000)
|
||||
}
|
||||
} catch {
|
||||
setError("An error occurred. Please try again.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Temporary Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="role" className="block text-sm font-medium text-gray-700">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as "ADMIN" | "USER")}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-900 focus:outline-none focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="USER">User</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-50 p-4">
|
||||
<p className="text-sm text-green-800">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Creating..." : "Create User"}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
102
components/DeletePhotoButton.tsx
Normal 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
@ -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
@ -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
@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
|
||||
export default function PhotoImage({ src, alt }: { src: string; alt: string }) {
|
||||
// Handle external URLs and local paths
|
||||
const isExternal = src.startsWith("http://") || src.startsWith("https://")
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="w-full h-full object-contain"
|
||||
style={{ maxHeight: "100%" }}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='600'%3E%3Crect fill='%23ddd' width='800' height='600'/%3E%3Ctext fill='%23999' font-family='sans-serif' font-size='20' dy='10.5' font-weight='bold' x='50%25' y='50%25' text-anchor='middle'%3EImage not found%3C/text%3E%3C/svg%3E"
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 600px"
|
||||
loading="lazy"
|
||||
unoptimized={src.startsWith("/uploads/")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
36
components/PhotoThumbnail.tsx
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
58
package.json
Normal 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
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
prisma.config.ts
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
1
public/vercel.svg
Normal 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
@ -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 |
BIN
test_image/istockphoto-1359149467-612x612.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
test_image/photo-1507003211169-0a1dd7228f2d.jpeg
Normal file
|
After Width: | Height: | Size: 1010 KiB |
BIN
test_image/sara.jpeg
Normal file
|
After Width: | Height: | Size: 904 KiB |
36
tsconfig.json
Normal 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
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
27
types/next-auth.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||