Compare commits

..

No commits in common. "75a4dc7a4fccdd8bd986365956690b472e300532" and "713584dc04f3d119d0aa1bd2034583a0f33f9ac7" have entirely different histories.

185 changed files with 32 additions and 33554 deletions

View File

@ -1,310 +0,0 @@
---
name: CI
on:
push:
branches: [master, dev]
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 admin-frontend dependencies
run: |
cd admin-frontend
npm ci
- name: Run ESLint (admin-frontend)
run: |
cd admin-frontend
npm run lint || true
continue-on-error: true
- name: Install viewer-frontend dependencies
run: |
cd viewer-frontend
npm ci
- name: Type check (viewer-frontend)
run: |
cd viewer-frontend
npm run type-check || true
continue-on-error: true
python-lint:
needs: skip-ci-check
runs-on: ubuntu-latest
if: needs.skip-ci-check.outputs.should-skip != '1'
container:
image: python:3.12-slim
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install Python dependencies
run: |
pip install --no-cache-dir flake8 black mypy pylint
- name: Check Python syntax
run: |
find backend -name "*.py" -exec python -m py_compile {} \; || true
continue-on-error: true
- name: Run flake8
run: |
flake8 backend --max-line-length=100 --ignore=E501,W503 || true
continue-on-error: true
test-backend:
needs: skip-ci-check
runs-on: ubuntu-latest
if: needs.skip-ci-check.outputs.should-skip != '1'
container:
image: python:3.12-slim
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: punimtag_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
env:
DATABASE_URL: postgresql+psycopg2://postgres:postgres@postgres:5432/punimtag_test
DATABASE_URL_AUTH: postgresql+psycopg2://postgres:postgres@postgres:5432/punimtag_auth_test
REDIS_URL: redis://redis:6379/0
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install Python dependencies
run: |
apt-get update && apt-get install -y postgresql-client
pip install --no-cache-dir -r requirements.txt
- name: Run backend tests
run: |
export PYTHONPATH=$(pwd)
python -m pytest tests/ -v || true
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 admin-frontend dependencies
run: |
cd admin-frontend
npm ci
- name: Build admin-frontend
run: |
cd admin-frontend
npm run build
env:
VITE_API_URL: http://localhost:8000
- name: Install viewer-frontend dependencies
run: |
cd viewer-frontend
npm ci
- name: Generate Prisma Client
run: |
cd viewer-frontend
npx prisma generate
- name: Build viewer-frontend
run: |
cd viewer-frontend
npm run build
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/punimtag
DATABASE_URL_AUTH: postgresql://postgres:postgres@localhost:5432/punimtag_auth
NEXTAUTH_SECRET: test-secret-key-for-ci
NEXTAUTH_URL: http://localhost:3001
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,venv \
--exit-code 0 \
.
- name: Secret scan (Trivy)
run: |
trivy fs \
--scanners secret \
--timeout 10m \
--skip-dirs .git,node_modules,venv \
--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, python-lint, test-backend, 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 "| 🐍 Python Lint | ${{ needs.python-lint.result }} |" >> $GITHUB_STEP_SUMMARY || true
echo "| 🧪 Backend Tests | ${{ needs.test-backend.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
continue-on-error: true

View File

@ -12,7 +12,7 @@ module.exports = {
},
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
project: ['./tsconfig.json'],
},
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
extends: [
@ -30,37 +30,21 @@ module.exports = {
'max-len': [
'error',
{
code: 120,
code: 100,
tabWidth: 2,
ignoreUrls: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreComments: true,
},
],
'react/react-in-jsx-scope': 'off',
'react/no-unescaped-entities': [
'error',
{
forbid: ['>', '}'],
},
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'react-hooks/exhaustive-deps': 'warn',
},
overrides: [
{
files: ['**/Help.tsx', '**/Dashboard.tsx'],
rules: {
'react/no-unescaped-entities': 'off',
},
},
],
}

View File

@ -159,7 +159,10 @@ export default function ApproveIdentified() {
}
}, [dateFrom, dateTo])
// Removed unused handleOpenReport function
const handleOpenReport = () => {
setShowReport(true)
loadReport()
}
const handleCloseReport = () => {
setShowReport(false)

View File

@ -180,6 +180,7 @@ export default function AutoMatch() {
} finally {
setSettingsLoaded(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Load state from sessionStorage on mount (people, current index, selected faces)

View File

@ -4,7 +4,7 @@ import { photosApi, PhotoSearchResult } from '../api/photos'
import apiClient from '../api/client'
export default function Dashboard() {
const { username: _username } = useAuth()
const { username } = useAuth()
const [samplePhotos, setSamplePhotos] = useState<PhotoSearchResult[]>([])
const [loadingPhotos, setLoadingPhotos] = useState(true)

View File

@ -386,7 +386,7 @@ export default function Identify() {
} finally {
setSettingsLoaded(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [photoIds])
// Load state from sessionStorage on mount (faces, current index, similar, form data)
@ -433,7 +433,7 @@ export default function Identify() {
} finally {
setStateRestored(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [photoIds])
// Save state to sessionStorage whenever it changes (but only after initial restore)
@ -530,7 +530,7 @@ export default function Identify() {
loadPeople()
loadTags()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsLoaded])
// Reset filters when photoIds is provided (to ensure all faces from those photos are shown)
@ -544,7 +544,7 @@ export default function Identify() {
// Keep uniqueFacesOnly as is (user preference)
// Keep sortBy/sortDir as defaults (quality desc)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [photoIds, settingsLoaded])
// Initial load on mount (after settings and state are loaded)
@ -951,7 +951,6 @@ export default function Identify() {
loadVideos()
loadPeople() // Load people for the dropdown
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, videosPage, videosPageSize, videosFolderFilter, videosDateFrom, videosDateTo, videosHasPeople, videosPersonName, videosSortBy, videosSortDir])
return (
@ -1291,6 +1290,7 @@ export default function Identify() {
crossOrigin="anonymous"
loading="eager"
onLoad={() => setImageLoading(false)}
onLoadStart={() => setImageLoading(true)}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'

View File

@ -305,7 +305,7 @@ export default function Modify() {
} finally {
setStateRestored(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {

View File

@ -2,7 +2,7 @@ import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { pendingPhotosApi, PendingPhotoResponse, ReviewDecision, CleanupResponse } from '../api/pendingPhotos'
import { apiClient } from '../api/client'
import { useAuth } from '../context/AuthContext'
// Removed unused videosApi import
import { videosApi } from '../api/videos'
type SortKey = 'photo' | 'uploaded_by' | 'file_info' | 'submitted_at' | 'status'
@ -259,7 +259,7 @@ export default function PendingPhotos() {
// Apply to all currently rejected photos
const rejectedPhotoIds = Object.entries(decisions)
.filter(([_id, decision]) => decision === 'reject')
.filter(([id, decision]) => decision === 'reject')
.map(([id]) => parseInt(id))
if (rejectedPhotoIds.length > 0) {

View File

@ -4,7 +4,15 @@ import { useDeveloperMode } from '../context/DeveloperModeContext'
type ViewMode = 'list' | 'icons' | 'compact'
// Removed unused interfaces PendingTagChange and PendingTagRemoval
interface PendingTagChange {
photoId: number
tagIds: number[]
}
interface PendingTagRemoval {
photoId: number
tagIds: number[]
}
interface FolderGroup {
folderPath: string
@ -33,7 +41,7 @@ const loadFolderStatesFromStorage = (): Record<string, boolean> => {
}
export default function Tags() {
const { isDeveloperMode: _isDeveloperMode } = useDeveloperMode()
const { isDeveloperMode } = useDeveloperMode()
const [viewMode, setViewMode] = useState<ViewMode>('list')
const [photos, setPhotos] = useState<PhotoWithTagsItem[]>([])
const [tags, setTags] = useState<TagResponse[]>([])
@ -42,7 +50,7 @@ export default function Tags() {
const [pendingTagChanges, setPendingTagChanges] = useState<Record<number, number[]>>({})
const [pendingTagRemovals, setPendingTagRemovals] = useState<Record<number, number[]>>({})
const [loading, setLoading] = useState(false)
const [_saving, setSaving] = useState(false)
const [saving, setSaving] = useState(false)
const [showManageTags, setShowManageTags] = useState(false)
const [showTagDialog, setShowTagDialog] = useState<number | null>(null)
const [showBulkTagDialog, setShowBulkTagDialog] = useState<string | null>(null)
@ -181,7 +189,7 @@ export default function Tags() {
aVal = a.face_count || 0
bVal = b.face_count || 0
break
case 'identified': {
case 'identified':
// Sort by identified count (identified/total ratio)
const aTotal = a.face_count || 0
const aIdentified = aTotal - (a.unidentified_face_count || 0)
@ -198,15 +206,13 @@ export default function Tags() {
bVal = bIdentified
}
break
}
case 'tags': {
case 'tags':
// Get tags for comparison - use photo.tags directly
const aTags = (a.tags || '').toLowerCase()
const bTags = (b.tags || '').toLowerCase()
aVal = aTags
bVal = bTags
break
}
default:
return 0
}
@ -415,7 +421,7 @@ export default function Tags() {
}
// Save pending changes
const _saveChanges = async () => {
const saveChanges = async () => {
const pendingPhotoIds = new Set([
...Object.keys(pendingTagChanges).map(Number),
...Object.keys(pendingTagRemovals).map(Number),
@ -484,7 +490,7 @@ export default function Tags() {
}
// Get pending changes count
const _pendingChangesCount = useMemo(() => {
const pendingChangesCount = useMemo(() => {
const additions = Object.values(pendingTagChanges).reduce((sum, ids) => sum + ids.length, 0)
const removals = Object.values(pendingTagRemovals).reduce((sum, ids) => sum + ids.length, 0)
return additions + removals
@ -1559,7 +1565,7 @@ function BulkTagDialog({
onRemoveTag,
getPhotoTags,
}: {
folderPath: string // eslint-disable-line @typescript-eslint/no-unused-vars
folderPath: string
folder: FolderGroup | undefined
tags: TagResponse[]
pendingTagChanges: Record<number, number[]>

View File

@ -1,128 +0,0 @@
# CI Workflow and Package Scripts Mapping
This document maps the Gitea CI workflow jobs to the corresponding npm scripts in package.json.
## CI Workflow Jobs → Package Scripts
### 1. `lint-and-type-check` Job
**CI Workflow:**
- Runs `npm run lint` in admin-frontend
- Runs `npm run type-check` in viewer-frontend
**Package Scripts:**
- `npm run lint:admin` - Lint admin-frontend
- `npm run lint:viewer` - Lint viewer-frontend
- `npm run type-check:viewer` - Type check viewer-frontend
- `npm run lint:all` - Lint both frontends
### 2. `python-lint` Job
**CI Workflow:**
- Installs flake8, black, mypy, pylint
- Runs Python syntax check: `find backend -name "*.py" -exec python -m py_compile {} \;`
- Runs flake8: `flake8 backend --max-line-length=100 --ignore=E501,W503`
**Package Scripts:**
- `npm run lint:python` - Run flake8 on backend
- `npm run lint:python:syntax` - Check Python syntax
### 3. `test-backend` Job
**CI Workflow:**
- Installs dependencies from requirements.txt
- Runs: `python -m pytest tests/ -v`
**Package Scripts:**
- `npm run test:backend` - Run backend tests with pytest
- `npm run test:all` - Run all tests (currently just backend)
### 4. `build` Job
**CI Workflow:**
- Builds admin-frontend: `npm run build`
- Generates Prisma client: `npx prisma generate`
- Builds viewer-frontend: `npm run build`
**Package Scripts:**
- `npm run build:admin` - Build admin-frontend
- `npm run build:viewer` - Build viewer-frontend
- `npm run build:all` - Build both frontends
### 5. Security Scans
**CI Workflow:**
- `secret-scanning` - Gitleaks
- `dependency-scan` - Trivy vulnerability and secret scanning
- `sast-scan` - Semgrep
**Package Scripts:**
- No local scripts (these are CI-only security scans)
## Combined Scripts
### `ci:local` - Run All CI Checks Locally
**Package Script:**
```bash
npm run ci:local
```
This runs:
1. `lint:all` - Lint both frontends
2. `type-check:viewer` - Type check viewer-frontend
3. `lint:python` - Lint Python backend
4. `test:backend` - Run backend tests
5. `build:all` - Build both frontends
**Note:** This is a convenience script to run all CI checks locally before pushing.
## Missing from CI (Not in Package Scripts)
These CI jobs don't have corresponding package scripts (by design):
- `secret-scanning` - Gitleaks (security tool, CI-only)
- `dependency-scan` - Trivy (security tool, CI-only)
- `sast-scan` - Semgrep (security tool, CI-only)
- `workflow-summary` - CI workflow summary generation
## Usage Examples
### Run All CI Checks Locally
```bash
npm run ci:local
```
### Run Individual Checks
```bash
# Frontend linting
npm run lint:all
# Type checking
npm run type-check:viewer
# Python linting
npm run lint:python
# Backend tests
npm run test:backend
# Build everything
npm run build:all
```
### Development
```bash
# Start all services
npm run dev:admin # Terminal 1
npm run dev:viewer # Terminal 2
npm run dev:backend # Terminal 3
```
## Notes
- All CI scripts use `continue-on-error: true` or `|| true` to not fail the build
- Local scripts also use `|| true` for non-critical checks
- The `ci:local` script will stop on first failure (unlike CI which continues)
- Python linting requires flake8: `pip install flake8`
- Backend tests require pytest: `pip install pytest`

View File

@ -14,12 +14,6 @@
"lint:admin": "npm run lint --prefix admin-frontend",
"lint:viewer": "npm run lint --prefix viewer-frontend",
"lint:all": "npm run lint:admin && npm run lint:viewer",
"type-check:viewer": "npm run type-check --prefix viewer-frontend",
"lint:python": "flake8 backend --max-line-length=100 --ignore=E501,W503 || true",
"lint:python:syntax": "find backend -name '*.py' -exec python -m py_compile {} \\;",
"test:backend": "export PYTHONPATH=$(pwd) && python -m pytest tests/ -v",
"test:all": "npm run test:backend",
"ci:local": "npm run lint:all && npm run type-check:viewer && npm run lint:python && npm run test:backend && npm run build:all",
"deploy:dev": "npm run build:all && echo '✅ Build complete. Ready for deployment to dev server (10.0.10.121)'",
"deploy:dev:prepare": "npm run build:all && mkdir -p deploy/package && cp -r backend deploy/package/ && cp -r admin-frontend/dist deploy/package/admin-frontend-dist && cp -r viewer-frontend/.next deploy/package/viewer-frontend-next && cp requirements.txt deploy/package/ && cp .env.example deploy/package/ && echo '✅ Deployment package prepared in deploy/package/'"
},

View File

@ -1,67 +0,0 @@
# Scripts Directory
This directory contains utility scripts organized by purpose.
## Directory Structure
### `db/` - Database Utilities
Database management and migration scripts:
- `drop_all_tables.py` - Drop all database tables
- `drop_all_tables_web.py` - Drop all web database tables
- `grant_auth_db_permissions.py` - Grant permissions on auth database
- `migrate_sqlite_to_postgresql.py` - Migrate from SQLite to PostgreSQL
- `recreate_tables_web.py` - Recreate web database tables
- `show_db_tables.py` - Display database table information
### `debug/` - Debug and Analysis Scripts
Debugging and analysis tools:
- `analyze_all_faces.py` - Analyze all faces in database
- `analyze_pose_matching.py` - Analyze face pose matching
- `analyze_poses.py` - Analyze face poses
- `check_database_tables.py` - Check database table structure
- `check_identified_poses_web.py` - Check identified poses in web database
- `check_two_faces_pose.py` - Compare poses of two faces
- `check_yaw_angles.py` - Check face yaw angles
- `debug_pose_classification.py` - Debug pose classification
- `diagnose_frontend_issues.py` - Diagnose frontend issues
- `test_eye_visibility.py` - Test eye visibility detection
- `test_pose_calculation.py` - Test pose calculation
### `utils/` - Utility Scripts
General utility scripts:
- `fix_admin_password.py` - Fix admin user password
- `update_reported_photo_status.py` - Update reported photo status
## Root-Level Scripts
Project-specific scripts remain in the repository root:
- `install.sh` - Installation script
- `run_api_with_worker.sh` - Start API with worker
- `start_backend.sh` - Start backend server
- `stop_backend.sh` - Stop backend server
- `run_worker.sh` - Run RQ worker
- `demo.sh` - Demo helper script
## Database Shell Scripts
Database-related shell scripts remain in `scripts/`:
- `drop_auth_database.sh` - Drop auth database
- `grant_auth_db_delete_permission.sh` - Grant delete permissions
- `setup_postgresql.sh` - Set up PostgreSQL
## Usage
Most scripts can be run directly:
```bash
# Database utilities
python scripts/db/show_db_tables.py
# Debug scripts
python scripts/debug/analyze_all_faces.py
# Utility scripts
python scripts/utils/fix_admin_password.py
```
Some scripts may require environment variables or database connections. Check individual script documentation or comments for specific requirements.

View File

@ -14,4 +14,3 @@ else
fi

View File

@ -1,15 +0,0 @@
# Ignore history files and directories
.history/
*.history
*_YYYYMMDDHHMMSS.*
*_timestamp.*
# Ignore backup files
*.bak
*.backup
*~
# Ignore temporary files
*.tmp
*.temp

View File

@ -1,31 +0,0 @@
# Cursor Rules for PunimTag Viewer
## File Management
- NEVER create history files or backup files with timestamps
- NEVER create files in .history/ directory
- NEVER create files with patterns like: *_YYYYMMDDHHMMSS.* or *_timestamp.*
- DO NOT use Local History extension features that create history files
- When editing files, edit them directly - do not create timestamped copies
## Code Style
- Use TypeScript for all new files
- Follow Next.js 14 App Router conventions
- Use shadcn/ui components when available
- Prefer Server Components over Client Components when possible
- Use 'use client' directive only when necessary (interactivity, hooks, browser APIs)
## File Naming
- Use kebab-case for file names: `photo-grid.tsx`, `search-content.tsx`
- Use PascalCase for component names: `PhotoGrid`, `SearchContent`
- Use descriptive, clear names - avoid abbreviations
## Development Practices
- Edit files in place - do not create backup copies
- Use Git for version control, not file history extensions
- Test changes before committing
- Follow the existing code structure and patterns

View File

@ -1,19 +0,0 @@
# Database Configuration
# Read-only database connection (for reading photos, faces, people, tags)
DATABASE_URL="postgresql://viewer_readonly:password@localhost:5432/punimtag"
# Write-capable database connection (for user registration, pending identifications)
# If not set, will fall back to DATABASE_URL
# Option 1: Use the same user (after granting write permissions)
# DATABASE_URL_WRITE="postgresql://viewer_readonly:password@localhost:5432/punimtag"
# Option 2: Use a separate write user (recommended)
DATABASE_URL_WRITE="postgresql://viewer_write:password@localhost:5432/punimtag"
# NextAuth Configuration
# Generate a secure secret using: openssl rand -base64 32
NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32"
NEXTAUTH_URL="http://localhost:3001"
# Site Configuration
NEXT_PUBLIC_SITE_NAME="PunimTag Photo Viewer"
NEXT_PUBLIC_SITE_DESCRIPTION="Family Photo Gallery"

View File

@ -1,48 +0,0 @@
# 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
# history files (from Local History extension)
.history/
*.history

View File

@ -1 +0,0 @@
# Ensure npm doesn't treat this as a workspace

View File

@ -1,156 +0,0 @@
# Email Verification Setup
This document provides step-by-step instructions to complete the email verification setup.
## ✅ Already Completed
1. ✅ Resend package installed
2. ✅ Prisma schema updated
3. ✅ Prisma client regenerated
4. ✅ Code implementation complete
5. ✅ API endpoints created
6. ✅ UI components updated
## 🔧 Remaining Steps
### Step 1: Run Database Migration
The database migration needs to be run as a PostgreSQL superuser (or a user with ALTER TABLE permissions).
**Option A: Using psql as postgres user**
```bash
sudo -u postgres psql -d punimtag_auth -f migrations/add-email-verification-columns.sql
```
**Option B: Using psql with password**
```bash
psql -U postgres -d punimtag_auth -f migrations/add-email-verification-columns.sql
```
**Option C: Manual SQL execution**
Connect to your database and run:
```sql
\c punimtag_auth
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_confirmation_token VARCHAR(255) UNIQUE;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_confirmation_token_expiry TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_token ON users(email_confirmation_token);
UPDATE users
SET email_verified = true
WHERE email_confirmation_token IS NULL;
```
### Step 2: Set Up Resend
1. **Sign up for Resend:**
- Go to [resend.com](https://resend.com)
- Create a free account (3,000 emails/month free tier)
2. **Get your API key:**
- Go to API Keys in your Resend dashboard
- Create a new API key
- Copy the key (starts with `re_`)
3. **Add to your `.env` file:**
```bash
RESEND_API_KEY="re_your_api_key_here"
RESEND_FROM_EMAIL="noreply@yourdomain.com"
```
**For development/testing:**
- You can use Resend's test domain: `onboarding@resend.dev`
- No domain verification needed for testing
**For production:**
- Verify your domain in Resend dashboard
- Use your verified domain: `noreply@yourdomain.com`
### Step 3: Verify Setup
1. **Check database columns:**
```sql
\c punimtag_auth
\d users
```
You should see:
- `email_verified` (boolean)
- `email_confirmation_token` (varchar)
- `email_confirmation_token_expiry` (timestamp)
2. **Test registration:**
- Go to your registration page
- Create a new account
- Check your email for the confirmation message
- Click the confirmation link
- Try logging in
3. **Test resend:**
- If email doesn't arrive, try the "Resend confirmation email" option on the login page
## 🔍 Troubleshooting
### "must be owner of table users"
- You need to run the migration as a PostgreSQL superuser
- Use `sudo -u postgres` or connect as the `postgres` user
### "Failed to send confirmation email"
- Check that `RESEND_API_KEY` is set correctly in `.env`
- Verify the API key is valid in Resend dashboard
- Check server logs for detailed error messages
### "Email not verified" error on login
- Make sure the user clicked the confirmation link
- Check that the token hasn't expired (24 hours)
- Use "Resend confirmation email" to get a new link
### Existing users can't log in
- The migration sets `email_verified = true` for existing users automatically
- If issues persist, manually update:
```sql
UPDATE users SET email_verified = true WHERE email_confirmation_token IS NULL;
```
## 📝 Environment Variables Summary
Add these to your `.env` file:
```bash
# Required for email verification
RESEND_API_KEY="re_your_api_key_here"
RESEND_FROM_EMAIL="noreply@yourdomain.com"
# Optional: Override base URL for email links
# NEXT_PUBLIC_APP_URL="http://localhost:3001"
```
## ✅ Verification Checklist
- [ ] Database migration run successfully
- [ ] `email_verified` column exists in `users` table
- [ ] `email_confirmation_token` column exists
- [ ] `email_confirmation_token_expiry` column exists
- [ ] `RESEND_API_KEY` set in `.env`
- [ ] `RESEND_FROM_EMAIL` set in `.env`
- [ ] Test registration sends email
- [ ] Email confirmation link works
- [ ] Login works after verification
- [ ] Resend confirmation email works
## 🎉 You're Done!
Once all steps are complete, email verification is fully functional. New users will need to verify their email before they can log in.

View File

@ -1,191 +0,0 @@
# Face Tooltip and Click-to-Identify Analysis
## Issues Identified
### 1. **Image Reference Not Being Set Properly**
**Location:** `PhotoViewerClient.tsx` lines 546-549, 564-616
**Problem:**
- The `imageRef` is set in `handleImageLoad` callback (line 548)
- However, `findFaceAtPoint` checks if `imageRef.current` exists (line 569)
- If `imageRef.current` is null, face detection fails completely
- Next.js `Image` component with `fill` prop may not reliably trigger `onLoad` or the ref may not be accessible
**Evidence:**
```typescript
const findFaceAtPoint = useCallback((x: number, y: number) => {
// ...
if (!imageRef.current || !containerRef.current) {
return null; // ← This will prevent ALL face detection if ref isn't set
}
// ...
}, [currentPhoto.faces]);
```
**Impact:** If `imageRef.current` is null, `findFaceAtPoint` always returns null, so:
- No faces are detected on hover
- `hoveredFace` state never gets set
- Tooltips never appear
- Click detection never works
---
### 2. **Tooltip Logic Issues**
**Location:** `PhotoViewerClient.tsx` lines 155-159
**Problem:** The tooltip logic has restrictive conditions:
```typescript
const hoveredFaceTooltip = hoveredFace
? hoveredFace.personName
? (isLoggedIn ? hoveredFace.personName : null) // ← Issue: hides name if not logged in
: (!session || hasWriteAccess ? 'Identify' : null) // ← Issue: hides "Identify" for logged-in users without write access
: null;
```
**Issues:**
1. **Identified faces:** Tooltip only shows if user is logged in. If not logged in, tooltip is `null` even though face is identified.
2. **Unidentified faces:** Tooltip shows "Identify" only if:
- User is NOT signed in, OR
- User has write access
- If user is logged in but doesn't have write access, tooltip is `null`
**Expected Behavior:**
- Identified faces should show person name regardless of login status
- Unidentified faces should show "Identify" if user has write access (or is not logged in)
---
### 3. **Click Handler Logic Issues**
**Location:** `PhotoViewerClient.tsx` lines 661-686
**Problem:** The click handler has restrictive conditions:
```typescript
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
// ...
const face = findFaceAtPoint(e.clientX, e.clientY);
// Only allow clicking if: face is identified, or user is not signed in, or user has write access
if (face && (face.person || !session || hasWriteAccess)) {
setClickedFace({...});
setIsDialogOpen(true);
}
}, [findFaceAtPoint, session, hasWriteAccess, currentPhoto, isVideoPlaying]);
```
**Issues:**
1. If `findFaceAtPoint` returns null (due to imageRef issue), click never works
2. If user is logged in without write access and face is unidentified, click is blocked
3. The condition `face.person || !session || hasWriteAccess` means:
- Can click identified faces (anyone)
- Can click unidentified faces only if not logged in OR has write access
- Logged-in users without write access cannot click unidentified faces
**Expected Behavior:**
- Should allow clicking unidentified faces if user has write access
- Should allow clicking identified faces to view/edit (if has write access)
---
### 4. **Click Handler Event Blocking**
**Location:** `PhotoViewerClient.tsx` lines 1096-1106
**Problem:** The click handler checks for buttons and zoom controls, but also checks `isDragging`:
```typescript
onClick={(e) => {
// Don't handle click if it's on a button or zoom controls
const target = e.target as HTMLElement;
if (target.closest('button') || target.closest('[aria-label*="Zoom"]') || target.closest('[aria-label*="Reset zoom"]')) {
return;
}
// For images, only handle click if not dragging
if (!isDragging || zoom === 1) { // ← Issue: if dragging and zoomed, click is ignored
handleClick(e);
}
}}
```
**Issue:** If user is dragging (panning) and then clicks, the click is ignored. This might prevent face clicks if there's any drag state.
---
### 5. **Data Structure Mismatch (Potential)**
**Location:** `page.tsx` line 182 vs `PhotoViewerClient.tsx` line 33
**Problem:**
- Database query uses `Face` (capital F) and `Person` (capital P)
- Component expects `faces` (lowercase) and `person` (lowercase)
- Serialization function should handle this, but if it doesn't, faces won't be available
**Evidence:**
- `page.tsx` line 182: `Face: faces.filter(...)` (capital F)
- `PhotoViewerClient.tsx` line 33: `faces?: FaceWithLocation[]` (lowercase)
- Component accesses `currentPhoto.faces` (lowercase)
**Impact:** If serialization doesn't transform `Face``faces`, then `currentPhoto.faces` will be undefined, and face detection won't work.
---
## Root Cause Analysis
### Primary Issue: Image Reference
The most likely root cause is that `imageRef.current` is not being set properly, which causes:
1. `findFaceAtPoint` to always return null
2. No face detection on hover
3. No tooltips
4. No click detection
### Secondary Issues: Logic Conditions
Even if imageRef works, the tooltip and click logic have restrictive conditions that prevent:
- Showing tooltips for identified faces when not logged in
- Showing "Identify" tooltip for logged-in users without write access
- Clicking unidentified faces for logged-in users without write access
---
## Recommended Fixes
### Fix 1: Ensure Image Reference is Set
- Add a ref directly to the Image component's container or use a different approach
- Add fallback to find image element via DOM query if ref isn't set
- Add debug logging to verify ref is being set
### Fix 2: Fix Tooltip Logic
- Show person name for identified faces regardless of login status
- Show "Identify" for unidentified faces only if user has write access (or is not logged in)
### Fix 3: Fix Click Handler Logic
- Allow clicking unidentified faces if user has write access
- Allow clicking identified faces to view/edit (if has write access)
- Remove the `isDragging` check or make it more lenient
### Fix 4: Verify Data Structure
- Ensure serialization transforms `Face``faces` and `Person``person`
- Add debug logging to verify faces are present in `currentPhoto.faces`
### Fix 5: Add Debug Logging
- Log when `imageRef.current` is set
- Log when `findFaceAtPoint` is called and what it returns
- Log when `hoveredFace` state changes
- Log when click handler is triggered and what conditions are met
---
## Testing Checklist
After fixes, verify:
- [ ] Image ref is set after image loads
- [ ] Hovering over identified face shows person name (logged in and not logged in)
- [ ] Hovering over unidentified face shows "Identify" if user has write access
- [ ] Clicking identified face opens dialog (if has write access)
- [ ] Clicking unidentified face opens dialog (if has write access)
- [ ] Tooltips appear at correct position near cursor
- [ ] Click works even after panning/zooming

View File

@ -1,114 +0,0 @@
# Granting Database Permissions
This document describes how to grant read-only permissions to the `viewer_readonly` user on the main `punimtag` database tables.
## Quick Reference
**✅ WORKING METHOD (tested and confirmed):**
```bash
PGPASSWORD=punimtag_password psql -h localhost -U punimtag -d punimtag -f grant_readonly_permissions.sql
```
## When to Run This
Run this script when you see errors like:
- `permission denied for table photos`
- `permission denied for table people`
- `permission denied for table faces`
- Any other "permission denied" errors when accessing database tables
This typically happens when:
- Database tables are recreated/dropped
- Database is restored from backup
- Permissions are accidentally revoked
- Setting up a new environment
## Methods
### Method 1: Using punimtag user (Recommended - Tested)
```bash
PGPASSWORD=punimtag_password psql -h localhost -U punimtag -d punimtag -f grant_readonly_permissions.sql
```
### Method 2: Using postgres user
```bash
PGPASSWORD=postgres_password psql -h localhost -U postgres -d punimtag -f grant_readonly_permissions.sql
```
### Method 3: Using sudo
```bash
sudo -u postgres psql -d punimtag -f grant_readonly_permissions.sql
```
### Method 4: Manual connection
```bash
psql -U punimtag -d punimtag
```
Then paste these commands:
```sql
GRANT CONNECT ON DATABASE punimtag TO viewer_readonly;
GRANT USAGE ON SCHEMA public TO viewer_readonly;
GRANT SELECT ON TABLE photos TO viewer_readonly;
GRANT SELECT ON TABLE people TO viewer_readonly;
GRANT SELECT ON TABLE faces TO viewer_readonly;
GRANT SELECT ON TABLE person_encodings TO viewer_readonly;
GRANT SELECT ON TABLE tags TO viewer_readonly;
GRANT SELECT ON TABLE phototaglinkage TO viewer_readonly;
GRANT SELECT ON TABLE photo_favorites TO viewer_readonly;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO viewer_readonly;
```
## Verification
After granting permissions, verify they work:
1. **Check permissions script:**
```bash
npm run check:permissions
```
2. **Check health endpoint:**
```bash
curl http://localhost:3001/api/health
```
3. **Test the website:**
- Refresh the browser
- Photos should load without permission errors
- Search functionality should work
## What Permissions Are Granted
The script grants the following permissions to `viewer_readonly`:
- **CONNECT** on database `punimtag`
- **USAGE** on schema `public`
- **SELECT** on tables:
- `photos`
- `people`
- `faces`
- `person_encodings`
- `tags`
- `phototaglinkage`
- `photo_favorites`
- **USAGE, SELECT** on all sequences in schema `public`
- **Default privileges** for future tables (optional)
## Notes
- Replace `punimtag_password` with the actual password for the `punimtag` user (found in `.env` file)
- The `viewer_readonly` user should only have SELECT permissions (read-only)
- If you need write access, use `DATABASE_URL_WRITE` with a different user (`viewer_write`)

View File

@ -1,485 +0,0 @@
# PunimTag Photo Viewer
A modern, fast, and beautiful photo viewing website that connects to your PunimTag PostgreSQL database.
## 🚀 Quick Start
### Prerequisites
See the [Prerequisites Guide](docs/PREREQUISITES.md) for a complete list of required and optional software.
**Required:**
- Node.js 20+ (currently using 18.19.1 - may need upgrade)
- PostgreSQL database with PunimTag schema
- Read-only database user (see setup below)
**Optional:**
- **FFmpeg** (for video thumbnail generation) - See [FFmpeg Setup Guide](docs/FFMPEG_SETUP.md)
- **libvips** (for image watermarking) - See [Prerequisites Guide](docs/PREREQUISITES.md)
- **Resend API Key** (for email verification)
- **Network-accessible storage** (for photo uploads)
### Installation
**Quick Setup (Recommended):**
```bash
# Run the comprehensive setup script
npm run setup
```
This will:
- Install all npm dependencies
- Set up Sharp library (for image processing)
- Generate Prisma clients
- Set up database tables (if DATABASE_URL_AUTH is configured)
- Create admin user (if needed)
- Verify the setup
**Manual Setup:**
1. **Install dependencies:**
```bash
npm run install:deps
# Or manually:
npm install
npm run prisma:generate:all
```
The install script will:
- Check Node.js version
- Install npm dependencies
- Set up Sharp library (for image processing)
- Generate Prisma clients
- Check for optional system dependencies (libvips, FFmpeg)
2. **Set up environment variables:**
Create a `.env` file in the root directory:
```bash
DATABASE_URL="postgresql://viewer_readonly:password@localhost:5432/punimtag"
DATABASE_URL_WRITE="postgresql://viewer_write:password@localhost:5432/punimtag"
DATABASE_URL_AUTH="postgresql://viewer_write:password@localhost:5432/punimtag_auth"
NEXTAUTH_SECRET="your-secret-key-here"
NEXTAUTH_URL="http://localhost:3001"
NEXT_PUBLIC_SITE_NAME="PunimTag Photo Viewer"
NEXT_PUBLIC_SITE_DESCRIPTION="Family Photo Gallery"
# Email verification (Resend)
RESEND_API_KEY="re_your_resend_api_key_here"
RESEND_FROM_EMAIL="noreply@yourdomain.com"
# Optional: Override base URL for email links (defaults to NEXTAUTH_URL)
# NEXT_PUBLIC_APP_URL="http://localhost:3001"
# Upload directory for pending photos (REQUIRED - must be network-accessible)
# RECOMMENDED: Use the same server as your database (see docs/NETWORK_SHARE_SETUP.md)
# Examples:
# Database server via SSHFS: /mnt/db-server-uploads/pending-photos
# Separate network share: /mnt/shared/pending-photos
# Windows: \\server\share\pending-photos (mapped to drive)
UPLOAD_DIR="/mnt/db-server-uploads/pending-photos"
# Or use PENDING_PHOTOS_DIR as an alias
# PENDING_PHOTOS_DIR="/mnt/network-share/pending-photos"
```
**Note:** Generate a secure `NEXTAUTH_SECRET` using:
```bash
openssl rand -base64 32
```
3. **Grant read-only permissions on main database tables:**
The read-only user needs SELECT permissions on all main tables. If you see "permission denied" errors, run:
**✅ WORKING METHOD (tested and confirmed):**
```bash
PGPASSWORD=punimtag_password psql -h localhost -U punimtag -d punimtag -f grant_readonly_permissions.sql
```
**Alternative methods:**
```bash
# Using postgres user:
PGPASSWORD=postgres_password psql -h localhost -U postgres -d punimtag -f grant_readonly_permissions.sql
# Using sudo:
sudo -u postgres psql -d punimtag -f grant_readonly_permissions.sql
```
**Check permissions:**
```bash
npm run check:permissions
```
This will verify all required permissions and provide instructions if any are missing.
**For Face Identification (Write Access):**
You have two options to enable write access for face identification:
**Option 1: Grant write permissions to existing user** (simpler)
```bash
# Run as PostgreSQL superuser:
psql -U postgres -d punimtag -f grant_write_permissions.sql
```
Then use the same `DATABASE_URL` for both read and write operations.
**Option 2: Create a separate write user** (more secure)
```bash
# Run as PostgreSQL superuser:
psql -U postgres -d punimtag -f create_write_user.sql
```
Then add to your `.env` file:
```bash
DATABASE_URL_WRITE="postgresql://viewer_write:password@localhost:5432/punimtag"
```
4. **Create database tables for authentication:**
```bash
# Run as PostgreSQL superuser:
psql -U postgres -d punimtag_auth -f create_auth_tables.sql
```
**Add pending_photos table for photo uploads:**
```bash
# Run as PostgreSQL superuser:
psql -U postgres -d punimtag_auth -f migrations/add-pending-photos-table.sql
```
**Add email verification columns:**
```bash
# Run as PostgreSQL superuser:
psql -U postgres -d punimtag_auth -f migrations/add-email-verification-columns.sql
```
Then grant permissions to your write user:
```sql
-- If using viewer_write user:
GRANT SELECT, INSERT, UPDATE ON TABLE users TO viewer_write;
GRANT SELECT, INSERT, UPDATE ON TABLE pending_identifications TO viewer_write;
GRANT SELECT, INSERT, UPDATE ON TABLE pending_photos TO viewer_write;
GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO viewer_write;
GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO viewer_write;
GRANT USAGE, SELECT ON SEQUENCE pending_photos_id_seq TO viewer_write;
-- Or if using viewer_readonly with write permissions:
GRANT SELECT, INSERT, UPDATE ON TABLE users TO viewer_readonly;
GRANT SELECT, INSERT, UPDATE ON TABLE pending_identifications TO viewer_readonly;
GRANT SELECT, INSERT, UPDATE ON TABLE pending_photos TO viewer_readonly;
GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO viewer_readonly;
GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO viewer_readonly;
GRANT USAGE, SELECT ON SEQUENCE pending_photos_id_seq TO viewer_readonly;
```
5. **Generate Prisma client:**
```bash
npx prisma generate
```
6. **Run development server:**
```bash
npm run dev
```
7. **Open your browser:**
Navigate to http://localhost:3000
## 📁 Project Structure
```
punimtag-viewer/
├── app/ # Next.js 14 App Router
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Home page (photo grid with search)
│ ├── HomePageContent.tsx # Client component for home page
│ ├── search/ # Search page
│ │ ├── page.tsx # Search page
│ │ └── SearchContent.tsx # Search content component
│ └── api/ # API routes
│ ├── search/ # Search API endpoint
│ └── photos/ # Photo API endpoints
├── components/ # React components
│ ├── PhotoGrid.tsx # Photo grid with tooltips
│ ├── search/ # Search components
│ │ ├── CollapsibleSearch.tsx # Collapsible search bar
│ │ ├── FilterPanel.tsx # Filter panel
│ │ ├── PeopleFilter.tsx # People filter
│ │ ├── DateRangeFilter.tsx # Date range filter
│ │ ├── TagFilter.tsx # Tag filter
│ │ └── SearchBar.tsx # Search bar component
│ └── ui/ # shadcn/ui components
├── lib/ # Utilities
│ ├── db.ts # Prisma client
│ └── queries.ts # Database query helpers
├── prisma/
│ └── schema.prisma # Database schema
└── public/ # Static assets
```
## 🔐 Database Setup
### Create Read-Only User
On your PostgreSQL server, run:
```sql
-- Create read-only user
CREATE USER viewer_readonly WITH PASSWORD 'your_secure_password';
-- Grant permissions
GRANT CONNECT ON DATABASE punimtag TO viewer_readonly;
GRANT USAGE ON SCHEMA public TO viewer_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO viewer_readonly;
-- Grant on future tables
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO viewer_readonly;
-- Verify no write permissions
REVOKE INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public FROM viewer_readonly;
```
## 🎨 Features
- ✅ Photo grid with responsive layout
- ✅ Image optimization with Next.js Image
- ✅ Read-only database access
- ✅ Type-safe queries with Prisma
- ✅ Modern, clean design
- ✅ **Collapsible search bar** on main page with filters
- ✅ **Search functionality** - Search by people, dates, and tags
- ✅ **Photo tooltips** - Hover over photos to see people names
- ✅ **Search page** - Dedicated search page at `/search`
- ✅ **Filter panel** - People, date range, and tag filters
## ✉️ Email Verification
The application includes email verification for new user registrations. Users must verify their email address before they can sign in.
### Setup
1. **Get a Resend API Key:**
- Sign up at [resend.com](https://resend.com)
- Create an API key in your dashboard
- Add it to your `.env` file:
```bash
RESEND_API_KEY="re_your_api_key_here"
RESEND_FROM_EMAIL="noreply@yourdomain.com"
```
2. **Run the Database Migration:**
```bash
psql -U postgres -d punimtag_auth -f migrations/add-email-verification-columns.sql
```
3. **Configure Email Domain (Optional):**
- For production, verify your domain in Resend
- Update `RESEND_FROM_EMAIL` to use your verified domain
- For development, you can use Resend's test domain (`onboarding@resend.dev`)
### How It Works
1. **Registration:** When a user signs up, they receive a confirmation email with a verification link
2. **Verification:** Users click the link to verify their email address
3. **Login:** Users must verify their email before they can sign in
4. **Resend:** Users can request a new confirmation email if needed
### Features
- ✅ Secure token-based verification (24-hour expiration)
- ✅ Email verification required before login
- ✅ Resend confirmation email functionality
- ✅ User-friendly error messages
- ✅ Backward compatible (existing users are auto-verified)
## 📤 Photo Uploads
Users can upload photos for admin review. Uploaded photos are stored on a **network-accessible location** (required) and tracked in the database.
### Storage Location
Uploaded photos are stored in a directory structure organized by user ID:
```
{UPLOAD_DIR}/
└── {userId}/
└── {timestamp}-{filename}
```
**Configuration (REQUIRED):**
- **Must** set `UPLOAD_DIR` or `PENDING_PHOTOS_DIR` environment variable
- **Must** point to a network-accessible location (database server recommended)
- The directory will be created automatically if it doesn't exist
**Recommended: Use Database Server**
The simplest setup is to use the same server where your PostgreSQL database is located:
1. **Create directory on database server:**
```bash
ssh user@db-server.example.com
sudo mkdir -p /var/punimtag/uploads/pending-photos
```
2. **Mount database server on web server (via SSHFS):**
```bash
sudo apt-get install sshfs
sudo mkdir -p /mnt/db-server-uploads
sudo sshfs user@db-server.example.com:/var/punimtag/uploads /mnt/db-server-uploads
```
3. **Set in .env:**
```bash
UPLOAD_DIR="/mnt/db-server-uploads/pending-photos"
```
**See full setup guide:** [`docs/NETWORK_SHARE_SETUP.md`](docs/NETWORK_SHARE_SETUP.md)
**Important:**
- Ensure the web server process has read/write permissions
- The approval system must have read access to the same location
- Test network connectivity and permissions before deploying
### Database Tracking
Upload metadata is stored in the `pending_photos` table in the `punimtag_auth` database:
- File location and metadata
- User who uploaded
- Status: `pending`, `approved`, `rejected`
- Review information (when reviewed, by whom, rejection reason)
### Access for Approval System
The approval system can:
1. **Read files from disk** using the `file_path` from the database
2. **Query the database** for pending photos:
```sql
SELECT * FROM pending_photos WHERE status = 'pending' ORDER BY submitted_at;
```
3. **Update status** after review:
```sql
UPDATE pending_photos
SET status = 'approved', reviewed_at = NOW(), reviewed_by = {admin_user_id}
WHERE id = {photo_id};
```
## 🚧 Coming Soon
- [ ] Photo detail page with lightbox
- [ ] Infinite scroll
- [ ] Favorites system
- [ ] People and tags browsers
- [ ] Authentication (optional)
## 📚 Documentation
For complete documentation, see:
- [Quick Start Guide](../../punimtag/docs/PHOTO_VIEWER_QUICKSTART.md)
- [Complete Plan](../../punimtag/docs/PHOTO_VIEWER_PLAN.md)
- [Architecture](../../punimtag/docs/PHOTO_VIEWER_ARCHITECTURE.md)
## 🛠️ Development
### Available Scripts
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run start` - Start production server
- `npm run lint` - Run ESLint
- `npm run check:permissions` - Check database permissions and provide fix instructions
### Prisma Commands
- `npx prisma generate` - Generate Prisma client
- `npx prisma studio` - Open Prisma Studio (database browser)
- `npx prisma db pull` - Pull schema from database
## 🔍 Troubleshooting
### Permission Denied Errors
If you see "permission denied for table photos" errors:
1. **Check permissions:**
```bash
npm run check:permissions
```
2. **Grant permissions (WORKING METHOD - tested and confirmed):**
```bash
PGPASSWORD=punimtag_password psql -h localhost -U punimtag -d punimtag -f grant_readonly_permissions.sql
```
**Alternative methods:**
```bash
# Using postgres user:
PGPASSWORD=postgres_password psql -h localhost -U postgres -d punimtag -f grant_readonly_permissions.sql
# Using sudo:
sudo -u postgres psql -d punimtag -f grant_readonly_permissions.sql
```
3. **Or check health endpoint:**
```bash
curl http://localhost:3001/api/health
```
### Database Connection Issues
- Verify `DATABASE_URL` is set correctly in `.env`
- Check that the database user exists and has the correct password
- Ensure PostgreSQL is running and accessible
## ⚠️ Known Issues
- Node.js version: Currently using Node 18.19.1, but Next.js 16 requires >=20.9.0
- **Solution:** Upgrade Node.js or use Node Version Manager (nvm)
## 📝 Notes
### Image Serving (Hybrid Approach)
The application automatically detects and handles two types of photo storage:
1. **HTTP/HTTPS URLs** (SharePoint, CDN, etc.)
- If `photo.path` starts with `http://` or `https://`, images are served directly
- Next.js Image optimization is applied automatically
- Configure allowed domains in `next.config.ts``remotePatterns`
2. **File System Paths** (Local storage)
- If `photo.path` is a file system path, images are served via API proxy
- Make sure photo file paths are accessible from the Next.js server
- No additional configuration needed
**Benefits:**
- ✅ Works with both SharePoint URLs and local file system
- ✅ Automatic detection - no configuration needed per photo
- ✅ Optimal performance for both storage types
- ✅ No N+1 database queries (path passed via query parameter)
### Search Features
The application includes a powerful search system:
1. **Collapsible Search Bar** (Main Page)
- Minimized by default to save space
- Click to expand and reveal full filter panel
- Shows active filter count badge
- Filters photos in real-time
2. **Search Filters**
- **People Filter**: Multi-select searchable dropdown
- **Date Range Filter**: Presets (Today, This Week, This Month, This Year) or custom range
- **Tag Filter**: Multi-select searchable tag filter
- All filters work together with AND logic
3. **Photo Tooltips**
- Hover over any photo to see people names
- Shows "People: Name1, Name2" if people are identified
- Falls back to filename if no people identified
4. **Search Page** (`/search`)
- Dedicated search page with full filter panel
- URL query parameter sync for shareable search links
- Pagination support
## 🤝 Contributing
This is a private project. For questions or issues, refer to the main PunimTag documentation.
---
**Built with:** Next.js 14, React, TypeScript, Prisma, Tailwind CSS

View File

@ -1,264 +0,0 @@
# PunimTag Photo Viewer - Setup Instructions
## ✅ What's Been Completed
1. ✅ Next.js 14 project created with TypeScript and Tailwind CSS
2. ✅ Core dependencies installed:
- Prisma ORM
- TanStack Query
- React Photo Album
- Yet Another React Lightbox
- Lucide React (icons)
- Framer Motion (animations)
- Date-fns (date handling)
- shadcn/ui components (button, input, select, calendar, popover, badge, checkbox, tooltip)
3. ✅ Prisma schema created matching PunimTag database structure
4. ✅ Database connection utility created (`lib/db.ts`)
5. ✅ Initial home page with photo grid component
6. ✅ Next.js image optimization configured
7. ✅ shadcn/ui initialized
8. ✅ **Collapsible search bar** on main page
9. ✅ **Search functionality** - Search by people, dates, and tags
10. ✅ **Search API endpoint** (`/api/search`)
11. ✅ **Search page** at `/search`
12. ✅ **Photo tooltips** showing people names on hover
13. ✅ **Filter components** - People, Date Range, and Tag filters
## 🔧 Next Steps to Complete Setup
### 1. Configure Database Connection
Create a `.env` file in the project root:
```bash
DATABASE_URL="postgresql://viewer_readonly:your_password@localhost:5432/punimtag"
NEXT_PUBLIC_SITE_NAME="PunimTag Photo Viewer"
NEXT_PUBLIC_SITE_DESCRIPTION="Family Photo Gallery"
```
**Important:** Replace `your_password` with the actual password for the read-only database user.
### 2. Create Read-Only Database User (if not already done)
Connect to your PostgreSQL database and run:
```sql
-- Create read-only user
CREATE USER viewer_readonly WITH PASSWORD 'your_secure_password';
-- Grant permissions
GRANT CONNECT ON DATABASE punimtag TO viewer_readonly;
GRANT USAGE ON SCHEMA public TO viewer_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO viewer_readonly;
-- Grant on future tables
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO viewer_readonly;
-- Verify no write permissions
REVOKE INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public FROM viewer_readonly;
```
### 3. Install System Dependencies (Optional but Recommended)
**For Image Watermarking (libvips):**
```bash
# Ubuntu/Debian
sudo apt update
sudo apt install libvips-dev
# Rebuild sharp package after installing libvips
cd viewer-frontend
npm rebuild sharp
```
**For Video Thumbnails (FFmpeg):**
```bash
# Ubuntu/Debian
sudo apt install ffmpeg
```
**Note:** The application will work without these, but:
- Without libvips: Images will be served without watermarks
- Without FFmpeg: Videos will show placeholder thumbnails
### 4. Generate Prisma Client
```bash
cd /home/ladmin/Code/punimtag-viewer
npx prisma generate
```
### 5. Test Database Connection
```bash
# Optional: Open Prisma Studio to browse database
npx prisma studio
```
### 6. Run Development Server
```bash
npm run dev
```
Open http://localhost:3000 in your browser.
## ⚠️ Known Issues
### Node.js Version Warning
The project was created with Next.js 16, which requires Node.js >=20.9.0, but the system currently has Node.js 18.19.1.
**Solutions:**
1. **Upgrade Node.js** (Recommended):
```bash
# Using nvm (Node Version Manager)
nvm install 20
nvm use 20
```
2. **Or use Next.js 14** (if you prefer to stay on Node 18):
```bash
npm install next@14 react@18 react-dom@18
```
## 📁 Project Structure
```
punimtag-viewer/
├── app/
│ ├── layout.tsx # Root layout with Inter font
│ ├── page.tsx # Home page (server component)
│ ├── HomePageContent.tsx # Home page client component with search
│ ├── search/ # Search page
│ │ ├── page.tsx # Search page (server component)
│ │ └── SearchContent.tsx # Search content (client component)
│ ├── api/ # API routes
│ │ ├── search/ # Search API endpoint
│ │ │ └── route.ts # Search route handler
│ │ └── photos/ # Photo API endpoints
│ └── globals.css # Global styles (updated by shadcn)
├── components/
│ ├── PhotoGrid.tsx # Photo grid with tooltips
│ ├── search/ # Search components
│ │ ├── CollapsibleSearch.tsx # Collapsible search bar
│ │ ├── FilterPanel.tsx # Filter panel container
│ │ ├── PeopleFilter.tsx # People filter component
│ │ ├── DateRangeFilter.tsx # Date range filter
│ │ ├── TagFilter.tsx # Tag filter component
│ │ └── SearchBar.tsx # Search bar (for future text search)
│ └── ui/ # shadcn/ui components
│ ├── button.tsx
│ ├── input.tsx
│ ├── select.tsx
│ ├── calendar.tsx
│ ├── popover.tsx
│ ├── badge.tsx
│ ├── checkbox.tsx
│ └── tooltip.tsx
├── lib/
│ ├── db.ts # Prisma client
│ ├── queries.ts # Database query helpers
│ └── utils.ts # Utility functions (from shadcn)
├── prisma/
│ └── schema.prisma # Database schema
└── .env # Environment variables (create this)
```
## 🎨 Adding shadcn/ui Components
To add UI components as needed:
```bash
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add input
npx shadcn@latest add dialog
# ... etc
```
## 🚀 Next Development Steps
After setup is complete, follow the Quick Start Guide to add:
1. **Photo Detail Page** - Individual photo view with lightbox
2. **People Browser** - Browse photos by person
3. **Tags Browser** - Browse photos by tag
4. **Infinite Scroll** - Load more photos as user scrolls
5. **Favorites System** - Allow users to favorite photos
## ✨ Current Features
### Search & Filtering
- ✅ **Collapsible Search Bar** on main page
- Minimized by default, click to expand
- Shows active filter count badge
- Real-time photo filtering
- ✅ **Search Filters**
- People filter with searchable dropdown
- Date range filter with presets and custom range
- Tag filter with searchable dropdown
- All filters work together (AND logic)
- ✅ **Search Page** (`/search`)
- Full search interface
- URL query parameter sync
- Pagination support
### Photo Display
- ✅ **Photo Tooltips**
- Hover over photos to see people names
- Shows "People: Name1, Name2" format
- Falls back to filename if no people identified
- ✅ **Photo Grid**
- Responsive grid layout
- Optimized image loading
- Hover effects
## 📚 Documentation
- **Quick Start Guide:** `/home/ladmin/Code/punimtag/docs/PHOTO_VIEWER_QUICKSTART.md`
- **Complete Plan:** `/home/ladmin/Code/punimtag/docs/PHOTO_VIEWER_PLAN.md`
- **Architecture:** `/home/ladmin/Code/punimtag/docs/PHOTO_VIEWER_ARCHITECTURE.md`
## 🆘 Troubleshooting
### "Can't connect to database"
- Check `.env` file has correct `DATABASE_URL`
- Verify database is running
- Test connection: `psql -U viewer_readonly -d punimtag -h localhost`
### "Prisma Client not generated"
- Run: `npx prisma generate`
### "Module not found: @/..."
- Check `tsconfig.json` has `"@/*": ["./*"]` in paths
### "Images not loading"
**For File System Paths:**
- Verify photo file paths in database are accessible from the Next.js server
- Check that the API route (`/api/photos/[id]/image`) is working
- Check server logs for file not found errors
**For HTTP/HTTPS URLs (SharePoint, CDN):**
- Verify the URL format in database (should start with `http://` or `https://`)
- Check `next.config.ts` has the domain configured in `remotePatterns`
- For SharePoint Online: `**.sharepoint.com` is already configured
- For on-premises SharePoint: Uncomment and update the hostname in `next.config.ts`
- Verify the URLs are publicly accessible or authentication is configured
---
**Project Location:** `/home/ladmin/Code/punimtag-viewer`
**Ready to continue development!** 🚀

View File

@ -1,131 +0,0 @@
# Authentication Setup Guide
This guide will help you set up the authentication and pending identifications functionality.
## Prerequisites
1. ✅ Code changes are complete
2. ✅ `.env` file is configured with `NEXTAUTH_SECRET` and database URLs
3. ⚠️ Database tables need to be created
4. ⚠️ Database permissions need to be granted
## Step-by-Step Setup
### 1. Create Database Tables
Run the SQL script to create the new tables:
```bash
psql -U postgres -d punimtag -f create_auth_tables.sql
```
Or manually run the SQL commands in `create_auth_tables.sql`.
### 2. Grant Database Permissions
You need to grant write permissions for the new tables. Choose one option:
#### Option A: If using separate write user (`viewer_write`)
```sql
-- Connect as postgres superuser
psql -U postgres -d punimtag
-- Grant permissions
GRANT SELECT, INSERT, UPDATE ON TABLE users TO viewer_write;
GRANT SELECT, INSERT, UPDATE ON TABLE pending_identifications TO viewer_write;
GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO viewer_write;
GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO viewer_write;
```
#### Option B: If using same user with write permissions (`viewer_readonly`)
```sql
-- Connect as postgres superuser
psql -U postgres -d punimtag
-- Grant permissions
GRANT SELECT, INSERT, UPDATE ON TABLE users TO viewer_readonly;
GRANT SELECT, INSERT, UPDATE ON TABLE pending_identifications TO viewer_readonly;
GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO viewer_readonly;
GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO viewer_readonly;
```
### 3. Generate Prisma Client
After creating the tables, regenerate the Prisma client:
```bash
npx prisma generate
```
### 4. Verify Setup
1. **Check tables exist:**
```sql
\dt users
\dt pending_identifications
```
2. **Test user registration:**
- Start the dev server: `npm run dev`
- Navigate to `http://localhost:3001/register`
- Try creating a new user account
- Check if the user appears in the database:
```sql
SELECT * FROM users;
```
3. **Test face identification:**
- Log in with your new account
- Open a photo with faces
- Click on a face to identify it
- Check if pending identification is created:
```sql
SELECT * FROM pending_identifications;
```
## Troubleshooting
### Error: "permission denied for table users"
**Solution:** Grant write permissions to your database user (see Step 2 above).
### Error: "relation 'users' does not exist"
**Solution:** Run the `create_auth_tables.sql` script (see Step 1 above).
### Error: "PrismaClientValidationError"
**Solution:** Regenerate Prisma client: `npx prisma generate`
### Registration page shows error
**Check:**
1. `.env` file has `DATABASE_URL_WRITE` configured
2. Database user has INSERT permission on `users` table
3. Prisma client is up to date: `npx prisma generate`
## What Works Now
✅ User registration (`/register`)
✅ User login (`/login`)
✅ Face identification (requires login)
✅ Pending identifications saved to database
✅ Authentication checks in place
## What's Not Implemented Yet
❌ Admin approval interface (to approve/reject pending identifications)
❌ Applying approved identifications to the main `people` and `faces` tables
## Next Steps
Once everything is working:
1. Test user registration
2. Test face identification
3. Verify pending identifications are saved correctly
4. (Future) Implement admin approval interface

View File

@ -1,180 +0,0 @@
# Setting Up Separate Auth Database
This guide explains how to set up a separate database for authentication and pending identifications, so you don't need to write to the read-only `punimtag` database.
## Why a Separate Database?
The `punimtag` database is read-only, but we need to store:
- User accounts (for login/authentication)
- Pending identifications (face identifications waiting for admin approval)
By using a separate database (`punimtag_auth`), we can:
- ✅ Keep the punimtag database completely read-only
- ✅ Store user data and identifications separately
- ✅ Maintain data integrity without foreign key constraints across databases
## Setup Steps
### 1. Create the Auth Database
Run the SQL script as a PostgreSQL superuser:
```bash
psql -U postgres -f setup-auth-database.sql
```
Or connect to PostgreSQL and run manually:
```sql
-- Create the database
CREATE DATABASE punimtag_auth;
-- Connect to it
\c punimtag_auth
-- Then run the rest of setup-auth-database.sql
```
### 2. Configure Environment Variables
Add `DATABASE_URL_AUTH` to your `.env` file:
```bash
DATABASE_URL_AUTH="postgresql://username:password@localhost:5432/punimtag_auth"
```
**Note:** You can use the same PostgreSQL user that has access to the punimtag database, or create a separate user specifically for the auth database.
### 3. Generate Prisma Clients
Generate both Prisma clients:
```bash
# Generate main client (for punimtag database)
npm run prisma:generate
# Generate auth client (for punimtag_auth database)
npm run prisma:generate:auth
# Or generate both at once:
npm run prisma:generate:all
```
### 4. Create Admin User
After the database is set up and Prisma clients are generated, create an admin user:
```bash
npx tsx scripts/create-admin-user.ts
```
This will create an admin user with:
- **Email:** admin@admin.com
- **Password:** admin
- **Role:** Admin (can approve identifications)
### 5. Verify Setup
1. **Check tables exist:**
```sql
\c punimtag_auth
\dt
```
You should see `users` and `pending_identifications` tables.
2. **Check admin user:**
```sql
SELECT email, name, is_admin FROM users WHERE email = 'admin@admin.com';
```
3. **Test registration:**
- Go to http://localhost:3001/register
- Create a new user account
- Verify it appears in the `punimtag_auth` database
4. **Test login:**
- Go to http://localhost:3001/login
- Login with admin@admin.com / admin
## Database Structure
### `punimtag_auth` Database
- **users** - User accounts for authentication
- **pending_identifications** - Face identifications pending admin approval
### `punimtag` Database (Read-Only)
- **photos** - Photo metadata
- **faces** - Detected faces in photos
- **people** - Identified people
- **tags** - Photo tags
- etc.
## Important Notes
### Foreign Key Constraints
The `pending_identifications.face_id` field references `faces.id` in the `punimtag` database, but we **cannot use a foreign key constraint** across databases. The application validates that faces exist when creating pending identifications.
### Face ID Validation
When a user identifies a face, the application:
1. Validates the `faceId` exists in the `punimtag` database (read-only check)
2. Stores the identification in `punimtag_auth.pending_identifications` (write operation)
This ensures data integrity without requiring write access to the punimtag database.
## Troubleshooting
### "Cannot find module '../node_modules/.prisma/client-auth'"
Make sure you've generated the auth Prisma client:
```bash
npm run prisma:generate:auth
```
### "relation 'users' does not exist"
Make sure you've created the auth database and run the setup script:
```bash
psql -U postgres -f setup-auth-database.sql
```
### "permission denied for table users"
Make sure your database user has the necessary permissions. You can grant them with:
```sql
GRANT ALL PRIVILEGES ON DATABASE punimtag_auth TO your_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO your_user;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO your_user;
```
### "DATABASE_URL_AUTH is not defined"
Make sure you've added `DATABASE_URL_AUTH` to your `.env` file.
## Migration from Old Setup
If you previously had `users` and `pending_identifications` tables in the `punimtag` database:
1. **Export existing data** (if any):
```sql
\c punimtag
\copy users TO 'users_backup.csv' CSV HEADER;
\copy pending_identifications TO 'pending_identifications_backup.csv' CSV HEADER;
```
2. **Create the new auth database** (follow steps above)
3. **Import data** (if needed):
```sql
\c punimtag_auth
\copy users FROM 'users_backup.csv' CSV HEADER;
\copy pending_identifications FROM 'pending_identifications_backup.csv' CSV HEADER;
```
4. **Update your `.env` file** with `DATABASE_URL_AUTH`
5. **Regenerate Prisma clients** and restart your application

View File

@ -1,86 +0,0 @@
# Setup Instructions for Authentication
Follow these steps to set up authentication and create the admin user.
## Step 1: Create Database Tables
Run the SQL script as a PostgreSQL superuser:
```bash
psql -U postgres -d punimtag -f setup-auth-complete.sql
```
Or connect to your database and run the SQL manually:
```sql
-- Connect to database
\c punimtag
-- Then run the contents of setup-auth-complete.sql
```
## Step 2: Create Admin User
After the tables are created, run the Node.js script to create the admin user:
```bash
npx tsx scripts/create-admin-user.ts
```
This will create an admin user with:
- **Email:** admin@admin.com
- **Password:** admin
- **Role:** Admin (can approve identifications)
## Step 3: Regenerate Prisma Client
```bash
npx prisma generate
```
## Step 4: Verify Setup
1. **Check tables exist:**
```sql
\dt users
\dt pending_identifications
```
2. **Check admin user:**
```sql
SELECT email, name, is_admin FROM users WHERE email = 'admin@admin.com';
```
3. **Test registration:**
- Go to http://localhost:3001/register
- Create a new user account
- Verify it appears in the database
4. **Test admin login:**
- Go to http://localhost:3001/login
- Login with admin@admin.com / admin
## Permission Model
- **Regular Users:** Can INSERT into `pending_identifications` (identify faces)
- **Admin Users:** Can UPDATE `pending_identifications` (approve/reject identifications)
- **Application Level:** The `isAdmin` field in the User model controls who can approve
## Troubleshooting
### "permission denied for table users"
Make sure you've granted permissions:
```sql
GRANT SELECT, INSERT, UPDATE ON TABLE users TO viewer_write;
GRANT SELECT, INSERT, UPDATE ON TABLE pending_identifications TO viewer_write;
GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO viewer_write;
GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO viewer_write;
```
### "relation 'users' does not exist"
Run `setup-auth-complete.sql` first to create the tables.
### "Authentication failed"
Check your `.env` file has correct `DATABASE_URL_WRITE` credentials.

View File

@ -1,73 +0,0 @@
# How to Stop the Old PunimTag Server
## Quick Instructions
### Option 1: Kill the Process (Already Done)
The old server has been stopped. If you need to do it manually:
```bash
# Find the process
lsof -i :3000
# Kill it (replace PID with actual process ID)
kill <PID>
```
### Option 2: Find and Stop All PunimTag Processes
```bash
# Find all PunimTag processes
ps aux | grep punimtag | grep -v grep
# Kill the frontend (Vite) process
pkill -f "vite.*punimtag"
# Or kill by port
lsof -ti :3000 | xargs kill
```
### Option 3: Stop from Terminal Where It's Running
If you have the terminal open where the old server is running:
- Press `Ctrl+C` to stop it
## Start the New Photo Viewer
After stopping the old server, start the new one:
```bash
cd /home/ladmin/Code/punimtag-viewer
npm run dev
```
The new server will start on http://localhost:3000
## Check What's Running
```bash
# Check what's on port 3000
lsof -i :3000
# Check all Node processes
ps aux | grep node | grep -v grep
```
## If Port 3000 is Still Busy
If port 3000 is still in use, you can:
1. **Use a different port for the new viewer:**
```bash
PORT=3001 npm run dev
```
Then open http://localhost:3001
2. **Or kill all processes on port 3000:**
```bash
lsof -ti :3000 | xargs kill -9
```

File diff suppressed because it is too large Load Diff

View File

@ -1,666 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Trash2, Plus, Edit2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { isValidEmail } from '@/lib/utils';
interface User {
id: number;
email: string;
name: string | null;
isAdmin: boolean;
hasWriteAccess: boolean;
isActive?: boolean;
createdAt: string;
updatedAt: string;
}
type UserStatusFilter = 'all' | 'active' | 'inactive';
export function ManageUsersContent() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<UserStatusFilter>('active');
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
// Form state
const [formData, setFormData] = useState({
email: '',
password: '',
name: '',
hasWriteAccess: false,
isAdmin: false,
isActive: true,
});
// Fetch users
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
setError(null);
console.log('[ManageUsers] Fetching users with filter:', statusFilter);
const url = statusFilter === 'all'
? '/api/users?status=all'
: statusFilter === 'inactive'
? '/api/users?status=inactive'
: '/api/users?status=active';
console.log('[ManageUsers] Fetching from URL:', url);
const response = await fetch(url, {
credentials: 'include', // Ensure cookies are sent
});
console.log('[ManageUsers] Response status:', response.status, response.statusText);
let data;
const contentType = response.headers.get('content-type');
console.log('[ManageUsers] Content-Type:', contentType);
try {
const text = await response.text();
console.log('[ManageUsers] Response text:', text);
data = text ? JSON.parse(text) : {};
} catch (parseError) {
console.error('[ManageUsers] Failed to parse response:', parseError);
throw new Error(`Server error (${response.status}): Invalid JSON response`);
}
console.log('[ManageUsers] Parsed data:', data);
if (!response.ok) {
const errorMsg = data?.error || data?.details || data?.message || `HTTP ${response.status}: ${response.statusText}`;
console.error('[ManageUsers] API Error:', {
status: response.status,
statusText: response.statusText,
data
});
throw new Error(errorMsg);
}
if (!data.users) {
console.warn('[ManageUsers] Response missing users array:', data);
setUsers([]);
} else {
console.log('[ManageUsers] Successfully loaded', data.users.length, 'users');
setUsers(data.users);
}
} catch (err: any) {
console.error('[ManageUsers] Error fetching users:', err);
setError(err.message || 'Failed to load users');
} finally {
setLoading(false);
}
}, [statusFilter]);
// Debug: Log when statusFilter changes
useEffect(() => {
console.log('[ManageUsers] statusFilter state changed to:', statusFilter);
}, [statusFilter]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
// Handle add user
const handleAddUser = async () => {
try {
setError(null);
// Client-side validation
if (!formData.name || formData.name.trim().length === 0) {
setError('Name is required');
return;
}
if (!formData.email || !isValidEmail(formData.email)) {
setError('Please enter a valid email address');
return;
}
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create user');
}
setIsAddDialogOpen(false);
setFormData({ email: '', password: '', name: '', hasWriteAccess: false, isAdmin: false, isActive: true });
fetchUsers();
} catch (err: any) {
setError(err.message || 'Failed to create user');
}
};
// Handle edit user
const handleEditUser = async () => {
if (!editingUser) return;
try {
setError(null);
// Client-side validation
if (!formData.name || formData.name.trim().length === 0) {
setError('Name is required');
return;
}
const updateData: any = {};
if (formData.email !== editingUser.email) {
updateData.email = formData.email;
}
if (formData.name !== editingUser.name) {
updateData.name = formData.name;
}
if (formData.password) {
updateData.password = formData.password;
}
if (formData.hasWriteAccess !== editingUser.hasWriteAccess) {
updateData.hasWriteAccess = formData.hasWriteAccess;
}
if (formData.isAdmin !== editingUser.isAdmin) {
updateData.isAdmin = formData.isAdmin;
}
// Treat undefined/null as true, so only check if explicitly false
const currentIsActive = editingUser.isActive !== false;
if (formData.isActive !== currentIsActive) {
updateData.isActive = formData.isActive;
}
if (Object.keys(updateData).length === 0) {
setIsEditDialogOpen(false);
return;
}
const response = await fetch(`/api/users/${editingUser.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update user');
}
setIsEditDialogOpen(false);
setEditingUser(null);
setFormData({ email: '', password: '', name: '', hasWriteAccess: false, isAdmin: false, isActive: true });
fetchUsers();
} catch (err: any) {
setError(err.message || 'Failed to update user');
}
};
// Handle delete user
const handleDeleteUser = async () => {
if (!userToDelete) return;
try {
setError(null);
setSuccessMessage(null);
const response = await fetch(`/api/users/${userToDelete.id}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete user');
}
const data = await response.json();
setDeleteConfirmOpen(false);
setUserToDelete(null);
// Check if user was deactivated instead of deleted
if (data.deactivated) {
setSuccessMessage(
`User ${userToDelete.email} was deactivated (not deleted) because they have ${data.relatedRecords?.pendingLinkages || 0} pending linkages, ${data.relatedRecords?.photoFavorites || 0} favorites, and other related records.`
);
} else {
setSuccessMessage(`User ${userToDelete.email} was deleted successfully.`);
}
// Clear success message after 5 seconds
setTimeout(() => setSuccessMessage(null), 5000);
fetchUsers();
} catch (err: any) {
setError(err.message || 'Failed to delete user');
}
};
// Open edit dialog
const openEditDialog = (user: User) => {
setEditingUser(user);
setFormData({
email: user.email,
password: '',
name: user.name || '',
hasWriteAccess: user.hasWriteAccess,
isAdmin: user.isAdmin,
isActive: user.isActive !== false, // Treat undefined/null as true
});
setIsEditDialogOpen(true);
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="text-center">Loading users...</div>
</div>
);
}
return (
<div className="container mx-auto max-w-7xl">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Manage Users</h1>
<p className="text-muted-foreground mt-1">
Manage user accounts and permissions
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label htmlFor="status-filter" className="text-sm font-medium">
User Status:
</label>
<Select
value={statusFilter}
onValueChange={(value) => {
console.log('[ManageUsers] Filter changed to:', value);
setStatusFilter(value as UserStatusFilter);
}}
>
<SelectTrigger id="status-filter" className="w-[150px]">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent className="z-[120]">
<SelectItem value="all">All</SelectItem>
<SelectItem value="active">Active only</SelectItem>
<SelectItem value="inactive">Inactive only</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={() => setIsAddDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add User
</Button>
</div>
</div>
{error && (
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{successMessage && (
<div className="mb-4 rounded-md bg-green-50 p-4 text-green-800 dark:bg-green-900/20 dark:text-green-400">
{successMessage}
</div>
)}
<div className="rounded-lg border bg-card">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="px-4 py-3 text-left text-sm font-medium">Email</th>
<th className="px-4 py-3 text-left text-sm font-medium">Name</th>
<th className="px-4 py-3 text-left text-sm font-medium">Status</th>
<th className="px-4 py-3 text-left text-sm font-medium">Role</th>
<th className="px-4 py-3 text-left text-sm font-medium">Write Access</th>
<th className="px-4 py-3 text-left text-sm font-medium">Created</th>
<th className="px-4 py-3 text-right text-sm font-medium">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b">
<td className="px-4 py-3">{user.email}</td>
<td className="px-4 py-3">{user.name || '-'}</td>
<td className="px-4 py-3">
{user.isActive === false ? (
<Badge variant="outline" className="border-red-300 text-red-700 dark:border-red-800 dark:text-red-400">Inactive</Badge>
) : (
<Badge variant="outline" className="border-green-300 text-green-700 dark:border-green-800 dark:text-green-400">Active</Badge>
)}
</td>
<td className="px-4 py-3">
{user.isAdmin ? (
<Badge variant="outline" className="border-blue-300 text-blue-700 dark:border-blue-800 dark:text-blue-400">Admin</Badge>
) : (
<Badge variant="outline" className="border-gray-300 text-gray-700 dark:border-gray-600 dark:text-gray-400">User</Badge>
)}
</td>
<td className="px-4 py-3">
<span className="text-sm">
{user.hasWriteAccess ? 'Yes' : 'No'}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => openEditDialog(user)}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setUserToDelete(user);
setDeleteConfirmOpen(true);
}}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Add User Dialog */}
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent className="z-[110]" overlayClassName="z-[105]">
<DialogHeader>
<DialogTitle>Add New User</DialogTitle>
<DialogDescription>
Create a new user account. Write access can be granted later.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<label htmlFor="add-email" className="text-sm font-medium">
Email <span className="text-red-500">*</span>
</label>
<Input
id="add-email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
placeholder="user@example.com"
/>
</div>
<div className="grid gap-2">
<label htmlFor="add-password" className="text-sm font-medium">
Password <span className="text-red-500">*</span>
</label>
<Input
id="add-password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
placeholder="Minimum 6 characters"
/>
</div>
<div className="grid gap-2">
<label htmlFor="add-name" className="text-sm font-medium">
Name <span className="text-red-500">*</span>
</label>
<Input
id="add-name"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="Enter full name"
required
/>
</div>
<div className="grid gap-2">
<label htmlFor="add-role" className="text-sm font-medium">
Role <span className="text-red-500">*</span>
</label>
<Select
value={formData.isAdmin ? 'admin' : 'user'}
onValueChange={(value) =>
setFormData({
...formData,
isAdmin: value === 'admin',
hasWriteAccess: value === 'admin' ? true : formData.hasWriteAccess
})
}
>
<SelectTrigger id="add-role" className="w-full">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="add-write-access"
checked={formData.hasWriteAccess}
onCheckedChange={(checked) =>
setFormData({ ...formData, hasWriteAccess: !!checked })
}
/>
<label
htmlFor="add-write-access"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Grant Write Access
</label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsAddDialogOpen(false);
setFormData({ email: '', password: '', name: '', hasWriteAccess: false, isAdmin: false, isActive: true });
}}
>
Cancel
</Button>
<Button onClick={handleAddUser}>Create User</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit User Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="z-[110]" overlayClassName="z-[105]">
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogDescription>
Update user information. Leave password blank to keep current password.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<label htmlFor="edit-email" className="text-sm font-medium">
Email <span className="text-red-500">*</span>
</label>
<Input
id="edit-email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
placeholder="user@example.com"
/>
</div>
<div className="grid gap-2">
<label htmlFor="edit-password" className="text-sm font-medium">
New Password <span className="text-gray-500 font-normal">(leave empty to keep current)</span>
</label>
<Input
id="edit-password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
placeholder="Leave blank to keep current password"
/>
</div>
<div className="grid gap-2">
<label htmlFor="edit-name" className="text-sm font-medium">
Name <span className="text-red-500">*</span>
</label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="Enter full name"
required
/>
</div>
<div className="grid gap-2">
<label htmlFor="edit-role" className="text-sm font-medium">
Role <span className="text-red-500">*</span>
</label>
<Select
value={formData.isAdmin ? 'admin' : 'user'}
onValueChange={(value) =>
setFormData({
...formData,
isAdmin: value === 'admin',
hasWriteAccess: value === 'admin' ? true : formData.hasWriteAccess
})
}
>
<SelectTrigger id="edit-role" className="w-full">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="edit-write-access"
checked={formData.hasWriteAccess}
onCheckedChange={(checked) =>
setFormData({ ...formData, hasWriteAccess: !!checked })
}
/>
<label
htmlFor="edit-write-access"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Grant Write Access
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="edit-active"
checked={formData.isActive}
onCheckedChange={(checked) =>
setFormData({ ...formData, isActive: !!checked })
}
/>
<label
htmlFor="edit-active"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Active
</label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsEditDialogOpen(false);
setEditingUser(null);
setFormData({ email: '', password: '', name: '', hasWriteAccess: false, isAdmin: false, isActive: true });
}}
>
Cancel
</Button>
<Button onClick={handleEditUser}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="z-[110]" overlayClassName="z-[105]">
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
<DialogDescription>
Are you sure you want to delete {userToDelete?.email}? This action
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDeleteConfirmOpen(false);
setUserToDelete(null);
}}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteUser}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,84 +0,0 @@
'use client';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ManageUsersContent } from './ManageUsersContent';
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
interface ManageUsersPageClientProps {
onClose?: () => void;
}
export function ManageUsersPageClient({ onClose }: ManageUsersPageClientProps) {
const handleClose = () => {
if (onClose) {
onClose();
}
};
useEffect(() => {
// Prevent body scroll when overlay is open
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = 'unset';
};
}, []);
const overlayContent = (
<div className="fixed inset-0 z-[100] bg-background overflow-y-auto">
<div className="w-full px-4 py-8">
{/* Close button */}
<div className="mb-4 flex items-center justify-end">
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-9 w-9"
aria-label="Close manage users"
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Header */}
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<UserMenu />
</div>
</div>
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
</div>
{/* Manage Users content */}
<div className="mt-8">
<ManageUsersContent />
</div>
</div>
</div>
);
// Render in portal to ensure it's above everything
if (typeof window === 'undefined') {
return null;
}
return createPortal(overlayContent, document.body);
}

View File

@ -1,20 +0,0 @@
import { redirect } from 'next/navigation';
import { auth } from '@/app/api/auth/[...nextauth]/route';
import { isAdmin } from '@/lib/permissions';
import { ManageUsersContent } from './ManageUsersContent';
export default async function ManageUsersPage() {
const session = await auth();
if (!session?.user) {
redirect('/login?callbackUrl=/admin/users');
}
const admin = await isAdmin();
if (!admin) {
redirect('/');
}
return <ManageUsersContent />;
}

View File

@ -1,155 +0,0 @@
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { prismaAuth } from '@/lib/db';
import bcrypt from 'bcryptjs';
export const { handlers, signIn, signOut, auth } = NextAuth({
secret: process.env.NEXTAUTH_SECRET,
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
try {
if (!credentials?.email || !credentials?.password) {
console.log('[AUTH] Missing credentials');
return null;
}
console.log('[AUTH] Attempting to find user:', credentials.email);
const user = await prismaAuth.user.findUnique({
where: { email: credentials.email as string },
select: {
id: true,
email: true,
name: true,
passwordHash: true,
isAdmin: true,
hasWriteAccess: true,
emailVerified: true,
isActive: true,
},
});
if (!user) {
console.log('[AUTH] User not found:', credentials.email);
return null;
}
console.log('[AUTH] User found, checking password...');
const isPasswordValid = await bcrypt.compare(
credentials.password as string,
user.passwordHash
);
if (!isPasswordValid) {
console.log('[AUTH] Invalid password for user:', credentials.email);
return null;
}
// Check if email is verified
if (!user.emailVerified) {
console.log('[AUTH] Email not verified for user:', credentials.email);
return null; // Return null to indicate failed login
}
// Check if user is active (treat null/undefined as true)
if (user.isActive === false) {
console.log('[AUTH] User is inactive:', credentials.email);
return null; // Return null to indicate failed login
}
console.log('[AUTH] Login successful for:', credentials.email);
return {
id: user.id.toString(),
email: user.email,
name: user.name || undefined,
isAdmin: user.isAdmin,
hasWriteAccess: user.hasWriteAccess,
};
} catch (error: any) {
console.error('[AUTH] Error during authorization:', error);
return null;
}
},
}),
],
pages: {
signIn: '/login',
signOut: '/',
},
session: {
strategy: 'jwt',
maxAge: 24 * 60 * 60, // 24 hours in seconds
updateAge: 1 * 60 * 60, // Refresh session every 1 hour (more frequent validation)
},
jwt: {
maxAge: 24 * 60 * 60, // 24 hours in seconds
},
callbacks: {
async jwt({ token, user, trigger }) {
// Set expiration time when user first logs in
if (user) {
token.id = user.id;
token.email = user.email;
token.isAdmin = user.isAdmin;
token.hasWriteAccess = user.hasWriteAccess;
token.exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60); // 24 hours from now
}
// Refresh user data from database on token refresh to get latest hasWriteAccess and isActive
// This ensures permissions are up-to-date even if granted after login
if (token.email && !user) {
try {
const dbUser = await prismaAuth.user.findUnique({
where: { email: token.email as string },
select: {
id: true,
email: true,
isAdmin: true,
hasWriteAccess: true,
isActive: true,
},
});
if (dbUser) {
// Check if user is still active (treat null/undefined as true)
if (dbUser.isActive === false) {
// User was deactivated, invalidate token
return null as any;
}
token.id = dbUser.id.toString();
token.isAdmin = dbUser.isAdmin;
token.hasWriteAccess = dbUser.hasWriteAccess;
}
} catch (error) {
console.error('[AUTH] Error refreshing user data:', error);
// Continue with existing token data if refresh fails
}
}
return token;
},
async session({ session, token }) {
// If token is null or expired, return null session to force logout
if (!token || (token.exp && token.exp < Math.floor(Date.now() / 1000))) {
return null as any;
}
if (session.user) {
session.user.id = token.id as string;
session.user.email = token.email as string;
session.user.isAdmin = token.isAdmin as boolean;
session.user.hasWriteAccess = token.hasWriteAccess as boolean;
}
return session;
},
},
});
export const { GET, POST } = handlers;

View File

@ -1,72 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = body;
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password are required' },
{ status: 400 }
);
}
// Find user
const user = await prismaAuth.user.findUnique({
where: { email },
select: {
id: true,
email: true,
passwordHash: true,
emailVerified: true,
isActive: true,
},
});
if (!user) {
return NextResponse.json(
{ verified: false, exists: false },
{ status: 200 }
);
}
// Check if user is active (treat null/undefined as true)
if (user.isActive === false) {
return NextResponse.json(
{ verified: false, exists: true, passwordValid: false, active: false },
{ status: 200 }
);
}
// Check password
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
return NextResponse.json(
{ verified: false, exists: true, passwordValid: false },
{ status: 200 }
);
}
// Return verification status
return NextResponse.json(
{
verified: user.emailVerified,
exists: true,
passwordValid: true
},
{ status: 200 }
);
} catch (error: any) {
console.error('Error checking verification:', error);
return NextResponse.json(
{ error: 'Failed to check verification status' },
{ status: 500 }
);
}
}

View File

@ -1,103 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import { generatePasswordResetToken, sendPasswordResetEmail } from '@/lib/email';
import { isValidEmail } from '@/lib/utils';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email } = body;
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
if (!isValidEmail(email)) {
return NextResponse.json(
{ error: 'Please enter a valid email address' },
{ status: 400 }
);
}
// Find user
const user = await prismaAuth.user.findUnique({
where: { email },
});
// Don't reveal if user exists or not for security
// Always return success message
if (!user) {
return NextResponse.json(
{ message: 'If an account with that email exists, a password reset email has been sent.' },
{ status: 200 }
);
}
// Check if user is active
if (user.isActive === false) {
return NextResponse.json(
{ message: 'If an account with that email exists, a password reset email has been sent.' },
{ status: 200 }
);
}
// Generate password reset token
const resetToken = generatePasswordResetToken();
const tokenExpiry = new Date();
tokenExpiry.setHours(tokenExpiry.getHours() + 1); // Token expires in 1 hour
// Update user with reset token
await prismaAuth.user.update({
where: { id: user.id },
data: {
passwordResetToken: resetToken,
passwordResetTokenExpiry: tokenExpiry,
},
});
// Send password reset email
try {
console.log('[FORGOT-PASSWORD] Attempting to send password reset email to:', user.email);
await sendPasswordResetEmail(user.email, user.name, resetToken);
console.log('[FORGOT-PASSWORD] Password reset email sent successfully to:', user.email);
} catch (emailError: any) {
console.error('[FORGOT-PASSWORD] Error sending password reset email:', emailError);
console.error('[FORGOT-PASSWORD] Error details:', {
message: emailError?.message,
name: emailError?.name,
response: emailError?.response,
statusCode: emailError?.statusCode,
});
// Clear the token if email fails
await prismaAuth.user.update({
where: { id: user.id },
data: {
passwordResetToken: null,
passwordResetTokenExpiry: null,
},
});
return NextResponse.json(
{
error: 'Failed to send password reset email',
details: emailError?.message || 'Unknown error'
},
{ status: 500 }
);
}
return NextResponse.json(
{ message: 'If an account with that email exists, a password reset email has been sent.' },
{ status: 200 }
);
} catch (error: any) {
console.error('Error processing password reset request:', error);
return NextResponse.json(
{ error: 'Failed to process password reset request' },
{ status: 500 }
);
}
}

View File

@ -1,105 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { generateEmailConfirmationToken, sendEmailConfirmation } from '@/lib/email';
import { isValidEmail } from '@/lib/utils';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password, name } = body;
// Validate input
if (!email || !password || !name) {
return NextResponse.json(
{ error: 'Email, password, and name are required' },
{ status: 400 }
);
}
if (name.trim().length === 0) {
return NextResponse.json(
{ error: 'Name cannot be empty' },
{ status: 400 }
);
}
if (!isValidEmail(email)) {
return NextResponse.json(
{ error: 'Please enter a valid email address' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
// Check if user already exists
const existingUser = await prismaAuth.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'User with this email already exists' },
{ status: 409 }
);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Generate email confirmation token
const confirmationToken = generateEmailConfirmationToken();
const tokenExpiry = new Date();
tokenExpiry.setHours(tokenExpiry.getHours() + 24); // Token expires in 24 hours
// Create user (without write access by default, email not verified)
const user = await prismaAuth.user.create({
data: {
email,
passwordHash,
name: name.trim(),
hasWriteAccess: false, // New users don't have write access by default
emailVerified: false,
emailConfirmationToken: confirmationToken,
emailConfirmationTokenExpiry: tokenExpiry,
},
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
// Send confirmation email
try {
await sendEmailConfirmation(email, name.trim(), confirmationToken);
} catch (emailError) {
console.error('Error sending confirmation email:', emailError);
// Don't fail registration if email fails, but log it
// User can request a resend later
}
return NextResponse.json(
{
message: 'User created successfully. Please check your email to confirm your account.',
user,
requiresEmailConfirmation: true
},
{ status: 201 }
);
} catch (error: any) {
console.error('Error registering user:', error);
return NextResponse.json(
{ error: 'Failed to register user', details: error.message },
{ status: 500 }
);
}
}

View File

@ -1,81 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import { generateEmailConfirmationToken, sendEmailConfirmationResend } from '@/lib/email';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email } = body;
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
// Find user
const user = await prismaAuth.user.findUnique({
where: { email },
});
if (!user) {
// Don't reveal if user exists or not for security
return NextResponse.json(
{ message: 'If an account with that email exists, a confirmation email has been sent.' },
{ status: 200 }
);
}
// If already verified, don't send another email
if (user.emailVerified) {
return NextResponse.json(
{ message: 'Email is already verified.' },
{ status: 200 }
);
}
// Generate new token
const confirmationToken = generateEmailConfirmationToken();
const tokenExpiry = new Date();
tokenExpiry.setHours(tokenExpiry.getHours() + 24); // Token expires in 24 hours
// Update user with new token
await prismaAuth.user.update({
where: { id: user.id },
data: {
emailConfirmationToken: confirmationToken,
emailConfirmationTokenExpiry: tokenExpiry,
},
});
// Send confirmation email
try {
await sendEmailConfirmationResend(user.email, user.name, confirmationToken);
} catch (emailError) {
console.error('Error sending confirmation email:', emailError);
return NextResponse.json(
{ error: 'Failed to send confirmation email' },
{ status: 500 }
);
}
return NextResponse.json(
{ message: 'Confirmation email has been sent. Please check your inbox.' },
{ status: 200 }
);
} catch (error: any) {
console.error('Error resending confirmation email:', error);
return NextResponse.json(
{ error: 'Failed to resend confirmation email', details: error.message },
{ status: 500 }
);
}
}

View File

@ -1,82 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { token, password } = body;
if (!token || !password) {
return NextResponse.json(
{ error: 'Token and password are required' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
// Find user with this token
const user = await prismaAuth.user.findUnique({
where: { passwordResetToken: token },
});
if (!user) {
return NextResponse.json(
{ error: 'Invalid or expired reset token' },
{ status: 400 }
);
}
// Check if token has expired
if (user.passwordResetTokenExpiry && user.passwordResetTokenExpiry < new Date()) {
// Clear expired token
await prismaAuth.user.update({
where: { id: user.id },
data: {
passwordResetToken: null,
passwordResetTokenExpiry: null,
},
});
return NextResponse.json(
{ error: 'Reset token has expired. Please request a new password reset.' },
{ status: 400 }
);
}
// Hash new password
const passwordHash = await bcrypt.hash(password, 10);
// Update password and clear reset token
await prismaAuth.user.update({
where: { id: user.id },
data: {
passwordHash,
passwordResetToken: null,
passwordResetTokenExpiry: null,
},
});
return NextResponse.json(
{ message: 'Password has been reset successfully. You can now sign in with your new password.' },
{ status: 200 }
);
} catch (error: any) {
console.error('Error resetting password:', error);
return NextResponse.json(
{ error: 'Failed to reset password' },
{ status: 500 }
);
}
}

View File

@ -1,67 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const token = searchParams.get('token');
if (!token) {
return NextResponse.redirect(
new URL('/login?error=missing_token', request.url)
);
}
// Find user with this token
const user = await prismaAuth.user.findUnique({
where: { emailConfirmationToken: token },
});
if (!user) {
return NextResponse.redirect(
new URL('/login?error=invalid_token', request.url)
);
}
// Check if token has expired
if (user.emailConfirmationTokenExpiry && user.emailConfirmationTokenExpiry < new Date()) {
return NextResponse.redirect(
new URL('/login?error=token_expired', request.url)
);
}
// Check if already verified
if (user.emailVerified) {
return NextResponse.redirect(
new URL('/login?message=already_verified', request.url)
);
}
// Verify the email
await prismaAuth.user.update({
where: { id: user.id },
data: {
emailVerified: true,
emailConfirmationToken: null,
emailConfirmationTokenExpiry: null,
},
});
// Redirect to login with success message
return NextResponse.redirect(
new URL('/login?verified=true', request.url)
);
} catch (error: any) {
console.error('Error verifying email:', error);
return NextResponse.redirect(
new URL('/login?error=verification_failed', request.url)
);
}
}

View File

@ -1,23 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/app/api/auth/[...nextauth]/route';
// Debug endpoint to check session
export async function GET(request: NextRequest) {
try {
const session = await auth();
return NextResponse.json({
hasSession: !!session,
user: session?.user || null,
userId: session?.user?.id || null,
isAdmin: session?.user?.isAdmin || false,
hasWriteAccess: session?.user?.hasWriteAccess || false,
}, { status: 200 });
} catch (error: any) {
return NextResponse.json(
{ error: 'Failed to get session', details: error.message },
{ status: 500 }
);
}
}

View File

@ -1,174 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, prismaAuth } from '@/lib/db';
import { auth } from '@/app/api/auth/[...nextauth]/route';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Check authentication
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required. Please sign in to identify faces.' },
{ status: 401 }
);
}
// Check write access
if (!session.user.hasWriteAccess) {
return NextResponse.json(
{ error: 'Write access required. You need write access to identify faces. Please contact an administrator.' },
{ status: 403 }
);
}
const { id } = await params;
const faceId = parseInt(id, 10);
if (isNaN(faceId)) {
return NextResponse.json(
{ error: 'Invalid face ID' },
{ status: 400 }
);
}
const body = await request.json();
const { personId, firstName, lastName, middleName, maidenName, dateOfBirth } = body;
let finalFirstName: string;
let finalLastName: string;
let finalMiddleName: string | null = null;
let finalMaidenName: string | null = null;
let finalDateOfBirth: Date | null = null;
// If personId is provided, fetch person data from database
if (personId) {
const person = await prisma.person.findUnique({
where: { id: parseInt(personId, 10) },
});
if (!person) {
return NextResponse.json(
{ error: 'Person not found' },
{ status: 404 }
);
}
finalFirstName = person.first_name;
finalLastName = person.last_name;
finalMiddleName = person.middle_name;
finalMaidenName = person.maiden_name;
finalDateOfBirth = person.date_of_birth;
} else {
// Validate required fields for new person
if (!firstName || !lastName) {
return NextResponse.json(
{ error: 'First name and last name are required' },
{ status: 400 }
);
}
finalFirstName = firstName;
finalLastName = lastName;
finalMiddleName = middleName || null;
finalMaidenName = maidenName || null;
// Parse date of birth if provided
const dob = dateOfBirth ? new Date(dateOfBirth) : null;
if (dateOfBirth && dob && isNaN(dob.getTime())) {
return NextResponse.json(
{ error: 'Invalid date of birth' },
{ status: 400 }
);
}
finalDateOfBirth = dob;
}
// Check if face exists (use read client for this - from punimtag database)
const face = await prisma.face.findUnique({
where: { id: faceId },
include: { Person: true },
});
if (!face) {
return NextResponse.json(
{ error: 'Face not found' },
{ status: 404 }
);
}
const userId = parseInt(session.user.id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user session' },
{ status: 401 }
);
}
// Check if there's already a pending identification for this face by this user
// Use auth client (connects to punimtag_auth database)
const existingPending = await prismaAuth.pendingIdentification.findFirst({
where: {
faceId,
userId,
status: 'pending',
},
});
if (existingPending) {
// Update existing pending identification
const updated = await prismaAuth.pendingIdentification.update({
where: { id: existingPending.id },
data: {
firstName: finalFirstName,
lastName: finalLastName,
middleName: finalMiddleName,
maidenName: finalMaidenName,
dateOfBirth: finalDateOfBirth,
},
});
return NextResponse.json({
message: 'Identification updated and pending approval',
pendingIdentification: updated,
});
}
// Create new pending identification
const pendingIdentification = await prismaAuth.pendingIdentification.create({
data: {
faceId,
userId,
firstName: finalFirstName,
lastName: finalLastName,
middleName: finalMiddleName,
maidenName: finalMaidenName,
dateOfBirth: finalDateOfBirth,
status: 'pending',
},
});
return NextResponse.json({
message: 'Identification submitted and pending approval',
pendingIdentification,
});
} catch (error: any) {
console.error('Error identifying face:', error);
// Handle unique constraint violation
if (error.code === 'P2002') {
return NextResponse.json(
{ error: 'A person with these details already exists' },
{ status: 409 }
);
}
return NextResponse.json(
{ error: 'Failed to identify face', details: error.message },
{ status: 500 }
);
}
}

View File

@ -1,87 +0,0 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
/**
* Health check endpoint that verifies database connectivity and permissions
* This runs automatically and can help detect permission issues early
*/
export async function GET() {
const checks: Record<string, { status: 'ok' | 'error'; message: string }> = {};
// Check database connection
try {
await prisma.$connect();
checks.database_connection = {
status: 'ok',
message: 'Database connection successful',
};
} catch (error: any) {
checks.database_connection = {
status: 'error',
message: `Database connection failed: ${error.message}`,
};
return NextResponse.json(
{
status: 'error',
checks,
message: 'Database health check failed',
},
{ status: 503 }
);
}
// Check permissions on key tables
const tables = [
{ name: 'photos', query: () => prisma.photo.findFirst() },
{ name: 'people', query: () => prisma.person.findFirst() },
{ name: 'faces', query: () => prisma.face.findFirst() },
{ name: 'tags', query: () => prisma.tag.findFirst() },
];
for (const table of tables) {
try {
await table.query();
checks[`table_${table.name}`] = {
status: 'ok',
message: `SELECT permission on ${table.name} table is OK`,
};
} catch (error: any) {
if (error.message?.includes('permission denied')) {
checks[`table_${table.name}`] = {
status: 'error',
message: `Permission denied on ${table.name} table. Run grant_readonly_permissions.sql as superuser.`,
};
} else {
checks[`table_${table.name}`] = {
status: 'error',
message: `Error accessing ${table.name}: ${error.message}`,
};
}
}
}
const hasErrors = Object.values(checks).some((check) => check.status === 'error');
return NextResponse.json(
{
status: hasErrors ? 'error' : 'ok',
checks,
timestamp: new Date().toISOString(),
...(hasErrors && {
fixInstructions: {
message: 'To fix permission errors, run as PostgreSQL superuser:',
command: 'psql -U postgres -d punimtag -f grant_readonly_permissions.sql',
},
}),
},
{ status: hasErrors ? 503 : 200 }
);
}

View File

@ -1,81 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const people = await prisma.person.findMany({
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
});
// Transform snake_case to camelCase for frontend
const transformedPeople = people.map((person) => ({
id: person.id,
firstName: person.first_name,
lastName: person.last_name,
middleName: person.middle_name,
maidenName: person.maiden_name,
dateOfBirth: person.date_of_birth,
createdDate: person.created_date,
}));
return NextResponse.json({ people: transformedPeople }, { status: 200 });
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted person data detected, attempting fallback query');
try {
// Try with minimal fields first
const people = await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
// Exclude potentially corrupted optional fields
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
// Transform snake_case to camelCase for frontend
const transformedPeople = people.map((person) => ({
id: person.id,
firstName: person.first_name,
lastName: person.last_name,
middleName: null,
maidenName: null,
dateOfBirth: null,
createdDate: null,
}));
return NextResponse.json({ people: transformedPeople }, { status: 200 });
} catch (fallbackError: any) {
console.error('Fallback person query also failed:', fallbackError);
return NextResponse.json(
{ error: 'Failed to fetch people', details: fallbackError.message },
{ status: 500 }
);
}
}
console.error('Error fetching people:', error);
return NextResponse.json(
{ error: 'Failed to fetch people', details: error.message },
{ status: 500 }
);
}
}

View File

@ -1,394 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, prismaAuth } from '@/lib/db';
import { serializePhotos } from '@/lib/serialize';
import { auth } from '@/app/api/auth/[...nextauth]/route';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
// Parse query parameters
const people = searchParams.get('people')?.split(',').filter(Boolean).map(Number) || [];
const peopleMode = (searchParams.get('peopleMode') || 'any') as 'any' | 'all';
const tags = searchParams.get('tags')?.split(',').filter(Boolean).map(Number) || [];
const tagsMode = (searchParams.get('tagsMode') || 'any') as 'any' | 'all';
const dateFrom = searchParams.get('dateFrom');
const dateTo = searchParams.get('dateTo');
const mediaType = (searchParams.get('mediaType') || 'all') as 'all' | 'photos' | 'videos';
const favoritesOnly = searchParams.get('favoritesOnly') === 'true';
const page = parseInt(searchParams.get('page') || '1', 10);
const pageSize = parseInt(searchParams.get('pageSize') || '30', 10);
const skip = (page - 1) * pageSize;
// Get user session for favorites filter
const session = await auth();
let favoritePhotoIds: number[] = [];
if (favoritesOnly && session?.user?.id) {
const userId = parseInt(session.user.id, 10);
if (!isNaN(userId)) {
try {
const favorites = await prismaAuth.photoFavorite.findMany({
where: { userId },
select: { photoId: true },
});
favoritePhotoIds = favorites.map(f => f.photoId);
// If user has no favorites, return empty result
if (favoritePhotoIds.length === 0) {
return NextResponse.json({
photos: [],
total: 0,
page,
pageSize,
totalPages: 0,
});
}
} catch (error: any) {
// Handle case where table doesn't exist yet (P2021 = table does not exist)
if (error.code === 'P2021') {
console.warn('photo_favorites table does not exist yet. Run migration: migrations/add-photo-favorites-table.sql');
} else {
console.error('Error fetching favorites:', error);
}
// If favorites table doesn't exist or error, treat as no favorites
if (favoritesOnly) {
return NextResponse.json({
photos: [],
total: 0,
page,
pageSize,
totalPages: 0,
});
}
}
}
}
// Build where clause
const where: any = {
processed: true,
};
// Media type filter
if (mediaType !== 'all') {
if (mediaType === 'photos') {
where.media_type = 'image';
} else if (mediaType === 'videos') {
where.media_type = 'video';
}
}
// Date filter
if (dateFrom || dateTo) {
where.date_taken = {};
if (dateFrom) {
where.date_taken.gte = new Date(dateFrom);
}
if (dateTo) {
where.date_taken.lte = new Date(dateTo);
}
}
// People filter
if (people.length > 0) {
if (peopleMode === 'all') {
// Photo must have ALL selected people
where.AND = where.AND || [];
people.forEach((personId) => {
where.AND.push({
Face: {
some: {
person_id: personId,
},
},
});
});
} else {
// Photo has ANY of the selected people (default)
where.Face = {
some: {
person_id: { in: people },
},
};
}
}
// Tags filter
if (tags.length > 0) {
if (tagsMode === 'all') {
// Photo must have ALL selected tags
where.AND = where.AND || [];
tags.forEach((tagId) => {
where.AND.push({
PhotoTagLinkage: {
some: {
tag_id: tagId,
},
},
});
});
} else {
// Photo has ANY of the selected tags (default)
where.PhotoTagLinkage = {
some: {
tag_id: { in: tags },
},
};
}
}
// Favorites filter
if (favoritesOnly && favoritePhotoIds.length > 0) {
where.id = { in: favoritePhotoIds };
} else if (favoritesOnly && favoritePhotoIds.length === 0) {
// User has no favorites, return empty (already handled above, but keep for safety)
where.id = { in: [] };
}
// Execute query - load photos and relations separately
// Use raw query to read dates as strings and convert manually to avoid Prisma conversion issues
let photosBase: any[];
let total: number;
try {
// Build WHERE clause for raw SQL
const whereConditions: string[] = ['processed = true'];
const params: any[] = [];
let paramIndex = 1; // PostgreSQL uses $1, $2, etc.
if (mediaType !== 'all') {
if (mediaType === 'photos') {
whereConditions.push(`media_type = $${paramIndex}`);
params.push('image');
paramIndex++;
} else if (mediaType === 'videos') {
whereConditions.push(`media_type = $${paramIndex}`);
params.push('video');
paramIndex++;
}
}
if (dateFrom || dateTo) {
if (dateFrom) {
whereConditions.push(`date_taken >= $${paramIndex}`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
whereConditions.push(`date_taken <= $${paramIndex}`);
params.push(dateTo);
paramIndex++;
}
}
// Handle people filter - embed IDs directly since they're safe integers
if (people.length > 0) {
const peopleIds = people.join(',');
whereConditions.push(`id IN (
SELECT DISTINCT photo_id FROM faces WHERE person_id IN (${peopleIds})
)`);
}
// Handle tags filter - embed IDs directly since they're safe integers
if (tags.length > 0) {
const tagIds = tags.join(',');
whereConditions.push(`id IN (
SELECT DISTINCT photo_id FROM phototaglinkage WHERE tag_id IN (${tagIds})
)`);
}
// Handle favorites filter - embed IDs directly since they're safe integers
if (favoritesOnly && favoritePhotoIds.length > 0) {
const favIds = favoritePhotoIds.join(',');
whereConditions.push(`id IN (${favIds})`);
} else if (favoritesOnly && favoritePhotoIds.length === 0) {
whereConditions.push('1 = 0'); // No favorites, return empty
}
const whereClause = whereConditions.join(' AND ');
// Build query parameters (LIMIT and OFFSET are embedded directly as they're safe integers)
const queryParams = [...params];
const countParams = [...params];
// Use raw query to read dates as strings
// Note: LIMIT and OFFSET are embedded directly since they're integers and safe
const [photosRaw, totalResult] = await Promise.all([
prisma.$queryRawUnsafe<Array<{
id: number;
path: string;
filename: string;
date_added: string;
date_taken: string | null;
processed: boolean;
media_type: string | null;
}>>(
`SELECT
id,
path,
filename,
date_added,
date_taken,
processed,
media_type
FROM photos
WHERE ${whereClause}
ORDER BY date_taken DESC, id DESC
LIMIT ${pageSize} OFFSET ${skip}`,
...queryParams
),
prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
`SELECT COUNT(*) as count FROM photos WHERE ${whereClause}`,
...countParams
),
]);
// Convert date strings to Date objects
photosBase = photosRaw.map(photo => ({
id: photo.id,
path: photo.path,
filename: photo.filename,
date_added: new Date(photo.date_added),
date_taken: photo.date_taken ? new Date(photo.date_taken) : null,
processed: photo.processed,
media_type: photo.media_type,
}));
total = Number(totalResult[0].count);
} catch (error: any) {
console.error('Error loading photos:', error);
throw error;
}
// Load faces and tags separately
const photoIds = photosBase.map(p => p.id);
// Fetch faces
let faces: any[] = [];
try {
faces = await prisma.face.findMany({
where: { photo_id: { in: photoIds } },
select: {
id: true,
photo_id: true,
person_id: true,
location: true,
confidence: true,
quality_score: true,
is_primary_encoding: true,
detector_backend: true,
model_name: true,
face_confidence: true,
exif_orientation: true,
pose_mode: true,
yaw_angle: true,
pitch_angle: true,
roll_angle: true,
landmarks: true,
identified_by_user_id: true,
excluded: true,
Person: {
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
},
// Exclude encoding field (Bytes) to avoid P2023 conversion errors
},
});
} catch (faceError: any) {
if (faceError?.code === 'P2023' || faceError?.message?.includes('Conversion failed')) {
console.warn('Corrupted face data detected in search, skipping faces');
faces = [];
} else {
throw faceError;
}
}
// Fetch photo tag linkages with error handling
let photoTagLinkages: any[] = [];
try {
photoTagLinkages = await prisma.photoTagLinkage.findMany({
where: { photo_id: { in: photoIds } },
select: {
linkage_id: true,
photo_id: true,
tag_id: true,
linkage_type: true,
created_date: true,
Tag: {
select: {
id: true,
tag_name: true,
created_date: true,
},
},
},
});
} catch (linkageError: any) {
if (linkageError?.code === 'P2023' || linkageError?.message?.includes('Conversion failed')) {
console.warn('Corrupted photo tag linkage data detected, attempting fallback query');
try {
// Try with minimal fields
photoTagLinkages = await prisma.photoTagLinkage.findMany({
where: { photo_id: { in: photoIds } },
select: {
linkage_id: true,
photo_id: true,
tag_id: true,
// Exclude potentially corrupted fields
Tag: {
select: {
id: true,
tag_name: true,
// Exclude created_date if it's corrupted
},
},
},
});
} catch (fallbackError: any) {
console.error('Fallback photo tag linkage query also failed:', fallbackError);
// Return empty array as last resort to prevent API crash
photoTagLinkages = [];
}
} else {
throw linkageError;
}
}
// Combine the data manually
const photos = photosBase.map(photo => ({
...photo,
Face: faces.filter(face => face.photo_id === photo.id),
PhotoTagLinkage: photoTagLinkages.filter(link => link.photo_id === photo.id),
}));
return NextResponse.json({
photos: serializePhotos(photos),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
});
} catch (error) {
console.error('Search error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorStack = error instanceof Error ? error.stack : undefined;
console.error('Error details:', { errorMessage, errorStack, error });
return NextResponse.json(
{
error: 'Failed to search photos',
details: errorMessage,
...(process.env.NODE_ENV === 'development' && { stack: errorStack })
},
{ status: 500 }
);
}
}

View File

@ -1,324 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import { isAdmin } from '@/lib/permissions';
import bcrypt from 'bcryptjs';
// PATCH /api/users/[id] - Update user (admin only)
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Check if user is admin
const admin = await isAdmin();
if (!admin) {
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.' },
{ status: 403 }
);
}
const { id } = await params;
const userId = parseInt(id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
const body = await request.json();
const { hasWriteAccess, name, password, email, isAdmin: isAdminValue, isActive } = body;
// Prevent users from removing their own admin status
const session = await import('@/app/api/auth/[...nextauth]/route').then(
(m) => m.auth()
);
if (session?.user?.id && parseInt(session.user.id, 10) === userId) {
if (isAdminValue === false) {
return NextResponse.json(
{ error: 'You cannot remove your own admin status' },
{ status: 400 }
);
}
}
// Build update data
const updateData: {
hasWriteAccess?: boolean;
name?: string;
passwordHash?: string;
email?: string;
isAdmin?: boolean;
isActive?: boolean;
} = {};
if (typeof hasWriteAccess === 'boolean') {
updateData.hasWriteAccess = hasWriteAccess;
}
if (typeof isAdminValue === 'boolean') {
updateData.isAdmin = isAdminValue;
}
if (typeof isActive === 'boolean') {
updateData.isActive = isActive;
}
if (name !== undefined) {
if (!name || name.trim().length === 0) {
return NextResponse.json(
{ error: 'Name is required and cannot be empty' },
{ status: 400 }
);
}
updateData.name = name.trim();
}
if (email !== undefined) {
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email format' },
{ status: 400 }
);
}
updateData.email = email;
}
if (password) {
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
updateData.passwordHash = await bcrypt.hash(password, 10);
}
if (Object.keys(updateData).length === 0) {
return NextResponse.json(
{ error: 'No valid fields to update' },
{ status: 400 }
);
}
// Update user
const user = await prismaAuth.user.update({
where: { id: userId },
data: updateData,
select: {
id: true,
email: true,
name: true,
isAdmin: true,
hasWriteAccess: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json(
{ message: 'User updated successfully', user },
{ status: 200 }
);
} catch (error: any) {
console.error('Error updating user:', error);
if (error.code === 'P2025') {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
if (error.code === 'P2002') {
// Unique constraint violation (likely email already exists)
return NextResponse.json(
{ error: 'Email already exists. Please use a different email address.' },
{ status: 409 }
);
}
return NextResponse.json(
{ error: 'Failed to update user', details: error.message },
{ status: 500 }
);
}
}
// DELETE /api/users/[id] - Delete user (admin only)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Check if user is admin
const admin = await isAdmin();
if (!admin) {
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.' },
{ status: 403 }
);
}
const { id } = await params;
const userId = parseInt(id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
// Prevent deleting yourself
const session = await import('@/app/api/auth/[...nextauth]/route').then(
(m) => m.auth()
);
if (session?.user?.id && parseInt(session.user.id, 10) === userId) {
return NextResponse.json(
{ error: 'You cannot delete your own account' },
{ status: 400 }
);
}
// Check if user has any related records in other tables
let pendingIdentifications = 0;
let pendingPhotos = 0;
let inappropriatePhotoReports = 0;
let pendingLinkages = 0;
let photoFavorites = 0;
try {
[pendingIdentifications, pendingPhotos, inappropriatePhotoReports, pendingLinkages, photoFavorites] = await Promise.all([
prismaAuth.pendingIdentification.count({ where: { userId } }),
prismaAuth.pendingPhoto.count({ where: { userId } }),
prismaAuth.inappropriatePhotoReport.count({ where: { userId } }),
prismaAuth.pendingLinkage.count({ where: { userId } }),
prismaAuth.photoFavorite.count({ where: { userId } }),
]);
} catch (countError: any) {
console.error('Error counting related records:', countError);
// If counting fails, err on the side of caution and deactivate instead of delete
await prismaAuth.user.update({
where: { id: userId },
data: { isActive: false },
});
return NextResponse.json(
{
message: 'User deactivated successfully (error checking related records)',
deactivated: true
},
{ status: 200 }
);
}
console.log(`[DELETE User ${userId}] Related records:`, {
pendingIdentifications,
pendingPhotos,
inappropriatePhotoReports,
pendingLinkages,
photoFavorites,
});
// Ensure all counts are numbers and check explicitly
const counts = {
pendingIdentifications: Number(pendingIdentifications) || 0,
pendingPhotos: Number(pendingPhotos) || 0,
inappropriatePhotoReports: Number(inappropriatePhotoReports) || 0,
pendingLinkages: Number(pendingLinkages) || 0,
photoFavorites: Number(photoFavorites) || 0,
};
const hasRelatedRecords =
counts.pendingIdentifications > 0 ||
counts.pendingPhotos > 0 ||
counts.inappropriatePhotoReports > 0 ||
counts.pendingLinkages > 0 ||
counts.photoFavorites > 0;
console.log(`[DELETE User ${userId}] hasRelatedRecords:`, hasRelatedRecords, 'Counts:', counts);
if (hasRelatedRecords) {
console.log(`[DELETE User ${userId}] Deactivating user due to related records`);
// Set user as inactive instead of deleting
try {
await prismaAuth.user.update({
where: { id: userId },
data: { isActive: false },
});
console.log(`[DELETE User ${userId}] User deactivated successfully`);
} catch (updateError: any) {
console.error(`[DELETE User ${userId}] Error deactivating user:`, updateError);
throw updateError;
}
return NextResponse.json(
{
message: 'User deactivated successfully (user has related records in other tables)',
deactivated: true,
relatedRecords: {
pendingIdentifications: counts.pendingIdentifications,
pendingPhotos: counts.pendingPhotos,
inappropriatePhotoReports: counts.inappropriatePhotoReports,
pendingLinkages: counts.pendingLinkages,
photoFavorites: counts.photoFavorites,
}
},
{ status: 200 }
);
}
console.log(`[DELETE User ${userId}] No related records found, proceeding with deletion`);
// Double-check one more time before deleting (defensive programming)
const finalCheck = await Promise.all([
prismaAuth.pendingIdentification.count({ where: { userId } }),
prismaAuth.pendingPhoto.count({ where: { userId } }),
prismaAuth.inappropriatePhotoReport.count({ where: { userId } }),
prismaAuth.pendingLinkage.count({ where: { userId } }),
prismaAuth.photoFavorite.count({ where: { userId } }),
]);
const finalHasRelatedRecords = finalCheck.some(count => count > 0);
if (finalHasRelatedRecords) {
console.log(`[DELETE User ${userId}] Final check found related records, deactivating instead`);
await prismaAuth.user.update({
where: { id: userId },
data: { isActive: false },
});
return NextResponse.json(
{
message: 'User deactivated successfully (related records detected in final check)',
deactivated: true
},
{ status: 200 }
);
}
// No related records, safe to delete
console.log(`[DELETE User ${userId}] Confirmed no related records, deleting user`);
await prismaAuth.user.delete({
where: { id: userId },
});
console.log(`[DELETE User ${userId}] User deleted successfully`);
return NextResponse.json(
{ message: 'User deleted successfully' },
{ status: 200 }
);
} catch (error: any) {
console.error('Error deleting user:', error);
if (error.code === 'P2025') {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to delete user', details: error.message },
{ status: 500 }
);
}
}

View File

@ -1,173 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import { isAdmin } from '@/lib/permissions';
import bcrypt from 'bcryptjs';
import { isValidEmail } from '@/lib/utils';
// GET /api/users - List all users (admin only)
export async function GET(request: NextRequest) {
try {
console.log('[API /users] Request received');
// Check if user is admin
console.log('[API /users] Checking admin status...');
const admin = await isAdmin();
console.log('[API /users] Admin check result:', admin);
if (!admin) {
console.log('[API /users] Unauthorized - user is not admin');
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.', message: 'You must be an administrator to access this resource.' },
{ status: 403 }
);
}
console.log('[API /users] User is admin, fetching users from database...');
// Get filter from query parameters
const { searchParams } = new URL(request.url);
const statusFilter = searchParams.get('status'); // 'all', 'active', 'inactive'
// Build where clause based on filter
let whereClause: any = {};
if (statusFilter === 'active') {
whereClause = { NOT: { isActive: false } }; // Active only (treat null/undefined as active)
} else if (statusFilter === 'inactive') {
whereClause = { isActive: false }; // Inactive only
}
// If 'all' or no filter, don't add where clause (get all users)
const users = await prismaAuth.user.findMany({
where: whereClause,
select: {
id: true,
email: true,
name: true,
isAdmin: true,
hasWriteAccess: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
orderBy: {
createdAt: 'desc',
},
});
console.log('[API /users] Successfully fetched', users.length, 'users');
return NextResponse.json({ users }, { status: 200 });
} catch (error: any) {
console.error('[API /users] Error:', error);
console.error('[API /users] Error stack:', error.stack);
return NextResponse.json(
{
error: 'Failed to fetch users',
details: error.message,
message: error.message || 'An unexpected error occurred while fetching users.'
},
{ status: 500 }
);
}
}
// POST /api/users - Create new user (admin only)
export async function POST(request: NextRequest) {
try {
// Check if user is admin
const admin = await isAdmin();
if (!admin) {
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.' },
{ status: 403 }
);
}
const body = await request.json();
const {
email,
password,
name,
hasWriteAccess,
isAdmin: newUserIsAdmin,
} = body;
// Validate input
if (!email || !password || !name) {
return NextResponse.json(
{ error: 'Email, password, and name are required' },
{ status: 400 }
);
}
if (name.trim().length === 0) {
return NextResponse.json(
{ error: 'Name cannot be empty' },
{ status: 400 }
);
}
if (!isValidEmail(email)) {
return NextResponse.json(
{ error: 'Please enter a valid email address' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
// Check if user already exists
const existingUser = await prismaAuth.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'User with this email already exists' },
{ status: 409 }
);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create user (admin-created users are automatically verified)
const user = await prismaAuth.user.create({
data: {
email,
passwordHash,
name: name.trim(),
hasWriteAccess: hasWriteAccess ?? false,
isAdmin: newUserIsAdmin ?? false,
emailVerified: true, // Admin-created users are automatically verified
emailConfirmationToken: null, // No confirmation token needed
emailConfirmationTokenExpiry: null, // No expiry needed
},
select: {
id: true,
email: true,
name: true,
isAdmin: true,
hasWriteAccess: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json(
{ message: 'User created successfully', user },
{ status: 201 }
);
} catch (error: any) {
console.error('Error creating user:', error);
return NextResponse.json(
{ error: 'Failed to create user', details: error.message },
{ status: 500 }
);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,128 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
/* Blue as primary color (from logo) */
--primary: #1e40af;
--primary-foreground: oklch(0.985 0 0);
/* Blue for secondary/interactive elements - standard blue */
--secondary: #2563eb;
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
/* Blue accent */
--accent: #1e40af;
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: #1e40af;
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: #1e40af;
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: #2563eb;
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: #1e40af;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
/* Dark blue for cards in dark mode */
--card: oklch(0.25 0.08 250);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.25 0.08 250);
--popover-foreground: oklch(0.985 0 0);
/* Blue primary in dark mode (from logo) */
--primary: #3b82f6;
--primary-foreground: oklch(0.145 0 0);
/* Blue secondary in dark mode - standard blue */
--secondary: #3b82f6;
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: #3b82f6;
--accent-foreground: oklch(0.145 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: #3b82f6;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.25 0.08 250);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: #3b82f6;
--sidebar-primary-foreground: oklch(0.145 0 0);
--sidebar-accent: #3b82f6;
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: #3b82f6;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -1,30 +0,0 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { SessionProviderWrapper } from "@/components/SessionProviderWrapper";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const metadata: Metadata = {
title: "PunimTag Photo Viewer",
description: "Browse and search your family photos",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${inter.variable} font-sans antialiased`}>
<SessionProviderWrapper>
{children}
</SessionProviderWrapper>
</body>
</html>
);
}

View File

@ -1,215 +0,0 @@
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl') || '/';
const registered = searchParams.get('registered') === 'true';
const verified = searchParams.get('verified') === 'true';
const passwordReset = searchParams.get('passwordReset') === 'true';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [emailNotVerified, setEmailNotVerified] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isResending, setIsResending] = useState(false);
const handleResendConfirmation = async () => {
setIsResending(true);
try {
const response = await fetch('/api/auth/resend-confirmation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
setError('');
setEmailNotVerified(false);
alert('Confirmation email sent! Please check your inbox.');
} else {
alert(data.error || 'Failed to resend confirmation email');
}
} catch (err) {
alert('An error occurred. Please try again.');
} finally {
setIsResending(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setEmailNotVerified(false);
setIsLoading(true);
try {
// First check if email is verified
const checkResponse = await fetch('/api/auth/check-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const checkData = await checkResponse.json();
if (!checkData.exists) {
setError('Invalid email or password');
setIsLoading(false);
return;
}
if (!checkData.passwordValid) {
setError('Invalid email or password');
setIsLoading(false);
return;
}
if (!checkData.verified) {
setEmailNotVerified(true);
setIsLoading(false);
return;
}
// Email is verified, proceed with login
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Invalid email or password');
} else {
router.push(callbackUrl);
router.refresh();
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
href="/register"
className="font-medium text-blue-600 hover:text-blue-500"
>
create a new account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{registered && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
Account created successfully! Please check your email to confirm your account before signing in.
</p>
</div>
)}
{verified && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
Email verified successfully! You can now sign in.
</p>
</div>
)}
{passwordReset && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
Password reset successfully! You can now sign in with your new password.
</p>
</div>
)}
{emailNotVerified && (
<div className="rounded-md bg-yellow-50 p-4">
<p className="text-sm text-yellow-800 mb-2">
Please verify your email address before signing in. Check your inbox for a confirmation email.
</p>
<button
type="button"
onClick={handleResendConfirmation}
disabled={isResending}
className="text-sm text-yellow-900 underline hover:no-underline font-medium"
>
{isResending ? 'Sending...' : 'Resend confirmation email'}
</button>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="space-y-4 rounded-md shadow-sm">
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary">
Email address
</label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => {
setEmail(e.target.value);
// Clear email verification error when email changes
if (emailNotVerified) {
setEmailNotVerified(false);
}
}}
className="mt-1"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary">
Password
</label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1"
placeholder="••••••••"
/>
</div>
</div>
<div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,236 +0,0 @@
import { prisma } from '@/lib/db';
import { HomePageContent } from './HomePageContent';
import { Photo } from '@prisma/client';
import { serializePhotos, serializePeople, serializeTags } from '@/lib/serialize';
async function getAllPeople() {
try {
return await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted person data detected, attempting fallback query');
try {
// Try with minimal fields first
return await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
// Exclude potentially corrupted optional fields
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
} catch (fallbackError: any) {
console.error('Fallback person query also failed:', fallbackError);
// Return empty array as last resort to prevent page crash
return [];
}
}
// Re-throw if it's a different error
throw error;
}
}
async function getAllTags() {
try {
return await prisma.tag.findMany({
select: {
id: true,
tag_name: true,
created_date: true,
},
orderBy: { tag_name: 'asc' },
});
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted tag data detected, attempting fallback query');
try {
// Try with minimal fields
return await prisma.tag.findMany({
select: {
id: true,
tag_name: true,
// Exclude potentially corrupted date field
},
orderBy: { tag_name: 'asc' },
});
} catch (fallbackError: any) {
console.error('Fallback tag query also failed:', fallbackError);
// Return empty array as last resort to prevent page crash
return [];
}
}
// Re-throw if it's a different error
throw error;
}
}
export default async function HomePage() {
// Fetch photos from database
// Note: Make sure DATABASE_URL is set in .env file
let photos: any[] = []; // Using any to handle select-based query return type
let error: string | null = null;
try {
// Fetch first page of photos (30 photos) for initial load
// Infinite scroll will load more as user scrolls
// Try to load with date fields first, fallback if corrupted data exists
let photosBase;
try {
// Use raw query to read dates as strings and convert manually to avoid Prisma conversion issues
const photosRaw = await prisma.$queryRaw<Array<{
id: number;
path: string;
filename: string;
date_added: string;
date_taken: string | null;
processed: boolean;
media_type: string | null;
}>>`
SELECT
id,
path,
filename,
date_added,
date_taken,
processed,
media_type
FROM photos
WHERE processed = true
ORDER BY date_taken DESC, id DESC
LIMIT 30
`;
photosBase = photosRaw.map(photo => ({
id: photo.id,
path: photo.path,
filename: photo.filename,
date_added: new Date(photo.date_added),
date_taken: photo.date_taken ? new Date(photo.date_taken) : null,
processed: photo.processed,
media_type: photo.media_type,
}));
} catch (dateError: any) {
// If date fields are corrupted, load without them and use fallback values
// Check for P2023 error code or various date conversion error messages
const isDateError = dateError?.code === 'P2023' ||
dateError?.message?.includes('Conversion failed') ||
dateError?.message?.includes('Inconsistent column data') ||
dateError?.message?.includes('Could not convert value');
if (isDateError) {
console.warn('Corrupted date data detected, loading photos without date fields');
photosBase = await prisma.photo.findMany({
where: { processed: true },
select: {
id: true,
path: true,
filename: true,
processed: true,
media_type: true,
// Exclude date fields due to corruption
},
orderBy: { id: 'desc' },
take: 30,
});
// Add fallback date values
photosBase = photosBase.map(photo => ({
...photo,
date_added: new Date(),
date_taken: null,
}));
} else {
throw dateError;
}
}
// If base query works, load faces separately
const photoIds = photosBase.map(p => p.id);
const faces = await prisma.face.findMany({
where: { photo_id: { in: photoIds } },
select: {
id: true,
photo_id: true,
person_id: true,
location: true,
confidence: true,
quality_score: true,
is_primary_encoding: true,
detector_backend: true,
model_name: true,
face_confidence: true,
exif_orientation: true,
pose_mode: true,
yaw_angle: true,
pitch_angle: true,
roll_angle: true,
landmarks: true,
identified_by_user_id: true,
excluded: true,
Person: {
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
},
// Exclude encoding field (Bytes) to avoid P2023 conversion errors
},
});
// Combine the data manually
photos = photosBase.map(photo => ({
...photo,
Face: faces.filter(face => face.photo_id === photo.id),
})) as any;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load photos';
console.error('Error loading photos:', err);
}
// Fetch people and tags for search
const [people, tags] = await Promise.all([
getAllPeople(),
getAllTags(),
]);
return (
<main className="w-full px-4 py-8">
{error ? (
<div className="rounded-lg bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-200">
<p className="font-semibold">Error loading photos</p>
<p className="text-sm">{error}</p>
<p className="mt-2 text-xs">
Make sure DATABASE_URL is configured in your .env file
</p>
</div>
) : (
<HomePageContent initialPhotos={serializePhotos(photos)} people={serializePeople(people)} tags={serializeTags(tags)} />
)}
</main>
);
}

View File

@ -1,106 +0,0 @@
import { notFound } from 'next/navigation';
import { PhotoViewerClient } from '@/components/PhotoViewerClient';
import { prisma } from '@/lib/db';
import { serializePhoto, serializePhotos } from '@/lib/serialize';
async function getPhoto(id: number) {
try {
const photo = await prisma.photo.findUnique({
where: { id },
include: {
faces: {
include: {
person: true,
},
},
photoTags: {
include: {
tag: true,
},
},
},
});
return photo ? serializePhoto(photo) : null;
} catch (error) {
console.error('Error fetching photo:', error);
return null;
}
}
async function getPhotosByIds(ids: number[]) {
try {
const photos = await prisma.photo.findMany({
where: {
id: { in: ids },
processed: true,
},
include: {
faces: {
include: {
person: true,
},
},
photoTags: {
include: {
tag: true,
},
},
},
orderBy: { dateTaken: 'desc' },
});
return serializePhotos(photos);
} catch (error) {
console.error('Error fetching photos:', error);
return [];
}
}
export default async function PhotoPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ photos?: string; index?: string }>;
}) {
const { id } = await params;
const { photos: photosParam, index: indexParam } = await searchParams;
const photoId = parseInt(id, 10);
if (isNaN(photoId)) {
notFound();
}
// Get the current photo
const photo = await getPhoto(photoId);
if (!photo) {
notFound();
}
// If we have a photo list context, fetch all photos for client-side navigation
let allPhotos: typeof photo[] = [];
let currentIndex = 0;
if (photosParam && indexParam) {
const photoIds = photosParam.split(',').map(Number).filter(Boolean);
const parsedIndex = parseInt(indexParam, 10);
if (photoIds.length > 0 && !isNaN(parsedIndex)) {
allPhotos = await getPhotosByIds(photoIds);
// Maintain the original order from the photoIds array
const photoMap = new Map(allPhotos.map((p) => [p.id, p]));
allPhotos = photoIds.map((id) => photoMap.get(id)).filter(Boolean) as typeof photo[];
currentIndex = parsedIndex;
}
}
return (
<PhotoViewerClient
initialPhoto={photo}
allPhotos={allPhotos}
currentIndex={currentIndex}
/>
);
}

View File

@ -1,185 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
import { isValidEmail } from '@/lib/utils';
export default function RegisterPage() {
const router = useRouter();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name || name.trim().length === 0) {
setError('Name is required');
return;
}
if (!email || !isValidEmail(email)) {
setError('Please enter a valid email address');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, name }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to create account');
return;
}
// Registration successful - clear form and redirect to login
setName('');
setEmail('');
setPassword('');
setConfirmPassword('');
setError('');
router.push('/login?registered=true');
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500"
>
sign in to your existing account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="space-y-4 rounded-md shadow-sm">
<div>
<label htmlFor="name" className="block text-sm font-medium text-secondary">
Name <span className="text-red-500">*</span>
</label>
<Input
id="name"
name="name"
type="text"
autoComplete="off"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
placeholder="Your full name"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary">
Email address <span className="text-red-500">*</span>
</label>
<Input
id="email"
name="email"
type="email"
autoComplete="off"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary">
Password
</label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1"
placeholder="••••••••"
/>
<p className="mt-1 text-xs text-gray-500">
Must be at least 6 characters
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary">
Confirm Password
</label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1"
placeholder="••••••••"
/>
</div>
</div>
<div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Creating account...' : 'Create account'}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,184 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
export default function ResetPasswordPage() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!token) {
setError('Invalid reset link. Please request a new password reset.');
}
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!token) {
setError('Invalid reset link. Please request a new password reset.');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to reset password');
} else {
setSuccess(true);
// Redirect to login after 3 seconds
setTimeout(() => {
router.push('/login?passwordReset=true');
}, 3000);
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
if (success) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Password reset successful
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Your password has been reset successfully. Redirecting to login...
</p>
</div>
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
You can now sign in with your new password.
</p>
</div>
<div className="text-center">
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500"
>
Go to login page
</Link>
</div>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Enter your new password below
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="space-y-4 rounded-md shadow-sm">
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary">
New Password
</label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1"
placeholder="••••••••"
/>
<p className="mt-1 text-xs text-gray-500">
Must be at least 6 characters
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary">
Confirm Password
</label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1"
placeholder="••••••••"
/>
</div>
</div>
<div>
<Button
type="submit"
className="w-full"
disabled={isLoading || !token}
>
{isLoading ? 'Resetting password...' : 'Reset password'}
</Button>
</div>
<div className="text-center">
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500"
>
Back to login
</Link>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,208 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Person, Tag, Photo } from '@prisma/client';
import { FilterPanel, SearchFilters } from '@/components/search/FilterPanel';
import { PhotoGrid } from '@/components/PhotoGrid';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
interface SearchContentProps {
people: Person[];
tags: Tag[];
}
export function SearchContent({ people, tags }: SearchContentProps) {
const router = useRouter();
const searchParams = useSearchParams();
// Initialize filters from URL params
const [filters, setFilters] = useState<SearchFilters>(() => {
const peopleParam = searchParams.get('people');
const tagsParam = searchParams.get('tags');
const dateFromParam = searchParams.get('dateFrom');
const dateToParam = searchParams.get('dateTo');
const mediaTypeParam = searchParams.get('mediaType');
const favoritesOnlyParam = searchParams.get('favoritesOnly');
return {
people: peopleParam ? peopleParam.split(',').map(Number).filter(Boolean) : [],
tags: tagsParam ? tagsParam.split(',').map(Number).filter(Boolean) : [],
dateFrom: dateFromParam ? new Date(dateFromParam) : undefined,
dateTo: dateToParam ? new Date(dateToParam) : undefined,
mediaType: (mediaTypeParam as 'all' | 'photos' | 'videos') || 'all',
favoritesOnly: favoritesOnlyParam === 'true',
};
});
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
// Update URL when filters change
useEffect(() => {
const params = new URLSearchParams();
if (filters.people.length > 0) {
params.set('people', filters.people.join(','));
}
if (filters.tags.length > 0) {
params.set('tags', filters.tags.join(','));
}
if (filters.dateFrom) {
params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]);
}
if (filters.dateTo) {
params.set('dateTo', filters.dateTo.toISOString().split('T')[0]);
}
if (filters.mediaType && filters.mediaType !== 'all') {
params.set('mediaType', filters.mediaType);
}
if (filters.favoritesOnly) {
params.set('favoritesOnly', 'true');
}
const newUrl = params.toString() ? `/search?${params.toString()}` : '/search';
router.replace(newUrl, { scroll: false });
}, [filters, router]);
// Reset to page 1 when filters change
useEffect(() => {
setPage(1);
}, [filters.people, filters.tags, filters.dateFrom, filters.dateTo, filters.mediaType, filters.favoritesOnly]);
// Fetch photos when filters or page change
useEffect(() => {
const fetchPhotos = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filters.people.length > 0) {
params.set('people', filters.people.join(','));
if (filters.peopleMode) {
params.set('peopleMode', filters.peopleMode);
}
}
if (filters.tags.length > 0) {
params.set('tags', filters.tags.join(','));
if (filters.tagsMode) {
params.set('tagsMode', filters.tagsMode);
}
}
if (filters.dateFrom) {
params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]);
}
if (filters.dateTo) {
params.set('dateTo', filters.dateTo.toISOString().split('T')[0]);
}
if (filters.mediaType && filters.mediaType !== 'all') {
params.set('mediaType', filters.mediaType);
}
if (filters.favoritesOnly) {
params.set('favoritesOnly', 'true');
}
params.set('page', page.toString());
params.set('pageSize', '30');
const response = await fetch(`/api/search?${params.toString()}`);
if (!response.ok) throw new Error('Failed to search photos');
const data = await response.json();
setPhotos(data.photos);
setTotal(data.total);
} catch (error) {
console.error('Error searching photos:', error);
} finally {
setLoading(false);
}
};
fetchPhotos();
}, [filters, page]);
const hasActiveFilters =
filters.people.length > 0 ||
filters.tags.length > 0 ||
filters.dateFrom ||
filters.dateTo ||
(filters.mediaType && filters.mediaType !== 'all') ||
filters.favoritesOnly === true;
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-4">
{/* Filter Panel */}
<div className="lg:col-span-1">
<FilterPanel
people={people}
tags={tags}
filters={filters}
onFiltersChange={setFilters}
/>
</div>
{/* Results */}
<div className="lg:col-span-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{total === 0 ? (
hasActiveFilters ? (
'No photos found matching your filters'
) : (
'Start by selecting filters to search photos'
)
) : (
`Found ${total} photo${total !== 1 ? 's' : ''}`
)}
</div>
</div>
{photos.length > 0 ? (
<>
<PhotoGrid photos={photos} />
{total > 30 && (
<div className="mt-8 flex justify-center gap-2">
<Button
variant="outline"
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<span className="flex items-center px-4 text-sm text-gray-600 dark:text-gray-400">
Page {page} of {Math.ceil(total / 30)}
</span>
<Button
variant="outline"
disabled={page >= Math.ceil(total / 30)}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
)}
</>
) : hasActiveFilters ? (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500">No photos found matching your filters</p>
</div>
) : (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500">Select filters to search photos</p>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@ -1,114 +0,0 @@
import { Suspense } from 'react';
import { prisma } from '@/lib/db';
import { SearchContent } from './SearchContent';
import { PhotoGrid } from '@/components/PhotoGrid';
async function getAllPeople() {
try {
return await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted person data detected, attempting fallback query');
try {
// Try with minimal fields first
return await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
// Exclude potentially corrupted optional fields
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
} catch (fallbackError: any) {
console.error('Fallback person query also failed:', fallbackError);
// Return empty array as last resort to prevent page crash
return [];
}
}
// Re-throw if it's a different error
throw error;
}
}
async function getAllTags() {
try {
return await prisma.tag.findMany({
select: {
id: true,
tag_name: true,
created_date: true,
},
orderBy: { tag_name: 'asc' },
});
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted tag data detected, attempting fallback query');
try {
// Try with minimal fields
return await prisma.tag.findMany({
select: {
id: true,
tag_name: true,
// Exclude potentially corrupted date field
},
orderBy: { tag_name: 'asc' },
});
} catch (fallbackError: any) {
console.error('Fallback tag query also failed:', fallbackError);
// Return empty array as last resort to prevent page crash
return [];
}
}
// Re-throw if it's a different error
throw error;
}
}
export default async function SearchPage() {
const [people, tags] = await Promise.all([
getAllPeople(),
getAllTags(),
]);
return (
<main className="w-full px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
Search Photos
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Find photos by people, dates, and tags
</p>
</div>
<Suspense fallback={
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Loading search...</div>
</div>
}>
<SearchContent people={people} tags={tags} />
</Suspense>
</main>
);
}

View File

@ -1,122 +0,0 @@
import { PhotoGrid } from '@/components/PhotoGrid';
import { Photo } from '@prisma/client';
/**
* Test page to verify direct URL access vs API proxy
*
* This page displays test images to verify:
* 1. Direct access works for HTTP/HTTPS URLs
* 2. API proxy works for file system paths
* 3. Automatic detection is working correctly
*/
export default function TestImagesPage() {
// Test photos with different path types
const testPhotos: Photo[] = [
// Test 1: Direct URL access (public test image)
{
id: 9991,
path: 'https://picsum.photos/800/600?random=1',
filename: 'test-direct-url-1.jpg',
dateAdded: new Date(),
dateTaken: null,
processed: true,
file_hash: 'test-hash-1',
media_type: 'image',
},
// Test 2: Another direct URL
{
id: 9992,
path: 'https://picsum.photos/800/600?random=2',
filename: 'test-direct-url-2.jpg',
dateAdded: new Date(),
dateTaken: null,
processed: true,
file_hash: 'test-hash-2',
media_type: 'image',
},
// Test 3: File system path (will use API proxy)
{
id: 9993,
path: '/nonexistent/path/test.jpg',
filename: 'test-file-system.jpg',
dateAdded: new Date(),
dateTaken: null,
processed: true,
file_hash: 'test-hash-3',
media_type: 'image',
},
];
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
Image Source Test Page
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Testing direct URL access vs API proxy
</p>
</div>
<div className="mb-6 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<h2 className="mb-2 font-semibold text-blue-900 dark:text-blue-200">
Test Instructions:
</h2>
<ol className="list-inside list-decimal space-y-1 text-sm text-blue-800 dark:text-blue-300">
<li>Open browser DevTools (F12) Network tab</li>
<li>Filter by &quot;Img&quot; to see image requests</li>
<li>
<strong>Direct URL images</strong> should show requests to{' '}
<code className="rounded bg-blue-100 px-1 dark:bg-blue-800">
picsum.photos
</code>
</li>
<li>
<strong>File system images</strong> should show requests to{' '}
<code className="rounded bg-blue-100 px-1 dark:bg-blue-800">
/api/photos/...
</code>
</li>
</ol>
</div>
<div className="mb-4">
<h2 className="mb-2 text-xl font-semibold">Test Images</h2>
<div className="mb-2 text-sm text-gray-600 dark:text-gray-400">
<p>
<strong>Images 1-2:</strong> Direct URL access (should load from
picsum.photos)
</p>
<p>
<strong>Image 3:</strong> File system path (will use API proxy, may
show error if file doesn&apos;t exist)
</p>
</div>
</div>
<PhotoGrid photos={testPhotos} />
<div className="mt-8 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<h3 className="mb-2 font-semibold">Path Details:</h3>
<div className="space-y-2 text-sm">
{testPhotos.map((photo) => (
<div key={photo.id} className="font-mono text-xs">
<div>
<strong>ID {photo.id}:</strong> {photo.path}
</div>
<div className="ml-4 text-gray-600 dark:text-gray-400">
Type:{' '}
{photo.path.startsWith('http://') ||
photo.path.startsWith('https://')
? '✅ Direct URL'
: '📁 File System (API Proxy)'}
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@ -1,367 +0,0 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { useSession } from 'next-auth/react';
import { Button } from '@/components/ui/button';
import { Upload, X, CheckCircle2, AlertCircle, Loader2, Play, Pause } from 'lucide-react';
interface UploadedFile {
file: File;
preview: string;
id: string;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
}
interface FilePreviewItemProps {
uploadedFile: UploadedFile;
onRemove: (id: string) => void;
}
function FilePreviewItem({ uploadedFile, onRemove }: FilePreviewItemProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const isVideo = uploadedFile.file.type.startsWith('video/');
const togglePlay = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const video = videoRef.current;
if (!video) return;
try {
if (video.paused) {
await video.play();
setIsPlaying(true);
} else {
video.pause();
setIsPlaying(false);
}
} catch (error) {
console.error('Error playing video:', error);
// If play() fails, try with muted
try {
video.muted = true;
await video.play();
setIsPlaying(true);
} catch (mutedError) {
console.error('Error playing video even when muted:', mutedError);
}
}
}, []);
return (
<div className="group relative aspect-square overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-900">
{isVideo ? (
<>
<video
ref={videoRef}
src={uploadedFile.preview}
className="h-full w-full object-cover"
playsInline
preload="metadata"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
onLoadedMetadata={() => {
// Video is ready to play
}}
/>
{/* Play/Pause Button Overlay */}
{uploadedFile.status === 'pending' && (
<button
onClick={togglePlay}
type="button"
className="absolute inset-0 z-20 flex items-center justify-center bg-black/20 hover:bg-black/30 transition-colors"
aria-label={isPlaying ? 'Pause video' : 'Play video'}
>
{isPlaying ? (
<Pause className="h-12 w-12 text-white opacity-80" />
) : (
<Play className="h-12 w-12 text-white opacity-80" />
)}
</button>
)}
</>
) : (
<img
src={uploadedFile.preview}
alt={uploadedFile.file.name}
className="h-full w-full object-cover"
/>
)}
{!isVideo && (
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
)}
{/* Status Overlay */}
{uploadedFile.status !== 'pending' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
{uploadedFile.status === 'uploading' && (
<Loader2 className="h-8 w-8 animate-spin text-white" />
)}
{uploadedFile.status === 'success' && (
<CheckCircle2 className="h-8 w-8 text-green-400" />
)}
{uploadedFile.status === 'error' && (
<AlertCircle className="h-8 w-8 text-red-400" />
)}
</div>
)}
{/* Remove Button */}
{uploadedFile.status === 'pending' && (
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onRemove(uploadedFile.id);
}}
className="absolute right-2 top-2 z-30 rounded-full bg-red-500 p-1.5 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600"
aria-label="Remove file"
type="button"
>
<X className="h-4 w-4" />
</button>
)}
{/* File Name */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
<p className="truncate text-xs text-white">
{uploadedFile.file.name}
</p>
{uploadedFile.error && (
<p className="mt-1 text-xs text-red-300">
{uploadedFile.error}
</p>
)}
</div>
</div>
);
}
export function UploadContent() {
const { data: session } = useSession();
const [files, setFiles] = useState<UploadedFile[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const handleFileSelect = useCallback((selectedFiles: FileList | null) => {
if (!selectedFiles) return;
const newFiles: UploadedFile[] = Array.from(selectedFiles)
.filter((file) => file.type.startsWith('image/') || file.type.startsWith('video/'))
.map((file) => ({
file,
preview: URL.createObjectURL(file),
id: `${Date.now()}-${Math.random()}`,
status: 'pending' as const,
}));
setFiles((prev) => [...prev, ...newFiles]);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
handleFileSelect(e.dataTransfer.files);
},
[handleFileSelect]
);
const removeFile = useCallback((id: string) => {
setFiles((prev) => {
const file = prev.find((f) => f.id === id);
if (file) {
URL.revokeObjectURL(file.preview);
}
return prev.filter((f) => f.id !== id);
});
}, []);
const handleSubmit = useCallback(async () => {
if (files.length === 0 || !session?.user) return;
setIsSubmitting(true);
try {
const formData = new FormData();
files.forEach((uploadedFile) => {
formData.append('photos', uploadedFile.file);
});
// Update files to uploading status
setFiles((prev) =>
prev.map((f) => ({ ...f, status: 'uploading' as const }))
);
const response = await fetch('/api/photos/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to upload files');
}
const result = await response.json();
// Update files to success status
setFiles((prev) =>
prev.map((f) => ({ ...f, status: 'success' as const }))
);
// Clear files after 3 seconds
setTimeout(() => {
setFiles((currentFiles) => {
// Revoke object URLs to free memory
currentFiles.forEach((f) => URL.revokeObjectURL(f.preview));
return [];
});
}, 3000);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to upload files';
// Update files to error status
setFiles((prev) =>
prev.map((f) => ({
...f,
status: 'error' as const,
error: errorMessage,
}))
);
} finally {
setIsSubmitting(false);
}
}, [files, session]);
const pendingFiles = files.filter((f) => f.status === 'pending');
const hasPendingFiles = pendingFiles.length > 0;
const allSuccess = files.length > 0 && files.every((f) => f.status === 'success');
return (
<div className="space-y-6">
{/* Upload Area */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`relative rounded-lg border-2 border-dashed p-12 text-center transition-colors ${
isDragging
? 'border-primary bg-primary/5'
: 'border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600'
}`}
>
<input
type="file"
id="file-upload"
ref={fileInputRef}
multiple
accept="image/*,video/*"
className="hidden"
onChange={(e) => handleFileSelect(e.target.files)}
/>
<label
htmlFor="file-upload"
className="flex cursor-pointer flex-col items-center justify-center space-y-4"
>
<Upload
className={`h-12 w-12 ${
isDragging
? 'text-primary'
: 'text-gray-400 dark:text-gray-500'
}`}
/>
<div>
<span className="text-lg font-medium text-secondary dark:text-gray-50">
Drop photos and videos here or click to browse
</span>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Images: JPEG, PNG, GIF, WebP (max 50MB) | Videos: MP4, MOV, AVI, WebM (max 500MB)
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={(event) => {
event.preventDefault();
fileInputRef.current?.click();
}}
>
Select Files
</Button>
</label>
</div>
{/* File List */}
{files.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-secondary dark:text-gray-50">
Selected Files ({files.length})
</h2>
{!allSuccess && (
<Button
onClick={handleSubmit}
disabled={!hasPendingFiles || isSubmitting}
size="sm"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Submit for Review
</>
)}
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{files.map((uploadedFile) => (
<FilePreviewItem
key={uploadedFile.id}
uploadedFile={uploadedFile}
onRemove={removeFile}
/>
))}
</div>
{allSuccess && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4">
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
<p className="text-sm font-medium text-green-800 dark:text-green-200">
Files submitted successfully! They are now pending admin review.
</p>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -1,72 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { UploadContent } from './UploadContent';
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
export function UploadPageClient() {
const router = useRouter();
const handleClose = () => {
router.push('/');
};
return (
<div className="fixed inset-0 z-50 bg-background overflow-y-auto">
<div className="w-full px-4 py-8">
{/* Close button */}
<div className="mb-4 flex items-center justify-end">
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-9 w-9"
aria-label="Close upload"
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Header */}
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<UserMenu />
</div>
</div>
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
</div>
{/* Upload content */}
<div className="mt-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
Upload Photos & Videos
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Upload your photos and videos for admin review. Once approved, they will be added to the collection.
</p>
</div>
<UploadContent />
</div>
</div>
</div>
);
}

View File

@ -1,14 +0,0 @@
import { auth } from '@/app/api/auth/[...nextauth]/route';
import { redirect } from 'next/navigation';
import { UploadPageClient } from './UploadPageClient';
export default async function UploadPage() {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
return <UploadPageClient />;
}

View File

@ -1,22 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -1,104 +0,0 @@
'use client';
import { Button } from '@/components/ui/button';
import { Play, Heart } from 'lucide-react';
interface ActionButtonsProps {
photosCount: number;
isLoggedIn: boolean;
selectedPhotoIds: number[];
selectionMode: boolean;
isBulkFavoriting: boolean;
isPreparingDownload: boolean;
onStartSlideshow: () => void;
onTagSelected: () => void;
onBulkFavorite: () => void;
onDownloadSelected: () => void;
onToggleSelectionMode: () => void;
}
export function ActionButtons({
photosCount,
isLoggedIn,
selectedPhotoIds,
selectionMode,
isBulkFavoriting,
isPreparingDownload,
onStartSlideshow,
onTagSelected,
onBulkFavorite,
onDownloadSelected,
onToggleSelectionMode,
}: ActionButtonsProps) {
if (photosCount === 0) {
return null;
}
return (
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={onStartSlideshow}
className="flex items-center gap-2"
size="sm"
>
<Play className="h-4 w-4" />
Play Slides
</Button>
{isLoggedIn && (
<>
<Button
variant="default"
size="sm"
onClick={onTagSelected}
className="flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white"
disabled={selectedPhotoIds.length === 0}
>
Tag selected
{selectedPhotoIds.length > 0
? ` (${selectedPhotoIds.length})`
: ''}
</Button>
<Button
variant="outline"
size="sm"
onClick={onBulkFavorite}
className="bg-orange-500 hover:bg-orange-600 text-white border-orange-500 hover:border-orange-600 flex items-center gap-1"
disabled={selectedPhotoIds.length === 0 || isBulkFavoriting}
>
<Heart className="h-4 w-4" />
{isBulkFavoriting ? 'Updating...' : 'Favorite selected'}
{!isBulkFavoriting && selectedPhotoIds.length > 0
? ` (${selectedPhotoIds.length})`
: ''}
</Button>
<Button
variant="outline"
size="sm"
onClick={onDownloadSelected}
className="bg-blue-500 hover:bg-blue-600 text-white border-blue-500 hover:border-blue-600"
disabled={selectedPhotoIds.length === 0 || isPreparingDownload}
>
{isPreparingDownload ? 'Preparing download...' : 'Download selected'}
{!isPreparingDownload && selectedPhotoIds.length > 0
? ` (${selectedPhotoIds.length})`
: ''}
</Button>
<Button
variant={selectionMode ? 'secondary' : 'outline'}
size="sm"
onClick={onToggleSelectionMode}
className={selectionMode ? 'bg-blue-400 hover:bg-blue-500 text-white border-blue-400 hover:border-blue-500' : 'bg-blue-400 hover:bg-blue-500 text-white border-blue-400 hover:border-blue-500'}
>
{selectionMode ? 'Done selecting' : 'Select'}
</Button>
</>
)}
</div>
);
}

View File

@ -1,154 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { isValidEmail } from '@/lib/utils';
interface ForgotPasswordDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ForgotPasswordDialog({
open,
onOpenChange,
}: ForgotPasswordDialogProps) {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setEmail('');
setError('');
setSuccess(false);
setIsLoading(false);
}
}, [open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess(false);
if (!email || !isValidEmail(email)) {
setError('Please enter a valid email address');
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to send password reset email');
} else {
setSuccess(true);
setEmail('');
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setEmail('');
setError('');
setSuccess(false);
}
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reset your password</DialogTitle>
<DialogDescription>
Enter your email address and we'll send you a link to reset your password.
</DialogDescription>
</DialogHeader>
{success ? (
<div className="space-y-4">
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
Password reset email sent! Please check your inbox and follow the instructions to reset your password.
</p>
</div>
<DialogFooter>
<Button onClick={() => handleOpenChange(false)} className="w-full">
Close
</Button>
</DialogFooter>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<div>
<label htmlFor="forgot-email" className="block text-sm font-medium text-secondary dark:text-gray-300">
Email address
</label>
<Input
id="forgot-email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1"
placeholder="you@example.com"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={isLoading}
>
{isLoading ? 'Sending...' : 'Send reset link'}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -1,191 +0,0 @@
'use client';
import { useState } from 'react';
import { useSession, signOut } from 'next-auth/react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { User, LogIn, UserPlus, Users, Home, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { LoginDialog } from '@/components/LoginDialog';
import { RegisterDialog } from '@/components/RegisterDialog';
import { ManageUsersPageClient } from '@/app/admin/users/ManageUsersPageClient';
export function Header() {
const { data: session, status } = useSession();
const router = useRouter();
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [manageUsersOpen, setManageUsersOpen] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const handleSignOut = async () => {
await signOut({ callbackUrl: '/' });
};
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="w-full flex h-16 items-center justify-between px-4">
<div className="flex items-center space-x-3">
{/* Home button - commented out for future use */}
{/* <Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="icon"
asChild
className="bg-primary hover:bg-primary/90 rounded-lg"
>
<Link href="/" aria-label="Home">
<Home className="h-5 w-5" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Go to Home</p>
</TooltipContent>
</Tooltip> */}
</div>
<div className="flex items-center gap-2">
{session?.user && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
asChild
className="bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800"
>
<Link href="/upload" aria-label="Upload Photos">
<Upload className="h-5 w-5" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Upload your own photos</p>
</TooltipContent>
</Tooltip>
)}
{status === 'loading' ? (
<div className="h-9 w-9 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
) : session?.user ? (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full"
aria-label="Account menu"
>
<User className="h-5 w-5 text-orange-600" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="end">
<div className="space-y-1">
<div className="px-2 py-1.5">
<p className="text-sm font-medium text-secondary">
{session.user.name || 'User'}
</p>
<p className="text-xs text-muted-foreground">
{session.user.email}
</p>
</div>
<div className="border-t pt-1">
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
router.push('/upload');
}}
>
<Upload className="mr-2 h-4 w-4" />
Upload Photos
</Button>
{session.user.isAdmin && (
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
setManageUsersOpen(true);
}}
>
<Users className="mr-2 h-4 w-4" />
Manage Users
</Button>
)}
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
handleSignOut();
}}
>
Sign out
</Button>
</div>
</div>
</PopoverContent>
</Popover>
) : (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setLoginDialogOpen(true)}
className="flex items-center gap-2"
>
<LogIn className="h-4 w-4" />
<span className="hidden sm:inline">Sign in</span>
</Button>
<Button
size="sm"
onClick={() => setRegisterDialogOpen(true)}
className="flex items-center gap-2"
>
<UserPlus className="h-4 w-4" />
<span className="hidden sm:inline">Sign up</span>
</Button>
</div>
)}
</div>
</div>
<LoginDialog
open={loginDialogOpen}
onOpenChange={(open) => {
setLoginDialogOpen(open);
}}
onOpenRegister={() => {
setLoginDialogOpen(false);
setRegisterDialogOpen(true);
}}
/>
<RegisterDialog
open={registerDialogOpen}
onOpenChange={(open) => {
setRegisterDialogOpen(open);
}}
onOpenLogin={() => {
setRegisterDialogOpen(false);
setLoginDialogOpen(true);
}}
/>
{manageUsersOpen && (
<ManageUsersPageClient onClose={() => setManageUsersOpen(false)} />
)}
</header>
);
}

View File

@ -1,604 +0,0 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { LoginDialog } from '@/components/LoginDialog';
import { RegisterDialog } from '@/components/RegisterDialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
interface IdentifyFaceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
faceId: number;
existingPerson?: {
firstName: string;
lastName: string;
middleName?: string | null;
maidenName?: string | null;
dateOfBirth?: Date | null;
} | null;
onSave: (data: {
personId?: number;
firstName?: string;
lastName?: string;
middleName?: string;
maidenName?: string;
dateOfBirth?: Date;
}) => Promise<void>;
}
export function IdentifyFaceDialog({
open,
onOpenChange,
faceId,
existingPerson,
onSave,
}: IdentifyFaceDialogProps) {
const { data: session, status, update } = useSession();
const router = useRouter();
const [firstName, setFirstName] = useState(existingPerson?.firstName || '');
const [lastName, setLastName] = useState(existingPerson?.lastName || '');
const [middleName, setMiddleName] = useState(existingPerson?.middleName || '');
const [maidenName, setMaidenName] = useState(existingPerson?.maidenName || '');
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<{
firstName?: string;
lastName?: string;
}>({});
const isAuthenticated = status === 'authenticated';
const hasWriteAccess = session?.user?.hasWriteAccess === true;
const isLoading = status === 'loading';
const [mounted, setMounted] = useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [showRegisteredMessage, setShowRegisteredMessage] = useState(false);
const [mode, setMode] = useState<'existing' | 'new'>('existing');
const [people, setPeople] = useState<Array<{
id: number;
firstName: string;
lastName: string;
middleName: string | null;
maidenName: string | null;
dateOfBirth: Date | null;
}>>([]);
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null);
const [peopleSearchQuery, setPeopleSearchQuery] = useState('');
const [peoplePopoverOpen, setPeoplePopoverOpen] = useState(false);
const [loadingPeople, setLoadingPeople] = useState(false);
// Dragging state
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const dialogRef = useRef<HTMLDivElement>(null);
// Prevent hydration mismatch by only rendering on client
useEffect(() => {
setMounted(true);
}, []);
// Reset position when dialog opens
useEffect(() => {
if (open) {
setPosition({ x: 0, y: 0 });
// Reset mode and selected person when dialog opens
setMode('existing');
setSelectedPersonId(null);
setPeopleSearchQuery('');
}
}, [open]);
// Fetch people when dialog opens
useEffect(() => {
if (open && mode === 'existing' && people.length === 0) {
fetchPeople();
}
}, [open, mode]);
const fetchPeople = async () => {
setLoadingPeople(true);
try {
const response = await fetch('/api/people');
if (!response.ok) throw new Error('Failed to fetch people');
const data = await response.json();
setPeople(data.people);
} catch (error) {
console.error('Error fetching people:', error);
} finally {
setLoadingPeople(false);
}
};
// Handle drag start
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault(); // Prevent text selection and other default behaviors
if (dialogRef.current) {
setIsDragging(true);
const rect = dialogRef.current.getBoundingClientRect();
// Calculate the center of the dialog
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// Store the offset from mouse to dialog center
setDragStart({
x: e.clientX - centerX,
y: e.clientY - centerY,
});
}
};
// Handle dragging
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
// Calculate new position relative to center (50%, 50%)
const newX = e.clientX - window.innerWidth / 2 - dragStart.x;
const newY = e.clientY - window.innerHeight / 2 - dragStart.y;
setPosition({ x: newX, y: newY });
};
const handleMouseUp = () => {
setIsDragging(false);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, dragStart]);
const handleSave = async () => {
// Reset errors
setErrors({});
if (mode === 'existing') {
// Validate person selection
if (!selectedPersonId) {
alert('Please select a person');
return;
}
setIsSaving(true);
try {
await onSave({ personId: selectedPersonId });
// Show success message
alert('Identification submitted successfully! It will be reviewed by an administrator before being applied.');
onOpenChange(false);
} catch (error: any) {
console.error('Error saving face identification:', error);
alert(error.message || 'Failed to submit identification. Please try again.');
} finally {
setIsSaving(false);
}
} else {
// Validate required fields for new person
const newErrors: typeof errors = {};
if (!firstName.trim()) {
newErrors.firstName = 'First name is required';
}
if (!lastName.trim()) {
newErrors.lastName = 'Last name is required';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setIsSaving(true);
try {
await onSave({
firstName: firstName.trim(),
lastName: lastName.trim(),
middleName: middleName.trim() || undefined,
maidenName: maidenName.trim() || undefined,
});
// Show success message
alert('Identification submitted successfully! It will be reviewed by an administrator before being applied.');
onOpenChange(false);
// Reset form after successful save
if (!existingPerson) {
setFirstName('');
setLastName('');
setMiddleName('');
setMaidenName('');
}
} catch (error: any) {
console.error('Error saving face identification:', error);
setErrors({
...errors,
// Show error message
});
alert(error.message || 'Failed to submit identification. Please try again.');
} finally {
setIsSaving(false);
}
}
};
// Prevent hydration mismatch - don't render until mounted
if (!mounted) {
return null;
}
// Handle successful login/register - refresh session
const handleAuthSuccess = async () => {
await update();
router.refresh();
};
// Show login prompt if not authenticated
if (!isLoading && !isAuthenticated) {
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={dialogRef}
className="sm:max-w-[500px]"
style={{
transform: position.x !== 0 || position.y !== 0
? `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))`
: undefined,
cursor: isDragging ? 'grabbing' : undefined,
}}
>
<DialogHeader
onMouseDown={handleMouseDown}
className="cursor-grab active:cursor-grabbing select-none"
>
<DialogTitle>Sign In Required</DialogTitle>
<DialogDescription>
You need to be signed in to identify faces. Your identifications will be submitted for approval.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600 mb-4">
Please sign in or create an account to continue.
</p>
<div className="flex gap-2">
<Button
onClick={() => {
setLoginDialogOpen(true);
}}
className="flex-1"
>
Sign in
</Button>
<Button
variant="outline"
onClick={() => {
setRegisterDialogOpen(true);
}}
className="flex-1"
>
Register
</Button>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<LoginDialog
open={loginDialogOpen}
onOpenChange={(open) => {
setLoginDialogOpen(open);
if (!open) {
setShowRegisteredMessage(false);
}
}}
onSuccess={handleAuthSuccess}
onOpenRegister={() => {
setLoginDialogOpen(false);
setRegisterDialogOpen(true);
}}
registered={showRegisteredMessage}
callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
/>
<RegisterDialog
open={registerDialogOpen}
onOpenChange={(open) => {
setRegisterDialogOpen(open);
if (!open) {
setShowRegisteredMessage(false);
}
}}
onSuccess={handleAuthSuccess}
onOpenLogin={() => {
setShowRegisteredMessage(true);
setRegisterDialogOpen(false);
setLoginDialogOpen(true);
}}
callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
/>
</>
);
}
// Show write access required message if authenticated but no write access
if (!isLoading && isAuthenticated && !hasWriteAccess) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={dialogRef}
className="sm:max-w-[500px]"
style={{
transform: position.x !== 0 || position.y !== 0
? `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))`
: undefined,
cursor: isDragging ? 'grabbing' : undefined,
}}
>
<DialogHeader
onMouseDown={handleMouseDown}
className="cursor-grab active:cursor-grabbing select-none"
>
<DialogTitle>Write Access Required</DialogTitle>
<DialogDescription>
You need write access to identify faces.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600 mb-4">
Only users with write access can identify faces. Please contact an administrator to request write access.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={dialogRef}
className="sm:max-w-[500px]"
style={{
transform: position.x !== 0 || position.y !== 0
? `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))`
: undefined,
cursor: isDragging ? 'grabbing' : undefined,
}}
>
<DialogHeader
onMouseDown={handleMouseDown}
className="cursor-grab active:cursor-grabbing select-none"
>
<DialogTitle>Identify Face</DialogTitle>
<DialogDescription>
Choose an existing person or add a new person to identify this face. Your identification will be submitted for approval.
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="py-4 text-center">Loading...</div>
) : (
<div className="grid gap-4 py-4">
{/* Mode selector */}
<div className="flex gap-2 border-b pb-4">
<Button
type="button"
variant={mode === 'existing' ? 'default' : 'outline'}
size="sm"
onClick={() => {
// Clear new person form data when switching to existing mode
setFirstName('');
setLastName('');
setMiddleName('');
setMaidenName('');
setErrors({});
setMode('existing');
}}
className="flex-1"
>
Select Existing Person
</Button>
<Button
type="button"
variant={mode === 'new' ? 'default' : 'outline'}
size="sm"
onClick={() => {
// Clear selected person when switching to new person mode
setSelectedPersonId(null);
setPeopleSearchQuery('');
setPeoplePopoverOpen(false);
setMode('new');
}}
className="flex-1"
>
Add New Person
</Button>
</div>
{mode === 'existing' ? (
<div className="grid gap-2">
<label htmlFor="personSelect" className="text-sm font-medium">
Select Person <span className="text-red-500">*</span>
</label>
<Popover open={peoplePopoverOpen} onOpenChange={setPeoplePopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
disabled={loadingPeople}
>
<Search className="mr-2 h-4 w-4" />
{selectedPersonId
? (() => {
const person = people.find((p) => p.id === selectedPersonId);
return person
? `${person.firstName} ${person.lastName}`
: 'Select a person...';
})()
: loadingPeople
? 'Loading people...'
: 'Select a person...'}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[400px] p-0"
align="start"
onWheel={(event) => {
event.stopPropagation();
}}
>
<div className="p-2">
<Input
placeholder="Search people..."
value={peopleSearchQuery}
onChange={(e) => setPeopleSearchQuery(e.target.value)}
className="mb-2"
/>
<div
className="max-h-[300px] overflow-y-auto"
onWheel={(event) => event.stopPropagation()}
>
{people.filter((person) => {
const fullName = `${person.firstName} ${person.lastName} ${person.middleName || ''} ${person.maidenName || ''}`.toLowerCase();
return fullName.includes(peopleSearchQuery.toLowerCase());
}).length === 0 ? (
<p className="p-2 text-sm text-gray-500">No people found</p>
) : (
<div className="space-y-1">
{people
.filter((person) => {
const fullName = `${person.firstName} ${person.lastName} ${person.middleName || ''} ${person.maidenName || ''}`.toLowerCase();
return fullName.includes(peopleSearchQuery.toLowerCase());
})
.map((person) => {
const isSelected = selectedPersonId === person.id;
return (
<div
key={person.id}
className={cn(
"flex items-center space-x-2 rounded-md p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800",
isSelected && "bg-gray-100 dark:bg-gray-800"
)}
onClick={() => {
setSelectedPersonId(person.id);
setPeoplePopoverOpen(false);
}}
>
<div className="flex-1">
<div className="text-sm font-medium">
{person.firstName} {person.lastName}
</div>
{(person.middleName || person.maidenName) && (
<div className="text-xs text-gray-500">
{[person.middleName, person.maidenName].filter(Boolean).join(' • ')}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
) : (
<>
<div className="grid gap-2">
<label htmlFor="firstName" className="text-sm font-medium">
First Name <span className="text-red-500">*</span>
</label>
<Input
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Enter first name"
className={cn(errors.firstName && 'border-red-500')}
/>
{errors.firstName && (
<p className="text-sm text-red-500">{errors.firstName}</p>
)}
</div>
<div className="grid gap-2">
<label htmlFor="lastName" className="text-sm font-medium">
Last Name <span className="text-red-500">*</span>
</label>
<Input
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Enter last name"
className={cn(errors.lastName && 'border-red-500')}
/>
{errors.lastName && (
<p className="text-sm text-red-500">{errors.lastName}</p>
)}
</div>
<div className="grid gap-2">
<label htmlFor="middleName" className="text-sm font-medium">
Middle Name
</label>
<Input
id="middleName"
value={middleName}
onChange={(e) => setMiddleName(e.target.value)}
placeholder="Enter middle name (optional)"
/>
</div>
<div className="grid gap-2">
<label htmlFor="maidenName" className="text-sm font-medium">
Maiden Name
</label>
<Input
id="maidenName"
value={maidenName}
onChange={(e) => setMaidenName(e.target.value)}
placeholder="Enter maiden name (optional)"
/>
</div>
</>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving || isLoading}>
{isSaving ? 'Saving...' : 'Submit for Approval'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,23 +0,0 @@
'use client';
import { useIdleLogout } from '@/hooks/useIdleLogout';
/**
* Component that handles idle logout functionality
* Must be rendered inside SessionProvider to use useSession hook
*/
export function IdleLogoutHandler() {
// Log out users after 2 hours of inactivity
useIdleLogout(2 * 60 * 60 * 1000); // 2 hours in milliseconds
// This component doesn't render anything
return null;
}

View File

@ -1,304 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import Link from 'next/link';
import { ForgotPasswordDialog } from '@/components/ForgotPasswordDialog';
interface LoginDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
onOpenRegister?: () => void;
callbackUrl?: string;
registered?: boolean;
}
export function LoginDialog({
open,
onOpenChange,
onSuccess,
onOpenRegister,
callbackUrl: initialCallbackUrl,
registered: initialRegistered,
}: LoginDialogProps) {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = initialCallbackUrl || searchParams.get('callbackUrl') || '/';
const registered = initialRegistered || searchParams.get('registered') === 'true';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [emailNotVerified, setEmailNotVerified] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isResending, setIsResending] = useState(false);
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// Reset all form state when dialog opens
useEffect(() => {
if (open) {
setEmail('');
setPassword('');
setError('');
setEmailNotVerified(false);
setIsLoading(false);
setIsResending(false);
setShowPassword(false);
}
}, [open]);
const handleResendConfirmation = async () => {
setIsResending(true);
try {
const response = await fetch('/api/auth/resend-confirmation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
setError('');
setEmailNotVerified(false);
alert('Confirmation email sent! Please check your inbox.');
} else {
alert(data.error || 'Failed to resend confirmation email');
}
} catch (err) {
alert('An error occurred. Please try again.');
} finally {
setIsResending(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setEmailNotVerified(false);
setIsLoading(true);
try {
// First check if email is verified
const checkResponse = await fetch('/api/auth/check-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const checkData = await checkResponse.json();
if (!checkData.exists) {
setError('Invalid email or password');
setIsLoading(false);
return;
}
if (!checkData.passwordValid) {
setError('Invalid email or password');
setIsLoading(false);
return;
}
if (!checkData.verified) {
setEmailNotVerified(true);
setIsLoading(false);
return;
}
// Email is verified, proceed with login
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Invalid email or password');
} else {
onOpenChange(false);
if (onSuccess) {
onSuccess();
} else {
router.push(callbackUrl);
router.refresh();
}
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
// Reset form when closing
setEmail('');
setPassword('');
setError('');
setEmailNotVerified(false);
setIsResending(false);
}
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Sign in to your account</DialogTitle>
<DialogDescription>
Or{' '}
{onOpenRegister ? (
<button
type="button"
className="font-medium text-secondary hover:text-secondary/80"
onClick={() => {
handleOpenChange(false);
onOpenRegister();
}}
>
create a new account
</button>
) : (
<Link
href="/register"
className="font-medium text-secondary hover:text-secondary/80"
onClick={() => handleOpenChange(false)}
>
create a new account
</Link>
)}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{registered && (
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
Account created successfully! Please check your email to confirm your account before signing in.
</p>
</div>
)}
{searchParams.get('verified') === 'true' && (
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
Email verified successfully! You can now sign in.
</p>
</div>
)}
{emailNotVerified && (
<div className="rounded-md bg-yellow-50 p-4 dark:bg-yellow-900/20">
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-2">
Please verify your email address before signing in. Check your inbox for a confirmation email.
</p>
<button
type="button"
onClick={handleResendConfirmation}
disabled={isResending}
className="text-sm text-yellow-900 dark:text-yellow-100 underline hover:no-underline font-medium"
>
{isResending ? 'Sending...' : 'Resend confirmation email'}
</button>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary dark:text-gray-300">
Email address
</label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => {
setEmail(e.target.value);
// Clear email verification error when email changes
if (emailNotVerified) {
setEmailNotVerified(false);
}
}}
className="mt-1"
placeholder="you@example.com"
/>
</div>
<div>
<div className="flex items-center justify-between">
<label htmlFor="password" className="block text-sm font-medium text-secondary dark:text-gray-300">
Password
</label>
<button
type="button"
onClick={() => {
setForgotPasswordOpen(true);
}}
className="text-sm text-secondary hover:text-secondary/80 font-medium"
>
Forgot password?
</button>
</div>
<div className="relative mt-1">
<Input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pr-10"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary hover:text-secondary/80 focus:outline-none"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
<DialogFooter>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</DialogFooter>
</form>
</DialogContent>
<ForgotPasswordDialog
open={forgotPasswordOpen}
onOpenChange={setForgotPasswordOpen}
/>
</Dialog>
);
}

View File

@ -1,78 +0,0 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
import { ActionButtons } from '@/components/ActionButtons';
interface PageHeaderProps {
photosCount: number;
isLoggedIn: boolean;
selectedPhotoIds: number[];
selectionMode: boolean;
isBulkFavoriting: boolean;
isPreparingDownload: boolean;
onStartSlideshow: () => void;
onTagSelected: () => void;
onBulkFavorite: () => void;
onDownloadSelected: () => void;
onToggleSelectionMode: () => void;
}
export function PageHeader({
photosCount,
isLoggedIn,
selectedPhotoIds,
selectionMode,
isBulkFavoriting,
isPreparingDownload,
onStartSlideshow,
onTagSelected,
onBulkFavorite,
onDownloadSelected,
onToggleSelectionMode,
}: PageHeaderProps) {
return (
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<UserMenu />
</div>
</div>
<div className="flex items-center justify-between gap-4">
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
<ActionButtons
photosCount={photosCount}
isLoggedIn={isLoggedIn}
selectedPhotoIds={selectedPhotoIds}
selectionMode={selectionMode}
isBulkFavoriting={isBulkFavoriting}
isPreparingDownload={isPreparingDownload}
onStartSlideshow={onStartSlideshow}
onTagSelected={onTagSelected}
onBulkFavorite={onBulkFavorite}
onDownloadSelected={onDownloadSelected}
onToggleSelectionMode={onToggleSelectionMode}
/>
</div>
</div>
);
}

View File

@ -1,917 +0,0 @@
'use client';
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import { Photo, Person } from '@prisma/client';
import Image from 'next/image';
import { Check, Flag, Play, Heart, Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
TooltipProvider,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { parseFaceLocation, isPointInFace } from '@/lib/face-utils';
import { isUrl, isVideo, getImageSrc } from '@/lib/photo-utils';
import { LoginDialog } from '@/components/LoginDialog';
import { RegisterDialog } from '@/components/RegisterDialog';
interface FaceWithLocation {
id: number;
personId: number | null;
location: string;
person: Person | null;
}
interface PhotoWithPeople extends Photo {
faces?: FaceWithLocation[];
}
interface PhotoGridProps {
photos: PhotoWithPeople[];
selectionMode?: boolean;
selectedPhotoIds?: number[];
onToggleSelect?: (photoId: number) => void;
refreshFavoritesKey?: number;
}
/**
* Gets unique people names from photo faces
*/
function getPeopleNames(photo: PhotoWithPeople): string[] {
if (!photo.faces) return [];
const people = photo.faces
.map((face) => face.person)
.filter((person): person is Person => person !== null)
.map((person: any) => {
// Handle both camelCase and snake_case
const firstName = person.firstName || person.first_name || '';
const lastName = person.lastName || person.last_name || '';
return `${firstName} ${lastName}`.trim();
});
// Remove duplicates
return Array.from(new Set(people));
}
const REPORT_COMMENT_MAX_LENGTH = 300;
const getPhotoFilename = (photo: Photo) => {
if (photo?.filename) {
return photo.filename;
}
if (photo?.path) {
const segments = photo.path.split(/[/\\]/);
const lastSegment = segments.pop();
if (lastSegment) {
return lastSegment;
}
}
return `photo-${photo?.id ?? 'download'}.jpg`;
};
const getPhotoDownloadUrl = (
photo: Photo,
options?: { forceProxy?: boolean; watermark?: boolean }
) => {
const path = photo.path || '';
const isExternal = path.startsWith('http://') || path.startsWith('https://');
if (isExternal && !options?.forceProxy) {
return path;
}
const params = new URLSearchParams();
if (options?.watermark) {
params.set('watermark', 'true');
}
const query = params.toString();
return `/api/photos/${photo.id}/image${query ? `?${query}` : ''}`;
};
export function PhotoGrid({
photos,
selectionMode = false,
selectedPhotoIds = [],
onToggleSelect,
refreshFavoritesKey = 0,
}: PhotoGridProps) {
const router = useRouter();
const { data: session, update } = useSession();
const isLoggedIn = Boolean(session);
const hasWriteAccess = session?.user?.hasWriteAccess === true;
// Normalize photos: ensure faces is always available (handle Face vs faces)
const normalizePhoto = (photo: PhotoWithPeople): PhotoWithPeople => {
const normalized = { ...photo };
// If photo has Face (capital F) but no faces (lowercase), convert it
if (!normalized.faces && (normalized as any).Face) {
normalized.faces = (normalized as any).Face.map((face: any) => ({
id: face.id,
personId: face.person_id || face.personId,
location: face.location,
person: face.Person ? {
id: face.Person.id,
firstName: face.Person.first_name,
lastName: face.Person.last_name,
middleName: face.Person.middle_name,
maidenName: face.Person.maiden_name,
dateOfBirth: face.Person.date_of_birth,
} : null,
}));
}
return normalized;
};
// Normalize all photos
const normalizedPhotos = useMemo(() => {
return photos.map(normalizePhoto);
}, [photos]);
const [hoveredFace, setHoveredFace] = useState<{
photoId: number;
faceId: number;
personId: number | null;
personName: string | null;
} | null>(null);
const [reportingPhotoId, setReportingPhotoId] = useState<number | null>(null);
const [reportedPhotos, setReportedPhotos] = useState<Map<number, { status: string }>>(new Map());
const [favoritingPhotoId, setFavoritingPhotoId] = useState<number | null>(null);
const [favoritedPhotos, setFavoritedPhotos] = useState<Map<number, boolean>>(new Map());
const [showSignInRequiredDialog, setShowSignInRequiredDialog] = useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [showRegisteredMessage, setShowRegisteredMessage] = useState(false);
const [reportDialogPhotoId, setReportDialogPhotoId] = useState<number | null>(null);
const [reportDialogComment, setReportDialogComment] = useState('');
const [reportDialogError, setReportDialogError] = useState<string | null>(null);
const imageRefs = useRef<Map<number, { naturalWidth: number; naturalHeight: number }>>(new Map());
const handleMouseMove = useCallback((
e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>,
photo: PhotoWithPeople
) => {
// Skip face detection for videos
if (isVideo(photo)) {
setHoveredFace(null);
return;
}
if (!photo.faces || photo.faces.length === 0) {
setHoveredFace(null);
return;
}
const container = e.currentTarget;
const rect = container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get image dimensions from cache
const imageData = imageRefs.current.get(photo.id);
if (!imageData) {
setHoveredFace(null);
return;
}
const { naturalWidth, naturalHeight } = imageData;
const containerWidth = rect.width;
const containerHeight = rect.height;
// Check each face to see if mouse is over it
for (const face of photo.faces) {
const location = parseFaceLocation(face.location);
if (!location) continue;
if (
isPointInFace(
mouseX,
mouseY,
location,
naturalWidth,
naturalHeight,
containerWidth,
containerHeight
)
) {
// Face detected!
const person = face.person as any; // Handle both camelCase and snake_case
const personName = person
? `${person.firstName || person.first_name || ''} ${person.lastName || person.last_name || ''}`.trim()
: null;
setHoveredFace({
photoId: photo.id,
faceId: face.id,
personId: face.personId,
personName: personName || null,
});
return;
}
}
// No face detected
setHoveredFace(null);
}, []);
const handleImageLoad = useCallback((photoId: number, img: HTMLImageElement) => {
imageRefs.current.set(photoId, {
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
});
}, []);
const handleDownloadPhoto = useCallback((event: React.MouseEvent, photo: Photo) => {
event.stopPropagation();
const link = document.createElement('a');
link.href = getPhotoDownloadUrl(photo, { watermark: !isLoggedIn });
link.download = getPhotoFilename(photo);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [isLoggedIn]);
// Remove duplicates by ID to prevent React key errors
// Memoized to prevent recalculation on every render
// Must be called before any early returns to maintain hooks order
const uniquePhotos = useMemo(() => {
return normalizedPhotos.filter((photo, index, self) =>
index === self.findIndex((p) => p.id === photo.id)
);
}, [normalizedPhotos]);
// Fetch report status for all photos when component mounts or photos change
// Uses batch API to reduce N+1 query problem
// Must be called before any early returns to maintain hooks order
useEffect(() => {
if (!session?.user?.id) {
setReportedPhotos(new Map());
return;
}
const fetchReportStatuses = async () => {
const photoIds = uniquePhotos.map(p => p.id);
if (photoIds.length === 0) {
return;
}
try {
// Batch API call - single request for all photos
const response = await fetch('/api/photos/reports/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ photoIds }),
});
if (!response.ok) {
throw new Error('Failed to fetch report statuses');
}
const data = await response.json();
const statusMap = new Map<number, { status: string }>();
// Process batch results
if (data.results) {
for (const [photoIdStr, result] of Object.entries(data.results)) {
const photoId = parseInt(photoIdStr, 10);
const reportData = result as { reported: boolean; status?: string };
if (reportData.reported && reportData.status) {
statusMap.set(photoId, { status: reportData.status });
}
}
}
setReportedPhotos(statusMap);
} catch (error) {
console.error('Error fetching batch report statuses:', error);
// Fallback: set empty map on error
setReportedPhotos(new Map());
}
};
fetchReportStatuses();
}, [uniquePhotos, session?.user?.id]);
// Fetch favorite status for all photos when component mounts or photos change
// Uses batch API to reduce N+1 query problem
useEffect(() => {
if (!session?.user?.id) {
setFavoritedPhotos(new Map());
return;
}
const fetchFavoriteStatuses = async () => {
const photoIds = uniquePhotos.map(p => p.id);
if (photoIds.length === 0) {
return;
}
try {
// Batch API call - single request for all photos
const response = await fetch('/api/photos/favorites/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ photoIds }),
});
if (!response.ok) {
throw new Error('Failed to fetch favorite statuses');
}
const data = await response.json();
const favoriteMap = new Map<number, boolean>();
// Process batch results
if (data.results) {
for (const [photoIdStr, isFavorited] of Object.entries(data.results)) {
const photoId = parseInt(photoIdStr, 10);
favoriteMap.set(photoId, isFavorited as boolean);
}
}
setFavoritedPhotos(favoriteMap);
} catch (error) {
console.error('Error fetching batch favorite statuses:', error);
// Fallback: set empty map on error
setFavoritedPhotos(new Map());
}
};
fetchFavoriteStatuses();
}, [uniquePhotos, session?.user?.id, refreshFavoritesKey]);
// Filter out videos for slideshow navigation (only images)
// Note: This is only used for slideshow context, not for navigation
// Memoized to maintain consistent hook order
const imageOnlyPhotos = useMemo(() => {
return uniquePhotos.filter((p) => !isVideo(p));
}, [uniquePhotos]);
const handlePhotoClick = (photoId: number, index: number) => {
const photo = uniquePhotos.find((p) => p.id === photoId);
if (!photo) return;
// Use the full photos list (including videos) for navigation
// This ensures consistent navigation whether clicking a photo or video
const allPhotoIds = uniquePhotos.map((p) => p.id).join(',');
const photoIndex = uniquePhotos.findIndex((p) => p.id === photoId);
if (photoIndex === -1) return;
// Update URL with photo query param while preserving existing params (filters, etc.)
const params = new URLSearchParams(window.location.search);
params.set('photo', photoId.toString());
params.set('photos', allPhotoIds);
params.set('index', photoIndex.toString());
router.push(`/?${params.toString()}`, { scroll: false });
};
const handlePhotoInteraction = (photoId: number, index: number) => {
if (selectionMode && onToggleSelect) {
onToggleSelect(photoId);
return;
}
handlePhotoClick(photoId, index);
};
const resetReportDialog = () => {
setReportDialogPhotoId(null);
setReportDialogComment('');
setReportDialogError(null);
};
const handleUndoReport = async (photoId: number) => {
const reportInfo = reportedPhotos.get(photoId);
const isReported = reportInfo && reportInfo.status === 'pending';
if (!isReported || reportingPhotoId === photoId) {
return;
}
setReportingPhotoId(photoId);
try {
const response = await fetch(`/api/photos/${photoId}/report`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
if (response.status === 401) {
alert('Please sign in to report photos');
} else if (response.status === 403) {
alert('Cannot undo report that has already been reviewed');
} else if (response.status === 404) {
alert('Report not found');
} else {
alert(error.error || 'Failed to undo report');
}
return;
}
const newMap = new Map(reportedPhotos);
newMap.delete(photoId);
setReportedPhotos(newMap);
alert('Report undone successfully.');
} catch (error) {
console.error('Error undoing photo report:', error);
alert('Failed to undo report. Please try again.');
} finally {
setReportingPhotoId(null);
}
};
const handleReportButtonClick = async (e: React.MouseEvent, photoId: number) => {
e.stopPropagation(); // Prevent photo click from firing
if (!session) {
setShowSignInRequiredDialog(true);
return;
}
if (reportingPhotoId === photoId) return; // Already processing
const reportInfo = reportedPhotos.get(photoId);
const isPending = reportInfo && reportInfo.status === 'pending';
const isDismissed = reportInfo && reportInfo.status === 'dismissed';
if (isDismissed) {
alert('This report was dismissed by an administrator and cannot be resubmitted.');
return;
}
if (isPending) {
await handleUndoReport(photoId);
return;
}
setReportDialogPhotoId(photoId);
setReportDialogComment('');
setReportDialogError(null);
};
const handleSubmitReport = async () => {
if (reportDialogPhotoId === null) {
return;
}
const trimmedComment = reportDialogComment.trim();
if (trimmedComment.length > REPORT_COMMENT_MAX_LENGTH) {
setReportDialogError(`Comment must be ${REPORT_COMMENT_MAX_LENGTH} characters or less.`);
return;
}
setReportDialogError(null);
setReportingPhotoId(reportDialogPhotoId);
try {
const response = await fetch(`/api/photos/${reportDialogPhotoId}/report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
comment: trimmedComment.length > 0 ? trimmedComment : null,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => null);
if (response.status === 401) {
setShowSignInRequiredDialog(true);
} else if (response.status === 403) {
alert(error?.error || 'Cannot re-report this photo.');
} else if (response.status === 409) {
alert('You have already reported this photo');
} else if (response.status === 400) {
setReportDialogError(error?.error || 'Invalid comment');
return;
} else {
alert(error?.error || 'Failed to report photo. Please try again.');
}
return;
}
const newMap = new Map(reportedPhotos);
newMap.set(reportDialogPhotoId, { status: 'pending' });
setReportedPhotos(newMap);
const previousReport = reportedPhotos.get(reportDialogPhotoId);
alert(
previousReport && previousReport.status === 'reviewed'
? 'Photo re-reported successfully. Thank you for your report.'
: 'Photo reported successfully. Thank you for your report.'
);
resetReportDialog();
} catch (error) {
console.error('Error reporting photo:', error);
alert('Failed to create report. Please try again.');
} finally {
setReportingPhotoId(null);
}
};
const handleToggleFavorite = async (e: React.MouseEvent, photoId: number) => {
e.stopPropagation(); // Prevent photo click from firing
if (!session) {
setShowSignInRequiredDialog(true);
return;
}
if (favoritingPhotoId === photoId) return; // Already processing
setFavoritingPhotoId(photoId);
try {
const response = await fetch(`/api/photos/${photoId}/favorite`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
if (response.status === 401) {
setShowSignInRequiredDialog(true);
} else {
alert(error.error || 'Failed to toggle favorite');
}
return;
}
const data = await response.json();
const newMap = new Map(favoritedPhotos);
newMap.set(photoId, data.favorited);
setFavoritedPhotos(newMap);
} catch (error) {
console.error('Error toggling favorite:', error);
alert('Failed to toggle favorite. Please try again.');
} finally {
setFavoritingPhotoId(null);
}
};
return (
<TooltipProvider delayDuration={200}>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{uniquePhotos.map((photo, index) => {
const hoveredFaceForPhoto = hoveredFace?.photoId === photo.id ? hoveredFace : null;
const isSelected = selectionMode && selectedPhotoIds.includes(photo.id);
// Determine tooltip text while respecting auth visibility rules
let tooltipText: string = photo.filename; // Default fallback
const isVideoPhoto = isVideo(photo);
if (isVideoPhoto) {
tooltipText = `Video: ${photo.filename}`;
} else if (hoveredFaceForPhoto) {
// Hovering over a specific face
if (hoveredFaceForPhoto.personName) {
// Face is identified - show person name (only if logged in)
tooltipText = isLoggedIn ? hoveredFaceForPhoto.personName : photo.filename;
} else {
// Face is not identified - show "Identify" if user has write access or is not logged in
tooltipText = (!session || hasWriteAccess) ? 'Identify' : photo.filename;
}
} else if (isLoggedIn) {
// Hovering over photo (not a face) - show "People: " + names
const peopleNames = getPeopleNames(photo);
tooltipText = peopleNames.length > 0
? `People: ${peopleNames.join(', ')}`
: photo.filename;
}
return (
<TooltipPrimitive.Root key={photo.id} delayDuration={200}>
<div className="group relative aspect-square">
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handlePhotoInteraction(photo.id, index)}
aria-pressed={isSelected}
className={`relative w-full h-full overflow-hidden rounded-lg bg-gray-100 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-900 ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2' : ''}`}
onMouseMove={(e) => !isVideoPhoto && handleMouseMove(e, photo)}
onMouseLeave={() => setHoveredFace(null)}
>
<Image
src={getImageSrc(photo, { watermark: !isLoggedIn, thumbnail: isVideoPhoto })}
alt={photo.filename}
fill
className="object-contain bg-black/5 transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
priority={index < 9}
onLoad={(e) => !isVideoPhoto && handleImageLoad(photo.id, e.currentTarget)}
/>
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/10" />
{/* Video play icon overlay */}
{isVideoPhoto && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/30 transition-colors">
<div className="rounded-full bg-white/90 p-3 shadow-lg group-hover:bg-white transition-colors">
<Play className="h-6 w-6 text-secondary fill-secondary ml-1" />
</div>
</div>
)}
{selectionMode && (
<>
<div
className={`absolute inset-0 rounded-lg border-2 transition-colors pointer-events-none ${isSelected ? 'border-orange-600' : 'border-transparent'}`}
/>
<div
className={`absolute right-2 top-2 z-10 rounded-full border border-white/50 p-1 text-white transition-colors ${isSelected ? 'bg-orange-600' : 'bg-black/60'}`}
>
<Check className="h-4 w-4" />
</div>
</>
)}
</button>
</TooltipTrigger>
{/* Download Button - Top Left Corner */}
<button
type="button"
onClick={(e) => handleDownloadPhoto(e, photo)}
className="absolute left-2 top-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
aria-label="Download photo"
title="Download photo"
>
<Download className="h-4 w-4" />
</button>
{/* Report Button - Left Bottom Corner - Show always */}
{(() => {
if (!session) {
// Not logged in - show basic report button
return (
<button
type="button"
onClick={(e) => handleReportButtonClick(e, photo.id)}
className="absolute left-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
aria-label="Report inappropriate photo"
title="Report inappropriate photo"
>
<Flag className="h-4 w-4" />
</button>
);
}
// Logged in - show button with status
const reportInfo = reportedPhotos.get(photo.id);
const isReported = reportInfo && reportInfo.status === 'pending';
const isReviewed = reportInfo && reportInfo.status === 'reviewed';
const isDismissed = reportInfo && reportInfo.status === 'dismissed';
let tooltipText: string;
let buttonClass: string;
if (isReported) {
tooltipText = 'Reported as inappropriate. Click to undo';
buttonClass = 'bg-red-600/70 hover:bg-red-600/90';
} else if (isReviewed) {
tooltipText = 'Report reviewed and kept. Click to report again';
buttonClass = 'bg-green-600/70 hover:bg-green-600/90';
} else if (isDismissed) {
tooltipText = 'Report dismissed';
buttonClass = 'bg-gray-600/70 hover:bg-gray-600/90';
} else {
tooltipText = 'Report inappropriate photo';
buttonClass = 'bg-black/50 hover:bg-black/70';
}
return (
<button
type="button"
onClick={(e) => handleReportButtonClick(e, photo.id)}
disabled={reportingPhotoId === photo.id || isDismissed}
className={`absolute left-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed ${buttonClass}`}
aria-label={tooltipText}
title={tooltipText}
>
<Flag className={`h-4 w-4 ${isReported || isReviewed ? 'fill-current' : ''}`} />
</button>
);
})()}
{/* Favorite Button - Right Bottom Corner - Show always */}
{(() => {
if (!session) {
// Not logged in - show basic favorite button
return (
<button
type="button"
onClick={(e) => handleToggleFavorite(e, photo.id)}
className="absolute right-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
aria-label="Add to favorites"
title="Add to favorites (sign in required)"
>
<Heart className="h-4 w-4" />
</button>
);
}
// Logged in - show button with favorite status
const isFavorited = favoritedPhotos.get(photo.id) || false;
return (
<button
type="button"
onClick={(e) => handleToggleFavorite(e, photo.id)}
disabled={favoritingPhotoId === photo.id}
className={`absolute right-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed ${
isFavorited
? 'bg-red-600/70 hover:bg-red-600/90'
: 'bg-black/50 hover:bg-black/70'
}`}
aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
title={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart className={`h-4 w-4 ${isFavorited ? 'fill-current' : ''}`} />
</button>
);
})()}
</div>
<TooltipContent
side="bottom"
sideOffset={5}
className="max-w-xs bg-blue-400 text-white z-[9999]"
arrowColor="blue-400"
>
<p className="text-sm font-medium">{tooltipText || photo.filename}</p>
</TooltipContent>
</TooltipPrimitive.Root>
);
})}
</div>
{/* Report Comment Dialog */}
<Dialog
open={reportDialogPhotoId !== null}
onOpenChange={(open) => {
if (!open) {
resetReportDialog();
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Report Photo</DialogTitle>
<DialogDescription>
Optionally include a short comment to help administrators understand the issue.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<label htmlFor="report-comment" className="text-sm font-medium text-secondary">
Comment (optional)
</label>
<textarea
id="report-comment"
value={reportDialogComment}
onChange={(event) => setReportDialogComment(event.target.value)}
maxLength={REPORT_COMMENT_MAX_LENGTH}
className="mt-2 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900"
rows={4}
placeholder="Add a short note about why this photo should be reviewed..."
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>{`${reportDialogComment.length}/${REPORT_COMMENT_MAX_LENGTH} characters`}</span>
{reportDialogError && <span className="text-red-600">{reportDialogError}</span>}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
resetReportDialog();
}}
>
Cancel
</Button>
<Button
onClick={handleSubmitReport}
disabled={
reportDialogPhotoId === null || reportingPhotoId === reportDialogPhotoId
}
>
{reportDialogPhotoId !== null && reportingPhotoId === reportDialogPhotoId
? 'Reporting...'
: 'Report photo'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Sign In Required Dialog for Report */}
<Dialog open={showSignInRequiredDialog} onOpenChange={setShowSignInRequiredDialog}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Sign In Required</DialogTitle>
<DialogDescription>
You need to be signed in to report photos. Your reports will be reviewed by administrators.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600 mb-4">
Please sign in or create an account to continue.
</p>
<div className="flex gap-2">
<Button
onClick={() => {
setLoginDialogOpen(true);
}}
className="flex-1"
>
Sign in
</Button>
<Button
variant="outline"
onClick={() => {
setRegisterDialogOpen(true);
}}
className="flex-1"
>
Register
</Button>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowSignInRequiredDialog(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Login Dialog */}
<LoginDialog
open={loginDialogOpen}
onOpenChange={(open) => {
setLoginDialogOpen(open);
if (!open) {
setShowRegisteredMessage(false);
}
}}
onSuccess={async () => {
await update();
router.refresh();
setShowSignInRequiredDialog(false);
}}
onOpenRegister={() => {
setLoginDialogOpen(false);
setRegisterDialogOpen(true);
}}
registered={showRegisteredMessage}
callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
/>
{/* Register Dialog */}
<RegisterDialog
open={registerDialogOpen}
onOpenChange={(open) => {
setRegisterDialogOpen(open);
if (!open) {
setShowRegisteredMessage(false);
}
}}
onSuccess={async () => {
await update();
router.refresh();
setShowSignInRequiredDialog(false);
}}
onOpenLogin={() => {
setShowRegisteredMessage(true);
setRegisterDialogOpen(false);
setLoginDialogOpen(true);
}}
callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
/>
</TooltipProvider>
);
}

View File

@ -1,172 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { Photo, Person } from '@prisma/client';
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { isUrl, getImageSrc } from '@/lib/photo-utils';
interface PhotoWithDetails extends Photo {
faces?: Array<{
person: Person | null;
}>;
photoTags?: Array<{
tag: {
tagName: string;
};
}>;
}
interface PhotoViewerProps {
photo: PhotoWithDetails;
previousId: number | null;
nextId: number | null;
}
export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const { data: session } = useSession();
const isLoggedIn = Boolean(session);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft' && previousId) {
navigateToPhoto(previousId);
} else if (e.key === 'ArrowRight' && nextId) {
navigateToPhoto(nextId);
} else if (e.key === 'Escape') {
router.back();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [previousId, nextId, router]);
const navigateToPhoto = (photoId: number) => {
setLoading(true);
router.push(`/photo/${photoId}`);
};
const handlePrevious = () => {
if (previousId) {
navigateToPhoto(previousId);
}
};
const handleNext = () => {
if (nextId) {
navigateToPhoto(nextId);
}
};
const handleClose = () => {
// Use router.back() to return to the previous page without reloading
// This preserves filters, pagination, and scroll position
router.back();
};
const peopleNames = photo.faces
?.map((face) => face.person)
.filter((person): person is Person => person !== null)
.map((person) => `${person.firstName} ${person.lastName}`.trim()) || [];
const tags = photo.photoTags?.map((pt) => pt.tag.tagName) || [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
{/* Close Button */}
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 z-10 text-white hover:bg-white/20"
onClick={handleClose}
aria-label="Close"
>
<X className="h-6 w-6" />
</Button>
{/* Previous Button */}
{previousId && (
<Button
variant="ghost"
size="icon"
className="absolute left-4 z-10 text-white hover:bg-white/20 disabled:opacity-30"
onClick={handlePrevious}
disabled={loading}
aria-label="Previous photo"
>
<ChevronLeft className="h-8 w-8" />
</Button>
)}
{/* Next Button */}
{nextId && (
<Button
variant="ghost"
size="icon"
className="absolute right-4 z-10 text-white hover:bg-white/20 disabled:opacity-30"
onClick={handleNext}
disabled={loading}
aria-label="Next photo"
>
<ChevronRight className="h-8 w-8" />
</Button>
)}
{/* Photo Container */}
<div className="relative h-full w-full flex items-center justify-center p-4">
{loading ? (
<div className="text-white">Loading...</div>
) : (
<div className="relative h-full w-full max-h-[90vh] max-w-full">
<Image
src={getImageSrc(photo, { watermark: !isLoggedIn })}
alt={photo.filename}
fill
className="object-contain"
priority
unoptimized={!isUrl(photo.path)}
sizes="100vw"
/>
</div>
)}
</div>
{/* Photo Info Overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 text-white">
<div className="container mx-auto">
<h2 className="text-xl font-semibold mb-2">{photo.filename}</h2>
{photo.dateTaken && (
<p className="text-sm text-gray-300 mb-2">
{new Date(photo.dateTaken).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
)}
{peopleNames.length > 0 && (
<p className="text-sm text-gray-300 mb-1">
<span className="font-medium">People: </span>
{peopleNames.join(', ')}
</p>
)}
{tags.length > 0 && (
<p className="text-sm text-gray-300">
<span className="font-medium">Tags: </span>
{tags.join(', ')}
</p>
)}
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,281 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import Link from 'next/link';
import { isValidEmail } from '@/lib/utils';
interface RegisterDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
onOpenLogin?: () => void;
callbackUrl?: string;
}
export function RegisterDialog({
open,
onOpenChange,
onSuccess,
onOpenLogin,
callbackUrl: initialCallbackUrl,
}: RegisterDialogProps) {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = initialCallbackUrl || searchParams.get('callbackUrl') || '/';
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// Clear form when dialog opens
useEffect(() => {
if (open) {
setName('');
setEmail('');
setPassword('');
setConfirmPassword('');
setError('');
setShowPassword(false);
setShowConfirmPassword(false);
}
}, [open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name || name.trim().length === 0) {
setError('Name is required');
return;
}
if (!email || !isValidEmail(email)) {
setError('Please enter a valid email address');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, name }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to create account');
return;
}
// Registration successful - clear form and show success message
setName('');
setEmail('');
setPassword('');
setConfirmPassword('');
setError('');
// Show success state
alert('Account created successfully! Please check your email to confirm your account before signing in.');
onOpenChange(false);
if (onOpenLogin) {
// Open login dialog with registered flag
onOpenLogin();
} else if (onSuccess) {
onSuccess();
} else {
// Redirect to login with registered flag
router.push(`/login?registered=true&callbackUrl=${encodeURIComponent(callbackUrl)}`);
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
// Reset form when closing
setName('');
setEmail('');
setPassword('');
setConfirmPassword('');
setError('');
setShowPassword(false);
setShowConfirmPassword(false);
}
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create your account</DialogTitle>
<DialogDescription>
Or{' '}
<button
type="button"
className="font-medium text-secondary hover:text-secondary/80"
onClick={() => {
handleOpenChange(false);
if (onOpenLogin) {
onOpenLogin();
}
}}
>
sign in to your existing account
</button>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-secondary dark:text-gray-300">
Name <span className="text-red-500">*</span>
</label>
<Input
id="name"
name="name"
type="text"
autoComplete="off"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
placeholder="Your full name"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary dark:text-gray-300">
Email address <span className="text-red-500">*</span>
</label>
<Input
id="email"
name="email"
type="email"
autoComplete="off"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary dark:text-gray-300">
Password
</label>
<div className="relative mt-1">
<Input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pr-10"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary hover:text-secondary/80 focus:outline-none"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Must be at least 6 characters
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary dark:text-gray-300">
Confirm Password
</label>
<div className="relative mt-1">
<Input
id="confirmPassword"
name="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pr-10"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary hover:text-secondary/80 focus:outline-none"
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
<DialogFooter>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Creating account...' : 'Create account'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -1,20 +0,0 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import { IdleLogoutHandler } from '@/components/IdleLogoutHandler';
export function SessionProviderWrapper({
children,
}: {
children: React.ReactNode;
}) {
return (
<SessionProvider>
<IdleLogoutHandler />
{children}
</SessionProvider>
);
}

View File

@ -1,36 +0,0 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
export function SimpleHeader() {
return (
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<UserMenu />
</div>
</div>
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
</div>
);
}

View File

@ -1,334 +0,0 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Tag as TagModel } from '@prisma/client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Loader2, Tag as TagIcon, X } from 'lucide-react';
interface TagSelectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
photoIds: number[];
tags: TagModel[];
onSuccess?: () => void;
}
export function TagSelectionDialog({
open,
onOpenChange,
photoIds,
tags,
onSuccess,
}: TagSelectionDialogProps) {
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [customTags, setCustomTags] = useState<string[]>([]);
const [customTagInput, setCustomTagInput] = useState('');
const [notes, setNotes] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const filteredTags = useMemo(() => {
if (!searchQuery.trim()) {
return tags;
}
const query = searchQuery.toLowerCase();
return tags.filter((tag) => tag.tagName.toLowerCase().includes(query));
}, [searchQuery, tags]);
useEffect(() => {
if (!open) {
setSelectedTagIds([]);
setSearchQuery('');
setCustomTags([]);
setCustomTagInput('');
setNotes('');
setError(null);
}
}, [open]);
useEffect(() => {
setSelectedTagIds((prev) =>
prev.filter((id) => tags.some((tag) => tag.id === id))
);
}, [tags]);
const toggleTagSelection = (tagId: number) => {
setSelectedTagIds((prev) =>
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]
);
};
const canSubmit =
photoIds.length > 0 &&
(selectedTagIds.length > 0 ||
customTags.length > 0 ||
customTagInput.trim().length > 0);
const normalizeTagName = (value: string) => value.trim().replace(/\s+/g, ' ');
const addCustomTag = () => {
const candidate = normalizeTagName(customTagInput);
if (!candidate) {
setCustomTagInput('');
return;
}
const exists = customTags.some(
(tag) => tag.toLowerCase() === candidate.toLowerCase()
);
if (!exists) {
setCustomTags((prev) => [...prev, candidate]);
}
setCustomTagInput('');
};
const removeCustomTag = (tagName: string) => {
setCustomTags((prev) =>
prev.filter((tag) => tag.toLowerCase() !== tagName.toLowerCase())
);
};
const handleSubmit = async () => {
setError(null);
if (photoIds.length === 0) {
setError('Select at least one photo before tagging.');
return;
}
const normalizedInput = normalizeTagName(customTagInput);
const proposedTags = [
...customTags,
...(normalizedInput ? [normalizedInput] : []),
];
const uniqueNewTags = Array.from(
new Map(
proposedTags.map((tag) => [tag.toLowerCase(), tag])
).values()
);
const payload = {
photoIds,
tagIds: selectedTagIds.length > 0 ? selectedTagIds : undefined,
newTagNames: uniqueNewTags.length > 0 ? uniqueNewTags : undefined,
notes: notes.trim() || undefined,
};
try {
setIsSubmitting(true);
const response = await fetch('/api/photos/tag-linkages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to submit tag linkages');
}
alert(
data.message ||
'Tag submissions sent for approval. An administrator will review them soon.'
);
onOpenChange(false);
onSuccess?.();
setCustomTags([]);
setCustomTagInput('');
} catch (submissionError: any) {
setError(submissionError.message || 'Failed to submit tag linkages');
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Tag selected photos</DialogTitle>
<DialogDescription>
Choose existing tags or propose a new tag. Your request goes to the
pending queue for admin approval before it appears on the site.
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="rounded-md bg-muted/40 p-3 text-sm text-muted-foreground">
Tagging{' '}
<span className="font-medium text-foreground">
{photoIds.length}
</span>{' '}
photo{photoIds.length === 1 ? '' : 's'}. Pending linkages require
administrator approval.
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Choose existing tags</label>
<Input
placeholder="Start typing to filter tags..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
<div className="max-h-52 overflow-y-auto rounded-md border p-2 space-y-1">
{filteredTags.length === 0 ? (
<p className="text-sm text-muted-foreground px-1">
No tags match your search.
</p>
) : (
filteredTags.map((tag) => (
<label
key={tag.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/60"
>
<Checkbox
checked={selectedTagIds.includes(tag.id)}
onCheckedChange={() => toggleTagSelection(tag.id)}
/>
<span className="text-sm">{tag.tagName}</span>
</label>
))
)}
</div>
{selectedTagIds.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{selectedTagIds.map((id) => {
const tag = tags.find((item) => item.id === id);
if (!tag) return null;
return (
<Badge
key={id}
variant="secondary"
className="flex items-center gap-1"
>
<TagIcon className="h-3 w-3" />
{tag.tagName}
</Badge>
);
})}
</div>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Add a new tag
</label>
<div className="flex flex-col gap-2">
<Input
placeholder="Enter a new tag name, press Enter to add"
value={customTagInput}
onChange={(event) => setCustomTagInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustomTag();
}
}}
/>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
onClick={addCustomTag}
>
Add
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setCustomTags([]);
setCustomTagInput('');
}}
disabled={customTags.length === 0 && !customTagInput.trim()}
>
Clear
</Button>
</div>
</div>
{customTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{customTags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
<TagIcon className="h-3 w-3" />
{tag}
<button
type="button"
className="ml-1 text-xs text-muted-foreground hover:text-foreground"
onClick={() => removeCustomTag(tag)}
aria-label={`Remove ${tag}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
Add as many missing tags as you need. Admins will create them during
review.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Notes for admins (optional)
</label>
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
rows={3}
placeholder="Add any additional context to help admins approve faster"
/>
</div>
{error && (
<p className="text-sm text-red-500" role="alert">
{error}
</p>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
'Submit for review'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,169 +0,0 @@
'use client';
import { useState } from 'react';
import { useSession, signOut } from 'next-auth/react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { User, Upload, Users, LogIn, UserPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { LoginDialog } from '@/components/LoginDialog';
import { RegisterDialog } from '@/components/RegisterDialog';
import { ManageUsersPageClient } from '@/app/admin/users/ManageUsersPageClient';
function UserMenu() {
const { data: session, status } = useSession();
const router = useRouter();
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [manageUsersOpen, setManageUsersOpen] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const handleSignOut = async () => {
await signOut({ callbackUrl: '/' });
};
if (status === 'loading') {
return <div className="h-9 w-9 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />;
}
if (session?.user) {
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
asChild
className="bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800"
>
<Link href="/upload" aria-label="Upload Photos">
<Upload className="h-5 w-5" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Upload your own photos</p>
</TooltipContent>
</Tooltip>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full"
aria-label="Account menu"
>
<User className="h-5 w-5 text-orange-600" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2 z-[110]" align="end">
<div className="space-y-1">
<div className="px-2 py-1.5">
<p className="text-sm font-medium text-secondary">
{session.user.name || 'User'}
</p>
<p className="text-xs text-muted-foreground">
{session.user.email}
</p>
</div>
<div className="border-t pt-1">
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
router.push('/upload');
}}
>
<Upload className="mr-2 h-4 w-4" />
Upload Photos
</Button>
{session.user.isAdmin && (
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
setManageUsersOpen(true);
}}
>
<Users className="mr-2 h-4 w-4" />
Manage Users
</Button>
)}
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
handleSignOut();
}}
>
Sign out
</Button>
</div>
</div>
</PopoverContent>
</Popover>
{manageUsersOpen && (
<ManageUsersPageClient onClose={() => setManageUsersOpen(false)} />
)}
</>
);
}
return (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setLoginDialogOpen(true)}
className="flex items-center gap-2"
>
<LogIn className="h-4 w-4" />
<span className="hidden sm:inline">Sign in</span>
</Button>
<Button
size="sm"
onClick={() => setRegisterDialogOpen(true)}
className="flex items-center gap-2"
>
<UserPlus className="h-4 w-4" />
<span className="hidden sm:inline">Sign up</span>
</Button>
<LoginDialog
open={loginDialogOpen}
onOpenChange={(open) => {
setLoginDialogOpen(open);
}}
onOpenRegister={() => {
setLoginDialogOpen(false);
setRegisterDialogOpen(true);
}}
/>
<RegisterDialog
open={registerDialogOpen}
onOpenChange={(open) => {
setRegisterDialogOpen(open);
}}
onOpenLogin={() => {
setRegisterDialogOpen(false);
setLoginDialogOpen(true);
}}
/>
</>
);
}
export default UserMenu;

View File

@ -1,92 +0,0 @@
'use client';
import { useState } from 'react';
import { Person, Tag } from '@prisma/client';
import { FilterPanel, SearchFilters } from './FilterPanel';
import { Button } from '@/components/ui/button';
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface CollapsibleSearchProps {
people: Person[];
tags: Tag[];
filters: SearchFilters;
onFiltersChange: (filters: SearchFilters) => void;
}
export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: CollapsibleSearchProps) {
const [isExpanded, setIsExpanded] = useState(true);
const hasActiveFilters =
filters.people.length > 0 ||
filters.tags.length > 0 ||
filters.dateFrom ||
filters.dateTo;
return (
<div
className={cn(
'flex flex-col border-r bg-card transition-all duration-300 sticky top-0 self-start',
isExpanded ? 'w-80' : 'w-16',
'h-[calc(100vh-8rem)]'
)}
>
{/* Collapse/Expand Button */}
<div className="flex items-center justify-between border-b p-4 flex-shrink-0">
{isExpanded ? (
<>
<div className="flex items-center gap-2">
<Search className="h-4 w-4" />
<span className="font-medium text-secondary">Search & Filter</span>
{hasActiveFilters && (
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
{[
filters.people.length,
filters.tags.length,
filters.dateFrom || filters.dateTo ? 1 : 0,
].reduce((a, b) => a + b, 0)}
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(false)}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</>
) : (
<div className="flex items-center justify-center w-full">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(true)}
className="h-8 w-8 p-0 relative"
title="Expand search"
>
<ChevronRight className="h-4 w-4" />
{hasActiveFilters && (
<span className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-primary border-2 border-card" />
)}
</Button>
</div>
)}
</div>
{/* Expanded Filter Panel */}
{isExpanded && (
<div className="flex-1 overflow-y-auto min-h-0">
<FilterPanel
people={people}
tags={tags}
filters={filters}
onFiltersChange={onFiltersChange}
/>
</div>
)}
</div>
);
}

View File

@ -1,182 +0,0 @@
'use client';
import { useState } from 'react';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { CalendarIcon, X } from 'lucide-react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
interface DateRangeFilterProps {
dateFrom?: Date;
dateTo?: Date;
onDateChange: (dateFrom?: Date, dateTo?: Date) => void;
}
const datePresets = [
{ label: 'Today', getDates: () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return { from: today, to: new Date() };
}},
{ label: 'This Week', getDates: () => {
const today = new Date();
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay());
weekStart.setHours(0, 0, 0, 0);
return { from: weekStart, to: new Date() };
}},
{ label: 'This Month', getDates: () => {
const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
return { from: monthStart, to: new Date() };
}},
{ label: 'This Year', getDates: () => {
const today = new Date();
const yearStart = new Date(today.getFullYear(), 0, 1);
return { from: yearStart, to: new Date() };
}},
];
export function DateRangeFilter({ dateFrom, dateTo, onDateChange }: DateRangeFilterProps) {
const [open, setOpen] = useState(false);
const applyPreset = (preset: typeof datePresets[0]) => {
const { from, to } = preset.getDates();
onDateChange(from, to);
setOpen(false);
};
const clearDates = () => {
onDateChange(undefined, undefined);
};
return (
<div className="space-y-2">
<label className="text-sm font-medium text-secondary">Date Range</label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!dateFrom && !dateTo && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateFrom && dateTo ? (
<>
{format(dateFrom, 'MMM d, yyyy')} - {format(dateTo, 'MMM d, yyyy')}
</>
) : (
'Select date range...'
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-3 space-y-2">
<div className="space-y-1">
<p className="text-sm font-medium text-secondary">Quick Presets</p>
<div className="flex flex-wrap gap-2">
{datePresets.map((preset) => (
<Button
key={preset.label}
variant="outline"
size="sm"
onClick={() => applyPreset(preset)}
className="text-xs"
>
{preset.label}
</Button>
))}
</div>
</div>
<div className="border-t pt-2">
<p className="text-sm font-medium text-secondary mb-2">Custom Range</p>
<Calendar
mode="range"
captionLayout="dropdown"
fromYear={1900}
toYear={new Date().getFullYear() + 10}
selected={{
from: dateFrom,
to: dateTo,
}}
onSelect={(range: { from?: Date; to?: Date } | undefined) => {
if (!range) {
onDateChange(undefined, undefined);
return;
}
// If both from and to are set, check if they're different dates
if (range.from && range.to) {
// Check if dates are on the same day
const fromDate = new Date(range.from);
fromDate.setHours(0, 0, 0, 0);
const toDate = new Date(range.to);
toDate.setHours(0, 0, 0, 0);
const sameDay = fromDate.getTime() === toDate.getTime();
if (!sameDay) {
// Valid range with different dates - complete selection and close
onDateChange(range.from, range.to);
setOpen(false);
} else {
// Same day - treat as "from" only, keep popover open for "to" selection
onDateChange(range.from, undefined);
}
} else if (range.from) {
// Only "from" is selected - keep popover open for "to" selection
onDateChange(range.from, undefined);
} else if (range.to) {
// Only "to" is selected (shouldn't happen in range mode, but handle it)
onDateChange(undefined, range.to);
}
}}
numberOfMonths={2}
/>
</div>
{(dateFrom || dateTo) && (
<div className="border-t pt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
clearDates();
setOpen(false);
}}
className="w-full text-xs"
>
<X className="mr-2 h-3 w-3" />
Clear Dates
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
{(dateFrom || dateTo) && (
<Badge variant="secondary" className="flex items-center gap-1 w-fit">
{dateFrom && dateTo ? (
<>
{format(dateFrom, 'MMM d')} - {format(dateTo, 'MMM d, yyyy')}
</>
) : dateFrom ? (
`From ${format(dateFrom, 'MMM d, yyyy')}`
) : (
`Until ${format(dateTo!, 'MMM d, yyyy')}`
)}
<button
onClick={clearDates}
className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
>
<X className="h-3 w-3" />
</button>
</Badge>
)}
</div>
);
}

View File

@ -1,34 +0,0 @@
'use client';
import { Checkbox } from '@/components/ui/checkbox';
import { Heart } from 'lucide-react';
interface FavoritesFilterProps {
value: boolean;
onChange: (value: boolean) => void;
disabled?: boolean;
}
export function FavoritesFilter({ value, onChange, disabled }: FavoritesFilterProps) {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-secondary">Favorites</label>
<div className="flex items-center space-x-2">
<Checkbox
id="favorites-only"
checked={value}
onCheckedChange={(checked) => onChange(checked === true)}
disabled={disabled}
/>
<label
htmlFor="favorites-only"
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<Heart className="h-4 w-4" />
Show favorites only
</label>
</div>
</div>
);
}

View File

@ -1,115 +0,0 @@
'use client';
import { Person, Tag } from '@prisma/client';
import { useSession } from 'next-auth/react';
import { PeopleFilter } from './PeopleFilter';
import { DateRangeFilter } from './DateRangeFilter';
import { TagFilter } from './TagFilter';
import { MediaTypeFilter } from './MediaTypeFilter';
import { FavoritesFilter } from './FavoritesFilter';
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
export interface SearchFilters {
people: number[];
peopleMode?: 'any' | 'all';
tags: number[];
tagsMode?: 'any' | 'all';
dateFrom?: Date;
dateTo?: Date;
mediaType?: 'all' | 'photos' | 'videos';
favoritesOnly?: boolean;
}
interface FilterPanelProps {
people: Person[];
tags: Tag[];
filters: SearchFilters;
onFiltersChange: (filters: SearchFilters) => void;
}
export function FilterPanel({ people, tags, filters, onFiltersChange }: FilterPanelProps) {
const { data: session } = useSession();
const isLoggedIn = Boolean(session);
const updateFilters = (updates: Partial<SearchFilters>) => {
onFiltersChange({ ...filters, ...updates });
};
const clearAllFilters = () => {
onFiltersChange({
people: [],
peopleMode: 'any',
tags: [],
tagsMode: 'any',
dateFrom: undefined,
dateTo: undefined,
mediaType: 'all',
favoritesOnly: false,
});
};
const hasActiveFilters =
filters.people.length > 0 ||
filters.tags.length > 0 ||
filters.dateFrom ||
filters.dateTo ||
(filters.mediaType && filters.mediaType !== 'all') ||
filters.favoritesOnly === true;
return (
<div className="space-y-6 p-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-secondary">Filters</h2>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="h-8"
>
<X className="mr-2 h-4 w-4" />
Clear All
</Button>
)}
</div>
{isLoggedIn && (
<PeopleFilter
people={people}
selected={filters.people}
mode={filters.peopleMode || 'any'}
onSelectionChange={(selected) => updateFilters({ people: selected })}
onModeChange={(mode) => updateFilters({ peopleMode: mode })}
/>
)}
<MediaTypeFilter
value={filters.mediaType || 'all'}
onChange={(value) => updateFilters({ mediaType: value })}
/>
{isLoggedIn && (
<FavoritesFilter
value={filters.favoritesOnly || false}
onChange={(value) => updateFilters({ favoritesOnly: value })}
/>
)}
<DateRangeFilter
dateFrom={filters.dateFrom}
dateTo={filters.dateTo}
onDateChange={(dateFrom, dateTo) => updateFilters({ dateFrom, dateTo })}
/>
<TagFilter
tags={tags}
selected={filters.tags}
mode={filters.tagsMode || 'any'}
onSelectionChange={(selected) => updateFilters({ tags: selected })}
onModeChange={(mode) => updateFilters({ tagsMode: mode })}
/>
</div>
);
}

View File

@ -1,30 +0,0 @@
'use client';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
export type MediaType = 'all' | 'photos' | 'videos';
interface MediaTypeFilterProps {
value: MediaType;
onChange: (value: MediaType) => void;
}
export function MediaTypeFilter({ value, onChange }: MediaTypeFilterProps) {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-secondary">Media type</label>
<Select value={value} onValueChange={(val) => onChange(val as MediaType)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="photos">Photos</SelectItem>
<SelectItem value="videos">Videos</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@ -1,128 +0,0 @@
'use client';
import { useState } from 'react';
import { Person } from '@prisma/client';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Search, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface PeopleFilterProps {
people: Person[];
selected: number[];
mode: 'any' | 'all';
onSelectionChange: (selected: number[]) => void;
onModeChange: (mode: 'any' | 'all') => void;
}
export function PeopleFilter({ people, selected, mode, onSelectionChange, onModeChange }: PeopleFilterProps) {
const [searchQuery, setSearchQuery] = useState('');
const [open, setOpen] = useState(false);
const filteredPeople = people.filter((person) => {
const fullName = `${person.firstName} ${person.lastName}`.toLowerCase();
return fullName.includes(searchQuery.toLowerCase());
});
const togglePerson = (personId: number) => {
if (selected.includes(personId)) {
onSelectionChange(selected.filter((id) => id !== personId));
} else {
onSelectionChange([...selected, personId]);
}
};
const selectedPeople = people.filter((p) => selected.includes(p.id));
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-secondary">People</label>
{selected.length > 1 && (
<Select value={mode} onValueChange={(value) => onModeChange(value as 'any' | 'all')}>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
)}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
<Search className="mr-2 h-4 w-4" />
{selected.length === 0 ? 'Select people...' : `${selected.length} selected`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<div className="p-2">
<Input
placeholder="Search people..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="mb-2"
/>
<div className="max-h-[300px] overflow-y-auto">
{filteredPeople.length === 0 ? (
<p className="p-2 text-sm text-gray-500">No people found</p>
) : (
<div className="space-y-1">
{filteredPeople.map((person) => {
const isSelected = selected.includes(person.id);
return (
<div
key={person.id}
className="flex items-center space-x-2 rounded-md p-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
onClick={() => togglePerson(person.id)}
>
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => togglePerson(person.id)}
/>
</span>
<label className="flex-1 cursor-pointer text-sm">
{person.firstName} {person.lastName}
</label>
</div>
);
})}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
{selectedPeople.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedPeople.map((person) => (
<Badge
key={person.id}
variant="secondary"
className="flex items-center gap-1"
>
{person.firstName} {person.lastName}
<button
onClick={() => togglePerson(person.id)}
className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More