feat: Add new scripts and update project structure for database management and user authentication
This commit introduces several new scripts for managing database operations, including user creation, permission grants, and data migrations. It also adds new documentation files to guide users through the setup and configuration processes. Additionally, the project structure is updated to enhance organization and maintainability, ensuring a smoother development experience for contributors. These changes support the ongoing transition to a web-based architecture and improve overall project functionality.
This commit is contained in:
parent
713584dc04
commit
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
|
||||
|
||||
@ -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.
|
||||
|
||||
78
scripts/db/drop_all_tables.py
Executable file
78
scripts/db/drop_all_tables.py
Executable file
@ -0,0 +1,78 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
def drop_all_tables(db_path: str) -> None:
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database not found: {db_path}")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.isolation_level = None # autocommit mode for DDL
|
||||
cur = conn.cursor()
|
||||
|
||||
# Disable foreign key enforcement to allow dropping in any order
|
||||
cur.execute("PRAGMA foreign_keys = OFF;")
|
||||
|
||||
# Collect tables and views
|
||||
cur.execute("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%';")
|
||||
objects = cur.fetchall()
|
||||
print(f"DB: {db_path}")
|
||||
if not objects:
|
||||
print("No user tables or views found.")
|
||||
return
|
||||
|
||||
# Drop views first, then tables
|
||||
views = [name for name, t in objects if t == 'view']
|
||||
tables = [name for name, t in objects if t == 'table']
|
||||
|
||||
print(f"Found {len(tables)} tables and {len(views)} views.")
|
||||
for v in views:
|
||||
print(f"Dropping view: {v}")
|
||||
cur.execute(f"DROP VIEW IF EXISTS \"{v}\";")
|
||||
|
||||
for t in tables:
|
||||
print(f"Dropping table: {t}")
|
||||
cur.execute(f"DROP TABLE IF EXISTS \"{t}\";")
|
||||
|
||||
# Vacuum to clean up
|
||||
cur.execute("VACUUM;")
|
||||
print("Done.")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_tables(db_path: str) -> None:
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database not found: {db_path}")
|
||||
return
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY type, name;")
|
||||
objects = cur.fetchall()
|
||||
print(f"DB: {db_path}")
|
||||
if not objects:
|
||||
print("No user tables or views found.")
|
||||
return
|
||||
for name, t in objects:
|
||||
print(f"- {t}: {name}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Usage: python drop_all_tables.py <db1> [<db2> ...]
|
||||
paths = sys.argv[1:]
|
||||
if not paths:
|
||||
base = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
paths = [os.path.join(base, 'photos.db'), os.path.join(base, 'data', 'photos.db')]
|
||||
|
||||
for p in paths:
|
||||
list_tables(p)
|
||||
for p in paths:
|
||||
drop_all_tables(p)
|
||||
for p in paths:
|
||||
list_tables(p)
|
||||
59
scripts/db/drop_all_tables_web.py
Normal file
59
scripts/db/drop_all_tables_web.py
Normal file
@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drop all tables from the web database to start fresh."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import inspect
|
||||
from backend.db.session import engine, get_database_url
|
||||
from backend.db.models import Base
|
||||
|
||||
# Ordered list ensures foreign-key dependents drop first
|
||||
TARGET_TABLES = [
|
||||
"photo_favorites",
|
||||
"phototaglinkage",
|
||||
"person_encodings",
|
||||
"faces",
|
||||
"tags",
|
||||
"photos",
|
||||
"people",
|
||||
]
|
||||
|
||||
|
||||
def drop_all_tables():
|
||||
"""Drop all tables from the database."""
|
||||
db_url = get_database_url()
|
||||
print(f"Connecting to database: {db_url}")
|
||||
|
||||
inspector = inspect(engine)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
print("\nDropping selected tables...")
|
||||
for table_name in TARGET_TABLES:
|
||||
if table_name not in Base.metadata.tables:
|
||||
print(f" ⚠️ Table '{table_name}' not found in metadata, skipping.")
|
||||
continue
|
||||
if table_name not in existing_tables:
|
||||
print(f" ℹ️ Table '{table_name}' does not exist in database, skipping.")
|
||||
continue
|
||||
table = Base.metadata.tables[table_name]
|
||||
print(f" 🗑️ Dropping '{table_name}'...")
|
||||
table.drop(bind=engine, checkfirst=True)
|
||||
|
||||
print("✅ Selected tables dropped successfully!")
|
||||
print("\nYou can now recreate tables using:")
|
||||
print(" python scripts/recreate_tables_web.py")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
drop_all_tables()
|
||||
except Exception as e:
|
||||
print(f"❌ Error dropping tables: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
115
scripts/db/grant_auth_db_permissions.py
Executable file
115
scripts/db/grant_auth_db_permissions.py
Executable file
@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Grant DELETE permission on auth database users table.
|
||||
|
||||
This script grants DELETE permission to the database user specified in DATABASE_URL_AUTH.
|
||||
It requires superuser access (postgres user) to grant permissions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
# Load environment variables
|
||||
env_path = Path(__file__).parent.parent.parent / ".env"
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
|
||||
def parse_database_url(db_url: str) -> dict:
|
||||
"""Parse database URL into components."""
|
||||
# Handle postgresql+psycopg2:// format
|
||||
if db_url.startswith("postgresql+psycopg2://"):
|
||||
db_url = db_url.replace("postgresql+psycopg2://", "postgresql://")
|
||||
|
||||
parsed = urlparse(db_url)
|
||||
return {
|
||||
"user": parsed.username,
|
||||
"password": parsed.password,
|
||||
"host": parsed.hostname or "localhost",
|
||||
"port": parsed.port or 5432,
|
||||
"database": parsed.path.lstrip("/"),
|
||||
}
|
||||
|
||||
|
||||
def grant_delete_permission() -> None:
|
||||
"""Grant DELETE permission on users and pending_photos tables in auth database."""
|
||||
auth_db_url = os.getenv("DATABASE_URL_AUTH")
|
||||
if not auth_db_url:
|
||||
print("❌ Error: DATABASE_URL_AUTH environment variable not set")
|
||||
sys.exit(1)
|
||||
|
||||
if not auth_db_url.startswith("postgresql"):
|
||||
print("ℹ️ Auth database is not PostgreSQL. No permissions to grant.")
|
||||
return
|
||||
|
||||
db_info = parse_database_url(auth_db_url)
|
||||
db_user = db_info["user"]
|
||||
db_name = db_info["database"]
|
||||
|
||||
print(f"📋 Granting DELETE permission on auth database tables...")
|
||||
print(f" Database: {db_name}")
|
||||
print(f" User: {db_user}")
|
||||
|
||||
# Tables that need DELETE permission
|
||||
tables = ["users", "pending_photos", "pending_identifications", "inappropriate_photo_reports"]
|
||||
|
||||
# Connect as postgres superuser to grant permissions
|
||||
# Try to connect as postgres user (superuser)
|
||||
try:
|
||||
# Try to get postgres password from environment or use peer authentication
|
||||
postgres_url = f"postgresql://postgres@{db_info['host']}:{db_info['port']}/{db_name}"
|
||||
engine = create_engine(postgres_url)
|
||||
|
||||
with engine.connect() as conn:
|
||||
for table in tables:
|
||||
try:
|
||||
# Grant DELETE permission
|
||||
conn.execute(text(f"""
|
||||
GRANT DELETE ON TABLE {table} TO {db_user}
|
||||
"""))
|
||||
print(f" ✅ Granted DELETE on {table}")
|
||||
except Exception as e:
|
||||
# Table might not exist, skip it
|
||||
print(f" ⚠️ Could not grant DELETE on {table}: {e}")
|
||||
conn.commit()
|
||||
|
||||
print(f"✅ Successfully granted DELETE permissions to user '{db_user}'")
|
||||
return
|
||||
except Exception as e:
|
||||
# If connecting as postgres fails, try with the same user (might have grant privileges)
|
||||
print(f"⚠️ Could not connect as postgres user: {e}")
|
||||
print(f" Trying with current database user...")
|
||||
|
||||
try:
|
||||
engine = create_engine(auth_db_url)
|
||||
with engine.connect() as conn:
|
||||
for table in tables:
|
||||
try:
|
||||
# Try to grant permission
|
||||
conn.execute(text(f"""
|
||||
GRANT DELETE ON TABLE {table} TO {db_user}
|
||||
"""))
|
||||
print(f" ✅ Granted DELETE on {table}")
|
||||
except Exception as e2:
|
||||
print(f" ⚠️ Could not grant DELETE on {table}: {e2}")
|
||||
conn.commit()
|
||||
|
||||
print(f"✅ Successfully granted DELETE permissions to user '{db_user}'")
|
||||
return
|
||||
except Exception as e2:
|
||||
print(f"❌ Failed to grant permission: {e2}")
|
||||
print(f"\n💡 To grant permission manually, run as postgres superuser:")
|
||||
for table in tables:
|
||||
print(f" sudo -u postgres psql -d {db_name} -c \"GRANT DELETE ON TABLE {table} TO {db_user};\"")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
grant_delete_permission()
|
||||
|
||||
|
||||
264
scripts/db/migrate_sqlite_to_postgresql.py
Normal file
264
scripts/db/migrate_sqlite_to_postgresql.py
Normal file
@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migrate data from SQLite to PostgreSQL database.
|
||||
|
||||
This script:
|
||||
1. Creates PostgreSQL databases if they don't exist
|
||||
2. Creates all tables in PostgreSQL
|
||||
3. Migrates all data from SQLite to PostgreSQL
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from backend.db.models import Base
|
||||
from backend.db.session import get_database_url
|
||||
import sqlite3
|
||||
|
||||
def create_postgresql_databases():
|
||||
"""Create PostgreSQL databases if they don't exist."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Get database URLs from environment
|
||||
db_url = os.getenv("DATABASE_URL", "postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag")
|
||||
auth_db_url = os.getenv("DATABASE_URL_AUTH", "postgresql://punimtag:punimtag_password@localhost:5432/punimtag_auth")
|
||||
|
||||
# Parse URLs
|
||||
main_parsed = urlparse(db_url.replace("postgresql+psycopg2://", "postgresql://"))
|
||||
auth_parsed = urlparse(auth_db_url.replace("postgresql+psycopg2://", "postgresql://"))
|
||||
|
||||
main_db_name = main_parsed.path.lstrip("/")
|
||||
auth_db_name = auth_parsed.path.lstrip("/")
|
||||
|
||||
# Connect to postgres database to create other databases
|
||||
postgres_url = f"postgresql://{main_parsed.username}:{main_parsed.password}@{main_parsed.hostname}:{main_parsed.port or 5432}/postgres"
|
||||
|
||||
try:
|
||||
engine = create_engine(postgres_url)
|
||||
with engine.connect() as conn:
|
||||
# Check if databases exist
|
||||
result = conn.execute(text("SELECT 1 FROM pg_database WHERE datname = :name"), {"name": main_db_name})
|
||||
if not result.fetchone():
|
||||
conn.execute(text("COMMIT")) # End any transaction
|
||||
conn.execute(text(f'CREATE DATABASE "{main_db_name}"'))
|
||||
print(f"✅ Created database: {main_db_name}")
|
||||
else:
|
||||
print(f"✅ Database already exists: {main_db_name}")
|
||||
|
||||
result = conn.execute(text("SELECT 1 FROM pg_database WHERE datname = :name"), {"name": auth_db_name})
|
||||
if not result.fetchone():
|
||||
conn.execute(text("COMMIT"))
|
||||
conn.execute(text(f'CREATE DATABASE "{auth_db_name}"'))
|
||||
print(f"✅ Created database: {auth_db_name}")
|
||||
else:
|
||||
print(f"✅ Database already exists: {auth_db_name}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error creating databases: {e}")
|
||||
print(" Make sure PostgreSQL is running and credentials are correct")
|
||||
|
||||
def migrate_data():
|
||||
"""Migrate data from SQLite to PostgreSQL."""
|
||||
print("=" * 80)
|
||||
print("MIGRATING DATA FROM SQLITE TO POSTGRESQL")
|
||||
print("=" * 80)
|
||||
|
||||
# Get database URLs
|
||||
sqlite_url = "sqlite:///data/punimtag.db"
|
||||
postgres_url = os.getenv("DATABASE_URL", "postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag")
|
||||
|
||||
if not postgres_url.startswith("postgresql"):
|
||||
print("❌ DATABASE_URL is not set to PostgreSQL")
|
||||
print(" Set DATABASE_URL in .env file to PostgreSQL connection string")
|
||||
return False
|
||||
|
||||
# Connect to both databases
|
||||
sqlite_engine = create_engine(sqlite_url)
|
||||
postgres_engine = create_engine(postgres_url)
|
||||
|
||||
# Create tables in PostgreSQL
|
||||
print("\n📋 Creating tables in PostgreSQL...")
|
||||
Base.metadata.create_all(bind=postgres_engine)
|
||||
print("✅ Tables created")
|
||||
|
||||
# Get table names
|
||||
inspector = inspect(sqlite_engine)
|
||||
all_tables = inspector.get_table_names()
|
||||
|
||||
# Exclude system tables
|
||||
all_tables = [t for t in all_tables if not t.startswith("sqlite_")]
|
||||
|
||||
# Define migration order (respecting foreign key constraints)
|
||||
# Tables with no dependencies first, then dependent tables
|
||||
migration_order = [
|
||||
"alembic_version", # Migration tracking (optional)
|
||||
"photos", # Base table
|
||||
"people", # Base table
|
||||
"tags", # Base table
|
||||
"users", # Base table
|
||||
"faces", # Depends on photos, people, users
|
||||
"person_encodings", # Depends on people, faces
|
||||
"phototaglinkage", # Depends on photos, tags
|
||||
"photo_favorites", # Depends on photos
|
||||
"photo_person_linkage", # Depends on photos, people, users
|
||||
"role_permissions", # Base table
|
||||
]
|
||||
|
||||
# Filter to only tables that exist
|
||||
tables = [t for t in migration_order if t in all_tables]
|
||||
# Add any remaining tables not in the order list
|
||||
for t in all_tables:
|
||||
if t not in tables:
|
||||
tables.append(t)
|
||||
|
||||
print(f"\n📊 Found {len(tables)} tables to migrate: {', '.join(tables)}")
|
||||
|
||||
# Boolean columns mapping (SQLite stores as integer, PostgreSQL needs boolean)
|
||||
boolean_columns = {
|
||||
"photos": ["processed"],
|
||||
"faces": ["is_primary_encoding", "excluded"],
|
||||
"users": ["is_active", "is_admin", "password_change_required"],
|
||||
"role_permissions": ["allowed"],
|
||||
}
|
||||
|
||||
# Columns that might be missing in SQLite but required in PostgreSQL
|
||||
# Map: table_name -> {column: default_value}
|
||||
default_values = {
|
||||
"photos": {"file_hash": "migrated"}, # file_hash might be missing in old SQLite
|
||||
}
|
||||
|
||||
# Migrate each table
|
||||
with sqlite_engine.connect() as sqlite_conn, postgres_engine.connect() as postgres_conn:
|
||||
for table in tables:
|
||||
print(f"\n🔄 Migrating table: {table}")
|
||||
|
||||
# Get row count
|
||||
count_result = sqlite_conn.execute(text(f"SELECT COUNT(*) FROM {table}"))
|
||||
row_count = count_result.scalar()
|
||||
|
||||
if row_count == 0:
|
||||
print(f" ⏭️ Table is empty, skipping")
|
||||
continue
|
||||
|
||||
print(f" 📦 {row_count} rows to migrate")
|
||||
|
||||
# Check if table already has data in PostgreSQL
|
||||
try:
|
||||
pg_count_result = postgres_conn.execute(text(f'SELECT COUNT(*) FROM "{table}"'))
|
||||
pg_count = pg_count_result.scalar()
|
||||
if pg_count > 0:
|
||||
print(f" ⚠️ Table already has {pg_count} rows in PostgreSQL")
|
||||
# Auto-truncate for non-interactive mode, or ask in interactive
|
||||
print(f" 🗑️ Truncating existing data...")
|
||||
postgres_conn.execute(text(f'TRUNCATE TABLE "{table}" CASCADE'))
|
||||
postgres_conn.commit()
|
||||
except Exception as e:
|
||||
# Table might not exist yet, that's OK
|
||||
pass
|
||||
|
||||
# Get column names and types from SQLite
|
||||
columns_result = sqlite_conn.execute(text(f"PRAGMA table_info({table})"))
|
||||
column_info = columns_result.fetchall()
|
||||
sqlite_columns = [row[1] for row in column_info]
|
||||
|
||||
# Get PostgreSQL column names
|
||||
pg_inspector = inspect(postgres_engine)
|
||||
pg_columns_info = pg_inspector.get_columns(table)
|
||||
pg_columns = [col['name'] for col in pg_columns_info]
|
||||
|
||||
# Use PostgreSQL columns (they're the source of truth)
|
||||
columns = pg_columns
|
||||
|
||||
# Get boolean columns for this table
|
||||
table_bool_cols = boolean_columns.get(table, [])
|
||||
|
||||
# Get default values for missing columns
|
||||
table_defaults = default_values.get(table, {})
|
||||
|
||||
# Build SELECT statement for SQLite (only select columns that exist)
|
||||
select_cols = [col for col in columns if col in sqlite_columns]
|
||||
select_sql = f"SELECT {', '.join(select_cols)} FROM {table}"
|
||||
|
||||
# Fetch all data
|
||||
data_result = sqlite_conn.execute(text(select_sql))
|
||||
rows = data_result.fetchall()
|
||||
|
||||
# Insert into PostgreSQL
|
||||
inserted = 0
|
||||
for row in rows:
|
||||
try:
|
||||
# Build insert statement with boolean conversion
|
||||
values = {}
|
||||
for i, col in enumerate(select_cols):
|
||||
val = row[i]
|
||||
# Convert integer booleans to Python booleans for PostgreSQL
|
||||
if col in table_bool_cols:
|
||||
val = bool(val) if val is not None else None
|
||||
values[col] = val
|
||||
|
||||
# Add default values for missing columns
|
||||
for col, default_val in table_defaults.items():
|
||||
if col not in values and col in columns:
|
||||
values[col] = default_val
|
||||
|
||||
# Only insert columns we have values for (that exist in PostgreSQL)
|
||||
insert_cols = [col for col in columns if col in values]
|
||||
cols_str = ', '.join([f'"{c}"' for c in insert_cols])
|
||||
placeholders = ', '.join([f':{c}' for c in insert_cols])
|
||||
insert_sql = f'INSERT INTO "{table}" ({cols_str}) VALUES ({placeholders})'
|
||||
|
||||
postgres_conn.execute(text(insert_sql), values)
|
||||
inserted += 1
|
||||
|
||||
if inserted % 100 == 0:
|
||||
postgres_conn.commit()
|
||||
print(f" ✅ Inserted {inserted}/{row_count} rows...", end='\r')
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n ❌ Error inserting row: {e}")
|
||||
print(f" Row data: {dict(zip(columns, row))}")
|
||||
postgres_conn.rollback()
|
||||
break
|
||||
|
||||
postgres_conn.commit()
|
||||
print(f" ✅ Migrated {inserted}/{row_count} rows from {table}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("✅ MIGRATION COMPLETE")
|
||||
print("=" * 80)
|
||||
print("\nNext steps:")
|
||||
print("1. Update .env file to use PostgreSQL:")
|
||||
print(" DATABASE_URL=postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag")
|
||||
print("2. Restart the backend API")
|
||||
print("3. Restart the viewer frontend")
|
||||
print("4. Verify data in viewer frontend")
|
||||
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🔧 SQLite to PostgreSQL Migration Tool\n")
|
||||
|
||||
# Check if SQLite database exists
|
||||
sqlite_path = project_root / "data" / "punimtag.db"
|
||||
if not sqlite_path.exists():
|
||||
print(f"❌ SQLite database not found: {sqlite_path}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"✅ Found SQLite database: {sqlite_path}")
|
||||
|
||||
# Create PostgreSQL databases
|
||||
print("\n📦 Creating PostgreSQL databases...")
|
||||
create_postgresql_databases()
|
||||
|
||||
# Migrate data
|
||||
print("\n")
|
||||
migrate_data()
|
||||
|
||||
35
scripts/db/recreate_tables_web.py
Normal file
35
scripts/db/recreate_tables_web.py
Normal file
@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recreate all tables from models (fresh start)."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from backend.db.models import Base
|
||||
from backend.db.session import engine, get_database_url
|
||||
|
||||
|
||||
def recreate_tables():
|
||||
"""Recreate all tables from models."""
|
||||
db_url = get_database_url()
|
||||
print(f"Connecting to database: {db_url}")
|
||||
|
||||
# Create all tables from models
|
||||
print("\nCreating all tables from models...")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
print("✅ All tables created successfully!")
|
||||
print("✅ Database is now fresh and ready to use!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
recreate_tables()
|
||||
except Exception as e:
|
||||
print(f"❌ Error recreating tables: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
129
scripts/db/show_db_tables.py
Normal file
129
scripts/db/show_db_tables.py
Normal file
@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Show all tables and their structures in the database."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from backend.db.session import engine, get_database_url
|
||||
from backend.db.models import Base
|
||||
|
||||
|
||||
def show_table_structure(table_name: str, inspector):
|
||||
"""Show the structure of a table."""
|
||||
print(f"\n{'='*80}")
|
||||
print(f"Table: {table_name}")
|
||||
print(f"{'='*80}")
|
||||
|
||||
# Get columns
|
||||
columns = inspector.get_columns(table_name)
|
||||
print("\nColumns:")
|
||||
print(f"{'Name':<30} {'Type':<25} {'Nullable':<10} {'Primary Key':<12} {'Default'}")
|
||||
print("-" * 100)
|
||||
|
||||
for col in columns:
|
||||
col_type = str(col['type'])
|
||||
nullable = "Yes" if col['nullable'] else "No"
|
||||
primary_key = "Yes" if col.get('primary_key', False) else "No"
|
||||
default = str(col.get('default', ''))[:30] if col.get('default') else ''
|
||||
print(f"{col['name']:<30} {col_type:<25} {nullable:<10} {primary_key:<12} {default}")
|
||||
|
||||
# Get indexes
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
if indexes:
|
||||
print("\nIndexes:")
|
||||
for idx in indexes:
|
||||
unique = "UNIQUE" if idx.get('unique', False) else ""
|
||||
columns_str = ", ".join(idx['column_names'])
|
||||
print(f" {idx['name']}: {columns_str} {unique}")
|
||||
|
||||
# Get foreign keys
|
||||
foreign_keys = inspector.get_foreign_keys(table_name)
|
||||
if foreign_keys:
|
||||
print("\nForeign Keys:")
|
||||
for fk in foreign_keys:
|
||||
constrained_cols = ", ".join(fk['constrained_columns'])
|
||||
referred_table = fk['referred_table']
|
||||
referred_cols = ", ".join(fk['referred_columns'])
|
||||
print(f" {constrained_cols} -> {referred_table}({referred_cols})")
|
||||
|
||||
|
||||
def show_all_tables():
|
||||
"""Show all tables and their structures."""
|
||||
db_url = get_database_url()
|
||||
print(f"Database: {db_url}")
|
||||
print(f"\n{'='*80}")
|
||||
|
||||
# Create inspector
|
||||
inspector = inspect(engine)
|
||||
|
||||
# Get all table names
|
||||
table_names = inspector.get_table_names()
|
||||
|
||||
if not table_names:
|
||||
print("No tables found in database.")
|
||||
print("\nTables should be created on web app startup.")
|
||||
print("\nHere are the table structures from models:")
|
||||
|
||||
# Show from models instead
|
||||
from backend.db.models import Photo, Person, Face, PersonEmbedding, Tag, PhotoTag
|
||||
|
||||
models = [
|
||||
("photos", Photo),
|
||||
("people", Person),
|
||||
("faces", Face),
|
||||
("person_embeddings", PersonEmbedding),
|
||||
("tags", Tag),
|
||||
("photo_tags", PhotoTag),
|
||||
]
|
||||
|
||||
for table_name, model in models:
|
||||
print(f"\n{'='*80}")
|
||||
print(f"Table: {table_name}")
|
||||
print(f"{'='*80}")
|
||||
print("\nColumns:")
|
||||
for col in model.__table__.columns:
|
||||
nullable = "Yes" if col.nullable else "No"
|
||||
primary_key = "Yes" if col.primary_key else "No"
|
||||
default = str(col.default) if col.default else ''
|
||||
print(f" {col.name:<30} {col.type!s:<25} Nullable: {nullable:<10} PK: {primary_key:<12} Default: {default}")
|
||||
|
||||
# Show indexes
|
||||
indexes = model.__table__.indexes
|
||||
if indexes:
|
||||
print("\nIndexes:")
|
||||
for idx in indexes:
|
||||
unique = "UNIQUE" if idx.unique else ""
|
||||
cols = ", ".join([c.name for c in idx.columns])
|
||||
print(f" {idx.name}: {cols} {unique}")
|
||||
|
||||
# Show foreign keys
|
||||
fks = [fk for fk in model.__table__.foreign_keys]
|
||||
if fks:
|
||||
print("\nForeign Keys:")
|
||||
for fk in fks:
|
||||
print(f" {fk.parent.name} -> {fk.column.table.name}({fk.column.name})")
|
||||
|
||||
return
|
||||
|
||||
print(f"\nFound {len(table_names)} table(s):")
|
||||
for table_name in sorted(table_names):
|
||||
print(f" - {table_name}")
|
||||
|
||||
# Show structure for each table
|
||||
for table_name in sorted(table_names):
|
||||
show_table_structure(table_name, inspector)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
show_all_tables()
|
||||
except Exception as e:
|
||||
print(f"❌ Error showing tables: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
83
scripts/debug/analyze_all_faces.py
Normal file
83
scripts/debug/analyze_all_faces.py
Normal file
@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyze all faces to see why most don't have angle data
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
db_path = "data/punimtag.db"
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"❌ Database not found: {db_path}")
|
||||
exit(1)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get total faces
|
||||
cursor.execute("SELECT COUNT(*) FROM faces")
|
||||
total_faces = cursor.fetchone()[0]
|
||||
|
||||
# Get faces with angle data
|
||||
cursor.execute("SELECT COUNT(*) FROM faces WHERE yaw_angle IS NOT NULL OR pitch_angle IS NOT NULL OR roll_angle IS NOT NULL")
|
||||
faces_with_angles = cursor.fetchone()[0]
|
||||
|
||||
# Get faces without any angle data
|
||||
faces_without_angles = total_faces - faces_with_angles
|
||||
|
||||
print("=" * 80)
|
||||
print("FACE ANGLE DATA ANALYSIS")
|
||||
print("=" * 80)
|
||||
print(f"\nTotal faces: {total_faces}")
|
||||
print(f"Faces WITH angle data: {faces_with_angles}")
|
||||
print(f"Faces WITHOUT angle data: {faces_without_angles}")
|
||||
print(f"Percentage with angle data: {(faces_with_angles/total_faces*100):.1f}%")
|
||||
|
||||
# Check pose_mode distribution
|
||||
print("\n" + "=" * 80)
|
||||
print("POSE_MODE DISTRIBUTION")
|
||||
print("=" * 80)
|
||||
cursor.execute("""
|
||||
SELECT pose_mode, COUNT(*) as count
|
||||
FROM faces
|
||||
GROUP BY pose_mode
|
||||
ORDER BY count DESC
|
||||
""")
|
||||
|
||||
pose_modes = cursor.fetchall()
|
||||
for row in pose_modes:
|
||||
percentage = (row['count'] / total_faces) * 100
|
||||
print(f" {row['pose_mode']:<30} : {row['count']:>4} ({percentage:>5.1f}%)")
|
||||
|
||||
# Check faces with pose_mode=frontal but might have high yaw
|
||||
print("\n" + "=" * 80)
|
||||
print("FACES WITH POSE_MODE='frontal' BUT NO ANGLE DATA")
|
||||
print("=" * 80)
|
||||
print("(These faces might actually be profile faces but weren't analyzed)")
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM faces
|
||||
WHERE pose_mode = 'frontal'
|
||||
AND yaw_angle IS NULL
|
||||
AND pitch_angle IS NULL
|
||||
AND roll_angle IS NULL
|
||||
""")
|
||||
frontal_no_data = cursor.fetchone()[0]
|
||||
print(f" Faces with pose_mode='frontal' and no angle data: {frontal_no_data}")
|
||||
|
||||
# Check if pose detection is being run for all faces
|
||||
print("\n" + "=" * 80)
|
||||
print("ANALYSIS")
|
||||
print("=" * 80)
|
||||
print(f"Only {faces_with_angles} out of {total_faces} faces have angle data stored.")
|
||||
print("This suggests that pose detection is NOT being run for all faces.")
|
||||
print("\nPossible reasons:")
|
||||
print(" 1. Pose detection may have been disabled or failed for most faces")
|
||||
print(" 2. Only faces processed recently have pose data")
|
||||
print(" 3. Pose detection might only run when RetinaFace is available")
|
||||
|
||||
conn.close()
|
||||
|
||||
156
scripts/debug/analyze_pose_matching.py
Normal file
156
scripts/debug/analyze_pose_matching.py
Normal file
@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyze why only 6 faces have yaw angle data - investigate the matching process
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import json
|
||||
|
||||
db_path = "data/punimtag.db"
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"❌ Database not found: {db_path}")
|
||||
exit(1)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get total faces
|
||||
cursor.execute("SELECT COUNT(*) FROM faces")
|
||||
total_faces = cursor.fetchone()[0]
|
||||
|
||||
# Get faces with angle data
|
||||
cursor.execute("SELECT COUNT(*) FROM faces WHERE yaw_angle IS NOT NULL")
|
||||
faces_with_yaw = cursor.fetchone()[0]
|
||||
|
||||
# Get faces without angle data
|
||||
cursor.execute("SELECT COUNT(*) FROM faces WHERE yaw_angle IS NULL AND pitch_angle IS NULL AND roll_angle IS NULL")
|
||||
faces_without_angles = cursor.fetchone()[0]
|
||||
|
||||
print("=" * 80)
|
||||
print("POSE DATA COVERAGE ANALYSIS")
|
||||
print("=" * 80)
|
||||
print(f"\nTotal faces: {total_faces}")
|
||||
print(f"Faces WITH yaw angle: {faces_with_yaw}")
|
||||
print(f"Faces WITHOUT any angle data: {faces_without_angles}")
|
||||
print(f"Coverage: {(faces_with_yaw/total_faces*100):.1f}%")
|
||||
|
||||
# Check pose_mode distribution
|
||||
print("\n" + "=" * 80)
|
||||
print("POSE_MODE DISTRIBUTION")
|
||||
print("=" * 80)
|
||||
cursor.execute("""
|
||||
SELECT pose_mode, COUNT(*) as count,
|
||||
SUM(CASE WHEN yaw_angle IS NOT NULL THEN 1 ELSE 0 END) as with_yaw,
|
||||
SUM(CASE WHEN pitch_angle IS NOT NULL THEN 1 ELSE 0 END) as with_pitch,
|
||||
SUM(CASE WHEN roll_angle IS NOT NULL THEN 1 ELSE 0 END) as with_roll
|
||||
FROM faces
|
||||
GROUP BY pose_mode
|
||||
ORDER BY count DESC
|
||||
""")
|
||||
|
||||
pose_modes = cursor.fetchall()
|
||||
for row in pose_modes:
|
||||
print(f"\n{row['pose_mode']}:")
|
||||
print(f" Total: {row['count']}")
|
||||
print(f" With yaw: {row['with_yaw']}")
|
||||
print(f" With pitch: {row['with_pitch']}")
|
||||
print(f" With roll: {row['with_roll']}")
|
||||
|
||||
# Check photos and see if some photos have pose data while others don't
|
||||
print("\n" + "=" * 80)
|
||||
print("POSE DATA BY PHOTO")
|
||||
print("=" * 80)
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
p.id as photo_id,
|
||||
p.filename,
|
||||
COUNT(f.id) as total_faces,
|
||||
SUM(CASE WHEN f.yaw_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_yaw,
|
||||
SUM(CASE WHEN f.pitch_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_pitch,
|
||||
SUM(CASE WHEN f.roll_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_roll
|
||||
FROM photos p
|
||||
LEFT JOIN faces f ON f.photo_id = p.id
|
||||
GROUP BY p.id, p.filename
|
||||
HAVING COUNT(f.id) > 0
|
||||
ORDER BY faces_with_yaw DESC, total_faces DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
|
||||
photos = cursor.fetchall()
|
||||
print(f"\n{'Photo ID':<10} {'Filename':<40} {'Total':<8} {'Yaw':<6} {'Pitch':<7} {'Roll':<6}")
|
||||
print("-" * 80)
|
||||
for row in photos:
|
||||
print(f"{row['photo_id']:<10} {row['filename'][:38]:<40} {row['total_faces']:<8} "
|
||||
f"{row['faces_with_yaw']:<6} {row['faces_with_pitch']:<7} {row['faces_with_roll']:<6}")
|
||||
|
||||
# Check if there's a pattern - maybe older photos don't have pose data
|
||||
print("\n" + "=" * 80)
|
||||
print("ANALYSIS")
|
||||
print("=" * 80)
|
||||
|
||||
# Check date added vs pose data
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
DATE(p.date_added) as date_added,
|
||||
COUNT(f.id) as total_faces,
|
||||
SUM(CASE WHEN f.yaw_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_yaw
|
||||
FROM photos p
|
||||
LEFT JOIN faces f ON f.photo_id = p.id
|
||||
GROUP BY DATE(p.date_added)
|
||||
ORDER BY date_added DESC
|
||||
""")
|
||||
|
||||
dates = cursor.fetchall()
|
||||
print("\nFaces by date added:")
|
||||
print(f"{'Date':<15} {'Total':<8} {'With Yaw':<10} {'Coverage':<10}")
|
||||
print("-" * 50)
|
||||
for row in dates:
|
||||
coverage = (row['faces_with_yaw'] / row['total_faces'] * 100) if row['total_faces'] > 0 else 0
|
||||
print(f"{row['date_added'] or 'NULL':<15} {row['total_faces']:<8} {row['faces_with_yaw']:<10} {coverage:.1f}%")
|
||||
|
||||
# Check if pose detection might be failing for some photos
|
||||
print("\n" + "=" * 80)
|
||||
print("POSSIBLE REASONS FOR LOW COVERAGE")
|
||||
print("=" * 80)
|
||||
print("\n1. Pose detection might not be running for all photos")
|
||||
print("2. Matching between DeepFace and RetinaFace might be failing (IoU threshold too strict?)")
|
||||
print("3. RetinaFace might not be detecting faces in some photos")
|
||||
print("4. Photos might have been processed before pose detection was fully implemented")
|
||||
|
||||
# Check if there are photos with multiple faces where some have pose data and some don't
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
p.id as photo_id,
|
||||
p.filename,
|
||||
COUNT(f.id) as total_faces,
|
||||
SUM(CASE WHEN f.yaw_angle IS NOT NULL THEN 1 ELSE 0 END) as faces_with_yaw,
|
||||
SUM(CASE WHEN f.yaw_angle IS NULL THEN 1 ELSE 0 END) as faces_without_yaw
|
||||
FROM photos p
|
||||
JOIN faces f ON f.photo_id = p.id
|
||||
GROUP BY p.id, p.filename
|
||||
HAVING COUNT(f.id) > 1
|
||||
AND SUM(CASE WHEN f.yaw_angle IS NOT NULL THEN 1 ELSE 0 END) > 0
|
||||
AND SUM(CASE WHEN f.yaw_angle IS NULL THEN 1 ELSE 0 END) > 0
|
||||
ORDER BY total_faces DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
|
||||
mixed_photos = cursor.fetchall()
|
||||
if mixed_photos:
|
||||
print("\n" + "=" * 80)
|
||||
print("PHOTOS WITH MIXED POSE DATA (some faces have it, some don't)")
|
||||
print("=" * 80)
|
||||
print(f"\n{'Photo ID':<10} {'Filename':<40} {'Total':<8} {'With Yaw':<10} {'Without Yaw':<12}")
|
||||
print("-" * 80)
|
||||
for row in mixed_photos:
|
||||
print(f"{row['photo_id']:<10} {row['filename'][:38]:<40} {row['total_faces']:<8} "
|
||||
f"{row['faces_with_yaw']:<10} {row['faces_without_yaw']:<12}")
|
||||
print("\n⚠️ This suggests matching is failing for some faces even when pose detection runs")
|
||||
else:
|
||||
print("\n✅ No photos found with mixed pose data (all or nothing per photo)")
|
||||
|
||||
conn.close()
|
||||
|
||||
192
scripts/debug/analyze_poses.py
Normal file
192
scripts/debug/analyze_poses.py
Normal file
@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyze pose_mode values in the faces table
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import os
|
||||
from collections import Counter
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
# Default database path
|
||||
DEFAULT_DB_PATH = "data/photos.db"
|
||||
|
||||
|
||||
def analyze_poses(db_path: str) -> None:
|
||||
"""Analyze pose_mode values in faces table"""
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"❌ Database not found: {db_path}")
|
||||
return
|
||||
|
||||
print(f"📊 Analyzing poses in database: {db_path}\n")
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get total number of faces
|
||||
cursor.execute("SELECT COUNT(*) FROM faces")
|
||||
total_faces = cursor.fetchone()[0]
|
||||
print(f"Total faces in database: {total_faces}\n")
|
||||
|
||||
if total_faces == 0:
|
||||
print("No faces found in database.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# Get pose_mode distribution
|
||||
cursor.execute("""
|
||||
SELECT pose_mode, COUNT(*) as count
|
||||
FROM faces
|
||||
GROUP BY pose_mode
|
||||
ORDER BY count DESC
|
||||
""")
|
||||
|
||||
pose_modes = cursor.fetchall()
|
||||
|
||||
print("=" * 60)
|
||||
print("POSE_MODE DISTRIBUTION")
|
||||
print("=" * 60)
|
||||
for row in pose_modes:
|
||||
pose_mode = row['pose_mode'] or 'NULL'
|
||||
count = row['count']
|
||||
percentage = (count / total_faces) * 100
|
||||
print(f" {pose_mode:30s} : {count:6d} ({percentage:5.1f}%)")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("ANGLE STATISTICS")
|
||||
print("=" * 60)
|
||||
|
||||
# Yaw angle statistics
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(yaw_angle) as with_yaw,
|
||||
MIN(yaw_angle) as min_yaw,
|
||||
MAX(yaw_angle) as max_yaw,
|
||||
AVG(yaw_angle) as avg_yaw
|
||||
FROM faces
|
||||
WHERE yaw_angle IS NOT NULL
|
||||
""")
|
||||
yaw_stats = cursor.fetchone()
|
||||
|
||||
# Pitch angle statistics
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(pitch_angle) as with_pitch,
|
||||
MIN(pitch_angle) as min_pitch,
|
||||
MAX(pitch_angle) as max_pitch,
|
||||
AVG(pitch_angle) as avg_pitch
|
||||
FROM faces
|
||||
WHERE pitch_angle IS NOT NULL
|
||||
""")
|
||||
pitch_stats = cursor.fetchone()
|
||||
|
||||
# Roll angle statistics
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(roll_angle) as with_roll,
|
||||
MIN(roll_angle) as min_roll,
|
||||
MAX(roll_angle) as max_roll,
|
||||
AVG(roll_angle) as avg_roll
|
||||
FROM faces
|
||||
WHERE roll_angle IS NOT NULL
|
||||
""")
|
||||
roll_stats = cursor.fetchone()
|
||||
|
||||
print(f"\nYaw Angle:")
|
||||
print(f" Faces with yaw data: {yaw_stats['with_yaw']}")
|
||||
if yaw_stats['with_yaw'] > 0:
|
||||
print(f" Min: {yaw_stats['min_yaw']:.1f}°")
|
||||
print(f" Max: {yaw_stats['max_yaw']:.1f}°")
|
||||
print(f" Avg: {yaw_stats['avg_yaw']:.1f}°")
|
||||
|
||||
print(f"\nPitch Angle:")
|
||||
print(f" Faces with pitch data: {pitch_stats['with_pitch']}")
|
||||
if pitch_stats['with_pitch'] > 0:
|
||||
print(f" Min: {pitch_stats['min_pitch']:.1f}°")
|
||||
print(f" Max: {pitch_stats['max_pitch']:.1f}°")
|
||||
print(f" Avg: {pitch_stats['avg_pitch']:.1f}°")
|
||||
|
||||
print(f"\nRoll Angle:")
|
||||
print(f" Faces with roll data: {roll_stats['with_roll']}")
|
||||
if roll_stats['with_roll'] > 0:
|
||||
print(f" Min: {roll_stats['min_roll']:.1f}°")
|
||||
print(f" Max: {roll_stats['max_roll']:.1f}°")
|
||||
print(f" Avg: {roll_stats['avg_roll']:.1f}°")
|
||||
|
||||
# Sample faces with different poses
|
||||
print("\n" + "=" * 60)
|
||||
print("SAMPLE FACES BY POSE")
|
||||
print("=" * 60)
|
||||
|
||||
for row in pose_modes[:10]: # Top 10 pose modes
|
||||
pose_mode = row['pose_mode']
|
||||
cursor.execute("""
|
||||
SELECT id, photo_id, pose_mode, yaw_angle, pitch_angle, roll_angle
|
||||
FROM faces
|
||||
WHERE pose_mode = ?
|
||||
LIMIT 3
|
||||
""", (pose_mode,))
|
||||
samples = cursor.fetchall()
|
||||
|
||||
print(f"\n{pose_mode}:")
|
||||
for sample in samples:
|
||||
yaw_str = f"{sample['yaw_angle']:.1f}°" if sample['yaw_angle'] is not None else "N/A"
|
||||
pitch_str = f"{sample['pitch_angle']:.1f}°" if sample['pitch_angle'] is not None else "N/A"
|
||||
roll_str = f"{sample['roll_angle']:.1f}°" if sample['roll_angle'] is not None else "N/A"
|
||||
print(f" Face ID {sample['id']}: "
|
||||
f"yaw={yaw_str} "
|
||||
f"pitch={pitch_str} "
|
||||
f"roll={roll_str}")
|
||||
|
||||
conn.close()
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"❌ Database error: {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
|
||||
|
||||
def check_web_database() -> None:
|
||||
"""Check if web database exists and analyze it"""
|
||||
# Common web database locations
|
||||
web_db_paths = [
|
||||
"data/punimtag.db", # Default web database
|
||||
"data/web_photos.db",
|
||||
"data/photos_web.db",
|
||||
"web_photos.db",
|
||||
]
|
||||
|
||||
for db_path in web_db_paths:
|
||||
if os.path.exists(db_path):
|
||||
print(f"\n{'='*60}")
|
||||
print(f"WEB DATABASE: {db_path}")
|
||||
print(f"{'='*60}\n")
|
||||
analyze_poses(db_path)
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Check desktop database
|
||||
desktop_db = DEFAULT_DB_PATH
|
||||
if os.path.exists(desktop_db):
|
||||
analyze_poses(desktop_db)
|
||||
|
||||
# Check web database
|
||||
check_web_database()
|
||||
|
||||
# If no database found, list what we tried
|
||||
if not os.path.exists(desktop_db):
|
||||
print(f"❌ Desktop database not found: {desktop_db}")
|
||||
print("\nTrying to find database files...")
|
||||
for root, dirs, files in os.walk("data"):
|
||||
for file in files:
|
||||
if file.endswith(('.db', '.sqlite', '.sqlite3')):
|
||||
print(f" Found: {os.path.join(root, file)}")
|
||||
|
||||
135
scripts/debug/check_database_tables.py
Normal file
135
scripts/debug/check_database_tables.py
Normal file
@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check what tables exist in the punimtag main database and their record counts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from backend.db.session import get_database_url
|
||||
|
||||
|
||||
def check_database_tables() -> None:
|
||||
"""Check all tables in the database and their record counts."""
|
||||
database_url = get_database_url()
|
||||
|
||||
print("=" * 80)
|
||||
print("PUNIMTAG MAIN DATABASE - TABLE INFORMATION")
|
||||
print("=" * 80)
|
||||
print(f"\nDatabase URL: {database_url.replace('://', '://****') if '://' in database_url else database_url}\n")
|
||||
|
||||
# Create engine
|
||||
connect_args = {}
|
||||
if database_url.startswith("sqlite"):
|
||||
connect_args = {"check_same_thread": False}
|
||||
|
||||
engine = create_engine(database_url, connect_args=connect_args)
|
||||
|
||||
try:
|
||||
# Get inspector to list tables
|
||||
inspector = inspect(engine)
|
||||
all_tables = inspector.get_table_names()
|
||||
|
||||
if not all_tables:
|
||||
print("❌ No tables found in database.")
|
||||
return
|
||||
|
||||
print(f"Found {len(all_tables)} tables:\n")
|
||||
|
||||
# Expected tables from models
|
||||
expected_tables = {
|
||||
"photos",
|
||||
"people",
|
||||
"faces",
|
||||
"person_encodings",
|
||||
"tags",
|
||||
"phototaglinkage",
|
||||
"photo_favorites",
|
||||
"users",
|
||||
"photo_person_linkage",
|
||||
"role_permissions",
|
||||
}
|
||||
|
||||
# Connect and query each table
|
||||
with engine.connect() as conn:
|
||||
print(f"{'Table Name':<30} {'Record Count':<15} {'Status'}")
|
||||
print("-" * 80)
|
||||
|
||||
for table_name in sorted(all_tables):
|
||||
# Skip SQLite system tables
|
||||
if table_name.startswith("sqlite_"):
|
||||
continue
|
||||
|
||||
try:
|
||||
# Get record count
|
||||
if database_url.startswith("sqlite"):
|
||||
result = conn.execute(text(f"SELECT COUNT(*) FROM {table_name}"))
|
||||
else:
|
||||
result = conn.execute(text(f'SELECT COUNT(*) FROM "{table_name}"'))
|
||||
|
||||
count = result.scalar()
|
||||
|
||||
# Check if it's an expected table
|
||||
status = "✅ Expected" if table_name in expected_tables else "⚠️ Unexpected"
|
||||
|
||||
print(f"{table_name:<30} {count:<15} {status}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"{table_name:<30} {'ERROR':<15} ❌ {str(e)[:50]}")
|
||||
|
||||
print("-" * 80)
|
||||
|
||||
# Summary
|
||||
print("\n📊 Summary:")
|
||||
with engine.connect() as conn:
|
||||
total_records = 0
|
||||
tables_with_data = 0
|
||||
for table_name in sorted(all_tables):
|
||||
if table_name.startswith("sqlite_"):
|
||||
continue
|
||||
try:
|
||||
if database_url.startswith("sqlite"):
|
||||
result = conn.execute(text(f"SELECT COUNT(*) FROM {table_name}"))
|
||||
else:
|
||||
result = conn.execute(text(f'SELECT COUNT(*) FROM "{table_name}"'))
|
||||
count = result.scalar()
|
||||
total_records += count
|
||||
if count > 0:
|
||||
tables_with_data += 1
|
||||
except:
|
||||
pass
|
||||
|
||||
print(f" Total tables: {len([t for t in all_tables if not t.startswith('sqlite_')])}")
|
||||
print(f" Tables with records: {tables_with_data}")
|
||||
print(f" Total records across all tables: {total_records:,}")
|
||||
|
||||
# Check for missing expected tables
|
||||
missing_tables = expected_tables - set(all_tables)
|
||||
if missing_tables:
|
||||
print(f"\n⚠️ Missing expected tables: {', '.join(sorted(missing_tables))}")
|
||||
|
||||
# Check for unexpected tables
|
||||
unexpected_tables = set(all_tables) - expected_tables - {"alembic_version"}
|
||||
unexpected_tables = {t for t in unexpected_tables if not t.startswith("sqlite_")}
|
||||
if unexpected_tables:
|
||||
print(f"\nℹ️ Additional tables found: {', '.join(sorted(unexpected_tables))}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error connecting to database: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_database_tables()
|
||||
|
||||
99
scripts/debug/check_identified_poses_web.py
Normal file
99
scripts/debug/check_identified_poses_web.py
Normal file
@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check all identified faces for pose information (web database)"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from backend.db.models import Face, Person, Photo
|
||||
from backend.db.session import get_database_url
|
||||
|
||||
def check_identified_faces():
|
||||
"""Check all identified faces for pose information"""
|
||||
db_url = get_database_url()
|
||||
print(f"Connecting to database: {db_url}")
|
||||
|
||||
engine = create_engine(db_url)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
try:
|
||||
# Get all identified faces with pose information
|
||||
faces = (
|
||||
session.query(Face, Person, Photo)
|
||||
.join(Person, Face.person_id == Person.id)
|
||||
.join(Photo, Face.photo_id == Photo.id)
|
||||
.filter(Face.person_id.isnot(None))
|
||||
.order_by(Person.id, Face.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not faces:
|
||||
print("No identified faces found.")
|
||||
return
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f"Found {len(faces)} identified faces")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
# Group by person
|
||||
by_person = {}
|
||||
for face, person, photo in faces:
|
||||
person_id = person.id
|
||||
if person_id not in by_person:
|
||||
by_person[person_id] = []
|
||||
by_person[person_id].append((face, person, photo))
|
||||
|
||||
# Print summary
|
||||
print("SUMMARY BY PERSON:")
|
||||
print("-" * 80)
|
||||
for person_id, person_faces in by_person.items():
|
||||
person = person_faces[0][1]
|
||||
person_name = f"{person.first_name} {person.last_name}"
|
||||
pose_modes = [f[0].pose_mode for f in person_faces]
|
||||
frontal_count = sum(1 for p in pose_modes if p == 'frontal')
|
||||
profile_count = sum(1 for p in pose_modes if 'profile' in p)
|
||||
other_count = len(pose_modes) - frontal_count - profile_count
|
||||
|
||||
print(f"\nPerson {person_id}: {person_name}")
|
||||
print(f" Total faces: {len(person_faces)}")
|
||||
print(f" Frontal: {frontal_count}")
|
||||
print(f" Profile: {profile_count}")
|
||||
print(f" Other: {other_count}")
|
||||
print(f" Pose modes: {set(pose_modes)}")
|
||||
|
||||
# Print detailed information
|
||||
print(f"\n{'='*80}")
|
||||
print("DETAILED FACE INFORMATION:")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
for face, person, photo in faces:
|
||||
person_name = f"{person.first_name} {person.last_name}"
|
||||
print(f"Face ID: {face.id}")
|
||||
print(f" Person: {person_name} (ID: {face.person_id})")
|
||||
print(f" Photo: {photo.filename}")
|
||||
print(f" Pose Mode: {face.pose_mode}")
|
||||
print(f" Yaw: {face.yaw_angle:.2f}°" if face.yaw_angle is not None else " Yaw: None")
|
||||
print(f" Pitch: {face.pitch_angle:.2f}°" if face.pitch_angle is not None else " Pitch: None")
|
||||
print(f" Roll: {face.roll_angle:.2f}°" if face.roll_angle is not None else " Roll: None")
|
||||
print(f" Confidence: {face.face_confidence:.3f}")
|
||||
print(f" Quality: {face.quality_score:.3f}")
|
||||
print(f" Location: {face.location}")
|
||||
print()
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
check_identified_faces()
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
188
scripts/debug/check_two_faces_pose.py
Executable file
188
scripts/debug/check_two_faces_pose.py
Executable file
@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check two identified faces and analyze why their pose modes are wrong"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from backend.db.models import Face, Person, Photo
|
||||
from backend.db.session import get_database_url
|
||||
from src.utils.pose_detection import PoseDetector
|
||||
|
||||
def check_two_faces(face_id1: int = None, face_id2: int = None):
|
||||
"""Check two identified faces and analyze their pose modes"""
|
||||
db_url = get_database_url()
|
||||
print(f"Connecting to database: {db_url}")
|
||||
|
||||
engine = create_engine(db_url)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
try:
|
||||
# Get all identified faces
|
||||
query = (
|
||||
session.query(Face, Person, Photo)
|
||||
.join(Person, Face.person_id == Person.id)
|
||||
.join(Photo, Face.photo_id == Photo.id)
|
||||
.filter(Face.person_id.isnot(None))
|
||||
.order_by(Face.id)
|
||||
)
|
||||
|
||||
if face_id1:
|
||||
query = query.filter(Face.id == face_id1)
|
||||
elif face_id2:
|
||||
query = query.filter(Face.id == face_id2)
|
||||
|
||||
faces = query.limit(2).all()
|
||||
|
||||
if len(faces) < 2:
|
||||
print(f"Found {len(faces)} identified face(s). Need 2 faces to compare.")
|
||||
if len(faces) == 0:
|
||||
print("No identified faces found.")
|
||||
return
|
||||
print("\nShowing available identified faces:")
|
||||
all_faces = (
|
||||
session.query(Face, Person, Photo)
|
||||
.join(Person, Face.person_id == Person.id)
|
||||
.join(Photo, Face.photo_id == Photo.id)
|
||||
.filter(Face.person_id.isnot(None))
|
||||
.order_by(Face.id)
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
for face, person, photo in all_faces:
|
||||
print(f" Face ID: {face.id}, Person: {person.first_name} {person.last_name}, Photo: {photo.filename}, Pose: {face.pose_mode}")
|
||||
return
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print("ANALYZING TWO IDENTIFIED FACES")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
for idx, (face, person, photo) in enumerate(faces, 1):
|
||||
person_name = f"{person.first_name} {person.last_name}"
|
||||
|
||||
print(f"{'='*80}")
|
||||
print(f"FACE {idx}: ID {face.id}")
|
||||
print(f"{'='*80}")
|
||||
print(f"Person: {person_name} (ID: {face.person_id})")
|
||||
print(f"Photo: {photo.filename}")
|
||||
print(f"Current Pose Mode: {face.pose_mode}")
|
||||
print(f"Yaw: {face.yaw_angle:.2f}°" if face.yaw_angle is not None else "Yaw: None")
|
||||
print(f"Pitch: {face.pitch_angle:.2f}°" if face.pitch_angle is not None else "Pitch: None")
|
||||
print(f"Roll: {face.roll_angle:.2f}°" if face.roll_angle is not None else "Roll: None")
|
||||
print(f"Face Width: {face.face_width if hasattr(face, 'face_width') else 'N/A'}")
|
||||
print(f"Confidence: {face.face_confidence:.3f}")
|
||||
print(f"Quality: {face.quality_score:.3f}")
|
||||
print(f"Location: {face.location}")
|
||||
|
||||
# Parse landmarks if available
|
||||
landmarks = None
|
||||
if face.landmarks:
|
||||
try:
|
||||
landmarks = json.loads(face.landmarks)
|
||||
print(f"\nLandmarks:")
|
||||
for key, value in landmarks.items():
|
||||
print(f" {key}: {value}")
|
||||
except json.JSONDecodeError:
|
||||
print(f"\nLandmarks: (invalid JSON)")
|
||||
|
||||
# Recalculate pose mode using current logic
|
||||
print(f"\n{'─'*80}")
|
||||
print("RECALCULATING POSE MODE:")
|
||||
print(f"{'─'*80}")
|
||||
|
||||
# Calculate face width from landmarks if available
|
||||
face_width = None
|
||||
if landmarks:
|
||||
face_width = PoseDetector.calculate_face_width_from_landmarks(landmarks)
|
||||
print(f"Calculated face_width from landmarks: {face_width}")
|
||||
|
||||
# Recalculate pose mode
|
||||
recalculated_pose = PoseDetector.classify_pose_mode(
|
||||
face.yaw_angle,
|
||||
face.pitch_angle,
|
||||
face.roll_angle,
|
||||
face_width,
|
||||
landmarks
|
||||
)
|
||||
|
||||
print(f"Recalculated Pose Mode: {recalculated_pose}")
|
||||
|
||||
if recalculated_pose != face.pose_mode:
|
||||
print(f"⚠️ MISMATCH! Current: '{face.pose_mode}' vs Recalculated: '{recalculated_pose}'")
|
||||
|
||||
# Analyze why
|
||||
print(f"\nAnalysis:")
|
||||
if face.yaw_angle is None:
|
||||
print(f" - Yaw is None")
|
||||
if landmarks:
|
||||
left_eye = landmarks.get('left_eye')
|
||||
right_eye = landmarks.get('right_eye')
|
||||
nose = landmarks.get('nose')
|
||||
missing = []
|
||||
if not left_eye:
|
||||
missing.append('left_eye')
|
||||
if not right_eye:
|
||||
missing.append('right_eye')
|
||||
if not nose:
|
||||
missing.append('nose')
|
||||
if missing:
|
||||
print(f" - Missing landmarks: {', '.join(missing)}")
|
||||
print(f" - Should be classified as profile (missing landmarks)")
|
||||
else:
|
||||
print(f" - All landmarks present")
|
||||
if face_width:
|
||||
print(f" - Face width: {face_width}px")
|
||||
if face_width < 25.0:
|
||||
print(f" - Face width < 25px, should be profile")
|
||||
else:
|
||||
print(f" - Face width >= 25px, should be frontal")
|
||||
else:
|
||||
print(f" - No landmarks available")
|
||||
else:
|
||||
abs_yaw = abs(face.yaw_angle)
|
||||
print(f" - Yaw angle: {face.yaw_angle:.2f}° (abs: {abs_yaw:.2f}°)")
|
||||
if abs_yaw >= 30.0:
|
||||
expected = "profile_left" if face.yaw_angle < 0 else "profile_right"
|
||||
print(f" - |yaw| >= 30°, should be '{expected}'")
|
||||
else:
|
||||
print(f" - |yaw| < 30°, should be 'frontal'")
|
||||
else:
|
||||
print(f"✓ Pose mode matches recalculated value")
|
||||
|
||||
print()
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
face_id1 = None
|
||||
face_id2 = None
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
try:
|
||||
face_id1 = int(sys.argv[1])
|
||||
except ValueError:
|
||||
print(f"Invalid face ID: {sys.argv[1]}")
|
||||
sys.exit(1)
|
||||
|
||||
if len(sys.argv) > 2:
|
||||
try:
|
||||
face_id2 = int(sys.argv[2])
|
||||
except ValueError:
|
||||
print(f"Invalid face ID: {sys.argv[2]}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
check_two_faces(face_id1, face_id2)
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
80
scripts/debug/check_yaw_angles.py
Normal file
80
scripts/debug/check_yaw_angles.py
Normal file
@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check yaw angles in database to see why profile faces aren't being detected
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
db_path = "data/punimtag.db"
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"❌ Database not found: {db_path}")
|
||||
exit(1)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get all faces with yaw data
|
||||
cursor.execute("""
|
||||
SELECT id, pose_mode, yaw_angle, pitch_angle, roll_angle
|
||||
FROM faces
|
||||
WHERE yaw_angle IS NOT NULL
|
||||
ORDER BY ABS(yaw_angle) DESC
|
||||
""")
|
||||
|
||||
faces = cursor.fetchall()
|
||||
|
||||
print(f"Found {len(faces)} faces with yaw data\n")
|
||||
print("=" * 80)
|
||||
print("YAW ANGLE ANALYSIS")
|
||||
print("=" * 80)
|
||||
print(f"\n{'Face ID':<10} {'Pose Mode':<25} {'Yaw':<10} {'Should be Profile?'}")
|
||||
print("-" * 80)
|
||||
|
||||
PROFILE_THRESHOLD = 30.0 # From pose_detection.py
|
||||
|
||||
profile_count = 0
|
||||
for face in faces:
|
||||
yaw = face['yaw_angle']
|
||||
pose_mode = face['pose_mode']
|
||||
is_profile = abs(yaw) >= PROFILE_THRESHOLD
|
||||
should_be_profile = "YES" if is_profile else "NO"
|
||||
|
||||
if is_profile:
|
||||
profile_count += 1
|
||||
|
||||
print(f"{face['id']:<10} {pose_mode:<25} {yaw:>8.2f}° {should_be_profile}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print(f"Total faces with yaw data: {len(faces)}")
|
||||
print(f"Faces with |yaw| >= {PROFILE_THRESHOLD}° (should be profile): {profile_count}")
|
||||
print(f"Faces currently classified as profile: {cursor.execute('SELECT COUNT(*) FROM faces WHERE pose_mode LIKE \"profile%\"').fetchone()[0]}")
|
||||
print("=" * 80)
|
||||
|
||||
# Check yaw distribution
|
||||
print("\n" + "=" * 80)
|
||||
print("YAW ANGLE DISTRIBUTION")
|
||||
print("=" * 80)
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN ABS(yaw_angle) < 30 THEN 'frontal (< 30°)'
|
||||
WHEN ABS(yaw_angle) >= 30 AND ABS(yaw_angle) < 60 THEN 'profile (30-60°)'
|
||||
WHEN ABS(yaw_angle) >= 60 THEN 'extreme profile (>= 60°)'
|
||||
ELSE 'unknown'
|
||||
END as category,
|
||||
COUNT(*) as count
|
||||
FROM faces
|
||||
WHERE yaw_angle IS NOT NULL
|
||||
GROUP BY category
|
||||
ORDER BY count DESC
|
||||
""")
|
||||
|
||||
distribution = cursor.fetchall()
|
||||
for row in distribution:
|
||||
print(f" {row['category']}: {row['count']} faces")
|
||||
|
||||
conn.close()
|
||||
|
||||
253
scripts/debug/debug_pose_classification.py
Executable file
253
scripts/debug/debug_pose_classification.py
Executable file
@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Debug pose classification for identified faces
|
||||
|
||||
This script helps identify why poses might be incorrectly classified.
|
||||
It shows detailed pose information and can recalculate poses from photos.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from backend.db.models import Face, Person, Photo
|
||||
from backend.db.session import get_database_url
|
||||
from src.utils.pose_detection import PoseDetector
|
||||
|
||||
|
||||
def analyze_pose_classification(
|
||||
face_id: Optional[int] = None,
|
||||
person_id: Optional[int] = None,
|
||||
recalculate: bool = False,
|
||||
) -> None:
|
||||
"""Analyze pose classification for identified faces.
|
||||
|
||||
Args:
|
||||
face_id: Specific face ID to check (None = all identified faces)
|
||||
person_id: Specific person ID to check (None = all persons)
|
||||
recalculate: If True, recalculate pose from photo to verify classification
|
||||
"""
|
||||
db_url = get_database_url()
|
||||
print(f"Connecting to database: {db_url}")
|
||||
|
||||
engine = create_engine(db_url)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
try:
|
||||
# Build query
|
||||
query = (
|
||||
session.query(Face, Person, Photo)
|
||||
.join(Person, Face.person_id == Person.id)
|
||||
.join(Photo, Face.photo_id == Photo.id)
|
||||
.filter(Face.person_id.isnot(None))
|
||||
)
|
||||
|
||||
if face_id:
|
||||
query = query.filter(Face.id == face_id)
|
||||
if person_id:
|
||||
query = query.filter(Person.id == person_id)
|
||||
|
||||
faces = query.order_by(Person.id, Face.id).all()
|
||||
|
||||
if not faces:
|
||||
print("No identified faces found matching criteria.")
|
||||
return
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f"Found {len(faces)} identified face(s)")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
pose_detector = None
|
||||
if recalculate:
|
||||
try:
|
||||
pose_detector = PoseDetector()
|
||||
print("Pose detector initialized for recalculation\n")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not initialize pose detector: {e}")
|
||||
print("Skipping recalculation\n")
|
||||
recalculate = False
|
||||
|
||||
for face, person, photo in faces:
|
||||
person_name = f"{person.first_name} {person.last_name}"
|
||||
|
||||
print(f"{'='*80}")
|
||||
print(f"Face ID: {face.id}")
|
||||
print(f"Person: {person_name} (ID: {person.id})")
|
||||
print(f"Photo: {photo.filename}")
|
||||
print(f"Photo Path: {photo.path}")
|
||||
print(f"{'-'*80}")
|
||||
|
||||
# Current stored pose information
|
||||
print("STORED POSE INFORMATION:")
|
||||
print(f" Pose Mode: {face.pose_mode}")
|
||||
print(f" Yaw Angle: {face.yaw_angle:.2f}°" if face.yaw_angle is not None else " Yaw Angle: None")
|
||||
print(f" Pitch Angle: {face.pitch_angle:.2f}°" if face.pitch_angle is not None else " Pitch Angle: None")
|
||||
print(f" Roll Angle: {face.roll_angle:.2f}°" if face.roll_angle is not None else " Roll Angle: None")
|
||||
print(f" Face Confidence: {face.face_confidence:.3f}")
|
||||
print(f" Quality Score: {face.quality_score:.3f}")
|
||||
|
||||
# Parse location
|
||||
try:
|
||||
location = json.loads(face.location) if isinstance(face.location, str) else face.location
|
||||
print(f" Location: {location}")
|
||||
except:
|
||||
print(f" Location: {face.location}")
|
||||
|
||||
# Analyze classification
|
||||
print(f"\nPOSE CLASSIFICATION ANALYSIS:")
|
||||
yaw = face.yaw_angle
|
||||
pitch = face.pitch_angle
|
||||
roll = face.roll_angle
|
||||
|
||||
if yaw is not None:
|
||||
abs_yaw = abs(yaw)
|
||||
print(f" Yaw: {yaw:.2f}° (absolute: {abs_yaw:.2f}°)")
|
||||
|
||||
if abs_yaw < 30.0:
|
||||
expected_mode = "frontal"
|
||||
print(f" → Expected: {expected_mode} (yaw < 30°)")
|
||||
elif yaw <= -30.0:
|
||||
expected_mode = "profile_left"
|
||||
print(f" → Expected: {expected_mode} (yaw <= -30°, face turned left)")
|
||||
elif yaw >= 30.0:
|
||||
expected_mode = "profile_right"
|
||||
print(f" → Expected: {expected_mode} (yaw >= 30°, face turned right)")
|
||||
else:
|
||||
expected_mode = "unknown"
|
||||
print(f" → Expected: {expected_mode} (edge case)")
|
||||
|
||||
if face.pose_mode != expected_mode:
|
||||
print(f" ⚠️ MISMATCH: Stored pose_mode='{face.pose_mode}' but expected '{expected_mode}'")
|
||||
else:
|
||||
print(f" ✓ Classification matches expected mode")
|
||||
else:
|
||||
print(f" Yaw: None (cannot determine pose from yaw)")
|
||||
print(f" ⚠️ Warning: Yaw angle is missing, pose classification may be unreliable")
|
||||
|
||||
# Recalculate if requested
|
||||
if recalculate and pose_detector and photo.path and os.path.exists(photo.path):
|
||||
print(f"\nRECALCULATING POSE FROM PHOTO:")
|
||||
try:
|
||||
pose_faces = pose_detector.detect_pose_faces(photo.path)
|
||||
|
||||
if not pose_faces:
|
||||
print(" No faces detected in photo")
|
||||
else:
|
||||
# Try to match face by location
|
||||
face_location = location if isinstance(location, dict) else json.loads(face.location) if isinstance(face.location, str) else {}
|
||||
face_x = face_location.get('x', 0)
|
||||
face_y = face_location.get('y', 0)
|
||||
face_w = face_location.get('w', 0)
|
||||
face_h = face_location.get('h', 0)
|
||||
face_center_x = face_x + face_w / 2
|
||||
face_center_y = face_y + face_h / 2
|
||||
|
||||
best_match = None
|
||||
best_distance = float('inf')
|
||||
|
||||
for pose_face in pose_faces:
|
||||
pose_area = pose_face.get('facial_area', {})
|
||||
if isinstance(pose_area, dict):
|
||||
pose_x = pose_area.get('x', 0)
|
||||
pose_y = pose_area.get('y', 0)
|
||||
pose_w = pose_area.get('w', 0)
|
||||
pose_h = pose_area.get('h', 0)
|
||||
pose_center_x = pose_x + pose_w / 2
|
||||
pose_center_y = pose_y + pose_h / 2
|
||||
|
||||
# Calculate distance between centers
|
||||
distance = ((face_center_x - pose_center_x) ** 2 +
|
||||
(face_center_y - pose_center_y) ** 2) ** 0.5
|
||||
|
||||
if distance < best_distance:
|
||||
best_distance = distance
|
||||
best_match = pose_face
|
||||
|
||||
if best_match:
|
||||
recalc_yaw = best_match.get('yaw_angle')
|
||||
recalc_pitch = best_match.get('pitch_angle')
|
||||
recalc_roll = best_match.get('roll_angle')
|
||||
recalc_face_width = best_match.get('face_width')
|
||||
recalc_pose_mode = best_match.get('pose_mode')
|
||||
|
||||
print(f" Recalculated Yaw: {recalc_yaw:.2f}°" if recalc_yaw is not None else " Recalculated Yaw: None")
|
||||
print(f" Recalculated Pitch: {recalc_pitch:.2f}°" if recalc_pitch is not None else " Recalculated Pitch: None")
|
||||
print(f" Recalculated Roll: {recalc_roll:.2f}°" if recalc_roll is not None else " Recalculated Roll: None")
|
||||
print(f" Face Width: {recalc_face_width:.2f}px" if recalc_face_width is not None else " Face Width: None")
|
||||
print(f" Recalculated Pose Mode: {recalc_pose_mode}")
|
||||
|
||||
# Compare
|
||||
if recalc_pose_mode != face.pose_mode:
|
||||
print(f" ⚠️ MISMATCH: Stored='{face.pose_mode}' vs Recalculated='{recalc_pose_mode}'")
|
||||
|
||||
if recalc_yaw is not None and face.yaw_angle is not None:
|
||||
# Convert Decimal to float for comparison
|
||||
stored_yaw = float(face.yaw_angle)
|
||||
yaw_diff = abs(recalc_yaw - stored_yaw)
|
||||
if yaw_diff > 1.0: # More than 1 degree difference
|
||||
print(f" ⚠️ Yaw difference: {yaw_diff:.2f}°")
|
||||
else:
|
||||
print(" Could not match face location to detected faces")
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error recalculating: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print()
|
||||
|
||||
print(f"{'='*80}")
|
||||
print("Analysis complete")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Debug pose classification for identified faces"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--face-id",
|
||||
type=int,
|
||||
help="Specific face ID to check"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--person-id",
|
||||
type=int,
|
||||
help="Specific person ID to check"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--recalculate",
|
||||
action="store_true",
|
||||
help="Recalculate pose from photo to verify classification"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
analyze_pose_classification(
|
||||
face_id=args.face_id,
|
||||
person_id=args.person_id,
|
||||
recalculate=args.recalculate,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
160
scripts/debug/diagnose_frontend_issues.py
Normal file
160
scripts/debug/diagnose_frontend_issues.py
Normal file
@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Diagnose frontend issues:
|
||||
1. Check if backend API is running and accessible
|
||||
2. Check database connection
|
||||
3. Test search endpoint
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from backend.db.session import get_database_url, engine
|
||||
from sqlalchemy import text
|
||||
|
||||
def check_backend_api():
|
||||
"""Check if backend API is running."""
|
||||
print("=" * 80)
|
||||
print("BACKEND API CHECK")
|
||||
print("=" * 80)
|
||||
|
||||
try:
|
||||
# Check if docs endpoint is accessible
|
||||
response = requests.get("http://127.0.0.1:8000/docs", timeout=5)
|
||||
if response.status_code == 200:
|
||||
print("✅ Backend API is running (docs accessible)")
|
||||
else:
|
||||
print(f"⚠️ Backend API returned status {response.status_code}")
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("❌ Backend API is NOT running or not accessible")
|
||||
print(" Start it with: cd backend && uvicorn app:app --reload")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Error checking backend API: {e}")
|
||||
return False
|
||||
|
||||
# Check search endpoint (requires auth)
|
||||
try:
|
||||
response = requests.get(
|
||||
"http://127.0.0.1:8000/api/v1/photos",
|
||||
params={"search_type": "processed", "page": 1, "page_size": 1},
|
||||
timeout=5
|
||||
)
|
||||
if response.status_code == 200:
|
||||
print("✅ Search endpoint is accessible (no auth required for this query)")
|
||||
elif response.status_code == 401:
|
||||
print("⚠️ Search endpoint requires authentication")
|
||||
print(" User needs to log in through admin frontend")
|
||||
else:
|
||||
print(f"⚠️ Search endpoint returned status {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error checking search endpoint: {e}")
|
||||
|
||||
return True
|
||||
|
||||
def check_database():
|
||||
"""Check database connection and photo count."""
|
||||
print("\n" + "=" * 80)
|
||||
print("DATABASE CHECK")
|
||||
print("=" * 80)
|
||||
|
||||
db_url = get_database_url()
|
||||
print(f"Database URL: {db_url.replace('://', '://****') if '://' in db_url else db_url}")
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
# Check photo count
|
||||
result = conn.execute(text("SELECT COUNT(*) FROM photos WHERE processed = 1"))
|
||||
count = result.scalar()
|
||||
print(f"✅ Database connection successful")
|
||||
print(f" Processed photos: {count}")
|
||||
|
||||
if count == 0:
|
||||
print("⚠️ No processed photos found in database")
|
||||
print(" This explains why viewer frontend shows 0 photos")
|
||||
else:
|
||||
print(f" Database has {count} processed photos")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Database connection error: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def check_viewer_frontend_config():
|
||||
"""Check viewer frontend configuration."""
|
||||
print("\n" + "=" * 80)
|
||||
print("VIEWER FRONTEND CONFIGURATION")
|
||||
print("=" * 80)
|
||||
|
||||
viewer_env = project_root / "viewer-frontend" / ".env"
|
||||
if not viewer_env.exists():
|
||||
print("❌ viewer-frontend/.env file not found")
|
||||
return False
|
||||
|
||||
with open(viewer_env) as f:
|
||||
content = f.read()
|
||||
if "DATABASE_URL" in content:
|
||||
# Extract DATABASE_URL
|
||||
for line in content.split("\n"):
|
||||
if line.startswith("DATABASE_URL="):
|
||||
db_url = line.split("=", 1)[1].strip().strip('"')
|
||||
print(f"Viewer frontend DATABASE_URL: {db_url.replace('://', '://****') if '://' in db_url else db_url}")
|
||||
|
||||
# Check if it matches actual database
|
||||
actual_db = get_database_url()
|
||||
if "postgresql" in db_url and "sqlite" in actual_db:
|
||||
print("❌ MISMATCH: Viewer frontend configured for PostgreSQL")
|
||||
print(" but actual database is SQLite")
|
||||
print("\n SOLUTION OPTIONS:")
|
||||
print(" 1. Change viewer-frontend/.env DATABASE_URL to SQLite:")
|
||||
print(f' DATABASE_URL="file:../data/punimtag.db"')
|
||||
print(" 2. Update Prisma schema to use SQLite provider")
|
||||
print(" 3. Migrate database to PostgreSQL")
|
||||
return False
|
||||
elif "sqlite" in db_url and "sqlite" in actual_db:
|
||||
print("✅ Viewer frontend configured for SQLite (matches actual database)")
|
||||
else:
|
||||
print("⚠️ Database type mismatch or unclear")
|
||||
else:
|
||||
print("⚠️ DATABASE_URL not found in viewer-frontend/.env")
|
||||
|
||||
return True
|
||||
|
||||
def main():
|
||||
print("\n🔍 DIAGNOSING FRONTEND ISSUES\n")
|
||||
|
||||
backend_ok = check_backend_api()
|
||||
db_ok = check_database()
|
||||
viewer_config_ok = check_viewer_frontend_config()
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("SUMMARY")
|
||||
print("=" * 80)
|
||||
|
||||
if not backend_ok:
|
||||
print("❌ Backend API is not running - admin frontend search will fail")
|
||||
else:
|
||||
print("✅ Backend API is running")
|
||||
|
||||
if not db_ok:
|
||||
print("❌ Database connection issue")
|
||||
else:
|
||||
print("✅ Database connection OK")
|
||||
|
||||
if not viewer_config_ok:
|
||||
print("❌ Viewer frontend configuration issue - needs to be fixed")
|
||||
else:
|
||||
print("✅ Viewer frontend configuration OK")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
115
scripts/debug/test_eye_visibility.py
Normal file
115
scripts/debug/test_eye_visibility.py
Normal file
@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test if RetinaFace provides both eyes for profile faces or if one eye is missing
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
try:
|
||||
from src.utils.pose_detection import PoseDetector, RETINAFACE_AVAILABLE
|
||||
from pathlib import Path
|
||||
|
||||
if not RETINAFACE_AVAILABLE:
|
||||
print("❌ RetinaFace not available")
|
||||
exit(1)
|
||||
|
||||
detector = PoseDetector()
|
||||
|
||||
# Find test images
|
||||
test_image_paths = ["demo_photos", "data/uploads"]
|
||||
test_image = None
|
||||
|
||||
for path in test_image_paths:
|
||||
if os.path.exists(path):
|
||||
for ext in ['.jpg', '.jpeg', '.png']:
|
||||
for img_file in Path(path).glob(f'*{ext}'):
|
||||
test_image = str(img_file)
|
||||
break
|
||||
if test_image:
|
||||
break
|
||||
|
||||
if not test_image:
|
||||
print("❌ No test image found")
|
||||
exit(1)
|
||||
|
||||
print(f"Testing with: {test_image}\n")
|
||||
print("=" * 80)
|
||||
print("EYE VISIBILITY ANALYSIS")
|
||||
print("=" * 80)
|
||||
|
||||
faces = detector.detect_faces_with_landmarks(test_image)
|
||||
|
||||
if not faces:
|
||||
print("❌ No faces detected")
|
||||
exit(1)
|
||||
|
||||
print(f"Found {len(faces)} face(s)\n")
|
||||
|
||||
for face_key, face_data in faces.items():
|
||||
landmarks = face_data.get('landmarks', {})
|
||||
print(f"{face_key}:")
|
||||
print(f" Landmarks available: {list(landmarks.keys())}")
|
||||
|
||||
left_eye = landmarks.get('left_eye')
|
||||
right_eye = landmarks.get('right_eye')
|
||||
nose = landmarks.get('nose')
|
||||
|
||||
print(f" Left eye: {left_eye}")
|
||||
print(f" Right eye: {right_eye}")
|
||||
print(f" Nose: {nose}")
|
||||
|
||||
# Check if both eyes are present
|
||||
both_eyes_present = left_eye is not None and right_eye is not None
|
||||
only_left_eye = left_eye is not None and right_eye is None
|
||||
only_right_eye = left_eye is None and right_eye is not None
|
||||
no_eyes = left_eye is None and right_eye is None
|
||||
|
||||
print(f"\n Eye visibility:")
|
||||
print(f" Both eyes present: {both_eyes_present}")
|
||||
print(f" Only left eye: {only_left_eye}")
|
||||
print(f" Only right eye: {only_right_eye}")
|
||||
print(f" No eyes: {no_eyes}")
|
||||
|
||||
# Calculate yaw if possible
|
||||
yaw = detector.calculate_yaw_from_landmarks(landmarks)
|
||||
print(f" Yaw angle: {yaw:.2f}°" if yaw is not None else " Yaw angle: None (requires both eyes)")
|
||||
|
||||
# Calculate face width if both eyes present
|
||||
if both_eyes_present:
|
||||
face_width = abs(right_eye[0] - left_eye[0])
|
||||
print(f" Face width (eye distance): {face_width:.2f} pixels")
|
||||
|
||||
# If face width is very small, it might be a profile view
|
||||
if face_width < 20:
|
||||
print(f" ⚠️ Very small face width - likely extreme profile view")
|
||||
|
||||
# Classify pose
|
||||
pitch = detector.calculate_pitch_from_landmarks(landmarks)
|
||||
roll = detector.calculate_roll_from_landmarks(landmarks)
|
||||
pose_mode = detector.classify_pose_mode(yaw, pitch, roll)
|
||||
|
||||
print(f" Pose mode: {pose_mode}")
|
||||
print()
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("CONCLUSION")
|
||||
print("=" * 80)
|
||||
print("""
|
||||
If RetinaFace provides both eyes even for profile faces:
|
||||
- We can use eye distance (face width) as an indicator
|
||||
- Small face width (< 20-30 pixels) suggests extreme profile
|
||||
- But we can't directly use 'missing eye' as a signal
|
||||
|
||||
If RetinaFace sometimes only provides one eye for profile faces:
|
||||
- We can check if left_eye or right_eye is None
|
||||
- If only one eye is present, it's likely a profile view
|
||||
- This would be a strong indicator for profile detection
|
||||
""")
|
||||
|
||||
except ImportError as e:
|
||||
print(f"❌ Import error: {e}")
|
||||
print("Make sure you're in the project directory and dependencies are installed")
|
||||
|
||||
161
scripts/debug/test_pose_calculation.py
Normal file
161
scripts/debug/test_pose_calculation.py
Normal file
@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test pitch and roll angle calculations to investigate issues
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
try:
|
||||
from src.utils.pose_detection import PoseDetector, RETINAFACE_AVAILABLE
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
def test_retinaface_landmarks():
|
||||
"""Test what landmarks RetinaFace actually provides"""
|
||||
if not RETINAFACE_AVAILABLE:
|
||||
print("❌ RetinaFace not available")
|
||||
return
|
||||
|
||||
print("=" * 60)
|
||||
print("TESTING RETINAFACE LANDMARKS")
|
||||
print("=" * 60)
|
||||
|
||||
# Try to find a test image
|
||||
test_image_paths = [
|
||||
"demo_photos",
|
||||
"data/uploads",
|
||||
"data"
|
||||
]
|
||||
|
||||
detector = PoseDetector()
|
||||
test_image = None
|
||||
|
||||
for path in test_image_paths:
|
||||
if os.path.exists(path):
|
||||
for ext in ['.jpg', '.jpeg', '.png']:
|
||||
for img_file in Path(path).glob(f'*{ext}'):
|
||||
test_image = str(img_file)
|
||||
break
|
||||
if test_image:
|
||||
break
|
||||
|
||||
if not test_image:
|
||||
print("❌ No test image found")
|
||||
return
|
||||
|
||||
print(f"Using test image: {test_image}")
|
||||
|
||||
# Detect faces
|
||||
faces = detector.detect_faces_with_landmarks(test_image)
|
||||
|
||||
if not faces:
|
||||
print("❌ No faces detected")
|
||||
return
|
||||
|
||||
print(f"\n✅ Found {len(faces)} face(s)")
|
||||
|
||||
for face_key, face_data in faces.items():
|
||||
print(f"\n{face_key}:")
|
||||
landmarks = face_data.get('landmarks', {})
|
||||
print(f" Landmarks keys: {list(landmarks.keys())}")
|
||||
|
||||
for landmark_name, position in landmarks.items():
|
||||
print(f" {landmark_name}: {position}")
|
||||
|
||||
# Test calculations
|
||||
yaw = detector.calculate_yaw_from_landmarks(landmarks)
|
||||
pitch = detector.calculate_pitch_from_landmarks(landmarks)
|
||||
roll = detector.calculate_roll_from_landmarks(landmarks)
|
||||
|
||||
print(f"\n Calculated angles:")
|
||||
print(f" Yaw: {yaw:.2f}°" if yaw is not None else " Yaw: None")
|
||||
print(f" Pitch: {pitch:.2f}°" if pitch is not None else " Pitch: None")
|
||||
print(f" Roll: {roll:.2f}°" if roll is not None else " Roll: None")
|
||||
|
||||
# Check which landmarks are missing for pitch
|
||||
required_for_pitch = ['left_eye', 'right_eye', 'left_mouth', 'right_mouth', 'nose']
|
||||
missing = [lm for lm in required_for_pitch if lm not in landmarks]
|
||||
if missing:
|
||||
print(f" ⚠️ Missing landmarks for pitch: {missing}")
|
||||
|
||||
# Check roll calculation
|
||||
if roll is not None:
|
||||
left_eye = landmarks.get('left_eye')
|
||||
right_eye = landmarks.get('right_eye')
|
||||
if left_eye and right_eye:
|
||||
dx = right_eye[0] - left_eye[0]
|
||||
dy = right_eye[1] - left_eye[1]
|
||||
print(f" Roll calculation details:")
|
||||
print(f" dx (right_eye[0] - left_eye[0]): {dx:.2f}")
|
||||
print(f" dy (right_eye[1] - left_eye[1]): {dy:.2f}")
|
||||
print(f" atan2(dy, dx) = {roll:.2f}°")
|
||||
|
||||
# Normalize to [-90, 90] range
|
||||
normalized_roll = roll
|
||||
if normalized_roll > 90:
|
||||
normalized_roll = normalized_roll - 180
|
||||
elif normalized_roll < -90:
|
||||
normalized_roll = normalized_roll + 180
|
||||
print(f" Normalized to [-90, 90]: {normalized_roll:.2f}°")
|
||||
|
||||
pose_mode = detector.classify_pose_mode(yaw, pitch, roll)
|
||||
print(f" Pose mode: {pose_mode}")
|
||||
|
||||
def analyze_database_angles():
|
||||
"""Analyze angles in database to find patterns"""
|
||||
db_path = "data/punimtag.db"
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"❌ Database not found: {db_path}")
|
||||
return
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("ANALYZING DATABASE ANGLES")
|
||||
print("=" * 60)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get faces with angle data
|
||||
cursor.execute("""
|
||||
SELECT id, pose_mode, yaw_angle, pitch_angle, roll_angle
|
||||
FROM faces
|
||||
WHERE yaw_angle IS NOT NULL OR pitch_angle IS NOT NULL OR roll_angle IS NOT NULL
|
||||
LIMIT 20
|
||||
""")
|
||||
|
||||
faces = cursor.fetchall()
|
||||
print(f"\nFound {len(faces)} faces with angle data\n")
|
||||
|
||||
for face in faces:
|
||||
print(f"Face ID {face['id']}: {face['pose_mode']}")
|
||||
print(f" Yaw: {face['yaw_angle']:.2f}°" if face['yaw_angle'] else " Yaw: None")
|
||||
print(f" Pitch: {face['pitch_angle']:.2f}°" if face['pitch_angle'] else " Pitch: None")
|
||||
print(f" Roll: {face['roll_angle']:.2f}°" if face['roll_angle'] else " Roll: None")
|
||||
|
||||
# Check roll normalization
|
||||
if face['roll_angle'] is not None:
|
||||
roll = face['roll_angle']
|
||||
normalized = roll
|
||||
if normalized > 90:
|
||||
normalized = normalized - 180
|
||||
elif normalized < -90:
|
||||
normalized = normalized + 180
|
||||
print(f" Roll normalized: {normalized:.2f}°")
|
||||
print()
|
||||
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_retinaface_landmarks()
|
||||
analyze_database_angles()
|
||||
|
||||
except ImportError as e:
|
||||
print(f"❌ Import error: {e}")
|
||||
print("Make sure you're in the project directory and dependencies are installed")
|
||||
|
||||
53
scripts/utils/fix_admin_password.py
Normal file
53
scripts/utils/fix_admin_password.py
Normal file
@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fix admin user password in database."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from backend.db.session import get_db
|
||||
from backend.db.models import User
|
||||
from backend.utils.password import hash_password, verify_password
|
||||
|
||||
def fix_admin_password():
|
||||
"""Set admin user password to 'admin'."""
|
||||
db = next(get_db())
|
||||
try:
|
||||
admin_user = db.query(User).filter(User.username == 'admin').first()
|
||||
|
||||
if not admin_user:
|
||||
print("❌ Admin user not found in database")
|
||||
return False
|
||||
|
||||
# Set password to 'admin'
|
||||
new_hash = hash_password('admin')
|
||||
admin_user.password_hash = new_hash
|
||||
admin_user.is_active = True
|
||||
admin_user.is_admin = True
|
||||
db.commit()
|
||||
|
||||
# Verify it works
|
||||
if verify_password('admin', new_hash):
|
||||
print("✅ Admin password updated successfully")
|
||||
print(" Username: admin")
|
||||
print(" Password: admin")
|
||||
return True
|
||||
else:
|
||||
print("❌ Password verification failed after update")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
db.rollback()
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = fix_admin_password()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
116
scripts/utils/update_reported_photo_status.py
Normal file
116
scripts/utils/update_reported_photo_status.py
Normal file
@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Update status of a reported photo in the auth database."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from sqlalchemy import text
|
||||
from backend.db.session import get_auth_database_url, AuthSessionLocal
|
||||
|
||||
def update_reported_photo_status(report_id: int, new_status: str):
|
||||
"""Update the status of a reported photo."""
|
||||
if AuthSessionLocal is None:
|
||||
raise ValueError("Auth database not configured. Set DATABASE_URL_AUTH environment variable.")
|
||||
|
||||
db = AuthSessionLocal()
|
||||
try:
|
||||
# First check if the report exists and get its current status
|
||||
check_result = db.execute(text("""
|
||||
SELECT id, status, review_notes
|
||||
FROM inappropriate_photo_reports
|
||||
WHERE id = :report_id
|
||||
"""), {"report_id": report_id})
|
||||
|
||||
row = check_result.fetchone()
|
||||
if not row:
|
||||
print(f"❌ Reported photo {report_id} not found in database.")
|
||||
return
|
||||
|
||||
current_status = row.status
|
||||
review_notes = row.review_notes
|
||||
|
||||
print(f"📋 Current status: '{current_status}'")
|
||||
if review_notes:
|
||||
print(f"📝 Review notes: '{review_notes}'")
|
||||
|
||||
if current_status == new_status:
|
||||
print(f"ℹ️ Status is already '{new_status}'. No update needed.")
|
||||
return
|
||||
|
||||
# Update the status
|
||||
result = db.execute(text("""
|
||||
UPDATE inappropriate_photo_reports
|
||||
SET status = :new_status
|
||||
WHERE id = :report_id
|
||||
"""), {
|
||||
"new_status": new_status,
|
||||
"report_id": report_id
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
print(f"✅ Successfully updated reported photo {report_id} status from '{current_status}' to '{new_status}'")
|
||||
else:
|
||||
print(f"⚠️ No rows updated.")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"❌ Error updating reported photo status: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def find_reported_photo_by_note(search_note: str):
|
||||
"""Find reported photos by review notes."""
|
||||
if AuthSessionLocal is None:
|
||||
raise ValueError("Auth database not configured. Set DATABASE_URL_AUTH environment variable.")
|
||||
|
||||
db = AuthSessionLocal()
|
||||
try:
|
||||
result = db.execute(text("""
|
||||
SELECT id, photo_id, status, review_notes, reported_at
|
||||
FROM inappropriate_photo_reports
|
||||
WHERE review_notes LIKE :search_pattern
|
||||
ORDER BY id DESC
|
||||
"""), {"search_pattern": f"%{search_note}%"})
|
||||
|
||||
rows = result.fetchall()
|
||||
if not rows:
|
||||
print(f"❌ No reported photos found with note containing '{search_note}'")
|
||||
return []
|
||||
|
||||
print(f"📋 Found {len(rows)} reported photo(s) with note containing '{search_note}':\n")
|
||||
for row in rows:
|
||||
print(f" ID: {row.id}, Photo ID: {row.photo_id}, Status: {row.status}")
|
||||
print(f" Notes: {row.review_notes}")
|
||||
print(f" Reported at: {row.reported_at}\n")
|
||||
|
||||
return rows
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error searching for reported photos: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python scripts/update_reported_photo_status.py <report_id> <new_status>")
|
||||
print(" OR: python scripts/update_reported_photo_status.py search <search_text>")
|
||||
print("Example: python scripts/update_reported_photo_status.py 57 dismissed")
|
||||
print("Example: python scripts/update_reported_photo_status.py search 'agree. removed'")
|
||||
sys.exit(1)
|
||||
|
||||
if sys.argv[1] == "search":
|
||||
search_text = sys.argv[2]
|
||||
find_reported_photo_by_note(search_text)
|
||||
else:
|
||||
report_id = int(sys.argv[1])
|
||||
new_status = sys.argv[2]
|
||||
update_reported_photo_status(report_id, new_status)
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
38
viewer-frontend/components/search/SearchBar.tsx
Normal file
38
viewer-frontend/components/search/SearchBar.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
interface SearchBarProps {
|
||||
onSearch: (query: string) => void;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export function SearchBar({ onSearch, placeholder = 'Search photos...', defaultValue = '' }: SearchBarProps) {
|
||||
const [query, setQuery] = useState(defaultValue);
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onSearch(query);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, onSearch]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
127
viewer-frontend/components/search/TagFilter.tsx
Normal file
127
viewer-frontend/components/search/TagFilter.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Tag } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface TagFilterProps {
|
||||
tags: Tag[];
|
||||
selected: number[];
|
||||
mode: 'any' | 'all';
|
||||
onSelectionChange: (selected: number[]) => void;
|
||||
onModeChange: (mode: 'any' | 'all') => void;
|
||||
}
|
||||
|
||||
export function TagFilter({ tags, selected, mode, onSelectionChange, onModeChange }: TagFilterProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const filteredTags = tags.filter((tag) => {
|
||||
const tagName = tag.tagName || tag.tag_name || '';
|
||||
return tagName.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
const toggleTag = (tagId: number) => {
|
||||
if (selected.includes(tagId)) {
|
||||
onSelectionChange(selected.filter((id) => id !== tagId));
|
||||
} else {
|
||||
onSelectionChange([...selected, tagId]);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTags = tags.filter((t) => selected.includes(t.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-secondary">Tags</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 tags...' : `${selected.length} selected`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder="Search tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{filteredTags.length === 0 ? (
|
||||
<p className="p-2 text-sm text-gray-500">No tags found</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredTags.map((tag) => {
|
||||
const isSelected = selected.includes(tag.id);
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center space-x-2 rounded-md p-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
>
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleTag(tag.id)}
|
||||
/>
|
||||
</span>
|
||||
<label className="flex-1 cursor-pointer text-sm">
|
||||
{tag.tagName || tag.tag_name || 'Unnamed Tag'}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{tag.tagName || tag.tag_name || 'Unnamed Tag'}
|
||||
<button
|
||||
onClick={() => toggleTag(tag.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>
|
||||
);
|
||||
}
|
||||
|
||||
46
viewer-frontend/components/ui/badge.tsx
Normal file
46
viewer-frontend/components/ui/badge.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
60
viewer-frontend/components/ui/button.tsx
Normal file
60
viewer-frontend/components/ui/button.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
216
viewer-frontend/components/ui/calendar.tsx
Normal file
216
viewer-frontend/components/ui/calendar.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
32
viewer-frontend/components/ui/checkbox.tsx
Normal file
32
viewer-frontend/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
144
viewer-frontend/components/ui/dialog.tsx
Normal file
144
viewer-frontend/components/ui/dialog.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
overlayClassName?: string
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
})
|
||||
DialogContent.displayName = "DialogContent"
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
21
viewer-frontend/components/ui/input.tsx
Normal file
21
viewer-frontend/components/ui/input.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
48
viewer-frontend/components/ui/popover.tsx
Normal file
48
viewer-frontend/components/ui/popover.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
187
viewer-frontend/components/ui/select.tsx
Normal file
187
viewer-frontend/components/ui/select.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
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