From de2144be2ac605962a64409e9d58d899f7c3c380 Mon Sep 17 00:00:00 2001 From: Tanya Date: Tue, 6 Jan 2026 13:53:24 -0500 Subject: [PATCH] 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. --- .gitea/workflows/ci.yml | 310 + package.json | 6 + scripts/README.md | 67 + scripts/db/drop_all_tables.py | 78 + scripts/db/drop_all_tables_web.py | 59 + scripts/db/grant_auth_db_permissions.py | 115 + scripts/db/migrate_sqlite_to_postgresql.py | 264 + scripts/db/recreate_tables_web.py | 35 + scripts/db/show_db_tables.py | 129 + scripts/debug/analyze_all_faces.py | 83 + scripts/debug/analyze_pose_matching.py | 156 + scripts/debug/analyze_poses.py | 192 + scripts/debug/check_database_tables.py | 135 + scripts/debug/check_identified_poses_web.py | 99 + scripts/debug/check_two_faces_pose.py | 188 + scripts/debug/check_yaw_angles.py | 80 + scripts/debug/debug_pose_classification.py | 253 + scripts/debug/diagnose_frontend_issues.py | 160 + scripts/debug/test_eye_visibility.py | 115 + scripts/debug/test_pose_calculation.py | 161 + scripts/utils/fix_admin_password.py | 53 + scripts/utils/update_reported_photo_status.py | 116 + viewer-frontend/.cursorignore | 15 + viewer-frontend/.cursorrules | 31 + viewer-frontend/.env.example | 19 + viewer-frontend/.gitignore | 48 + viewer-frontend/.npmrc | 1 + viewer-frontend/EMAIL_VERIFICATION_SETUP.md | 156 + viewer-frontend/FACE_TOOLTIP_ANALYSIS.md | 191 + viewer-frontend/GRANT_PERMISSIONS.md | 114 + viewer-frontend/README.md | 485 + viewer-frontend/SETUP.md | 264 + viewer-frontend/SETUP_AUTH.md | 131 + viewer-frontend/SETUP_AUTH_DATABASE.md | 180 + viewer-frontend/SETUP_INSTRUCTIONS.md | 86 + viewer-frontend/STOP_OLD_SERVER.md | 73 + viewer-frontend/app/HomePageContent.tsx | 1100 ++ .../app/admin/users/ManageUsersContent.tsx | 666 ++ .../app/admin/users/ManageUsersPageClient.tsx | 84 + viewer-frontend/app/admin/users/page.tsx | 20 + .../app/api/auth/[...nextauth]/route.ts | 155 + .../app/api/auth/check-verification/route.ts | 72 + .../app/api/auth/forgot-password/route.ts | 103 + .../app/api/auth/register/route.ts | 105 + .../app/api/auth/resend-confirmation/route.ts | 81 + .../app/api/auth/reset-password/route.ts | 82 + .../app/api/auth/verify-email/route.ts | 67 + .../app/api/debug-session/route.ts | 23 + .../app/api/faces/[id]/identify/route.ts | 174 + viewer-frontend/app/api/health/route.ts | 87 + viewer-frontend/app/api/people/route.ts | 81 + viewer-frontend/app/api/search/route.ts | 394 + viewer-frontend/app/api/users/[id]/route.ts | 324 + viewer-frontend/app/api/users/route.ts | 173 + viewer-frontend/app/favicon.ico | Bin 0 -> 25931 bytes viewer-frontend/app/globals.css | 128 + viewer-frontend/app/layout.tsx | 30 + viewer-frontend/app/login/page.tsx | 215 + viewer-frontend/app/page.tsx | 236 + viewer-frontend/app/photo/[id]/page.tsx | 106 + viewer-frontend/app/register/page.tsx | 185 + viewer-frontend/app/reset-password/page.tsx | 184 + viewer-frontend/app/search/SearchContent.tsx | 208 + viewer-frontend/app/search/page.tsx | 114 + viewer-frontend/app/test-images/page.tsx | 122 + viewer-frontend/app/upload/UploadContent.tsx | 367 + .../app/upload/UploadPageClient.tsx | 72 + viewer-frontend/app/upload/page.tsx | 14 + viewer-frontend/components.json | 22 + viewer-frontend/components/ActionButtons.tsx | 104 + .../components/ForgotPasswordDialog.tsx | 154 + viewer-frontend/components/Header.tsx | 191 + .../components/IdentifyFaceDialog.tsx | 604 ++ .../components/IdleLogoutHandler.tsx | 23 + viewer-frontend/components/LoginDialog.tsx | 304 + viewer-frontend/components/PageHeader.tsx | 78 + viewer-frontend/components/PhotoGrid.tsx | 917 ++ viewer-frontend/components/PhotoViewer.tsx | 172 + .../components/PhotoViewerClient.tsx | 1679 +++ viewer-frontend/components/RegisterDialog.tsx | 281 + .../components/SessionProviderWrapper.tsx | 20 + viewer-frontend/components/SimpleHeader.tsx | 36 + .../components/TagSelectionDialog.tsx | 334 + viewer-frontend/components/UserMenu.tsx | 169 + .../components/search/CollapsibleSearch.tsx | 92 + .../components/search/DateRangeFilter.tsx | 182 + .../components/search/FavoritesFilter.tsx | 34 + .../components/search/FilterPanel.tsx | 115 + .../components/search/MediaTypeFilter.tsx | 30 + .../components/search/PeopleFilter.tsx | 128 + .../components/search/SearchBar.tsx | 38 + .../components/search/TagFilter.tsx | 127 + viewer-frontend/components/ui/badge.tsx | 46 + viewer-frontend/components/ui/button.tsx | 60 + viewer-frontend/components/ui/calendar.tsx | 216 + viewer-frontend/components/ui/checkbox.tsx | 32 + viewer-frontend/components/ui/dialog.tsx | 144 + viewer-frontend/components/ui/input.tsx | 21 + viewer-frontend/components/ui/popover.tsx | 48 + viewer-frontend/components/ui/select.tsx | 187 + viewer-frontend/components/ui/tooltip.tsx | 69 + viewer-frontend/create_auth_tables.sql | 55 + viewer-frontend/create_viewer_user.sql | 31 + viewer-frontend/create_write_user.sql | 45 + viewer-frontend/docs/FFMPEG_SETUP.md | 82 + viewer-frontend/docs/NETWORK_SHARE_SETUP.md | 230 + .../docs/PHOTO_VIEWER_ARCHITECTURE.md | 821 ++ .../docs/PHOTO_VIEWER_EXECUTIVE_SUMMARY.md | 425 + viewer-frontend/docs/PHOTO_VIEWER_PLAN.md | 2002 ++++ .../docs/PHOTO_VIEWER_QUICKSTART.md | 470 + viewer-frontend/docs/PHOTO_VIEWER_README.md | 384 + viewer-frontend/docs/PREREQUISITES.md | 316 + .../docs/VIDEO_VIEWING_ANALYSIS.md | 267 + viewer-frontend/eslint.config.mjs | 18 + viewer-frontend/grant-auth-permissions.sql | 39 + viewer-frontend/grant-delete-permission.sql | 15 + .../grant-favorites-permissions.sql | 27 + viewer-frontend/grant_permissions_now.sql | 25 + .../grant_readonly_permissions.sql | 49 + viewer-frontend/grant_write_permissions.sql | 18 + viewer-frontend/hooks/useIdleLogout.ts | 72 + .../add-email-verification-columns.sql | 30 + .../add-inappropriate-photo-reports-table.sql | 32 + .../migrations/add-is-active-column.sql | 17 + .../migrations/add-password-reset-columns.sql | 19 + .../migrations/add-pending-photos-table.sql | 32 + .../migrations/add-photo-favorites-table.sql | 32 + ...comment-to-inappropriate-photo-reports.sql | 16 + .../migrations/add-write-access-column.sql | 21 + .../migrations/make-name-required.sql | 19 + viewer-frontend/next.config.ts | 26 + viewer-frontend/package-lock.json | 9210 +++++++++++++++++ viewer-frontend/package.json | 63 + viewer-frontend/postcss.config.mjs | 7 + viewer-frontend/prisma.config.ts.bak | 12 + viewer-frontend/prisma/schema-auth.prisma | 143 + viewer-frontend/prisma/schema.prisma | 214 + viewer-frontend/public/file.svg | 1 + viewer-frontend/public/globe.svg | 1 + viewer-frontend/public/jam-watermark.svg | 27 + viewer-frontend/public/next.svg | 1 + viewer-frontend/public/vercel.svg | 1 + viewer-frontend/public/window.svg | 1 + viewer-frontend/reset_viewer_password.sql | 20 + viewer-frontend/run-setup.sh | 114 + viewer-frontend/scripts/check-admin-user.ts | 87 + .../scripts/check-and-create-databases.sh | 197 + .../scripts/check-and-create-databases.ts | 261 + .../scripts/check-database-permissions.ts | 139 + .../scripts/check-user-and-resend.ts | 178 + viewer-frontend/scripts/create-admin-user.ts | 70 + .../scripts/find-corrupted-data.ts | 308 + viewer-frontend/scripts/fix-admin-user.ts | 124 + .../scripts/grant-delete-permission.ts | 65 + viewer-frontend/scripts/grant-permissions.ts | 102 + .../scripts/install-dependencies.sh | 206 + .../scripts/make-name-required-migration.ts | 95 + .../scripts/manually-verify-user.ts | 75 + .../run-email-verification-migration.ts | 80 + .../scripts/run-favorites-migration.ts | 130 + .../run-inappropriate-reports-migration.ts | 150 + viewer-frontend/scripts/run-migration.ts | 79 + viewer-frontend/scripts/setup-auth.ts | 132 + viewer-frontend/scripts/setup-database.ts | 124 + .../scripts/setup-with-superuser.sh | 34 + viewer-frontend/scripts/test-admin-check.ts | 70 + viewer-frontend/scripts/test-email-sending.ts | 77 + viewer-frontend/scripts/test-prisma-query.ts | 147 + viewer-frontend/scripts/with-sharp-libpath.sh | 17 + viewer-frontend/setup-auth-complete.sql | 83 + viewer-frontend/setup-auth-database.sql | 72 + viewer-frontend/setup-auth-tables.sql | 57 + viewer-frontend/test-image-detection.ts | 102 + viewer-frontend/tsconfig.json | 34 + viewer-frontend/types/next-auth.d.ts | 32 + 175 files changed, 35854 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 scripts/README.md create mode 100755 scripts/db/drop_all_tables.py create mode 100644 scripts/db/drop_all_tables_web.py create mode 100755 scripts/db/grant_auth_db_permissions.py create mode 100644 scripts/db/migrate_sqlite_to_postgresql.py create mode 100644 scripts/db/recreate_tables_web.py create mode 100644 scripts/db/show_db_tables.py create mode 100644 scripts/debug/analyze_all_faces.py create mode 100644 scripts/debug/analyze_pose_matching.py create mode 100644 scripts/debug/analyze_poses.py create mode 100644 scripts/debug/check_database_tables.py create mode 100644 scripts/debug/check_identified_poses_web.py create mode 100755 scripts/debug/check_two_faces_pose.py create mode 100644 scripts/debug/check_yaw_angles.py create mode 100755 scripts/debug/debug_pose_classification.py create mode 100644 scripts/debug/diagnose_frontend_issues.py create mode 100644 scripts/debug/test_eye_visibility.py create mode 100644 scripts/debug/test_pose_calculation.py create mode 100644 scripts/utils/fix_admin_password.py create mode 100644 scripts/utils/update_reported_photo_status.py create mode 100644 viewer-frontend/.cursorignore create mode 100644 viewer-frontend/.cursorrules create mode 100644 viewer-frontend/.env.example create mode 100644 viewer-frontend/.gitignore create mode 100644 viewer-frontend/.npmrc create mode 100644 viewer-frontend/EMAIL_VERIFICATION_SETUP.md create mode 100644 viewer-frontend/FACE_TOOLTIP_ANALYSIS.md create mode 100644 viewer-frontend/GRANT_PERMISSIONS.md create mode 100644 viewer-frontend/README.md create mode 100644 viewer-frontend/SETUP.md create mode 100644 viewer-frontend/SETUP_AUTH.md create mode 100644 viewer-frontend/SETUP_AUTH_DATABASE.md create mode 100644 viewer-frontend/SETUP_INSTRUCTIONS.md create mode 100644 viewer-frontend/STOP_OLD_SERVER.md create mode 100644 viewer-frontend/app/HomePageContent.tsx create mode 100644 viewer-frontend/app/admin/users/ManageUsersContent.tsx create mode 100644 viewer-frontend/app/admin/users/ManageUsersPageClient.tsx create mode 100644 viewer-frontend/app/admin/users/page.tsx create mode 100644 viewer-frontend/app/api/auth/[...nextauth]/route.ts create mode 100644 viewer-frontend/app/api/auth/check-verification/route.ts create mode 100644 viewer-frontend/app/api/auth/forgot-password/route.ts create mode 100644 viewer-frontend/app/api/auth/register/route.ts create mode 100644 viewer-frontend/app/api/auth/resend-confirmation/route.ts create mode 100644 viewer-frontend/app/api/auth/reset-password/route.ts create mode 100644 viewer-frontend/app/api/auth/verify-email/route.ts create mode 100644 viewer-frontend/app/api/debug-session/route.ts create mode 100644 viewer-frontend/app/api/faces/[id]/identify/route.ts create mode 100644 viewer-frontend/app/api/health/route.ts create mode 100644 viewer-frontend/app/api/people/route.ts create mode 100644 viewer-frontend/app/api/search/route.ts create mode 100644 viewer-frontend/app/api/users/[id]/route.ts create mode 100644 viewer-frontend/app/api/users/route.ts create mode 100644 viewer-frontend/app/favicon.ico create mode 100644 viewer-frontend/app/globals.css create mode 100644 viewer-frontend/app/layout.tsx create mode 100644 viewer-frontend/app/login/page.tsx create mode 100644 viewer-frontend/app/page.tsx create mode 100644 viewer-frontend/app/photo/[id]/page.tsx create mode 100644 viewer-frontend/app/register/page.tsx create mode 100644 viewer-frontend/app/reset-password/page.tsx create mode 100644 viewer-frontend/app/search/SearchContent.tsx create mode 100644 viewer-frontend/app/search/page.tsx create mode 100644 viewer-frontend/app/test-images/page.tsx create mode 100644 viewer-frontend/app/upload/UploadContent.tsx create mode 100644 viewer-frontend/app/upload/UploadPageClient.tsx create mode 100644 viewer-frontend/app/upload/page.tsx create mode 100644 viewer-frontend/components.json create mode 100644 viewer-frontend/components/ActionButtons.tsx create mode 100644 viewer-frontend/components/ForgotPasswordDialog.tsx create mode 100644 viewer-frontend/components/Header.tsx create mode 100644 viewer-frontend/components/IdentifyFaceDialog.tsx create mode 100644 viewer-frontend/components/IdleLogoutHandler.tsx create mode 100644 viewer-frontend/components/LoginDialog.tsx create mode 100644 viewer-frontend/components/PageHeader.tsx create mode 100644 viewer-frontend/components/PhotoGrid.tsx create mode 100644 viewer-frontend/components/PhotoViewer.tsx create mode 100644 viewer-frontend/components/PhotoViewerClient.tsx create mode 100644 viewer-frontend/components/RegisterDialog.tsx create mode 100644 viewer-frontend/components/SessionProviderWrapper.tsx create mode 100644 viewer-frontend/components/SimpleHeader.tsx create mode 100644 viewer-frontend/components/TagSelectionDialog.tsx create mode 100644 viewer-frontend/components/UserMenu.tsx create mode 100644 viewer-frontend/components/search/CollapsibleSearch.tsx create mode 100644 viewer-frontend/components/search/DateRangeFilter.tsx create mode 100644 viewer-frontend/components/search/FavoritesFilter.tsx create mode 100644 viewer-frontend/components/search/FilterPanel.tsx create mode 100644 viewer-frontend/components/search/MediaTypeFilter.tsx create mode 100644 viewer-frontend/components/search/PeopleFilter.tsx create mode 100644 viewer-frontend/components/search/SearchBar.tsx create mode 100644 viewer-frontend/components/search/TagFilter.tsx create mode 100644 viewer-frontend/components/ui/badge.tsx create mode 100644 viewer-frontend/components/ui/button.tsx create mode 100644 viewer-frontend/components/ui/calendar.tsx create mode 100644 viewer-frontend/components/ui/checkbox.tsx create mode 100644 viewer-frontend/components/ui/dialog.tsx create mode 100644 viewer-frontend/components/ui/input.tsx create mode 100644 viewer-frontend/components/ui/popover.tsx create mode 100644 viewer-frontend/components/ui/select.tsx create mode 100644 viewer-frontend/components/ui/tooltip.tsx create mode 100644 viewer-frontend/create_auth_tables.sql create mode 100644 viewer-frontend/create_viewer_user.sql create mode 100644 viewer-frontend/create_write_user.sql create mode 100644 viewer-frontend/docs/FFMPEG_SETUP.md create mode 100644 viewer-frontend/docs/NETWORK_SHARE_SETUP.md create mode 100644 viewer-frontend/docs/PHOTO_VIEWER_ARCHITECTURE.md create mode 100644 viewer-frontend/docs/PHOTO_VIEWER_EXECUTIVE_SUMMARY.md create mode 100644 viewer-frontend/docs/PHOTO_VIEWER_PLAN.md create mode 100644 viewer-frontend/docs/PHOTO_VIEWER_QUICKSTART.md create mode 100644 viewer-frontend/docs/PHOTO_VIEWER_README.md create mode 100644 viewer-frontend/docs/PREREQUISITES.md create mode 100644 viewer-frontend/docs/VIDEO_VIEWING_ANALYSIS.md create mode 100644 viewer-frontend/eslint.config.mjs create mode 100644 viewer-frontend/grant-auth-permissions.sql create mode 100644 viewer-frontend/grant-delete-permission.sql create mode 100644 viewer-frontend/grant-favorites-permissions.sql create mode 100644 viewer-frontend/grant_permissions_now.sql create mode 100644 viewer-frontend/grant_readonly_permissions.sql create mode 100644 viewer-frontend/grant_write_permissions.sql create mode 100644 viewer-frontend/hooks/useIdleLogout.ts create mode 100644 viewer-frontend/migrations/add-email-verification-columns.sql create mode 100644 viewer-frontend/migrations/add-inappropriate-photo-reports-table.sql create mode 100644 viewer-frontend/migrations/add-is-active-column.sql create mode 100644 viewer-frontend/migrations/add-password-reset-columns.sql create mode 100644 viewer-frontend/migrations/add-pending-photos-table.sql create mode 100644 viewer-frontend/migrations/add-photo-favorites-table.sql create mode 100644 viewer-frontend/migrations/add-report-comment-to-inappropriate-photo-reports.sql create mode 100644 viewer-frontend/migrations/add-write-access-column.sql create mode 100644 viewer-frontend/migrations/make-name-required.sql create mode 100644 viewer-frontend/next.config.ts create mode 100644 viewer-frontend/package-lock.json create mode 100644 viewer-frontend/package.json create mode 100644 viewer-frontend/postcss.config.mjs create mode 100644 viewer-frontend/prisma.config.ts.bak create mode 100644 viewer-frontend/prisma/schema-auth.prisma create mode 100644 viewer-frontend/prisma/schema.prisma create mode 100644 viewer-frontend/public/file.svg create mode 100644 viewer-frontend/public/globe.svg create mode 100644 viewer-frontend/public/jam-watermark.svg create mode 100644 viewer-frontend/public/next.svg create mode 100644 viewer-frontend/public/vercel.svg create mode 100644 viewer-frontend/public/window.svg create mode 100644 viewer-frontend/reset_viewer_password.sql create mode 100755 viewer-frontend/run-setup.sh create mode 100644 viewer-frontend/scripts/check-admin-user.ts create mode 100755 viewer-frontend/scripts/check-and-create-databases.sh create mode 100644 viewer-frontend/scripts/check-and-create-databases.ts create mode 100755 viewer-frontend/scripts/check-database-permissions.ts create mode 100644 viewer-frontend/scripts/check-user-and-resend.ts create mode 100644 viewer-frontend/scripts/create-admin-user.ts create mode 100755 viewer-frontend/scripts/find-corrupted-data.ts create mode 100644 viewer-frontend/scripts/fix-admin-user.ts create mode 100644 viewer-frontend/scripts/grant-delete-permission.ts create mode 100644 viewer-frontend/scripts/grant-permissions.ts create mode 100755 viewer-frontend/scripts/install-dependencies.sh create mode 100644 viewer-frontend/scripts/make-name-required-migration.ts create mode 100644 viewer-frontend/scripts/manually-verify-user.ts create mode 100644 viewer-frontend/scripts/run-email-verification-migration.ts create mode 100644 viewer-frontend/scripts/run-favorites-migration.ts create mode 100644 viewer-frontend/scripts/run-inappropriate-reports-migration.ts create mode 100644 viewer-frontend/scripts/run-migration.ts create mode 100644 viewer-frontend/scripts/setup-auth.ts create mode 100644 viewer-frontend/scripts/setup-database.ts create mode 100755 viewer-frontend/scripts/setup-with-superuser.sh create mode 100644 viewer-frontend/scripts/test-admin-check.ts create mode 100644 viewer-frontend/scripts/test-email-sending.ts create mode 100644 viewer-frontend/scripts/test-prisma-query.ts create mode 100755 viewer-frontend/scripts/with-sharp-libpath.sh create mode 100644 viewer-frontend/setup-auth-complete.sql create mode 100644 viewer-frontend/setup-auth-database.sql create mode 100644 viewer-frontend/setup-auth-tables.sql create mode 100644 viewer-frontend/test-image-detection.ts create mode 100644 viewer-frontend/tsconfig.json create mode 100644 viewer-frontend/types/next-auth.d.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8154990 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 + diff --git a/package.json b/package.json index 4a248a8..e398a00 100644 --- a/package.json +++ b/package.json @@ -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/'" }, diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..6414fca --- /dev/null +++ b/scripts/README.md @@ -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. + diff --git a/scripts/db/drop_all_tables.py b/scripts/db/drop_all_tables.py new file mode 100755 index 0000000..afb1804 --- /dev/null +++ b/scripts/db/drop_all_tables.py @@ -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 [ ...] + 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) diff --git a/scripts/db/drop_all_tables_web.py b/scripts/db/drop_all_tables_web.py new file mode 100644 index 0000000..3b25377 --- /dev/null +++ b/scripts/db/drop_all_tables_web.py @@ -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) + diff --git a/scripts/db/grant_auth_db_permissions.py b/scripts/db/grant_auth_db_permissions.py new file mode 100755 index 0000000..2f23ffc --- /dev/null +++ b/scripts/db/grant_auth_db_permissions.py @@ -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() + + diff --git a/scripts/db/migrate_sqlite_to_postgresql.py b/scripts/db/migrate_sqlite_to_postgresql.py new file mode 100644 index 0000000..fe3f9c3 --- /dev/null +++ b/scripts/db/migrate_sqlite_to_postgresql.py @@ -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() + diff --git a/scripts/db/recreate_tables_web.py b/scripts/db/recreate_tables_web.py new file mode 100644 index 0000000..3a53a71 --- /dev/null +++ b/scripts/db/recreate_tables_web.py @@ -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) + diff --git a/scripts/db/show_db_tables.py b/scripts/db/show_db_tables.py new file mode 100644 index 0000000..5dd6238 --- /dev/null +++ b/scripts/db/show_db_tables.py @@ -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) + diff --git a/scripts/debug/analyze_all_faces.py b/scripts/debug/analyze_all_faces.py new file mode 100644 index 0000000..9a7c623 --- /dev/null +++ b/scripts/debug/analyze_all_faces.py @@ -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() + diff --git a/scripts/debug/analyze_pose_matching.py b/scripts/debug/analyze_pose_matching.py new file mode 100644 index 0000000..91653fd --- /dev/null +++ b/scripts/debug/analyze_pose_matching.py @@ -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() + diff --git a/scripts/debug/analyze_poses.py b/scripts/debug/analyze_poses.py new file mode 100644 index 0000000..17115f7 --- /dev/null +++ b/scripts/debug/analyze_poses.py @@ -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)}") + diff --git a/scripts/debug/check_database_tables.py b/scripts/debug/check_database_tables.py new file mode 100644 index 0000000..485748f --- /dev/null +++ b/scripts/debug/check_database_tables.py @@ -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() + diff --git a/scripts/debug/check_identified_poses_web.py b/scripts/debug/check_identified_poses_web.py new file mode 100644 index 0000000..50c03b7 --- /dev/null +++ b/scripts/debug/check_identified_poses_web.py @@ -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) + diff --git a/scripts/debug/check_two_faces_pose.py b/scripts/debug/check_two_faces_pose.py new file mode 100755 index 0000000..a9b8074 --- /dev/null +++ b/scripts/debug/check_two_faces_pose.py @@ -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) + diff --git a/scripts/debug/check_yaw_angles.py b/scripts/debug/check_yaw_angles.py new file mode 100644 index 0000000..d2e399f --- /dev/null +++ b/scripts/debug/check_yaw_angles.py @@ -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() + diff --git a/scripts/debug/debug_pose_classification.py b/scripts/debug/debug_pose_classification.py new file mode 100755 index 0000000..48966ee --- /dev/null +++ b/scripts/debug/debug_pose_classification.py @@ -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() + diff --git a/scripts/debug/diagnose_frontend_issues.py b/scripts/debug/diagnose_frontend_issues.py new file mode 100644 index 0000000..9249ca9 --- /dev/null +++ b/scripts/debug/diagnose_frontend_issues.py @@ -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() + diff --git a/scripts/debug/test_eye_visibility.py b/scripts/debug/test_eye_visibility.py new file mode 100644 index 0000000..2503d06 --- /dev/null +++ b/scripts/debug/test_eye_visibility.py @@ -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") + diff --git a/scripts/debug/test_pose_calculation.py b/scripts/debug/test_pose_calculation.py new file mode 100644 index 0000000..ac01cf7 --- /dev/null +++ b/scripts/debug/test_pose_calculation.py @@ -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") + diff --git a/scripts/utils/fix_admin_password.py b/scripts/utils/fix_admin_password.py new file mode 100644 index 0000000..5ab7c7a --- /dev/null +++ b/scripts/utils/fix_admin_password.py @@ -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) + + diff --git a/scripts/utils/update_reported_photo_status.py b/scripts/utils/update_reported_photo_status.py new file mode 100644 index 0000000..a69f682 --- /dev/null +++ b/scripts/utils/update_reported_photo_status.py @@ -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 ") + print(" OR: python scripts/update_reported_photo_status.py search ") + 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) + diff --git a/viewer-frontend/.cursorignore b/viewer-frontend/.cursorignore new file mode 100644 index 0000000..5178c33 --- /dev/null +++ b/viewer-frontend/.cursorignore @@ -0,0 +1,15 @@ +# Ignore history files and directories +.history/ +*.history +*_YYYYMMDDHHMMSS.* +*_timestamp.* + +# Ignore backup files +*.bak +*.backup +*~ + +# Ignore temporary files +*.tmp +*.temp + diff --git a/viewer-frontend/.cursorrules b/viewer-frontend/.cursorrules new file mode 100644 index 0000000..32e4496 --- /dev/null +++ b/viewer-frontend/.cursorrules @@ -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 + diff --git a/viewer-frontend/.env.example b/viewer-frontend/.env.example new file mode 100644 index 0000000..15aac4c --- /dev/null +++ b/viewer-frontend/.env.example @@ -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" diff --git a/viewer-frontend/.gitignore b/viewer-frontend/.gitignore new file mode 100644 index 0000000..1cf6372 --- /dev/null +++ b/viewer-frontend/.gitignore @@ -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 diff --git a/viewer-frontend/.npmrc b/viewer-frontend/.npmrc new file mode 100644 index 0000000..6eccebb --- /dev/null +++ b/viewer-frontend/.npmrc @@ -0,0 +1 @@ +# Ensure npm doesn't treat this as a workspace diff --git a/viewer-frontend/EMAIL_VERIFICATION_SETUP.md b/viewer-frontend/EMAIL_VERIFICATION_SETUP.md new file mode 100644 index 0000000..0e691a7 --- /dev/null +++ b/viewer-frontend/EMAIL_VERIFICATION_SETUP.md @@ -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. + + + + + + + diff --git a/viewer-frontend/FACE_TOOLTIP_ANALYSIS.md b/viewer-frontend/FACE_TOOLTIP_ANALYSIS.md new file mode 100644 index 0000000..609d108 --- /dev/null +++ b/viewer-frontend/FACE_TOOLTIP_ANALYSIS.md @@ -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) => { + // ... + 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 + diff --git a/viewer-frontend/GRANT_PERMISSIONS.md b/viewer-frontend/GRANT_PERMISSIONS.md new file mode 100644 index 0000000..badf62f --- /dev/null +++ b/viewer-frontend/GRANT_PERMISSIONS.md @@ -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`) + + + + + + + + diff --git a/viewer-frontend/README.md b/viewer-frontend/README.md new file mode 100644 index 0000000..f72ddbd --- /dev/null +++ b/viewer-frontend/README.md @@ -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 diff --git a/viewer-frontend/SETUP.md b/viewer-frontend/SETUP.md new file mode 100644 index 0000000..e5e623c --- /dev/null +++ b/viewer-frontend/SETUP.md @@ -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!** ๐Ÿš€ + + + + + diff --git a/viewer-frontend/SETUP_AUTH.md b/viewer-frontend/SETUP_AUTH.md new file mode 100644 index 0000000..05fa877 --- /dev/null +++ b/viewer-frontend/SETUP_AUTH.md @@ -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 + + + diff --git a/viewer-frontend/SETUP_AUTH_DATABASE.md b/viewer-frontend/SETUP_AUTH_DATABASE.md new file mode 100644 index 0000000..34670aa --- /dev/null +++ b/viewer-frontend/SETUP_AUTH_DATABASE.md @@ -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 + diff --git a/viewer-frontend/SETUP_INSTRUCTIONS.md b/viewer-frontend/SETUP_INSTRUCTIONS.md new file mode 100644 index 0000000..24abed4 --- /dev/null +++ b/viewer-frontend/SETUP_INSTRUCTIONS.md @@ -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. + + diff --git a/viewer-frontend/STOP_OLD_SERVER.md b/viewer-frontend/STOP_OLD_SERVER.md new file mode 100644 index 0000000..5544110 --- /dev/null +++ b/viewer-frontend/STOP_OLD_SERVER.md @@ -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 +``` + +### 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 + ``` + + + + + diff --git a/viewer-frontend/app/HomePageContent.tsx b/viewer-frontend/app/HomePageContent.tsx new file mode 100644 index 0000000..3a19e3b --- /dev/null +++ b/viewer-frontend/app/HomePageContent.tsx @@ -0,0 +1,1100 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { useSession } from 'next-auth/react'; +import { Person, Tag, Photo } from '@prisma/client'; +import { CollapsibleSearch } from '@/components/search/CollapsibleSearch'; +import { PhotoGrid } from '@/components/PhotoGrid'; +import { PhotoViewerClient } from '@/components/PhotoViewerClient'; +import { SearchFilters } from '@/components/search/FilterPanel'; +import { Loader2, CheckSquare, Square } from 'lucide-react'; +import { TagSelectionDialog } from '@/components/TagSelectionDialog'; +import { PageHeader } from '@/components/PageHeader'; +import JSZip from 'jszip'; + +interface HomePageContentProps { + initialPhotos: Photo[]; + people: Person[]; + tags: Tag[]; +} + +export function HomePageContent({ initialPhotos, people, tags }: HomePageContentProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const { data: session } = useSession(); + const isLoggedIn = Boolean(session); + + // Initialize filters from URL params to persist state across navigation + const [filters, setFilters] = useState(() => { + const peopleParam = searchParams.get('people'); + const tagsParam = searchParams.get('tags'); + const dateFromParam = searchParams.get('dateFrom'); + const dateToParam = searchParams.get('dateTo'); + const peopleModeParam = searchParams.get('peopleMode'); + const tagsModeParam = searchParams.get('tagsMode'); + 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, + peopleMode: peopleModeParam === 'all' ? 'all' : 'any', + tagsMode: tagsModeParam === 'all' ? 'all' : 'any', + mediaType: (mediaTypeParam as 'all' | 'photos' | 'videos') || 'all', + favoritesOnly: favoritesOnlyParam === 'true', + }; + }); + + // Check if we have active filters from URL on initial load + const hasInitialFilters = Boolean( + filters.people.length > 0 || + filters.tags.length > 0 || + filters.dateFrom || + filters.dateTo || + (filters.mediaType && filters.mediaType !== 'all') || + filters.favoritesOnly === true + ); + + // Only use initialPhotos if there are no filters from URL + const [photos, setPhotos] = useState(hasInitialFilters ? [] : initialPhotos); + const [loading, setLoading] = useState(hasInitialFilters); // Start loading if filters are active + const [loadingMore, setLoadingMore] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(initialPhotos.length === 30); + const observerTarget = useRef(null); + const pageRef = useRef(1); + const isLoadingRef = useRef(false); + const scrollRestoredRef = useRef(false); + const isInitialMount = useRef(true); + const photosInitializedRef = useRef(false); + const isClosingModalRef = useRef(false); + + // Modal state - read photo query param + const photoParam = searchParams.get('photo'); + const photosParam = searchParams.get('photos'); + const indexParam = searchParams.get('index'); + const autoplayParam = searchParams.get('autoplay') === 'true'; + const [modalPhoto, setModalPhoto] = useState(null); + const [modalPhotos, setModalPhotos] = useState([]); + const [modalIndex, setModalIndex] = useState(0); + const [modalLoading, setModalLoading] = useState(false); + const [selectionMode, setSelectionMode] = useState(false); + const [selectedPhotoIds, setSelectedPhotoIds] = useState([]); + const [isPreparingDownload, setIsPreparingDownload] = useState(false); + const [tagDialogOpen, setTagDialogOpen] = useState(false); + const [isBulkFavoriting, setIsBulkFavoriting] = useState(false); + const [refreshFavoritesKey, setRefreshFavoritesKey] = useState(0); + + const hasActiveFilters = + filters.people.length > 0 || + filters.tags.length > 0 || + filters.dateFrom || + filters.dateTo || + (filters.mediaType && filters.mediaType !== 'all') || + filters.favoritesOnly === true; + + // Update URL when filters change (without page reload) + // Skip on initial mount since filters are already initialized from URL + useEffect(() => { + // Skip URL update on initial mount + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + + // Skip if we're closing the modal (to prevent reload) + if (isClosingModalRef.current) { + return; + } + + const params = new URLSearchParams(); + + if (filters.people.length > 0) { + params.set('people', filters.people.join(',')); + if (filters.peopleMode && filters.peopleMode !== 'any') { + params.set('peopleMode', filters.peopleMode); + } + } + if (filters.tags.length > 0) { + params.set('tags', filters.tags.join(',')); + if (filters.tagsMode && filters.tagsMode !== 'any') { + 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'); + } + + const newUrl = params.toString() ? `/?${params.toString()}` : '/'; + router.replace(newUrl, { scroll: false }); + + // Clear saved scroll position when filters change (user is starting a new search) + sessionStorage.removeItem('homePageScrollY'); + scrollRestoredRef.current = false; + photosInitializedRef.current = false; // Reset photos initialization flag + }, [filters, router]); + + // Restore scroll position when returning from photo viewer + // Wait for photos to be loaded and rendered before restoring scroll to prevent flash + useEffect(() => { + if (scrollRestoredRef.current) return; + + const scrollY = sessionStorage.getItem('homePageScrollY'); + if (!scrollY) { + scrollRestoredRef.current = true; + return; + } + + // Wait for loading to complete + if (loading) return; + + // Wait for photos to be initialized (either from initial state or fetched) + // This prevents flash by ensuring we only restore after everything is stable + if (!photosInitializedRef.current && photos.length === 0) { + // Photos not ready yet, wait + return; + } + + photosInitializedRef.current = true; + + // Restore scroll after DOM is fully rendered + // Use multiple animation frames to ensure all images are laid out + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!scrollRestoredRef.current) { + window.scrollTo({ top: parseInt(scrollY, 10), behavior: 'instant' }); + scrollRestoredRef.current = true; + } + }); + }); + }, [loading, photos.length]); + + // Save scroll position before navigating away + useEffect(() => { + const handleScroll = () => { + sessionStorage.setItem('homePageScrollY', window.scrollY.toString()); + }; + + // Throttle scroll events + let timeoutId: NodeJS.Timeout; + const throttledScroll = () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(handleScroll, 100); + }; + + window.addEventListener('scroll', throttledScroll, { passive: true }); + return () => { + window.removeEventListener('scroll', throttledScroll); + clearTimeout(timeoutId); + }; + }, []); + + // Handle photo modal - use existing photos data, only fetch if not available + useEffect(() => { + // Skip if we're intentionally closing the modal + if (isClosingModalRef.current) { + isClosingModalRef.current = false; + return; + } + + if (!photoParam) { + setModalPhoto(null); + setModalPhotos([]); + return; + } + + const photoId = parseInt(photoParam, 10); + if (isNaN(photoId)) return; + + // If we already have this photo in modalPhotos, just update the index - no fetch needed! + if (modalPhotos.length > 0) { + const existingModalPhoto = modalPhotos.find((p) => p.id === photoId); + if (existingModalPhoto) { + console.log('[HomePageContent] Using existing modal photo:', { + photoId: existingModalPhoto.id, + hasFaces: !!existingModalPhoto.faces, + hasFace: !!existingModalPhoto.Face, + facesCount: existingModalPhoto.faces?.length || existingModalPhoto.Face?.length || 0, + photoKeys: Object.keys(existingModalPhoto), + }); + // Photo is already in modal list, just update index - instant, no API calls! + const photoIds = photosParam ? photosParam.split(',').map(Number).filter(Boolean) : []; + const parsedIndex = indexParam ? parseInt(indexParam, 10) : 0; + if (photoIds.length > 0 && !isNaN(parsedIndex) && parsedIndex >= 0 && parsedIndex < modalPhotos.length) { + setModalIndex(parsedIndex); + setModalPhoto(existingModalPhoto); + return; // Skip all fetching! + } + } + } + + // First, try to find the photo in the already-loaded photos + const existingPhoto = photos.find((p) => p.id === photoId); + + if (existingPhoto) { + // Photo is already loaded, use it directly - no database access! + console.log('[HomePageContent] Using existing photo from photos array:', { + photoId: existingPhoto.id, + hasFaces: !!existingPhoto.faces, + hasFace: !!(existingPhoto as any).Face, + facesCount: existingPhoto.faces?.length || (existingPhoto as any).Face?.length || 0, + photoKeys: Object.keys(existingPhoto), + }); + setModalPhoto(existingPhoto); + + // If we have a photo list context, use existing photos + if (photosParam && indexParam) { + const photoIds = photosParam.split(',').map(Number).filter(Boolean); + const parsedIndex = parseInt(indexParam, 10); + + if (photoIds.length > 0 && !isNaN(parsedIndex)) { + // Check if modalPhotos already has all these photos in the right order + const currentPhotoIds = modalPhotos.map((p) => p.id); + const needsRebuild = + currentPhotoIds.length !== photoIds.length || + currentPhotoIds.some((id, idx) => id !== photoIds[idx]); + + if (needsRebuild) { + // Build photo list from existing photos - no API calls! + const photoMap = new Map(photos.map((p) => [p.id, p])); + const orderedPhotos = photoIds + .map((id) => photoMap.get(id)) + .filter(Boolean) as typeof photos; + + setModalPhotos(orderedPhotos); + } + setModalIndex(parsedIndex); + } else { + if (modalPhotos.length !== 1 || modalPhotos[0]?.id !== existingPhoto.id) { + setModalPhotos([existingPhoto]); + } + setModalIndex(0); + } + } else { + if (modalPhotos.length !== 1 || modalPhotos[0]?.id !== existingPhoto.id) { + setModalPhotos([existingPhoto]); + } + setModalIndex(0); + } + setModalLoading(false); + return; + } + + // Photo not in loaded list, need to fetch it (should be rare) + const fetchPhotoData = async () => { + setModalLoading(true); + try { + const photoResponse = await fetch(`/api/photos/${photoId}`); + if (!photoResponse.ok) throw new Error('Failed to fetch photo'); + const photoData = await photoResponse.json(); + + // Serialize the photo (handle Decimal fields) + console.log('[HomePageContent] Photo data from API:', { + photoId: photoData.id, + hasFaces: !!photoData.faces, + facesCount: photoData.faces?.length || 0, + facesRaw: photoData.faces, + photoDataKeys: Object.keys(photoData), + }); + + const serializedPhoto = { + ...photoData, + faces: photoData.faces?.map((face: any) => ({ + ...face, + confidence: face.confidence ? Number(face.confidence) : 0, + qualityScore: face.qualityScore ? Number(face.qualityScore) : 0, + faceConfidence: face.faceConfidence ? Number(face.faceConfidence) : 0, + yawAngle: face.yawAngle ? Number(face.yawAngle) : null, + pitchAngle: face.pitchAngle ? Number(face.pitchAngle) : null, + rollAngle: face.rollAngle ? Number(face.rollAngle) : null, + })), + }; + + console.log('[HomePageContent] Serialized photo:', { + photoId: serializedPhoto.id, + hasFaces: !!serializedPhoto.faces, + facesCount: serializedPhoto.faces?.length || 0, + faces: serializedPhoto.faces, + }); + + setModalPhoto(serializedPhoto); + + // For navigation, try to use existing photos first, then fetch missing ones + if (photosParam && indexParam) { + const photoIds = photosParam.split(',').map(Number).filter(Boolean); + const parsedIndex = parseInt(indexParam, 10); + + if (photoIds.length > 0 && !isNaN(parsedIndex)) { + const photoMap = new Map(photos.map((p) => [p.id, p])); + const missingIds = photoIds.filter((id) => !photoMap.has(id)); + + // Fetch only missing photos + let fetchedPhotos: any[] = []; + if (missingIds.length > 0) { + const photoPromises = missingIds.map((id) => + fetch(`/api/photos/${id}`).then((res) => res.json()) + ); + const fetchedData = await Promise.all(photoPromises); + fetchedPhotos = fetchedData.map((p: any) => ({ + ...p, + faces: p.faces?.map((face: any) => ({ + ...face, + confidence: face.confidence ? Number(face.confidence) : 0, + qualityScore: face.qualityScore ? Number(face.qualityScore) : 0, + faceConfidence: face.faceConfidence ? Number(face.faceConfidence) : 0, + yawAngle: face.yawAngle ? Number(face.yawAngle) : null, + pitchAngle: face.pitchAngle ? Number(face.pitchAngle) : null, + rollAngle: face.rollAngle ? Number(face.rollAngle) : null, + })), + })); + } + + // Combine existing and fetched photos + fetchedPhotos.forEach((p) => photoMap.set(p.id, p)); + // Include all photos (videos and images) for navigation + const orderedPhotos = photoIds + .map((id) => photoMap.get(id)) + .filter(Boolean) as typeof photos; + + setModalPhotos(orderedPhotos); + // Use the original index (videos are included in navigation) + setModalIndex(Math.min(parsedIndex, orderedPhotos.length - 1)); + } else { + setModalPhotos([serializedPhoto]); + setModalIndex(0); + } + } else { + setModalPhotos([serializedPhoto]); + setModalIndex(0); + } + } catch (error) { + console.error('Error fetching photo data:', error); + } finally { + setModalLoading(false); + } + }; + + fetchPhotoData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [photoParam, photosParam, indexParam]); // Only depend on URL params - photos is accessed but we check modalPhotos first + + // Handle starting slideshow + const handleStartSlideshow = () => { + if (photos.length === 0) return; + + // Filter out videos from slideshow (only show images) + const imagePhotos = photos.filter((p) => p.media_type !== 'video'); + if (imagePhotos.length === 0) return; + + // Set first image as modal photo + setModalPhoto(imagePhotos[0]); + setModalPhotos(imagePhotos); + setModalIndex(0); + + // Update URL to open first photo with autoPlay + const params = new URLSearchParams(window.location.search); + params.set('photo', imagePhotos[0].id.toString()); + params.set('photos', imagePhotos.map((p) => p.id).join(',')); + params.set('index', '0'); + params.set('autoplay', 'true'); + router.replace(`/?${params.toString()}`, { scroll: false }); + }; + + const handleToggleSelectionMode = () => { + setSelectionMode((prev) => { + if (prev) { + setSelectedPhotoIds([]); + } + return !prev; + }); + }; + + const handleTogglePhotoSelection = (photoId: number) => { + setSelectedPhotoIds((prev) => { + if (prev.includes(photoId)) { + return prev.filter((id) => id !== photoId); + } + return [...prev, photoId]; + }); + }; + + const handleSelectAll = () => { + const allPhotoIds = photos.map((photo) => photo.id); + setSelectedPhotoIds(allPhotoIds); + }; + + const handleClearAll = () => { + setSelectedPhotoIds([]); + }; + + const getPhotoFilename = (photo: Photo) => { + if (photo.filename?.trim()) { + return photo.filename.trim(); + } + const path = photo.path || ''; + if (path) { + const segments = path.split(/[/\\]/); + const lastSegment = segments.pop(); + if (lastSegment) { + return lastSegment; + } + } + return `photo-${photo.id}.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}` : ''}`; + }; + + const downloadPhotosAsZip = async ( + photoIds: number[], + photoMap: Map + ) => { + const zip = new JSZip(); + let filesAdded = 0; + + for (const photoId of photoIds) { + const photo = photoMap.get(photoId); + if (!photo) continue; + + const response = await fetch( + getPhotoDownloadUrl(photo, { + forceProxy: true, + watermark: !isLoggedIn, + }) + ); + if (!response.ok) { + throw new Error(`Failed to download photo ${photoId}`); + } + + const blob = await response.blob(); + zip.file(getPhotoFilename(photo), blob); + filesAdded += 1; + } + + if (filesAdded === 0) { + throw new Error('No photos available to download.'); + } + + const zipBlob = await zip.generateAsync({ type: 'blob' }); + const url = URL.createObjectURL(zipBlob); + const link = document.createElement('a'); + link.href = url; + link.download = `photos-${new Date().toISOString().split('T')[0]}.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const handleDownloadSelected = async () => { + if ( + selectedPhotoIds.length === 0 || + typeof window === 'undefined' || + isPreparingDownload + ) { + return; + } + + const photoMap = new Map(photos.map((photo) => [photo.id, photo])); + + if (selectedPhotoIds.length === 1) { + const photo = photoMap.get(selectedPhotoIds[0]); + if (!photo) { + return; + } + 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); + return; + } + + try { + setIsPreparingDownload(true); + await downloadPhotosAsZip(selectedPhotoIds, photoMap); + } catch (error) { + console.error('Error downloading selected photos:', error); + alert('Failed to download selected photos. Please try again.'); + } finally { + setIsPreparingDownload(false); + } + }; + + const handleTagSelected = () => { + if (selectedPhotoIds.length === 0) { + return; + } + setTagDialogOpen(true); + }; + + const handleBulkFavorite = async () => { + if (selectedPhotoIds.length === 0 || !isLoggedIn || isBulkFavoriting) { + return; + } + + setIsBulkFavoriting(true); + + try { + const response = await fetch('/api/photos/favorites/bulk', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + photoIds: selectedPhotoIds, + action: 'add', // Always add to favorites (skip if already favorited) + }), + }); + + if (!response.ok) { + const error = await response.json(); + if (response.status === 401) { + alert('Please sign in to favorite photos'); + } else { + alert(error.error || 'Failed to update favorites'); + } + return; + } + + const data = await response.json(); + + // Trigger PhotoGrid to refetch favorite statuses + setRefreshFavoritesKey(prev => prev + 1); + } catch (error) { + console.error('Error bulk favoriting photos:', error); + alert('Failed to update favorites. Please try again.'); + } finally { + setIsBulkFavoriting(false); + } + }; + + useEffect(() => { + if (selectedPhotoIds.length === 0) { + return; + } + const availableIds = new Set(photos.map((photo) => photo.id)); + setSelectedPhotoIds((prev) => { + const filtered = prev.filter((id) => availableIds.has(id)); + return filtered.length === prev.length ? prev : filtered; + }); + }, [photos, selectedPhotoIds.length]); + + useEffect(() => { + if (tagDialogOpen && selectedPhotoIds.length === 0) { + setTagDialogOpen(false); + } + }, [tagDialogOpen, selectedPhotoIds.length]); + + // Handle closing the modal + const handleCloseModal = () => { + // Set flag to prevent useEffect from running + isClosingModalRef.current = true; + + // Clear modal state immediately (no reload, instant close) + setModalPhoto(null); + setModalPhotos([]); + setModalIndex(0); + + // Update URL directly using history API to avoid triggering Next.js router effects + // This prevents any reload or re-fetch when closing + const params = new URLSearchParams(window.location.search); + params.delete('photo'); + params.delete('photos'); + params.delete('index'); + params.delete('autoplay'); + + const newUrl = params.toString() ? `/?${params.toString()}` : '/'; + // Use window.history directly to avoid Next.js router processing + window.history.replaceState( + { ...window.history.state, as: newUrl, url: newUrl }, + '', + newUrl + ); + + // Reset flag after a short delay to allow effects to see it + setTimeout(() => { + isClosingModalRef.current = false; + }, 100); + }; + + // Fetch photos when filters change (reset to page 1) + useEffect(() => { + // If no filters, use initial photos and fetch total count + if (!hasActiveFilters) { + // Only update photos if they're different to prevent unnecessary re-renders + const photosChanged = photos.length !== initialPhotos.length || + photos.some((p, i) => p.id !== initialPhotos[i]?.id); + + if (photosChanged) { + setPhotos(initialPhotos); + photosInitializedRef.current = true; + } else if (photos.length > 0) { + // Photos are already set correctly + photosInitializedRef.current = true; + } + + setPage(1); + pageRef.current = 1; + isLoadingRef.current = false; + // Fetch total count for display (use search API with no filters) + fetch('/api/search?page=1&pageSize=1') + .then((res) => res.json()) + .then((data) => { + setTotal(data.total); + setHasMore(initialPhotos.length < data.total); + }) + .catch(() => { + setTotal(initialPhotos.length); + setHasMore(false); + }); + return; + } + + 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', '1'); + 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); + photosInitializedRef.current = true; + setTotal(data.total); + setHasMore(data.photos.length < data.total); + setPage(1); + pageRef.current = 1; + isLoadingRef.current = false; + } catch (error) { + console.error('Error searching photos:', error); + } finally { + setLoading(false); + } + }; + + fetchPhotos(); + }, [filters, hasActiveFilters, initialPhotos]); + + // Infinite scroll observer + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + // Don't load if we've already loaded all photos + if (photos.length >= total && total > 0) { + setHasMore(false); + return; + } + + if (entries[0].isIntersecting && hasMore && !loadingMore && !loading && photos.length < total && !isLoadingRef.current) { + const fetchMore = async () => { + if (isLoadingRef.current) { + console.log('Already loading, skipping observer trigger'); + return; + } + isLoadingRef.current = true; + setLoadingMore(true); + const nextPage = pageRef.current + 1; + pageRef.current = nextPage; + + console.log('Observer triggered - loading page', nextPage, { currentPhotos: photos.length, total }); + + 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', nextPage.toString()); + params.set('pageSize', '30'); + + const response = await fetch(`/api/search?${params.toString()}`); + if (!response.ok) throw new Error('Failed to load more photos'); + + const data = await response.json(); + + // If we got 0 photos, we've reached the end + if (data.photos.length === 0) { + console.log('Got 0 photos, reached the end. Setting hasMore to false'); + setHasMore(false); + return; + } + + setPhotos((prev) => { + // Filter out duplicates by photo ID + const existingIds = new Set(prev.map((p) => p.id)); + const uniqueNewPhotos = data.photos.filter((p: Photo) => !existingIds.has(p.id)); + const newPhotos = [...prev, ...uniqueNewPhotos]; + + // Stop loading if we've loaded all photos or got no new photos + const hasMorePhotos = newPhotos.length < data.total && + uniqueNewPhotos.length > 0; + + console.log('Loading page', nextPage, { + prevCount: prev.length, + newCount: data.photos.length, + uniqueNew: uniqueNewPhotos.length, + totalNow: newPhotos.length, + totalExpected: data.total, + hasMore: hasMorePhotos, + loadedAll: newPhotos.length >= data.total + }); + + // Always set hasMore to false if we've loaded all photos or got no new unique photos + if (newPhotos.length >= data.total || uniqueNewPhotos.length === 0) { + console.log('All photos loaded or no new photos! Setting hasMore to false', { + newPhotos: newPhotos.length, + total: data.total, + uniqueNew: uniqueNewPhotos.length + }); + setHasMore(false); + } else { + setHasMore(hasMorePhotos); + } + + return newPhotos; + }); + setPage(nextPage); + } catch (error) { + console.error('Error loading more photos:', error); + setHasMore(false); // Stop on error + } finally { + setLoadingMore(false); + isLoadingRef.current = false; + } + }; + + fetchMore(); + } else { + // If we have all photos, make sure hasMore is false + if (photos.length >= total && total > 0) { + console.log('Observer: Already have all photos, setting hasMore to false', { photos: photos.length, total }); + setHasMore(false); + } + } + }, + { threshold: 0.1 } + ); + + const currentTarget = observerTarget.current; + if (currentTarget) { + observer.observe(currentTarget); + } + + return () => { + if (currentTarget) { + observer.unobserve(currentTarget); + } + }; + }, [hasMore, loadingMore, loading, filters, photos.length, total]); + + // Ensure we load the last page when we're close to the end + useEffect(() => { + // Don't run if we've already loaded all photos + if (photos.length >= total && total > 0) { + if (hasMore) { + console.log('All photos loaded, setting hasMore to false', { photos: photos.length, total }); + setHasMore(false); + } + return; + } + + // If we're very close to the end (1-5 photos remaining), load immediately + const remaining = total - photos.length; + if (remaining > 0 && remaining <= 5 && !loadingMore && !loading && !isLoadingRef.current && hasMore) { + console.log('Very close to end, loading remaining photos immediately', { remaining, photos: photos.length, total }); + + const fetchRemaining = async () => { + isLoadingRef.current = true; + setLoadingMore(true); + const nextPage = pageRef.current + 1; + pageRef.current = nextPage; + + 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', nextPage.toString()); + params.set('pageSize', '30'); + + const response = await fetch(`/api/search?${params.toString()}`); + if (!response.ok) throw new Error('Failed to load more photos'); + + const data = await response.json(); + + if (data.photos.length === 0) { + console.log('Got 0 photos, reached the end'); + setHasMore(false); + return; + } + + setPhotos((prev) => { + const existingIds = new Set(prev.map((p) => p.id)); + const uniqueNewPhotos = data.photos.filter((p: Photo) => !existingIds.has(p.id)); + const newPhotos = [...prev, ...uniqueNewPhotos]; + + const allLoaded = newPhotos.length >= data.total; + const noNewPhotos = uniqueNewPhotos.length === 0; + + console.log('Loaded remaining photos:', { + prevCount: prev.length, + newCount: data.photos.length, + uniqueNew: uniqueNewPhotos.length, + totalNow: newPhotos.length, + totalExpected: data.total, + allLoaded, + noNewPhotos + }); + + if (allLoaded || noNewPhotos) { + console.log('All photos loaded or no new photos, stopping'); + setHasMore(false); + } else { + setHasMore(newPhotos.length < data.total && uniqueNewPhotos.length > 0); + } + + return newPhotos; + }); + setPage(nextPage); + } catch (error) { + console.error('Error loading remaining photos:', error); + setHasMore(false); + } finally { + setLoadingMore(false); + isLoadingRef.current = false; + } + }; + + // Small delay to avoid race conditions + const timeoutId = setTimeout(() => { + if (!isLoadingRef.current && !loadingMore && !loading && photos.length < total) { + fetchRemaining(); + } + }, 50); + + return () => clearTimeout(timeoutId); + } + }, [photos.length, total, hasMore, loadingMore, loading, filters]); + + return ( + <> + +
+ +
+ {loading ? ( +
+ +
+ ) : ( + <> +
+
+
+ {hasActiveFilters ? ( + `Found ${total} photo${total !== 1 ? 's' : ''} - Showing ${photos.length}` + ) : ( + total > 0 ? ( + `Showing ${photos.length} of ${total} photo${total !== 1 ? 's' : ''}` + ) : ( + `Showing ${photos.length} photo${photos.length !== 1 ? 's' : ''}` + ) + )} +
+ {selectionMode && ( +
+ + +
+ )} +
+
+ + + {/* Infinite scroll sentinel */} +
+ {loadingMore && ( + + )} +
+ + {!hasMore && photos.length > 0 && ( +
+ No more photos to load +
+ )} + + )} +
+
+ + {/* Photo Modal Overlay */} + {photoParam && modalPhoto && !modalLoading && ( + + )} + + {photoParam && modalLoading && ( +
+ +
+ )} + + { + setSelectionMode(false); + setSelectedPhotoIds([]); + }} + /> + + ); +} + diff --git a/viewer-frontend/app/admin/users/ManageUsersContent.tsx b/viewer-frontend/app/admin/users/ManageUsersContent.tsx new file mode 100644 index 0000000..2159a79 --- /dev/null +++ b/viewer-frontend/app/admin/users/ManageUsersContent.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [statusFilter, setStatusFilter] = useState('active'); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [userToDelete, setUserToDelete] = useState(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 ( +
+
Loading users...
+
+ ); + } + + return ( +
+
+
+

Manage Users

+

+ Manage user accounts and permissions +

+
+
+
+ + +
+ +
+
+ + {error && ( +
+ {error} +
+ )} + + {successMessage && ( +
+ {successMessage} +
+ )} + +
+
+ + + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + + ))} + +
EmailNameStatusRoleWrite AccessCreatedActions
{user.email}{user.name || '-'} + {user.isActive === false ? ( + Inactive + ) : ( + Active + )} + + {user.isAdmin ? ( + Admin + ) : ( + User + )} + + + {user.hasWriteAccess ? 'Yes' : 'No'} + + + {new Date(user.createdAt).toLocaleDateString()} + +
+ + +
+
+
+
+ + {/* Add User Dialog */} + + + + Add New User + + Create a new user account. Write access can be granted later. + + +
+
+ + + setFormData({ ...formData, email: e.target.value }) + } + placeholder="user@example.com" + /> +
+
+ + + setFormData({ ...formData, password: e.target.value }) + } + placeholder="Minimum 6 characters" + /> +
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + placeholder="Enter full name" + required + /> +
+
+ + +
+
+ + setFormData({ ...formData, hasWriteAccess: !!checked }) + } + /> + +
+
+ + + + +
+
+ + {/* Edit User Dialog */} + + + + Edit User + + Update user information. Leave password blank to keep current password. + + +
+
+ + + setFormData({ ...formData, email: e.target.value }) + } + placeholder="user@example.com" + /> +
+
+ + + setFormData({ ...formData, password: e.target.value }) + } + placeholder="Leave blank to keep current password" + /> +
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + placeholder="Enter full name" + required + /> +
+
+ + +
+
+ + setFormData({ ...formData, hasWriteAccess: !!checked }) + } + /> + +
+
+ + setFormData({ ...formData, isActive: !!checked }) + } + /> + +
+
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + + + + Delete User + + Are you sure you want to delete {userToDelete?.email}? This action + cannot be undone. + + + + + + + + +
+ ); +} + diff --git a/viewer-frontend/app/admin/users/ManageUsersPageClient.tsx b/viewer-frontend/app/admin/users/ManageUsersPageClient.tsx new file mode 100644 index 0000000..ebe2260 --- /dev/null +++ b/viewer-frontend/app/admin/users/ManageUsersPageClient.tsx @@ -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 = ( +
+
+ {/* Close button */} +
+ +
+ + {/* Header */} +
+
+ + PunimTag + +
+ +
+
+

+ Browse our photo collection +

+
+ + {/* Manage Users content */} +
+ +
+
+
+ ); + + // Render in portal to ensure it's above everything + if (typeof window === 'undefined') { + return null; + } + + return createPortal(overlayContent, document.body); +} + diff --git a/viewer-frontend/app/admin/users/page.tsx b/viewer-frontend/app/admin/users/page.tsx new file mode 100644 index 0000000..7bfc57c --- /dev/null +++ b/viewer-frontend/app/admin/users/page.tsx @@ -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 ; +} + diff --git a/viewer-frontend/app/api/auth/[...nextauth]/route.ts b/viewer-frontend/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..b0a3fbe --- /dev/null +++ b/viewer-frontend/app/api/auth/[...nextauth]/route.ts @@ -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; + diff --git a/viewer-frontend/app/api/auth/check-verification/route.ts b/viewer-frontend/app/api/auth/check-verification/route.ts new file mode 100644 index 0000000..22dc432 --- /dev/null +++ b/viewer-frontend/app/api/auth/check-verification/route.ts @@ -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 } + ); + } +} + + diff --git a/viewer-frontend/app/api/auth/forgot-password/route.ts b/viewer-frontend/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..bb8d0f3 --- /dev/null +++ b/viewer-frontend/app/api/auth/forgot-password/route.ts @@ -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 } + ); + } +} + diff --git a/viewer-frontend/app/api/auth/register/route.ts b/viewer-frontend/app/api/auth/register/route.ts new file mode 100644 index 0000000..eec521c --- /dev/null +++ b/viewer-frontend/app/api/auth/register/route.ts @@ -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 } + ); + } +} + diff --git a/viewer-frontend/app/api/auth/resend-confirmation/route.ts b/viewer-frontend/app/api/auth/resend-confirmation/route.ts new file mode 100644 index 0000000..19cc682 --- /dev/null +++ b/viewer-frontend/app/api/auth/resend-confirmation/route.ts @@ -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 } + ); + } +} + + + + + + + diff --git a/viewer-frontend/app/api/auth/reset-password/route.ts b/viewer-frontend/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..1c572fb --- /dev/null +++ b/viewer-frontend/app/api/auth/reset-password/route.ts @@ -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 } + ); + } +} + + + + + + diff --git a/viewer-frontend/app/api/auth/verify-email/route.ts b/viewer-frontend/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000..b4e17ae --- /dev/null +++ b/viewer-frontend/app/api/auth/verify-email/route.ts @@ -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) + ); + } +} + + + + + + + diff --git a/viewer-frontend/app/api/debug-session/route.ts b/viewer-frontend/app/api/debug-session/route.ts new file mode 100644 index 0000000..f5449e3 --- /dev/null +++ b/viewer-frontend/app/api/debug-session/route.ts @@ -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 } + ); + } +} + diff --git a/viewer-frontend/app/api/faces/[id]/identify/route.ts b/viewer-frontend/app/api/faces/[id]/identify/route.ts new file mode 100644 index 0000000..f5b2d1c --- /dev/null +++ b/viewer-frontend/app/api/faces/[id]/identify/route.ts @@ -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 } + ); + } +} + diff --git a/viewer-frontend/app/api/health/route.ts b/viewer-frontend/app/api/health/route.ts new file mode 100644 index 0000000..c310cd5 --- /dev/null +++ b/viewer-frontend/app/api/health/route.ts @@ -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 = {}; + + // 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 } + ); +} + + + + + + + + diff --git a/viewer-frontend/app/api/people/route.ts b/viewer-frontend/app/api/people/route.ts new file mode 100644 index 0000000..ad4f9e1 --- /dev/null +++ b/viewer-frontend/app/api/people/route.ts @@ -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 } + ); + } +} + diff --git a/viewer-frontend/app/api/search/route.ts b/viewer-frontend/app/api/search/route.ts new file mode 100644 index 0000000..aa52627 --- /dev/null +++ b/viewer-frontend/app/api/search/route.ts @@ -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>( + `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>( + `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 } + ); + } +} + diff --git a/viewer-frontend/app/api/users/[id]/route.ts b/viewer-frontend/app/api/users/[id]/route.ts new file mode 100644 index 0000000..68025c1 --- /dev/null +++ b/viewer-frontend/app/api/users/[id]/route.ts @@ -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 } + ); + } +} + diff --git a/viewer-frontend/app/api/users/route.ts b/viewer-frontend/app/api/users/route.ts new file mode 100644 index 0000000..f90d85e --- /dev/null +++ b/viewer-frontend/app/api/users/route.ts @@ -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 } + ); + } +} + diff --git a/viewer-frontend/app/favicon.ico b/viewer-frontend/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/viewer-frontend/app/globals.css b/viewer-frontend/app/globals.css new file mode 100644 index 0000000..e709622 --- /dev/null +++ b/viewer-frontend/app/globals.css @@ -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; + } +} diff --git a/viewer-frontend/app/layout.tsx b/viewer-frontend/app/layout.tsx new file mode 100644 index 0000000..952656d --- /dev/null +++ b/viewer-frontend/app/layout.tsx @@ -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 ( + + + + {children} + + + + ); +} diff --git a/viewer-frontend/app/login/page.tsx b/viewer-frontend/app/login/page.tsx new file mode 100644 index 0000000..ec8195c --- /dev/null +++ b/viewer-frontend/app/login/page.tsx @@ -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 ( +
+
+
+

+ Sign in to your account +

+

+ Or{' '} + + create a new account + +

+
+
+ {registered && ( +
+

+ Account created successfully! Please check your email to confirm your account before signing in. +

+
+ )} + {verified && ( +
+

+ Email verified successfully! You can now sign in. +

+
+ )} + {passwordReset && ( +
+

+ Password reset successfully! You can now sign in with your new password. +

+
+ )} + {emailNotVerified && ( +
+

+ Please verify your email address before signing in. Check your inbox for a confirmation email. +

+ +
+ )} + {error && ( +
+

{error}

+
+ )} +
+
+ + { + setEmail(e.target.value); + // Clear email verification error when email changes + if (emailNotVerified) { + setEmailNotVerified(false); + } + }} + className="mt-1" + placeholder="you@example.com" + /> +
+
+ + setPassword(e.target.value)} + className="mt-1" + placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" + /> +
+
+ +
+ +
+
+
+
+ ); +} + diff --git a/viewer-frontend/app/page.tsx b/viewer-frontend/app/page.tsx new file mode 100644 index 0000000..15134ac --- /dev/null +++ b/viewer-frontend/app/page.tsx @@ -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>` + 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 ( +
+ + {error ? ( +
+

Error loading photos

+

{error}

+

+ Make sure DATABASE_URL is configured in your .env file +

+
+ ) : ( + + )} +
+ ); +} diff --git a/viewer-frontend/app/photo/[id]/page.tsx b/viewer-frontend/app/photo/[id]/page.tsx new file mode 100644 index 0000000..1fffb17 --- /dev/null +++ b/viewer-frontend/app/photo/[id]/page.tsx @@ -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 ( + + ); +} + diff --git a/viewer-frontend/app/register/page.tsx b/viewer-frontend/app/register/page.tsx new file mode 100644 index 0000000..8f64cd9 --- /dev/null +++ b/viewer-frontend/app/register/page.tsx @@ -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 ( +
+
+
+

+ Create your account +

+

+ Or{' '} + + sign in to your existing account + +

+
+
+ {error && ( +
+

{error}

+
+ )} +
+
+ + setName(e.target.value)} + className="mt-1" + placeholder="Your full name" + /> +
+
+ + setEmail(e.target.value)} + className="mt-1" + placeholder="you@example.com" + /> +
+
+ + setPassword(e.target.value)} + className="mt-1" + placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" + /> +

+ Must be at least 6 characters +

+
+
+ + setConfirmPassword(e.target.value)} + className="mt-1" + placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" + /> +
+
+ +
+ +
+
+
+
+ ); +} + + + diff --git a/viewer-frontend/app/reset-password/page.tsx b/viewer-frontend/app/reset-password/page.tsx new file mode 100644 index 0000000..dccaeaf --- /dev/null +++ b/viewer-frontend/app/reset-password/page.tsx @@ -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 ( +
+
+
+

+ Password reset successful +

+

+ Your password has been reset successfully. Redirecting to login... +

+
+
+

+ You can now sign in with your new password. +

+
+
+ + Go to login page + +
+
+
+ ); + } + + return ( +
+
+
+

+ Reset your password +

+

+ Enter your new password below +

+
+
+ {error && ( +
+

{error}

+
+ )} +
+
+ + setPassword(e.target.value)} + className="mt-1" + placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" + /> +

+ Must be at least 6 characters +

+
+
+ + setConfirmPassword(e.target.value)} + className="mt-1" + placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" + /> +
+
+ +
+ +
+
+ + Back to login + +
+
+
+
+ ); +} + + + + + + diff --git a/viewer-frontend/app/search/SearchContent.tsx b/viewer-frontend/app/search/SearchContent.tsx new file mode 100644 index 0000000..edd02a6 --- /dev/null +++ b/viewer-frontend/app/search/SearchContent.tsx @@ -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(() => { + 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([]); + 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 ( +
+ {/* Filter Panel */} +
+ +
+ + {/* Results */} +
+ {loading ? ( +
+ +
+ ) : ( + <> +
+
+ {total === 0 ? ( + hasActiveFilters ? ( + 'No photos found matching your filters' + ) : ( + 'Start by selecting filters to search photos' + ) + ) : ( + `Found ${total} photo${total !== 1 ? 's' : ''}` + )} +
+
+ + {photos.length > 0 ? ( + <> + + {total > 30 && ( +
+ + + Page {page} of {Math.ceil(total / 30)} + + +
+ )} + + ) : hasActiveFilters ? ( +
+

No photos found matching your filters

+
+ ) : ( +
+

Select filters to search photos

+
+ )} + + )} +
+
+ ); +} + diff --git a/viewer-frontend/app/search/page.tsx b/viewer-frontend/app/search/page.tsx new file mode 100644 index 0000000..93f6347 --- /dev/null +++ b/viewer-frontend/app/search/page.tsx @@ -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 ( +
+
+

+ Search Photos +

+

+ Find photos by people, dates, and tags +

+
+ + +
Loading search...
+ + }> + +
+
+ ); +} + diff --git a/viewer-frontend/app/test-images/page.tsx b/viewer-frontend/app/test-images/page.tsx new file mode 100644 index 0000000..f4a444c --- /dev/null +++ b/viewer-frontend/app/test-images/page.tsx @@ -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 ( +
+
+

+ Image Source Test Page +

+

+ Testing direct URL access vs API proxy +

+
+ +
+

+ Test Instructions: +

+
    +
  1. Open browser DevTools (F12) โ†’ Network tab
  2. +
  3. Filter by "Img" to see image requests
  4. +
  5. + Direct URL images should show requests to{' '} + + picsum.photos + +
  6. +
  7. + File system images should show requests to{' '} + + /api/photos/... + +
  8. +
+
+ +
+

Test Images

+
+

+ Images 1-2: Direct URL access (should load from + picsum.photos) +

+

+ Image 3: File system path (will use API proxy, may + show error if file doesn't exist) +

+
+
+ + + +
+

Path Details:

+
+ {testPhotos.map((photo) => ( +
+
+ ID {photo.id}: {photo.path} +
+
+ Type:{' '} + {photo.path.startsWith('http://') || + photo.path.startsWith('https://') + ? 'โœ… Direct URL' + : '๐Ÿ“ File System (API Proxy)'} +
+
+ ))} +
+
+
+ ); +} + + diff --git a/viewer-frontend/app/upload/UploadContent.tsx b/viewer-frontend/app/upload/UploadContent.tsx new file mode 100644 index 0000000..edc8adb --- /dev/null +++ b/viewer-frontend/app/upload/UploadContent.tsx @@ -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(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 ( +
+ {isVideo ? ( + <> +