feat: Add new scripts and update project structure for database management and user authentication

This commit introduces several new scripts for managing database operations, including user creation, permission grants, and data migrations. It also adds new documentation files to guide users through the setup and configuration processes. Additionally, the project structure is updated to enhance organization and maintainability, ensuring a smoother development experience for contributors. These changes support the ongoing transition to a web-based architecture and improve overall project functionality.
This commit is contained in:
Tanya 2026-01-06 13:53:24 -05:00
parent 713584dc04
commit de2144be2a
175 changed files with 35854 additions and 0 deletions

310
.gitea/workflows/ci.yml Normal file
View 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

View File

@ -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
View 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
View 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)

View 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)

View 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()

View 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()

View 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)

View 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)

View 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()

View 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()

View 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)}")

View 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()

View 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)

View 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)

View 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()

View 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()

View 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()

View 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")

View 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")

View 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)

View 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)

View File

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

View 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

View 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
View 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
View File

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

View 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.

View 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

View 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
View 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
View 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!** 🚀

View 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

View 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

View 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.

View 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
```

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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);
}

View 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 />;
}

View 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;

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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)
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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;
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &quot;Img&quot; to see image requests</li>
<li>
<strong>Direct URL images</strong> should show requests to{' '}
<code className="rounded bg-blue-100 px-1 dark:bg-blue-800">
picsum.photos
</code>
</li>
<li>
<strong>File system images</strong> should show requests to{' '}
<code className="rounded bg-blue-100 px-1 dark:bg-blue-800">
/api/photos/...
</code>
</li>
</ol>
</div>
<div className="mb-4">
<h2 className="mb-2 text-xl font-semibold">Test Images</h2>
<div className="mb-2 text-sm text-gray-600 dark:text-gray-400">
<p>
<strong>Images 1-2:</strong> Direct URL access (should load from
picsum.photos)
</p>
<p>
<strong>Image 3:</strong> File system path (will use API proxy, may
show error if file doesn&apos;t exist)
</p>
</div>
</div>
<PhotoGrid photos={testPhotos} />
<div className="mt-8 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<h3 className="mb-2 font-semibold">Path Details:</h3>
<div className="space-y-2 text-sm">
{testPhotos.map((photo) => (
<div key={photo.id} className="font-mono text-xs">
<div>
<strong>ID {photo.id}:</strong> {photo.path}
</div>
<div className="ml-4 text-gray-600 dark:text-gray-400">
Type:{' '}
{photo.path.startsWith('http://') ||
photo.path.startsWith('https://')
? '✅ Direct URL'
: '📁 File System (API Proxy)'}
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@ -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>
);
}

View 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>
);
}

View 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 />;
}

View 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": {}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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