Compare commits
2 Commits
713584dc04
...
75a4dc7a4f
| Author | SHA1 | Date | |
|---|---|---|---|
| 75a4dc7a4f | |||
| de2144be2a |
310
.gitea/workflows/ci.yml
Normal file
310
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,310 @@
|
||||
---
|
||||
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
|
||||
|
||||
@ -12,7 +12,7 @@ module.exports = {
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json'],
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
|
||||
extends: [
|
||||
@ -30,21 +30,37 @@ module.exports = {
|
||||
'max-len': [
|
||||
'error',
|
||||
{
|
||||
code: 100,
|
||||
code: 120,
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -159,10 +159,7 @@ export default function ApproveIdentified() {
|
||||
}
|
||||
}, [dateFrom, dateTo])
|
||||
|
||||
const handleOpenReport = () => {
|
||||
setShowReport(true)
|
||||
loadReport()
|
||||
}
|
||||
// Removed unused handleOpenReport function
|
||||
|
||||
const handleCloseReport = () => {
|
||||
setShowReport(false)
|
||||
|
||||
@ -180,7 +180,6 @@ 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)
|
||||
|
||||
@ -4,7 +4,7 @@ import { photosApi, PhotoSearchResult } from '../api/photos'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { username } = useAuth()
|
||||
const { username: _username } = useAuth()
|
||||
const [samplePhotos, setSamplePhotos] = useState<PhotoSearchResult[]>([])
|
||||
const [loadingPhotos, setLoadingPhotos] = useState(true)
|
||||
|
||||
|
||||
@ -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,6 +951,7 @@ 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 (
|
||||
@ -1290,7 +1291,6 @@ 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'
|
||||
|
||||
@ -305,7 +305,7 @@ export default function Modify() {
|
||||
} finally {
|
||||
setStateRestored(true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -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'
|
||||
import { videosApi } from '../api/videos'
|
||||
// Removed unused videosApi import
|
||||
|
||||
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) {
|
||||
|
||||
@ -4,15 +4,7 @@ import { useDeveloperMode } from '../context/DeveloperModeContext'
|
||||
|
||||
type ViewMode = 'list' | 'icons' | 'compact'
|
||||
|
||||
interface PendingTagChange {
|
||||
photoId: number
|
||||
tagIds: number[]
|
||||
}
|
||||
|
||||
interface PendingTagRemoval {
|
||||
photoId: number
|
||||
tagIds: number[]
|
||||
}
|
||||
// Removed unused interfaces PendingTagChange and PendingTagRemoval
|
||||
|
||||
interface FolderGroup {
|
||||
folderPath: string
|
||||
@ -41,7 +33,7 @@ const loadFolderStatesFromStorage = (): Record<string, boolean> => {
|
||||
}
|
||||
|
||||
export default function Tags() {
|
||||
const { isDeveloperMode } = useDeveloperMode()
|
||||
const { isDeveloperMode: _isDeveloperMode } = useDeveloperMode()
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list')
|
||||
const [photos, setPhotos] = useState<PhotoWithTagsItem[]>([])
|
||||
const [tags, setTags] = useState<TagResponse[]>([])
|
||||
@ -50,7 +42,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)
|
||||
@ -189,7 +181,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)
|
||||
@ -206,13 +198,15 @@ 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
|
||||
}
|
||||
@ -421,7 +415,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),
|
||||
@ -490,7 +484,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
|
||||
@ -1565,7 +1559,7 @@ function BulkTagDialog({
|
||||
onRemoveTag,
|
||||
getPhotoTags,
|
||||
}: {
|
||||
folderPath: string
|
||||
folderPath: string // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
folder: FolderGroup | undefined
|
||||
tags: TagResponse[]
|
||||
pendingTagChanges: Record<number, number[]>
|
||||
|
||||
128
docs/CI_SCRIPTS_MAPPING.md
Normal file
128
docs/CI_SCRIPTS_MAPPING.md
Normal file
@ -0,0 +1,128 @@
|
||||
# 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`
|
||||
|
||||
@ -14,6 +14,12 @@
|
||||
"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/'"
|
||||
},
|
||||
|
||||
67
scripts/README.md
Normal file
67
scripts/README.md
Normal file
@ -0,0 +1,67 @@
|
||||
# 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.
|
||||
|
||||
@ -14,3 +14,4 @@ else
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
15
viewer-frontend/.cursorignore
Normal file
15
viewer-frontend/.cursorignore
Normal file
@ -0,0 +1,15 @@
|
||||
# Ignore history files and directories
|
||||
.history/
|
||||
*.history
|
||||
*_YYYYMMDDHHMMSS.*
|
||||
*_timestamp.*
|
||||
|
||||
# Ignore backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*~
|
||||
|
||||
# Ignore temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
31
viewer-frontend/.cursorrules
Normal file
31
viewer-frontend/.cursorrules
Normal file
@ -0,0 +1,31 @@
|
||||
# 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
|
||||
|
||||
19
viewer-frontend/.env.example
Normal file
19
viewer-frontend/.env.example
Normal file
@ -0,0 +1,19 @@
|
||||
# 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"
|
||||
48
viewer-frontend/.gitignore
vendored
Normal file
48
viewer-frontend/.gitignore
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
# 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
|
||||
1
viewer-frontend/.npmrc
Normal file
1
viewer-frontend/.npmrc
Normal file
@ -0,0 +1 @@
|
||||
# Ensure npm doesn't treat this as a workspace
|
||||
156
viewer-frontend/EMAIL_VERIFICATION_SETUP.md
Normal file
156
viewer-frontend/EMAIL_VERIFICATION_SETUP.md
Normal file
@ -0,0 +1,156 @@
|
||||
# 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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
191
viewer-frontend/FACE_TOOLTIP_ANALYSIS.md
Normal file
191
viewer-frontend/FACE_TOOLTIP_ANALYSIS.md
Normal file
@ -0,0 +1,191 @@
|
||||
# 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
|
||||
|
||||
114
viewer-frontend/GRANT_PERMISSIONS.md
Normal file
114
viewer-frontend/GRANT_PERMISSIONS.md
Normal file
@ -0,0 +1,114 @@
|
||||
# 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`)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
485
viewer-frontend/README.md
Normal file
485
viewer-frontend/README.md
Normal file
@ -0,0 +1,485 @@
|
||||
# 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
|
||||
264
viewer-frontend/SETUP.md
Normal file
264
viewer-frontend/SETUP.md
Normal file
@ -0,0 +1,264 @@
|
||||
# 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!** 🚀
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
131
viewer-frontend/SETUP_AUTH.md
Normal file
131
viewer-frontend/SETUP_AUTH.md
Normal file
@ -0,0 +1,131 @@
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
180
viewer-frontend/SETUP_AUTH_DATABASE.md
Normal file
180
viewer-frontend/SETUP_AUTH_DATABASE.md
Normal file
@ -0,0 +1,180 @@
|
||||
# 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
|
||||
|
||||
86
viewer-frontend/SETUP_INSTRUCTIONS.md
Normal file
86
viewer-frontend/SETUP_INSTRUCTIONS.md
Normal file
@ -0,0 +1,86 @@
|
||||
# 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.
|
||||
|
||||
|
||||
73
viewer-frontend/STOP_OLD_SERVER.md
Normal file
73
viewer-frontend/STOP_OLD_SERVER.md
Normal file
@ -0,0 +1,73 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1100
viewer-frontend/app/HomePageContent.tsx
Normal file
1100
viewer-frontend/app/HomePageContent.tsx
Normal file
File diff suppressed because it is too large
Load Diff
666
viewer-frontend/app/admin/users/ManageUsersContent.tsx
Normal file
666
viewer-frontend/app/admin/users/ManageUsersContent.tsx
Normal file
@ -0,0 +1,666 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
84
viewer-frontend/app/admin/users/ManageUsersPageClient.tsx
Normal file
84
viewer-frontend/app/admin/users/ManageUsersPageClient.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
'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);
|
||||
}
|
||||
|
||||
20
viewer-frontend/app/admin/users/page.tsx
Normal file
20
viewer-frontend/app/admin/users/page.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
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 />;
|
||||
}
|
||||
|
||||
155
viewer-frontend/app/api/auth/[...nextauth]/route.ts
Normal file
155
viewer-frontend/app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,155 @@
|
||||
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;
|
||||
|
||||
72
viewer-frontend/app/api/auth/check-verification/route.ts
Normal file
72
viewer-frontend/app/api/auth/check-verification/route.ts
Normal file
@ -0,0 +1,72 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
103
viewer-frontend/app/api/auth/forgot-password/route.ts
Normal file
103
viewer-frontend/app/api/auth/forgot-password/route.ts
Normal file
@ -0,0 +1,103 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
105
viewer-frontend/app/api/auth/register/route.ts
Normal file
105
viewer-frontend/app/api/auth/register/route.ts
Normal file
@ -0,0 +1,105 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
81
viewer-frontend/app/api/auth/resend-confirmation/route.ts
Normal file
81
viewer-frontend/app/api/auth/resend-confirmation/route.ts
Normal file
@ -0,0 +1,81 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
82
viewer-frontend/app/api/auth/reset-password/route.ts
Normal file
82
viewer-frontend/app/api/auth/reset-password/route.ts
Normal file
@ -0,0 +1,82 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
67
viewer-frontend/app/api/auth/verify-email/route.ts
Normal file
67
viewer-frontend/app/api/auth/verify-email/route.ts
Normal file
@ -0,0 +1,67 @@
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
23
viewer-frontend/app/api/debug-session/route.ts
Normal file
23
viewer-frontend/app/api/debug-session/route.ts
Normal file
@ -0,0 +1,23 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
174
viewer-frontend/app/api/faces/[id]/identify/route.ts
Normal file
174
viewer-frontend/app/api/faces/[id]/identify/route.ts
Normal file
@ -0,0 +1,174 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
87
viewer-frontend/app/api/health/route.ts
Normal file
87
viewer-frontend/app/api/health/route.ts
Normal file
@ -0,0 +1,87 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
81
viewer-frontend/app/api/people/route.ts
Normal file
81
viewer-frontend/app/api/people/route.ts
Normal file
@ -0,0 +1,81 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
394
viewer-frontend/app/api/search/route.ts
Normal file
394
viewer-frontend/app/api/search/route.ts
Normal file
@ -0,0 +1,394 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
324
viewer-frontend/app/api/users/[id]/route.ts
Normal file
324
viewer-frontend/app/api/users/[id]/route.ts
Normal file
@ -0,0 +1,324 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
173
viewer-frontend/app/api/users/route.ts
Normal file
173
viewer-frontend/app/api/users/route.ts
Normal file
@ -0,0 +1,173 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BIN
viewer-frontend/app/favicon.ico
Normal file
BIN
viewer-frontend/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
128
viewer-frontend/app/globals.css
Normal file
128
viewer-frontend/app/globals.css
Normal file
@ -0,0 +1,128 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
30
viewer-frontend/app/layout.tsx
Normal file
30
viewer-frontend/app/layout.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
215
viewer-frontend/app/login/page.tsx
Normal file
215
viewer-frontend/app/login/page.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
236
viewer-frontend/app/page.tsx
Normal file
236
viewer-frontend/app/page.tsx
Normal file
@ -0,0 +1,236 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
106
viewer-frontend/app/photo/[id]/page.tsx
Normal file
106
viewer-frontend/app/photo/[id]/page.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
185
viewer-frontend/app/register/page.tsx
Normal file
185
viewer-frontend/app/register/page.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
184
viewer-frontend/app/reset-password/page.tsx
Normal file
184
viewer-frontend/app/reset-password/page.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
208
viewer-frontend/app/search/SearchContent.tsx
Normal file
208
viewer-frontend/app/search/SearchContent.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
114
viewer-frontend/app/search/page.tsx
Normal file
114
viewer-frontend/app/search/page.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
122
viewer-frontend/app/test-images/page.tsx
Normal file
122
viewer-frontend/app/test-images/page.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
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 "Img" 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'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
367
viewer-frontend/app/upload/UploadContent.tsx
Normal file
367
viewer-frontend/app/upload/UploadContent.tsx
Normal file
@ -0,0 +1,367 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
72
viewer-frontend/app/upload/UploadPageClient.tsx
Normal file
72
viewer-frontend/app/upload/UploadPageClient.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
14
viewer-frontend/app/upload/page.tsx
Normal file
14
viewer-frontend/app/upload/page.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
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 />;
|
||||
}
|
||||
|
||||
22
viewer-frontend/components.json
Normal file
22
viewer-frontend/components.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"$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": {}
|
||||
}
|
||||
104
viewer-frontend/components/ActionButtons.tsx
Normal file
104
viewer-frontend/components/ActionButtons.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
154
viewer-frontend/components/ForgotPasswordDialog.tsx
Normal file
154
viewer-frontend/components/ForgotPasswordDialog.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
191
viewer-frontend/components/Header.tsx
Normal file
191
viewer-frontend/components/Header.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
604
viewer-frontend/components/IdentifyFaceDialog.tsx
Normal file
604
viewer-frontend/components/IdentifyFaceDialog.tsx
Normal file
@ -0,0 +1,604 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
23
viewer-frontend/components/IdleLogoutHandler.tsx
Normal file
23
viewer-frontend/components/IdleLogoutHandler.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
'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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
304
viewer-frontend/components/LoginDialog.tsx
Normal file
304
viewer-frontend/components/LoginDialog.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
78
viewer-frontend/components/PageHeader.tsx
Normal file
78
viewer-frontend/components/PageHeader.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
917
viewer-frontend/components/PhotoGrid.tsx
Normal file
917
viewer-frontend/components/PhotoGrid.tsx
Normal file
@ -0,0 +1,917 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
172
viewer-frontend/components/PhotoViewer.tsx
Normal file
172
viewer-frontend/components/PhotoViewer.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
1679
viewer-frontend/components/PhotoViewerClient.tsx
Normal file
1679
viewer-frontend/components/PhotoViewerClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
281
viewer-frontend/components/RegisterDialog.tsx
Normal file
281
viewer-frontend/components/RegisterDialog.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
20
viewer-frontend/components/SessionProviderWrapper.tsx
Normal file
20
viewer-frontend/components/SessionProviderWrapper.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
36
viewer-frontend/components/SimpleHeader.tsx
Normal file
36
viewer-frontend/components/SimpleHeader.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
334
viewer-frontend/components/TagSelectionDialog.tsx
Normal file
334
viewer-frontend/components/TagSelectionDialog.tsx
Normal file
@ -0,0 +1,334 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
169
viewer-frontend/components/UserMenu.tsx
Normal file
169
viewer-frontend/components/UserMenu.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
'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;
|
||||
92
viewer-frontend/components/search/CollapsibleSearch.tsx
Normal file
92
viewer-frontend/components/search/CollapsibleSearch.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
182
viewer-frontend/components/search/DateRangeFilter.tsx
Normal file
182
viewer-frontend/components/search/DateRangeFilter.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
34
viewer-frontend/components/search/FavoritesFilter.tsx
Normal file
34
viewer-frontend/components/search/FavoritesFilter.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
115
viewer-frontend/components/search/FilterPanel.tsx
Normal file
115
viewer-frontend/components/search/FilterPanel.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
30
viewer-frontend/components/search/MediaTypeFilter.tsx
Normal file
30
viewer-frontend/components/search/MediaTypeFilter.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
128
viewer-frontend/components/search/PeopleFilter.tsx
Normal file
128
viewer-frontend/components/search/PeopleFilter.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
'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
Loading…
x
Reference in New Issue
Block a user