PunimTag Web Application - Major Feature Release #1
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