-
-
{
- // Fallback if logo.png doesn't exist, try logo.svg
- const target = e.target as HTMLImageElement
- if (target.src.endsWith('logo.png')) {
- target.src = '/logo.svg'
- }
- }}
- />
-
+
+ {isIOSDevice ? (
+
setSidebarOpen(!sidebarOpen)}
+ className="flex items-center justify-center p-2 hover:bg-gray-100 rounded-lg transition-colors"
+ aria-label="Toggle menu"
+ >
+
+ {sidebarOpen ? (
+
+ ) : (
+
+ )}
+
+
+ ) : (
+
+
{
+ // Fallback if logo.png doesn't exist, try logo.svg
+ const target = e.target as HTMLImageElement
+ if (target.src.endsWith('logo.png')) {
+ target.src = '/logo.svg'
+ }
+ }}
+ />
+
+ )}
{/* Header content - aligned with main content */}
-
+
{getPageTitle()}
@@ -140,8 +170,22 @@ export default function Layout() {
+ {/* Overlay for mobile when sidebar is open */}
+ {isIOSDevice && sidebarOpen && (
+
setSidebarOpen(false)}
+ />
+ )}
+
{/* Left sidebar - fixed position */}
-
+
{visiblePrimary.map((item) => renderNavLink(item))}
@@ -172,7 +216,7 @@ export default function Layout() {
{/* Main content - with left margin to account for fixed sidebar */}
-
diff --git a/admin-frontend/src/components/PhotoViewer.tsx b/admin-frontend/src/components/PhotoViewer.tsx
index 1eec0ac..2b0df99 100644
--- a/admin-frontend/src/components/PhotoViewer.tsx
+++ b/admin-frontend/src/components/PhotoViewer.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState, useRef } from 'react'
import { PhotoSearchResult, photosApi } from '../api/photos'
import { apiClient } from '../api/client'
+import videosApi from '../api/videos'
interface PhotoViewerProps {
photos: PhotoSearchResult[]
@@ -36,7 +37,7 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
// Slideshow state
const [isPlaying, setIsPlaying] = useState(false)
const [slideshowInterval, setSlideshowInterval] = useState(3) // seconds
- const slideshowTimerRef = useRef
(null)
+ const slideshowTimerRef = useRef | null>(null)
// Favorite state
const [isFavorite, setIsFavorite] = useState(false)
@@ -46,29 +47,43 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
const canGoPrev = currentIndex > 0
const canGoNext = currentIndex < photos.length - 1
- // Get photo URL
- const getPhotoUrl = (photoId: number) => {
+ // Check if current photo is a video
+ const isVideo = (photo: PhotoSearchResult) => {
+ return photo.media_type === 'video'
+ }
+
+ // Get photo/video URL
+ const getPhotoUrl = (photoId: number, mediaType?: string) => {
+ if (mediaType === 'video') {
+ return videosApi.getVideoUrl(photoId)
+ }
return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
}
- // Preload adjacent images
+ // Preload adjacent images (skip videos)
const preloadAdjacent = (index: number) => {
- // Preload next photo
+ // Preload next photo (only if it's an image)
if (index + 1 < photos.length) {
- const nextPhotoId = photos[index + 1].id
- if (!preloadedImages.current.has(nextPhotoId)) {
- const img = new Image()
- img.src = getPhotoUrl(nextPhotoId)
- preloadedImages.current.add(nextPhotoId)
+ const nextPhoto = photos[index + 1]
+ if (!isVideo(nextPhoto)) {
+ const nextPhotoId = nextPhoto.id
+ if (!preloadedImages.current.has(nextPhotoId)) {
+ const img = new Image()
+ img.src = getPhotoUrl(nextPhotoId, nextPhoto.media_type)
+ preloadedImages.current.add(nextPhotoId)
+ }
}
}
- // Preload previous photo
+ // Preload previous photo (only if it's an image)
if (index - 1 >= 0) {
- const prevPhotoId = photos[index - 1].id
- if (!preloadedImages.current.has(prevPhotoId)) {
- const img = new Image()
- img.src = getPhotoUrl(prevPhotoId)
- preloadedImages.current.add(prevPhotoId)
+ const prevPhoto = photos[index - 1]
+ if (!isVideo(prevPhoto)) {
+ const prevPhotoId = prevPhoto.id
+ if (!preloadedImages.current.has(prevPhotoId)) {
+ const img = new Image()
+ img.src = getPhotoUrl(prevPhotoId, prevPhoto.media_type)
+ preloadedImages.current.add(prevPhotoId)
+ }
}
}
}
@@ -258,7 +273,8 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
return null
}
- const photoUrl = getPhotoUrl(currentPhoto.id)
+ const photoUrl = getPhotoUrl(currentPhoto.id, currentPhoto.media_type)
+ const currentIsVideo = isVideo(currentPhoto)
return (
@@ -330,16 +346,16 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
- {/* Main Image Area */}
+ {/* Main Image/Video Area */}
1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
+ onWheel={currentIsVideo ? undefined : handleWheel}
+ onMouseDown={currentIsVideo ? undefined : handleMouseDown}
+ onMouseMove={currentIsVideo ? undefined : handleMouseMove}
+ onMouseUp={currentIsVideo ? undefined : handleMouseUp}
+ onMouseLeave={currentIsVideo ? undefined : handleMouseUp}
+ style={{ cursor: currentIsVideo ? 'default' : (zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default') }}
>
{imageLoading && (
@@ -348,9 +364,33 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
)}
{imageError ? (
-
Failed to load image
+
Failed to load {currentIsVideo ? 'video' : 'image'}
{currentPhoto.path}
+ ) : currentIsVideo ? (
+
+ {
+ setImageLoading(false)
+ }}
+ onError={() => {
+ setImageLoading(false)
+ setImageError(true)
+ }}
+ />
+
) : (
)}
- {/* Zoom Controls */}
-
-
= ZOOM_MAX}
- className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
- title="Zoom in (Ctrl/Cmd + Wheel)"
- >
- +
-
-
- {Math.round(zoom * 100)}%
-
-
- −
-
- {zoom !== 1 && (
+ {/* Zoom Controls (hidden for videos) */}
+ {!currentIsVideo && (
+
= ZOOM_MAX}
+ className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
+ title="Zoom in (Ctrl/Cmd + Wheel)"
>
- Reset
+ +
- )}
-
+
+ {Math.round(zoom * 100)}%
+
+
+ −
+
+ {zoom !== 1 && (
+
+ Reset
+
+ )}
+
+ )}
{/* Navigation Buttons */}
{
+ console.log('🔍 Auth /me response:', {
+ username: user.username,
+ is_admin: user.is_admin,
+ role: user.role,
+ permissions: user.permissions
+ })
setAuthState({
isAuthenticated: true,
username: user.username,
@@ -76,10 +82,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = async (username: string, password: string) => {
try {
+ setAuthState((prev) => ({ ...prev, isLoading: true }))
const tokens: TokenResponse = await authApi.login({ username, password })
localStorage.setItem('access_token', tokens.access_token)
localStorage.setItem('refresh_token', tokens.refresh_token)
const user = await authApi.me()
+ console.log('🔍 Login /me response:', {
+ username: user.username,
+ is_admin: user.is_admin,
+ role: user.role,
+ permissions: user.permissions
+ })
const passwordChangeRequired = tokens.password_change_required || false
setAuthState({
isAuthenticated: true,
@@ -92,9 +105,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
})
return { success: true, passwordChangeRequired }
} catch (error: any) {
+ setAuthState((prev) => ({ ...prev, isLoading: false }))
+ console.error('Login error:', error)
return {
success: false,
- error: error.response?.data?.detail || 'Login failed',
+ error: error.response?.data?.detail || error.message || 'Login failed',
}
}
}
@@ -130,7 +145,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (authState.isAdmin) {
return true
}
- return Boolean(authState.permissions[featureKey])
+ const hasPerm = Boolean(authState.permissions[featureKey])
+ console.log(`🔍 hasPermission(${featureKey}):`, {
+ isAdmin: authState.isAdmin,
+ hasPerm,
+ permissions: authState.permissions
+ })
+ return hasPerm
},
[authState.isAdmin, authState.permissions]
)
diff --git a/admin-frontend/src/context/DeveloperModeContext.tsx b/admin-frontend/src/context/DeveloperModeContext.tsx
index 25a1bfa..426be3e 100644
--- a/admin-frontend/src/context/DeveloperModeContext.tsx
+++ b/admin-frontend/src/context/DeveloperModeContext.tsx
@@ -1,32 +1,17 @@
-import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
+import { createContext, useContext, ReactNode } from 'react'
interface DeveloperModeContextType {
isDeveloperMode: boolean
- setDeveloperMode: (enabled: boolean) => void
}
const DeveloperModeContext = createContext(undefined)
-const STORAGE_KEY = 'punimtag_developer_mode'
+// Check environment variable (set at build time)
+const isDeveloperMode = import.meta.env.VITE_DEVELOPER_MODE === 'true'
export function DeveloperModeProvider({ children }: { children: ReactNode }) {
- const [isDeveloperMode, setIsDeveloperMode] = useState(false)
-
- // Load from localStorage on mount
- useEffect(() => {
- const stored = localStorage.getItem(STORAGE_KEY)
- if (stored !== null) {
- setIsDeveloperMode(stored === 'true')
- }
- }, [])
-
- const setDeveloperMode = (enabled: boolean) => {
- setIsDeveloperMode(enabled)
- localStorage.setItem(STORAGE_KEY, enabled.toString())
- }
-
return (
-
+
{children}
)
diff --git a/admin-frontend/src/pages/ApproveIdentified.tsx b/admin-frontend/src/pages/ApproveIdentified.tsx
index 5438757..139c15c 100644
--- a/admin-frontend/src/pages/ApproveIdentified.tsx
+++ b/admin-frontend/src/pages/ApproveIdentified.tsx
@@ -159,10 +159,7 @@ export default function ApproveIdentified() {
}
}, [dateFrom, dateTo])
- const handleOpenReport = () => {
- setShowReport(true)
- loadReport()
- }
+ // Removed unused handleOpenReport function
const handleCloseReport = () => {
setShowReport(false)
@@ -337,7 +334,7 @@ export default function ApproveIdentified() {
title="Click to open full photo"
>
) : (
([])
const [filteredPeople, setFilteredPeople] = useState([])
- // Store matches separately, keyed by person_id
- const [matchesCache, setMatchesCache] = useState>({})
+ // Store matches separately, keyed by person_id_tolerance (composite key)
+ const [matchesCache, setMatchesCache] = useState>({})
const [currentIndex, setCurrentIndex] = useState(0)
const [searchQuery, setSearchQuery] = useState('')
const [allPeople, setAllPeople] = useState([])
@@ -44,6 +45,8 @@ export default function AutoMatch() {
const [stateRestored, setStateRestored] = useState(false)
// Track if initial restoration is complete (prevents reload effects from firing during restoration)
const restorationCompleteRef = useRef(false)
+ // Track current tolerance in a ref to avoid stale closures
+ const toleranceRef = useRef(tolerance)
const currentPerson = useMemo(() => {
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
@@ -52,30 +55,49 @@ export default function AutoMatch() {
const currentMatches = useMemo(() => {
if (!currentPerson) return []
- return matchesCache[currentPerson.person_id] || []
- }, [currentPerson, matchesCache])
+ // Use ref tolerance to ensure we always get the current tolerance value
+ const currentTolerance = toleranceRef.current
+ const cacheKey = `${currentPerson.person_id}_${currentTolerance}`
+ return matchesCache[cacheKey] || []
+ }, [currentPerson, matchesCache, tolerance]) // Keep tolerance in deps to trigger recalculation when it changes
// Check if any matches are selected
const hasSelectedMatches = useMemo(() => {
- return currentMatches.some(match => selectedFaces[match.id] === true)
+ return currentMatches.some((match: AutoMatchFaceItem) => selectedFaces[match.id] === true)
}, [currentMatches, selectedFaces])
+ // Update tolerance ref whenever tolerance changes
+ useEffect(() => {
+ toleranceRef.current = tolerance
+ }, [tolerance])
+
// Load matches for a specific person (lazy loading)
- const loadPersonMatches = async (personId: number) => {
- // Skip if already cached
- if (matchesCache[personId]) {
- return
+ const loadPersonMatches = async (personId: number, currentTolerance?: number) => {
+ // Use provided tolerance, or ref tolerance (always current), or state tolerance as fallback
+ const toleranceToUse = currentTolerance !== undefined ? currentTolerance : toleranceRef.current
+ // Create cache key that includes tolerance to avoid stale matches
+ const cacheKey = `${personId}_${toleranceToUse}`
+
+ // Double-check: if tolerance changed, don't use cached value
+ if (toleranceToUse !== toleranceRef.current) {
+ // Tolerance changed since this was called, don't use cache
+ // Will fall through to load fresh matches
+ } else {
+ // Skip if already cached for this tolerance
+ if (matchesCache[cacheKey]) {
+ return
+ }
}
try {
const response = await facesApi.getAutoMatchPersonMatches(personId, {
- tolerance,
+ tolerance: toleranceToUse,
filter_frontal_only: false
})
setMatchesCache(prev => ({
...prev,
- [personId]: response.matches
+ [cacheKey]: response.matches
}))
// Update total_matches in people list
@@ -106,9 +128,10 @@ export default function AutoMatch() {
} catch (error) {
console.error('Failed to load matches for person:', error)
// Set empty matches on error, and remove person from list
+ // Use composite cache key
setMatchesCache(prev => ({
...prev,
- [personId]: []
+ [cacheKey]: []
}))
// Remove person if matches failed to load (assume no matches)
setPeople(prev => prev.filter(p => p.person_id !== personId))
@@ -118,7 +141,10 @@ export default function AutoMatch() {
// Shared function for auto-load and refresh (loads people list only - fast)
const loadAutoMatch = async (clearState: boolean = false) => {
- if (tolerance < 0 || tolerance > 1) {
+ // Use ref to get current tolerance (avoids stale closure)
+ const currentTolerance = toleranceRef.current
+
+ if (currentTolerance < 0 || currentTolerance > 1) {
return
}
@@ -128,12 +154,30 @@ export default function AutoMatch() {
// Clear saved state if explicitly requested (Refresh button)
if (clearState) {
sessionStorage.removeItem(STATE_KEY)
- setMatchesCache({}) // Clear matches cache
+ // Clear ALL cache entries
+ setMatchesCache({})
+ } else {
+ // Also clear any cache entries that don't match current tolerance (even if not explicitly clearing)
+ setMatchesCache(prev => {
+ const cleaned: Record = {}
+ // Only keep cache entries that match current tolerance
+ Object.keys(prev).forEach(key => {
+ const parts = key.split('_')
+ if (parts.length >= 2) {
+ const cachedTolerance = parseFloat(parts[parts.length - 1])
+ if (!isNaN(cachedTolerance) && cachedTolerance === currentTolerance) {
+ cleaned[key] = prev[key]
+ }
+ }
+ })
+ return cleaned
+ })
}
// Load people list only (fast - no match calculations)
const response = await facesApi.getAutoMatchPeople({
- filter_frontal_only: false
+ filter_frontal_only: false,
+ tolerance: currentTolerance
})
if (response.people.length === 0) {
@@ -154,9 +198,9 @@ export default function AutoMatch() {
setOriginalSelectedFaces({})
setIsActive(true)
- // Load matches for first person immediately
+ // Load matches for first person immediately with current tolerance
if (response.people.length > 0) {
- await loadPersonMatches(response.people[0].person_id)
+ await loadPersonMatches(response.people[0].person_id, currentTolerance)
}
} catch (error) {
console.error('Auto-match failed:', error)
@@ -180,7 +224,6 @@ export default function AutoMatch() {
} finally {
setSettingsLoaded(true)
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Load state from sessionStorage on mount (people, current index, selected faces)
@@ -262,7 +305,7 @@ export default function AutoMatch() {
const matchesCacheRef = useRef(matchesCache)
const isActiveRef = useRef(isActive)
const hasNoResultsRef = useRef(hasNoResults)
- const toleranceRef = useRef(tolerance)
+ // Note: toleranceRef is already declared above, don't redeclare
// Update refs whenever state changes
useEffect(() => {
@@ -356,7 +399,15 @@ export default function AutoMatch() {
if (initialLoadRef.current && restorationCompleteRef.current) {
// Clear matches cache when tolerance changes (matches depend on tolerance)
setMatchesCache({})
- loadAutoMatch()
+ // Clear people list to force fresh load with new tolerance
+ setPeople([])
+ setFilteredPeople([])
+ setSelectedFaces({})
+ setOriginalSelectedFaces({})
+ setCurrentIndex(0)
+ setIsActive(false)
+ // Reload with new tolerance
+ loadAutoMatch(true) // Pass true to clear sessionStorage as well
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tolerance])
@@ -398,12 +449,29 @@ export default function AutoMatch() {
return
}
+ // Show informational message about bulk operation
+ const infoMessage = [
+ 'ℹ️ Bulk Auto-Match Operation',
+ '',
+ 'This operation will automatically match faces across your entire photo library.',
+ 'While the system uses advanced matching algorithms, some matches may not be 100% accurate.',
+ '',
+ 'Please review the results after completion to ensure accuracy.',
+ '',
+ 'Do you want to proceed with the auto-match operation?'
+ ].join('\n')
+
+ if (!confirm(infoMessage)) {
+ return
+ }
+
setBusy(true)
try {
const response = await facesApi.autoMatch({
- tolerance,
+ tolerance: RUN_AUTO_MATCH_TOLERANCE, // Use 0.5 for Run auto-match button (stricter)
auto_accept: true,
- auto_accept_threshold: autoAcceptThreshold
+ auto_accept_threshold: autoAcceptThreshold,
+ use_distance_based_thresholds: true // Enable distance-based thresholds for Run auto-match button
})
// Show summary if auto-accept was performed
@@ -458,7 +526,7 @@ export default function AutoMatch() {
const selectAll = () => {
const newSelected: Record = {}
- currentMatches.forEach(match => {
+ currentMatches.forEach((match: AutoMatchFaceItem) => {
newSelected[match.id] = true
})
setSelectedFaces(newSelected)
@@ -466,7 +534,7 @@ export default function AutoMatch() {
const clearAll = () => {
const newSelected: Record = {}
- currentMatches.forEach(match => {
+ currentMatches.forEach((match: AutoMatchFaceItem) => {
newSelected[match.id] = false
})
setSelectedFaces(newSelected)
@@ -478,14 +546,14 @@ export default function AutoMatch() {
setSaving(true)
try {
const faceIds = currentMatches
- .filter(match => selectedFaces[match.id] === true)
- .map(match => match.id)
+ .filter((match: AutoMatchFaceItem) => selectedFaces[match.id] === true)
+ .map((match: AutoMatchFaceItem) => match.id)
await peopleApi.acceptMatches(currentPerson.person_id, faceIds)
// Update original selected faces to current state
const newOriginal: Record = {}
- currentMatches.forEach(match => {
+ currentMatches.forEach((match: AutoMatchFaceItem) => {
newOriginal[match.id] = selectedFaces[match.id] || false
})
setOriginalSelectedFaces(prev => ({ ...prev, ...newOriginal }))
@@ -499,33 +567,45 @@ export default function AutoMatch() {
}
}
- // Load matches when current person changes (lazy loading)
+ // Load matches when current person changes OR tolerance changes (lazy loading)
useEffect(() => {
if (currentPerson && restorationCompleteRef.current) {
- loadPersonMatches(currentPerson.person_id)
+ // Always use ref tolerance (always current) to avoid stale matches
+ const currentTolerance = toleranceRef.current
+
+ // Force reload when tolerance changes - clear cache for this person first
+ const cacheKey = `${currentPerson.person_id}_${currentTolerance}`
+ if (!matchesCache[cacheKey]) {
+ // Only load if not already cached for current tolerance
+ loadPersonMatches(currentPerson.person_id, currentTolerance)
+ }
// Preload matches for next person in background
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
if (currentIndex + 1 < activePeople.length) {
const nextPerson = activePeople[currentIndex + 1]
- loadPersonMatches(nextPerson.person_id)
+ const nextCacheKey = `${nextPerson.person_id}_${currentTolerance}`
+ if (!matchesCache[nextCacheKey]) {
+ loadPersonMatches(nextPerson.person_id, currentTolerance)
+ }
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [currentPerson?.person_id, currentIndex])
+ }, [currentPerson?.person_id, currentIndex, tolerance])
// Restore selected faces when navigating to a different person
useEffect(() => {
if (currentPerson) {
- const matches = matchesCache[currentPerson.person_id] || []
+ const cacheKey = `${currentPerson.person_id}_${tolerance}`
+ const matches = matchesCache[cacheKey] || []
const restored: Record = {}
- matches.forEach(match => {
+ matches.forEach((match: AutoMatchFaceItem) => {
restored[match.id] = originalSelectedFaces[match.id] || false
})
setSelectedFaces(restored)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [currentIndex, filteredPeople.length, people.length, currentPerson?.person_id, matchesCache])
+ }, [currentIndex, filteredPeople.length, people.length, currentPerson?.person_id, matchesCache, tolerance])
const goBack = () => {
if (currentIndex > 0) {
@@ -696,7 +776,7 @@ export default function AutoMatch() {
)}
- ℹ️ Auto-Match Criteria: Only faces with similarity higher than 70% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.
+ ℹ️ Auto-Match Criteria: Only faces with similarity higher than 85% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.
@@ -807,7 +887,7 @@ export default function AutoMatch() {
title="Click to open full photo"
>
@@ -876,7 +956,7 @@ export default function AutoMatch() {
title="Click to open full photo"
>
diff --git a/admin-frontend/src/pages/Dashboard.tsx b/admin-frontend/src/pages/Dashboard.tsx
index 4213b5a..95e99ad 100644
--- a/admin-frontend/src/pages/Dashboard.tsx
+++ b/admin-frontend/src/pages/Dashboard.tsx
@@ -4,7 +4,7 @@ import { photosApi, PhotoSearchResult } from '../api/photos'
import apiClient from '../api/client'
export default function Dashboard() {
- const { username } = useAuth()
+ const { username: _username } = useAuth()
const [samplePhotos, setSamplePhotos] = useState
([])
const [loadingPhotos, setLoadingPhotos] = useState(true)
@@ -261,36 +261,6 @@ export default function Dashboard() {
)}
-
- {/* CTA Section */}
-
-
-
- Ready to Get Started?
-
-
- Begin organizing your photo collection today. Use the navigation menu
- to explore all the powerful features PunimTag has to offer.
-
-
-
- 🗂️ Scan Photos
-
-
- ⚙️ Process Faces
-
-
- 👤 Identify People
-
-
- 🤖 Auto-Match
-
-
- 🔍 Search Photos
-
-
-
-
)
}
diff --git a/admin-frontend/src/pages/Help.tsx b/admin-frontend/src/pages/Help.tsx
index 9e7b39f..dd6b035 100644
--- a/admin-frontend/src/pages/Help.tsx
+++ b/admin-frontend/src/pages/Help.tsx
@@ -167,39 +167,95 @@ function ScanPageHelp({ onBack }: { onBack: () => void }) {
Purpose
-
Import photos into your collection from folders or upload files
+
Import photos into your collection from folders. Choose between scanning from your local computer or from network paths.
+
+
+
Scan Modes
+
+
Scan from Local:
+
+ Select folders from your local computer using the browser
+ Works with File System Access API (Chrome, Edge, Safari) or webkitdirectory (Firefox)
+ The browser reads files and uploads them to the server
+ No server-side filesystem access needed
+ Perfect for scanning folders on your local machine
+
+
+
+
Scan from Network:
+
+ Scan folders on network shares (UNC paths, mounted NFS/SMB shares)
+ Type the network path directly or use "Browse Network" to navigate
+ The server accesses the filesystem directly
+ Requires the backend server to have access to the network path
+ Perfect for scanning folders on network drives or mounted shares
+
+
Features
- Folder Selection: Browse and select folders containing photos
+ Scan Mode Selection: Choose between "Scan from Local" or "Scan from Network"
+ Local Folder Selection: Use browser's folder picker to select folders from your computer
+ Network Path Input: Type network paths directly or browse network shares
Recursive Scanning: Option to scan subdirectories recursively (enabled by default)
Duplicate Detection: Automatically detects and skips duplicate photos
Real-time Progress: Live progress tracking during import
-
How to Use
-
Folder Scan:
-
- Click "Browse Folder" button
- Select a folder containing photos
- Toggle "Subdirectories recursively" if you want to include subfolders (enabled by default)
- Click "Start Scan" button
- Monitor progress in the progress bar
- View results (photos added, existing photos skipped)
-
+
+
Scan from Local:
+
+ Select "Scan from Local" radio button
+ Click "Select Folder" button
+ Choose a folder from your local computer using the folder picker
+ The selected folder name will appear in the input field
+ Toggle "Scan subdirectories recursively" if you want to include subfolders (enabled by default)
+ Click "Start Scanning" button to begin the upload
+ Monitor progress in the progress bar
+ View results (photos added, existing photos skipped)
+
+
+
+
Scan from Network:
+
+ Select "Scan from Network" radio button
+ Either:
+
+ Type the network path directly (e.g., \\server\share or /mnt/nfs-share)
+ Or click "Browse Network" to navigate network shares visually
+
+
+ Toggle "Scan subdirectories recursively" if you want to include subfolders (enabled by default)
+ Click "Start Scanning" button
+ Monitor progress in the progress bar
+ View results (photos added, existing photos skipped)
+
+
What Happens
-
+ Local Mode: Browser reads files from your computer and uploads them to the server via HTTP
+ Network Mode: Server accesses files directly from the network path
Photos are added to database
+ Duplicate photos are automatically skipped
Faces are NOT detected yet (use Process page for that)
-
+
+
Tips
+
+ Use "Scan from Local" for folders on your computer - works in all modern browsers
+ Use "Scan from Network" for folders on network drives or mounted shares
+ Recursive scanning is enabled by default - uncheck if you only want the top-level folder
+ Large folders may take time to scan - be patient and monitor the progress
+ Duplicate detection prevents adding the same photo twice
+ After scanning, use the Process page to detect faces in the imported photos
+
+
)
@@ -418,7 +474,7 @@ function AutoMatchPageHelp({ onBack }: { onBack: () => void }) {
Click "🚀 Run Auto-Match" button
The system will automatically match unidentified faces to identified people based on:
- Similarity higher than 70%
+ Similarity higher than 85%
Picture quality higher than 50%
Profile faces are excluded for better accuracy
@@ -616,7 +672,7 @@ function ModifyPageHelp({ onBack }: { onBack: () => void }) {
Finding and Selecting a Person:
Navigate to Modify page
- Optionally search for a person by entering their last name or maiden name in the search box
+ Optionally search for a person by entering their first, middle, last, or maiden name in the search box
Click "Search" to filter the list, or "Clear" to show all people
Click on a person's name in the left panel to select them
The person's faces and videos will load in the right panels
diff --git a/admin-frontend/src/pages/Identify.tsx b/admin-frontend/src/pages/Identify.tsx
index 0625566..72a92d0 100644
--- a/admin-frontend/src/pages/Identify.tsx
+++ b/admin-frontend/src/pages/Identify.tsx
@@ -348,7 +348,8 @@ export default function Identify() {
return
}
try {
- const res = await facesApi.getSimilar(faceId, includeExcludedFaces)
+ // Enable debug mode to log encoding info to browser console
+ const res = await facesApi.getSimilar(faceId, includeExcludedFaces, true)
setSimilar(res.items || [])
setSelectedSimilar({})
} catch (error) {
@@ -386,7 +387,7 @@ export default function Identify() {
} finally {
setSettingsLoaded(true)
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [photoIds])
// Load state from sessionStorage on mount (faces, current index, similar, form data)
@@ -433,7 +434,7 @@ export default function Identify() {
} finally {
setStateRestored(true)
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [photoIds])
// Save state to sessionStorage whenever it changes (but only after initial restore)
@@ -530,7 +531,7 @@ export default function Identify() {
loadPeople()
loadTags()
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [settingsLoaded])
// Reset filters when photoIds is provided (to ensure all faces from those photos are shown)
@@ -544,7 +545,7 @@ export default function Identify() {
// Keep uniqueFacesOnly as is (user preference)
// Keep sortBy/sortDir as defaults (quality desc)
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [photoIds, settingsLoaded])
// Initial load on mount (after settings and state are loaded)
@@ -604,7 +605,8 @@ export default function Identify() {
const preloadImages = () => {
const preloadUrls: string[] = []
- const baseUrl = apiClient.defaults.baseURL || 'http://127.0.0.1:8000'
+ // Use relative path when baseURL is empty (works with proxy and HTTPS)
+ const baseUrl = apiClient.defaults.baseURL || ''
// Preload next face
if (currentIdx + 1 < faces.length) {
@@ -951,6 +953,7 @@ export default function Identify() {
loadVideos()
loadPeople() // Load people for the dropdown
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, videosPage, videosPageSize, videosFolderFilter, videosDateFrom, videosDateTo, videosHasPeople, videosPersonName, videosSortBy, videosSortDir])
return (
@@ -1290,7 +1293,6 @@ export default function Identify() {
crossOrigin="anonymous"
loading="eager"
onLoad={() => setImageLoading(false)}
- onLoadStart={() => setImageLoading(true)}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
diff --git a/admin-frontend/src/pages/ManageUsers.tsx b/admin-frontend/src/pages/ManageUsers.tsx
index ba7937f..1b1dbc9 100644
--- a/admin-frontend/src/pages/ManageUsers.tsx
+++ b/admin-frontend/src/pages/ManageUsers.tsx
@@ -621,7 +621,10 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
const filteredUsers = useMemo(() => {
// Hide the special system user used for frontend approvals
- const visibleUsers = users.filter((user) => user.username !== 'FrontEndUser')
+ // Also hide the default admin user
+ const visibleUsers = users.filter(
+ (user) => user.username !== 'FrontEndUser' && user.username?.toLowerCase() !== 'admin'
+ )
if (filterRole === null) {
return visibleUsers
@@ -647,7 +650,10 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
}, [filteredUsers, userSort])
const filteredAuthUsers = useMemo(() => {
- let filtered = [...authUsers]
+ // Hide the default admin user (admin@admin.com)
+ let filtered = authUsers.filter(
+ (user) => user.email?.toLowerCase() !== 'admin@admin.com'
+ )
// Filter by active status
if (authFilterActive !== null) {
diff --git a/admin-frontend/src/pages/Modify.tsx b/admin-frontend/src/pages/Modify.tsx
index de402f5..1bf799d 100644
--- a/admin-frontend/src/pages/Modify.tsx
+++ b/admin-frontend/src/pages/Modify.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState, useRef, useCallback } from 'react'
import peopleApi, { PersonWithFaces, PersonFaceItem, PersonVideoItem, PersonUpdateRequest } from '../api/people'
import facesApi from '../api/faces'
import videosApi from '../api/videos'
+import { apiClient } from '../api/client'
interface EditDialogProps {
person: PersonWithFaces
@@ -146,7 +147,7 @@ function EditPersonDialog({ person, onSave, onClose }: EditDialogProps) {
export default function Modify() {
const [people, setPeople] = useState([])
- const [lastNameFilter, setLastNameFilter] = useState('')
+ const [nameFilter, setNameFilter] = useState('')
const [selectedPersonId, setSelectedPersonId] = useState(null)
const [selectedPersonName, setSelectedPersonName] = useState('')
const [faces, setFaces] = useState([])
@@ -186,7 +187,7 @@ export default function Modify() {
try {
setBusy(true)
setError(null)
- const res = await peopleApi.listWithFaces(lastNameFilter || undefined)
+ const res = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(res.items)
// Auto-select first person if available and none selected (only if not restoring state)
@@ -202,7 +203,7 @@ export default function Modify() {
} finally {
setBusy(false)
}
- }, [lastNameFilter, selectedPersonId])
+ }, [nameFilter, selectedPersonId])
// Load faces for a person
const loadPersonFaces = useCallback(async (personId: number) => {
@@ -247,12 +248,15 @@ export default function Modify() {
useEffect(() => {
let restoredPanelWidth = false
try {
- const saved = sessionStorage.getItem(STATE_KEY)
- if (saved) {
- const state = JSON.parse(saved)
- if (state.lastNameFilter !== undefined) {
- setLastNameFilter(state.lastNameFilter || '')
- }
+ const saved = sessionStorage.getItem(STATE_KEY)
+ if (saved) {
+ const state = JSON.parse(saved)
+ if (state.nameFilter !== undefined) {
+ setNameFilter(state.nameFilter || '')
+ } else if (state.lastNameFilter !== undefined) {
+ // Backward compatibility with old state key
+ setNameFilter(state.lastNameFilter || '')
+ }
if (state.selectedPersonId !== undefined && state.selectedPersonId !== null) {
setSelectedPersonId(state.selectedPersonId)
}
@@ -305,7 +309,7 @@ export default function Modify() {
} finally {
setStateRestored(true)
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [])
useEffect(() => {
@@ -364,7 +368,7 @@ export default function Modify() {
try {
const state = {
- lastNameFilter,
+ nameFilter,
selectedPersonId,
selectedPersonName,
faces,
@@ -379,10 +383,10 @@ export default function Modify() {
} catch (error) {
console.error('Error saving state to sessionStorage:', error)
}
- }, [lastNameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth, stateRestored])
+ }, [nameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth, stateRestored])
// Save state on unmount (when navigating away) - use refs to capture latest values
- const lastNameFilterRef = useRef(lastNameFilter)
+ const nameFilterRef = useRef(nameFilter)
const selectedPersonIdRef = useRef(selectedPersonId)
const selectedPersonNameRef = useRef(selectedPersonName)
const facesRef = useRef(faces)
@@ -395,7 +399,7 @@ export default function Modify() {
// Update refs whenever state changes
useEffect(() => {
- lastNameFilterRef.current = lastNameFilter
+ nameFilterRef.current = nameFilter
selectedPersonIdRef.current = selectedPersonId
selectedPersonNameRef.current = selectedPersonName
facesRef.current = faces
@@ -405,14 +409,14 @@ export default function Modify() {
facesExpandedRef.current = facesExpanded
videosExpandedRef.current = videosExpanded
peoplePanelWidthRef.current = peoplePanelWidth
- }, [lastNameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth])
+ }, [nameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth])
// Save state on unmount (when navigating away)
useEffect(() => {
return () => {
try {
const state = {
- lastNameFilter: lastNameFilterRef.current,
+ nameFilter: nameFilterRef.current,
selectedPersonId: selectedPersonIdRef.current,
selectedPersonName: selectedPersonNameRef.current,
faces: facesRef.current,
@@ -462,7 +466,7 @@ export default function Modify() {
}
const handleClearSearch = () => {
- setLastNameFilter('')
+ setNameFilter('')
// loadPeople will be called by useEffect
}
@@ -547,6 +551,33 @@ export default function Modify() {
})
}
+ const confirmUnmatchFace = async () => {
+ if (!unmatchConfirmDialog || !selectedPersonId || !unmatchConfirmDialog.faceId) return
+
+ try {
+ setBusy(true)
+ setError(null)
+ setUnmatchConfirmDialog(null)
+
+ // Unmatch the single face
+ await facesApi.batchUnmatch({ face_ids: [unmatchConfirmDialog.faceId] })
+
+ // Reload people list to update face counts
+ const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined)
+ setPeople(peopleRes.items)
+
+ // Reload faces
+ await loadPersonFaces(selectedPersonId)
+
+ setSuccess('Successfully unlinked face')
+ setTimeout(() => setSuccess(null), 3000)
+ } catch (err: any) {
+ setError(err.response?.data?.detail || err.message || 'Failed to unmatch face')
+ } finally {
+ setBusy(false)
+ }
+ }
+
const confirmBulkUnmatchFaces = async () => {
if (!unmatchConfirmDialog || !selectedPersonId || selectedFaces.size === 0) return
@@ -563,7 +594,7 @@ export default function Modify() {
setSelectedFaces(new Set())
// Reload people list to update face counts
- const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
+ const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(peopleRes.items)
// Reload faces
@@ -599,7 +630,7 @@ export default function Modify() {
await videosApi.removePerson(unmatchConfirmDialog.videoId, selectedPersonId)
// Reload people list
- const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
+ const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(peopleRes.items)
// Reload videos
@@ -651,7 +682,7 @@ export default function Modify() {
setSelectedVideos(new Set())
// Reload people list
- const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
+ const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(peopleRes.items)
// Reload videos
@@ -692,10 +723,10 @@ export default function Modify() {
setLastNameFilter(e.target.value)}
+ value={nameFilter}
+ onChange={(e) => setNameFilter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
- placeholder="Type Last Name or Maiden Name"
+ placeholder="Type First, Middle, Last, or Maiden Name"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
- Search by Last Name or Maiden Name
+ Search by First, Middle, Last, or Maiden Name
{/* People list */}
@@ -852,12 +883,12 @@ export default function Modify() {
{
// Open photo in new window
- window.open(`/api/v1/photos/${face.photo_id}/image`, '_blank')
+ window.open(`${apiClient.defaults.baseURL}/api/v1/photos/${face.photo_id}/image`, '_blank')
}}
title="Click to show original photo"
onError={(e) => {
diff --git a/admin-frontend/src/pages/PendingPhotos.tsx b/admin-frontend/src/pages/PendingPhotos.tsx
index 668ab3d..9c1fe4c 100644
--- a/admin-frontend/src/pages/PendingPhotos.tsx
+++ b/admin-frontend/src/pages/PendingPhotos.tsx
@@ -2,7 +2,7 @@ import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { pendingPhotosApi, PendingPhotoResponse, ReviewDecision, CleanupResponse } from '../api/pendingPhotos'
import { apiClient } from '../api/client'
import { useAuth } from '../context/AuthContext'
-import { videosApi } from '../api/videos'
+// Removed unused videosApi import
type SortKey = 'photo' | 'uploaded_by' | 'file_info' | 'submitted_at' | 'status'
@@ -259,7 +259,7 @@ export default function PendingPhotos() {
// Apply to all currently rejected photos
const rejectedPhotoIds = Object.entries(decisions)
- .filter(([id, decision]) => decision === 'reject')
+ .filter(([_id, decision]) => decision === 'reject')
.map(([id]) => parseInt(id))
if (rejectedPhotoIds.length > 0) {
diff --git a/admin-frontend/src/pages/ReportedPhotos.tsx b/admin-frontend/src/pages/ReportedPhotos.tsx
index fd64f75..49910ee 100644
--- a/admin-frontend/src/pages/ReportedPhotos.tsx
+++ b/admin-frontend/src/pages/ReportedPhotos.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useCallback } from 'react'
+import { useEffect, useState, useCallback, useRef } from 'react'
import {
reportedPhotosApi,
ReportedPhotoResponse,
@@ -18,6 +18,8 @@ export default function ReportedPhotos() {
const [submitting, setSubmitting] = useState(false)
const [clearing, setClearing] = useState(false)
const [statusFilter, setStatusFilter] = useState
('pending')
+ const [imageUrls, setImageUrls] = useState>({})
+ const imageUrlsRef = useRef>({})
const loadReportedPhotos = useCallback(async () => {
setLoading(true)
@@ -36,6 +38,19 @@ export default function ReportedPhotos() {
}
})
setReviewNotes(existingNotes)
+
+ // Create direct backend URLs for images (only for non-video photos)
+ const newImageUrls: Record = {}
+ // Use relative path when baseURL is empty (works with proxy and HTTPS)
+ const baseURL = apiClient.defaults.baseURL || ''
+ response.items.forEach((reported) => {
+ if (reported.photo_id && reported.photo_media_type !== 'video') {
+ // Use direct backend URL - the backend endpoint doesn't require auth for images
+ newImageUrls[reported.photo_id] = `${baseURL}/api/v1/photos/${reported.photo_id}/image`
+ }
+ })
+ setImageUrls(newImageUrls)
+ imageUrlsRef.current = newImageUrls
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load reported photos')
console.error('Error loading reported photos:', err)
@@ -43,6 +58,15 @@ export default function ReportedPhotos() {
setLoading(false)
}
}, [statusFilter])
+
+ // Cleanup blob URLs on unmount
+ useEffect(() => {
+ return () => {
+ Object.values(imageUrlsRef.current).forEach((url) => {
+ URL.revokeObjectURL(url)
+ })
+ }
+ }, [])
useEffect(() => {
loadReportedPhotos()
@@ -364,9 +388,10 @@ export default function ReportedPhotos() {
}
}}
/>
- ) : (
+ ) : imageUrls[reported.photo_id] ? (
+ ) : (
+
+ Loading...
+
)}
) : (
diff --git a/admin-frontend/src/pages/Scan.tsx b/admin-frontend/src/pages/Scan.tsx
index 28d1f68..77e9ad6 100644
--- a/admin-frontend/src/pages/Scan.tsx
+++ b/admin-frontend/src/pages/Scan.tsx
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react'
import { photosApi, PhotoImportRequest } from '../api/photos'
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
+import FolderBrowser from '../components/FolderBrowser'
interface JobProgress {
id: string
@@ -11,11 +12,70 @@ interface JobProgress {
total?: number
}
+type ScanMode = 'network' | 'local'
+
+// Supported image and video extensions for File System Access API
+const SUPPORTED_EXTENSIONS = [
+ '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif',
+ '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.flv', '.wmv'
+]
+
+// Check if File System Access API is supported
+const isFileSystemAccessSupported = (): boolean => {
+ return 'showDirectoryPicker' in window
+}
+
+// Check if webkitdirectory (fallback) is supported
+const isWebkitDirectorySupported = (): boolean => {
+ const input = document.createElement('input')
+ return 'webkitdirectory' in input
+}
+
+// Check if running on iOS
+const isIOS = (): boolean => {
+ return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
+}
+
+// Recursively read all files from a directory handle
+async function readDirectoryRecursive(
+ dirHandle: FileSystemDirectoryHandle,
+ recursive: boolean = true
+): Promise
{
+ const files: File[] = []
+
+ async function traverse(handle: FileSystemDirectoryHandle, path: string = '') {
+ // @ts-ignore - File System Access API types may not be available
+ for await (const entry of handle.values()) {
+ if (entry.kind === 'file') {
+ const file = await entry.getFile()
+ const ext = '.' + file.name.split('.').pop()?.toLowerCase()
+ if (SUPPORTED_EXTENSIONS.includes(ext)) {
+ files.push(file)
+ }
+ } else if (entry.kind === 'directory' && recursive) {
+ await traverse(entry, path + '/' + entry.name)
+ }
+ }
+ }
+
+ await traverse(dirHandle)
+ return files
+}
+
export default function Scan() {
+ const [scanMode, setScanMode] = useState('local')
const [folderPath, setFolderPath] = useState('')
const [recursive, setRecursive] = useState(true)
const [isImporting, setIsImporting] = useState(false)
- const [isBrowsing, setIsBrowsing] = useState(false)
+ const [showFolderBrowser, setShowFolderBrowser] = useState(false)
+ const [localUploadProgress, setLocalUploadProgress] = useState<{
+ current: number
+ total: number
+ filename: string
+ } | null>(null)
+ const [selectedFiles, setSelectedFiles] = useState([])
+ const fileInputRef = useRef(null)
const [currentJob, setCurrentJob] = useState(null)
const [jobProgress, setJobProgress] = useState(null)
const [importResult, setImportResult] = useState<{
@@ -35,189 +95,196 @@ export default function Scan() {
}
}, [])
- const handleFolderBrowse = async () => {
- setIsBrowsing(true)
+ const handleFolderBrowse = () => {
setError(null)
-
- // Try backend API first (uses tkinter for native folder picker with full path)
- try {
- console.log('Attempting to open native folder picker...')
- const result = await photosApi.browseFolder()
- console.log('Backend folder picker result:', result)
-
- if (result.success && result.path) {
- // Ensure we have a valid absolute path (not just folder name)
- const path = result.path.trim()
- if (path && path.length > 0) {
- // Verify it looks like an absolute path:
- // - Unix/Linux: starts with / (includes mounted network shares like /mnt/...)
- // - Windows local: starts with drive letter like C:\
- // - Windows UNC: starts with \\ (network paths like \\server\share\folder)
- const isUnixPath = path.startsWith('/')
- const isWindowsLocalPath = /^[A-Za-z]:[\\/]/.test(path)
- const isWindowsUncPath = path.startsWith('\\\\') || path.startsWith('//')
-
- if (isUnixPath || isWindowsLocalPath || isWindowsUncPath) {
- setFolderPath(path)
- setIsBrowsing(false)
- return
- } else {
- // Backend validated it, so trust it even if it doesn't match our patterns
- // (might be a valid path format we didn't account for)
- console.warn('Backend returned path with unexpected format:', path)
- setFolderPath(path)
- setIsBrowsing(false)
- return
- }
- }
- }
- // If we get here, result.success was false or path was empty
- console.warn('Backend folder picker returned no path:', result)
- if (result.success === false && result.message) {
- setError(result.message || 'No folder was selected. Please try again.')
- } else {
- setError('No folder was selected. Please try again.')
- }
- setIsBrowsing(false)
- } catch (err: any) {
- // Backend API failed, fall back to browser picker
- console.warn('Backend folder picker unavailable, using browser fallback:', err)
-
- // Extract error message from various possible locations
- const errorMsg = err?.response?.data?.detail ||
- err?.response?.data?.message ||
- err?.message ||
- String(err) ||
- ''
-
- console.log('Error details:', {
- status: err?.response?.status,
- detail: err?.response?.data?.detail,
- message: err?.message,
- fullError: err
- })
-
- // Check if it's a display/availability issue
- if (errorMsg.includes('display') || errorMsg.includes('DISPLAY') || errorMsg.includes('tkinter')) {
- // Show user-friendly message about display issue
- setError('Native folder picker unavailable. Using browser fallback.')
- } else if (err?.response?.status === 503) {
- // 503 Service Unavailable - likely tkinter or display issue
- setError('Native folder picker unavailable (tkinter/display issue). Using browser fallback.')
- } else {
- // Other error - log it but continue to browser fallback
- console.error('Error calling backend folder picker:', err)
- setError('Native folder picker unavailable. Using browser fallback.')
- }
- }
-
- // Fallback: Use browser-based folder picker
- // This code runs if backend API failed or returned no path
- console.log('Attempting browser fallback folder picker...')
-
- // Use File System Access API if available (modern browsers)
- if (typeof window !== 'undefined' && 'showDirectoryPicker' in window) {
- try {
- console.log('Using File System Access API...')
- const directoryHandle = await (window as any).showDirectoryPicker()
- // Get the folder name from the handle
- const folderName = directoryHandle.name
- // Note: Browsers don't expose full absolute paths for security reasons
- console.log('Selected folder name:', folderName)
-
- // Browser picker only gives folder name, not full path
- // Set the folder name and show helpful message
- setFolderPath(folderName)
- setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.')
- } catch (err: any) {
- // User cancelled the picker
- if (err.name !== 'AbortError') {
- console.error('Error selecting folder:', err)
- setError('Error opening folder picker: ' + err.message)
- } else {
- // User cancelled - clear any previous error
- setError(null)
- }
- } finally {
- setIsBrowsing(false)
- }
- } else {
- // Fallback: use a hidden directory input
- // Note: This will show a browser confirmation dialog that cannot be removed
- console.log('Using file input fallback...')
- const input = document.createElement('input')
- input.type = 'file'
- input.setAttribute('webkitdirectory', '')
- input.setAttribute('directory', '')
- input.setAttribute('multiple', '')
- input.style.display = 'none'
-
- input.onchange = (e: any) => {
- const files = e.target.files
- if (files && files.length > 0) {
- const firstFile = files[0]
- const relativePath = firstFile.webkitRelativePath
- const pathParts = relativePath.split('/')
- const rootFolder = pathParts[0]
- // Note: Browsers don't expose full absolute paths for security reasons
- console.log('Selected folder name:', rootFolder)
-
- // Browser picker only gives folder name, not full path
- // Set the folder name and show helpful message
- setFolderPath(rootFolder)
- setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.')
- }
- if (document.body.contains(input)) {
- document.body.removeChild(input)
- }
- setIsBrowsing(false)
- }
-
- input.oncancel = () => {
- if (document.body.contains(input)) {
- document.body.removeChild(input)
- }
- setIsBrowsing(false)
- }
-
- document.body.appendChild(input)
- input.click()
- }
+ setShowFolderBrowser(true)
}
- const handleScanFolder = async () => {
- if (!folderPath.trim()) {
- setError('Please enter a folder path')
+ const handleFolderSelect = (selectedPath: string) => {
+ setFolderPath(selectedPath)
+ setError(null)
+ }
+
+ const handleLocalFolderSelect = (files: FileList | null) => {
+ if (!files || files.length === 0) {
return
}
- setIsImporting(true)
setError(null)
setImportResult(null)
setCurrentJob(null)
setJobProgress(null)
+ setLocalUploadProgress(null)
+
+ // Filter to only supported files
+ const fileArray = Array.from(files).filter((file) => {
+ const ext = '.' + file.name.split('.').pop()?.toLowerCase()
+ return SUPPORTED_EXTENSIONS.includes(ext)
+ })
+
+ if (fileArray.length === 0) {
+ setError('No supported image or video files found in the selected folder.')
+ setSelectedFiles([])
+ return
+ }
+
+ // Set folder path from first file's path
+ if (fileArray.length > 0) {
+ const firstFile = fileArray[0]
+ // Extract folder path from file path (webkitdirectory includes full path)
+ // On iOS, webkitRelativePath may not be available, so use a generic label
+ if (firstFile.webkitRelativePath) {
+ const folderPath = firstFile.webkitRelativePath.split('/').slice(0, -1).join('/')
+ setFolderPath(folderPath || 'Selected folder')
+ } else {
+ // iOS Photos selection - no folder path available
+ setFolderPath(`Selected ${fileArray.length} file${fileArray.length > 1 ? 's' : ''} from Photos`)
+ }
+ }
+
+ // Store files for later upload
+ setSelectedFiles(fileArray)
+ }
+
+ const handleStartLocalScan = async () => {
+ if (selectedFiles.length === 0) {
+ setError('Please select a folder first.')
+ return
+ }
try {
- const request: PhotoImportRequest = {
- folder_path: folderPath.trim(),
- recursive,
+ setIsImporting(true)
+ setError(null)
+ setImportResult(null)
+ setCurrentJob(null)
+ setJobProgress(null)
+ setLocalUploadProgress(null)
+
+ // Upload files to backend in batches to show progress
+ setLocalUploadProgress({ current: 0, total: selectedFiles.length, filename: '' })
+
+ // Upload files in batches to show progress (increased from 10 to 25 for better performance)
+ const batchSize = 25
+ let uploaded = 0
+ let totalAdded = 0
+ let totalExisting = 0
+
+ for (let i = 0; i < selectedFiles.length; i += batchSize) {
+ const batch = selectedFiles.slice(i, i + batchSize)
+ const response = await photosApi.uploadPhotos(batch)
+
+ uploaded += batch.length
+ totalAdded += response.added || 0
+ totalExisting += response.existing || 0
+
+ setLocalUploadProgress({
+ current: uploaded,
+ total: selectedFiles.length,
+ filename: batch[batch.length - 1]?.name || '',
+ })
+ }
+
+ setImportResult({
+ added: totalAdded,
+ existing: totalExisting,
+ total: selectedFiles.length,
+ })
+
+ setIsImporting(false)
+ setLocalUploadProgress(null)
+ } catch (err: any) {
+ setError(err.response?.data?.detail || err.message || 'Failed to upload files')
+ setIsImporting(false)
+ setLocalUploadProgress(null)
+ }
+ }
+
+ const handleScanFolder = async () => {
+ if (scanMode === 'local') {
+ // For local mode, use File System Access API if available, otherwise fallback to webkitdirectory
+ if (isFileSystemAccessSupported()) {
+ // Use File System Access API (Chrome, Edge, Safari)
+ try {
+ setIsImporting(true)
+ setError(null)
+ setImportResult(null)
+ setCurrentJob(null)
+ setJobProgress(null)
+ setLocalUploadProgress(null)
+
+ // Show directory picker
+ // @ts-ignore - File System Access API types may not be available
+ const dirHandle = await window.showDirectoryPicker()
+ const folderName = dirHandle.name
+ setFolderPath(folderName)
+
+ // Read all files from the directory
+ const files = await readDirectoryRecursive(dirHandle, recursive)
+
+ if (files.length === 0) {
+ setError('No supported image or video files found in the selected folder.')
+ setSelectedFiles([])
+ setIsImporting(false)
+ return
+ }
+
+ // For File System Access API, files are File objects with lastModified
+ // Store files with their metadata for later upload
+ setSelectedFiles(files)
+ setIsImporting(false)
+ } catch (err: any) {
+ if (err.name === 'AbortError') {
+ // User cancelled the folder picker
+ setError(null)
+ setSelectedFiles([])
+ setIsImporting(false)
+ } else {
+ setError(err.message || 'Failed to select folder')
+ setSelectedFiles([])
+ setIsImporting(false)
+ }
+ }
+ } else if (isWebkitDirectorySupported()) {
+ // Fallback: Use webkitdirectory input (Firefox, older browsers)
+ fileInputRef.current?.click()
+ } else {
+ setError('Folder selection is not supported in your browser. Please use Chrome, Edge, Safari, or Firefox.')
+ }
+ } else {
+ // For network mode, use the existing path-based import
+ if (!folderPath.trim()) {
+ setError('Please enter a folder path')
+ return
}
- const response = await photosApi.importPhotos(request)
- setCurrentJob({
- id: response.job_id,
- status: JobStatus.PENDING,
- progress: 0,
- message: response.message,
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- })
+ setIsImporting(true)
+ setError(null)
+ setImportResult(null)
+ setCurrentJob(null)
+ setJobProgress(null)
- // Start SSE stream for job progress
- startJobProgressStream(response.job_id)
- } catch (err: any) {
- setError(err.response?.data?.detail || err.message || 'Import failed')
- setIsImporting(false)
+ try {
+ const request: PhotoImportRequest = {
+ folder_path: folderPath.trim(),
+ recursive,
+ }
+
+ const response = await photosApi.importPhotos(request)
+ setCurrentJob({
+ id: response.job_id,
+ status: JobStatus.PENDING,
+ progress: 0,
+ message: response.message,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ })
+
+ // Start SSE stream for job progress
+ startJobProgressStream(response.job_id)
+ } catch (err: any) {
+ setError(err.response?.data?.detail || err.message || 'Import failed')
+ setIsImporting(false)
+ }
}
}
@@ -271,9 +338,22 @@ export default function Scan() {
eventSource.onerror = (err) => {
console.error('SSE error:', err)
+ // Check if connection failed (readyState 0 = CONNECTING, 2 = CLOSED)
+ if (eventSource.readyState === EventSource.CLOSED) {
+ setError('Connection to server lost. The job may still be running. Please refresh the page to check status.')
+ setIsImporting(false)
+ } else if (eventSource.readyState === EventSource.CONNECTING) {
+ // Still connecting, don't show error yet
+ console.log('SSE still connecting...')
+ }
eventSource.close()
eventSourceRef.current = null
}
+
+ // Handle connection open
+ eventSource.onopen = () => {
+ console.log('SSE connection opened for job:', jobId)
+ }
}
const fetchJobResult = async (jobId: string) => {
@@ -312,34 +392,139 @@ export default function Scan() {
+ {/* Scan Mode Selection */}
+
+
@@ -360,17 +545,61 @@ export default function Scan() {
-
- {isImporting ? 'Scanning...' : 'Start Scanning'}
-
+ {scanMode === 'local' && (
+
+ {isImporting ? 'Scanning...' : 'Start Scanning'}
+
+ )}
+ {scanMode === 'network' && (
+
+ {isImporting ? 'Scanning...' : 'Start Scanning'}
+
+ )}
+ {/* Local Upload Progress Section */}
+ {localUploadProgress && (
+
+
+ Upload Progress
+
+
+
+
+
+ Uploading files...
+
+
+ {localUploadProgress.current} / {localUploadProgress.total}
+
+
+
+
+ {localUploadProgress.filename && (
+
+
Current file: {localUploadProgress.filename}
+
+ )}
+
+
+ )}
+
{/* Progress Section */}
{(currentJob || jobProgress) && (
@@ -455,6 +684,15 @@ export default function Scan() {
)}
+
+ {/* Folder Browser Modal */}
+ {showFolderBrowser && (
+
setShowFolderBrowser(false)}
+ />
+ )}
)
}
diff --git a/admin-frontend/src/pages/Search.tsx b/admin-frontend/src/pages/Search.tsx
index e794181..e2acd17 100644
--- a/admin-frontend/src/pages/Search.tsx
+++ b/admin-frontend/src/pages/Search.tsx
@@ -680,9 +680,17 @@ export default function Search() {
.join(', ')
}, [selectedTagIds, allPhotoTags])
- const openPhoto = (photoId: number) => {
- const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
- window.open(photoUrl, '_blank')
+ const openPhoto = (photoId: number, mediaType?: string) => {
+ const isVideo = mediaType === 'video'
+ if (isVideo) {
+ // Open video in VideoPlayer page with Play button
+ const videoPlayerUrl = `/video/${photoId}`
+ window.open(videoPlayerUrl, '_blank')
+ } else {
+ // Use image endpoint for images
+ const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
+ window.open(photoUrl, '_blank')
+ }
}
const openFolder = async (photoId: number) => {
@@ -1784,9 +1792,9 @@ export default function Search() {
)}
openPhoto(photo.id)}
+ onClick={() => openPhoto(photo.id, photo.media_type)}
className="text-blue-600 hover:underline cursor-pointer"
- title="Open photo"
+ title={photo.media_type === 'video' ? 'Open video' : 'Open photo'}
>
{photo.path}
diff --git a/admin-frontend/src/pages/Settings.tsx b/admin-frontend/src/pages/Settings.tsx
index 933cb8a..75edc6c 100644
--- a/admin-frontend/src/pages/Settings.tsx
+++ b/admin-frontend/src/pages/Settings.tsx
@@ -1,7 +1,7 @@
import { useDeveloperMode } from '../context/DeveloperModeContext'
export default function Settings() {
- const { isDeveloperMode, setDeveloperMode } = useDeveloperMode()
+ const { isDeveloperMode } = useDeveloperMode()
return (
@@ -11,24 +11,23 @@ export default function Settings() {
-
+
Developer Mode
- Enable developer features. Additional features will be available when enabled.
+ {isDeveloperMode
+ ? 'Developer mode is enabled (controlled by VITE_DEVELOPER_MODE environment variable)'
+ : 'Developer mode is disabled. Set VITE_DEVELOPER_MODE=true in .env to enable.'}
-
- setDeveloperMode(e.target.checked)}
- className="sr-only peer"
- />
-
-
+
+ {isDeveloperMode ? 'Enabled' : 'Disabled'}
+
diff --git a/admin-frontend/src/pages/Tags.tsx b/admin-frontend/src/pages/Tags.tsx
index 4d69da1..2327155 100644
--- a/admin-frontend/src/pages/Tags.tsx
+++ b/admin-frontend/src/pages/Tags.tsx
@@ -1,18 +1,11 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'
import tagsApi, { PhotoWithTagsItem, TagResponse } from '../api/tags'
import { useDeveloperMode } from '../context/DeveloperModeContext'
+import { apiClient } from '../api/client'
type ViewMode = 'list' | 'icons' | 'compact'
-interface PendingTagChange {
- photoId: number
- tagIds: number[]
-}
-
-interface PendingTagRemoval {
- photoId: number
- tagIds: number[]
-}
+// Removed unused interfaces PendingTagChange and PendingTagRemoval
interface FolderGroup {
folderPath: string
@@ -41,7 +34,7 @@ const loadFolderStatesFromStorage = (): Record => {
}
export default function Tags() {
- const { isDeveloperMode } = useDeveloperMode()
+ const { isDeveloperMode: _isDeveloperMode } = useDeveloperMode()
const [viewMode, setViewMode] = useState('list')
const [photos, setPhotos] = useState([])
const [tags, setTags] = useState([])
@@ -50,7 +43,7 @@ export default function Tags() {
const [pendingTagChanges, setPendingTagChanges] = useState>({})
const [pendingTagRemovals, setPendingTagRemovals] = useState>({})
const [loading, setLoading] = useState(false)
- const [saving, setSaving] = useState(false)
+ const [_saving, setSaving] = useState(false)
const [showManageTags, setShowManageTags] = useState(false)
const [showTagDialog, setShowTagDialog] = useState(null)
const [showBulkTagDialog, setShowBulkTagDialog] = useState(null)
@@ -189,7 +182,7 @@ export default function Tags() {
aVal = a.face_count || 0
bVal = b.face_count || 0
break
- case 'identified':
+ case 'identified': {
// Sort by identified count (identified/total ratio)
const aTotal = a.face_count || 0
const aIdentified = aTotal - (a.unidentified_face_count || 0)
@@ -206,13 +199,15 @@ export default function Tags() {
bVal = bIdentified
}
break
- case 'tags':
+ }
+ case 'tags': {
// Get tags for comparison - use photo.tags directly
const aTags = (a.tags || '').toLowerCase()
const bTags = (b.tags || '').toLowerCase()
aVal = aTags
bVal = bTags
break
+ }
default:
return 0
}
@@ -420,8 +415,10 @@ export default function Tags() {
}
}
- // Save pending changes
- const saveChanges = async () => {
+ // Save pending changes (currently unused, kept for future use)
+ // @ts-expect-error - Intentionally unused, kept for future use
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const _saveChanges = async () => {
const pendingPhotoIds = new Set([
...Object.keys(pendingTagChanges).map(Number),
...Object.keys(pendingTagRemovals).map(Number),
@@ -489,8 +486,10 @@ export default function Tags() {
}
}
- // Get pending changes count
- const pendingChangesCount = useMemo(() => {
+ // Get pending changes count (currently unused, kept for future use)
+ // @ts-expect-error - Intentionally unused, kept for future use
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const _pendingChangesCount = useMemo(() => {
const additions = Object.values(pendingTagChanges).reduce((sum, ids) => sum + ids.length, 0)
const removals = Object.values(pendingTagRemovals).reduce((sum, ids) => sum + ids.length, 0)
return additions + removals
@@ -755,7 +754,7 @@ export default function Tags() {
{photo.id}
{folder.photos.map(photo => {
- const photoUrl = `/api/v1/photos/${photo.id}/image`
+ const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photo.id}/image`
const isSelected = selectedPhotoIds.has(photo.id)
return (
@@ -1116,6 +1115,11 @@ export default function Tags() {
selectedPhotoIds={Array.from(selectedPhotoIds)}
photos={photos.filter(p => selectedPhotoIds.has(p.id))}
tags={tags}
+ onTagsUpdated={async () => {
+ // Reload tags when new tags are created
+ const tagsRes = await tagsApi.list()
+ setTags(tagsRes.items)
+ }}
onClose={async () => {
setShowTagSelectedDialog(false)
setSelectedPhotoIds(new Set())
@@ -1399,6 +1403,7 @@ function PhotoTagDialog({
getPhotoTags: (photoId: number) => Promise
}) {
const [selectedTagName, setSelectedTagName] = useState('')
+ const [newTagName, setNewTagName] = useState('')
const [photoTags, setPhotoTags] = useState([])
const [selectedTagIds, setSelectedTagIds] = useState>(new Set())
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
@@ -1417,10 +1422,36 @@ function PhotoTagDialog({
}
const handleAddTag = async () => {
- if (!selectedTagName.trim()) return
- await onAddTag(selectedTagName.trim())
- setSelectedTagName('')
- await loadPhotoTags()
+ // Collect both tags: selected existing tag and new tag name
+ const tagsToAdd: string[] = []
+
+ if (selectedTagName.trim()) {
+ tagsToAdd.push(selectedTagName.trim())
+ }
+
+ if (newTagName.trim()) {
+ tagsToAdd.push(newTagName.trim())
+ }
+
+ if (tagsToAdd.length === 0) {
+ alert('Please select a tag or enter a new tag name.')
+ return
+ }
+
+ try {
+ // Add all tags (onAddTag handles creating new tags if needed)
+ for (const tagName of tagsToAdd) {
+ await onAddTag(tagName)
+ }
+
+ // Clear inputs after successful tagging
+ setSelectedTagName('')
+ setNewTagName('')
+ await loadPhotoTags()
+ } catch (error) {
+ console.error('Failed to add tag:', error)
+ alert('Failed to add tag')
+ }
}
const handleRemoveTags = () => {
@@ -1478,11 +1509,14 @@ function PhotoTagDialog({
{photo && {photo.filename}
}
-
+
+
+ Select Existing Tag:
+
setSelectedTagName(e.target.value)}
- className="flex-1 px-3 py-2 border border-gray-300 rounded"
+ className="w-full px-3 py-2 border border-gray-300 rounded"
>
Select tag...
{tags.map(tag => (
@@ -1491,15 +1525,35 @@ function PhotoTagDialog({
))}
-
- Add
-
+
+ You can select an existing tag and enter a new tag name to add both at once.
+
+
+
+
+ Enter New Tag Name:
+
+
setNewTagName(e.target.value)}
+ className="w-full px-3 py-2 border border-gray-300 rounded"
+ placeholder="Type new tag name..."
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ handleAddTag()
+ }
+ }}
+ />
+
+ New tags will be created in the database automatically.
+
+
+ Tags:
+
{allTags.length === 0 ? (
No tags linked to this photo
) : (
@@ -1540,12 +1594,21 @@ function PhotoTagDialog({
>
Remove selected tags
-
- Close
-
+
+
+ Close
+
+
+ Add Tag
+
+
@@ -1555,7 +1618,7 @@ function PhotoTagDialog({
// Bulk Tag Dialog Component
function BulkTagDialog({
- folderPath,
+ folderPath: _folderPath,
folder,
tags,
pendingTagChanges,
@@ -1776,17 +1839,26 @@ function TagSelectedPhotosDialog({
selectedPhotoIds,
photos,
tags,
+ onTagsUpdated,
onClose,
}: {
selectedPhotoIds: number[]
photos: PhotoWithTagsItem[]
tags: TagResponse[]
+ onTagsUpdated?: () => Promise
onClose: () => void
}) {
const [selectedTagName, setSelectedTagName] = useState('')
+ const [newTagName, setNewTagName] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState>(new Set())
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [photoTagsData, setPhotoTagsData] = useState>({})
+ const [localTags, setLocalTags] = useState(tags)
+
+ // Update local tags when tags prop changes
+ useEffect(() => {
+ setLocalTags(tags)
+ }, [tags])
// Load tag linkage information for all selected photos
useEffect(() => {
@@ -1810,28 +1882,59 @@ function TagSelectedPhotosDialog({
}, [selectedPhotoIds])
const handleAddTag = async () => {
- if (!selectedTagName.trim() || selectedPhotoIds.length === 0) return
+ if (selectedPhotoIds.length === 0) return
- // Check if tag exists, create if not
- let tag = tags.find(t => t.tag_name.toLowerCase() === selectedTagName.toLowerCase().trim())
- if (!tag) {
- try {
- tag = await tagsApi.create(selectedTagName.trim())
- // Note: We don't update the tags list here since it's passed from parent
- } catch (error) {
- console.error('Failed to create tag:', error)
- alert('Failed to create tag')
- return
- }
+ // Collect both tags: selected existing tag and new tag name
+ const tagsToAdd: string[] = []
+
+ if (selectedTagName.trim()) {
+ tagsToAdd.push(selectedTagName.trim())
+ }
+
+ if (newTagName.trim()) {
+ tagsToAdd.push(newTagName.trim())
+ }
+
+ if (tagsToAdd.length === 0) {
+ alert('Please select a tag or enter a new tag name.')
+ return
}
- // Make single batch API call for all selected photos
try {
+ // Create any new tags first
+ const newTags = tagsToAdd.filter(tag =>
+ !localTags.some(availableTag =>
+ availableTag.tag_name.toLowerCase() === tag.toLowerCase()
+ )
+ )
+
+ if (newTags.length > 0) {
+ const createdTags: TagResponse[] = []
+ for (const newTag of newTags) {
+ const createdTag = await tagsApi.create(newTag)
+ createdTags.push(createdTag)
+ }
+ // Update local tags immediately with newly created tags
+ setLocalTags(prev => {
+ const updated = [...prev, ...createdTags]
+ // Sort by tag name
+ return updated.sort((a, b) => a.tag_name.localeCompare(b.tag_name))
+ })
+ // Also reload tags list in parent to keep it in sync
+ if (onTagsUpdated) {
+ await onTagsUpdated()
+ }
+ }
+
+ // Add all tags to photos in a single API call
await tagsApi.addToPhotos({
photo_ids: selectedPhotoIds,
- tag_names: [selectedTagName.trim()],
+ tag_names: tagsToAdd,
})
+
+ // Clear inputs after successful tagging
setSelectedTagName('')
+ setNewTagName('')
// Reload photo tags data to update the common tags list
const tagsData: Record = {}
@@ -1902,7 +2005,7 @@ function TagSelectedPhotosDialog({
allPhotoTags[photoId] = photoTagsData[photoId] || []
})
- const tagIdToName = new Map(tags.map(t => [t.id, t.tag_name]))
+ const tagIdToName = new Map(localTags.map(t => [t.id, t.tag_name]))
// Get all unique tag IDs from all photos
const allTagIds = new Set()
@@ -1931,7 +2034,7 @@ function TagSelectedPhotosDialog({
}
})
.filter(Boolean) as any[]
- }, [photos, tags, selectedPhotoIds, photoTagsData])
+ }, [photos, localTags, selectedPhotoIds, photoTagsData])
// Get selected tag names for confirmation message
const selectedTagNames = useMemo(() => {
@@ -1962,11 +2065,14 @@ function TagSelectedPhotosDialog({
-
+
+
+ Select Existing Tag:
+
setSelectedTagName(e.target.value)}
- className="flex-1 px-3 py-2 border border-gray-300 rounded"
+ className="w-full px-3 py-2 border border-gray-300 rounded"
>
Select tag...
{tags.map(tag => (
@@ -1975,13 +2081,29 @@ function TagSelectedPhotosDialog({
))}
-
- Add
-
+
+ You can select an existing tag and enter a new tag name to add both at once.
+
+
+
+
+ Enter New Tag Name:
+
+
setNewTagName(e.target.value)}
+ className="w-full px-3 py-2 border border-gray-300 rounded"
+ placeholder="Type new tag name..."
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ handleAddTag()
+ }
+ }}
+ />
+
+ New tags will be created in the database automatically.
+
@@ -2025,12 +2147,21 @@ function TagSelectedPhotosDialog({
>
Remove selected tags
-
- Close
-
+
+
+ Close
+
+
+ Add Tag
+
+
diff --git a/admin-frontend/src/pages/UserTaggedPhotos.tsx b/admin-frontend/src/pages/UserTaggedPhotos.tsx
index 0dd34ca..83fd100 100644
--- a/admin-frontend/src/pages/UserTaggedPhotos.tsx
+++ b/admin-frontend/src/pages/UserTaggedPhotos.tsx
@@ -469,7 +469,7 @@ export default function UserTaggedPhotos() {
/>
) : (
()
+ const videoId = id ? parseInt(id, 10) : null
+ const videoRef = useRef(null)
+ const [showPlayButton, setShowPlayButton] = useState(true)
+
+ const videoUrl = videoId ? videosApi.getVideoUrl(videoId) : ''
+
+ const handlePlay = () => {
+ if (videoRef.current) {
+ videoRef.current.play()
+ setShowPlayButton(false)
+ }
+ }
+
+ const handlePause = () => {
+ setShowPlayButton(true)
+ }
+
+ const handlePlayClick = () => {
+ handlePlay()
+ }
+
+ // Hide play button when video starts playing
+ useEffect(() => {
+ const video = videoRef.current
+ if (!video) return
+
+ const handlePlayEvent = () => {
+ setShowPlayButton(false)
+ }
+
+ const handlePauseEvent = () => {
+ setShowPlayButton(true)
+ }
+
+ const handleEnded = () => {
+ setShowPlayButton(true)
+ }
+
+ video.addEventListener('play', handlePlayEvent)
+ video.addEventListener('pause', handlePauseEvent)
+ video.addEventListener('ended', handleEnded)
+
+ return () => {
+ video.removeEventListener('play', handlePlayEvent)
+ video.removeEventListener('pause', handlePauseEvent)
+ video.removeEventListener('ended', handleEnded)
+ }
+ }, [])
+
+ if (!videoId || !videoUrl) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+ {/* Play button overlay - centered, positioned above video controls */}
+ {showPlayButton && (
+
+ )}
+
+
+ )
+}
+
diff --git a/admin-frontend/src/services/clickLogger.ts b/admin-frontend/src/services/clickLogger.ts
new file mode 100644
index 0000000..b8917a9
--- /dev/null
+++ b/admin-frontend/src/services/clickLogger.ts
@@ -0,0 +1,201 @@
+/**
+ * Click logging service for admin frontend.
+ * Sends click events to backend API for logging to file.
+ */
+
+import { apiClient } from '../api/client'
+
+interface ClickLogData {
+ page: string
+ element_type: string
+ element_id?: string
+ element_text?: string
+ context?: Record
+}
+
+// Batch clicks to avoid excessive API calls
+const CLICK_BATCH_SIZE = 10
+const CLICK_BATCH_DELAY = 1000 // 1 second
+
+let clickQueue: ClickLogData[] = []
+let batchTimeout: number | null = null
+
+/**
+ * Get the current page path.
+ */
+function getCurrentPage(): string {
+ return window.location.pathname
+}
+
+/**
+ * Get element type from HTML element.
+ */
+function getElementType(element: HTMLElement): string {
+ const tagName = element.tagName.toLowerCase()
+
+ // Map common elements
+ if (tagName === 'button' || element.getAttribute('role') === 'button') {
+ return 'button'
+ }
+ if (tagName === 'a') {
+ return 'link'
+ }
+ if (tagName === 'input') {
+ return 'input'
+ }
+ if (tagName === 'select') {
+ return 'select'
+ }
+ if (tagName === 'textarea') {
+ return 'textarea'
+ }
+
+ // Check for clickable elements
+ if (element.onclick || element.getAttribute('onclick')) {
+ return 'clickable'
+ }
+
+ // Default to tag name
+ return tagName
+}
+
+/**
+ * Get element text content (truncated to 100 chars).
+ */
+function getElementText(element: HTMLElement): string {
+ const text = element.textContent?.trim() || element.getAttribute('aria-label') || ''
+ return text.substring(0, 100)
+}
+
+/**
+ * Extract context from element (data attributes, etc.).
+ */
+function extractContext(element: HTMLElement): Record {
+ const context: Record = {}
+
+ // Extract data-* attributes
+ Array.from(element.attributes).forEach(attr => {
+ if (attr.name.startsWith('data-')) {
+ const key = attr.name.replace('data-', '').replace(/-/g, '_')
+ context[key] = attr.value
+ }
+ })
+
+ // Extract common IDs that might be useful
+ const id = element.id
+ if (id) {
+ context.element_id = id
+ }
+
+ const className = element.className
+ if (className && typeof className === 'string') {
+ context.class_name = className.split(' ').slice(0, 3).join(' ') // First 3 classes
+ }
+
+ return context
+}
+
+/**
+ * Flush queued clicks to backend.
+ */
+async function flushClickQueue(): Promise {
+ if (clickQueue.length === 0) {
+ return
+ }
+
+ const clicksToSend = [...clickQueue]
+ clickQueue = []
+
+ // Send clicks in parallel (but don't wait for all to complete)
+ clicksToSend.forEach(clickData => {
+ apiClient.post('/api/v1/log/click', clickData).catch(error => {
+ // Silently fail - don't interrupt user experience
+ console.debug('Click logging failed:', error)
+ })
+ })
+}
+
+/**
+ * Queue a click for logging.
+ */
+function queueClick(clickData: ClickLogData): void {
+ clickQueue.push(clickData)
+
+ // Flush if batch size reached
+ if (clickQueue.length >= CLICK_BATCH_SIZE) {
+ if (batchTimeout !== null) {
+ window.clearTimeout(batchTimeout)
+ batchTimeout = null
+ }
+ flushClickQueue()
+ } else {
+ // Set timeout to flush after delay
+ if (batchTimeout === null) {
+ batchTimeout = window.setTimeout(() => {
+ batchTimeout = null
+ flushClickQueue()
+ }, CLICK_BATCH_DELAY)
+ }
+ }
+}
+
+/**
+ * Log a click event.
+ */
+export function logClick(
+ element: HTMLElement,
+ additionalContext?: Record
+): void {
+ try {
+ const elementType = getElementType(element)
+ const elementId = element.id || undefined
+ const elementText = getElementText(element)
+ const page = getCurrentPage()
+ const context = {
+ ...extractContext(element),
+ ...additionalContext,
+ }
+
+ // Skip logging for certain elements (to reduce noise)
+ const skipSelectors = [
+ 'input[type="password"]',
+ 'input[type="hidden"]',
+ '[data-no-log]', // Allow opt-out via data attribute
+ ]
+
+ const shouldSkip = skipSelectors.some(selector => {
+ try {
+ return element.matches(selector)
+ } catch {
+ return false
+ }
+ })
+
+ if (shouldSkip) {
+ return
+ }
+
+ queueClick({
+ page,
+ element_type: elementType,
+ element_id: elementId,
+ element_text: elementText || undefined,
+ context: Object.keys(context).length > 0 ? context : undefined,
+ })
+ } catch (error) {
+ // Silently fail - don't interrupt user experience
+ console.debug('Click logging error:', error)
+ }
+}
+
+/**
+ * Flush any pending clicks (useful on page unload).
+ */
+export function flushPendingClicks(): void {
+ if (batchTimeout !== null) {
+ window.clearTimeout(batchTimeout)
+ batchTimeout = null
+ }
+ flushClickQueue()
+}
+
diff --git a/admin-frontend/src/vite-env.d.ts b/admin-frontend/src/vite-env.d.ts
index 9134121..26726e8 100644
--- a/admin-frontend/src/vite-env.d.ts
+++ b/admin-frontend/src/vite-env.d.ts
@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_URL?: string
+ readonly VITE_DEVELOPER_MODE?: string
}
interface ImportMeta {
diff --git a/backend/api/auth.py b/backend/api/auth.py
index 8f023f6..fe4cdf8 100644
--- a/backend/api/auth.py
+++ b/backend/api/auth.py
@@ -3,11 +3,12 @@
from __future__ import annotations
import os
+import uuid
from datetime import datetime, timedelta
from typing import Annotated
-from fastapi import APIRouter, Depends, HTTPException, status
-from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+from fastapi import APIRouter, Depends, HTTPException, Request, status
+from fastapi.security import HTTPAuthorizationCredentials
from jose import JWTError, jwt
from sqlalchemy.orm import Session
@@ -30,10 +31,50 @@ from backend.schemas.auth import (
from backend.services.role_permissions import fetch_role_permissions_map
router = APIRouter(prefix="/auth", tags=["auth"])
-security = HTTPBearer()
-# Placeholder secrets - replace with env vars in production
-SECRET_KEY = "dev-secret-key-change-in-production"
+
+def get_bearer_token(request: Request) -> HTTPAuthorizationCredentials:
+ """Custom security dependency that returns 401 for missing tokens (not 403).
+
+ This replaces HTTPBearer() to follow HTTP standards where missing authentication
+ should return 401 Unauthorized, not 403 Forbidden.
+ """
+ authorization = request.headers.get("Authorization")
+ if not authorization:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Not authenticated",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ # Parse Authorization header: "Bearer "
+ parts = authorization.split(" ", 1)
+ if len(parts) != 2:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid authentication scheme",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ scheme, credentials = parts
+ if scheme.lower() != "bearer":
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid authentication scheme",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ if not credentials:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid authentication credentials",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials)
+
+# Read secrets from environment variables
+SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 360
REFRESH_TOKEN_EXPIRE_DAYS = 7
@@ -47,7 +88,7 @@ def create_access_token(data: dict, expires_delta: timedelta) -> str:
"""Create JWT access token."""
to_encode = data.copy()
expire = datetime.utcnow() + expires_delta
- to_encode.update({"exp": expire})
+ to_encode.update({"exp": expire, "jti": str(uuid.uuid4())})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@@ -55,12 +96,34 @@ def create_refresh_token(data: dict) -> str:
"""Create JWT refresh token."""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
- to_encode.update({"exp": expire, "type": "refresh"})
+ to_encode.update({"exp": expire, "type": "refresh", "jti": str(uuid.uuid4())})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+def get_current_user_from_token(token: str) -> dict:
+ """Get current user from JWT token string (for query parameter auth).
+
+ Used for endpoints that need authentication but can't use headers
+ (e.g., EventSource/SSE endpoints).
+ """
+ try:
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+ username: str = payload.get("sub")
+ if username is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid authentication credentials",
+ )
+ return {"username": username}
+ except JWTError:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid authentication credentials",
+ )
+
+
def get_current_user(
- credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]
+ credentials: Annotated[HTTPAuthorizationCredentials, Depends(get_bearer_token)]
) -> dict:
"""Get current user from JWT token."""
try:
@@ -303,9 +366,18 @@ def get_current_user_info(
is_admin = user.is_admin if user else False
role_value = _resolve_user_role(user, is_admin)
- permissions_map = fetch_role_permissions_map(db)
- permissions = permissions_map.get(role_value, {})
-
+
+ # Fetch permissions - if it fails, return empty permissions to avoid blocking login
+ try:
+ permissions_map = fetch_role_permissions_map(db)
+ permissions = permissions_map.get(role_value, {})
+ except Exception as e:
+ # If permissions fetch fails, return empty permissions to avoid blocking login
+ # Log the error but don't fail the request
+ import traceback
+ print(f"⚠️ Failed to fetch permissions for /me endpoint: {e}")
+ print(f" Traceback: {traceback.format_exc()}")
+ permissions = {}
return UserResponse(
username=username,
is_admin=is_admin,
diff --git a/backend/api/auth_users.py b/backend/api/auth_users.py
index af9016b..f897606 100644
--- a/backend/api/auth_users.py
+++ b/backend/api/auth_users.py
@@ -69,6 +69,8 @@ def list_auth_users(
select_fields += ", role"
select_fields += ", created_at, updated_at"
+ # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
+ # Safe: select_fields is controlled (column names only, not user input)
result = auth_db.execute(text(f"""
SELECT {select_fields}
FROM users
@@ -83,6 +85,8 @@ def list_auth_users(
if has_is_active_column:
select_fields += ", is_active"
select_fields += ", created_at, updated_at"
+ # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
+ # Safe: select_fields is controlled (column names only, not user input)
result = auth_db.execute(text(f"""
SELECT {select_fields}
FROM users
@@ -291,6 +295,8 @@ def get_auth_user(
select_fields += ", role"
select_fields += ", created_at, updated_at"
+ # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
+ # Safe: select_fields is controlled (column names only, not user input), user_id is parameterized
result = auth_db.execute(text(f"""
SELECT {select_fields}
FROM users
@@ -305,6 +311,8 @@ def get_auth_user(
if has_is_active_column:
select_fields += ", is_active"
select_fields += ", created_at, updated_at"
+ # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
+ # Safe: select_fields is controlled (column names only, not user input), user_id is parameterized
result = auth_db.execute(text(f"""
SELECT {select_fields}
FROM users
@@ -450,6 +458,8 @@ def update_auth_user(
if has_role_column:
select_fields += ", role"
select_fields += ", created_at, updated_at"
+ # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
+ # Safe: update_sql and select_fields are controlled (column names only, not user input), params are parameterized
result = auth_db.execute(text(f"""
{update_sql}
RETURNING {select_fields}
diff --git a/backend/api/click_log.py b/backend/api/click_log.py
new file mode 100644
index 0000000..c2dbe08
--- /dev/null
+++ b/backend/api/click_log.py
@@ -0,0 +1,56 @@
+"""Click logging API endpoint."""
+
+from __future__ import annotations
+
+from typing import Annotated, Optional
+from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic import BaseModel
+
+from backend.api.auth import get_current_user
+from backend.utils.click_logger import log_click
+
+router = APIRouter(prefix="/log", tags=["logging"])
+
+
+class ClickLogRequest(BaseModel):
+ """Request model for click logging."""
+ page: str
+ element_type: str
+ element_id: Optional[str] = None
+ element_text: Optional[str] = None
+ context: Optional[dict] = None
+
+
+@router.post("/click")
+def log_click_event(
+ request: ClickLogRequest,
+ current_user: Annotated[dict, Depends(get_current_user)],
+) -> dict:
+ """Log a click event from the admin frontend.
+
+ Args:
+ request: Click event data
+ current_user: Authenticated user (from JWT token)
+
+ Returns:
+ Success confirmation
+ """
+ username = current_user.get("username", "unknown")
+
+ try:
+ log_click(
+ username=username,
+ page=request.page,
+ element_type=request.element_type,
+ element_id=request.element_id,
+ element_text=request.element_text,
+ context=request.context,
+ )
+ return {"status": "ok", "message": "Click logged"}
+ except Exception as e:
+ # Don't fail the request if logging fails
+ # Just return success but log the error
+ import logging
+ logging.error(f"Failed to log click: {e}")
+ return {"status": "ok", "message": "Click logged (with errors)"}
+
diff --git a/backend/api/faces.py b/backend/api/faces.py
index 64f9f4f..61d4e83 100644
--- a/backend/api/faces.py
+++ b/backend/api/faces.py
@@ -90,9 +90,9 @@ def process_faces(request: ProcessFacesRequest) -> ProcessFacesResponse:
job_timeout="1h", # Long timeout for face processing
)
- print(f"[Faces API] Enqueued face processing job: {job.id}")
- print(f"[Faces API] Job status: {job.get_status()}")
- print(f"[Faces API] Queue length: {len(queue)}")
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.info(f"Enqueued face processing job: {job.id}, status: {job.get_status()}, queue length: {len(queue)}")
return ProcessFacesResponse(
job_id=job.id,
@@ -197,12 +197,14 @@ def get_unidentified_faces(
def get_similar_faces(
face_id: int,
include_excluded: bool = Query(False, description="Include excluded faces in results"),
+ debug: bool = Query(False, description="Include debug information (encoding stats) in response"),
db: Session = Depends(get_db)
) -> SimilarFacesResponse:
"""Return similar unidentified faces for a given face."""
import logging
+ import numpy as np
logger = logging.getLogger(__name__)
- logger.info(f"API: get_similar_faces called for face_id={face_id}, include_excluded={include_excluded}")
+ logger.info(f"API: get_similar_faces called for face_id={face_id}, include_excluded={include_excluded}, debug={debug}")
# Validate face exists
base = db.query(Face).filter(Face.id == face_id).first()
@@ -210,8 +212,23 @@ def get_similar_faces(
logger.warning(f"API: Face {face_id} not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Face {face_id} not found")
+ # Load base encoding for debug info if needed
+ base_debug_info = None
+ if debug:
+ from backend.services.face_service import load_face_encoding
+ base_enc = load_face_encoding(base.encoding)
+ base_debug_info = {
+ "encoding_length": len(base_enc),
+ "encoding_min": float(np.min(base_enc)),
+ "encoding_max": float(np.max(base_enc)),
+ "encoding_mean": float(np.mean(base_enc)),
+ "encoding_std": float(np.std(base_enc)),
+ "encoding_first_10": [float(x) for x in base_enc[:10].tolist()],
+ }
+
logger.info(f"API: Calling find_similar_faces for face_id={face_id}, include_excluded={include_excluded}")
- results = find_similar_faces(db, face_id, include_excluded=include_excluded)
+ # Use 0.6 tolerance for Identify People (more lenient for manual review)
+ results = find_similar_faces(db, face_id, tolerance=0.6, include_excluded=include_excluded, debug=debug)
logger.info(f"API: find_similar_faces returned {len(results)} results")
items = [
@@ -223,12 +240,13 @@ def get_similar_faces(
quality_score=float(f.quality_score),
filename=f.photo.filename if f.photo else "unknown",
pose_mode=getattr(f, "pose_mode", None) or "frontal",
+ debug_info=debug_info if debug else None,
)
- for f, distance, confidence_pct in results
+ for f, distance, confidence_pct, debug_info in results
]
logger.info(f"API: Returning {len(items)} items for face_id={face_id}")
- return SimilarFacesResponse(base_face_id=face_id, items=items)
+ return SimilarFacesResponse(base_face_id=face_id, items=items, debug_info=base_debug_info)
@router.post("/batch-similarity", response_model=BatchSimilarityResponse)
@@ -246,10 +264,12 @@ def get_batch_similarities(
logger.info(f"API: batch_similarity called for {len(request.face_ids)} faces")
# Calculate similarities between all pairs
+ # Use 0.6 tolerance for Identify People (more lenient for manual review)
pairs = calculate_batch_similarities(
db,
request.face_ids,
min_confidence=request.min_confidence,
+ tolerance=0.6,
)
# Convert to response format
@@ -435,7 +455,9 @@ def get_face_crop(face_id: int, db: Session = Depends(get_db)) -> Response:
except HTTPException:
raise
except Exception as e:
- print(f"[Faces API] get_face_crop error for face {face_id}: {e}")
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.error(f"get_face_crop error for face {face_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to extract face crop: {str(e)}",
@@ -607,10 +629,12 @@ def auto_match_faces(
# Find matches for all identified people
# Filter by frontal reference faces if auto_accept enabled
+ # Use distance-based thresholds only when auto_accept is enabled (Run auto-match button)
matches_data = find_auto_match_matches(
db,
tolerance=request.tolerance,
- filter_frontal_only=request.auto_accept
+ filter_frontal_only=request.auto_accept,
+ use_distance_based_thresholds=request.use_distance_based_thresholds or request.auto_accept
)
# If auto_accept enabled, process matches automatically
@@ -644,7 +668,9 @@ def auto_match_faces(
)
auto_accepted_faces += identified_count
except Exception as e:
- print(f"Error auto-accepting matches for person {person_id}: {e}")
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.error(f"Error auto-accepting matches for person {person_id}: {e}")
if not matches_data:
return AutoMatchResponse(
@@ -747,7 +773,7 @@ def auto_match_faces(
@router.get("/auto-match/people", response_model=AutoMatchPeopleResponse)
def get_auto_match_people(
filter_frontal_only: bool = Query(False, description="Only include frontal/tilted reference faces"),
- tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold"),
+ tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold (default 0.6 for regular auto-match)"),
db: Session = Depends(get_db),
) -> AutoMatchPeopleResponse:
"""Get list of people for auto-match (without matches) - fast initial load.
@@ -810,7 +836,7 @@ def get_auto_match_people(
@router.get("/auto-match/people/{person_id}/matches", response_model=AutoMatchPersonMatchesResponse)
def get_auto_match_person_matches(
person_id: int,
- tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold"),
+ tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold (default 0.6 for regular auto-match)"),
filter_frontal_only: bool = Query(False, description="Only return frontal/tilted faces"),
db: Session = Depends(get_db),
) -> AutoMatchPersonMatchesResponse:
diff --git a/backend/api/jobs.py b/backend/api/jobs.py
index 1ee0b2a..bf2351b 100644
--- a/backend/api/jobs.py
+++ b/backend/api/jobs.py
@@ -4,15 +4,17 @@ from __future__ import annotations
from datetime import datetime
-from fastapi import APIRouter, HTTPException, status
+from fastapi import APIRouter, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from rq import Queue
from rq.job import Job
from redis import Redis
import json
import time
+from typing import Optional
from backend.schemas.jobs import JobResponse, JobStatus
+from backend.api.auth import get_current_user_from_token
router = APIRouter(prefix="/jobs", tags=["jobs"])
@@ -89,8 +91,26 @@ def get_job(job_id: str) -> JobResponse:
@router.get("/stream/{job_id}")
-def stream_job_progress(job_id: str):
- """Stream job progress via Server-Sent Events (SSE)."""
+def stream_job_progress(
+ job_id: str,
+ token: Optional[str] = Query(None, description="JWT token for authentication"),
+):
+ """Stream job progress via Server-Sent Events (SSE).
+
+ Note: EventSource cannot send custom headers, so authentication
+ is done via query parameter 'token'.
+ """
+ # Authenticate user via token query parameter (required for EventSource)
+ if not token:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Authentication required. Provide 'token' query parameter.",
+ )
+
+ try:
+ get_current_user_from_token(token)
+ except HTTPException as e:
+ raise e
def event_generator():
"""Generate SSE events for job progress."""
diff --git a/backend/api/pending_linkages.py b/backend/api/pending_linkages.py
index 00e1b0d..a362c4b 100644
--- a/backend/api/pending_linkages.py
+++ b/backend/api/pending_linkages.py
@@ -138,6 +138,8 @@ def list_pending_linkages(
status_clause = "WHERE pl.status = :status_filter"
params["status_filter"] = status_filter
+ # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
+ # Safe: SQL uses only column names (no user input in query structure)
result = auth_db.execute(
text(
f"""
diff --git a/backend/api/pending_photos.py b/backend/api/pending_photos.py
index 57238fe..165281f 100644
--- a/backend/api/pending_photos.py
+++ b/backend/api/pending_photos.py
@@ -266,115 +266,246 @@ def review_pending_photos(
"""
import shutil
import uuid
+ import traceback
+ import logging
+
+ logger = logging.getLogger(__name__)
approved_count = 0
rejected_count = 0
duplicate_count = 0
errors = []
- admin_user_id = current_user.get("user_id")
- now = datetime.utcnow()
- # Base directories
- # Try to get upload directory from environment, fallback to hardcoded path
- upload_base_dir = Path(os.getenv("UPLOAD_DIR") or os.getenv("PENDING_PHOTOS_DIR") or "/mnt/db-server-uploads")
- main_storage_dir = Path(PHOTO_STORAGE_DIR)
- main_storage_dir.mkdir(parents=True, exist_ok=True)
-
- for decision in request.decisions:
+ try:
+ admin_user_id = current_user.get("user_id")
+ if not admin_user_id:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="User ID not found in authentication token"
+ )
+
+ now = datetime.utcnow()
+
+ # Base directories
+ # Try to get upload directory from environment, fallback to hardcoded path
+ upload_base_dir = Path(os.getenv("UPLOAD_DIR") or os.getenv("PENDING_PHOTOS_DIR") or "/mnt/db-server-uploads")
+
+ # Resolve PHOTO_STORAGE_DIR relative to project root (/opt/punimtag)
+ # If it's already absolute, use it as-is; otherwise resolve relative to project root
+ photo_storage_path = PHOTO_STORAGE_DIR
+ if not os.path.isabs(photo_storage_path):
+ # Get project root (backend/api/pending_photos.py -> backend/api -> backend -> project root)
+ project_root = Path(__file__).resolve().parents[2]
+ main_storage_dir = project_root / photo_storage_path
+ else:
+ main_storage_dir = Path(photo_storage_path)
+
+ # Ensure main storage directory exists
+ # Try to create the directory and all parent directories
try:
- # Get pending photo from auth database with file info
- # Only allow processing 'pending' status photos
- result = auth_db.execute(text("""
- SELECT
- pp.id,
- pp.status,
- pp.file_path,
- pp.filename,
- pp.original_filename
- FROM pending_photos pp
- WHERE pp.id = :id AND pp.status = 'pending'
- """), {"id": decision.id})
+ # Check if parent directory exists and is writable
+ parent_dir = main_storage_dir.parent
+ if parent_dir.exists():
+ if not os.access(parent_dir, os.W_OK):
+ error_msg = (
+ f"Permission denied: Cannot create directory {main_storage_dir}. "
+ f"Parent directory {parent_dir} exists but is not writable. "
+ f"Please ensure the directory is writable by the application user (appuser)."
+ )
+ logger.error(error_msg)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=error_msg
+ )
- row = result.fetchone()
- if not row:
- errors.append(f"Pending photo {decision.id} not found or already reviewed")
- continue
+ # Create directory and all parent directories
+ main_storage_dir.mkdir(parents=True, exist_ok=True)
- if decision.decision == 'approve':
- # Find the source file
- db_file_path = row.file_path
- source_path = None
+ # Verify we can write to it
+ if not os.access(main_storage_dir, os.W_OK):
+ error_msg = (
+ f"Permission denied: Directory {main_storage_dir} exists but is not writable. "
+ f"Please ensure the directory is writable by the application user (appuser)."
+ )
+ logger.error(error_msg)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=error_msg
+ )
- # Try to find the file - handle both absolute and relative paths
- if os.path.isabs(db_file_path):
- # Use absolute path directly
- source_path = Path(db_file_path)
- else:
- # Try relative to upload base directory
- source_path = upload_base_dir / db_file_path
+ except HTTPException:
+ # Re-raise HTTP exceptions
+ raise
+ except PermissionError as e:
+ error_msg = (
+ f"Permission denied creating main storage directory {main_storage_dir}. "
+ f"Error: {str(e)}. Please ensure the directory and parent directories are writable by the application user (appuser)."
+ )
+ logger.error(error_msg)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=error_msg
+ )
+ except Exception as e:
+ error_msg = f"Failed to create main storage directory {main_storage_dir}: {str(e)}"
+ logger.error(error_msg)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=error_msg
+ )
+
+ if not request.decisions:
+ return ReviewResponse(
+ approved=0,
+ rejected=0,
+ errors=["No decisions provided"],
+ warnings=[]
+ )
+
+ for decision in request.decisions:
+ try:
+ # Get pending photo from auth database with file info
+ # Only allow processing 'pending' status photos
+ result = auth_db.execute(text("""
+ SELECT
+ pp.id,
+ pp.status,
+ pp.file_path,
+ pp.filename,
+ pp.original_filename
+ FROM pending_photos pp
+ WHERE pp.id = :id AND pp.status = 'pending'
+ """), {"id": decision.id})
- # If file doesn't exist, try alternative locations
- if not source_path.exists():
- # Try with just the filename in upload_base_dir
- source_path = upload_base_dir / row.filename
- if not source_path.exists() and row.original_filename:
- # Try with original filename
- source_path = upload_base_dir / row.original_filename
- # If still not found, try looking in user subdirectories
- if not source_path.exists() and upload_base_dir.exists():
- # Check if file_path contains user ID subdirectory
- # file_path format might be: {userId}/{filename} or full path
- try:
- for user_id_dir in upload_base_dir.iterdir():
- if user_id_dir.is_dir():
- potential_path = user_id_dir / row.filename
- if potential_path.exists():
- source_path = potential_path
- break
- if row.original_filename:
- potential_path = user_id_dir / row.original_filename
+ row = result.fetchone()
+ if not row:
+ errors.append(f"Pending photo {decision.id} not found or already reviewed")
+ continue
+
+ if decision.decision == 'approve':
+ # Find the source file
+ db_file_path = row.file_path
+ source_path = None
+
+ # Try to find the file - handle both absolute and relative paths
+ if os.path.isabs(db_file_path):
+ # Use absolute path directly
+ source_path = Path(db_file_path)
+ else:
+ # Try relative to upload base directory
+ source_path = upload_base_dir / db_file_path
+
+ # If file doesn't exist, try alternative locations
+ if not source_path.exists():
+ # Try with just the filename in upload_base_dir
+ source_path = upload_base_dir / row.filename
+ if not source_path.exists() and row.original_filename:
+ # Try with original filename
+ source_path = upload_base_dir / row.original_filename
+ # If still not found, try looking in user subdirectories
+ if not source_path.exists() and upload_base_dir.exists():
+ # Check if file_path contains user ID subdirectory
+ # file_path format might be: {userId}/{filename} or full path
+ try:
+ for user_id_dir in upload_base_dir.iterdir():
+ if user_id_dir.is_dir():
+ potential_path = user_id_dir / row.filename
if potential_path.exists():
source_path = potential_path
break
- except (PermissionError, OSError) as e:
- # Can't read directory, skip this search
- pass
-
- if not source_path.exists():
- errors.append(f"Photo file not found for pending photo {decision.id}. Tried: {db_file_path}, {upload_base_dir / row.filename}, {upload_base_dir / row.original_filename if row.original_filename else 'N/A'}")
- continue
-
- # Calculate file hash and check for duplicates BEFORE moving file
- try:
- file_hash = calculate_file_hash(str(source_path))
- except Exception as e:
- errors.append(f"Failed to calculate hash for pending photo {decision.id}: {str(e)}")
- continue
-
- # Check if photo with same hash already exists in main database
- # Handle case where file_hash column might not exist or be NULL for old photos
- try:
- existing_photo = main_db.execute(text("""
- SELECT id, path FROM photos WHERE file_hash = :file_hash AND file_hash IS NOT NULL
- """), {"file_hash": file_hash}).fetchone()
- except Exception as e:
- # If file_hash column doesn't exist, skip duplicate check
- # This can happen if database schema is outdated
- if "no such column" in str(e).lower() or "file_hash" in str(e).lower():
- existing_photo = None
- else:
- raise
-
- if existing_photo:
- # Photo already exists - mark as duplicate and skip import
- # Don't add to errors - we'll show a summary message instead
- # Update status to rejected with duplicate reason
+ if row.original_filename:
+ potential_path = user_id_dir / row.original_filename
+ if potential_path.exists():
+ source_path = potential_path
+ break
+ except (PermissionError, OSError) as e:
+ # Can't read directory, skip this search
+ pass
+
+ if not source_path.exists():
+ errors.append(f"Photo file not found for pending photo {decision.id}. Tried: {db_file_path}, {upload_base_dir / row.filename}, {upload_base_dir / row.original_filename if row.original_filename else 'N/A'}")
+ continue
+
+ # Calculate file hash and check for duplicates BEFORE moving file
+ try:
+ file_hash = calculate_file_hash(str(source_path))
+ except Exception as e:
+ errors.append(f"Failed to calculate hash for pending photo {decision.id}: {str(e)}")
+ continue
+
+ # Check if photo with same hash already exists in main database
+ # Handle case where file_hash column might not exist or be NULL for old photos
+ try:
+ existing_photo = main_db.execute(text("""
+ SELECT id, path FROM photos WHERE file_hash = :file_hash AND file_hash IS NOT NULL
+ """), {"file_hash": file_hash}).fetchone()
+ except Exception as e:
+ # If file_hash column doesn't exist, skip duplicate check
+ # This can happen if database schema is outdated
+ if "no such column" in str(e).lower() or "file_hash" in str(e).lower():
+ existing_photo = None
+ else:
+ raise
+
+ if existing_photo:
+ # Photo already exists - mark as duplicate and skip import
+ # Don't add to errors - we'll show a summary message instead
+ # Update status to rejected with duplicate reason
+ auth_db.execute(text("""
+ UPDATE pending_photos
+ SET status = 'rejected',
+ reviewed_at = :reviewed_at,
+ reviewed_by = :reviewed_by,
+ rejection_reason = 'Duplicate photo already exists in database'
+ WHERE id = :id
+ """), {
+ "id": decision.id,
+ "reviewed_at": now,
+ "reviewed_by": admin_user_id,
+ })
+ auth_db.commit()
+ rejected_count += 1
+ duplicate_count += 1
+ continue
+
+ # Generate unique filename for main storage to avoid conflicts
+ file_ext = source_path.suffix
+ unique_filename = f"{uuid.uuid4()}{file_ext}"
+ dest_path = main_storage_dir / unique_filename
+
+ # Copy file to main storage (keep original in shared location)
+ try:
+ shutil.copy2(str(source_path), str(dest_path))
+ except Exception as e:
+ errors.append(f"Failed to copy photo file for {decision.id}: {str(e)}")
+ continue
+
+ # Import photo into main database (Scan process)
+ # This will also check for duplicates by hash, but we've already checked above
+ try:
+ photo, is_new = import_photo_from_path(main_db, str(dest_path))
+ if not is_new:
+ # Photo already exists (shouldn't happen due to hash check above, but handle gracefully)
+ if dest_path.exists():
+ dest_path.unlink()
+ errors.append(f"Photo already exists in main database: {photo.path}")
+ continue
+ except Exception as e:
+ # If import fails, delete the copied file (original remains in shared location)
+ if dest_path.exists():
+ try:
+ dest_path.unlink()
+ except:
+ pass
+ errors.append(f"Failed to import photo {decision.id} into main database: {str(e)}")
+ continue
+
+ # Update status to approved in auth database
auth_db.execute(text("""
UPDATE pending_photos
- SET status = 'rejected',
+ SET status = 'approved',
reviewed_at = :reviewed_at,
- reviewed_by = :reviewed_by,
- rejection_reason = 'Duplicate photo already exists in database'
+ reviewed_by = :reviewed_by
WHERE id = :id
"""), {
"id": decision.id,
@@ -382,99 +513,61 @@ def review_pending_photos(
"reviewed_by": admin_user_id,
})
auth_db.commit()
+
+ approved_count += 1
+
+ elif decision.decision == 'reject':
+ # Update status to rejected
+ auth_db.execute(text("""
+ UPDATE pending_photos
+ SET status = 'rejected',
+ reviewed_at = :reviewed_at,
+ reviewed_by = :reviewed_by,
+ rejection_reason = :rejection_reason
+ WHERE id = :id
+ """), {
+ "id": decision.id,
+ "reviewed_at": now,
+ "reviewed_by": admin_user_id,
+ "rejection_reason": decision.rejection_reason or None,
+ })
+ auth_db.commit()
+
rejected_count += 1
- duplicate_count += 1
- continue
-
- # Generate unique filename for main storage to avoid conflicts
- file_ext = source_path.suffix
- unique_filename = f"{uuid.uuid4()}{file_ext}"
- dest_path = main_storage_dir / unique_filename
-
- # Copy file to main storage (keep original in shared location)
- try:
- shutil.copy2(str(source_path), str(dest_path))
- except Exception as e:
- errors.append(f"Failed to copy photo file for {decision.id}: {str(e)}")
- continue
-
- # Import photo into main database (Scan process)
- # This will also check for duplicates by hash, but we've already checked above
- try:
- photo, is_new = import_photo_from_path(main_db, str(dest_path))
- if not is_new:
- # Photo already exists (shouldn't happen due to hash check above, but handle gracefully)
- if dest_path.exists():
- dest_path.unlink()
- errors.append(f"Photo already exists in main database: {photo.path}")
- continue
- except Exception as e:
- # If import fails, delete the copied file (original remains in shared location)
- if dest_path.exists():
- try:
- dest_path.unlink()
- except:
- pass
- errors.append(f"Failed to import photo {decision.id} into main database: {str(e)}")
- continue
-
- # Update status to approved in auth database
- auth_db.execute(text("""
- UPDATE pending_photos
- SET status = 'approved',
- reviewed_at = :reviewed_at,
- reviewed_by = :reviewed_by
- WHERE id = :id
- """), {
- "id": decision.id,
- "reviewed_at": now,
- "reviewed_by": admin_user_id,
- })
- auth_db.commit()
-
- approved_count += 1
-
- elif decision.decision == 'reject':
- # Update status to rejected
- auth_db.execute(text("""
- UPDATE pending_photos
- SET status = 'rejected',
- reviewed_at = :reviewed_at,
- reviewed_by = :reviewed_by,
- rejection_reason = :rejection_reason
- WHERE id = :id
- """), {
- "id": decision.id,
- "reviewed_at": now,
- "reviewed_by": admin_user_id,
- "rejection_reason": decision.rejection_reason or None,
- })
- auth_db.commit()
-
- rejected_count += 1
+ else:
+ errors.append(f"Invalid decision '{decision.decision}' for pending photo {decision.id}")
+
+ except Exception as e:
+ errors.append(f"Error processing pending photo {decision.id}: {str(e)}")
+ # Rollback any partial changes
+ auth_db.rollback()
+ main_db.rollback()
+
+ # Add friendly message about duplicates if any were found
+ warnings = []
+ if duplicate_count > 0:
+ if duplicate_count == 1:
+ warnings.append(f"{duplicate_count} photo was not added as it already exists in the database")
else:
- errors.append(f"Invalid decision '{decision.decision}' for pending photo {decision.id}")
-
- except Exception as e:
- errors.append(f"Error processing pending photo {decision.id}: {str(e)}")
- # Rollback any partial changes
- auth_db.rollback()
- main_db.rollback()
-
- # Add friendly message about duplicates if any were found
- warnings = []
- if duplicate_count > 0:
- if duplicate_count == 1:
- warnings.append(f"{duplicate_count} photo was not added as it already exists in the database")
- else:
- warnings.append(f"{duplicate_count} photos were not added as they already exist in the database")
-
- return ReviewResponse(
- approved=approved_count,
- rejected=rejected_count,
- errors=errors,
- warnings=warnings
- )
+ warnings.append(f"{duplicate_count} photos were not added as they already exist in the database")
+
+ return ReviewResponse(
+ approved=approved_count,
+ rejected=rejected_count,
+ errors=errors,
+ warnings=warnings
+ )
+ except HTTPException:
+ # Re-raise HTTP exceptions as-is
+ raise
+ except Exception as e:
+ # Catch any unexpected errors and log them
+ error_traceback = traceback.format_exc()
+ logger.error(f"Unexpected error in review_pending_photos: {str(e)}\n{error_traceback}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Internal server error while processing photo review: {str(e)}"
+ )
class CleanupResponse(BaseModel):
diff --git a/backend/api/people.py b/backend/api/people.py
index ad6d774..c17b001 100644
--- a/backend/api/people.py
+++ b/backend/api/people.py
@@ -6,7 +6,7 @@ from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
-from sqlalchemy import func
+from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from backend.db.session import get_db
@@ -48,12 +48,12 @@ def list_people(
@router.get("/with-faces", response_model=PeopleWithFacesListResponse)
def list_people_with_faces(
- last_name: str | None = Query(None, description="Filter by last name or maiden name (case-insensitive)"),
+ last_name: str | None = Query(None, description="Filter by first, middle, last, or maiden name (case-insensitive)"),
db: Session = Depends(get_db),
) -> PeopleWithFacesListResponse:
"""List all people with face counts and video counts, sorted by last_name, first_name.
- Optionally filter by last_name or maiden_name if provided (case-insensitive search).
+ Optionally filter by first_name, middle_name, last_name, or maiden_name if provided (case-insensitive search).
Returns all people, including those with zero faces or videos.
"""
# Query people with face counts using LEFT OUTER JOIN to include people with no faces
@@ -67,11 +67,15 @@ def list_people_with_faces(
)
if last_name:
- # Case-insensitive search on both last_name and maiden_name
+ # Case-insensitive search on first_name, middle_name, last_name, and maiden_name
search_term = last_name.lower()
query = query.filter(
- (func.lower(Person.last_name).contains(search_term)) |
- ((Person.maiden_name.isnot(None)) & (func.lower(Person.maiden_name).contains(search_term)))
+ or_(
+ func.lower(Person.first_name).contains(search_term),
+ func.lower(Person.middle_name).contains(search_term),
+ func.lower(Person.last_name).contains(search_term),
+ ((Person.maiden_name.isnot(None)) & (func.lower(Person.maiden_name).contains(search_term)))
+ )
)
results = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all()
@@ -266,9 +270,17 @@ def accept_matches(
from backend.api.auth import get_current_user_with_id
user_id = current_user["user_id"]
- identified_count, updated_count = accept_auto_match_matches(
- db, person_id, request.face_ids, user_id=user_id
- )
+ try:
+ identified_count, updated_count = accept_auto_match_matches(
+ db, person_id, request.face_ids, user_id=user_id
+ )
+ except ValueError as e:
+ if "not found" in str(e).lower():
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=str(e)
+ )
+ raise
return IdentifyFaceResponse(
identified_face_ids=request.face_ids,
diff --git a/backend/api/photos.py b/backend/api/photos.py
index aca7d01..9a3cfb3 100644
--- a/backend/api/photos.py
+++ b/backend/api/photos.py
@@ -5,8 +5,8 @@ from __future__ import annotations
from datetime import date, datetime
from typing import List, Optional
-from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
-from fastapi.responses import JSONResponse, FileResponse
+from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status, Request
+from fastapi.responses import JSONResponse, FileResponse, Response
from typing import Annotated
from rq import Queue
from redis import Redis
@@ -29,6 +29,8 @@ from backend.schemas.photos import (
BulkDeletePhotosResponse,
BulkRemoveFavoritesRequest,
BulkRemoveFavoritesResponse,
+ BrowseDirectoryResponse,
+ DirectoryItem,
)
from backend.schemas.search import (
PhotoSearchResult,
@@ -130,6 +132,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
+ media_type=photo.media_type or "image",
person_name=full_name,
tags=tags,
has_faces=face_count > 0,
@@ -158,6 +161,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
+ media_type=photo.media_type or "image",
person_name=person_name_val,
tags=tags,
has_faces=face_count > 0,
@@ -193,6 +197,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
+ media_type=photo.media_type or "image",
person_name=person_name_val,
tags=tags,
has_faces=face_count > 0,
@@ -214,6 +219,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
+ media_type=photo.media_type or "image",
person_name=None,
tags=tags,
has_faces=False,
@@ -236,6 +242,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
+ media_type=photo.media_type or "image",
person_name=person_name_val,
tags=[],
has_faces=face_count > 0,
@@ -259,6 +266,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
+ media_type=photo.media_type or "image",
person_name=person_name_val,
tags=tags,
has_faces=face_count > 0,
@@ -282,6 +290,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
+ media_type=photo.media_type or "image",
person_name=person_name_val,
tags=tags,
has_faces=face_count > 0,
@@ -310,6 +319,7 @@ def search_photos(
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
+ media_type=photo.media_type or "image",
person_name=person_name_val,
tags=tags,
has_faces=face_count > 0,
@@ -329,6 +339,7 @@ def search_photos(
@router.post("/import", response_model=PhotoImportResponse)
def import_photos(
request: PhotoImportRequest,
+ current_user: Annotated[dict, Depends(get_current_user)],
) -> PhotoImportResponse:
"""Import photos from a folder path.
@@ -371,7 +382,7 @@ def import_photos(
@router.post("/import/upload")
async def upload_photos(
- files: list[UploadFile] = File(...),
+ request: Request,
db: Session = Depends(get_db),
) -> dict:
"""Upload photo files directly.
@@ -383,6 +394,7 @@ async def upload_photos(
import os
import shutil
from pathlib import Path
+ from datetime import datetime, date
from backend.settings import PHOTO_STORAGE_DIR
@@ -394,6 +406,49 @@ async def upload_photos(
existing_count = 0
errors = []
+ # Read form data first to get both files and metadata
+ form_data = await request.form()
+
+ import logging
+ logger = logging.getLogger(__name__)
+
+ # Extract file metadata (EXIF dates and original modification timestamps) from form data
+ # These are captured from the ORIGINAL file BEFORE upload, so they preserve the real dates
+ file_original_mtime = {}
+ file_exif_dates = {}
+ files = []
+
+ # Extract files first using getlist (handles multiple files with same key)
+ files = form_data.getlist('files')
+
+ # Extract metadata from form data
+ for key, value in form_data.items():
+ if key.startswith('file_exif_date_'):
+ # Extract EXIF date from browser (format: file_exif_date_)
+ filename = key.replace('file_exif_date_', '')
+ file_exif_dates[filename] = str(value)
+ elif key.startswith('file_original_mtime_'):
+ # Extract original file modification time from browser (format: file_original_mtime_)
+ # This is the modification date from the ORIGINAL file before upload
+ filename = key.replace('file_original_mtime_', '')
+ try:
+ file_original_mtime[filename] = int(value)
+ except (ValueError, TypeError) as e:
+ logger.debug(f"Could not parse original mtime for {filename}: {e}")
+
+ # If no files found in form_data, try to get them from request directly
+ if not files:
+ # Fallback: try to get files from request.files() if available
+ try:
+ if hasattr(request, '_form'):
+ form = await request.form()
+ files = form.getlist('files')
+ except:
+ pass
+
+ if not files:
+ raise HTTPException(status_code=400, detail="No files provided")
+
for file in files:
try:
# Generate unique filename to avoid conflicts
@@ -408,8 +463,63 @@ async def upload_photos(
with open(stored_path, "wb") as f:
f.write(content)
+ # Extract date metadata from browser BEFORE upload
+ # Priority: 1) Browser EXIF date, 2) Original file modification date (from before upload)
+ # This ensures we use the ORIGINAL file's metadata, not the server's copy
+ browser_exif_date = None
+ file_last_modified = None
+
+ # First try: Use EXIF date extracted in browser (from original file)
+ if file.filename in file_exif_dates:
+ exif_date_str = file_exif_dates[file.filename]
+ logger.info(f"[UPLOAD] Found browser EXIF date for {file.filename}: {exif_date_str}")
+ try:
+ # Parse EXIF date string (format: "YYYY:MM:DD HH:MM:SS" or ISO format)
+ from dateutil import parser
+ exif_datetime = parser.parse(exif_date_str)
+ browser_exif_date = exif_datetime.date()
+ # Validate the date
+ if browser_exif_date > date.today() or browser_exif_date < date(1900, 1, 1):
+ logger.warning(f"[UPLOAD] Browser EXIF date {browser_exif_date} is invalid for {file.filename}, trying original mtime")
+ browser_exif_date = None
+ else:
+ logger.info(f"[UPLOAD] Parsed browser EXIF date: {browser_exif_date} for {file.filename}")
+ except Exception as e:
+ logger.warning(f"[UPLOAD] Could not parse browser EXIF date '{exif_date_str}' for {file.filename}: {e}, trying original mtime")
+ browser_exif_date = None
+ else:
+ logger.debug(f"[UPLOAD] No browser EXIF date found for {file.filename}")
+
+ # Second try: Use original file modification time (captured BEFORE upload)
+ if file.filename in file_original_mtime:
+ timestamp_ms = file_original_mtime[file.filename]
+ logger.info(f"[UPLOAD] Found original mtime for {file.filename}: {timestamp_ms}")
+ try:
+ file_last_modified = datetime.fromtimestamp(timestamp_ms / 1000.0).date()
+ # Validate the date
+ if file_last_modified > date.today() or file_last_modified < date(1900, 1, 1):
+ logger.warning(f"[UPLOAD] Original file mtime {file_last_modified} is invalid for {file.filename}")
+ file_last_modified = None
+ else:
+ logger.info(f"[UPLOAD] Parsed original mtime: {file_last_modified} for {file.filename}")
+ except (ValueError, OSError) as e:
+ logger.warning(f"[UPLOAD] Could not parse original mtime timestamp {timestamp_ms} for {file.filename}: {e}")
+ file_last_modified = None
+ else:
+ logger.debug(f"[UPLOAD] No original mtime found for {file.filename}")
+
+ logger.info(f"[UPLOAD] Calling import_photo_from_path for {file.filename} with browser_exif_date={browser_exif_date}, file_last_modified={file_last_modified}")
# Import photo from stored location
- photo, is_new = import_photo_from_path(db, str(stored_path))
+ # Pass browser-extracted EXIF date and file modification time separately
+ # Priority: browser_exif_date > server EXIF extraction > file_last_modified
+ photo, is_new = import_photo_from_path(
+ db,
+ str(stored_path),
+ is_uploaded_file=True,
+ file_last_modified=file_last_modified,
+ browser_exif_date=browser_exif_date
+ )
+
if is_new:
added_count += 1
else:
@@ -428,6 +538,112 @@ async def upload_photos(
}
+@router.get("/browse-directory", response_model=BrowseDirectoryResponse)
+def browse_directory(
+ current_user: Annotated[dict, Depends(get_current_user)],
+ path: str = Query("/", description="Directory path to list"),
+) -> BrowseDirectoryResponse:
+ """List directories and files in a given path.
+
+ No GUI required - uses os.listdir() to read filesystem.
+ Returns JSON with directory structure for web-based folder browser.
+
+ Args:
+ path: Directory path to list (can be relative or absolute)
+
+ Returns:
+ BrowseDirectoryResponse with current path, parent path, and items list
+
+ Raises:
+ HTTPException: If path doesn't exist, is not a directory, or access is denied
+ """
+ import os
+ from pathlib import Path
+
+ try:
+ # Convert to absolute path
+ abs_path = os.path.abspath(path)
+
+ # Normalize path separators
+ abs_path = os.path.normpath(abs_path)
+
+ # Security: Optional - restrict to certain base paths
+ # For now, allow any path (server admin should configure file permissions)
+ # You can uncomment and customize this for production:
+ # allowed_bases = ["/home", "/mnt", "/opt/punimtag", "/media"]
+ # if not any(abs_path.startswith(base) for base in allowed_bases):
+ # raise HTTPException(
+ # status_code=status.HTTP_403_FORBIDDEN,
+ # detail=f"Path not allowed: {abs_path}"
+ # )
+
+ if not os.path.exists(abs_path):
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Path does not exist: {abs_path}",
+ )
+
+ if not os.path.isdir(abs_path):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Path is not a directory: {abs_path}",
+ )
+
+ # Read directory contents
+ items = []
+ try:
+ for item in os.listdir(abs_path):
+ item_path = os.path.join(abs_path, item)
+ full_path = os.path.abspath(item_path)
+
+ # Skip if we can't access it (permission denied)
+ try:
+ is_dir = os.path.isdir(full_path)
+ is_file = os.path.isfile(full_path)
+ except (OSError, PermissionError):
+ # Skip items we can't access
+ continue
+
+ items.append(
+ DirectoryItem(
+ name=item,
+ path=full_path,
+ is_directory=is_dir,
+ is_file=is_file,
+ )
+ )
+ except PermissionError:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=f"Permission denied reading directory: {abs_path}",
+ )
+
+ # Sort: directories first, then files, both alphabetically
+ items.sort(key=lambda x: (not x.is_directory, x.name.lower()))
+
+ # Get parent path (None if at root)
+ parent_path = None
+ if abs_path != "/" and abs_path != os.path.dirname(abs_path):
+ parent_path = os.path.dirname(abs_path)
+ # Normalize parent path
+ parent_path = os.path.normpath(parent_path)
+
+ return BrowseDirectoryResponse(
+ current_path=abs_path,
+ parent_path=parent_path,
+ items=items,
+ )
+
+ except HTTPException:
+ # Re-raise HTTP exceptions as-is
+ raise
+ except Exception as e:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error reading directory: {str(e)}",
+ )
+
+
@router.post("/browse-folder")
def browse_folder() -> dict:
"""Open native folder picker dialog and return selected folder path.
@@ -556,11 +772,16 @@ def get_photo(photo_id: int, db: Session = Depends(get_db)) -> PhotoResponse:
@router.get("/{photo_id}/image")
-def get_photo_image(photo_id: int, db: Session = Depends(get_db)) -> FileResponse:
- """Serve photo image file for display (not download)."""
+def get_photo_image(
+ photo_id: int,
+ request: Request,
+ db: Session = Depends(get_db)
+):
+ """Serve photo image or video file for display (not download)."""
import os
import mimetypes
from backend.db.models import Photo
+ from starlette.responses import FileResponse
photo = db.query(Photo).filter(Photo.id == photo_id).first()
if not photo:
@@ -575,7 +796,81 @@ def get_photo_image(photo_id: int, db: Session = Depends(get_db)) -> FileRespons
detail=f"Photo file not found: {photo.path}",
)
- # Determine media type from file extension
+ # If it's a video, handle range requests for video streaming
+ if photo.media_type == "video":
+ media_type, _ = mimetypes.guess_type(photo.path)
+ if not media_type or not media_type.startswith('video/'):
+ media_type = "video/mp4"
+
+ file_size = os.path.getsize(photo.path)
+ # Get range header - Starlette uses lowercase
+ range_header = request.headers.get("range")
+
+ # Debug: log what we're getting (remove after debugging)
+ if photo_id == 737: # Only for this specific video
+ import json
+ debug_info = {
+ "range_header": range_header,
+ "all_headers": dict(request.headers),
+ "header_keys": list(request.headers.keys())
+ }
+ print(f"DEBUG photo 737: {json.dumps(debug_info, indent=2)}")
+
+ if range_header:
+ try:
+ # Parse range header: "bytes=start-end" or "bytes=start-" or "bytes=-suffix"
+ range_match = range_header.replace("bytes=", "").split("-")
+ start_str = range_match[0].strip()
+ end_str = range_match[1].strip() if len(range_match) > 1 and range_match[1] else ""
+
+ start = int(start_str) if start_str else 0
+ end = int(end_str) if end_str else file_size - 1
+
+ # Validate range
+ if start < 0:
+ start = 0
+ if end >= file_size:
+ end = file_size - 1
+ if start > end:
+ return Response(
+ status_code=416,
+ headers={"Content-Range": f"bytes */{file_size}"}
+ )
+
+ # Read the requested chunk
+ chunk_size = end - start + 1
+ with open(photo.path, "rb") as f:
+ f.seek(start)
+ chunk = f.read(chunk_size)
+
+ return Response(
+ content=chunk,
+ status_code=206,
+ headers={
+ "Content-Range": f"bytes {start}-{end}/{file_size}",
+ "Accept-Ranges": "bytes",
+ "Content-Length": str(chunk_size),
+ "Content-Type": media_type,
+ "Content-Disposition": "inline",
+ "Cache-Control": "public, max-age=3600",
+ },
+ media_type=media_type,
+ )
+ except (ValueError, IndexError) as e:
+ # If range parsing fails, fall through to serve full file
+ pass
+
+ # No range request or parsing failed - serve full file with range support headers
+ response = FileResponse(
+ photo.path,
+ media_type=media_type,
+ )
+ response.headers["Content-Disposition"] = "inline"
+ response.headers["Accept-Ranges"] = "bytes"
+ response.headers["Cache-Control"] = "public, max-age=3600"
+ return response
+
+ # Determine media type from file extension for images
media_type, _ = mimetypes.guess_type(photo.path)
if not media_type or not media_type.startswith('image/'):
media_type = "image/jpeg"
@@ -787,8 +1082,18 @@ def bulk_delete_photos(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
db: Session = Depends(get_db),
) -> BulkDeletePhotosResponse:
- """Delete multiple photos and all related data (faces, encodings, tags, favorites)."""
+ """Delete multiple photos and all related data (faces, encodings, tags, favorites).
+
+ If a photo's file is in the uploads folder, it will also be deleted from the filesystem
+ to prevent duplicate uploads.
+ """
+ import os
+ import logging
+ from pathlib import Path
from backend.db.models import Photo, PhotoTagLinkage
+ from backend.settings import PHOTO_STORAGE_DIR
+
+ logger = logging.getLogger(__name__)
photo_ids = list(dict.fromkeys(request.photo_ids))
if not photo_ids:
@@ -797,13 +1102,36 @@ def bulk_delete_photos(
detail="photo_ids list cannot be empty",
)
+ # Get the uploads folder path for comparison
+ uploads_dir = Path(PHOTO_STORAGE_DIR).resolve()
+
try:
photos = db.query(Photo).filter(Photo.id.in_(photo_ids)).all()
found_ids = {photo.id for photo in photos}
missing_ids = sorted(set(photo_ids) - found_ids)
deleted_count = 0
+ files_deleted_count = 0
for photo in photos:
+ # Only delete file from filesystem if it's directly in the uploads folder
+ # Do NOT delete files from other folders (main photo storage, etc.)
+ photo_path = Path(photo.path).resolve()
+ # Strict check: only delete if parent directory is exactly the uploads folder
+ if photo_path.parent == uploads_dir:
+ try:
+ if photo_path.exists():
+ os.remove(photo_path)
+ files_deleted_count += 1
+ logger.warning(f"DELETED file from uploads folder: {photo_path} (Photo ID: {photo.id})")
+ else:
+ logger.warning(f"Photo file not found (already deleted?): {photo_path} (Photo ID: {photo.id})")
+ except OSError as e:
+ logger.error(f"Failed to delete file {photo_path} (Photo ID: {photo.id}): {e}")
+ # Continue with database deletion even if file deletion fails
+ else:
+ # File is not in uploads folder - do not delete from filesystem
+ logger.info(f"Photo {photo.id} is not in uploads folder (path: {photo_path.parent}, uploads: {uploads_dir}), skipping file deletion")
+
# Remove tag linkages explicitly (in addition to cascade) to keep counts accurate
db.query(PhotoTagLinkage).filter(
PhotoTagLinkage.photo_id == photo.id
@@ -824,6 +1152,8 @@ def bulk_delete_photos(
admin_username = current_admin.get("username", "unknown")
message_parts = [f"Deleted {deleted_count} photo(s)"]
+ if files_deleted_count > 0:
+ message_parts.append(f"{files_deleted_count} file(s) removed from uploads folder")
if missing_ids:
message_parts.append(f"{len(missing_ids)} photo(s) not found")
message_parts.append(f"Request by admin: {admin_username}")
diff --git a/backend/api/videos.py b/backend/api/videos.py
index 55c7d7a..a921dd8 100644
--- a/backend/api/videos.py
+++ b/backend/api/videos.py
@@ -5,8 +5,8 @@ from __future__ import annotations
from datetime import date
from typing import Annotated, Optional
-from fastapi import APIRouter, Depends, HTTPException, Query, status
-from fastapi.responses import FileResponse
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
+from fastapi.responses import FileResponse, Response, StreamingResponse
from sqlalchemy.orm import Session
from backend.db.session import get_db
@@ -296,11 +296,13 @@ def get_video_thumbnail(
@router.get("/{video_id}/video")
def get_video_file(
video_id: int,
+ request: Request,
db: Session = Depends(get_db),
-) -> FileResponse:
- """Serve video file for playback."""
+):
+ """Serve video file for playback with range request support."""
import os
import mimetypes
+ from starlette.responses import FileResponse
# Verify video exists
video = db.query(Photo).filter(
@@ -325,7 +327,89 @@ def get_video_file(
if not media_type or not media_type.startswith('video/'):
media_type = "video/mp4"
- # Use FileResponse with range request support for video streaming
+ file_size = os.path.getsize(video.path)
+ # Get range header - Starlette normalizes headers to lowercase
+ range_header = request.headers.get("range")
+
+ # Debug: Write to file to verify code execution
+ try:
+ with open("/tmp/video_debug.log", "a") as f:
+ all_headers = {k: v for k, v in request.headers.items()}
+ f.write(f"Video {video_id}: range_header={range_header}, all_headers={all_headers}\n")
+ if hasattr(request, 'scope'):
+ scope_headers = request.scope.get("headers", [])
+ f.write(f" scope headers: {scope_headers}\n")
+ f.flush()
+ except Exception as e:
+ with open("/tmp/video_debug.log", "a") as f:
+ f.write(f"Debug write error: {e}\n")
+ f.flush()
+
+ # Also check request scope directly as fallback
+ if not range_header and hasattr(request, 'scope'):
+ scope_headers = request.scope.get("headers", [])
+ for header_name, header_value in scope_headers:
+ if header_name.lower() == b"range":
+ range_header = header_value.decode() if isinstance(header_value, bytes) else header_value
+ with open("/tmp/video_debug.log", "a") as f:
+ f.write(f" Found range in scope: {range_header}\n")
+ f.flush()
+ break
+
+ if range_header:
+ try:
+ # Parse range header: "bytes=start-end"
+ range_match = range_header.replace("bytes=", "").split("-")
+ start_str = range_match[0].strip()
+ end_str = range_match[1].strip() if len(range_match) > 1 and range_match[1] else ""
+
+ start = int(start_str) if start_str else 0
+ end = int(end_str) if end_str else file_size - 1
+
+ # Validate range
+ if start < 0:
+ start = 0
+ if end >= file_size:
+ end = file_size - 1
+ if start > end:
+ return Response(
+ status_code=416,
+ headers={"Content-Range": f"bytes */{file_size}"}
+ )
+
+ # Read the requested chunk
+ chunk_size = end - start + 1
+
+ def generate_chunk():
+ with open(video.path, "rb") as f:
+ f.seek(start)
+ remaining = chunk_size
+ while remaining > 0:
+ chunk = f.read(min(8192, remaining))
+ if not chunk:
+ break
+ yield chunk
+ remaining -= len(chunk)
+
+ from fastapi.responses import StreamingResponse
+ return StreamingResponse(
+ generate_chunk(),
+ status_code=206,
+ headers={
+ "Content-Range": f"bytes {start}-{end}/{file_size}",
+ "Accept-Ranges": "bytes",
+ "Content-Length": str(chunk_size),
+ "Content-Type": media_type,
+ "Content-Disposition": "inline",
+ "Cache-Control": "public, max-age=3600",
+ },
+ media_type=media_type,
+ )
+ except (ValueError, IndexError):
+ # If range parsing fails, fall through to serve full file
+ pass
+
+ # No range request or parsing failed - serve full file with range support headers
response = FileResponse(
video.path,
media_type=media_type,
diff --git a/backend/app.py b/backend/app.py
index 5b3980c..d7bba1d 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -26,6 +26,7 @@ from backend.api.users import router as users_router
from backend.api.auth_users import router as auth_users_router
from backend.api.role_permissions import router as role_permissions_router
from backend.api.videos import router as videos_router
+from backend.api.click_log import router as click_log_router
from backend.api.version import router as version_router
from backend.settings import APP_TITLE, APP_VERSION
from backend.constants.roles import DEFAULT_ADMIN_ROLE, DEFAULT_USER_ROLE, ROLE_VALUES
@@ -56,9 +57,18 @@ def start_worker() -> None:
project_root = Path(__file__).parent.parent
# Use explicit Python path to avoid Cursor interception
- # Check if sys.executable is Cursor, if so use /usr/bin/python3
+ # Prefer virtual environment Python if available, otherwise use system Python
python_executable = sys.executable
- if "cursor" in python_executable.lower() or not python_executable.startswith("/usr"):
+ # If running in Cursor or not in venv, try to find venv Python
+ if "cursor" in python_executable.lower():
+ # Try to use venv Python from project root
+ venv_python = project_root / "venv" / "bin" / "python3"
+ if venv_python.exists():
+ python_executable = str(venv_python)
+ else:
+ python_executable = "/usr/bin/python3"
+ # Ensure we're using a valid Python executable
+ if not Path(python_executable).exists():
python_executable = "/usr/bin/python3"
# Ensure PYTHONPATH is set correctly and pass DATABASE_URL_AUTH explicitly
@@ -634,7 +644,13 @@ async def lifespan(app: FastAPI):
# This must happen BEFORE we try to use the engine
ensure_postgresql_database(database_url)
- # Note: Auth database is managed by the frontend, not created here
+ # Ensure auth database exists if configured
+ try:
+ auth_db_url = get_auth_database_url()
+ ensure_postgresql_database(auth_db_url)
+ except ValueError:
+ # DATABASE_URL_AUTH not set - that's okay
+ pass
# Only create tables if they don't already exist (safety check)
inspector = inspect(engine)
@@ -672,8 +688,15 @@ async def lifespan(app: FastAPI):
try:
ensure_auth_user_is_active_column()
# Import and call worker's setup function to create all auth tables
- from backend.worker import setup_auth_database_tables
- setup_auth_database_tables()
+ # Note: This import may fail if dotenv is not installed in API environment
+ # (worker.py imports dotenv at top level, but API doesn't need it)
+ try:
+ from backend.worker import setup_auth_database_tables
+ setup_auth_database_tables()
+ except ImportError as import_err:
+ # dotenv not available in API environment - that's okay, worker will handle setup
+ print(f"ℹ️ Could not import worker setup function: {import_err}")
+ print(" Worker process will handle auth database setup")
except Exception as auth_exc:
# Auth database might not exist yet - that's okay, frontend will handle it
print(f"ℹ️ Auth database not available: {auth_exc}")
@@ -696,9 +719,13 @@ def create_app() -> FastAPI:
lifespan=lifespan,
)
+ # CORS configuration - use environment variable for production
+ # Default to wildcard for development, restrict in production via CORS_ORIGINS env var
+ cors_origins = os.getenv("CORS_ORIGINS", "*").split(",") if os.getenv("CORS_ORIGINS") else ["*"]
+
app.add_middleware(
CORSMiddleware,
- allow_origins=["*"],
+ allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -721,6 +748,7 @@ def create_app() -> FastAPI:
app.include_router(users_router, prefix="/api/v1")
app.include_router(auth_users_router, prefix="/api/v1")
app.include_router(role_permissions_router, prefix="/api/v1")
+ app.include_router(click_log_router, prefix="/api/v1")
return app
diff --git a/backend/config.py b/backend/config.py
index 0d6c576..2cf11e5 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -22,8 +22,13 @@ MIN_FACE_SIZE = 40
MAX_FACE_SIZE = 1500
# Matching tolerance and calibration options
-DEFAULT_FACE_TOLERANCE = 0.6
+DEFAULT_FACE_TOLERANCE = 0.5 # Lowered from 0.6 for stricter matching
USE_CALIBRATED_CONFIDENCE = True
CONFIDENCE_CALIBRATION_METHOD = "empirical" # "empirical", "linear", or "sigmoid"
+# Auto-match face size filtering
+# Minimum face size as percentage of image area (0.5% = 0.005)
+# Faces smaller than this are excluded from auto-match to avoid generic encodings
+MIN_AUTO_MATCH_FACE_SIZE_RATIO = 0.005 # 0.5% of image area
+
diff --git a/backend/db/session.py b/backend/db/session.py
index 712a5cf..9cb8084 100644
--- a/backend/db/session.py
+++ b/backend/db/session.py
@@ -20,8 +20,12 @@ def get_database_url() -> str:
db_url = os.getenv("DATABASE_URL")
if db_url:
return db_url
- # Default to PostgreSQL for development
- return "postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag"
+ # Default to PostgreSQL for development (without password - must be set via env var)
+ # This ensures no hardcoded passwords in the codebase
+ raise ValueError(
+ "DATABASE_URL environment variable not set. "
+ "Please set DATABASE_URL in your .env file or environment."
+ )
def get_auth_database_url() -> str:
diff --git a/backend/schemas/faces.py b/backend/schemas/faces.py
index cc3f3d4..19bb8b1 100644
--- a/backend/schemas/faces.py
+++ b/backend/schemas/faces.py
@@ -89,6 +89,7 @@ class SimilarFaceItem(BaseModel):
quality_score: float
filename: str
pose_mode: Optional[str] = Field("frontal", description="Pose classification (frontal, profile_left, etc.)")
+ debug_info: Optional[dict] = Field(None, description="Debug information (encoding stats) when debug mode is enabled")
class SimilarFacesResponse(BaseModel):
@@ -98,6 +99,7 @@ class SimilarFacesResponse(BaseModel):
base_face_id: int
items: list[SimilarFaceItem]
+ debug_info: Optional[dict] = Field(None, description="Debug information (base face encoding stats) when debug mode is enabled")
class BatchSimilarityRequest(BaseModel):
@@ -212,9 +214,10 @@ class AutoMatchRequest(BaseModel):
model_config = ConfigDict(protected_namespaces=())
- tolerance: float = Field(0.6, ge=0.0, le=1.0, description="Tolerance threshold (lower = stricter matching)")
+ tolerance: float = Field(0.5, ge=0.0, le=1.0, description="Tolerance threshold (lower = stricter matching)")
auto_accept: bool = Field(False, description="Enable automatic acceptance of matching faces")
auto_accept_threshold: float = Field(70.0, ge=0.0, le=100.0, description="Similarity threshold for auto-acceptance (0-100%)")
+ use_distance_based_thresholds: bool = Field(False, description="Use distance-based confidence thresholds (stricter for borderline distances)")
class AutoMatchFaceItem(BaseModel):
diff --git a/backend/schemas/photos.py b/backend/schemas/photos.py
index 253d224..8059216 100644
--- a/backend/schemas/photos.py
+++ b/backend/schemas/photos.py
@@ -91,3 +91,20 @@ class BulkDeletePhotosResponse(BaseModel):
description="Photo IDs that were requested but not found",
)
+
+class DirectoryItem(BaseModel):
+ """Directory item (file or folder) in a directory listing."""
+
+ name: str = Field(..., description="Name of the item")
+ path: str = Field(..., description="Full absolute path to the item")
+ is_directory: bool = Field(..., description="Whether this is a directory")
+ is_file: bool = Field(..., description="Whether this is a file")
+
+
+class BrowseDirectoryResponse(BaseModel):
+ """Response for directory browsing."""
+
+ current_path: str = Field(..., description="Current directory path")
+ parent_path: Optional[str] = Field(None, description="Parent directory path (None if at root)")
+ items: List[DirectoryItem] = Field(..., description="List of items in the directory")
+
diff --git a/backend/schemas/search.py b/backend/schemas/search.py
index 918f000..e88ebf8 100644
--- a/backend/schemas/search.py
+++ b/backend/schemas/search.py
@@ -32,6 +32,7 @@ class PhotoSearchResult(BaseModel):
date_taken: Optional[date] = None
date_added: date
processed: bool
+ media_type: Optional[str] = "image" # "image" or "video"
person_name: Optional[str] = None # For name search
tags: List[str] = Field(default_factory=list) # All tags for the photo
has_faces: bool = False
diff --git a/backend/services/face_service.py b/backend/services/face_service.py
index 6b6903f..334c409 100644
--- a/backend/services/face_service.py
+++ b/backend/services/face_service.py
@@ -6,6 +6,7 @@ import json
import os
import tempfile
import time
+from pathlib import Path
from typing import Callable, Optional, Tuple, List, Dict
from datetime import date
@@ -14,11 +15,17 @@ from PIL import Image
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, func, case
-try:
- from deepface import DeepFace
- DEEPFACE_AVAILABLE = True
-except ImportError:
+# Skip DeepFace import during tests to avoid illegal instruction errors
+if os.getenv("SKIP_DEEPFACE_IN_TESTS") == "1":
DEEPFACE_AVAILABLE = False
+ DeepFace = None
+else:
+ try:
+ from deepface import DeepFace
+ DEEPFACE_AVAILABLE = True
+ except ImportError:
+ DEEPFACE_AVAILABLE = False
+ DeepFace = None
from backend.config import (
CONFIDENCE_CALIBRATION_METHOD,
@@ -28,6 +35,7 @@ from backend.config import (
MAX_FACE_SIZE,
MIN_FACE_CONFIDENCE,
MIN_FACE_SIZE,
+ MIN_AUTO_MATCH_FACE_SIZE_RATIO,
USE_CALIBRATED_CONFIDENCE,
)
from src.utils.exif_utils import EXIFOrientationHandler
@@ -471,9 +479,14 @@ def process_photo_faces(
return 0, 0
# Load image for quality calculation
+ # Use context manager to ensure image is closed properly to free memory
image = Image.open(photo_path)
- image_np = np.array(image)
- image_width, image_height = image.size
+ try:
+ image_np = np.array(image)
+ image_width, image_height = image.size
+ finally:
+ # Explicitly close image to free memory immediately
+ image.close()
# Count total faces from DeepFace
faces_detected = len(results)
@@ -515,7 +528,9 @@ def process_photo_faces(
_print_with_stderr(f"[FaceService] Debug - face_confidence value: {face_confidence}")
_print_with_stderr(f"[FaceService] Debug - result['face_confidence'] exists: {'face_confidence' in result}")
- encoding = np.array(result['embedding'])
+ # DeepFace returns float32 embeddings, but we store as float64 for consistency
+ # Convert to float64 explicitly to match how we read them back
+ encoding = np.array(result['embedding'], dtype=np.float64)
# Convert to location format (JSON string like desktop version)
location = {
@@ -616,17 +631,21 @@ def process_photo_faces(
if face_width is None:
face_width = matched_pose_face.get('face_width')
pose_mode = PoseDetector.classify_pose_mode(
- yaw_angle, pitch_angle, roll_angle, face_width
+ yaw_angle, pitch_angle, roll_angle, face_width, landmarks
)
else:
- # Can't calculate yaw, use face_width
+ # Can't calculate yaw, use face_width and landmarks for single-eye detection
pose_mode = PoseDetector.classify_pose_mode(
- yaw_angle, pitch_angle, roll_angle, face_width
+ yaw_angle, pitch_angle, roll_angle, face_width, landmarks
)
elif face_width is not None:
# No landmarks available, use face_width only
+ # Try to get landmarks from matched_pose_face if available
+ landmarks_for_classification = None
+ if matched_pose_face:
+ landmarks_for_classification = matched_pose_face.get('landmarks')
pose_mode = PoseDetector.classify_pose_mode(
- yaw_angle, pitch_angle, roll_angle, face_width
+ yaw_angle, pitch_angle, roll_angle, face_width, landmarks_for_classification
)
else:
# No landmarks and no face_width, use default
@@ -730,8 +749,19 @@ def process_photo_faces(
# If commit fails, rollback and log the error
db.rollback()
error_msg = str(commit_error)
+ error_str_lower = error_msg.lower()
+
+ # Check if it's a connection/disconnection error
+ is_connection_error = any(keyword in error_str_lower for keyword in [
+ 'connection', 'disconnect', 'timeout', 'closed', 'lost',
+ 'operationalerror', 'server closed', 'connection reset',
+ 'connection pool', 'connection refused'
+ ])
+
try:
_print_with_stderr(f"[FaceService] Failed to commit {faces_stored} faces for {photo.filename}: {error_msg}")
+ if is_connection_error:
+ _print_with_stderr(f"[FaceService] ⚠️ Database connection error detected - session may need refresh")
import traceback
traceback.print_exc()
except (BrokenPipeError, OSError):
@@ -741,8 +771,7 @@ def process_photo_faces(
# This ensures the return value accurately reflects what was actually saved
faces_stored = 0
- # Re-raise to be caught by outer exception handler in process_unprocessed_photos
- # This allows the batch to continue processing other photos
+ # Re-raise with connection error flag so caller can refresh session
raise Exception(f"Database commit failed for {photo.filename}: {error_msg}")
# Mark photo as processed after handling faces (desktop parity)
@@ -750,7 +779,18 @@ def process_photo_faces(
photo.processed = True
db.add(photo)
db.commit()
- except Exception:
+ except Exception as mark_error:
+ # Log connection errors for debugging
+ error_str = str(mark_error).lower()
+ is_connection_error = any(keyword in error_str for keyword in [
+ 'connection', 'disconnect', 'timeout', 'closed', 'lost',
+ 'operationalerror', 'server closed', 'connection reset'
+ ])
+ if is_connection_error:
+ try:
+ _print_with_stderr(f"[FaceService] ⚠️ Database connection error while marking photo as processed: {mark_error}")
+ except (BrokenPipeError, OSError):
+ pass
db.rollback()
# Log summary
@@ -1253,6 +1293,26 @@ def process_unprocessed_photos(
update_progress(0, total, f"Starting face detection on {total} photos...", 0, 0)
for idx, photo in enumerate(unprocessed_photos, 1):
+ # Periodic database health check every 10 photos to catch connection issues early
+ if idx > 1 and idx % 10 == 0:
+ try:
+ from sqlalchemy import text
+ db.execute(text("SELECT 1"))
+ db.commit()
+ except Exception as health_check_error:
+ # Database connection is stale - this will be caught and handled below
+ error_str = str(health_check_error).lower()
+ is_connection_error = any(keyword in error_str for keyword in [
+ 'connection', 'disconnect', 'timeout', 'closed', 'lost',
+ 'operationalerror', 'server closed', 'connection reset'
+ ])
+ if is_connection_error:
+ try:
+ print(f"[FaceService] ⚠️ Database health check failed at photo {idx}/{total}: {health_check_error}")
+ print(f"[FaceService] Session may need refresh - will be handled by error handler")
+ except (BrokenPipeError, OSError):
+ pass
+
# Check for cancellation BEFORE starting each photo
# This is the primary cancellation point - we stop before starting a new photo
if check_cancelled():
@@ -1379,6 +1439,14 @@ def process_unprocessed_photos(
except (BrokenPipeError, OSError):
pass
+ # Check if it's a database connection error
+ error_str = str(e).lower()
+ is_db_connection_error = any(keyword in error_str for keyword in [
+ 'connection', 'disconnect', 'timeout', 'closed', 'lost',
+ 'operationalerror', 'database', 'server closed', 'connection reset',
+ 'connection pool', 'connection refused'
+ ])
+
# Refresh database session after error to ensure it's in a good state
# This prevents session state issues from affecting subsequent photos
# Note: process_photo_faces already does db.rollback(), but we ensure
@@ -1388,6 +1456,23 @@ def process_unprocessed_photos(
db.rollback()
# Expire the current photo object to clear any stale state
db.expire(photo)
+
+ # If it's a connection error, try to refresh the session
+ if is_db_connection_error:
+ try:
+ # Test if session is still alive
+ from sqlalchemy import text
+ db.execute(text("SELECT 1"))
+ db.commit()
+ except Exception:
+ # Session is dead - need to get a new one from the caller
+ # We can't create a new SessionLocal here, so we'll raise a special exception
+ try:
+ print(f"[FaceService] ⚠️ Database session is dead after connection error - caller should refresh session")
+ except (BrokenPipeError, OSError):
+ pass
+ # Re-raise with a flag that indicates session needs refresh
+ raise Exception(f"Database connection lost - session needs refresh: {str(e)}")
except Exception as session_error:
# If session refresh fails, log but don't fail the batch
try:
@@ -1592,6 +1677,47 @@ def list_unidentified_faces(
return items, total
+def load_face_encoding(encoding_bytes: bytes) -> np.ndarray:
+ """Load face encoding from bytes, auto-detecting dtype (float32 or float64).
+
+ ArcFace encodings are 512 dimensions:
+ - float32: 512 * 4 bytes = 2048 bytes
+ - float64: 512 * 8 bytes = 4096 bytes
+
+ Args:
+ encoding_bytes: Raw encoding bytes from database
+
+ Returns:
+ numpy array of encoding (always float64 for consistency)
+ """
+ encoding_size = len(encoding_bytes)
+
+ # Auto-detect dtype based on size
+ if encoding_size == 2048:
+ # float32 encoding (old format)
+ encoding = np.frombuffer(encoding_bytes, dtype=np.float32)
+ # Convert to float64 for consistency
+ return encoding.astype(np.float64)
+ elif encoding_size == 4096:
+ # float64 encoding (new format)
+ return np.frombuffer(encoding_bytes, dtype=np.float64)
+ else:
+ # Unexpected size - try float64 first, fallback to float32
+ # This handles edge cases or future changes
+ try:
+ encoding = np.frombuffer(encoding_bytes, dtype=np.float64)
+ if len(encoding) == 512:
+ return encoding
+ except:
+ pass
+ # Fallback to float32
+ encoding = np.frombuffer(encoding_bytes, dtype=np.float32)
+ if len(encoding) == 512:
+ return encoding.astype(np.float64)
+ else:
+ raise ValueError(f"Unexpected encoding size: {encoding_size} bytes (expected 2048 or 4096)")
+
+
def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> float:
"""Calculate cosine distance between two face encodings, matching desktop exactly.
@@ -1624,7 +1750,6 @@ def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> f
# Normalize encodings (matching desktop exactly)
norm1 = np.linalg.norm(enc1)
norm2 = np.linalg.norm(enc2)
-
if norm1 == 0 or norm2 == 0:
return 2.0
@@ -1647,6 +1772,32 @@ def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> f
return 2.0 # Maximum distance on error
+def get_distance_based_min_confidence(distance: float) -> float:
+ """Get minimum confidence threshold based on distance.
+
+ For borderline distances, require higher confidence to reduce false positives.
+ This is used only when use_distance_based_thresholds=True (e.g., in auto-match).
+
+ Args:
+ distance: Cosine distance between faces (0 = identical, 2 = opposite)
+
+ Returns:
+ Minimum confidence percentage (0-100) required for this distance
+ """
+ if distance <= 0.15:
+ # Very close matches: standard threshold
+ return 50.0
+ elif distance <= 0.20:
+ # Borderline matches: require higher confidence
+ return 70.0
+ elif distance <= 0.25:
+ # Near threshold: require very high confidence
+ return 85.0
+ else:
+ # Far matches: require extremely high confidence
+ return 95.0
+
+
def calculate_adaptive_tolerance(base_tolerance: float, face_quality: float) -> float:
"""Calculate adaptive tolerance based on face quality, matching desktop exactly."""
# Start with base tolerance
@@ -1657,7 +1808,10 @@ def calculate_adaptive_tolerance(base_tolerance: float, face_quality: float) ->
tolerance *= quality_factor
# Ensure tolerance stays within reasonable bounds for DeepFace
- return max(0.2, min(0.6, tolerance))
+ # Allow tolerance down to 0.0 (user can set very strict matching)
+ # Allow tolerance up to 1.0 (matching API validation range)
+ # The quality factor can increase tolerance up to 1.1x, so cap at 1.0 to stay within API limits
+ return max(0.0, min(1.0, tolerance))
def calibrate_confidence(distance: float, tolerance: float = None) -> float:
@@ -1691,27 +1845,34 @@ def calibrate_confidence(distance: float, tolerance: float = None) -> float:
else: # "empirical" - default method (matching desktop exactly)
# Empirical calibration parameters for DeepFace ArcFace model
# These are derived from analysis of distance distributions for matching/non-matching pairs
+ # Moderate calibration: stricter than original but not too strict
+
+ # For very close distances (< 0.12): very high confidence
+ if distance <= 0.12:
+ # Very close matches: exponential decay from 100%
+ confidence = 100 * np.exp(-distance * 2.8)
+ return min(100, max(92, confidence))
# For distances well below threshold: high confidence
- if distance <= tolerance * 0.5:
- # Very close matches: exponential decay from 100%
- confidence = 100 * np.exp(-distance * 2.5)
- return min(100, max(95, confidence))
+ elif distance <= tolerance * 0.5:
+ # Close matches: exponential decay
+ confidence = 100 * np.exp(-distance * 2.6)
+ return min(92, max(82, confidence))
# For distances near threshold: moderate confidence
elif distance <= tolerance:
# Near-threshold matches: sigmoid-like curve
# Maps distance to probability based on empirical data
normalized_distance = (distance - tolerance * 0.5) / (tolerance * 0.5)
- confidence = 95 - (normalized_distance * 40) # 95% to 55% range
- return max(55, min(95, confidence))
+ confidence = 82 - (normalized_distance * 32) # 82% to 50% range
+ return max(50, min(82, confidence))
# For distances above threshold: low confidence
elif distance <= tolerance * 1.5:
# Above threshold but not too far: rapid decay
normalized_distance = (distance - tolerance) / (tolerance * 0.5)
- confidence = 55 - (normalized_distance * 35) # 55% to 20% range
- return max(20, min(55, confidence))
+ confidence = 50 - (normalized_distance * 30) # 50% to 20% range
+ return max(20, min(50, confidence))
# For very large distances: very low confidence
else:
@@ -1720,6 +1881,46 @@ def calibrate_confidence(distance: float, tolerance: float = None) -> float:
return max(1, min(20, confidence))
+def _calculate_face_size_ratio(face: Face, photo: Photo) -> float:
+ """Calculate face size as ratio of image area.
+
+ Args:
+ face: Face model with location
+ photo: Photo model (needed for path to load image dimensions)
+
+ Returns:
+ Face size ratio (0.0-1.0), or 0.0 if cannot calculate
+ """
+ try:
+ import json
+ from PIL import Image
+
+ # Parse location
+ location = json.loads(face.location) if isinstance(face.location, str) else face.location
+ face_w = location.get('w', 0)
+ face_h = location.get('h', 0)
+ face_area = face_w * face_h
+
+ if face_area == 0:
+ return 0.0
+
+ # Load image to get dimensions
+ photo_path = Path(photo.path)
+ if not photo_path.exists():
+ return 0.0
+
+ img = Image.open(photo_path)
+ img_width, img_height = img.size
+ image_area = img_width * img_height
+
+ if image_area == 0:
+ return 0.0
+
+ return face_area / image_area
+ except Exception:
+ return 0.0
+
+
def _is_acceptable_pose_for_auto_match(pose_mode: str) -> bool:
"""Check if pose_mode is acceptable for auto-match (frontal or tilted, but not profile).
@@ -1759,10 +1960,14 @@ def find_similar_faces(
db: Session,
face_id: int,
limit: int = 20000, # Very high default limit - effectively unlimited
- tolerance: float = 0.6, # DEFAULT_FACE_TOLERANCE from desktop
+ tolerance: float = 0.5, # DEFAULT_FACE_TOLERANCE
filter_frontal_only: bool = False, # New: Only return frontal or tilted faces (not profile)
include_excluded: bool = False, # Include excluded faces in results
-) -> List[Tuple[Face, float, float]]: # Returns (face, distance, confidence_pct)
+ filter_small_faces: bool = False, # Filter out small faces (for auto-match)
+ min_face_size_ratio: float = 0.005, # Minimum face size ratio (0.5% of image)
+ debug: bool = False, # Include debug information (encoding stats)
+ use_distance_based_thresholds: bool = False, # Use distance-based confidence thresholds (for auto-match)
+) -> List[Tuple[Face, float, float, dict | None]]: # Returns (face, distance, confidence_pct, debug_info)
"""Find similar faces matching desktop logic exactly.
Desktop flow:
@@ -1789,32 +1994,48 @@ def find_similar_faces(
base: Face = db.query(Face).filter(Face.id == face_id).first()
if not base:
return []
+
- # Load base encoding - desktop uses float64, ArcFace has 512 dimensions
- # Stored as float64: 512 * 8 bytes = 4096 bytes
- base_enc = np.frombuffer(base.encoding, dtype=np.float64)
+ # Load base encoding - auto-detect dtype (supports both float32 and float64)
+ base_enc = load_face_encoding(base.encoding)
base_enc = base_enc.copy() # Make a copy to avoid buffer issues
- # Desktop uses 0.5 as default quality for target face (hardcoded, matching desktop exactly)
- # Desktop: target_quality = 0.5 # Default quality for target face
- base_quality = 0.5
+ # Use actual quality score of the reference face, defaulting to 0.5 if not set
+ # This ensures adaptive tolerance is calculated correctly based on the actual face quality
+ base_quality = float(base.quality_score) if base.quality_score is not None else 0.5
# Desktop: get ALL faces from database (matching get_all_face_encodings)
# Desktop find_similar_faces gets ALL faces, doesn't filter by photo_id
- # Get all faces except itself, with photo loaded
+ # However, for auto-match, we should exclude faces from the same photo to avoid
+ # duplicate detections of the same face (same encoding stored multiple times)
+ # Get all faces except itself and faces from the same photo, with photo loaded
all_faces: List[Face] = (
db.query(Face)
.options(joinedload(Face.photo))
.filter(Face.id != face_id)
+ .filter(Face.photo_id != base.photo_id) # Exclude faces from same photo
.all()
)
matches: List[Tuple[Face, float, float]] = []
+
for f in all_faces:
- # Load other encoding - desktop uses float64, ArcFace has 512 dimensions
- other_enc = np.frombuffer(f.encoding, dtype=np.float64)
+ # Load other encoding - auto-detect dtype (supports both float32 and float64)
+ other_enc = load_face_encoding(f.encoding)
other_enc = other_enc.copy() # Make a copy to avoid buffer issues
+ # Calculate debug info if requested
+ debug_info = None
+ if debug:
+ debug_info = {
+ "encoding_length": len(other_enc),
+ "encoding_min": float(np.min(other_enc)),
+ "encoding_max": float(np.max(other_enc)),
+ "encoding_mean": float(np.mean(other_enc)),
+ "encoding_std": float(np.std(other_enc)),
+ "encoding_first_10": [float(x) for x in other_enc[:10].tolist()],
+ }
+
other_quality = float(f.quality_score) if f.quality_score is not None else 0.5
# Calculate adaptive tolerance based on both face qualities (matching desktop exactly)
@@ -1829,14 +2050,22 @@ def find_similar_faces(
# Get photo info (desktop does this in find_similar_faces)
if f.photo:
# Calculate calibrated confidence (matching desktop _get_filtered_similar_faces)
- confidence_pct = calibrate_confidence(distance, DEFAULT_FACE_TOLERANCE)
+ # Use the actual tolerance parameter, not the default
+ confidence_pct = calibrate_confidence(distance, tolerance)
# Desktop _get_filtered_similar_faces filters by:
# 1. person_id is None (unidentified)
- # 2. confidence >= 40%
+ # 2. confidence >= 50% (increased from 40% to reduce false matches)
+ # OR confidence >= distance-based threshold if use_distance_based_thresholds=True
is_unidentified = f.person_id is None
- if is_unidentified and confidence_pct >= 40:
+ # Calculate minimum confidence threshold
+ if use_distance_based_thresholds:
+ min_confidence = get_distance_based_min_confidence(distance)
+ else:
+ min_confidence = 50.0 # Standard threshold
+
+ if is_unidentified and confidence_pct >= min_confidence:
# Filter by excluded status if not including excluded faces
if not include_excluded and getattr(f, "excluded", False):
continue
@@ -1845,9 +2074,16 @@ def find_similar_faces(
if filter_frontal_only and not _is_acceptable_pose_for_auto_match(f.pose_mode):
continue
+ # Filter by face size if requested (for auto-match)
+ if filter_small_faces:
+ if f.photo:
+ face_size_ratio = _calculate_face_size_ratio(f, f.photo)
+ if face_size_ratio < min_face_size_ratio:
+ continue # Skip small faces
+
# Return calibrated confidence percentage (matching desktop)
# Desktop displays confidence_pct directly from _get_calibrated_confidence
- matches.append((f, distance, confidence_pct))
+ matches.append((f, distance, confidence_pct, debug_info))
# Sort by distance (lower is better) - matching desktop
matches.sort(key=lambda x: x[1])
@@ -1860,6 +2096,7 @@ def calculate_batch_similarities(
db: Session,
face_ids: list[int],
min_confidence: float = 60.0,
+ tolerance: float = 0.6, # Use 0.6 for Identify People (more lenient for manual review)
) -> list[tuple[int, int, float, float]]:
"""Calculate similarities between N faces and all M faces in database.
@@ -1909,7 +2146,7 @@ def calculate_batch_similarities(
for face in all_faces:
# Pre-load encoding as numpy array
- all_encodings[face.id] = np.frombuffer(face.encoding, dtype=np.float64)
+ all_encodings[face.id] = load_face_encoding(face.encoding)
# Pre-cache quality score
all_qualities[face.id] = float(face.quality_score) if face.quality_score is not None else 0.5
@@ -2005,8 +2242,9 @@ def calculate_batch_similarities(
def find_auto_match_matches(
db: Session,
- tolerance: float = 0.6,
+ tolerance: float = 0.5,
filter_frontal_only: bool = False,
+ use_distance_based_thresholds: bool = False, # Use distance-based confidence thresholds
) -> List[Tuple[int, int, Face, List[Tuple[Face, float, float]]]]:
"""Find auto-match matches for all identified people, matching desktop logic exactly.
@@ -2099,16 +2337,30 @@ def find_auto_match_matches(
for person_id, reference_face, person_name in person_faces_list:
reference_face_id = reference_face.id
+ # TEMPORARILY DISABLED: Check if reference face is too small (exclude from auto-match)
+ # reference_photo = db.query(Photo).filter(Photo.id == reference_face.photo_id).first()
+ # if reference_photo:
+ # ref_size_ratio = _calculate_face_size_ratio(reference_face, reference_photo)
+ # if ref_size_ratio < MIN_AUTO_MATCH_FACE_SIZE_RATIO:
+ # # Skip this person - reference face is too small
+ # continue
+
# Use find_similar_faces which matches desktop _get_filtered_similar_faces logic
# Desktop: similar_faces = self.face_processor._get_filtered_similar_faces(
# reference_face_id, tolerance, include_same_photo=False, face_status=None)
- # This filters by: person_id is None (unidentified), confidence >= 40%, sorts by distance
+ # This filters by: person_id is None (unidentified), confidence >= 50% (increased from 40%), sorts by distance
# Auto-match always excludes excluded faces
- similar_faces = find_similar_faces(
+ # TEMPORARILY DISABLED: filter_small_faces=True to exclude small match faces
+ similar_faces_with_debug = find_similar_faces(
db, reference_face_id, tolerance=tolerance,
filter_frontal_only=filter_frontal_only,
- include_excluded=False # Auto-match always excludes excluded faces
+ include_excluded=False, # Auto-match always excludes excluded faces
+ filter_small_faces=False, # TEMPORARILY DISABLED: Exclude small faces from auto-match
+ min_face_size_ratio=MIN_AUTO_MATCH_FACE_SIZE_RATIO,
+ use_distance_based_thresholds=use_distance_based_thresholds # Use distance-based thresholds if enabled
)
+ # Strip debug_info for internal use
+ similar_faces = [(f, dist, conf) for f, dist, conf, _ in similar_faces_with_debug]
if similar_faces:
results.append((person_id, reference_face_id, reference_face, similar_faces))
@@ -2119,7 +2371,7 @@ def find_auto_match_matches(
def get_auto_match_people_list(
db: Session,
filter_frontal_only: bool = False,
- tolerance: float = 0.6,
+ tolerance: float = 0.5,
) -> List[Tuple[int, Face, str, int]]:
"""Get list of people for auto-match (without matches) - fast initial load.
@@ -2223,7 +2475,7 @@ def get_auto_match_people_list(
def get_auto_match_person_matches(
db: Session,
person_id: int,
- tolerance: float = 0.6,
+ tolerance: float = 0.5,
filter_frontal_only: bool = False,
) -> List[Tuple[Face, float, float]]:
"""Get matches for a specific person - for lazy loading.
@@ -2252,11 +2504,13 @@ def get_auto_match_person_matches(
# Find similar faces using existing function
# Auto-match always excludes excluded faces
- similar_faces = find_similar_faces(
+ similar_faces_with_debug = find_similar_faces(
db, reference_face.id, tolerance=tolerance,
filter_frontal_only=filter_frontal_only,
include_excluded=False # Auto-match always excludes excluded faces
)
+ # Strip debug_info for internal use
+ similar_faces = [(f, dist, conf) for f, dist, conf, _ in similar_faces_with_debug]
return similar_faces
diff --git a/backend/services/photo_service.py b/backend/services/photo_service.py
index 3bfccef..124b7c3 100644
--- a/backend/services/photo_service.py
+++ b/backend/services/photo_service.py
@@ -58,31 +58,118 @@ def extract_exif_date(image_path: str) -> Optional[date]:
"""Extract date taken from photo EXIF data - returns Date (not DateTime) to match desktop schema.
Tries multiple methods to extract EXIF date:
- 1. PIL's getexif() (modern method)
- 2. PIL's _getexif() (deprecated but sometimes more reliable)
- 3. Access EXIF IFD directly if available
+ 1. exifread library (most reliable for reading EXIF)
+ 2. PIL's getexif() (modern method) - uses .get() for tag access
+ 3. PIL's _getexif() (deprecated but sometimes more reliable)
+ 4. Access EXIF IFD directly if available
+
+ Returns:
+ Date object or None if no valid EXIF date found
"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ # Try exifread library first (most reliable)
+ try:
+ import exifread
+ with open(image_path, 'rb') as f:
+ tags = exifread.process_file(f, details=False)
+
+ # Look for date tags in exifread format
+ # exifread uses tag names like 'EXIF DateTimeOriginal', 'Image DateTime', etc.
+ date_tag_names = [
+ 'EXIF DateTimeOriginal', # When photo was taken (highest priority)
+ 'EXIF DateTimeDigitized', # When photo was digitized
+ 'Image DateTime', # File modification date
+ 'EXIF DateTime', # Alternative format
+ ]
+
+ for tag_name in date_tag_names:
+ if tag_name in tags:
+ date_str = str(tags[tag_name]).strip()
+ if date_str and date_str != "0000:00:00 00:00:00" and not date_str.startswith("0000:"):
+ try:
+ # exifread returns dates in format "YYYY:MM:DD HH:MM:SS"
+ dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
+ extracted_date = dt.date()
+ if extracted_date <= date.today() and extracted_date >= date(1900, 1, 1):
+ logger.info(f"Successfully extracted date {extracted_date} from {tag_name} using exifread for {image_path}")
+ return extracted_date
+ except ValueError:
+ # Try alternative format
+ try:
+ dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
+ extracted_date = dt.date()
+ if extracted_date <= date.today() and extracted_date >= date(1900, 1, 1):
+ logger.info(f"Successfully extracted date {extracted_date} from {tag_name} using exifread for {image_path}")
+ return extracted_date
+ except ValueError:
+ continue
+ elif date_str:
+ logger.debug(f"Skipping invalid date string '{date_str}' from {tag_name} in {image_path}")
+ except ImportError:
+ logger.debug("exifread library not available, falling back to PIL")
+ except Exception as e:
+ logger.warning(f"exifread failed for {image_path}: {e}, trying PIL", exc_info=True)
+ # Log what tags exifread could see (if any)
+ try:
+ import exifread
+ with open(image_path, 'rb') as test_f:
+ test_tags = exifread.process_file(test_f, details=False)
+ if test_tags:
+ logger.warning(f"exifread found {len(test_tags)} tags but couldn't parse dates. Sample tags: {list(test_tags.keys())[:5]}")
+ except Exception:
+ pass
+
+ # Fallback to PIL methods
try:
with Image.open(image_path) as image:
exifdata = None
+ is_modern_api = False
# Try modern getexif() first
try:
exifdata = image.getexif()
- except Exception:
- pass
+ if exifdata and len(exifdata) > 0:
+ is_modern_api = True
+ logger.debug(f"Using modern getexif() API for {image_path}, found {len(exifdata)} EXIF tags")
+ except Exception as e:
+ logger.debug(f"Modern getexif() failed for {image_path}: {e}")
# If getexif() didn't work or returned empty, try deprecated _getexif()
if not exifdata or len(exifdata) == 0:
try:
if hasattr(image, '_getexif'):
exifdata = image._getexif()
- except Exception:
- pass
+ if exifdata:
+ logger.debug(f"Using deprecated _getexif() API for {image_path}, found {len(exifdata)} EXIF tags")
+ except Exception as e:
+ logger.debug(f"Deprecated _getexif() failed for {image_path}: {e}")
if not exifdata:
+ logger.warning(f"No EXIF data found in {image_path} - will fall back to file modification time")
+ # Try to open the file with exifread to see if it has EXIF at all
+ try:
+ import exifread
+ with open(image_path, 'rb') as test_f:
+ test_tags = exifread.process_file(test_f, details=False)
+ if test_tags:
+ logger.warning(f"File {image_path} has EXIF tags via exifread but PIL couldn't read them: {list(test_tags.keys())[:10]}")
+ else:
+ logger.warning(f"File {image_path} has no EXIF data at all")
+ except Exception:
+ pass
return None
+ # Debug: Log all available EXIF tags (only in debug mode to avoid spam)
+ if logger.isEnabledFor(logging.DEBUG):
+ try:
+ if hasattr(exifdata, 'items'):
+ all_tags = list(exifdata.items())[:20] # First 20 tags for debugging
+ logger.debug(f"Available EXIF tags in {image_path}: {all_tags}")
+ except Exception:
+ pass
+
# Look for date taken in EXIF tags
# Priority: DateTimeOriginal (when photo was taken) > DateTimeDigitized > DateTime (file modification)
date_tags = [
@@ -91,69 +178,203 @@ def extract_exif_date(image_path: str) -> Optional[date]:
306, # DateTime - file modification date (lowest priority)
]
- # Try direct access first
+ # Also try to find any date-like tags by iterating through all tags
+ # This helps catch dates that might be in different tag IDs
+ all_date_strings = []
+ try:
+ if hasattr(exifdata, 'items'):
+ for tag_id, value in exifdata.items():
+ if value and isinstance(value, (str, bytes)):
+ value_str = value.decode('utf-8', errors='ignore') if isinstance(value, bytes) else str(value)
+ # Check if it looks like a date string (YYYY:MM:DD or YYYY-MM-DD format)
+ if len(value_str) >= 10 and ('-' in value_str[:10] or ':' in value_str[:10]):
+ try:
+ # Try to parse it as a date
+ if ':' in value_str[:10]:
+ test_dt = datetime.strptime(value_str[:19], "%Y:%m:%d %H:%M:%S")
+ else:
+ test_dt = datetime.strptime(value_str[:19], "%Y-%m-%d %H:%M:%S")
+ all_date_strings.append((tag_id, value_str, test_dt.date()))
+ except (ValueError, IndexError):
+ pass
+ except Exception as e:
+ logger.debug(f"Error iterating through all EXIF tags in {image_path}: {e}")
+
+ # Try accessing tags - use multiple methods for compatibility
for tag_id in date_tags:
try:
- if tag_id in exifdata:
- date_str = exifdata[tag_id]
- if date_str:
- # Parse EXIF date format (YYYY:MM:DD HH:MM:SS)
+ # Try multiple access methods for compatibility
+ date_str = None
+
+ if is_modern_api:
+ # Modern getexif() API - try multiple access methods
+ # The Exif object from getexif() supports dictionary-like access
+ try:
+ # Method 1: Try .get() method
+ if hasattr(exifdata, 'get'):
+ date_str = exifdata.get(tag_id)
+ else:
+ date_str = None
+
+ # Method 2: If .get() returned None, try direct access
+ if not date_str:
+ try:
+ # Exif objects support __getitem__ for tag access
+ date_str = exifdata[tag_id]
+ except (KeyError, TypeError, AttributeError):
+ pass
+
+ # Method 3: Try iterating through all tags
+ if not date_str:
+ try:
+ # Exif objects are iterable
+ for key, value in exifdata.items():
+ if key == tag_id:
+ date_str = value
+ break
+ except (AttributeError, TypeError):
+ pass
+
+ # Method 4: Try using ExifTags.TAGS to help identify tags
+ if not date_str:
+ try:
+ from PIL.ExifTags import TAGS
+ # Log what tags are available for debugging
+ if logger.isEnabledFor(logging.DEBUG):
+ available_tag_ids = list(exifdata.keys())[:10]
+ logger.debug(f"Available tag IDs in {image_path}: {available_tag_ids}")
+ for tid in available_tag_ids:
+ tag_name = TAGS.get(tid, f"Unknown({tid})")
+ logger.debug(f" Tag {tid} ({tag_name}): {exifdata.get(tid)}")
+ except (ImportError, AttributeError, TypeError):
+ pass
+ except Exception as e:
+ logger.debug(f"Error accessing tag {tag_id} with modern API: {e}")
+ date_str = None
+ else:
+ # Old _getexif() returns a dict-like object
+ if hasattr(exifdata, 'get'):
+ date_str = exifdata.get(tag_id)
+ elif hasattr(exifdata, '__getitem__'):
try:
- dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
+ if tag_id in exifdata:
+ date_str = exifdata[tag_id]
+ except (KeyError, TypeError):
+ pass
+
+ if date_str:
+ # Ensure date_str is a string, not bytes or other type
+ if isinstance(date_str, bytes):
+ date_str = date_str.decode('utf-8', errors='ignore')
+ elif not isinstance(date_str, str):
+ date_str = str(date_str)
+ # Parse EXIF date format (YYYY:MM:DD HH:MM:SS)
+ try:
+ dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
+ extracted_date = dt.date()
+ # Validate date before returning (reject future dates)
+ if extracted_date > date.today() or extracted_date < date(1900, 1, 1):
+ logger.debug(f"Invalid date {extracted_date} from tag {tag_id} in {image_path}")
+ continue # Skip invalid dates
+ logger.debug(f"Successfully extracted date {extracted_date} from tag {tag_id} in {image_path}")
+ return extracted_date
+ except ValueError:
+ # Try alternative format
+ try:
+ dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
extracted_date = dt.date()
# Validate date before returning (reject future dates)
if extracted_date > date.today() or extracted_date < date(1900, 1, 1):
+ logger.debug(f"Invalid date {extracted_date} from tag {tag_id} in {image_path}")
continue # Skip invalid dates
+ logger.debug(f"Successfully extracted date {extracted_date} from tag {tag_id} in {image_path}")
return extracted_date
- except ValueError:
- # Try alternative format
- try:
- dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
- extracted_date = dt.date()
- # Validate date before returning (reject future dates)
- if extracted_date > date.today() or extracted_date < date(1900, 1, 1):
- continue # Skip invalid dates
- return extracted_date
- except ValueError:
- continue
- except (KeyError, TypeError):
+ except ValueError as ve:
+ logger.debug(f"Failed to parse date string '{date_str}' from tag {tag_id} in {image_path}: {ve}")
+ continue
+ except (KeyError, TypeError, AttributeError) as e:
+ logger.debug(f"Error accessing tag {tag_id} in {image_path}: {e}")
continue
+ # If we found date strings by iterating, try them (prioritize DateTimeOriginal-like dates)
+ if all_date_strings:
+ # Sort by tag ID (lower IDs like 306, 36867, 36868 are date tags)
+ # Priority: DateTimeOriginal (36867) > DateTimeDigitized (36868) > DateTime (306) > others
+ all_date_strings.sort(key=lambda x: (
+ 0 if x[0] == 36867 else # DateTimeOriginal first
+ 1 if x[0] == 36868 else # DateTimeDigitized second
+ 2 if x[0] == 306 else # DateTime third
+ 3 # Other dates last
+ ))
+
+ for tag_id, date_str, extracted_date in all_date_strings:
+ # Validate date
+ if extracted_date <= date.today() and extracted_date >= date(1900, 1, 1):
+ logger.info(f"Successfully extracted date {extracted_date} from tag {tag_id} (found by iteration) in {image_path}")
+ return extracted_date
+
# Try accessing EXIF IFD directly if available (for tags in EXIF IFD like DateTimeOriginal)
try:
if hasattr(exifdata, 'get_ifd'):
# EXIF IFD is at offset 0x8769
exif_ifd = exifdata.get_ifd(0x8769)
if exif_ifd:
+ logger.debug(f"Trying EXIF IFD for {image_path}")
for tag_id in date_tags:
- if tag_id in exif_ifd:
- date_str = exif_ifd[tag_id]
+ try:
+ # Try multiple access methods for IFD
+ date_str = None
+ if hasattr(exif_ifd, 'get'):
+ date_str = exif_ifd.get(tag_id)
+ elif hasattr(exif_ifd, '__getitem__'):
+ try:
+ if tag_id in exif_ifd:
+ date_str = exif_ifd[tag_id]
+ except (KeyError, TypeError):
+ pass
+
if date_str:
try:
dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
extracted_date = dt.date()
- # Validate date before returning (reject future dates)
if extracted_date > date.today() or extracted_date < date(1900, 1, 1):
- continue # Skip invalid dates
+ continue
+ logger.debug(f"Successfully extracted date {extracted_date} from IFD tag {tag_id} in {image_path}")
return extracted_date
except ValueError:
try:
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
extracted_date = dt.date()
- # Validate date before returning (reject future dates)
if extracted_date > date.today() or extracted_date < date(1900, 1, 1):
- continue # Skip invalid dates
+ continue
+ logger.debug(f"Successfully extracted date {extracted_date} from IFD tag {tag_id} in {image_path}")
return extracted_date
except ValueError:
continue
- except Exception:
- pass
+ except (KeyError, TypeError, AttributeError):
+ continue
+ except Exception as e:
+ logger.debug(f"Error accessing EXIF IFD for {image_path}: {e}")
+
+ logger.debug(f"No valid date found in EXIF data for {image_path}")
except Exception as e:
# Log error for debugging (but don't fail the import)
import logging
logger = logging.getLogger(__name__)
- logger.debug(f"Failed to extract EXIF date from {image_path}: {e}")
+ logger.warning(f"Failed to extract EXIF date from {image_path}: {e}", exc_info=True)
+ # Try a diagnostic check with exifread to see what's available
+ try:
+ import exifread
+ with open(image_path, 'rb') as diag_f:
+ diag_tags = exifread.process_file(diag_f, details=False)
+ if diag_tags:
+ date_tags_found = [k for k in diag_tags.keys() if 'date' in k.lower() or 'time' in k.lower()]
+ logger.warning(f"Diagnostic: File {image_path} has {len(diag_tags)} EXIF tags. Date-related tags: {date_tags_found[:10]}")
+ else:
+ logger.warning(f"Diagnostic: File {image_path} has no EXIF tags at all")
+ except Exception as diag_e:
+ logger.debug(f"Diagnostic check failed: {diag_e}")
return None
@@ -263,35 +484,102 @@ def extract_video_date(video_path: str) -> Optional[date]:
return None
-def extract_photo_date(image_path: str) -> Optional[date]:
- """Extract date taken from photo with fallback to file modification time.
+def extract_photo_date(image_path: str, is_uploaded_file: bool = False) -> Optional[date]:
+ """Extract date taken from photo with fallback to file modification time, then creation time.
Tries in order:
1. EXIF date tags (DateTimeOriginal, DateTimeDigitized, DateTime)
- 2. File modification time (as fallback)
+ 2. File modification time (as fallback if EXIF fails)
+ 3. File creation time (as final fallback if modification time doesn't exist)
+
+ Args:
+ image_path: Path to the image file
+ is_uploaded_file: If True, be more lenient about file modification times
+ (uploaded files have recent modification times but may have valid EXIF)
Returns:
Date object or None if no date can be determined
"""
+ import logging
+ import stat
+ logger = logging.getLogger(__name__)
+
# First try EXIF date extraction
date_taken = extract_exif_date(image_path)
if date_taken:
+ logger.info(f"Successfully extracted EXIF date {date_taken} from {image_path}")
return date_taken
- # Fallback to file modification time
+ # EXIF extraction failed - try file modification time
+ logger.warning(f"EXIF date extraction failed for {image_path}, trying file modification time")
+
try:
if os.path.exists(image_path):
- mtime = os.path.getmtime(image_path)
- mtime_date = datetime.fromtimestamp(mtime).date()
- # Validate date before returning (reject future dates)
- if mtime_date > date.today() or mtime_date < date(1900, 1, 1):
- return None # Skip invalid dates
- return mtime_date
+ # Try modification time first
+ try:
+ mtime = os.path.getmtime(image_path)
+ mtime_date = datetime.fromtimestamp(mtime).date()
+ today = date.today()
+ # Reject future dates and dates that are too recent (likely copy dates)
+ # If modification time is within the last 7 days, it's probably a copy date, not the original photo date
+ # BUT: for uploaded files, we should be more lenient since EXIF might have failed for other reasons
+ days_ago = (today - mtime_date).days
+ if mtime_date <= today and mtime_date >= date(1900, 1, 1):
+ if days_ago <= 7 and not is_uploaded_file:
+ # Modification time is too recent - likely a copy date, skip it
+ # (unless it's an uploaded file where we should trust EXIF extraction failure)
+ logger.debug(f"File modification time {mtime_date} is too recent (likely copy date) for {image_path}, trying creation time")
+ else:
+ # Modification time is old enough to be a real photo date, OR it's an uploaded file
+ if is_uploaded_file:
+ logger.info(f"Using file modification time {mtime_date} for uploaded file {image_path} (EXIF extraction failed)")
+ else:
+ logger.info(f"Using file modification time {mtime_date} for {image_path}")
+ return mtime_date
+ else:
+ logger.debug(f"File modification time {mtime_date} is invalid for {image_path}, trying creation time")
+ except (OSError, ValueError) as e:
+ logger.debug(f"Failed to get modification time from {image_path}: {e}, trying creation time")
+
+ # Fallback to creation time (birthtime on some systems, ctime on others)
+ try:
+ # Try to get creation time (birthtime on macOS/BSD, ctime on Linux as fallback)
+ stat_info = os.stat(image_path)
+
+ # On Linux, ctime is change time (not creation), but it's the best we have
+ # On macOS/BSD, st_birthtime exists
+ if hasattr(stat_info, 'st_birthtime'):
+ # macOS/BSD - use birthtime (actual creation time)
+ ctime = stat_info.st_birthtime
+ else:
+ # Linux - use ctime (change time, closest to creation we can get)
+ ctime = stat_info.st_ctime
+
+ ctime_date = datetime.fromtimestamp(ctime).date()
+ today = date.today()
+ # Validate date before returning (reject future dates and recent copy dates)
+ # BUT: for uploaded files, be more lenient since EXIF might have failed for other reasons
+ days_ago = (today - ctime_date).days
+ if ctime_date <= today and ctime_date >= date(1900, 1, 1):
+ if days_ago <= 7 and not is_uploaded_file:
+ # Creation time is too recent - likely a copy date, reject it
+ # (unless it's an uploaded file where we should trust EXIF extraction failure)
+ logger.warning(f"File creation time {ctime_date} is too recent (likely copy date) for {image_path}, cannot determine photo date")
+ return None
+ else:
+ # Creation time is old enough to be a real photo date, OR it's an uploaded file
+ if is_uploaded_file:
+ logger.info(f"Using file creation/change time {ctime_date} for uploaded file {image_path} (EXIF extraction failed)")
+ else:
+ logger.info(f"Using file creation/change time {ctime_date} for {image_path}")
+ return ctime_date
+ else:
+ logger.warning(f"File creation time {ctime_date} is invalid for {image_path}")
+ except (OSError, ValueError, AttributeError) as e:
+ logger.error(f"Failed to get creation time from {image_path}: {e}")
except Exception as e:
# Log error for debugging (but don't fail the import)
- import logging
- logger = logging.getLogger(__name__)
- logger.debug(f"Failed to get file modification time from {image_path}: {e}")
+ logger.error(f"Failed to get file timestamps from {image_path}: {e}")
return None
@@ -328,7 +616,7 @@ def find_photos_in_folder(folder_path: str, recursive: bool = True) -> list[str]
def import_photo_from_path(
- db: Session, photo_path: str, update_progress: Optional[Callable[[int, int, str], None]] = None
+ db: Session, photo_path: str, update_progress: Optional[Callable[[int, int, str], None]] = None, is_uploaded_file: bool = False, file_last_modified: Optional[date] = None, browser_exif_date: Optional[date] = None
) -> Tuple[Optional[Photo], bool]:
"""Import a single photo or video from file path into database.
@@ -363,7 +651,7 @@ def import_photo_from_path(
if media_type == "video":
date_taken = extract_video_date(photo_path)
else:
- date_taken = extract_photo_date(photo_path)
+ date_taken = extract_photo_date(photo_path, is_uploaded_file=is_uploaded_file)
# Validate date_taken before setting
date_taken = validate_date_taken(date_taken)
if date_taken:
@@ -385,7 +673,7 @@ def import_photo_from_path(
if media_type == "video":
date_taken = extract_video_date(photo_path)
else:
- date_taken = extract_photo_date(photo_path)
+ date_taken = extract_photo_date(photo_path, is_uploaded_file=is_uploaded_file)
# Validate date_taken before setting
date_taken = validate_date_taken(date_taken)
if date_taken:
@@ -394,15 +682,35 @@ def import_photo_from_path(
db.refresh(existing_by_path)
return existing_by_path, False
- # Extract date taken with fallback to file modification time
+ # Extract date taken with priority: browser EXIF > server EXIF > browser file modification time > server file modification time
+ import logging
+ logger = logging.getLogger(__name__)
+
if media_type == "video":
date_taken = extract_video_date(photo_path)
else:
- date_taken = extract_photo_date(photo_path)
+ # Priority 1: Use browser-extracted EXIF date (most reliable - extracted from original file before upload)
+ if browser_exif_date:
+ logger.info(f"[DATE_EXTRACTION] Using browser-extracted EXIF date {browser_exif_date} for {photo_path}")
+ date_taken = browser_exif_date
+ # Priority 2: Use browser-captured file modification time (from original file before upload)
+ # This MUST come before server-side extraction to avoid using the server file's modification time (which is today)
+ elif file_last_modified:
+ logger.info(f"[DATE_EXTRACTION] Using file's original modification date {file_last_modified} from browser metadata for {photo_path}")
+ date_taken = file_last_modified
+ else:
+ logger.debug(f"[DATE_EXTRACTION] No browser metadata for {photo_path}, trying server EXIF extraction")
+ # Priority 3: Try to extract EXIF from the uploaded file on server
+ date_taken = extract_photo_date(photo_path, is_uploaded_file=is_uploaded_file)
+
+ if not date_taken:
+ logger.warning(f"[DATE_EXTRACTION] No date found for {photo_path} - browser_exif_date={browser_exif_date}, file_last_modified={file_last_modified}")
# Validate date_taken - ensure it's a valid date object or None
# This prevents corrupted date data from being saved
+ logger.debug(f"[DATE_EXTRACTION] Before validation: date_taken={date_taken} for {photo_path}")
date_taken = validate_date_taken(date_taken)
+ logger.info(f"[DATE_EXTRACTION] After validation: date_taken={date_taken} for {photo_path}")
# For videos, mark as processed immediately (we don't process videos for faces)
# For images, start as unprocessed
diff --git a/backend/services/tasks.py b/backend/services/tasks.py
index 1776692..d376e20 100644
--- a/backend/services/tasks.py
+++ b/backend/services/tasks.py
@@ -119,6 +119,34 @@ def process_faces_task(
total_faces_detected = 0
total_faces_stored = 0
+ def refresh_db_session():
+ """Refresh database session if it becomes stale or disconnected.
+
+ This prevents crashes when the database connection is lost during long-running
+ processing tasks. Closes the old session and creates a new one.
+ """
+ nonlocal db
+ try:
+ # Test if the session is still alive by executing a simple query
+ from sqlalchemy import text
+ db.execute(text("SELECT 1"))
+ db.commit() # Ensure transaction is clean
+ except Exception as e:
+ # Session is stale or disconnected - create a new one
+ try:
+ print(f"[Task] Database session disconnected, refreshing... Error: {e}")
+ except (BrokenPipeError, OSError):
+ pass
+ try:
+ db.close()
+ except Exception:
+ pass
+ db = SessionLocal()
+ try:
+ print(f"[Task] Database session refreshed")
+ except (BrokenPipeError, OSError):
+ pass
+
try:
def update_progress(
processed: int,
@@ -181,6 +209,9 @@ def process_faces_task(
# Process faces
# Wrap in try-except to ensure we preserve progress even if process_unprocessed_photos fails
try:
+ # Refresh session before starting processing to ensure it's healthy
+ refresh_db_session()
+
photos_processed, total_faces_detected, total_faces_stored = (
process_unprocessed_photos(
db,
@@ -191,6 +222,27 @@ def process_faces_task(
)
)
except Exception as e:
+ # Check if it's a database connection error
+ error_str = str(e).lower()
+ is_db_error = any(keyword in error_str for keyword in [
+ 'connection', 'disconnect', 'timeout', 'closed', 'lost',
+ 'operationalerror', 'database', 'server closed', 'connection reset',
+ 'connection pool', 'connection refused', 'session needs refresh'
+ ])
+
+ if is_db_error:
+ # Try to refresh the session - this helps if the error is recoverable
+ # but we don't retry the entire batch to avoid reprocessing photos
+ try:
+ print(f"[Task] Database error detected, attempting to refresh session: {e}")
+ refresh_db_session()
+ print(f"[Task] Session refreshed - job will fail gracefully. Restart job to continue processing remaining photos.")
+ except Exception as refresh_error:
+ try:
+ print(f"[Task] Failed to refresh database session: {refresh_error}")
+ except (BrokenPipeError, OSError):
+ pass
+
# If process_unprocessed_photos fails, preserve any progress made
# and re-raise so the outer handler can log it properly
try:
diff --git a/backend/services/thumbnail_service.py b/backend/services/thumbnail_service.py
index 016d4fd..58dcd3c 100644
--- a/backend/services/thumbnail_service.py
+++ b/backend/services/thumbnail_service.py
@@ -10,9 +10,11 @@ from typing import Optional
from PIL import Image
-# Cache directory for thumbnails (relative to project root)
-# Will be created in the same directory as the database
-THUMBNAIL_CACHE_DIR = Path(__file__).parent.parent.parent.parent / "data" / "thumbnails"
+# Cache directory for thumbnails (relative to project root).
+# NOTE: This file lives at: /backend/services/thumbnail_service.py
+# So project root is 2 levels up from: /backend/services/
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+THUMBNAIL_CACHE_DIR = PROJECT_ROOT / "data" / "thumbnails"
THUMBNAIL_SIZE = (320, 240) # Width, Height
THUMBNAIL_QUALITY = 85 # JPEG quality
diff --git a/backend/utils/click_logger.py b/backend/utils/click_logger.py
new file mode 100644
index 0000000..7ab28ac
--- /dev/null
+++ b/backend/utils/click_logger.py
@@ -0,0 +1,123 @@
+"""Click logging utility with file rotation and management."""
+
+from __future__ import annotations
+
+import os
+import logging
+from datetime import datetime
+from pathlib import Path
+from typing import Optional
+
+# Log directory - relative to project root
+LOG_DIR = Path(__file__).parent.parent.parent / "logs"
+LOG_FILE = LOG_DIR / "admin-clicks.log"
+MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
+BACKUP_COUNT = 5 # Keep 5 rotated files
+RETENTION_DAYS = 30 # Keep logs for 30 days
+
+# Ensure log directory exists
+LOG_DIR.mkdir(parents=True, exist_ok=True)
+
+# Configure logger with rotation
+_logger: Optional[logging.Logger] = None
+
+
+def get_click_logger() -> logging.Logger:
+ """Get or create the click logger with rotation."""
+ global _logger
+
+ if _logger is not None:
+ return _logger
+
+ _logger = logging.getLogger("admin_clicks")
+ _logger.setLevel(logging.INFO)
+
+ # Remove existing handlers to avoid duplicates
+ _logger.handlers.clear()
+
+ # Create rotating file handler
+ from logging.handlers import RotatingFileHandler
+
+ handler = RotatingFileHandler(
+ LOG_FILE,
+ maxBytes=MAX_FILE_SIZE,
+ backupCount=BACKUP_COUNT,
+ encoding='utf-8'
+ )
+
+ # Simple format: timestamp | username | page | element_type | element_id | element_text | context
+ formatter = logging.Formatter(
+ '%(asctime)s | %(message)s',
+ datefmt='%Y-%m-%d %H:%M:%S'
+ )
+ handler.setFormatter(formatter)
+ _logger.addHandler(handler)
+
+ # Prevent propagation to root logger
+ _logger.propagate = False
+
+ return _logger
+
+
+def log_click(
+ username: str,
+ page: str,
+ element_type: str,
+ element_id: Optional[str] = None,
+ element_text: Optional[str] = None,
+ context: Optional[dict] = None,
+) -> None:
+ """Log a click event to the log file.
+
+ Args:
+ username: Username of the user who clicked
+ page: Page/route where click occurred (e.g., '/identify')
+ element_type: Type of element (button, link, input, etc.)
+ element_id: ID of the element (optional)
+ element_text: Text content of the element (optional)
+ context: Additional context as dict (optional, will be JSON stringified)
+ """
+ logger = get_click_logger()
+
+ # Format context as JSON string if provided
+ context_str = ""
+ if context:
+ import json
+ try:
+ context_str = f" | {json.dumps(context)}"
+ except (TypeError, ValueError):
+ context_str = f" | {str(context)}"
+
+ # Build log message
+ parts = [
+ username,
+ page,
+ element_type,
+ element_id or "",
+ element_text or "",
+ ]
+
+ # Join parts with | separator, remove empty parts
+ message = " | ".join(part for part in parts if part) + context_str
+
+ logger.info(message)
+
+
+def cleanup_old_logs() -> None:
+ """Remove log files older than RETENTION_DAYS."""
+ if not LOG_DIR.exists():
+ return
+
+ from datetime import timedelta
+ cutoff_date = datetime.now() - timedelta(days=RETENTION_DAYS)
+
+ for log_file in LOG_DIR.glob("admin-clicks.log.*"):
+ try:
+ # Check file modification time
+ mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
+ if mtime < cutoff_date:
+ log_file.unlink()
+ except (OSError, ValueError):
+ # Skip files we can't process
+ pass
+
diff --git a/docs/6DREPNET_ANALYSIS.md b/docs/6DREPNET_ANALYSIS.md
deleted file mode 100644
index 2d64533..0000000
--- a/docs/6DREPNET_ANALYSIS.md
+++ /dev/null
@@ -1,404 +0,0 @@
-# 6DRepNet Integration Analysis
-
-**Date:** 2025-01-XX
-**Status:** Analysis Only (No Code Changes)
-**Purpose:** Evaluate feasibility of integrating 6DRepNet for direct yaw/pitch/roll estimation
-
----
-
-## Executive Summary
-
-**6DRepNet is technically feasible to implement** as an alternative or enhancement to the current RetinaFace-based landmark pose estimation. The integration would provide more accurate direct pose estimation but requires PyTorch dependency and architectural adjustments.
-
-**Key Findings:**
-- ✅ **Technically Feasible**: 6DRepNet is available as a PyPI package (`sixdrepnet`)
-- ⚠️ **Dependency Conflict**: Requires PyTorch (currently using TensorFlow via DeepFace)
-- ✅ **Interface Compatible**: Can work with existing OpenCV/CV2 image processing
-- 📊 **Accuracy Improvement**: Direct estimation vs. geometric calculation from landmarks
-- 🔄 **Architectural Impact**: Requires abstraction layer to support both methods
-
----
-
-## Current Implementation Analysis
-
-### Current Pose Detection Architecture
-
-**Location:** `src/utils/pose_detection.py`
-
-**Current Method:**
-1. Uses RetinaFace to detect faces and extract facial landmarks
-2. Calculates yaw, pitch, roll **geometrically** from landmark positions:
- - **Yaw**: Calculated from nose position relative to eye midpoint
- - **Pitch**: Calculated from nose position relative to expected vertical position
- - **Roll**: Calculated from eye line angle
-3. Uses face width (eye distance) as additional indicator for profile detection
-4. Classifies pose mode from angles using thresholds
-
-**Key Characteristics:**
-- ✅ No additional ML model dependencies (uses RetinaFace landmarks)
-- ✅ Lightweight (geometric calculations only)
-- ⚠️ Accuracy depends on landmark quality and geometric assumptions
-- ⚠️ May have limitations with extreme poses or low-quality images
-
-**Integration Points:**
-- `FaceProcessor.__init__()`: Initializes `PoseDetector` with graceful fallback
-- `process_faces()`: Calls `pose_detector.detect_pose_faces(img_path)`
-- `face_service.py`: Uses shared `PoseDetector` instance for batch processing
-- Returns: `{'yaw_angle', 'pitch_angle', 'roll_angle', 'pose_mode', ...}`
-
----
-
-## 6DRepNet Overview
-
-### What is 6DRepNet?
-
-6DRepNet is a PyTorch-based deep learning model designed for **direct head pose estimation** using a continuous 6D rotation matrix representation. It addresses ambiguities in rotation labels and enables robust full-range head pose predictions.
-
-**Key Features:**
-- Direct estimation of yaw, pitch, roll angles
-- Full 360° range support
-- Competitive accuracy (MAE ~2.66° on BIWI dataset)
-- Available as easy-to-use Python package
-
-### Technical Specifications
-
-**Package:** `sixdrepnet` (PyPI)
-**Framework:** PyTorch
-**Input:** Image (OpenCV format, numpy array, or PIL Image)
-**Output:** `(pitch, yaw, roll)` angles in degrees
-**Model Size:** ~50-100MB (weights downloaded automatically)
-**Dependencies:**
-- PyTorch (CPU or CUDA)
-- OpenCV (already in requirements)
-- NumPy (already in requirements)
-
-### Usage Example
-
-```python
-from sixdrepnet import SixDRepNet
-import cv2
-
-# Initialize (weights downloaded automatically)
-model = SixDRepNet()
-
-# Load image
-img = cv2.imread('/path/to/image.jpg')
-
-# Predict pose (returns pitch, yaw, roll)
-pitch, yaw, roll = model.predict(img)
-
-# Optional: visualize results
-model.draw_axis(img, yaw, pitch, roll)
-```
-
----
-
-## Integration Feasibility Analysis
-
-### ✅ Advantages
-
-1. **Higher Accuracy**
- - Direct ML-based estimation vs. geometric calculations
- - Trained on diverse datasets, better generalization
- - Handles extreme poses better than geometric methods
-
-2. **Full Range Support**
- - Supports full 360° rotation (current method may struggle with extreme angles)
- - Better profile detection accuracy
-
-3. **Simpler Integration**
- - Single method call: `model.predict(img)` returns angles directly
- - No need to match landmarks to faces or calculate from geometry
- - Can work with face crops directly (no need for full landmarks)
-
-4. **Consistent Interface**
- - Returns same format: `(pitch, yaw, roll)` in degrees
- - Can drop-in replace current `PoseDetector` class methods
-
-### ⚠️ Challenges
-
-1. **Dependency Conflict**
- - **Current Stack:** TensorFlow (via DeepFace)
- - **6DRepNet Requires:** PyTorch
- - **Impact:** Both frameworks can coexist but increase memory footprint
-
-2. **Face Detection Dependency**
- - 6DRepNet requires **face crops** as input (not full images)
- - Current flow: RetinaFace → landmarks → geometric calculation
- - New flow: RetinaFace → face crop → 6DRepNet → angles
- - Still need RetinaFace for face detection/bounding boxes
-
-3. **Initialization Overhead**
- - Model loading time on first use (~1-2 seconds)
- - Model weights download (~50-100MB) on first initialization
- - GPU memory usage if CUDA available (optional but faster)
-
-4. **Processing Speed**
- - **Current:** Geometric calculations (very fast, <1ms per face)
- - **6DRepNet:** Neural network inference (~10-50ms per face on CPU, ~5-10ms on GPU)
- - Impact on batch processing: ~10-50x slower per face
-
-5. **Memory Footprint**
- - PyTorch + model weights: ~200-500MB additional memory
- - Model kept in memory for batch processing (good for performance)
-
----
-
-## Architecture Compatibility
-
-### Current Architecture
-
-```
-┌─────────────────────────────────────────┐
-│ FaceProcessor │
-│ ┌───────────────────────────────────┐ │
-│ │ PoseDetector (RetinaFace) │ │
-│ │ - detect_pose_faces(img_path) │ │
-│ │ - Returns: yaw, pitch, roll │ │
-│ └───────────────────────────────────┘ │
-│ │
-│ DeepFace (TensorFlow) │
-│ - Face detection + encoding │
-└─────────────────────────────────────────┘
-```
-
-### Proposed Architecture (6DRepNet)
-
-```
-┌─────────────────────────────────────────┐
-│ FaceProcessor │
-│ ┌───────────────────────────────────┐ │
-│ │ PoseDetector (6DRepNet) │ │
-│ │ - Requires: face crop (from │ │
-│ │ RetinaFace/DeepFace) │ │
-│ │ - model.predict(face_crop) │ │
-│ │ - Returns: yaw, pitch, roll │ │
-│ └───────────────────────────────────┘ │
-│ │
-│ DeepFace (TensorFlow) │
-│ - Face detection + encoding │
-│ │
-│ RetinaFace (still needed) │
-│ - Face detection + bounding boxes │
-└─────────────────────────────────────────┘
-```
-
-### Integration Strategy Options
-
-**Option 1: Replace Current Method**
-- Remove geometric calculations
-- Use 6DRepNet exclusively
-- **Pros:** Simpler, one method only
-- **Cons:** Loses lightweight fallback option
-
-**Option 2: Hybrid Approach (Recommended)**
-- Support both methods via configuration
-- Use 6DRepNet when available, fallback to geometric
-- **Pros:** Backward compatible, graceful degradation
-- **Cons:** More complex code
-
-**Option 3: Parallel Execution**
-- Run both methods and compare/validate
-- **Pros:** Best of both worlds, validation
-- **Cons:** 2x processing time
-
----
-
-## Implementation Requirements
-
-### 1. Dependencies
-
-**Add to `requirements.txt`:**
-```txt
-# 6DRepNet for direct pose estimation
-sixdrepnet>=1.0.0
-torch>=2.0.0 # PyTorch (CPU version)
-# OR
-# torch>=2.0.0+cu118 # PyTorch with CUDA support (if GPU available)
-```
-
-**Note:** PyTorch installation depends on system:
-- **CPU-only:** `pip install torch` (smaller, ~150MB)
-- **CUDA-enabled:** `pip install torch --index-url https://download.pytorch.org/whl/cu118` (larger, ~1GB)
-
-### 2. Code Changes Required
-
-**File: `src/utils/pose_detection.py`**
-
-**New Class: `SixDRepNetPoseDetector`**
-```python
-class SixDRepNetPoseDetector:
- """Pose detector using 6DRepNet for direct angle estimation"""
-
- def __init__(self):
- from sixdrepnet import SixDRepNet
- self.model = SixDRepNet()
-
- def predict_pose(self, face_crop_img) -> Tuple[float, float, float]:
- """Predict yaw, pitch, roll from face crop"""
- pitch, yaw, roll = self.model.predict(face_crop_img)
- return yaw, pitch, roll # Match current interface (yaw, pitch, roll)
-```
-
-**Integration Points:**
-1. Modify `PoseDetector.detect_pose_faces()` to optionally use 6DRepNet
-2. Extract face crops from RetinaFace bounding boxes
-3. Pass crops to 6DRepNet for prediction
-4. Return same format as current method
-
-**Key Challenge:** Need face crops, not just landmarks
-- Current: Uses landmarks from RetinaFace
-- 6DRepNet: Needs image crops (can extract from same RetinaFace detection)
-
-### 3. Configuration Changes
-
-**File: `src/core/config.py`**
-
-Add configuration option:
-```python
-# Pose detection method: 'geometric' (current) or '6drepnet' (ML-based)
-POSE_DETECTION_METHOD = 'geometric' # or '6drepnet'
-```
-
----
-
-## Performance Comparison
-
-### Current Method (Geometric)
-
-**Speed:**
-- ~0.1-1ms per face (geometric calculations only)
-- No model loading overhead
-
-**Accuracy:**
-- Good for frontal and moderate poses
-- May struggle with extreme angles or profile views
-- Depends on landmark quality
-
-**Memory:**
-- Minimal (~10-50MB for RetinaFace only)
-
-### 6DRepNet Method
-
-**Speed:**
-- CPU: ~10-50ms per face (neural network inference)
-- GPU: ~5-10ms per face (with CUDA)
-- Initial model load: ~1-2 seconds (one-time)
-
-**Accuracy:**
-- Higher accuracy across all pose ranges
-- Better generalization from training data
-- More robust to image quality variations
-
-**Memory:**
-- Model weights: ~50-100MB
-- PyTorch runtime: ~200-500MB
-- Total: ~250-600MB additional
-
-### Batch Processing Impact
-
-**Example: Processing 1000 photos with 3 faces each = 3000 faces**
-
-**Current Method:**
-- Time: ~300-3000ms (0.3-3 seconds)
-- Very fast, minimal impact
-
-**6DRepNet (CPU):**
-- Time: ~30-150 seconds (0.5-2.5 minutes)
-- Significant slowdown but acceptable for batch jobs
-
-**6DRepNet (GPU):**
-- Time: ~15-30 seconds
-- Much faster with GPU acceleration
-
----
-
-## Recommendations
-
-### ✅ Recommended Approach: Hybrid Implementation
-
-**Phase 1: Add 6DRepNet as Optional Enhancement**
-1. Keep current geometric method as default
-2. Add 6DRepNet as optional alternative
-3. Use configuration flag to enable: `POSE_DETECTION_METHOD = '6drepnet'`
-4. Graceful fallback if 6DRepNet unavailable
-
-**Phase 2: Performance Tuning**
-1. Implement GPU acceleration if available
-2. Batch processing optimizations
-3. Cache model instance across batch operations
-
-**Phase 3: Evaluation**
-1. Compare accuracy on real dataset
-2. Measure performance impact
-3. Decide on default method based on results
-
-### ⚠️ Considerations
-
-1. **Dependency Management:**
- - PyTorch + TensorFlow coexistence is possible but increases requirements
- - Consider making 6DRepNet optional (extra dependency group)
-
-2. **Face Crop Extraction:**
- - Need to extract face crops from images
- - Can use RetinaFace bounding boxes (already available)
- - Or use DeepFace detection results
-
-3. **Backward Compatibility:**
- - Keep current method available
- - Database schema unchanged (same fields: yaw_angle, pitch_angle, roll_angle)
- - API interface unchanged
-
-4. **GPU Support:**
- - Optional but recommended for performance
- - Can detect CUDA availability automatically
- - Falls back to CPU if GPU unavailable
-
----
-
-## Implementation Complexity Assessment
-
-### Complexity: **Medium**
-
-**Factors:**
-- ✅ Interface is compatible (same output format)
-- ✅ Existing architecture supports abstraction
-- ⚠️ Requires face crop extraction (not just landmarks)
-- ⚠️ PyTorch dependency adds complexity
-- ⚠️ Performance considerations for batch processing
-
-**Estimated Effort:**
-- **Initial Implementation:** 2-4 hours
-- **Testing & Validation:** 2-3 hours
-- **Documentation:** 1 hour
-- **Total:** ~5-8 hours
-
----
-
-## Conclusion
-
-**6DRepNet is technically feasible and recommended for integration** as an optional enhancement to the current geometric pose estimation method. The hybrid approach provides:
-
-1. **Backward Compatibility:** Current method remains default
-2. **Improved Accuracy:** Better pose estimation, especially for extreme angles
-3. **Flexibility:** Users can choose method based on accuracy vs. speed tradeoff
-4. **Future-Proof:** ML-based approach can be improved with model updates
-
-**Next Steps (if proceeding):**
-1. Add `sixdrepnet` and `torch` to requirements (optional dependency group)
-2. Implement `SixDRepNetPoseDetector` class
-3. Modify `PoseDetector` to support both methods
-4. Add configuration option
-5. Test on sample dataset
-6. Measure performance impact
-7. Update documentation
-
----
-
-## References
-
-- **6DRepNet Paper:** [6D Rotation Representation For Unconstrained Head Pose Estimation](https://www.researchgate.net/publication/358898627_6D_Rotation_Representation_For_Unconstrained_Head_Pose_Estimation)
-- **PyPI Package:** [sixdrepnet](https://pypi.org/project/sixdrepnet/)
-- **PyTorch Installation:** https://pytorch.org/get-started/locally/
-- **Current Implementation:** `src/utils/pose_detection.py`
-
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 674c688..1b3b7af 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -74,7 +74,7 @@ PunimTag is a modern web-based photo management and tagging application with adv
│ │ • /api/v1/users • /api/v1/videos │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
- ↕
+ ↕
┌─────────────────────────────────────────────────────────────────────────┐
│ BUSINESS LOGIC LAYER │
│ ┌──────────────────┬──────────────────┬──────────────────────────┐ │
@@ -92,7 +92,7 @@ PunimTag is a modern web-based photo management and tagging application with adv
│ │ (Multi-criteria)│ (Video Processing)│ (JWT, RBAC) │ │
│ └──────────────────┴──────────────────┴──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
- ↕
+ ↕
┌─────────────────────────────────────────────────────────────────────────┐
│ DATA ACCESS LAYER │
│ ┌──────────────────────────────────────────────────────────────────┐ │
@@ -103,7 +103,7 @@ PunimTag is a modern web-based photo management and tagging application with adv
│ │ • Query optimization • Data integrity │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
- ↕
+ ↕
┌─────────────────────────────────────────────────────────────────────────┐
│ PERSISTENCE LAYER │
│ ┌──────────────────────────────┬──────────────────────────────────┐ │
diff --git a/docs/AUTOMATCH_LOAD_ANALYSIS.md b/docs/AUTOMATCH_LOAD_ANALYSIS.md
deleted file mode 100644
index 689344f..0000000
--- a/docs/AUTOMATCH_LOAD_ANALYSIS.md
+++ /dev/null
@@ -1,174 +0,0 @@
-# Auto-Match Load Performance Analysis
-
-## Summary
-Auto-Match page loads significantly slower than Identify page because it lacks the performance optimizations that Identify uses. Auto-Match always fetches all data upfront with no caching, while Identify uses sessionStorage caching and lazy loading.
-
-## Identify Page Optimizations (Current)
-
-### 1. **SessionStorage Caching**
-- **State Caching**: Caches faces, current index, similar faces, and form data in sessionStorage
-- **Settings Caching**: Caches filter settings (pageSize, minQuality, sortBy, etc.)
-- **Restoration**: On mount, restores cached state instead of making API calls
-- **Implementation**:
- - `STATE_KEY = 'identify_state'` - stores faces, currentIdx, similar, faceFormData, selectedSimilar
- - `SETTINGS_KEY = 'identify_settings'` - stores filter settings
- - Only loads fresh data if no cached state exists
-
-### 2. **Lazy Loading**
-- **Similar Faces**: Only loads similar faces when:
- - `compareEnabled` is true
- - Current face changes
- - Not loaded during initial page load
-- **Images**: Uses lazy loading for similar face images (`loading="lazy"`)
-
-### 3. **Image Preloading**
-- Preloads next/previous face images in background
-- Uses `new Image()` to preload without blocking UI
-- Delayed by 100ms to avoid blocking current image load
-
-### 4. **Batch Operations**
-- Uses `batchSimilarity` endpoint for unique faces filtering
-- Single API call instead of multiple individual calls
-
-### 5. **Progressive State Management**
-- Uses refs to track restoration state
-- Prevents unnecessary reloads during state restoration
-- Only triggers API calls when actually needed
-
-## Auto-Match Page (Current - No Optimizations)
-
-### 1. **No Caching**
-- **No sessionStorage**: Always makes fresh API calls on mount
-- **No state restoration**: Always starts from scratch
-- **No settings persistence**: Tolerance and other settings reset on page reload
-
-### 2. **Eager Loading**
-- **All Data Upfront**: Loads ALL people and ALL matches in single API call
-- **No Lazy Loading**: All match data loaded even if user never views it
-- **No Progressive Loading**: Everything must be loaded before UI is usable
-
-### 3. **No Image Preloading**
-- Images load on-demand as user navigates
-- No preloading of next/previous person images
-
-### 4. **Large API Response**
-- Backend returns complete dataset:
- - All identified people
- - All matches for each person
- - All face metadata (photo info, locations, quality scores, etc.)
-- Response size can be very large (hundreds of KB to MB) depending on:
- - Number of identified people
- - Number of matches per person
- - Amount of metadata per match
-
-### 5. **Backend Processing**
-The `find_auto_match_matches` function:
-- Queries all identified faces (one per person, quality >= 0.3)
-- For EACH person, calls `find_similar_faces` to find matches
-- This means N database queries (where N = number of people)
-- All processing happens synchronously before response is sent
-
-## Performance Comparison
-
-### Identify Page Load Flow
-```
-1. Check sessionStorage for cached state
-2. If cached: Restore state (instant, no API call)
-3. If not cached: Load faces (paginated, ~50 faces)
-4. Load similar faces only when face changes (lazy)
-5. Preload next/previous images (background)
-```
-
-### Auto-Match Page Load Flow
-```
-1. Always call API (no cache check)
-2. Backend processes ALL people:
- - Query all identified faces
- - For each person: query similar faces
- - Build complete response with all matches
-3. Wait for complete response (can be large)
-4. Render all data at once
-```
-
-## Key Differences
-
-| Feature | Identify | Auto-Match |
-|---------|----------|------------|
-| **Caching** | ✅ sessionStorage | ❌ None |
-| **State Restoration** | ✅ Yes | ❌ No |
-| **Lazy Loading** | ✅ Similar faces only | ❌ All data upfront |
-| **Image Preloading** | ✅ Next/prev faces | ❌ None |
-| **Pagination** | ✅ Yes (page_size) | ❌ No (all at once) |
-| **Progressive Loading** | ✅ Yes | ❌ No |
-| **API Call Size** | Small (paginated) | Large (all data) |
-| **Backend Queries** | 1-2 queries | N+1 queries (N = people) |
-
-## Why Auto-Match is Slower
-
-1. **No Caching**: Every page load requires full API call
-2. **Large Response**: All people + all matches in single response
-3. **N+1 Query Problem**: Backend makes one query per person to find matches
-4. **Synchronous Processing**: All processing happens before response
-5. **No Lazy Loading**: All match data loaded even if never viewed
-
-## Potential Optimizations for Auto-Match
-
-### 1. **Add SessionStorage Caching** (High Impact)
-- Cache people list and matches in sessionStorage
-- Restore on mount instead of API call
-- Similar to Identify page approach
-
-### 2. **Lazy Load Matches** (High Impact)
-- Load people list first
-- Load matches for current person only
-- Load matches for next person in background
-- Similar to how Identify loads similar faces
-
-### 3. **Pagination** (Medium Impact)
-- Paginate people list (e.g., 20 people per page)
-- Load matches only for visible people
-- Reduces initial response size
-
-### 4. **Backend Optimization** (High Impact)
-- Batch similarity queries instead of N+1 pattern
-- Use `calculate_batch_similarities` for all people at once
-- Cache results if tolerance hasn't changed
-
-### 5. **Image Preloading** (Low Impact)
-- Preload reference face images for next/previous people
-- Preload match images for current person
-
-### 6. **Progressive Rendering** (Medium Impact)
-- Show people list immediately
-- Load matches progressively as user navigates
-- Show loading indicators for matches
-
-## Code Locations
-
-### Identify Page
-- **Frontend**: `frontend/src/pages/Identify.tsx`
- - Lines 42-45: SessionStorage keys
- - Lines 272-347: State restoration logic
- - Lines 349-399: State saving logic
- - Lines 496-527: Image preloading
- - Lines 258-270: Lazy loading of similar faces
-
-### Auto-Match Page
-- **Frontend**: `frontend/src/pages/AutoMatch.tsx`
- - Lines 35-71: `loadAutoMatch` function (always calls API)
- - Lines 74-77: Auto-load on mount (no cache check)
-
-### Backend
-- **API Endpoint**: `src/web/api/faces.py` (lines 539-702)
-- **Service Function**: `src/web/services/face_service.py` (lines 1736-1846)
- - `find_auto_match_matches`: Processes all people synchronously
-
-## Recommendations
-
-1. **Immediate**: Add sessionStorage caching (similar to Identify)
-2. **High Priority**: Implement lazy loading of matches
-3. **Medium Priority**: Optimize backend to use batch queries
-4. **Low Priority**: Add image preloading
-
-The biggest win would be adding sessionStorage caching, which would make subsequent page loads instant (like Identify).
-
diff --git a/docs/CLIENT_DEPLOYMENT_QUESTIONS.md b/docs/CLIENT_DEPLOYMENT_QUESTIONS.md
deleted file mode 100644
index a16abc3..0000000
--- a/docs/CLIENT_DEPLOYMENT_QUESTIONS.md
+++ /dev/null
@@ -1,219 +0,0 @@
-# Client Deployment Questions
-
-**PunimTag Web Application - Information Needed for Deployment**
-
-We have the source code ready. To deploy on your server, we need the following information:
-
----
-
-## 1. Server Access
-
-**How can we access your server?**
-- [ ] SSH access
- - Server IP/hostname: `_________________`
- - SSH port: `_________________` (default: 22)
- - Username: `_________________`
- - Authentication method:
- - [ ] SSH key (provide public key or key file)
- - [ ] Username/password: `_________________`
-- [ ] Other access method: `_________________`
-
-**Do we have permission to install software?**
-- [ ] Yes, we can install packages
-- [ ] No, limited permissions (what can we do?): `_________________`
-
----
-
-## 2. Databases
-
-**We need TWO PostgreSQL databases:**
-
-### Main Database (for photos, faces, people, tags)
-- **Database server location:**
- - [ ] Same server as application
- - [ ] Different server: `_________________`
-- **Connection details:**
- - Host/IP: `_________________`
- - Port: `_________________` (default: 5432)
- - Database name: `_________________` (or we can create: `punimtag`)
- - Username: `_________________`
- - Password: `_________________`
-- **Can we create the database?**
- - [ ] Yes
- - [ ] No (provide existing database details above)
-
-### Auth Database (for frontend website user accounts)
-- **Database server location:**
- - [ ] Same server as main database
- - [ ] Same server as application (different database)
- - [ ] Different server: `_________________`
-- **Connection details:**
- - Host/IP: `_________________`
- - Port: `_________________` (default: 5432)
- - Database name: `_________________` (or we can create: `punimtag_auth`)
- - Username: `_________________`
- - Password: `_________________`
-- **Can we create the database?**
- - [ ] Yes
- - [ ] No (provide existing database details above)
-
-**Database access:**
-- Can the application server connect to the databases?
- - [ ] Yes, direct connection
- - [ ] VPN required: `_________________`
- - [ ] IP whitelist required: `_________________`
-
----
-
-## 3. Redis (for background jobs)
-
-**Redis server:**
-- [ ] Same server as application
-- [ ] Different server: `_________________`
-- [ ] Not installed (we can install)
-
-**If separate server:**
-- Host/IP: `_________________`
-- Port: `_________________` (default: 6379)
-- Password (if required): `_________________`
-
----
-
-## 4. Network & Ports
-
-**What ports can we use?**
-- Backend API (port 8000):
- - [ ] Can use port 8000
- - [ ] Need different port: `_________________`
-- Frontend (port 3000 for dev, or web server for production):
- - [ ] Can use port 3000
- - [ ] Need different port: `_________________`
- - [ ] Will use web server (Nginx/Apache) - port 80/443
-
-**Who needs to access the application?**
-- [ ] Internal network only
-- [ ] External users (internet)
-- [ ] VPN users only
-- [ ] Specific IP ranges: `_________________`
-
-**Domain/URL:**
-- Do you have a domain name? `_________________`
-- What URL should users access? `_________________` (e.g., `https://punimtag.yourdomain.com`)
-
-**Firewall:**
-- [ ] We can configure firewall rules
-- [ ] IT team manages firewall (contact: `_________________`)
-
----
-
-## 5. Frontend Website
-
-**How should the frontend be served?**
-- [ ] Development mode (Vite dev server)
-- [ ] Production build with web server (Nginx/Apache)
-- [ ] Other: `_________________`
-
-**Backend API URL for frontend:**
-- What URL should the frontend use to connect to the backend API?
- - `_________________` (e.g., `http://server-ip:8000` or `https://api.yourdomain.com`)
-- **Important:** This URL must be accessible from users' browsers (not just localhost)
-
-**Web server (if using production build):**
-- [ ] Nginx installed
-- [ ] Apache installed
-- [ ] Not installed (we can install/configure)
-- [ ] Other: `_________________`
-
----
-
-## 6. Storage
-
-**Where should uploaded photos be stored?**
-- Storage path: `_________________` (e.g., `/var/punimtag/photos` or `/data/uploads`)
-- [ ] We can create and configure the directory
-- [ ] Directory already exists: `_________________`
-
-**Storage type:**
-- [ ] Local disk
-- [ ] Network storage (NAS): `_________________`
-- [ ] Other: `_________________`
-
----
-
-## 7. Software Installation
-
-**What's already installed on the server?**
-- Python 3.12+: [ ] Yes [ ] No
-- Node.js 18+: [ ] Yes [ ] No
-- PostgreSQL: [ ] Yes [ ] No
-- Redis: [ ] Yes [ ] No
-- Git: [ ] Yes [ ] No
-
-**Can we install missing software?**
-- [ ] Yes
-- [ ] No (what's available?): `_________________`
-
-**Does the server have internet access?**
-- [ ] Yes (can download packages)
-- [ ] No (internal package repository?): `_________________`
-
----
-
-## 8. SSL/HTTPS
-
-**Do you need HTTPS?**
-- [ ] Yes (SSL certificate required)
- - [ ] We can generate self-signed certificate
- - [ ] You will provide certificate
- - [ ] Let's Encrypt (domain required)
-- [ ] No (HTTP is fine for testing)
-
----
-
-## 9. Code Deployment
-
-**How should we deploy the code?**
-- [ ] Git repository access
- - Repository URL: `_________________`
- - Access credentials: `_________________`
-- [ ] File transfer (SFTP/SCP)
-- [ ] We will provide deployment package
-- [ ] Other: `_________________`
-
----
-
-## 10. Contact Information
-
-**Who should we contact for:**
-- IT/Network issues: `_________________` (email: `_________________`, phone: `_________________`)
-- Database issues: `_________________` (email: `_________________`, phone: `_________________`)
-- General questions: `_________________` (email: `_________________`, phone: `_________________`)
-
----
-
-## Quick Summary
-
-**What we need:**
-1. ✅ Server access (SSH)
-2. ✅ Two PostgreSQL databases (main + auth)
-3. ✅ Redis server
-4. ✅ Network ports (8000 for API, 3000 or web server for frontend)
-5. ✅ Storage location for photos
-6. ✅ Frontend API URL configuration
-7. ✅ Contact information
-
-**What we'll do:**
-- Install required software (if needed)
-- Configure databases
-- Deploy and configure the application
-- Set up frontend website
-- Test everything works
-
----
-
-**Please fill out this form and return it to us so we can begin deployment.**
-
-
-
-
-
diff --git a/docs/CLIENT_NETWORK_TESTING_INFO_for_BOTH.md b/docs/CLIENT_NETWORK_TESTING_INFO_for_BOTH.md
deleted file mode 100644
index 3ff1506..0000000
--- a/docs/CLIENT_NETWORK_TESTING_INFO_for_BOTH.md
+++ /dev/null
@@ -1,505 +0,0 @@
-# Client Network Testing Information Request
-
-**PunimTag Web Application - Network Testing Setup**
-
-This document outlines the information required from your organization to begin testing the PunimTag web application on your network infrastructure.
-
----
-
-## 1. Server Access & Infrastructure
-
-### 1.1 Server Details
-- **Server Hostname/IP Address**: `_________________`
-- **Operating System**: `_________________` (e.g., Ubuntu 22.04, RHEL 9, Windows Server 2022)
-- **SSH Access Method**:
- - [ ] SSH Key-based authentication (provide public key)
- - [ ] Username/Password authentication
-- **SSH Port**: `_________________` (default: 22)
-- **SSH Username**: `_________________`
-- **SSH Credentials**: `_________________` (or key file location)
-- **Sudo/Root Access**:
- - [ ] Yes (required for service installation)
- - [ ] No (limited permissions - specify what's available)
-
-### 1.2 Server Specifications
-- **CPU**: `_________________` (cores/threads)
-- **RAM**: `_________________` GB
-- **Disk Space Available**: `_________________` GB
-- **Network Bandwidth**: `_________________` Mbps
-- **Is this a virtual machine or physical server?**: `_________________`
-
----
-
-## 2. Network Configuration
-
-### 2.1 Network Topology
-- **Network Type**:
- - [ ] Internal/Private network only
- - [ ] Internet-facing with public IP
- - [ ] VPN-accessible only
- - [ ] Hybrid (internal + external access)
-
-### 2.2 IP Addresses & Ports
-- **Server IP Address**: `_________________`
-- **Internal Network Range**: `_________________` (e.g., 192.168.1.0/24)
-- **Public IP Address** (if applicable): `_________________`
-- **Domain Name** (if applicable): `_________________`
-- **Subdomain** (if applicable): `_________________` (e.g., punimtag.yourdomain.com)
-
-### 2.3 Firewall Rules
-Please confirm that the following ports can be opened for the application:
-
-**Required Ports:**
-- **Port 8000** (Backend API) - TCP
- - [ ] Can be opened
- - [ ] Cannot be opened (alternative port needed: `_________________`)
-- **Port 3000** (Frontend) - TCP
- - [ ] Can be opened
- - [ ] Cannot be opened (alternative port needed: `_________________`)
-- **Port 5432** (PostgreSQL) - TCP
- - [ ] Can be opened (if database is on separate server)
- - [ ] Internal only (localhost)
- - [ ] Cannot be opened (alternative port needed: `_________________`)
-- **Port 6379** (Redis) - TCP
- - [ ] Can be opened (if Redis is on separate server)
- - [ ] Internal only (localhost)
- - [ ] Cannot be opened (alternative port needed: `_________________`)
-
-**Additional Ports (if using reverse proxy):**
-- **Port 80** (HTTP) - TCP
-- **Port 443** (HTTPS) - TCP
-
-### 2.4 Network Access Requirements
-- **Who needs access to the application?**
- - [ ] Internal users only (same network)
- - [ ] External users (internet access)
- - [ ] VPN users only
- - [ ] Specific IP ranges: `_________________`
-
-- **Do users need to access from outside the network?**
- - [ ] Yes (requires public IP or VPN)
- - [ ] No (internal only)
-
-### 2.5 Proxy/VPN Configuration
-- **Is there a proxy server?**
- - [ ] Yes
- - Proxy address: `_________________`
- - Proxy port: `_________________`
- - Authentication required: [ ] Yes [ ] No
- - Credentials: `_________________`
- - [ ] No
-
-- **VPN Requirements:**
- - [ ] VPN access required for testing team
- - [ ] VPN type: `_________________` (OpenVPN, Cisco AnyConnect, etc.)
- - [ ] VPN credentials/configuration: `_________________`
-
----
-
-## 3. Database Configuration
-
-### 3.1 PostgreSQL Database
-- **Database Server Location**:
- - [ ] Same server as application
- - [ ] Separate server (provide details below)
-
-**If separate database server:**
-- **Database Server IP/Hostname**: `_________________`
-- **Database Port**: `_________________` (default: 5432)
-- **Database Name**: `_________________` (or we can create: `punimtag`)
-- **Database Username**: `_________________`
-- **Database Password**: `_________________`
-- **Database Version**: `_________________` (PostgreSQL 12+ required)
-
-**If database needs to be created:**
-- **Can we create the database?** [ ] Yes [ ] No
-- **Database administrator credentials**: `_________________`
-- **Preferred database name**: `_________________`
-
-### 3.2 Database Access
-- **Network access to database**:
- - [ ] Direct connection from application server
- - [ ] VPN required
- - [ ] Specific IP whitelist required: `_________________`
-
-### 3.3 Database Backup Requirements
-- **Backup policy**: `_________________`
-- **Backup location**: `_________________`
-- **Backup schedule**: `_________________`
-
-### 3.4 Auth Database (Frontend Website Authentication)
-The application uses a **separate authentication database** for the frontend website user accounts.
-
-- **Auth Database Server Location**:
- - [ ] Same server as main database
- - [ ] Same server as application (different database)
- - [ ] Separate server (provide details below)
-
-**If separate auth database server:**
-- **Auth Database Server IP/Hostname**: `_________________`
-- **Auth Database Port**: `_________________` (default: 5432)
-- **Auth Database Name**: `_________________` (or we can create: `punimtag_auth`)
-- **Auth Database Username**: `_________________`
-- **Auth Database Password**: `_________________`
-- **Auth Database Version**: `_________________` (PostgreSQL 12+ required)
-
-**If auth database needs to be created:**
-- **Can we create the auth database?** [ ] Yes [ ] No
-- **Database administrator credentials**: `_________________`
-- **Preferred database name**: `_________________` (default: `punimtag_auth`)
-
-**Auth Database Access:**
-- **Network access to auth database**:
- - [ ] Direct connection from application server
- - [ ] VPN required
- - [ ] Specific IP whitelist required: `_________________`
-
-**Note:** The auth database stores user accounts for the frontend website (separate from backend admin users). It requires its own connection string configured as `DATABASE_URL_AUTH`.
-
----
-
-## 4. Redis Configuration
-
-### 4.1 Redis Server
-- **Redis Server Location**:
- - [ ] Same server as application
- - [ ] Separate server (provide details below)
- - [ ] Not installed (we can install)
-
-**If separate Redis server:**
-- **Redis Server IP/Hostname**: `_________________`
-- **Redis Port**: `_________________` (default: 6379)
-- **Redis Password** (if password-protected): `_________________`
-
-**If Redis needs to be installed:**
-- **Can we install Redis?** [ ] Yes [ ] No
-- **Preferred installation method**:
- - [ ] Package manager (apt/yum)
- - [ ] Docker container
- - [ ] Manual compilation
-
----
-
-## 5. Storage & File System
-
-### 5.1 Photo Storage
-- **Storage Location**: `_________________` (e.g., /var/punimtag/photos, /data/uploads)
-- **Storage Capacity**: `_________________` GB
-- **Storage Type**:
- - [ ] Local disk
- - [ ] Network attached storage (NAS)
- - [ ] Cloud storage (specify: `_________________`)
-- **Storage Path Permissions**:
- - [ ] We can create and configure
- - [ ] Pre-configured (provide path: `_________________`)
-
-### 5.2 File System Access
-- **Mount points** (if using NAS): `_________________`
-- **NFS/SMB configuration** (if applicable): `_________________`
-- **Disk quotas**: `_________________` (if applicable)
-
----
-
-## 6. Software Prerequisites
-
-### 6.1 Installed Software
-Please confirm if the following are already installed:
-
-**Backend Requirements:**
-- **Python 3.12+**:
- - [ ] Installed (version: `_________________`)
- - [ ] Not installed (we can install)
-- **PostgreSQL**:
- - [ ] Installed (version: `_________________`)
- - [ ] Not installed (we can install)
-- **Redis**:
- - [ ] Installed (version: `_________________`)
- - [ ] Not installed (we can install)
-
-**Frontend Requirements:**
-- **Node.js 18+**:
- - [ ] Installed (version: `_________________`)
- - [ ] Not installed (we can install)
-- **npm**:
- - [ ] Installed (version: `_________________`)
- - [ ] Not installed (we can install)
-- **Web Server** (for serving built frontend):
- - [ ] Nginx (version: `_________________`)
- - [ ] Apache (version: `_________________`)
- - [ ] Other: `_________________`
- - [ ] Not installed (we can install/configure)
-
-**Development Tools:**
-- **Git**:
- - [ ] Installed
- - [ ] Not installed (we can install)
-
-### 6.2 Installation Permissions
-- **Can we install software packages?** [ ] Yes [ ] No
-- **Package manager available**:
- - [ ] apt (Debian/Ubuntu)
- - [ ] yum/dnf (RHEL/CentOS)
- - [ ] Other: `_________________`
-
-### 6.3 Internet Access
-- **Does the server have internet access?** [ ] Yes [ ] No
-- **If yes, can it download packages?** [ ] Yes [ ] No
-- **If no, do you have an internal package repository?**
- - [ ] Yes (provide details: `_________________`)
- - [ ] No
-
----
-
-## 7. Security & Authentication
-
-### 7.1 SSL/TLS Certificates
-- **SSL Certificate Required?**
- - [ ] Yes (HTTPS required)
- - [ ] No (HTTP acceptable for testing)
-- **Certificate Type**:
- - [ ] Self-signed (we can generate)
- - [ ] Organization CA certificate
- - [ ] Let's Encrypt
- - [ ] Commercial certificate
-- **Certificate Location** (if provided): `_________________`
-
-### 7.2 Authentication & Access Control
-- **Default Admin Credentials**:
- - Username: `_________________` (or use default: `admin`)
- - Password: `_________________` (or use default: `admin`)
-- **User Accounts**:
- - [ ] Single admin account only
- - [ ] Multiple test user accounts needed
- - Number of test users: `_________________`
- - User details: `_________________`
-
-### 7.3 Security Policies
-- **Firewall rules**:
- - [ ] Managed by IT team (provide contact: `_________________`)
- - [ ] We can configure
-- **Security scanning requirements**: `_________________`
-- **Compliance requirements**: `_________________` (e.g., HIPAA, GDPR, SOC 2)
-
----
-
-## 8. Monitoring & Logging
-
-### 8.1 Logging
-- **Log file location**: `_________________` (default: application directory)
-- **Log retention policy**: `_________________`
-- **Centralized logging system**:
- - [ ] Yes (provide details: `_________________`)
- - [ ] No
-
-### 8.2 Monitoring
-- **Monitoring tools in use**: `_________________`
-- **Do you need application metrics?** [ ] Yes [ ] No
-- **Health check endpoints**:
- - [ ] Available at `/api/v1/health`
- - [ ] Custom endpoint needed: `_________________`
-
----
-
-## 9. Testing Requirements
-
-### 9.1 Test Data
-- **Sample photos for testing**:
- - [ ] We will provide test photos
- - [ ] You will provide test photos
- - [ ] Location of test photos: `_________________`
-- **Expected photo volume for testing**: `_________________` photos
-- **Photo size range**: `_________________` MB per photo
-
-### 9.2 Test Users
-- **Number of concurrent test users**: `_________________`
-- **Test user accounts needed**:
- - [ ] Yes (provide usernames: `_________________`)
- - [ ] No (use default admin account)
-
-### 9.3 Testing Schedule
-- **Preferred testing window**:
- - Start date: `_________________`
- - End date: `_________________`
- - Preferred time: `_________________` (timezone: `_________________`)
-- **Maintenance windows** (if any): `_________________`
-
----
-
-## 10. Frontend Website Configuration
-
-### 10.1 Frontend Deployment Method
-- **How will the frontend be served?**
- - [ ] Development mode (Vite dev server on port 3000)
- - [ ] Production build served by web server (Nginx/Apache)
- - [ ] Static file hosting (CDN, S3, etc.)
- - [ ] Docker container
- - [ ] Other: `_________________`
-
-### 10.2 Frontend Environment Variables
-The frontend React application requires the following configuration:
-
-- **Backend API URL** (`VITE_API_URL`):
- - Development: `http://localhost:8000` or `http://127.0.0.1:8000`
- - Production: `_________________` (e.g., `https://api.yourdomain.com` or `http://server-ip:8000`)
- - **Note:** This must be accessible from users' browsers (not just localhost)
-
-### 10.3 Frontend Build Requirements
-- **Build location**: `_________________` (where built files will be placed)
-- **Build process**:
- - [ ] We will build on the server
- - [ ] We will provide pre-built files
- - [ ] Build will be done on a separate build server
-- **Static file serving**:
- - [ ] Nginx configured
- - [ ] Apache configured
- - [ ] Needs to be configured: `_________________`
-
-### 10.4 Frontend Access
-- **Frontend URL/Domain**: `_________________` (e.g., `https://punimtag.yourdomain.com` or `http://server-ip:3000`)
-- **HTTPS Required?**
- - [ ] Yes (SSL certificate needed)
- - [ ] No (HTTP acceptable for testing)
-- **CORS Configuration**:
- - [ ] Needs to be configured
- - [ ] Already configured
- - **Allowed origins**: `_________________`
-
----
-
-## 11. Deployment Method
-
-### 11.1 Preferred Deployment
-- **Deployment method**:
- - [ ] Direct installation on server
- - [ ] Docker containers
- - [ ] Docker Compose
- - [ ] Kubernetes
- - [ ] Other: `_________________`
-
-### 11.2 Code Deployment
-- **How will code be deployed?**
- - [ ] Git repository access (provide URL: `_________________`)
- - [ ] File transfer (SFTP/SCP)
- - [ ] We will provide deployment package
-- **Repository access credentials**: `_________________`
-
----
-
-## 12. Environment Variables Summary
-
-For your reference, here are all the environment variables that need to be configured:
-
-**Backend Environment Variables:**
-- `DATABASE_URL` - Main database connection (PostgreSQL or SQLite)
- - Example: `postgresql+psycopg2://user:password@host:5432/punimtag`
-- `DATABASE_URL_AUTH` - Auth database connection for frontend website users (PostgreSQL)
- - Example: `postgresql+psycopg2://user:password@host:5432/punimtag_auth`
-- `SECRET_KEY` - JWT secret key (change in production!)
-- `ADMIN_USERNAME` - Default admin username (optional, for backward compatibility)
-- `ADMIN_PASSWORD` - Default admin password (optional, for backward compatibility)
-- `PHOTO_STORAGE_DIR` - Directory for storing uploaded photos (default: `data/uploads`)
-
-**Frontend Environment Variables:**
-- `VITE_API_URL` - Backend API URL (must be accessible from browsers)
- - Example: `http://server-ip:8000` or `https://api.yourdomain.com`
-
-**Note:** All environment variables should be set securely and not exposed in version control.
-
----
-
-## 13. Contact Information
-
-### 13.1 Primary Contacts
-- **IT/Network Administrator**:
- - Name: `_________________`
- - Email: `_________________`
- - Phone: `_________________`
-- **Database Administrator**:
- - Name: `_________________`
- - Email: `_________________`
- - Phone: `_________________`
-- **Project Manager/Point of Contact**:
- - Name: `_________________`
- - Email: `_________________`
- - Phone: `_________________`
-
-### 13.2 Emergency Contacts
-- **After-hours support contact**: `_________________`
-- **Escalation procedure**: `_________________`
-
----
-
-## 14. Additional Requirements
-
-### 14.1 Custom Configuration
-- **Custom domain/subdomain**: `_________________`
-- **Custom branding**: `_________________`
-- **Integration requirements**: `_________________`
-- **Special network requirements**: `_________________`
-
-### 14.2 Documentation
-- **Network diagrams**: `_________________` (if available)
-- **Existing infrastructure documentation**: `_________________`
-- **Change management process**: `_________________`
-
-### 14.3 Other Notes
-- **Any other relevant information**:
- ```
- _________________________________________________
- _________________________________________________
- _________________________________________________
- ```
-
----
-
-## Application Requirements Summary
-
-For your reference, here are the key technical requirements:
-
-**Application Components:**
-- Backend API (FastAPI) - Port 8000
-- Frontend Website (React) - Port 3000 (dev) or served via web server (production)
-- Main PostgreSQL Database - Port 5432 (stores photos, faces, people, tags)
-- Auth PostgreSQL Database - Port 5432 (stores frontend website user accounts)
-- Redis (for background jobs) - Port 6379
-
-**System Requirements:**
-- Python 3.12 or higher (backend)
-- Node.js 18 or higher (frontend build)
-- PostgreSQL 12 or higher (both databases)
-- Redis 5.0 or higher
-- Web server (Nginx/Apache) for production frontend serving
-- Minimum 4GB RAM (8GB+ recommended)
-- Sufficient disk space for photo storage
-
-**Network Requirements:**
-- TCP ports: 3000 (dev frontend), 8000 (backend API)
-- TCP ports: 5432 (databases), 6379 (Redis) - if services are remote
-- HTTP/HTTPS access for users to frontend website
-- Network connectivity between:
- - Application server ↔ Main database
- - Application server ↔ Auth database
- - Application server ↔ Redis
- - Users' browsers ↔ Frontend website
- - Users' browsers ↔ Backend API (via VITE_API_URL)
-
----
-
-## Next Steps
-
-Once this information is provided, we will:
-1. Review the network configuration
-2. Prepare deployment scripts and configuration files
-3. Schedule a deployment window
-4. Perform initial setup and testing
-5. Provide access credentials and documentation
-
-**Please return this completed form to:** `_________________`
-
-**Deadline for information:** `_________________`
-
----
-
-*Document Version: 1.0*
-*Last Updated: [Current Date]*
-
diff --git a/docs/CONFIDENCE_CALIBRATION_SUMMARY.md b/docs/CONFIDENCE_CALIBRATION_SUMMARY.md
deleted file mode 100644
index a83e7d0..0000000
--- a/docs/CONFIDENCE_CALIBRATION_SUMMARY.md
+++ /dev/null
@@ -1,89 +0,0 @@
-# Confidence Calibration Implementation
-
-## Problem Solved
-
-The identify UI was showing confidence percentages that were **not** actual match probabilities. The old calculation used a simple linear transformation:
-
-```python
-confidence_pct = (1 - distance) * 100
-```
-
-This gave misleading results:
-- Distance 0.6 (at threshold) showed 40% confidence
-- Distance 1.0 showed 0% confidence
-- Distance 2.0 showed -100% confidence (impossible!)
-
-## Solution: Empirical Confidence Calibration
-
-Implemented a proper confidence calibration system that converts DeepFace distance values to actual match probabilities based on empirical analysis of the ArcFace model.
-
-### Key Improvements
-
-1. **Realistic Probabilities**:
- - Distance 0.6 (threshold) now shows ~55% confidence (realistic)
- - Distance 1.0 shows ~17% confidence (not 0%)
- - No negative percentages
-
-2. **Non-linear Mapping**: Accounts for the actual distribution of distances in face recognition
-
-3. **Configurable Methods**: Support for different calibration approaches:
- - `empirical`: Based on DeepFace ArcFace characteristics (default)
- - `sigmoid`: Sigmoid-based calibration
- - `linear`: Original linear transformation (fallback)
-
-### Calibration Curve
-
-The empirical calibration uses different approaches for different distance ranges:
-
-- **Very Close (≤ 0.5×tolerance)**: 95-100% confidence (exponential decay)
-- **Near Threshold (≤ tolerance)**: 55-95% confidence (linear interpolation)
-- **Above Threshold (≤ 1.5×tolerance)**: 20-55% confidence (rapid decay)
-- **Very Far (> 1.5×tolerance)**: 1-20% confidence (exponential decay)
-
-### Configuration
-
-Added new settings in `src/core/config.py`:
-
-```python
-USE_CALIBRATED_CONFIDENCE = True # Enable/disable calibration
-CONFIDENCE_CALIBRATION_METHOD = "empirical" # Calibration method
-```
-
-### Files Modified
-
-1. **`src/core/face_processing.py`**: Added calibration methods
-2. **`src/gui/identify_panel.py`**: Updated to use calibrated confidence
-3. **`src/gui/auto_match_panel.py`**: Updated to use calibrated confidence
-4. **`src/core/config.py`**: Added calibration settings
-5. **`src/photo_tagger.py`**: Updated to use calibrated confidence
-
-### Test Results
-
-The test script shows significant improvements:
-
-| Distance | Old Linear | New Calibrated | Improvement |
-|----------|-------------|----------------|-------------|
-| 0.6 | 40.0% | 55.0% | +15.0% |
-| 1.0 | 0.0% | 17.2% | +17.2% |
-| 1.5 | -50.0% | 8.1% | +58.1% |
-
-### Usage
-
-The calibrated confidence is now automatically used throughout the application. Users will see more realistic match probabilities that better reflect the actual likelihood of a face match.
-
-### Future Enhancements
-
-1. **Dynamic Calibration**: Learn from user feedback to improve calibration
-2. **Model-Specific Calibration**: Different calibration for different DeepFace models
-3. **Quality-Aware Calibration**: Adjust confidence based on face quality scores
-4. **User Preferences**: Allow users to adjust calibration sensitivity
-
-## Technical Details
-
-The calibration system uses empirical parameters derived from analysis of DeepFace ArcFace model behavior. The key insight is that face recognition distances don't follow a linear relationship with match probability - they follow a more complex distribution that varies by distance range.
-
-This implementation provides a foundation for more sophisticated calibration methods while maintaining backward compatibility through configuration options.
-
-
-
-
diff --git a/docs/DEEPFACE_MIGRATION_COMPLETE.md b/docs/DEEPFACE_MIGRATION_COMPLETE.md
deleted file mode 100644
index 8d22078..0000000
--- a/docs/DEEPFACE_MIGRATION_COMPLETE.md
+++ /dev/null
@@ -1,406 +0,0 @@
-# 🎉 DeepFace Migration COMPLETE! 🎉
-
-**Date:** October 16, 2025
-**Status:** ✅ ALL PHASES COMPLETE
-**Total Tests:** 14/14 PASSING
-
----
-
-## Executive Summary
-
-The complete migration from `face_recognition` to `DeepFace` has been successfully completed across all three phases! PunimTag now uses state-of-the-art face detection (RetinaFace) and recognition (ArcFace) with 512-dimensional embeddings for superior accuracy.
-
----
-
-## Phase Completion Summary
-
-### ✅ Phase 1: Database Schema Updates
-**Status:** COMPLETE
-**Tests:** 4/4 passing
-**Completed:** Database schema updated with DeepFace-specific columns
-
-**Key Changes:**
-- Added `detector_backend`, `model_name`, `face_confidence` to `faces` table
-- Added `detector_backend`, `model_name` to `person_encodings` table
-- Updated `add_face()` and `add_person_encoding()` methods
-- Created migration script
-
-**Documentation:** `PHASE1_COMPLETE.md`
-
----
-
-### ✅ Phase 2: Configuration Updates
-**Status:** COMPLETE
-**Tests:** 5/5 passing
-**Completed:** TensorFlow suppression and GUI controls added
-
-**Key Changes:**
-- Added TensorFlow warning suppression to all entry points
-- Updated `FaceProcessor.__init__()` to accept detector/model parameters
-- Added detector and model selection dropdowns to GUI
-- Updated process callback to pass settings
-
-**Documentation:** `PHASE2_COMPLETE.md`
-
----
-
-### ✅ Phase 3: Core Face Processing Migration
-**Status:** COMPLETE
-**Tests:** 5/5 passing
-**Completed:** Complete replacement of face_recognition with DeepFace
-
-**Key Changes:**
-- Replaced face detection with `DeepFace.represent()`
-- Implemented cosine similarity for matching
-- Updated location format handling (dict vs tuple)
-- Adjusted adaptive tolerance for DeepFace
-- 512-dimensional encodings (vs 128)
-
-**Documentation:** `PHASE3_COMPLETE.md`
-
----
-
-## Overall Test Results
-
-```
-Phase 1 Tests: 4/4 ✅
- ✅ PASS: Schema Columns
- ✅ PASS: add_face() Method
- ✅ PASS: add_person_encoding() Method
- ✅ PASS: Config Constants
-
-Phase 2 Tests: 5/5 ✅
- ✅ PASS: TensorFlow Suppression
- ✅ PASS: FaceProcessor Initialization
- ✅ PASS: Config Imports
- ✅ PASS: Entry Point Imports
- ✅ PASS: GUI Config Constants
-
-Phase 3 Tests: 5/5 ✅
- ✅ PASS: DeepFace Import
- ✅ PASS: DeepFace Detection
- ✅ PASS: Cosine Similarity
- ✅ PASS: Location Format Handling
- ✅ PASS: End-to-End Processing
-
-TOTAL: 14/14 tests passing ✅
-```
-
----
-
-## Technical Comparison
-
-### Before Migration (face_recognition)
-
-| Feature | Value |
-|---------|-------|
-| Detection | HOG/CNN (dlib) |
-| Model | dlib ResNet |
-| Encoding Size | 128 dimensions |
-| Storage | 1,024 bytes/face |
-| Similarity Metric | Euclidean distance |
-| Location Format | (top, right, bottom, left) |
-| Tolerance | 0.6 |
-
-### After Migration (DeepFace)
-
-| Feature | Value |
-|---------|-------|
-| Detection | RetinaFace/MTCNN/OpenCV/SSD ⭐ |
-| Model | ArcFace ⭐ |
-| Encoding Size | 512 dimensions ⭐ |
-| Storage | 4,096 bytes/face |
-| Similarity Metric | Cosine similarity ⭐ |
-| Location Format | {x, y, w, h} |
-| Tolerance | 0.4 |
-
----
-
-## Key Improvements
-
-### 🎯 Accuracy
-- ✅ State-of-the-art ArcFace model
-- ✅ Better detection in difficult conditions
-- ✅ More robust to pose variations
-- ✅ Superior cross-age recognition
-- ✅ Lower false positive rate
-
-### 🔧 Flexibility
-- ✅ 4 detector backends to choose from
-- ✅ 4 recognition models to choose from
-- ✅ GUI controls for easy switching
-- ✅ Configurable settings per run
-
-### 📊 Information
-- ✅ Face confidence scores from detector
-- ✅ Detailed facial landmark detection
-- ✅ Quality scoring preserved
-- ✅ Better match confidence metrics
-
----
-
-## Files Created/Modified
-
-### Created Files (9):
-1. `PHASE1_COMPLETE.md` - Phase 1 documentation
-2. `PHASE2_COMPLETE.md` - Phase 2 documentation
-3. `PHASE3_COMPLETE.md` - Phase 3 documentation
-4. `DEEPFACE_MIGRATION_COMPLETE.md` - This file
-5. `scripts/migrate_to_deepface.py` - Migration script
-6. `tests/test_phase1_schema.py` - Phase 1 tests
-7. `tests/test_phase2_config.py` - Phase 2 tests
-8. `tests/test_phase3_deepface.py` - Phase 3 tests
-9. `.notes/phase1_quickstart.md` & `phase2_quickstart.md` - Quick references
-
-### Modified Files (6):
-1. `requirements.txt` - Updated dependencies
-2. `src/core/config.py` - DeepFace configuration
-3. `src/core/database.py` - Schema updates
-4. `src/core/face_processing.py` - Complete DeepFace integration
-5. `src/gui/dashboard_gui.py` - GUI controls
-6. `run_dashboard.py` - Callback updates
-
----
-
-## Migration Path
-
-### For New Installations:
-```bash
-# Install dependencies
-pip install -r requirements.txt
-
-# Run the application
-python3 run_dashboard.py
-
-# Add photos and process with DeepFace
-# Select detector and model in GUI
-```
-
-### For Existing Installations:
-```bash
-# IMPORTANT: Backup your database first!
-cp data/photos.db data/photos.db.backup
-
-# Install new dependencies
-pip install -r requirements.txt
-
-# Run migration (DELETES ALL DATA!)
-python3 scripts/migrate_to_deepface.py
-# Type: DELETE ALL DATA
-
-# Re-add photos and process
-python3 run_dashboard.py
-```
-
----
-
-## Running All Tests
-
-```bash
-cd /home/ladmin/Code/punimtag
-source venv/bin/activate
-
-# Phase 1 tests
-python3 tests/test_phase1_schema.py
-
-# Phase 2 tests
-python3 tests/test_phase2_config.py
-
-# Phase 3 tests
-python3 tests/test_phase3_deepface.py
-```
-
-Expected: All 14 tests pass ✅
-
----
-
-## Configuration Options
-
-### Available Detectors:
-1. **retinaface** (default) - Best accuracy
-2. **mtcnn** - Good balance
-3. **opencv** - Fastest
-4. **ssd** - Good balance
-
-### Available Models:
-1. **ArcFace** (default) - 512-dim, best accuracy
-2. **Facenet** - 128-dim, fast
-3. **Facenet512** - 512-dim, very good
-4. **VGG-Face** - 2622-dim, good
-
-### How to Change:
-1. Open GUI: `python3 run_dashboard.py`
-2. Click "🔍 Process"
-3. Select detector and model from dropdowns
-4. Click "Start Processing"
-
----
-
-## Performance Notes
-
-### Processing Speed:
-- ~2-3x slower than face_recognition
-- Worth it for significantly better accuracy!
-- Use GPU for faster processing (future enhancement)
-
-### First Run:
-- Downloads models (~100MB+)
-- Stored in `~/.deepface/weights/`
-- Subsequent runs are faster
-
-### Memory Usage:
-- Higher due to larger encodings (4KB vs 1KB)
-- Deep learning models in memory
-- Acceptable for desktop application
-
----
-
-## Known Limitations
-
-1. **Cannot migrate old encodings:** 128-dim → 512-dim incompatible
-2. **Must re-process:** All faces need to be detected again
-3. **Slower processing:** ~2-3x slower (but more accurate)
-4. **GPU not used:** CPU-only for now (future enhancement)
-5. **Model downloads:** First run requires internet
-
----
-
-## Troubleshooting
-
-### "DeepFace not available" warning?
-```bash
-pip install deepface tensorflow opencv-python retina-face
-```
-
-### TensorFlow warnings?
-Already suppressed in code. If you see warnings, they're from first import only.
-
-### "No module named 'deepface'"?
-Make sure you're in the virtual environment:
-```bash
-source venv/bin/activate
-pip install -r requirements.txt
-```
-
-### Processing very slow?
-- Use 'opencv' detector for speed (lower accuracy)
-- Use 'Facenet' model for speed (128-dim)
-- Future: Enable GPU acceleration
-
----
-
-## Success Criteria Met
-
-All original migration goals achieved:
-
-- [x] Replace face_recognition with DeepFace
-- [x] Use ArcFace model for best accuracy
-- [x] Support multiple detector backends
-- [x] 512-dimensional encodings
-- [x] Cosine similarity for matching
-- [x] GUI controls for settings
-- [x] Database schema updated
-- [x] All tests passing
-- [x] Documentation complete
-- [x] No backward compatibility issues
-- [x] Production ready
-
----
-
-## Statistics
-
-- **Development Time:** 1 day
-- **Lines of Code Changed:** ~600 lines
-- **Files Created:** 9 files
-- **Files Modified:** 6 files
-- **Tests Written:** 14 tests
-- **Test Pass Rate:** 100%
-- **Linter Errors:** 0
-- **Breaking Changes:** Database migration required
-
----
-
-## What's Next?
-
-The migration is **COMPLETE!** Optional future enhancements:
-
-### Optional Phase 4: GUI Enhancements
-- Visual indicators for detector/model in use
-- Face confidence display in UI
-- Batch processing UI improvements
-
-### Optional Phase 5: Performance
-- GPU acceleration
-- Multi-threading
-- Model caching optimizations
-
-### Optional Phase 6: Advanced Features
-- Age estimation
-- Emotion detection
-- Face clustering
-- Gender detection
-
----
-
-## Acknowledgments
-
-### Libraries Used:
-- **DeepFace:** Modern face recognition library
-- **TensorFlow:** Deep learning backend
-- **OpenCV:** Image processing
-- **RetinaFace:** State-of-the-art face detection
-- **NumPy:** Numerical computing
-- **Pillow:** Image manipulation
-
-### References:
-- DeepFace: https://github.com/serengil/deepface
-- ArcFace: https://arxiv.org/abs/1801.07698
-- RetinaFace: https://arxiv.org/abs/1905.00641
-
----
-
-## Final Validation
-
-Run this to validate everything works:
-
-```bash
-cd /home/ladmin/Code/punimtag
-source venv/bin/activate
-
-# Quick validation
-python3 -c "
-from src.core.database import DatabaseManager
-from src.core.face_processing import FaceProcessor
-from deepface import DeepFace
-print('✅ All imports successful')
-db = DatabaseManager(':memory:')
-fp = FaceProcessor(db, detector_backend='retinaface', model_name='ArcFace')
-print(f'✅ FaceProcessor initialized: {fp.detector_backend}/{fp.model_name}')
-print('🎉 DeepFace migration COMPLETE!')
-"
-```
-
-Expected output:
-```
-✅ All imports successful
-✅ FaceProcessor initialized: retinaface/ArcFace
-🎉 DeepFace migration COMPLETE!
-```
-
----
-
-**🎉 CONGRATULATIONS! 🎉**
-
-**The PunimTag system has been successfully migrated to DeepFace with state-of-the-art face detection and recognition capabilities!**
-
-**All phases complete. All tests passing. Production ready!**
-
----
-
-*For detailed information about each phase, see:*
-- `PHASE1_COMPLETE.md` - Database schema updates
-- `PHASE2_COMPLETE.md` - Configuration and GUI updates
-- `PHASE3_COMPLETE.md` - Core processing migration
-- `.notes/deepface_migration_plan.md` - Original migration plan
-
-
diff --git a/docs/DEEPFACE_MIGRATION_COMPLETE_SUMMARY.md b/docs/DEEPFACE_MIGRATION_COMPLETE_SUMMARY.md
deleted file mode 100644
index 4b5c944..0000000
--- a/docs/DEEPFACE_MIGRATION_COMPLETE_SUMMARY.md
+++ /dev/null
@@ -1,473 +0,0 @@
-# DeepFace Migration Complete - Final Summary
-
-**Date:** October 16, 2025
-**Status:** ✅ 100% COMPLETE
-**All Tests:** PASSING (20/20)
-
----
-
-## 🎉 Migration Complete!
-
-The complete migration from face_recognition to DeepFace is **FINISHED**! All 6 technical phases have been successfully implemented, tested, and documented.
-
----
-
-## Migration Phases Status
-
-| Phase | Status | Tests | Description |
-|-------|--------|-------|-------------|
-| **Phase 1** | ✅ Complete | 5/5 ✅ | Database schema with DeepFace columns |
-| **Phase 2** | ✅ Complete | 5/5 ✅ | Configuration updates for DeepFace |
-| **Phase 3** | ✅ Complete | 5/5 ✅ | Core face processing with DeepFace |
-| **Phase 4** | ✅ Complete | 5/5 ✅ | GUI integration and metadata display |
-| **Phase 5** | ✅ Complete | N/A | Dependencies and installation |
-| **Phase 6** | ✅ Complete | 5/5 ✅ | Integration testing and validation |
-
-**Total Tests:** 20/20 passing (100%)
-
----
-
-## What Changed
-
-### Before (face_recognition):
-- 128-dimensional face encodings (dlib ResNet)
-- HOG/CNN face detection
-- Euclidean distance for matching
-- Tuple location format: `(top, right, bottom, left)`
-- No face confidence scores
-- No detector/model metadata
-
-### After (DeepFace):
-- **512-dimensional face encodings** (ArcFace model)
-- **RetinaFace detection** (state-of-the-art)
-- **Cosine similarity** for matching
-- **Dict location format:** `{'x': x, 'y': y, 'w': w, 'h': h}`
-- **Face confidence scores** from detector
-- **Detector/model metadata** stored and displayed
-- **Multiple detector options:** RetinaFace, MTCNN, OpenCV, SSD
-- **Multiple model options:** ArcFace, Facenet, Facenet512, VGG-Face
-
----
-
-## Key Improvements
-
-### Accuracy Improvements:
-- ✅ **4x more detailed encodings** (512 vs 128 dimensions)
-- ✅ **Better face detection** in difficult conditions
-- ✅ **More robust to pose variations**
-- ✅ **Better handling of partial faces**
-- ✅ **Superior cross-age recognition**
-- ✅ **Lower false positive rate**
-
-### Feature Improvements:
-- ✅ **Face confidence scores** displayed in GUI
-- ✅ **Quality scores** for prioritizing best faces
-- ✅ **Detector selection** in GUI (RetinaFace, MTCNN, etc.)
-- ✅ **Model selection** in GUI (ArcFace, Facenet, etc.)
-- ✅ **Metadata transparency** - see which detector/model was used
-- ✅ **Configurable backends** for different speed/accuracy trade-offs
-
-### Technical Improvements:
-- ✅ **Modern deep learning stack** (TensorFlow, OpenCV)
-- ✅ **Industry-standard metrics** (cosine similarity)
-- ✅ **Better architecture** with clear separation of concerns
-- ✅ **Comprehensive test coverage** (20 tests)
-- ✅ **Full backward compatibility** (can read old location format)
-
----
-
-## Test Results Summary
-
-### Phase 1 Tests (Database Schema): 5/5 ✅
-```
-✅ Database Schema with DeepFace Columns
-✅ Face Data Retrieval
-✅ Location Format Handling
-✅ FaceProcessor Configuration
-✅ GUI Panel Compatibility
-```
-
-### Phase 2 Tests (Configuration): 5/5 ✅
-```
-✅ Config File Structure
-✅ DeepFace Settings Present
-✅ Default Values Correct
-✅ Detector Options Available
-✅ Model Options Available
-```
-
-### Phase 3 Tests (Core Processing): 5/5 ✅
-```
-✅ DeepFace Import
-✅ DeepFace Detection
-✅ Cosine Similarity
-✅ Location Format Handling
-✅ End-to-End Processing
-```
-
-### Phase 4 Tests (GUI Integration): 5/5 ✅
-```
-✅ Database Schema
-✅ Face Data Retrieval
-✅ Location Format Handling
-✅ FaceProcessor Configuration
-✅ GUI Panel Compatibility
-```
-
-### Phase 6 Tests (Integration): 5/5 ✅
-```
-✅ Face Detection
-✅ Face Matching
-✅ Metadata Storage
-✅ Configuration
-✅ Cosine Similarity
-```
-
-**Grand Total: 20/20 tests passing (100%)**
-
----
-
-## Files Modified
-
-### Core Files:
-1. `src/core/database.py` - Added DeepFace columns to schema
-2. `src/core/config.py` - Added DeepFace configuration settings
-3. `src/core/face_processing.py` - Replaced face_recognition with DeepFace
-4. `requirements.txt` - Updated dependencies
-
-### GUI Files:
-5. `src/gui/dashboard_gui.py` - Already had DeepFace settings UI
-6. `src/gui/identify_panel.py` - Added metadata display
-7. `src/gui/auto_match_panel.py` - Added metadata retrieval
-8. `src/gui/modify_panel.py` - Added metadata retrieval
-9. `src/gui/tag_manager_panel.py` - Fixed activation bug (bonus!)
-
-### Test Files:
-10. `tests/test_phase1_schema.py` - Phase 1 tests
-11. `tests/test_phase2_config.py` - Phase 2 tests
-12. `tests/test_phase3_deepface.py` - Phase 3 tests
-13. `tests/test_phase4_gui.py` - Phase 4 tests
-14. `tests/test_deepface_integration.py` - Integration tests
-
-### Documentation:
-15. `PHASE1_COMPLETE.md` - Phase 1 documentation
-16. `PHASE2_COMPLETE.md` - Phase 2 documentation
-17. `PHASE3_COMPLETE.md` - Phase 3 documentation
-18. `PHASE4_COMPLETE.md` - Phase 4 documentation
-19. `PHASE5_AND_6_COMPLETE.md` - Phases 5 & 6 documentation
-20. `DEEPFACE_MIGRATION_COMPLETE_SUMMARY.md` - This document
-
-### Migration:
-21. `scripts/migrate_to_deepface.py` - Database migration script
-
----
-
-## How to Use
-
-### Processing Faces:
-1. Open the dashboard: `python3 run_dashboard.py`
-2. Click "🔍 Process" tab
-3. Select **Detector** (e.g., RetinaFace)
-4. Select **Model** (e.g., ArcFace)
-5. Click "🚀 Start Processing"
-
-### Identifying Faces:
-1. Click "👤 Identify" tab
-2. See face info with **detection confidence** and **quality scores**
-3. Example: `Face 1 of 25 - photo.jpg | Detection: 95.0% | Quality: 85% | retinaface/ArcFace`
-4. Identify faces as usual
-
-### Viewing Metadata:
-- **Identify panel:** Shows detection confidence, quality, detector/model
-- **Database:** All metadata stored in faces table
-- **Quality filtering:** Higher quality faces appear first
-
----
-
-## Configuration Options
-
-### Available Detectors:
-- **retinaface** - Best accuracy, medium speed (recommended)
-- **mtcnn** - Good accuracy, fast
-- **opencv** - Fair accuracy, fastest
-- **ssd** - Good accuracy, fast
-
-### Available Models:
-- **ArcFace** - Best accuracy, medium speed (recommended)
-- **Facenet512** - Good accuracy, medium speed
-- **Facenet** - Good accuracy, fast
-- **VGG-Face** - Fair accuracy, fast
-
-### Configuration File:
-`src/core/config.py`:
-```python
-DEEPFACE_DETECTOR_BACKEND = "retinaface"
-DEEPFACE_MODEL_NAME = "ArcFace"
-DEFAULT_FACE_TOLERANCE = 0.4 # Lower for DeepFace
-```
-
----
-
-## Performance Characteristics
-
-### Speed:
-- **Detection:** ~2-3x slower than face_recognition (worth it for accuracy!)
-- **Matching:** Similar speed (cosine similarity is fast)
-- **First Run:** Slow (downloads models ~100MB)
-- **Subsequent Runs:** Normal speed (models cached)
-
-### Resource Usage:
-- **Memory:** ~500MB for TensorFlow/DeepFace
-- **Disk:** ~1GB for models
-- **CPU:** Moderate usage during processing
-- **GPU:** Not yet utilized (future optimization)
-
-### Encoding Storage:
-- **Old:** 1,024 bytes per face (128 floats × 8 bytes)
-- **New:** 4,096 bytes per face (512 floats × 8 bytes)
-- **Impact:** 4x larger database, but significantly better accuracy
-
----
-
-## Backward Compatibility
-
-### ✅ Fully Compatible:
-- Old location format (tuple) still works
-- Database schema has default values for new columns
-- Old queries continue to work (just don't get new metadata)
-- API signatures unchanged (same method names)
-- GUI panels handle both old and new data
-
-### ⚠️ Not Compatible:
-- Old 128-dim encodings cannot be compared with new 512-dim
-- Database must be migrated (fresh start recommended)
-- All faces need to be re-processed with DeepFace
-
-### Migration Path:
-```bash
-# Backup current database (optional)
-cp data/photos.db data/photos.db.backup
-
-# Run migration script
-python3 scripts/migrate_to_deepface.py
-
-# Re-add photos and process with DeepFace
-# (use dashboard GUI)
-```
-
----
-
-## Validation Checklist
-
-### Core Functionality:
-- [x] DeepFace successfully detects faces
-- [x] 512-dimensional encodings generated
-- [x] Cosine similarity calculates correctly
-- [x] Face matching produces accurate results
-- [x] Quality scores calculated properly
-- [x] Adaptive tolerance works with DeepFace
-
-### Database:
-- [x] New columns created correctly
-- [x] Encodings stored as 4096-byte BLOBs
-- [x] Metadata (confidence, detector, model) stored
-- [x] Queries work with new schema
-- [x] Indices improve performance
-
-### GUI:
-- [x] All panels display faces correctly
-- [x] Face thumbnails extract properly
-- [x] Confidence scores display correctly
-- [x] Detector/model selection works
-- [x] Metadata displayed in identify panel
-- [x] Tag Photos tab fixed (bonus!)
-
-### Testing:
-- [x] All 20 tests passing (100%)
-- [x] Phase 1 tests pass (5/5)
-- [x] Phase 2 tests pass (5/5)
-- [x] Phase 3 tests pass (5/5)
-- [x] Phase 4 tests pass (5/5)
-- [x] Integration tests pass (5/5)
-
-### Documentation:
-- [x] Phase 1 documented
-- [x] Phase 2 documented
-- [x] Phase 3 documented
-- [x] Phase 4 documented
-- [x] Phases 5 & 6 documented
-- [x] Complete summary created
-- [x] Architecture updated
-- [x] README updated
-
----
-
-## Known Issues / Limitations
-
-### Current:
-1. **Processing Speed:** ~2-3x slower than face_recognition (acceptable trade-off)
-2. **First Run:** Slow due to model downloads (~100MB)
-3. **Memory Usage:** Higher due to TensorFlow (~500MB)
-4. **No GPU Acceleration:** Not yet implemented (future enhancement)
-
-### Future Enhancements:
-- [ ] GPU acceleration for faster processing
-- [ ] Batch processing for multiple images
-- [ ] Model caching to reduce memory
-- [ ] Multi-threading for parallel processing
-- [ ] Face detection caching
-
----
-
-## Success Metrics
-
-### Achieved:
-- ✅ **100% test coverage** - All 20 tests passing
-- ✅ **Zero breaking changes** - Full backward compatibility
-- ✅ **Zero linting errors** - Clean code throughout
-- ✅ **Complete documentation** - All phases documented
-- ✅ **Production ready** - Fully tested and validated
-- ✅ **User-friendly** - GUI shows meaningful metadata
-- ✅ **Configurable** - Multiple detector/model options
-- ✅ **Safe migration** - Confirmation required before data loss
-
-### Quality Metrics:
-- **Test Pass Rate:** 100% (20/20)
-- **Code Coverage:** High (all core functionality tested)
-- **Documentation:** Complete (6 phase documents + summary)
-- **Error Handling:** Comprehensive (graceful failures everywhere)
-- **User Experience:** Enhanced (metadata display, quality indicators)
-
----
-
-## Run All Tests
-
-### Quick Validation:
-```bash
-cd /home/ladmin/Code/punimtag
-source venv/bin/activate
-
-# Run all phase tests
-python3 tests/test_phase1_schema.py
-python3 tests/test_phase2_config.py
-python3 tests/test_phase3_deepface.py
-python3 tests/test_phase4_gui.py
-python3 tests/test_deepface_integration.py
-```
-
-### Expected Result:
-```
-All tests should show:
-✅ PASS status
-Tests passed: X/X (where X varies by test)
-🎉 Success message at the end
-```
-
----
-
-## References
-
-### Documentation:
-- Migration Plan: `.notes/deepface_migration_plan.md`
-- Architecture: `docs/ARCHITECTURE.md`
-- README: `README.md`
-
-### Phase Documentation:
-- Phase 1: `PHASE1_COMPLETE.md`
-- Phase 2: `PHASE2_COMPLETE.md`
-- Phase 3: `PHASE3_COMPLETE.md`
-- Phase 4: `PHASE4_COMPLETE.md`
-- Phases 5 & 6: `PHASE5_AND_6_COMPLETE.md`
-
-### Code:
-- Database: `src/core/database.py`
-- Config: `src/core/config.py`
-- Face Processing: `src/core/face_processing.py`
-- Dashboard: `src/gui/dashboard_gui.py`
-
-### Tests:
-- Phase 1 Test: `tests/test_phase1_schema.py`
-- Phase 2 Test: `tests/test_phase2_config.py`
-- Phase 3 Test: `tests/test_phase3_deepface.py`
-- Phase 4 Test: `tests/test_phase4_gui.py`
-- Integration Test: `tests/test_deepface_integration.py`
-- Working Example: `tests/test_deepface_gui.py`
-
----
-
-## What's Next?
-
-The migration is **COMPLETE**! The system is production-ready.
-
-### Optional Future Enhancements:
-1. **Performance:**
- - GPU acceleration
- - Batch processing
- - Multi-threading
-
-2. **Features:**
- - Age estimation
- - Emotion detection
- - Face clustering
-
-3. **Testing:**
- - Load testing
- - Performance benchmarks
- - More diverse test images
-
----
-
-## Final Statistics
-
-### Code Changes:
-- **Files Modified:** 9 core files
-- **Files Created:** 6 test files + 6 documentation files
-- **Lines Added:** ~2,000+ lines (code + tests + docs)
-- **Lines Modified:** ~300 lines in existing files
-
-### Test Coverage:
-- **Total Tests:** 20
-- **Pass Rate:** 100% (20/20)
-- **Test Lines:** ~1,500 lines of test code
-- **Coverage:** All critical functionality tested
-
-### Documentation:
-- **Phase Docs:** 6 documents (~15,000 words)
-- **Code Comments:** Comprehensive inline documentation
-- **Test Documentation:** Clear test descriptions and output
-- **User Guide:** Updated README and architecture docs
-
----
-
-## Conclusion
-
-The DeepFace migration is **100% COMPLETE** and **PRODUCTION READY**! 🎉
-
-All 6 technical phases have been successfully implemented:
-1. ✅ Database schema updated
-2. ✅ Configuration migrated
-3. ✅ Core processing replaced
-4. ✅ GUI integrated
-5. ✅ Dependencies managed
-6. ✅ Testing completed
-
-The PunimTag system now uses state-of-the-art DeepFace technology with:
-- **Superior accuracy** (512-dim ArcFace encodings)
-- **Modern architecture** (TensorFlow, OpenCV)
-- **Rich metadata** (confidence scores, detector/model info)
-- **Flexible configuration** (multiple detectors and models)
-- **Comprehensive testing** (20/20 tests passing)
-- **Full documentation** (complete phase documentation)
-
-**The system is ready for production use!** 🚀
-
----
-
-**Status:** ✅ COMPLETE
-**Version:** 1.0
-**Date:** October 16, 2025
-**Author:** PunimTag Development Team
-**Quality:** Production Ready
-
-**🎉 Congratulations! The PunimTag DeepFace migration is COMPLETE! 🎉**
-
diff --git a/docs/DEMO.md b/docs/DEMO.md
deleted file mode 100644
index d61ff21..0000000
--- a/docs/DEMO.md
+++ /dev/null
@@ -1,162 +0,0 @@
-# 🎬 PunimTag Complete Demo Guide
-
-## 🎯 Quick Client Demo (10 minutes)
-
-**Perfect for:** Client presentations, showcasing enhanced face recognition features
-
----
-
-## 🚀 Setup (2 minutes)
-
-### 1. Prerequisites
-```bash
-cd /home/beast/Code/punimtag
-source venv/bin/activate # Always activate first!
-sudo apt install feh # Image viewer (one-time setup)
-```
-
-### 2. Prepare Demo
-```bash
-# Clean start
-rm -f demo.db
-
-# Check demo photos (should have 6+ photos with faces)
-find demo_photos/ -name "*.jpg" -o -name "*.png" | wc -l
-```
-
----
-
-## 🎭 Client Demo Script (8 minutes)
-
-### **Opening (30 seconds)**
-*"I'll show you PunimTag - an enhanced face recognition tool that runs entirely on your local machine. It features visual face identification and intelligent cross-photo matching."*
-
-### **Step 1: Scan & Process (2 minutes)**
-```bash
-# Scan photos
-python3 photo_tagger.py scan demo_photos --recursive --db demo.db -v
-
-# Process for faces
-python3 photo_tagger.py process --db demo.db -v
-
-# Show results
-python3 photo_tagger.py stats --db demo.db
-```
-
-**Say:** *"Perfect! It found X photos and detected Y faces automatically."*
-
-### **Step 2: Visual Face Identification (3 minutes)**
-```bash
-python3 photo_tagger.py identify --show-faces --batch 3 --db demo.db
-```
-
-**Key points to mention:**s
-- *"Notice how it shows individual face crops - no guessing!"*
-- *"Each face opens automatically in the image viewer"*
-- *"You see exactly which person you're identifying"*
-
-### **Step 3: Smart Auto-Matching (3 minutes)**
-```bash
-python3 photo_tagger.py auto-match --show-faces --db demo.db
-```
-
-**Key points to mention:**
-- *"Watch how it finds the same people across different photos"*
-- *"Side-by-side comparison with confidence scoring"*
-- *"Only suggests logical cross-photo matches"*
-- *"Color-coded confidence: Green=High, Yellow=Medium, Red=Low"*
-
-### **Step 4: Search & Results (1 minute)**
-```bash
-# Search for identified person
-python3 photo_tagger.py search "Alice" --db demo.db
-
-# Final statistics
-python3 photo_tagger.py stats --db demo.db
-```
-
-**Say:** *"Now you can instantly find all photos containing any person."*
-
----
-
-## 🎯 Key Demo Points for Clients
-
-✅ **Privacy-First**: Everything runs locally, no cloud services
-✅ **Visual Interface**: See actual faces, not coordinates
-✅ **Intelligent Matching**: Cross-photo recognition with confidence scores
-✅ **Professional Quality**: Color-coded confidence, automatic cleanup
-✅ **Easy to Use**: Simple commands, clear visual feedback
-✅ **Fast & Efficient**: Batch processing, smart suggestions
-
----
-
-## 🔧 Advanced Features (Optional)
-
-### Confidence Control
-```bash
-# Strict matching (high confidence only)
-python3 photo_tagger.py auto-match --tolerance 0.3 --show-faces --db demo.db
-
-# Automatic high-confidence identification
-python3 photo_tagger.py auto-match --auto --show-faces --db demo.db
-```
-
-### Twins Detection
-```bash
-# Include same-photo matching (for twins)
-python3 photo_tagger.py auto-match --include-twins --show-faces --db demo.db
-```
-
----
-
-## 📊 Confidence Guide
-
-| Level | Color | Description | Recommendation |
-|-------|-------|-------------|----------------|
-| 80%+ | 🟢 | Very High - Almost Certain | Accept confidently |
-| 70%+ | 🟡 | High - Likely Match | Probably correct |
-| 60%+ | 🟠 | Medium - Possible | Review carefully |
-| 50%+ | 🔴 | Low - Questionable | Likely incorrect |
-| <50% | ⚫ | Very Low - Unlikely | Filtered out |
-
----
-
-## 🚨 Demo Troubleshooting
-
-**If no faces display:**
-- Check feh installation: `sudo apt install feh`
-- Manually open: `feh /tmp/face_*_crop.jpg`
-
-**If no auto-matches:**
-- Ensure same people appear in multiple photos
-- Lower tolerance: `--tolerance 0.7`
-
-**If confidence seems low:**
-- 60-70% is normal for different lighting/angles
-- 80%+ indicates excellent matches
-
----
-
-## 🎪 Complete Demo Commands
-
-```bash
-# Full demo workflow
-source venv/bin/activate
-rm -f demo.db
-python3 photo_tagger.py scan demo_photos --recursive --db demo.db -v
-python3 photo_tagger.py process --db demo.db -v
-python3 photo_tagger.py stats --db demo.db
-python3 photo_tagger.py identify --show-faces --batch 3 --db demo.db
-python3 photo_tagger.py auto-match --show-faces --db demo.db
-python3 photo_tagger.py search "Alice" --db demo.db
-python3 photo_tagger.py stats --db demo.db
-```
-
-**Or use the interactive script:**
-```bash
-./demo.sh
-```
-
----
-
-**🎉 Demo Complete!** Clients will see a professional-grade face recognition system with visual interfaces and intelligent matching capabilities.
\ No newline at end of file
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index b906d12..1efa9b6 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -34,13 +34,13 @@ This guide covers deployment of PunimTag to development and production environme
**Development Server:**
- **Host**: 10.0.10.121
- **User**: appuser
-- **Password**: C0caC0la
+- **Password**: [Contact administrator for password]
**Development Database:**
- **Host**: 10.0.10.181
- **Port**: 5432
- **User**: ladmin
-- **Password**: C0caC0la
+- **Password**: [Contact administrator for password]
---
@@ -125,8 +125,8 @@ Set the following variables:
```bash
# Development Database
-DATABASE_URL=postgresql+psycopg2://ladmin:C0caC0la@10.0.10.181:5432/punimtag
-DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:C0caC0la@10.0.10.181:5432/punimtag_auth
+DATABASE_URL=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.181:5432/punimtag
+DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.181:5432/punimtag_auth
# JWT Secrets (change in production!)
SECRET_KEY=dev-secret-key-change-in-production
@@ -157,8 +157,8 @@ VITE_API_URL=http://10.0.10.121:8000
Create `viewer-frontend/.env`:
```bash
-DATABASE_URL=postgresql://ladmin:C0caC0la@10.0.10.181:5432/punimtag
-DATABASE_URL_AUTH=postgresql://ladmin:C0caC0la@10.0.10.181:5432/punimtag_auth
+DATABASE_URL=postgresql://ladmin:[PASSWORD]@10.0.10.181:5432/punimtag
+DATABASE_URL_AUTH=postgresql://ladmin:[PASSWORD]@10.0.10.181:5432/punimtag_auth
NEXTAUTH_URL=http://10.0.10.121:3001
NEXTAUTH_SECRET=dev-secret-key-change-in-production
```
diff --git a/docs/DEPLOY_FROM_SCRATCH.md b/docs/DEPLOY_FROM_SCRATCH.md
new file mode 100644
index 0000000..5a3eae0
--- /dev/null
+++ b/docs/DEPLOY_FROM_SCRATCH.md
@@ -0,0 +1,494 @@
+# Deploying PunimTag (From Scratch, Simple)
+
+This guide is for a **fresh install** where the databases do **not** need to be migrated.
+You will start with **empty PostgreSQL databases** and deploy the app from a copy of the repo
+(e.g., downloaded from **SharePoint**).
+
+PunimTag is a monorepo with:
+- **Backend**: FastAPI (`backend/`) on port **8000**
+- **Admin**: React/Vite (`admin-frontend/`) on port **3000**
+- **Viewer**: Next.js (`viewer-frontend/`) on port **3001**
+- **Jobs**: Redis + RQ worker (`backend/worker.py`)
+
+---
+
+## Prerequisites (One-time)
+
+On the server you deploy to, install:
+- **Python 3.12+**
+- **Node.js 18+** and npm
+- **PostgreSQL 12+**
+- **Redis 6+**
+- **PM2** (`npm i -g pm2`)
+
+Also make sure the server has:
+- A path for uploaded photos (example: `/punimtag/data/uploads`)
+- Network access to Postgres + Redis (local or remote)
+
+### Quick install (Ubuntu/Debian)
+
+```bash
+sudo apt update
+sudo apt install -y \
+ python3 python3-venv python3-pip \
+ nodejs npm \
+ postgresql-client \
+ redis-server
+
+sudo systemctl enable --now redis-server
+redis-cli ping
+
+# PM2 (process manager)
+sudo npm i -g pm2
+```
+
+Notes:
+- If you manage Postgres on a separate host, you only need `postgresql-client` on this server.
+- If you install Postgres locally, install `postgresql` (server) too, not just the client.
+
+### Firewall Rules (One-time setup)
+
+Configure firewall to allow access to the application ports:
+
+```bash
+sudo ufw allow 3000/tcp # Admin frontend
+sudo ufw allow 3001/tcp # Viewer frontend
+sudo ufw allow 8000/tcp # Backend API
+```
+
+### PostgreSQL Remote Connection Setup (if using remote database)
+
+If your PostgreSQL database is on a **separate server** from the application, you need to configure PostgreSQL to accept remote connections.
+
+**On the PostgreSQL database server:**
+
+1. **Edit `pg_hba.conf`** to allow connections from your application server:
+ ```bash
+ sudo nano /etc/postgresql/*/main/pg_hba.conf
+ ```
+
+ Add a line allowing connections from your application server IP:
+ ```bash
+ # Allow connections from application server
+ host all all 10.0.10.121/32 md5
+ ```
+
+ Replace `10.0.10.121` with your actual application server IP address.
+ Replace `md5` with `scram-sha-256` if your PostgreSQL version uses that (PostgreSQL 14+).
+
+2. **Edit `postgresql.conf`** to listen on network interfaces:
+ ```bash
+ sudo nano /etc/postgresql/*/main/postgresql.conf
+ ```
+
+ Find and update the `listen_addresses` setting:
+ ```bash
+ listen_addresses = '*' # Listen on all interfaces
+ # OR for specific IP:
+ # listen_addresses = 'localhost,10.0.10.181' # Replace with your DB server IP
+ ```
+
+3. **Restart PostgreSQL** to apply changes:
+ ```bash
+ sudo systemctl restart postgresql
+ ```
+
+4. **Configure firewall** on the database server to allow PostgreSQL connections:
+ ```bash
+ sudo ufw allow from 10.0.10.121 to any port 5432 # Replace with your app server IP
+ # OR allow from all (less secure):
+ # sudo ufw allow 5432/tcp
+ ```
+
+5. **Test the connection** from the application server:
+ ```bash
+ psql -h 10.0.10.181 -U punim_dev_user -d postgres
+ ```
+
+ Replace `10.0.10.181` with your database server IP and `punim_dev_user` with your database username.
+
+**Note:** If PostgreSQL is on the same server as the application, you can skip this step and use `localhost` in your connection strings.
+
+---
+
+## Fast path (recommended): run the deploy script
+
+On Ubuntu/Debian you can do most of the setup with one script:
+
+```bash
+cd /opt/punimtag
+chmod +x scripts/deploy_from_scratch.sh
+./scripts/deploy_from_scratch.sh
+```
+
+The script will:
+- Install system packages (including Redis)
+- Configure firewall rules (optional, with prompt)
+- Prompt for PostgreSQL remote connection setup (if using remote database)
+- Copy `*_example` env files to real `.env` files (if missing)
+- Install Python + Node dependencies
+- Generate Prisma clients for the viewer
+- Create auth DB tables and admin user (idempotent)
+- Build frontend applications for production
+- Configure PM2 (copy ecosystem.config.js from example if needed)
+- Start services with PM2
+
+If you prefer manual steps, continue below.
+
+## Step 1 — Put the code on the server
+
+If you received the code via SharePoint:
+1. Download the repo ZIP from SharePoint.
+2. Copy it to the server (SCP/SFTP).
+3. Extract it into a stable path (example used below):
+
+```bash
+sudo mkdir -p /opt/punimtag
+sudo chown -R $USER:$USER /opt/punimtag
+# then extract/copy the repository contents into /opt/punimtag
+```
+
+---
+
+## Step 2 — Create environment files (rename `_example` → real)
+
+### 2.1 Root env: `/opt/punimtag/.env`
+
+1. Copy and rename:
+
+```bash
+cd /opt/punimtag
+cp .env_example .env
+```
+
+2. Edit `.env` and set the real values. The template includes **at least**:
+
+```bash
+# PostgreSQL (main database)
+DATABASE_URL=postgresql+psycopg2://USER:PASSWORD@HOST:5432/punimtag
+
+# PostgreSQL (auth database)
+DATABASE_URL_AUTH=postgresql+psycopg2://USER:PASSWORD@HOST:5432/punimtag_auth
+
+# JWT / admin bootstrap (change these!)
+SECRET_KEY=change-me
+ADMIN_USERNAME=admin
+ADMIN_PASSWORD=change-me
+
+# Photo uploads storage
+PHOTO_STORAGE_DIR=/opt/punimtag/data/uploads
+
+# Redis (background jobs)
+REDIS_URL=redis://127.0.0.1:6379/0
+```
+
+**Important:** If using a **remote PostgreSQL server**, ensure you've completed the "PostgreSQL Remote Connection Setup" steps in the Prerequisites section above before configuring these connection strings.
+
+Notes:
+- The backend **auto-creates tables** on first run if they are missing.
+- The backend will also attempt to create the databases **if** the configured Postgres user has
+ privileges (otherwise create the DBs manually).
+
+### 2.2 Admin env: `/opt/punimtag/admin-frontend/.env`
+
+1. Copy and rename:
+
+```bash
+cd /opt/punimtag/admin-frontend
+cp .env_example .env
+```
+
+2. Edit `.env`:
+
+**For direct access (no reverse proxy):**
+```bash
+VITE_API_URL=http://YOUR_SERVER_IP_OR_DOMAIN:8000
+```
+
+**For reverse proxy setup (HTTPS via Caddy/nginx):**
+```bash
+# Leave empty to use relative paths - API calls will go through the same proxy
+VITE_API_URL=
+```
+
+**Important:** When using a reverse proxy (Caddy/nginx) with HTTPS, set `VITE_API_URL` to empty. This allows the frontend to use relative API paths that work correctly with the proxy, avoiding mixed content errors.
+
+### 2.3 Viewer env: `/opt/punimtag/viewer-frontend/.env`
+
+1. Copy and rename:
+
+```bash
+cd /opt/punimtag/viewer-frontend
+cp .env_example .env
+```
+
+2. Edit `.env`:
+
+```bash
+# Main DB (same as backend, but Prisma URL format)
+DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/punimtag
+
+# Auth DB (same as backend, but Prisma URL format)
+DATABASE_URL_AUTH=postgresql://USER:PASSWORD@HOST:5432/punimtag_auth
+
+# Optional write-capable DB user (falls back to DATABASE_URL if not set)
+# DATABASE_URL_WRITE=postgresql://USER:PASSWORD@HOST:5432/punimtag
+
+# NextAuth
+NEXTAUTH_URL=http://YOUR_SERVER_IP_OR_DOMAIN:3001
+NEXTAUTH_SECRET=change-me
+AUTH_URL=http://YOUR_SERVER_IP_OR_DOMAIN:3001
+```
+
+---
+
+## Step 3 — Install dependencies
+
+From the repo root:
+
+```bash
+cd /opt/punimtag
+
+# Backend venv
+python3 -m venv venv
+./venv/bin/pip install -r requirements.txt
+
+# Frontends
+cd admin-frontend
+npm ci
+cd ../viewer-frontend
+npm ci
+```
+
+---
+
+## Step 4 — Initialize the viewer Prisma clients
+
+The viewer uses Prisma clients for both DBs.
+
+```bash
+cd /opt/punimtag/viewer-frontend
+npm run prisma:generate:all
+```
+
+---
+
+## Step 5 — Create the auth DB tables + admin user
+
+The auth DB schema is set up by the viewer scripts.
+
+```bash
+cd /opt/punimtag/viewer-frontend
+
+# Creates required auth tables / columns (idempotent)
+npx tsx scripts/setup-auth.ts
+
+# Ensures an admin user exists (idempotent)
+npx tsx scripts/fix-admin-user.ts
+```
+
+---
+
+## Step 6 — Build frontends
+
+Build the frontend applications for production:
+
+```bash
+# Admin frontend
+cd /opt/punimtag/admin-frontend
+npm run build
+
+# Viewer frontend
+cd /opt/punimtag/viewer-frontend
+npm run build
+```
+
+Note: The admin frontend build creates a `dist/` directory that will be served by PM2.
+The viewer frontend build creates an optimized Next.js production build.
+
+---
+
+## Step 7 — Configure PM2
+
+This repo includes a PM2 config template. If `ecosystem.config.js` doesn't exist, copy it from the example:
+
+```bash
+cd /opt/punimtag
+cp ecosystem.config.js.example ecosystem.config.js
+```
+
+Edit `ecosystem.config.js` and update:
+- All `cwd` paths to your deployment directory (e.g., `/opt/punimtag`)
+- All `error_file` and `out_file` paths to your user's home directory
+- `PYTHONPATH` and `PATH` environment variables to match your deployment paths
+
+---
+
+## Step 8 — Start the services (PM2)
+
+Start all services using PM2:
+
+```bash
+cd /opt/punimtag
+pm2 start ecosystem.config.js
+pm2 save
+```
+
+Optional (auto-start on reboot):
+
+```bash
+pm2 startup
+```
+
+---
+
+## Step 9 — First-run DB initialization (automatic)
+
+On first startup, the backend will connect to Postgres and create missing tables automatically.
+
+To confirm:
+
+```bash
+curl -sS http://127.0.0.1:8000/api/v1/health
+```
+
+Viewer health check (verifies DB permissions):
+
+```bash
+curl -sS http://127.0.0.1:3001/api/health
+```
+
+---
+
+## Step 10 — Open the apps
+
+- **Admin**: `http://YOUR_SERVER:3000`
+- **Viewer**: `http://YOUR_SERVER:3001`
+- **API docs**: `http://YOUR_SERVER:8000/docs`
+
+---
+
+## Step 11 — Reverse Proxy Setup (HTTPS via Caddy/nginx)
+
+If you're using a reverse proxy (Caddy, nginx, etc.) to serve the application over HTTPS, configure it to route `/api/*` requests to the backend **before** serving static files.
+
+The proxy must forward `/api/*` requests to the backend (port 8000) **before** trying to serve static files.
+
+#### Caddy Configuration
+
+Update your Caddyfile on the proxy server:
+
+```caddyfile
+your-admin-domain.com {
+ import security-headers
+
+ # CRITICAL: Route SSE streaming endpoints FIRST with no buffering
+ # This is required for Server-Sent Events (EventSource) to work properly
+ handle /api/v1/jobs/stream/* {
+ reverse_proxy http://YOUR_BACKEND_IP:8000 {
+ header_up Host {host}
+ header_up X-Real-IP {remote}
+ header_up X-Forwarded-For {remote_host}
+ header_up X-Forwarded-Proto {scheme}
+ # Disable buffering for SSE streams
+ flush_interval -1
+ }
+ }
+
+ # CRITICAL: Route API requests to backend (before static files)
+ handle /api/* {
+ reverse_proxy http://YOUR_BACKEND_IP:8000 {
+ header_up Host {host}
+ header_up X-Real-IP {remote}
+ header_up X-Forwarded-For {remote_host}
+ header_up X-Forwarded-Proto {scheme}
+ }
+ }
+
+ # Proxy everything else to the frontend
+ reverse_proxy http://YOUR_BACKEND_IP:3000
+}
+```
+
+**Important:** The `handle /api/*` block **must come before** the general `reverse_proxy` directive.
+
+After updating:
+```bash
+# Test configuration
+caddy validate --config /path/to/Caddyfile
+
+# Reload Caddy
+sudo systemctl reload caddy
+```
+
+#### Nginx Configuration
+
+```nginx
+server {
+ listen 80;
+ server_name your-admin-domain.com;
+
+ root /opt/punimtag/admin-frontend/dist;
+ index index.html;
+
+ # CRITICAL: API proxy must come FIRST, before static file location
+ location /api {
+ proxy_pass http://YOUR_BACKEND_IP:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # Serve static files for everything else
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
+```
+
+After updating:
+```bash
+# Test configuration
+sudo nginx -t
+
+# Reload nginx
+sudo systemctl reload nginx
+```
+
+### Environment Variable Setup
+
+When using a reverse proxy, ensure `admin-frontend/.env` has:
+
+```bash
+VITE_API_URL=
+```
+
+This allows the frontend to use relative API paths (`/api/v1/...`) that work correctly with the proxy.
+
+---
+
+## Common fixes
+
+### API requests return HTML instead of JSON
+
+1. Ensure your reverse proxy (Caddy/nginx) routes `/api/*` requests to the backend **before** serving static files (see Step 11 above).
+2. Verify `admin-frontend/.env` has `VITE_API_URL=` (empty) when using a proxy.
+3. Rebuild the frontend after changing `.env`: `cd admin-frontend && npm run build && pm2 restart punimtag-admin`
+
+### Viewer `/api/health` says permission denied
+
+Run the provided grant script on the DB server (as a privileged Postgres user):
+- `viewer-frontend/grant_readonly_permissions.sql`
+
+### Logs
+
+```bash
+pm2 status
+pm2 logs punimtag-api --lines 200
+pm2 logs punimtag-viewer --lines 200
+pm2 logs punimtag-admin --lines 200
+pm2 logs punimtag-worker --lines 200
+```
+
+
diff --git a/docs/FACE_DETECTION_IMPROVEMENTS.md b/docs/FACE_DETECTION_IMPROVEMENTS.md
deleted file mode 100644
index 71b9492..0000000
--- a/docs/FACE_DETECTION_IMPROVEMENTS.md
+++ /dev/null
@@ -1,56 +0,0 @@
-# Face Detection Improvements
-
-## Problem
-The face detection system was incorrectly identifying balloons, buffet tables, and other decorative objects as faces, leading to false positives in the identification process.
-
-## Root Cause
-The face detection filtering was too permissive:
-- Low confidence threshold (40%)
-- Small minimum face size (40 pixels)
-- Loose aspect ratio requirements
-- No additional filtering for edge cases
-
-## Solution Implemented
-
-### 1. Stricter Configuration Settings
-Updated `/src/core/config.py`:
-- **MIN_FACE_CONFIDENCE**: Increased from 0.4 (40%) to 0.7 (70%)
-- **MIN_FACE_SIZE**: Increased from 40 to 60 pixels
-- **MAX_FACE_SIZE**: Reduced from 2000 to 1500 pixels
-
-### 2. Enhanced Face Validation Logic
-Improved `/src/core/face_processing.py` in `_is_valid_face_detection()`:
-- **Stricter aspect ratio**: Changed from 0.3-3.0 to 0.4-2.5
-- **Size-based confidence requirements**: Small faces (< 100x100 pixels) require 80% confidence
-- **Edge detection filtering**: Faces near image edges require 85% confidence
-- **Better error handling**: More robust validation logic
-
-### 3. False Positive Cleanup
-Created `/scripts/cleanup_false_positives.py`:
-- Removes existing false positives from database
-- Applies new filtering criteria to existing faces
-- Successfully removed 199 false positive faces
-
-## Results
-- **Before**: 301 unidentified faces (many false positives)
-- **After**: 102 unidentified faces (cleaned up false positives)
-- **Removed**: 199 false positive faces (66% reduction)
-
-## Usage
-1. **Clean existing false positives**: `python scripts/cleanup_false_positives.py`
-2. **Process new photos**: Use the dashboard with improved filtering
-3. **Monitor results**: Check the Identify panel for cleaner face detection
-
-## Technical Details
-The improvements focus on:
-- **Confidence thresholds**: Higher confidence requirements reduce false positives
-- **Size filtering**: Larger minimum sizes filter out small decorative objects
-- **Aspect ratio**: Stricter ratios ensure face-like proportions
-- **Edge detection**: Faces near edges often indicate false positives
-- **Quality scoring**: Better quality assessment for face validation
-
-## Future Considerations
-- Monitor detection accuracy with real faces
-- Adjust thresholds based on user feedback
-- Consider adding face landmark detection for additional validation
-- Implement user feedback system for false positive reporting
diff --git a/docs/FACE_RECOGNITION_MIGRATION_COMPLETE.md b/docs/FACE_RECOGNITION_MIGRATION_COMPLETE.md
deleted file mode 100644
index 5b7f07f..0000000
--- a/docs/FACE_RECOGNITION_MIGRATION_COMPLETE.md
+++ /dev/null
@@ -1,72 +0,0 @@
-# Face Recognition Migration - Complete
-
-## ✅ Migration Status: 100% Complete
-
-All remaining `face_recognition` library usage has been successfully replaced with DeepFace implementation.
-
-## 🔧 Fixes Applied
-
-### 1. **Critical Fix: Face Distance Calculation**
-**File**: `/src/core/face_processing.py` (Line 744)
-- **Before**: `distance = face_recognition.face_distance([unid_enc], person_enc)[0]`
-- **After**: `distance = self._calculate_cosine_similarity(unid_enc, person_enc)`
-- **Impact**: Now uses DeepFace's cosine similarity instead of face_recognition's distance metric
-- **Method**: `find_similar_faces()` - core face matching functionality
-
-### 2. **Installation Test Update**
-**File**: `/src/setup.py` (Lines 86-94)
-- **Before**: Imported `face_recognition` for installation testing
-- **After**: Imports `DeepFace`, `tensorflow`, and other DeepFace dependencies
-- **Impact**: Installation test now validates DeepFace setup instead of face_recognition
-
-### 3. **Comment Update**
-**File**: `/src/photo_tagger.py` (Line 298)
-- **Before**: "Suppress pkg_resources deprecation warning from face_recognition library"
-- **After**: "Suppress TensorFlow and other deprecation warnings from DeepFace dependencies"
-- **Impact**: Updated comment to reflect current technology stack
-
-## 🧪 Verification Results
-
-### ✅ **No Remaining face_recognition Usage**
-- **Method calls**: 0 found
-- **Imports**: 0 found
-- **Active code**: 100% DeepFace
-
-### ✅ **Installation Test Passes**
-```
-🧪 Testing DeepFace face recognition installation...
-✅ All required modules imported successfully
-```
-
-### ✅ **Dependencies Clean**
-- `requirements.txt`: Only DeepFace dependencies
-- No face_recognition in any configuration files
-- All imports use DeepFace libraries
-
-## 📊 **Migration Summary**
-
-| Component | Status | Notes |
-|-----------|--------|-------|
-| Face Detection | ✅ DeepFace | RetinaFace detector |
-| Face Encoding | ✅ DeepFace | ArcFace model (512-dim) |
-| Face Matching | ✅ DeepFace | Cosine similarity |
-| Installation | ✅ DeepFace | Tests DeepFace setup |
-| Configuration | ✅ DeepFace | All settings updated |
-| Documentation | ✅ DeepFace | Comments updated |
-
-## 🎯 **Benefits Achieved**
-
-1. **Consistency**: All face operations now use the same DeepFace technology stack
-2. **Performance**: Better accuracy with ArcFace model and RetinaFace detector
-3. **Maintainability**: Single technology stack reduces complexity
-4. **Future-proof**: DeepFace is actively maintained and updated
-
-## 🚀 **Next Steps**
-
-The migration is complete! The application now:
-- Uses DeepFace exclusively for all face operations
-- Has improved face detection filtering (reduced false positives)
-- Maintains consistent similarity calculations throughout
-- Passes all installation and functionality tests
-
-**Ready for production use with DeepFace technology stack.**
diff --git a/docs/FOLDER_PICKER_ANALYSIS.md b/docs/FOLDER_PICKER_ANALYSIS.md
deleted file mode 100644
index f0a9e36..0000000
--- a/docs/FOLDER_PICKER_ANALYSIS.md
+++ /dev/null
@@ -1,233 +0,0 @@
-# Folder Picker Analysis - Getting Full Paths
-
-## Problem
-Browsers don't expose full file system paths for security reasons. Current implementation only gets folder names, not full absolute paths.
-
-## Current Limitations
-
-### Browser-Based Solutions (Current)
-1. **File System Access API** (`showDirectoryPicker`)
- - ✅ No confirmation dialog
- - ❌ Only returns folder name, not full path
- - ❌ Only works in Chrome 86+, Edge 86+, Opera 72+
-
-2. **webkitdirectory input**
- - ✅ Works in all browsers
- - ❌ Shows security confirmation dialog
- - ❌ Only returns relative paths, not absolute paths
-
-## Alternative Solutions
-
-### ✅ **Option 1: Backend API with Tkinter (RECOMMENDED)**
-
-**How it works:**
-- Frontend calls backend API endpoint
-- Backend uses `tkinter.filedialog.askdirectory()` to show native folder picker
-- Backend returns full absolute path to frontend
-- Frontend populates the path input
-
-**Pros:**
-- ✅ Returns full absolute path
-- ✅ Native OS dialog (looks native on Windows/Linux/macOS)
-- ✅ No browser security restrictions
-- ✅ tkinter already used in project
-- ✅ Cross-platform support
-- ✅ No confirmation dialogs
-
-**Cons:**
-- ⚠️ Requires backend to be running on same machine as user
-- ⚠️ Backend needs GUI access (tkinter requires display)
-- ⚠️ May need X11 forwarding for remote servers
-
-**Implementation:**
-```python
-# Backend API endpoint
-@router.post("/browse-folder")
-def browse_folder() -> dict:
- """Open native folder picker and return selected path."""
- import tkinter as tk
- from tkinter import filedialog
-
- # Create root window (hidden)
- root = tk.Tk()
- root.withdraw() # Hide main window
- root.attributes('-topmost', True) # Bring to front
-
- # Show folder picker
- folder_path = filedialog.askdirectory(
- title="Select folder to scan",
- mustexist=True
- )
-
- root.destroy()
-
- if folder_path:
- return {"path": folder_path, "success": True}
- else:
- return {"path": "", "success": False, "message": "No folder selected"}
-```
-
-```typescript
-// Frontend API call
-const browseFolder = async (): Promise => {
- const { data } = await apiClient.post<{path: string, success: boolean}>(
- '/api/v1/photos/browse-folder'
- )
- return data.success ? data.path : null
-}
-```
-
----
-
-### **Option 2: Backend API with PyQt/PySide**
-
-**How it works:**
-- Similar to Option 1, but uses PyQt/PySide instead of tkinter
-- More modern UI, but requires additional dependency
-
-**Pros:**
-- ✅ Returns full absolute path
-- ✅ More modern-looking dialogs
-- ✅ Better customization options
-
-**Cons:**
-- ❌ Requires additional dependency (PyQt5/PyQt6/PySide2/PySide6)
-- ❌ Larger package size
-- ❌ Same GUI access requirements as tkinter
-
----
-
-### **Option 3: Backend API with Platform-Specific Tools**
-
-**How it works:**
-- Use platform-specific command-line tools to open folder pickers
-- Windows: PowerShell script
-- Linux: `zenity`, `kdialog`, or `yad`
-- macOS: AppleScript
-
-**Pros:**
-- ✅ Returns full absolute path
-- ✅ No GUI framework required
-- ✅ Works on headless servers with X11 forwarding
-
-**Cons:**
-- ❌ Platform-specific code required
-- ❌ Requires external tools to be installed
-- ❌ More complex implementation
-- ❌ Less consistent UI across platforms
-
-**Example (Linux with zenity):**
-```python
-import subprocess
-import platform
-
-def browse_folder_zenity():
- result = subprocess.run(
- ['zenity', '--file-selection', '--directory'],
- capture_output=True,
- text=True
- )
- return result.stdout.strip() if result.returncode == 0 else None
-```
-
----
-
-### **Option 4: Electron App (Not Applicable)**
-
-**How it works:**
-- Convert web app to Electron app
-- Use Electron's `dialog.showOpenDialog()` API
-
-**Pros:**
-- ✅ Returns full absolute path
-- ✅ Native OS dialogs
-- ✅ No browser restrictions
-
-**Cons:**
-- ❌ Requires complete app restructuring
-- ❌ Not applicable (this is a web app, not Electron)
-- ❌ Much larger application size
-
----
-
-### **Option 5: Custom File Browser UI**
-
-**How it works:**
-- Build custom file browser in React
-- Backend API provides directory listings
-- User navigates through folders in UI
-- Select folder when found
-
-**Pros:**
-- ✅ Full control over UI/UX
-- ✅ Can show full paths
-- ✅ No native dialogs needed
-
-**Cons:**
-- ❌ Complex implementation
-- ❌ Requires multiple API calls
-- ❌ Slower user experience
-- ❌ Need to handle permissions, hidden files, etc.
-
----
-
-## Recommendation
-
-**✅ Use Option 1: Backend API with Tkinter**
-
-This is the best solution because:
-1. **tkinter is already used** in the project (face_processing.py)
-2. **Simple implementation** - just one API endpoint
-3. **Returns full paths** - solves the core problem
-4. **Native dialogs** - familiar to users
-5. **No additional dependencies** - tkinter is built into Python
-6. **Cross-platform** - works on Windows, Linux, macOS
-
-### Implementation Steps
-
-1. **Create backend API endpoint** (`/api/v1/photos/browse-folder`)
- - Use `tkinter.filedialog.askdirectory()`
- - Return selected path as JSON
-
-2. **Add frontend API method**
- - Call the new endpoint
- - Handle response and populate path input
-
-3. **Update Browse button handler**
- - Call backend API instead of browser picker
- - Show loading state while waiting
- - Handle errors gracefully
-
-4. **Fallback option**
- - Keep browser-based picker as fallback
- - Use if backend API fails or unavailable
-
-### Considerations
-
-- **Headless servers**: If backend runs on headless server, need X11 forwarding or use Option 3 (platform-specific tools)
-- **Remote access**: If users access from remote machines, backend must be on same machine as user
-- **Error handling**: Handle cases where tkinter dialog can't be shown (no display, permissions, etc.)
-
----
-
-## Quick Comparison Table
-
-| Solution | Full Path | Native Dialog | Dependencies | Complexity | Recommended |
-|----------|-----------|---------------|--------------|------------|-------------|
-| **Backend + Tkinter** | ✅ | ✅ | None (built-in) | Low | ✅ **YES** |
-| Backend + PyQt | ✅ | ✅ | PyQt/PySide | Medium | ⚠️ Maybe |
-| Platform Tools | ✅ | ✅ | zenity/kdialog/etc | High | ⚠️ Maybe |
-| Custom UI | ✅ | ❌ | None | Very High | ❌ No |
-| Electron | ✅ | ✅ | Electron | Very High | ❌ No |
-| Browser API | ❌ | ✅ | None | Low | ❌ No |
-
----
-
-## Next Steps
-
-1. Implement backend API endpoint with tkinter
-2. Add frontend API method
-3. Update Browse button to use backend API
-4. Add error handling and fallback
-5. Test on all platforms (Windows, Linux, macOS)
-
diff --git a/docs/IDENTIFY_PANEL_FIXES.md b/docs/IDENTIFY_PANEL_FIXES.md
deleted file mode 100644
index d59e527..0000000
--- a/docs/IDENTIFY_PANEL_FIXES.md
+++ /dev/null
@@ -1,166 +0,0 @@
-# Identify Panel Fixes
-
-**Date:** October 16, 2025
-**Status:** ✅ Complete
-
-## Issues Fixed
-
-### 1. ✅ Unique Checkbox Default State
-**Issue:** User requested that the "Unique faces only" checkbox be unchecked by default.
-
-**Status:** Already correct! The checkbox was already unchecked by default.
-
-**Code Location:** `src/gui/identify_panel.py`, line 76
-```python
-self.components['unique_var'] = tk.BooleanVar() # Defaults to False (unchecked)
-```
-
-### 2. ✅ Quality Filter Not Working
-**Issue:** The "Min quality" filter slider wasn't actually filtering faces when loading them from the database.
-
-**Root Cause:**
-- The quality filter value was being captured in the GUI (slider with 0-100% range)
-- However, the `_get_unidentified_faces()` method wasn't using this filter when querying the database
-- Quality filtering was only happening during navigation (Back/Next buttons), not during initial load
-
-**Solution:**
-1. Modified `_get_unidentified_faces()` to accept a `min_quality_score` parameter
-2. Added SQL WHERE clause to filter by quality score: `AND f.quality_score >= ?`
-3. Updated all 4 calls to `_get_unidentified_faces()` to pass the quality filter value:
- - `_start_identification()` - Initial load
- - `on_unique_change()` - When toggling unique faces filter
- - `_load_more_faces()` - Loading additional batches
- - `_apply_date_filters()` - When applying date filters
-
-**Code Changes:**
-
-**File:** `src/gui/identify_panel.py`
-
-**Modified Method Signature (line 519-521):**
-```python
-def _get_unidentified_faces(self, batch_size: int, date_from: str = None, date_to: str = None,
- date_processed_from: str = None, date_processed_to: str = None,
- min_quality_score: float = 0.0) -> List[Tuple]:
-```
-
-**Added SQL Filter (lines 537-540):**
-```python
-# Add quality filtering if specified
-if min_quality_score > 0.0:
- query += ' AND f.quality_score >= ?'
- params.append(min_quality_score)
-```
-
-**Updated Call Sites:**
-
-1. **`_start_identification()` (lines 494-501):**
-```python
-# Get quality filter
-min_quality = self.components['quality_filter_var'].get()
-min_quality_score = min_quality / 100.0
-
-# Get unidentified faces with quality filter
-self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to,
- date_processed_from, date_processed_to,
- min_quality_score)
-```
-
-2. **`on_unique_change()` (lines 267-274):**
-```python
-# Get quality filter
-min_quality = self.components['quality_filter_var'].get()
-min_quality_score = min_quality / 100.0
-
-# Reload faces with current filters
-self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to,
- date_processed_from, date_processed_to,
- min_quality_score)
-```
-
-3. **`_load_more_faces()` (lines 1378-1385):**
-```python
-# Get quality filter
-min_quality = self.components['quality_filter_var'].get()
-min_quality_score = min_quality / 100.0
-
-# Get more faces
-more_faces = self._get_unidentified_faces(DEFAULT_BATCH_SIZE, date_from, date_to,
- date_processed_from, date_processed_to,
- min_quality_score)
-```
-
-4. **`_apply_date_filters()` (lines 1575-1581):**
-```python
-# Quality filter is already extracted above in min_quality
-min_quality_score = min_quality / 100.0
-
-# Reload faces with new filters
-self.current_faces = self._get_unidentified_faces(batch_size, date_from, date_to,
- date_processed_from, date_processed_to,
- min_quality_score)
-```
-
-## Testing
-
-**Syntax Check:** ✅ Passed
-```bash
-python3 -m py_compile src/gui/identify_panel.py
-```
-
-**Linter Check:** ✅ No errors found
-
-## How Quality Filter Now Works
-
-1. **User adjusts slider:** Sets quality from 0% to 100% (in 5% increments)
-2. **User clicks "Start Identification":**
- - Gets quality value (e.g., 75%)
- - Converts to 0.0-1.0 scale (e.g., 0.75)
- - Passes to `_get_unidentified_faces()`
- - SQL query filters: `WHERE f.quality_score >= 0.75`
- - Only faces with quality ≥ 75% are loaded
-3. **Quality filter persists:**
- - When loading more batches
- - When toggling unique faces
- - When applying date filters
- - When navigating (Back/Next already had quality filtering)
-
-## Expected Behavior
-
-### Quality Filter = 0% (default)
-- Shows all faces regardless of quality
-- SQL: No quality filter applied
-
-### Quality Filter = 50%
-- Shows only faces with quality ≥ 50%
-- SQL: `WHERE f.quality_score >= 0.5`
-
-### Quality Filter = 75%
-- Shows only faces with quality ≥ 75%
-- SQL: `WHERE f.quality_score >= 0.75`
-
-### Quality Filter = 100%
-- Shows only perfect quality faces
-- SQL: `WHERE f.quality_score >= 1.0`
-
-## Notes
-
-- The quality score is stored in the database as a float between 0.0 and 1.0
-- The GUI displays it as a percentage (0-100%) for user-friendliness
-- The conversion happens at every call site: `min_quality_score = min_quality / 100.0`
-- The Back/Next navigation already had quality filtering logic via `_find_next_qualifying_face()` - this continues to work as before
-
-## Files Modified
-
-- `src/gui/identify_panel.py` (1 file, ~15 lines changed)
-
-## Validation Checklist
-
-- [x] Quality filter parameter added to method signature
-- [x] SQL WHERE clause added for quality filtering
-- [x] All 4 call sites updated with quality filter
-- [x] Syntax validation passed
-- [x] No linter errors
-- [x] Unique checkbox already defaults to unchecked
-- [x] Code follows PEP 8 style guidelines
-- [x] Changes are backward compatible (min_quality_score defaults to 0.0)
-
diff --git a/docs/IMPORT_FIX_SUMMARY.md b/docs/IMPORT_FIX_SUMMARY.md
deleted file mode 100644
index 12a9f66..0000000
--- a/docs/IMPORT_FIX_SUMMARY.md
+++ /dev/null
@@ -1,229 +0,0 @@
-# Import Statements Fix Summary
-
-**Date**: October 15, 2025
-**Status**: ✅ Complete
-
----
-
-## What Was Fixed
-
-All import statements have been updated to use the new `src/` package structure.
-
-### Files Updated (13 files)
-
-#### Core Module Imports
-1. **`src/core/database.py`**
- - `from config import` → `from src.core.config import`
-
-2. **`src/core/face_processing.py`**
- - `from config import` → `from src.core.config import`
- - `from database import` → `from src.core.database import`
-
-3. **`src/core/photo_management.py`**
- - `from config import` → `from src.core.config import`
- - `from database import` → `from src.core.database import`
- - `from path_utils import` → `from src.utils.path_utils import`
-
-4. **`src/core/search_stats.py`**
- - `from database import` → `from src.core.database import`
-
-5. **`src/core/tag_management.py`**
- - `from config import` → `from src.core.config import`
- - `from database import` → `from src.core.database import`
-
-#### GUI Module Imports
-6. **`src/gui/gui_core.py`**
- - `from config import` → `from src.core.config import`
-
-7. **`src/gui/dashboard_gui.py`**
- - `from gui_core import` → `from src.gui.gui_core import`
- - `from identify_panel import` → `from src.gui.identify_panel import`
- - `from auto_match_panel import` → `from src.gui.auto_match_panel import`
- - `from modify_panel import` → `from src.gui.modify_panel import`
- - `from tag_manager_panel import` → `from src.gui.tag_manager_panel import`
- - `from search_stats import` → `from src.core.search_stats import`
- - `from database import` → `from src.core.database import`
- - `from tag_management import` → `from src.core.tag_management import`
- - `from face_processing import` → `from src.core.face_processing import`
-
-8. **`src/gui/identify_panel.py`**
- - `from config import` → `from src.core.config import`
- - `from database import` → `from src.core.database import`
- - `from face_processing import` → `from src.core.face_processing import`
- - `from gui_core import` → `from src.gui.gui_core import`
-
-9. **`src/gui/auto_match_panel.py`**
- - `from config import` → `from src.core.config import`
- - `from database import` → `from src.core.database import`
- - `from face_processing import` → `from src.core.face_processing import`
- - `from gui_core import` → `from src.gui.gui_core import`
-
-10. **`src/gui/modify_panel.py`**
- - `from config import` → `from src.core.config import`
- - `from database import` → `from src.core.database import`
- - `from face_processing import` → `from src.core.face_processing import`
- - `from gui_core import` → `from src.gui.gui_core import`
-
-11. **`src/gui/tag_manager_panel.py`**
- - `from database import` → `from src.core.database import`
- - `from gui_core import` → `from src.gui.gui_core import`
- - `from tag_management import` → `from src.core.tag_management import`
- - `from face_processing import` → `from src.core.face_processing import`
-
-#### Entry Point
-12. **`src/photo_tagger.py`**
- - `from config import` → `from src.core.config import`
- - `from database import` → `from src.core.database import`
- - `from face_processing import` → `from src.core.face_processing import`
- - `from photo_management import` → `from src.core.photo_management import`
- - `from tag_management import` → `from src.core.tag_management import`
- - `from search_stats import` → `from src.core.search_stats import`
- - `from gui_core import` → `from src.gui.gui_core import`
- - `from dashboard_gui import` → `from src.gui.dashboard_gui import`
- - Removed imports for archived GUI files
-
-#### Launcher Created
-13. **`run_dashboard.py`** (NEW)
- - Created launcher script that adds project root to Python path
- - Initializes all required dependencies (DatabaseManager, FaceProcessor, etc.)
- - Properly instantiates and runs DashboardGUI
-
----
-
-## Running the Application
-
-### Method 1: Using Launcher (Recommended)
-```bash
-# Activate virtual environment
-source venv/bin/activate
-
-# Run dashboard
-python run_dashboard.py
-```
-
-### Method 2: Using Python Module
-```bash
-# Activate virtual environment
-source venv/bin/activate
-
-# Run as module
-python -m src.gui.dashboard_gui
-```
-
-### Method 3: CLI Tool
-```bash
-# Activate virtual environment
-source venv/bin/activate
-
-# Run CLI
-python -m src.photo_tagger --help
-```
-
----
-
-## Import Pattern Reference
-
-### Core Modules
-```python
-from src.core.config import DEFAULT_DB_PATH, ...
-from src.core.database import DatabaseManager
-from src.core.face_processing import FaceProcessor
-from src.core.photo_management import PhotoManager
-from src.core.tag_management import TagManager
-from src.core.search_stats import SearchStats
-```
-
-### GUI Modules
-```python
-from src.gui.gui_core import GUICore
-from src.gui.dashboard_gui import DashboardGUI
-from src.gui.identify_panel import IdentifyPanel
-from src.gui.auto_match_panel import AutoMatchPanel
-from src.gui.modify_panel import ModifyPanel
-from src.gui.tag_manager_panel import TagManagerPanel
-```
-
-### Utility Modules
-```python
-from src.utils.path_utils import normalize_path, validate_path_exists
-```
-
----
-
-## Verification Steps
-
-### ✅ Completed
-- [x] All core module imports updated
-- [x] All GUI module imports updated
-- [x] Entry point (photo_tagger.py) updated
-- [x] Launcher script created
-- [x] Dashboard tested and running
-
-### 🔄 To Do
-- [ ] Update test files (tests/*.py)
-- [ ] Update demo scripts (demo.sh, run_deepface_gui.sh)
-- [ ] Run full test suite
-- [ ] Verify all panels work correctly
-- [ ] Commit changes to git
-
----
-
-## Known Issues & Solutions
-
-### Issue: ModuleNotFoundError for 'src'
-**Solution**: Use the launcher script `run_dashboard.py` which adds project root to path
-
-### Issue: ImportError for PIL.ImageTk
-**Solution**: Make sure to use the virtual environment:
-```bash
-source venv/bin/activate
-pip install Pillow
-```
-
-### Issue: Relative imports not working
-**Solution**: All imports now use absolute imports from `src.`
-
----
-
-## File Structure After Fix
-
-```
-src/
-├── core/ # All core imports work ✅
-├── gui/ # All GUI imports work ✅
-└── utils/ # Utils imports work ✅
-
-Project Root:
-├── run_dashboard.py # Launcher script ✅
-└── src/ # Package with proper imports ✅
-```
-
----
-
-## Next Steps
-
-1. **Test All Functionality**
- ```bash
- source venv/bin/activate
- python run_dashboard.py
- ```
-
-2. **Update Test Files**
- - Fix imports in `tests/*.py`
- - Run test suite
-
-3. **Update Scripts**
- - Update `demo.sh`
- - Update `run_deepface_gui.sh`
-
-4. **Commit Changes**
- ```bash
- git add .
- git commit -m "fix: update all import statements for new structure"
- git push
- ```
-
----
-
-**Status**: Import statements fixed ✅ | Application running ✅ | Tests pending ⏳
-
diff --git a/docs/MONOREPO_MIGRATION.md b/docs/MONOREPO_MIGRATION.md
deleted file mode 100644
index 66c1d68..0000000
--- a/docs/MONOREPO_MIGRATION.md
+++ /dev/null
@@ -1,126 +0,0 @@
-# Monorepo Migration Summary
-
-This document summarizes the migration from separate `punimtag` and `punimtag-viewer` projects to a unified monorepo structure.
-
-## Migration Date
-December 2024
-
-## Changes Made
-
-### Directory Structure
-
-**Before:**
-```
-punimtag/
-├── src/web/ # Backend API
-└── frontend/ # Admin React frontend
-
-punimtag-viewer/ # Separate repository
-└── (Next.js viewer)
-```
-
-**After:**
-```
-punimtag/
-├── backend/ # FastAPI backend (renamed from src/web)
-├── admin-frontend/ # React admin interface (renamed from frontend)
-└── viewer-frontend/ # Next.js viewer (moved from punimtag-viewer)
-```
-
-### Import Path Changes
-
-All Python imports have been updated:
-- `from src.web.*` → `from backend.*`
-- `import src.web.*` → `import backend.*`
-
-### Configuration Updates
-
-1. **install.sh**: Updated to install dependencies for both frontends
-2. **package.json**: Created root package.json with workspace scripts
-3. **run_api_with_worker.sh**: Updated to use `backend.app` instead of `src.web.app`
-4. **run_worker.sh**: Updated to use `backend.worker` instead of `src.web.worker`
-5. **docker-compose.yml**: Updated service commands to use `backend.*` paths
-
-### Environment Files
-
-- **admin-frontend/.env**: Backend API URL configuration
-- **viewer-frontend/.env.local**: Database and NextAuth configuration
-
-### Port Configuration
-
-- **Admin Frontend**: Port 3000 (unchanged)
-- **Viewer Frontend**: Port 3001 (configured in viewer-frontend/package.json)
-- **Backend API**: Port 8000 (unchanged)
-
-## Running the Application
-
-### Development
-
-**Terminal 1 - Backend:**
-```bash
-source venv/bin/activate
-export PYTHONPATH=$(pwd)
-uvicorn backend.app:app --host 127.0.0.1 --port 8000
-```
-
-**Terminal 2 - Admin Frontend:**
-```bash
-cd admin-frontend
-npm run dev
-```
-
-**Terminal 3 - Viewer Frontend:**
-```bash
-cd viewer-frontend
-npm run dev
-```
-
-### Using Root Scripts
-
-```bash
-# Install all dependencies
-npm run install:all
-
-# Run individual services
-npm run dev:backend
-npm run dev:admin
-npm run dev:viewer
-```
-
-## Benefits
-
-1. **Unified Setup**: Single installation script for all components
-2. **Easier Maintenance**: All code in one repository
-3. **Shared Configuration**: Common environment variables and settings
-4. **Simplified Deployment**: Single repository to deploy
-5. **Better Organization**: Clear separation of admin and viewer interfaces
-
-## Migration Checklist
-
-- [x] Rename `src/web` to `backend`
-- [x] Rename `frontend` to `admin-frontend`
-- [x] Copy `punimtag-viewer` to `viewer-frontend`
-- [x] Update all Python imports
-- [x] Update all scripts
-- [x] Update install.sh
-- [x] Create root package.json
-- [x] Update docker-compose.yml
-- [x] Update README.md
-- [x] Update scripts in scripts/ directory
-
-## Notes
-
-- The viewer frontend manages the `punimtag_auth` database
-- Both frontends share the main `punimtag` database
-- Backend API serves both frontends
-- All database schemas remain unchanged
-
-## Next Steps
-
-1. Test all three services start correctly
-2. Verify database connections work
-3. Test authentication flows
-4. Update CI/CD pipelines if applicable
-5. Archive or remove the old `punimtag-viewer` repository
-
-
diff --git a/docs/PHASE1_CHECKLIST.md b/docs/PHASE1_CHECKLIST.md
deleted file mode 100644
index 77b1204..0000000
--- a/docs/PHASE1_CHECKLIST.md
+++ /dev/null
@@ -1,183 +0,0 @@
-# Phase 1: Foundations - Implementation Checklist
-
-**Date:** October 31, 2025
-**Status:** ✅ Most Complete | ⚠️ Some Items Missing
-
----
-
-## ✅ COMPLETED Items
-
-### Directory Structure
-- ✅ Created `src/web/` directory
-- ✅ Created `frontend/` directory
-- ✅ Created `deploy/` directory with docker-compose.yml
-
-### FastAPI Backend Structure
-- ✅ `src/web/app.py` - App factory with CORS middleware
-- ✅ `src/web/api/` - Router package
- - ✅ `auth.py` - Authentication endpoints
- - ✅ `health.py` - Health check
- - ✅ `jobs.py` - Job management
- - ✅ `version.py` - Version info
- - ✅ `photos.py` - Photos endpoints (placeholder)
- - ✅ `faces.py` - Faces endpoints (placeholder)
- - ✅ `tags.py` - Tags endpoints (placeholder)
- - ✅ `people.py` - People endpoints (placeholder)
- - ✅ `metrics.py` - Metrics endpoint
-- ✅ `src/web/schemas/` - Pydantic models
- - ✅ `auth.py` - Auth schemas
- - ✅ `jobs.py` - Job schemas
-- ✅ `src/web/db/` - Database layer
- - ✅ `models.py` - All SQLAlchemy models matching desktop schema (photos, faces, people, person_encodings, tags, phototaglinkage)
- - ✅ `session.py` - Session management with connection pooling
- - ✅ `base.py` - Base exports
-- ✅ `src/web/services/` - Service layer (ready for Phase 2)
-
-### Database Setup
-- ✅ SQLAlchemy models for all tables (matches desktop schema exactly):
- - ✅ `photos` (id, path, filename, date_added, date_taken DATE, processed)
- - ✅ `faces` (id, photo_id, person_id, encoding BLOB, location TEXT, confidence REAL, quality_score REAL, is_primary_encoding, detector_backend, model_name, face_confidence REAL, exif_orientation)
- - ✅ `people` (id, first_name, last_name, middle_name, maiden_name, date_of_birth, created_date)
- - ✅ `person_encodings` (id, person_id, face_id, encoding BLOB, quality_score REAL, detector_backend, model_name, created_date)
- - ✅ `tags` (id, tag_name, created_date)
- - ✅ `phototaglinkage` (linkage_id, photo_id, tag_id, linkage_type, created_date)
-- ✅ Auto-create tables on startup (via `Base.metadata.create_all()` in lifespan)
-- ✅ Alembic configuration:
- - ✅ `alembic.ini` - Configuration file
- - ✅ `alembic/env.py` - Environment setup
- - ✅ `alembic/script.py.mako` - Migration template
-- ✅ Database URL from environment (defaults to SQLite: `data/punimtag.db`)
-- ✅ Connection pooling enabled
-
-### Authentication
-- ✅ JWT token issuance and refresh
-- ✅ `/api/v1/auth/login` endpoint
-- ✅ `/api/v1/auth/refresh` endpoint
-- ✅ `/api/v1/auth/me` endpoint
-- ✅ Single-user mode (admin/admin)
-- ⚠️ **PARTIAL:** Password hashing not implemented (using plain text comparison)
-- ⚠️ **PARTIAL:** Env secrets not fully implemented (hardcoded SECRET_KEY)
-
-### Jobs Subsystem
-- ✅ Redis + RQ integration
-- ✅ Job schema/status (Pydantic models)
-- ✅ `/api/v1/jobs/{id}` endpoint
-- ✅ Worker entrypoint `src/web/worker.py` with graceful shutdown
-- ⚠️ **PARTIAL:** Worker not fully implemented (placeholder only)
-
-### Developer Experience
-- ✅ Docker Compose with services: `api`, `worker`, `db`, `redis`
-- ⚠️ **MISSING:** `frontend` service in Docker Compose
-- ⚠️ **MISSING:** `proxy` service in Docker Compose
-- ⚠️ **MISSING:** Request IDs middleware for logging
-- ⚠️ **MISSING:** Structured JSON logging
-- ✅ Health endpoint: `/health`
-- ✅ Version endpoint: `/version`
-- ✅ `/metrics` endpoint
-
-### Frontend Scaffold
-- ✅ Vite + React + TypeScript setup
-- ✅ Tailwind CSS configured
-- ✅ Base layout (left nav + top bar)
-- ✅ Auth flow (login page, token storage)
-- ✅ API client with interceptors (Axios)
-- ✅ Routes:
- - ✅ Dashboard (placeholder)
- - ✅ Search (placeholder)
- - ✅ Identify (placeholder)
- - ✅ Tags (placeholder)
- - ✅ Settings (placeholder)
-- ✅ React Router with protected routes
-- ✅ React Query setup
-
----
-
-## ⚠️ MISSING Items (Phase 1 Requirements)
-
-### API Routers (Required by Plan)
-- ✅ `photos.py` - Photos router (placeholder)
-- ✅ `faces.py` - Faces router (placeholder)
-- ✅ `tags.py` - Tags router (placeholder)
-- ✅ `people.py` - People router (placeholder)
-
-**Note:** All required routers now exist as placeholders.
-
-### Database
-- ❌ Initial Alembic migration not generated
- - **Action needed:** `alembic revision --autogenerate -m "Initial schema"`
-
-### Developer Experience
-- ❌ Request IDs middleware for logging
-- ❌ Structured JSON logging
-- ✅ `/metrics` endpoint
-- ❌ Frontend service in Docker Compose
-- ❌ Proxy service in Docker Compose
-
-### Authentication
-- ⚠️ Password hashing (bcrypt/argon2)
-- ⚠️ Environment variables for secrets (currently hardcoded)
-
----
-
-## 📊 Summary
-
-| Category | Status | Completion |
-|----------|--------|------------|
-| Directory Structure | ✅ Complete | 100% |
-| FastAPI Backend | ✅ Complete | 100% |
-| Database Models | ✅ Complete | 100% |
-| Database Setup | ⚠️ Partial | 90% |
-| Authentication | ⚠️ Partial | 90% |
-| Jobs Subsystem | ⚠️ Partial | 80% |
-| Developer Experience | ⚠️ Partial | 80% |
-| Frontend Scaffold | ✅ Complete | 100% |
-| **Overall Phase 1** | ✅ **~95%** | **95%** |
-
----
-
-## 🔧 Quick Fixes Needed
-
-### 1. Generate Initial Migration
-```bash
-cd /home/ladmin/Code/punimtag
-alembic revision --autogenerate -m "Initial schema"
-alembic upgrade head
-```
-
-### 2. ✅ Add Missing API Routers (Placeholders) - COMPLETED
-All placeholder routers created:
-- ✅ `src/web/api/photos.py`
-- ✅ `src/web/api/faces.py`
-- ✅ `src/web/api/tags.py`
-- ✅ `src/web/api/people.py`
-
-### 3. Add Missing Endpoints
-- ✅ `/metrics` endpoint - COMPLETED
-- ❌ Request ID middleware - OPTIONAL (can add later)
-- ❌ Structured logging - OPTIONAL (can add later)
-
-### 4. Improve Authentication
-- Add password hashing
-- Use environment variables for secrets
-
----
-
-## ✅ Phase 1 Ready for Phase 2?
-
-**Status:** ✅ **READY** - All critical Phase 1 requirements complete!
-
-**Recommendation:**
-1. ✅ Generate the initial migration (when ready to set up DB)
-2. ✅ Add placeholder API routers - COMPLETED
-3. ✅ Add `/metrics` endpoint - COMPLETED
-4. **Proceed to Phase 2!** 🚀
-
-### Remaining Optional Items (Non-Blocking)
-- Request ID middleware (nice-to-have)
-- Structured JSON logging (nice-to-have)
-- Frontend service in Docker Compose (optional)
-- Proxy service in Docker Compose (optional)
-- Password hashing (should add before production)
-
-**All core Phase 1 functionality is complete and working!**
-
diff --git a/docs/PHASE1_COMPLETE.md b/docs/PHASE1_COMPLETE.md
deleted file mode 100644
index d018752..0000000
--- a/docs/PHASE1_COMPLETE.md
+++ /dev/null
@@ -1,264 +0,0 @@
-# Phase 1 Implementation Complete: Database Schema Updates
-
-**Date:** October 16, 2025
-**Status:** ✅ COMPLETE
-**All Tests:** PASSING (4/4)
-
----
-
-## Summary
-
-Phase 1 of the DeepFace migration has been successfully implemented. The database schema and methods have been updated to support DeepFace-specific fields, while maintaining backward compatibility with existing code.
-
----
-
-## Changes Implemented
-
-### 1. ✅ Updated `requirements.txt`
-**File:** `/home/ladmin/Code/punimtag/requirements.txt`
-
-**Changes:**
-- ❌ Removed: `face-recognition`, `face-recognition-models`, `dlib`
-- ✅ Added: `deepface>=0.0.79`, `tensorflow>=2.13.0`, `opencv-python>=4.8.0`, `retina-face>=0.0.13`
-
-**Impact:** New dependencies required for DeepFace implementation
-
----
-
-### 2. ✅ Updated `src/core/config.py`
-**File:** `/home/ladmin/Code/punimtag/src/core/config.py`
-
-**New Constants:**
-```python
-# DeepFace Settings
-DEEPFACE_DETECTOR_BACKEND = "retinaface"
-DEEPFACE_MODEL_NAME = "ArcFace"
-DEEPFACE_DISTANCE_METRIC = "cosine"
-DEEPFACE_ENFORCE_DETECTION = False
-DEEPFACE_ALIGN_FACES = True
-
-# DeepFace Options
-DEEPFACE_DETECTOR_OPTIONS = ["retinaface", "mtcnn", "opencv", "ssd"]
-DEEPFACE_MODEL_OPTIONS = ["ArcFace", "Facenet", "Facenet512", "VGG-Face"]
-
-# Adjusted Tolerances
-DEFAULT_FACE_TOLERANCE = 0.4 # Lower for DeepFace (was 0.6)
-DEEPFACE_SIMILARITY_THRESHOLD = 60 # Percentage (0-100)
-```
-
-**Backward Compatibility:**
-- Kept `DEFAULT_FACE_DETECTION_MODEL` for Phase 2-3 compatibility
-- TensorFlow warning suppression configured
-
----
-
-### 3. ✅ Updated Database Schema
-**File:** `/home/ladmin/Code/punimtag/src/core/database.py`
-
-#### faces table - New Columns:
-```sql
-detector_backend TEXT DEFAULT 'retinaface'
-model_name TEXT DEFAULT 'ArcFace'
-face_confidence REAL DEFAULT 0.0
-```
-
-#### person_encodings table - New Columns:
-```sql
-detector_backend TEXT DEFAULT 'retinaface'
-model_name TEXT DEFAULT 'ArcFace'
-```
-
-**Key Changes:**
-- Encoding size will increase from 1,024 bytes (128 floats) to 4,096 bytes (512 floats)
-- Location format will change from tuple to dict: `{'x': x, 'y': y, 'w': w, 'h': h}`
-- New confidence score from DeepFace detector
-
----
-
-### 4. ✅ Updated Method Signatures
-
-#### `DatabaseManager.add_face()`
-**New Signature:**
-```python
-def add_face(self, photo_id: int, encoding: bytes, location: str,
- confidence: float = 0.0, quality_score: float = 0.0,
- person_id: Optional[int] = None,
- detector_backend: str = 'retinaface',
- model_name: str = 'ArcFace',
- face_confidence: float = 0.0) -> int:
-```
-
-**New Parameters:**
-- `detector_backend`: DeepFace detector used (retinaface, mtcnn, opencv, ssd)
-- `model_name`: DeepFace model used (ArcFace, Facenet, etc.)
-- `face_confidence`: Confidence score from DeepFace detector
-
-#### `DatabaseManager.add_person_encoding()`
-**New Signature:**
-```python
-def add_person_encoding(self, person_id: int, face_id: int,
- encoding: bytes, quality_score: float,
- detector_backend: str = 'retinaface',
- model_name: str = 'ArcFace'):
-```
-
-**New Parameters:**
-- `detector_backend`: DeepFace detector used
-- `model_name`: DeepFace model used
-
-**Backward Compatibility:** All new parameters have default values
-
----
-
-### 5. ✅ Created Migration Script
-**File:** `/home/ladmin/Code/punimtag/scripts/migrate_to_deepface.py`
-
-**Purpose:** Drop all existing tables and reinitialize with DeepFace schema
-
-**Features:**
-- Interactive confirmation (must type "DELETE ALL DATA")
-- Drops tables in correct order (respecting foreign keys)
-- Reinitializes database with new schema
-- Provides next steps guidance
-
-**Usage:**
-```bash
-cd /home/ladmin/Code/punimtag
-python3 scripts/migrate_to_deepface.py
-```
-
-**⚠️ WARNING:** This script DELETES ALL DATA!
-
----
-
-### 6. ✅ Created Test Suite
-**File:** `/home/ladmin/Code/punimtag/tests/test_phase1_schema.py`
-
-**Test Coverage:**
-1. ✅ Schema has DeepFace columns (faces & person_encodings tables)
-2. ✅ `add_face()` accepts and stores DeepFace parameters
-3. ✅ `add_person_encoding()` accepts and stores DeepFace parameters
-4. ✅ Configuration constants are present and correct
-
-**Test Results:**
-```
-Tests passed: 4/4
-✅ PASS: Schema Columns
-✅ PASS: add_face() Method
-✅ PASS: add_person_encoding() Method
-✅ PASS: Config Constants
-```
-
-**Run Tests:**
-```bash
-cd /home/ladmin/Code/punimtag
-source venv/bin/activate
-python3 tests/test_phase1_schema.py
-```
-
----
-
-## Migration Path
-
-### For New Installations:
-1. Install dependencies: `pip install -r requirements.txt`
-2. Database will automatically use new schema
-
-### For Existing Installations:
-1. **Backup your data** (copy `data/photos.db`)
-2. Run migration script: `python3 scripts/migrate_to_deepface.py`
-3. Type "DELETE ALL DATA" to confirm
-4. Database will be recreated with new schema
-5. Re-add photos and process with DeepFace
-
----
-
-## What's Next: Phase 2 & 3
-
-### Phase 2: Configuration Updates (Planned)
-- Add TensorFlow suppression to entry points
-- Update GUI with detector/model selection
-- Configure environment variables
-
-### Phase 3: Core Face Processing (Planned)
-- Replace `face_recognition` with `DeepFace` in `face_processing.py`
-- Update `process_faces()` method
-- Implement cosine similarity calculation
-- Update face location handling
-- Update adaptive tolerance for DeepFace metrics
-
----
-
-## File Changes Summary
-
-### Modified Files:
-1. `requirements.txt` - Updated dependencies
-2. `src/core/config.py` - Added DeepFace constants
-3. `src/core/database.py` - Updated schema and methods
-
-### New Files:
-1. `scripts/migrate_to_deepface.py` - Migration script
-2. `tests/test_phase1_schema.py` - Test suite
-3. `PHASE1_COMPLETE.md` - This document
-
----
-
-## Backward Compatibility Notes
-
-### Maintained:
-- ✅ `DEFAULT_FACE_DETECTION_MODEL` constant (legacy)
-- ✅ All existing method signatures work (new params have defaults)
-- ✅ Existing code can still import and use database methods
-
-### Breaking Changes (only after migration):
-- ❌ Old database cannot be used (must run migration)
-- ❌ Face encodings incompatible (128-dim vs 512-dim)
-- ❌ `face_recognition` library removed
-
----
-
-## Key Metrics
-
-- **Database Schema Changes:** 5 new columns
-- **Method Signature Updates:** 2 methods
-- **New Configuration Constants:** 9 constants
-- **Test Coverage:** 4 comprehensive tests
-- **Test Pass Rate:** 100% (4/4)
-- **Lines of Code Added:** ~350 lines
-- **Files Modified:** 3 files
-- **Files Created:** 3 files
-
----
-
-## Validation Checklist
-
-- [x] Database schema includes DeepFace columns
-- [x] Method signatures accept DeepFace parameters
-- [x] Configuration constants defined
-- [x] Migration script created and tested
-- [x] Test suite created
-- [x] All tests passing
-- [x] Backward compatibility maintained
-- [x] Documentation complete
-
----
-
-## Known Issues
-
-**None** - Phase 1 complete with all tests passing
-
----
-
-## References
-
-- Migration Plan: `.notes/deepface_migration_plan.md`
-- Architecture: `docs/ARCHITECTURE.md`
-- Test Results: Run `python3 tests/test_phase1_schema.py`
-
----
-
-**Phase 1 Status: ✅ READY FOR PHASE 2**
-
-All database schema updates are complete and tested. The foundation is ready for implementing DeepFace face processing in Phase 3.
-
-
diff --git a/docs/PHASE1_FOUNDATION_STATUS.md b/docs/PHASE1_FOUNDATION_STATUS.md
deleted file mode 100644
index c06c5e7..0000000
--- a/docs/PHASE1_FOUNDATION_STATUS.md
+++ /dev/null
@@ -1,196 +0,0 @@
-# Phase 1: Foundation - Status
-
-**Date:** October 31, 2025
-**Status:** ✅ **COMPLETE**
-
----
-
-## ✅ Completed Tasks
-
-### Backend Infrastructure
-- ✅ FastAPI application scaffold with CORS middleware
-- ✅ Health endpoint (`/health`)
-- ✅ Version endpoint (`/version`)
-- ✅ OpenAPI documentation (available at `/docs` and `/openapi.json`)
-
-### Database Layer
-- ✅ SQLAlchemy models for all entities:
- - `Photo` (id, path, filename, checksum, date_added, date_taken, width, height, mime_type)
- - `Face` (id, photo_id, person_id, bbox, embedding, confidence, quality, model, detector)
- - `Person` (id, display_name, given_name, family_name, notes, created_at)
- - `PersonEmbedding` (id, person_id, face_id, embedding, quality, model, created_at)
- - `Tag` (id, tag, created_at)
- - `PhotoTag` (photo_id, tag_id, created_at)
-- ✅ Alembic configuration for migrations
-- ✅ Database session management
-
-### Authentication
-- ✅ JWT-based authentication (python-jose)
-- ✅ Login endpoint (`POST /api/v1/auth/login`)
-- ✅ Token refresh endpoint (`POST /api/v1/auth/refresh`)
-- ✅ Current user endpoint (`GET /api/v1/auth/me`)
-- ✅ Single-user mode (default: admin/admin)
-
-### Jobs System
-- ✅ RQ (Redis Queue) integration
-- ✅ Job status endpoint (`GET /api/v1/jobs/{job_id}`)
-- ✅ Worker skeleton (`src/web/worker.py`)
-
-### Developer Experience
-- ✅ Docker Compose configuration (api, worker, db, redis)
-- ✅ Requirements.txt updated with all dependencies
-- ✅ Project structure organized (`src/web/`)
-
----
-
-## 📁 Project Structure Created
-
-```
-src/web/
-├── app.py # FastAPI app factory
-├── settings.py # App settings (version, title)
-├── worker.py # RQ worker entrypoint
-├── api/
-│ ├── __init__.py
-│ ├── auth.py # Authentication endpoints
-│ ├── health.py # Health check
-│ ├── jobs.py # Job management
-│ └── version.py # Version info
-├── db/
-│ ├── __init__.py
-│ ├── models.py # SQLAlchemy models
-│ ├── base.py # DB base exports
-│ └── session.py # Session management
-├── schemas/
-│ ├── __init__.py
-│ ├── auth.py # Auth Pydantic schemas
-│ └── jobs.py # Job Pydantic schemas
-└── services/
- └── __init__.py # Service layer (ready for Phase 2)
-
-alembic/ # Alembic migrations
-├── env.py # Alembic config
-└── script.py.mako # Migration template
-
-deploy/
-└── docker-compose.yml # Docker Compose config
-
-frontend/
-└── README.md # Frontend setup instructions
-```
-
----
-
-## 🔌 API Endpoints Available
-
-### Health & Meta
-- `GET /health` - Health check
-- `GET /version` - API version
-
-### Authentication (`/api/v1/auth`)
-- `POST /api/v1/auth/login` - Login (username, password) → returns access_token & refresh_token
-- `POST /api/v1/auth/refresh` - Refresh access token
-- `GET /api/v1/auth/me` - Get current user (requires Bearer token)
-
-### Jobs (`/api/v1/jobs`)
-- `GET /api/v1/jobs/{job_id}` - Get job status
-
----
-
-## 🚀 Running the Server
-
-```bash
-cd /home/ladmin/Code/punimtag
-source venv/bin/activate
-export PYTHONPATH=/home/ladmin/Code/punimtag
-uvicorn src.web.app:app --host 127.0.0.1 --port 8000
-```
-
-Then visit:
-- API: http://127.0.0.1:8000
-- Interactive Docs: http://127.0.0.1:8000/docs
-- OpenAPI JSON: http://127.0.0.1:8000/openapi.json
-
----
-
-## 🧪 Testing
-
-### Test Login
-```bash
-curl -X POST http://127.0.0.1:8000/api/v1/auth/login \
- -H "Content-Type: application/json" \
- -d '{"username":"admin","password":"admin"}'
-```
-
-Expected response:
-```json
-{
- "access_token": "eyJ...",
- "refresh_token": "eyJ...",
- "token_type": "bearer"
-}
-```
-
-### Test Health
-```bash
-curl http://127.0.0.1:8000/health
-```
-
-Expected response:
-```json
-{"status":"ok"}
-```
-
----
-
-## 📦 Dependencies Added
-
-- `fastapi==0.115.0`
-- `uvicorn[standard]==0.30.6`
-- `pydantic==2.9.1`
-- `SQLAlchemy==2.0.36`
-- `psycopg2-binary==2.9.9`
-- `alembic==1.13.2`
-- `redis==5.0.8`
-- `rq==1.16.2`
-- `python-jose[cryptography]==3.3.0`
-- `python-multipart==0.0.9`
-
----
-
-## 🔄 Next Steps (Phase 2)
-
-1. **Image Ingestion**
- - Implement `/api/v1/photos/import` endpoint
- - File upload and folder scanning
- - Thumbnail generation
-
-2. **DeepFace Processing**
- - Face detection pipeline in worker
- - Embedding computation
- - Store embeddings in database
-
-3. **Identify Workflow**
- - Unidentified faces endpoint
- - Face assignment endpoints
- - Auto-match engine
-
-4. **Frontend Basics**
- - React + Vite setup
- - Auth flow
- - Layout components
-
----
-
-## ⚠️ Notes
-
-- Database models are ready but migrations haven't been run yet
-- Auth uses default credentials (admin/admin) - must change for production
-- JWT secrets are hardcoded - must use environment variables in production
-- Redis connection is hardcoded to localhost - configure via env in deployment
-- Worker needs actual RQ task implementations (Phase 2)
-
----
-
-**Phase 1 Status:** ✅ **COMPLETE - Ready for Phase 2**
-
diff --git a/docs/PHASE2_COMPLETE.md b/docs/PHASE2_COMPLETE.md
deleted file mode 100644
index a905c48..0000000
--- a/docs/PHASE2_COMPLETE.md
+++ /dev/null
@@ -1,377 +0,0 @@
-# Phase 2 Implementation Complete: Configuration Updates
-
-**Date:** October 16, 2025
-**Status:** ✅ COMPLETE
-**All Tests:** PASSING (5/5)
-
----
-
-## Summary
-
-Phase 2 of the DeepFace migration has been successfully implemented. TensorFlow warning suppression is in place, FaceProcessor accepts DeepFace settings, and the GUI now includes detector and model selection.
-
----
-
-## Changes Implemented
-
-### 1. ✅ TensorFlow Warning Suppression
-
-**Files Modified:**
-- `run_dashboard.py`
-- `src/gui/dashboard_gui.py`
-- `src/photo_tagger.py`
-
-**Changes:**
-```python
-import os
-import warnings
-
-# Suppress TensorFlow warnings (must be before DeepFace import)
-os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
-warnings.filterwarnings('ignore')
-```
-
-**Impact:**
-- Eliminates TensorFlow console spam
-- Cleaner user experience
-- Already set in `config.py` for consistency
-
----
-
-### 2. ✅ Updated FaceProcessor Initialization
-
-**File:** `src/core/face_processing.py`
-
-**New Signature:**
-```python
-def __init__(self, db_manager: DatabaseManager, verbose: int = 0,
- detector_backend: str = None, model_name: str = None):
- """Initialize face processor with DeepFace settings
-
- Args:
- db_manager: Database manager instance
- verbose: Verbosity level (0-3)
- detector_backend: DeepFace detector backend (retinaface, mtcnn, opencv, ssd)
- If None, uses DEEPFACE_DETECTOR_BACKEND from config
- model_name: DeepFace model name (ArcFace, Facenet, Facenet512, VGG-Face)
- If None, uses DEEPFACE_MODEL_NAME from config
- """
- self.db = db_manager
- self.verbose = verbose
- self.detector_backend = detector_backend or DEEPFACE_DETECTOR_BACKEND
- self.model_name = model_name or DEEPFACE_MODEL_NAME
-```
-
-**Benefits:**
-- Configurable detector and model per instance
-- Falls back to config defaults
-- Verbose logging of settings
-
----
-
-### 3. ✅ GUI Detector/Model Selection
-
-**File:** `src/gui/dashboard_gui.py`
-
-**Added to Process Panel:**
-
-```python
-# DeepFace Settings Section
-deepface_frame = ttk.LabelFrame(form_frame, text="DeepFace Settings", padding="15")
-
-# Detector Backend Selection
-tk.Label(deepface_frame, text="Face Detector:")
-self.detector_var = tk.StringVar(value=DEEPFACE_DETECTOR_BACKEND)
-detector_combo = ttk.Combobox(deepface_frame, textvariable=self.detector_var,
- values=DEEPFACE_DETECTOR_OPTIONS,
- state="readonly")
-# Help text: "(RetinaFace recommended for accuracy)"
-
-# Model Selection
-tk.Label(deepface_frame, text="Recognition Model:")
-self.model_var = tk.StringVar(value=DEEPFACE_MODEL_NAME)
-model_combo = ttk.Combobox(deepface_frame, textvariable=self.model_var,
- values=DEEPFACE_MODEL_OPTIONS,
- state="readonly")
-# Help text: "(ArcFace provides best accuracy)"
-```
-
-**Features:**
-- Dropdown selectors for detector and model
-- Default values from config
-- Helpful tooltips for user guidance
-- Professional UI design
-
----
-
-### 4. ✅ Updated Process Callback
-
-**File:** `run_dashboard.py`
-
-**New Callback Signature:**
-```python
-def on_process(limit=None, stop_event=None, progress_callback=None,
- detector_backend=None, model_name=None):
- """Callback for processing faces with DeepFace settings"""
- # Update face_processor settings if provided
- if detector_backend:
- face_processor.detector_backend = detector_backend
- if model_name:
- face_processor.model_name = model_name
-
- return face_processor.process_faces(
- limit=limit or 50,
- stop_event=stop_event,
- progress_callback=progress_callback
- )
-```
-
-**Integration:**
-```python
-# In dashboard_gui.py _run_process():
-detector_backend = self.detector_var.get()
-model_name = self.model_var.get()
-result = self.on_process(limit_value, self._process_stop_event, progress_callback,
- detector_backend, model_name)
-```
-
-**Benefits:**
-- GUI selections passed to face processor
-- Settings applied before processing
-- No need to restart application
-
----
-
-## Test Results
-
-**File:** `tests/test_phase2_config.py`
-
-### All Tests Passing: 5/5
-
-```
-✅ PASS: TensorFlow Suppression
-✅ PASS: FaceProcessor Initialization
-✅ PASS: Config Imports
-✅ PASS: Entry Point Imports
-✅ PASS: GUI Config Constants
-```
-
-### Test Coverage:
-
-1. **TensorFlow Suppression**
- - Verifies `TF_CPP_MIN_LOG_LEVEL='3'` is set
- - Checks config.py and entry points
-
-2. **FaceProcessor Initialization**
- - Tests custom detector/model parameters
- - Tests default parameter fallback
- - Verifies settings are stored correctly
-
-3. **Config Imports**
- - All 8 DeepFace constants importable
- - Correct default values set
-
-4. **Entry Point Imports**
- - dashboard_gui.py imports cleanly
- - photo_tagger.py imports cleanly
- - No TensorFlow warnings during import
-
-5. **GUI Config Constants**
- - DEEPFACE_DETECTOR_OPTIONS list accessible
- - DEEPFACE_MODEL_OPTIONS list accessible
- - Contains expected values
-
----
-
-## Configuration Constants Added
-
-All from Phase 1 (already in `config.py`):
-
-```python
-DEEPFACE_DETECTOR_BACKEND = "retinaface"
-DEEPFACE_MODEL_NAME = "ArcFace"
-DEEPFACE_DISTANCE_METRIC = "cosine"
-DEEPFACE_ENFORCE_DETECTION = False
-DEEPFACE_ALIGN_FACES = True
-DEEPFACE_DETECTOR_OPTIONS = ["retinaface", "mtcnn", "opencv", "ssd"]
-DEEPFACE_MODEL_OPTIONS = ["ArcFace", "Facenet", "Facenet512", "VGG-Face"]
-DEFAULT_FACE_TOLERANCE = 0.4
-DEEPFACE_SIMILARITY_THRESHOLD = 60
-```
-
----
-
-## User Interface Updates
-
-### Process Panel - Before:
-```
-🔍 Process Faces
-┌─ Processing Configuration ──────┐
-│ ☐ Limit processing to [50] photos│
-│ [🚀 Start Processing] │
-└────────────────────────────────┘
-```
-
-### Process Panel - After:
-```
-🔍 Process Faces
-┌─ Processing Configuration ──────────────────────┐
-│ ┌─ DeepFace Settings ──────────────────────┐ │
-│ │ Face Detector: [retinaface ▼] │ │
-│ │ (RetinaFace recommended for accuracy) │ │
-│ │ Recognition Model: [ArcFace ▼] │ │
-│ │ (ArcFace provides best accuracy) │ │
-│ └─────────────────────────────────────────┘ │
-│ ☐ Limit processing to [50] photos │
-│ [🚀 Start Processing] │
-└──────────────────────────────────────────────┘
-```
-
----
-
-## Detector Options
-
-| Detector | Description | Speed | Accuracy |
-|----------|-------------|-------|----------|
-| **retinaface** | State-of-the-art detector | Medium | **Best** ⭐ |
-| mtcnn | Multi-task cascaded CNN | Fast | Good |
-| opencv | Haar Cascades (classic) | **Fastest** | Fair |
-| ssd | Single Shot Detector | Fast | Good |
-
-**Recommended:** RetinaFace (default)
-
----
-
-## Model Options
-
-| Model | Encoding Size | Speed | Accuracy |
-|-------|---------------|-------|----------|
-| **ArcFace** | 512-dim | Medium | **Best** ⭐ |
-| Facenet | 128-dim | Fast | Good |
-| Facenet512 | 512-dim | Medium | Very Good |
-| VGG-Face | 2622-dim | Slow | Good |
-
-**Recommended:** ArcFace (default)
-
----
-
-## File Changes Summary
-
-### Modified Files:
-1. `run_dashboard.py` - TF suppression + callback update
-2. `src/gui/dashboard_gui.py` - TF suppression + GUI controls
-3. `src/photo_tagger.py` - TF suppression
-4. `src/core/face_processing.py` - Updated __init__ signature
-
-### New Files:
-1. `tests/test_phase2_config.py` - Test suite (5 tests)
-2. `PHASE2_COMPLETE.md` - This document
-
----
-
-## Backward Compatibility
-
-✅ **Fully Maintained:**
-- Existing code without detector/model params still works
-- Default values from config used automatically
-- No breaking changes to API
-
-**Example:**
-```python
-# Old code still works:
-processor = FaceProcessor(db_manager, verbose=1)
-
-# New code adds options:
-processor = FaceProcessor(db_manager, verbose=1,
- detector_backend='mtcnn',
- model_name='Facenet')
-```
-
----
-
-## What's Next: Phase 3
-
-### Phase 3: Core Face Processing (Upcoming)
-
-The actual DeepFace implementation in `process_faces()`:
-
-1. Replace `face_recognition.load_image_file()` with DeepFace
-2. Use `DeepFace.represent()` for detection + encoding
-3. Handle new face location format: `{'x': x, 'y': y, 'w': w, 'h': h}`
-4. Implement cosine similarity for matching
-5. Update adaptive tolerance for DeepFace metrics
-6. Store 512-dim encodings (vs 128-dim)
-
-**Status:** Infrastructure ready, awaiting Phase 3 implementation
-
----
-
-## Run Tests
-
-```bash
-cd /home/ladmin/Code/punimtag
-source venv/bin/activate
-python3 tests/test_phase2_config.py
-```
-
----
-
-## Validation Checklist
-
-- [x] TensorFlow warnings suppressed in all entry points
-- [x] FaceProcessor accepts detector_backend parameter
-- [x] FaceProcessor accepts model_name parameter
-- [x] GUI has detector selection dropdown
-- [x] GUI has model selection dropdown
-- [x] Default values from config displayed
-- [x] User selections passed to processor
-- [x] All tests passing (5/5)
-- [x] No linter errors
-- [x] Backward compatibility maintained
-- [x] Documentation complete
-
----
-
-## Known Limitations
-
-**Phase 2 Only Provides UI/Config:**
-- Detector and model selections are captured in GUI
-- Settings are passed to FaceProcessor
-- **BUT:** Actual DeepFace processing not yet implemented (Phase 3)
-- Currently still using face_recognition library for processing
-- Phase 3 will replace the actual face detection/encoding code
-
-**Users can:**
-- ✅ Select detector and model in GUI
-- ✅ Settings are stored and passed correctly
-- ❌ Settings won't affect processing until Phase 3
-
----
-
-## Key Metrics
-
-- **Tests Created:** 5 comprehensive tests
-- **Test Pass Rate:** 100% (5/5)
-- **Files Modified:** 4 files
-- **Files Created:** 2 files
-- **New GUI Controls:** 2 dropdowns with 8 total options
-- **Code Added:** ~200 lines
-- **Breaking Changes:** 0
-
----
-
-## References
-
-- Migration Plan: `.notes/deepface_migration_plan.md`
-- Phase 1 Complete: `PHASE1_COMPLETE.md`
-- Architecture: `docs/ARCHITECTURE.md`
-- Test Results: Run `python3 tests/test_phase2_config.py`
-- Working Example: `tests/test_deepface_gui.py`
-
----
-
-**Phase 2 Status: ✅ READY FOR PHASE 3**
-
-All configuration updates complete and tested. The GUI now has DeepFace settings, and FaceProcessor is ready to receive them. Phase 3 will implement the actual DeepFace processing code.
-
-
diff --git a/docs/PHASE3_COMPLETE.md b/docs/PHASE3_COMPLETE.md
deleted file mode 100644
index 1aae012..0000000
--- a/docs/PHASE3_COMPLETE.md
+++ /dev/null
@@ -1,482 +0,0 @@
-# Phase 3 Implementation Complete: Core Face Processing with DeepFace
-
-**Date:** October 16, 2025
-**Status:** ✅ COMPLETE
-**All Tests:** PASSING (5/5)
-
----
-
-## Summary
-
-Phase 3 of the DeepFace migration has been successfully implemented! This is the **critical phase** where face_recognition has been completely replaced with DeepFace for face detection, encoding, and matching. The system now uses ArcFace model with 512-dimensional encodings and cosine similarity for superior accuracy.
-
----
-
-## Major Changes Implemented
-
-### 1. ✅ Replaced face_recognition with DeepFace
-
-**File:** `src/core/face_processing.py`
-
-**Old Code (face_recognition):**
-```python
-image = face_recognition.load_image_file(photo_path)
-face_locations = face_recognition.face_locations(image, model=model)
-face_encodings = face_recognition.face_encodings(image, face_locations)
-```
-
-**New Code (DeepFace):**
-```python
-results = DeepFace.represent(
- img_path=photo_path,
- model_name=self.model_name, # 'ArcFace'
- detector_backend=self.detector_backend, # 'retinaface'
- enforce_detection=DEEPFACE_ENFORCE_DETECTION, # False
- align=DEEPFACE_ALIGN_FACES # True
-)
-
-for result in results:
- facial_area = result.get('facial_area', {})
- face_confidence = result.get('face_confidence', 0.0)
- embedding = np.array(result['embedding']) # 512-dim
-
- location = {
- 'x': facial_area.get('x', 0),
- 'y': facial_area.get('y', 0),
- 'w': facial_area.get('w', 0),
- 'h': facial_area.get('h', 0)
- }
-```
-
-**Benefits:**
-- ✅ State-of-the-art face detection (RetinaFace)
-- ✅ Best-in-class recognition model (ArcFace)
-- ✅ 512-dimensional embeddings (4x more detailed than face_recognition)
-- ✅ Face confidence scores from detector
-- ✅ Automatic face alignment for better accuracy
-
----
-
-### 2. ✅ Updated Location Format Handling
-
-**Challenge:** DeepFace uses `{x, y, w, h}` format, face_recognition used `(top, right, bottom, left)` tuple.
-
-**Solution:** Dual-format support in `_extract_face_crop()`:
-
-```python
-# Parse location from string format
-if isinstance(location, str):
- import ast
- location = ast.literal_eval(location)
-
-# Handle both DeepFace dict format and legacy tuple format
-if isinstance(location, dict):
- # DeepFace format: {x, y, w, h}
- left = location.get('x', 0)
- top = location.get('y', 0)
- width = location.get('w', 0)
- height = location.get('h', 0)
- right = left + width
- bottom = top + height
-else:
- # Legacy face_recognition format: (top, right, bottom, left)
- top, right, bottom, left = location
-```
-
-**Benefits:**
-- ✅ Supports new DeepFace format
-- ✅ Backward compatible (can read old data if migrating)
-- ✅ Both formats work in face crop extraction
-
----
-
-### 3. ✅ Implemented Cosine Similarity
-
-**Why:** DeepFace embeddings work better with cosine similarity than Euclidean distance.
-
-**New Method:** `_calculate_cosine_similarity()`
-
-```python
-def _calculate_cosine_similarity(self, encoding1: np.ndarray, encoding2: np.ndarray) -> float:
- """Calculate cosine similarity distance between two face encodings
-
- Returns distance value (0 = identical, 2 = opposite) for compatibility.
- Uses cosine similarity internally which is better for DeepFace embeddings.
- """
- # Ensure encodings are numpy arrays
- enc1 = np.array(encoding1).flatten()
- enc2 = np.array(encoding2).flatten()
-
- # Check if encodings have the same length
- if len(enc1) != len(enc2):
- return 2.0 # Maximum distance on mismatch
-
- # Normalize encodings
- enc1_norm = enc1 / (np.linalg.norm(enc1) + 1e-8)
- enc2_norm = enc2 / (np.linalg.norm(enc2) + 1e-8)
-
- # Calculate cosine similarity
- cosine_sim = np.dot(enc1_norm, enc2_norm)
- cosine_sim = np.clip(cosine_sim, -1.0, 1.0)
-
- # Convert to distance (0 = identical, 2 = opposite)
- distance = 1.0 - cosine_sim
-
- return distance
-```
-
-**Replaced in:** `find_similar_faces()` and all face matching code
-
-**Old:**
-```python
-distance = face_recognition.face_distance([target_encoding], other_enc)[0]
-```
-
-**New:**
-```python
-distance = self._calculate_cosine_similarity(target_encoding, other_enc)
-```
-
-**Benefits:**
-- ✅ Better matching accuracy for deep learning embeddings
-- ✅ More stable with high-dimensional vectors (512-dim)
-- ✅ Industry-standard metric for face recognition
-- ✅ Handles encoding length mismatches gracefully
-
----
-
-### 4. ✅ Updated Adaptive Tolerance for DeepFace
-
-**Why:** DeepFace has different distance characteristics than face_recognition.
-
-**Updated Method:** `_calculate_adaptive_tolerance()`
-
-```python
-def _calculate_adaptive_tolerance(self, base_tolerance: float, face_quality: float,
- match_confidence: float = None) -> float:
- """Calculate adaptive tolerance based on face quality and match confidence
-
- Note: For DeepFace, tolerance values are generally lower than face_recognition
- """
- # Start with base tolerance (e.g., 0.4 instead of 0.6 for DeepFace)
- tolerance = base_tolerance
-
- # Adjust based on face quality
- quality_factor = 0.9 + (face_quality * 0.2) # Range: 0.9 to 1.1
- tolerance *= quality_factor
-
- # Adjust based on match confidence if provided
- if match_confidence is not None:
- confidence_factor = 0.95 + (match_confidence * 0.1)
- tolerance *= confidence_factor
-
- # Ensure tolerance stays within reasonable bounds for DeepFace
- return max(0.2, min(0.6, tolerance)) # Lower range for DeepFace
-```
-
-**Changes:**
-- Base tolerance: 0.6 → 0.4
-- Max tolerance: 0.8 → 0.6
-- Min tolerance: 0.3 → 0.2
-
----
-
-## Encoding Size Change
-
-### Before (face_recognition):
-- **Dimensions:** 128 floats
-- **Storage:** 1,024 bytes per encoding (128 × 8)
-- **Model:** dlib ResNet
-
-### After (DeepFace ArcFace):
-- **Dimensions:** 512 floats
-- **Storage:** 4,096 bytes per encoding (512 × 8)
-- **Model:** ArcFace (state-of-the-art)
-
-**Impact:** 4x larger encodings, but significantly better accuracy!
-
----
-
-## Test Results
-
-**File:** `tests/test_phase3_deepface.py`
-
-### All Tests Passing: 5/5
-
-```
-✅ PASS: DeepFace Import
-✅ PASS: DeepFace Detection
-✅ PASS: Cosine Similarity
-✅ PASS: Location Format Handling
-✅ PASS: End-to-End Processing
-
-Tests passed: 5/5
-```
-
-### Detailed Test Coverage:
-
-1. **DeepFace Import**
- - DeepFace 0.0.95 imported successfully
- - All dependencies available
-
-2. **DeepFace Detection**
- - Tested with real photos
- - Found 4 faces in test image
- - Verified 512-dimensional encodings
- - Correct facial_area format (x, y, w, h)
-
-3. **Cosine Similarity**
- - Identical encodings: distance = 0.000000 ✅
- - Different encodings: distance = 0.252952 ✅
- - Mismatched lengths: distance = 2.000000 (max) ✅
-
-4. **Location Format Handling**
- - Dict format (DeepFace): ✅
- - Tuple format (legacy): ✅
- - Conversion between formats: ✅
-
-5. **End-to-End Processing**
- - Added photo to database ✅
- - Processed with DeepFace ✅
- - Found 4 faces ✅
- - Stored 512-dim encodings ✅
-
----
-
-## File Changes Summary
-
-### Modified Files:
-1. **`src/core/face_processing.py`** - Complete DeepFace integration
- - Added DeepFace import (with fallback)
- - Replaced `process_faces()` method
- - Updated `_extract_face_crop()` (2 instances)
- - Added `_calculate_cosine_similarity()` method
- - Updated `_calculate_adaptive_tolerance()` method
- - Replaced all face_distance calls with cosine similarity
-
-### New Files:
-1. **`tests/test_phase3_deepface.py`** - Comprehensive test suite (5 tests)
-2. **`PHASE3_COMPLETE.md`** - This document
-
-### Lines Changed:
-- ~150 lines modified
-- ~60 new lines added
-- Total: ~210 lines of changes
-
----
-
-## Migration Requirements
-
-⚠️ **IMPORTANT:** Due to encoding size change, you MUST migrate your database!
-
-### Option 1: Fresh Start (Recommended)
-```bash
-cd /home/ladmin/Code/punimtag
-source venv/bin/activate
-python3 scripts/migrate_to_deepface.py
-```
-Then re-add and re-process all photos.
-
-### Option 2: Keep Old Data (Not Supported)
-Old 128-dim encodings are incompatible with new 512-dim encodings. Migration not possible.
-
----
-
-## Performance Characteristics
-
-### Detection Speed:
-| Detector | Speed | Accuracy |
-|----------|-------|----------|
-| RetinaFace | Medium | ⭐⭐⭐⭐⭐ Best |
-| MTCNN | Fast | ⭐⭐⭐⭐ Good |
-| OpenCV | Fastest | ⭐⭐⭐ Fair |
-| SSD | Fast | ⭐⭐⭐⭐ Good |
-
-### Recognition Speed:
-- **ArcFace:** Medium speed, best accuracy
-- **Processing:** ~2-3x slower than face_recognition
-- **Matching:** Similar speed (cosine similarity is fast)
-
-### Accuracy Improvements:
-- ✅ Better detection in difficult conditions
-- ✅ More robust to pose variations
-- ✅ Better handling of partial faces
-- ✅ Superior cross-age recognition
-- ✅ Lower false positive rate
-
----
-
-## What Was Removed
-
-### face_recognition Library References:
-- ❌ `face_recognition.load_image_file()`
-- ❌ `face_recognition.face_locations()`
-- ❌ `face_recognition.face_encodings()`
-- ❌ `face_recognition.face_distance()`
-
-All replaced with DeepFace and custom implementations.
-
----
-
-## Backward Compatibility
-
-### NOT Backward Compatible:
-- ❌ Old encodings (128-dim) cannot be used
-- ❌ Database must be migrated
-- ❌ All faces need to be re-processed
-
-### Still Compatible:
-- ✅ Old location format can be read (dual format support)
-- ✅ Database schema is backward compatible (new columns have defaults)
-- ✅ API signatures unchanged (same method names and parameters)
-
----
-
-## Configuration Constants Used
-
-From `config.py`:
-```python
-DEEPFACE_DETECTOR_BACKEND = "retinaface"
-DEEPFACE_MODEL_NAME = "ArcFace"
-DEEPFACE_ENFORCE_DETECTION = False
-DEEPFACE_ALIGN_FACES = True
-DEFAULT_FACE_TOLERANCE = 0.4 # Lower for DeepFace
-```
-
-All configurable via GUI in Phase 2!
-
----
-
-## Run Tests
-
-```bash
-cd /home/ladmin/Code/punimtag
-source venv/bin/activate
-python3 tests/test_phase3_deepface.py
-```
-
-Expected: All 5 tests pass ✅
-
----
-
-## Real-World Testing
-
-Tested with actual photos:
-- ✅ Detected 4 faces in demo photo
-- ✅ Generated 512-dim encodings
-- ✅ Stored with correct format
-- ✅ Face confidence scores recorded
-- ✅ Quality scores calculated
-- ✅ Face crops extracted successfully
-
----
-
-## Validation Checklist
-
-- [x] DeepFace imported and working
-- [x] Face detection with DeepFace functional
-- [x] 512-dimensional encodings generated
-- [x] Cosine similarity implemented
-- [x] Location format handling (dict & tuple)
-- [x] Face crop extraction updated
-- [x] Adaptive tolerance adjusted for DeepFace
-- [x] All face_recognition references removed from processing
-- [x] All tests passing (5/5)
-- [x] No linter errors
-- [x] Real photo processing tested
-- [x] Documentation complete
-
----
-
-## Known Limitations
-
-1. **Encoding Migration:** Cannot migrate old 128-dim encodings to 512-dim
-2. **Performance:** ~2-3x slower than face_recognition (worth it for accuracy!)
-3. **Model Downloads:** First run downloads models (~100MB+)
-4. **Memory:** Higher memory usage due to larger encodings
-5. **GPU:** Not using GPU acceleration yet (future optimization)
-
----
-
-## Future Optimizations (Optional)
-
-- [ ] GPU acceleration for faster processing
-- [ ] Batch processing for multiple images at once
-- [ ] Model caching to reduce memory
-- [ ] Multi-threading for parallel processing
-- [ ] Face detection caching
-
----
-
-## Key Metrics
-
-- **Tests Created:** 5 comprehensive tests
-- **Test Pass Rate:** 100% (5/5)
-- **Code Modified:** ~210 lines
-- **Encoding Size:** 128 → 512 dimensions (+300%)
-- **Storage Per Encoding:** 1KB → 4KB (+300%)
-- **Accuracy Improvement:** Significant (subjective)
-- **Processing Speed:** ~2-3x slower (acceptable)
-
----
-
-## Error Handling
-
-### Graceful Fallbacks:
-- ✅ No faces detected: Mark as processed, continue
-- ✅ Image load error: Skip photo, log error
-- ✅ Encoding length mismatch: Return max distance
-- ✅ DeepFace import failure: Warning message (graceful degradation)
-
-### Robust Error Messages:
-```python
-try:
- from deepface import DeepFace
- DEEPFACE_AVAILABLE = True
-except ImportError:
- DEEPFACE_AVAILABLE = False
- print("⚠️ Warning: DeepFace not available, some features may not work")
-```
-
----
-
-## References
-
-- Migration Plan: `.notes/deepface_migration_plan.md`
-- Phase 1 Complete: `PHASE1_COMPLETE.md`
-- Phase 2 Complete: `PHASE2_COMPLETE.md`
-- Architecture: `docs/ARCHITECTURE.md`
-- Working Example: `tests/test_deepface_gui.py`
-- Test Results: Run `python3 tests/test_phase3_deepface.py`
-
----
-
-## Next Steps (Optional Future Phases)
-
-The core migration is **COMPLETE**! Optional future enhancements:
-
-### Phase 4: GUI Updates (Optional)
-- Update all GUI panels for new features
-- Add visual indicators for detector/model
-- Show face confidence in UI
-
-### Phase 5: Performance Optimization (Optional)
-- GPU acceleration
-- Batch processing
-- Caching improvements
-
-### Phase 6: Advanced Features (Optional)
-- Age estimation
-- Emotion detection
-- Face clustering (unsupervised)
-- Multiple face comparison modes
-
----
-
-**Phase 3 Status: ✅ COMPLETE - DeepFace Migration SUCCESSFUL!**
-
-The system now uses state-of-the-art face detection and recognition. All core functionality has been migrated from face_recognition to DeepFace with superior accuracy and modern deep learning models.
-
-**🎉 Congratulations! The PunimTag system is now powered by DeepFace! 🎉**
-
-
diff --git a/docs/PHASE4_COMPLETE.md b/docs/PHASE4_COMPLETE.md
deleted file mode 100644
index 1379489..0000000
--- a/docs/PHASE4_COMPLETE.md
+++ /dev/null
@@ -1,572 +0,0 @@
-# Phase 4 Implementation Complete: GUI Integration for DeepFace
-
-**Date:** October 16, 2025
-**Status:** ✅ COMPLETE
-**All Tests:** PASSING (5/5)
-
----
-
-## Executive Summary
-
-Phase 4 of the DeepFace migration has been successfully completed! This phase focused on **GUI integration updates** to properly handle DeepFace metadata including face confidence scores, detector backend information, and the new dictionary-based location format. All three main GUI panels (Identify, Auto-Match, and Modify) have been updated to display and utilize the DeepFace-specific information.
-
----
-
-## Major Changes Implemented
-
-### 1. ✅ Dashboard GUI - DeepFace Settings Integration
-
-**File:** `src/gui/dashboard_gui.py`
-
-**Status:** Already implemented in previous phases
-
-The Process panel in the dashboard already includes:
-- **Face Detector Selection:** Dropdown to choose between RetinaFace, MTCNN, OpenCV, and SSD
-- **Recognition Model Selection:** Dropdown to choose between ArcFace, Facenet, Facenet512, and VGG-Face
-- **Settings Passthrough:** Selected detector and model are passed to FaceProcessor during face processing
-
-**Code Location:** Lines 1695-1719
-
-```python
-# DeepFace Settings Section
-deepface_frame = ttk.LabelFrame(form_frame, text="DeepFace Settings", padding="15")
-deepface_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 15))
-
-# Detector Backend Selection
-self.detector_var = tk.StringVar(value=DEEPFACE_DETECTOR_BACKEND)
-detector_combo = ttk.Combobox(deepface_frame, textvariable=self.detector_var,
- values=DEEPFACE_DETECTOR_OPTIONS,
- state="readonly", width=12)
-
-# Model Selection
-self.model_var = tk.StringVar(value=DEEPFACE_MODEL_NAME)
-model_combo = ttk.Combobox(deepface_frame, textvariable=self.model_var,
- values=DEEPFACE_MODEL_OPTIONS,
- state="readonly", width=12)
-```
-
-**Settings are passed to FaceProcessor:** Lines 2047-2055
-
-```python
-# Get selected detector and model settings
-detector = getattr(self, 'detector_var', None)
-model = getattr(self, 'model_var', None)
-detector_backend = detector.get() if detector else None
-model_name = model.get() if model else None
-
-# Run the actual processing with DeepFace settings
-result = self.on_process(limit_value, self._process_stop_event, progress_callback,
- detector_backend, model_name)
-```
-
----
-
-### 2. ✅ Identify Panel - DeepFace Metadata Display
-
-**File:** `src/gui/identify_panel.py`
-
-**Changes Made:**
-
-#### Updated Database Query (Line 445-451)
-Added DeepFace metadata columns to the face retrieval query:
-
-```python
-query = '''
- SELECT f.id, f.photo_id, p.path, p.filename, f.location,
- f.face_confidence, f.quality_score, f.detector_backend, f.model_name
- FROM faces f
- JOIN photos p ON f.photo_id = p.id
- WHERE f.person_id IS NULL
-'''
-```
-
-**Before:** Retrieved 5 fields (id, photo_id, path, filename, location)
-**After:** Retrieved 9 fields (added face_confidence, quality_score, detector_backend, model_name)
-
-#### Updated Tuple Unpacking (Lines 604, 1080, and others)
-Changed all tuple unpacking from 5 elements to 9 elements:
-
-```python
-# Before:
-face_id, photo_id, photo_path, filename, location = self.current_faces[self.current_face_index]
-
-# After:
-face_id, photo_id, photo_path, filename, location, face_conf, quality, detector, model = self.current_faces[self.current_face_index]
-```
-
-#### Enhanced Info Display (Lines 606-614)
-Added DeepFace metadata to the info label:
-
-```python
-info_text = f"Face {self.current_face_index + 1} of {len(self.current_faces)} - {filename}"
-if face_conf is not None and face_conf > 0:
- info_text += f" | Detection: {face_conf*100:.1f}%"
-if quality is not None:
- info_text += f" | Quality: {quality*100:.0f}%"
-if detector:
- info_text += f" | {detector}/{model}" if model else f" | {detector}"
-self.components['info_label'].config(text=info_text)
-```
-
-**User-Facing Improvement:**
-Users now see face detection confidence and quality scores in the identify panel, helping them understand which faces are higher quality for identification.
-
-**Example Display:**
-`Face 1 of 25 - photo.jpg | Detection: 95.0% | Quality: 85% | retinaface/ArcFace`
-
----
-
-### 3. ✅ Auto-Match Panel - DeepFace Metadata Integration
-
-**File:** `src/gui/auto_match_panel.py`
-
-**Changes Made:**
-
-#### Updated Database Query (Lines 215-220)
-Added DeepFace metadata to identified faces query:
-
-```python
-SELECT f.id, f.person_id, f.photo_id, f.location, p.filename, f.quality_score,
- f.face_confidence, f.detector_backend, f.model_name
-FROM faces f
-JOIN photos p ON f.photo_id = p.id
-WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3
-ORDER BY f.person_id, f.quality_score DESC
-```
-
-**Before:** Retrieved 6 fields
-**After:** Retrieved 9 fields (added face_confidence, detector_backend, model_name)
-
-**Note:** The auto-match panel uses tuple indexing (face[0], face[1], etc.) rather than unpacking, so no changes were needed to the unpacking code. The DeepFace metadata is stored in the database and available for future enhancements.
-
-**Existing Features:**
-- Already displays confidence percentages (calculated from cosine similarity)
-- Already uses quality scores for ranking matches
-- Location format already handled by `_extract_face_crop()` method
-
----
-
-### 4. ✅ Modify Panel - DeepFace Metadata Integration
-
-**File:** `src/gui/modify_panel.py`
-
-**Changes Made:**
-
-#### Updated Database Query (Lines 481-488)
-Added DeepFace metadata to person faces query:
-
-```python
-cursor.execute("""
- SELECT f.id, f.photo_id, p.path, p.filename, f.location,
- f.face_confidence, f.quality_score, f.detector_backend, f.model_name
- FROM faces f
- JOIN photos p ON f.photo_id = p.id
- WHERE f.person_id = ?
- ORDER BY p.filename
-""", (person_id,))
-```
-
-**Before:** Retrieved 5 fields
-**After:** Retrieved 9 fields (added face_confidence, quality_score, detector_backend, model_name)
-
-#### Updated Tuple Unpacking (Line 531)
-Changed tuple unpacking in the face display loop:
-
-```python
-# Before:
-for i, (face_id, photo_id, photo_path, filename, location) in enumerate(faces):
-
-# After:
-for i, (face_id, photo_id, photo_path, filename, location, face_conf, quality, detector, model) in enumerate(faces):
-```
-
-**Note:** The modify panel focuses on person management, so the additional metadata is available but not currently displayed in the UI. Future enhancements could add face quality indicators to the face grid.
-
----
-
-## Location Format Compatibility
-
-All three panels now work seamlessly with **both** location formats:
-
-### DeepFace Dict Format (New)
-```python
-location = "{'x': 100, 'y': 150, 'w': 80, 'h': 90}"
-```
-
-### Legacy Tuple Format (Old - for backward compatibility)
-```python
-location = "(150, 180, 240, 100)" # (top, right, bottom, left)
-```
-
-The `FaceProcessor._extract_face_crop()` method (lines 663-734 in `face_processing.py`) handles both formats automatically:
-
-```python
-# Parse location from string format
-if isinstance(location, str):
- import ast
- location = ast.literal_eval(location)
-
-# Handle both DeepFace dict format and legacy tuple format
-if isinstance(location, dict):
- # DeepFace format: {x, y, w, h}
- left = location.get('x', 0)
- top = location.get('y', 0)
- width = location.get('w', 0)
- height = location.get('h', 0)
- right = left + width
- bottom = top + height
-else:
- # Legacy face_recognition format: (top, right, bottom, left)
- top, right, bottom, left = location
-```
-
----
-
-## Test Results
-
-**File:** `tests/test_phase4_gui.py`
-
-### All Tests Passing: 5/5
-
-```
-✅ PASS: Database Schema
-✅ PASS: Face Data Retrieval
-✅ PASS: Location Format Handling
-✅ PASS: FaceProcessor Configuration
-✅ PASS: GUI Panel Compatibility
-
-Tests passed: 5/5
-```
-
-### Test Coverage:
-
-1. **Database Schema Test**
- - Verified all DeepFace columns exist in the `faces` table
- - Confirmed correct data types for each column
- - **Columns verified:** id, photo_id, person_id, encoding, location, confidence, quality_score, detector_backend, model_name, face_confidence
-
-2. **Face Data Retrieval Test**
- - Created test face with DeepFace metadata
- - Retrieved face data using GUI panel query patterns
- - Verified all metadata fields are correctly stored and retrieved
- - **Metadata verified:** face_confidence=0.95, quality_score=0.85, detector='retinaface', model='ArcFace'
-
-3. **Location Format Handling Test**
- - Tested parsing of DeepFace dict format
- - Tested parsing of legacy tuple format
- - Verified bidirectional conversion between formats
- - **Both formats work correctly**
-
-4. **FaceProcessor Configuration Test**
- - Verified default detector and model settings
- - Tested custom detector and model configuration
- - Confirmed settings are properly passed to FaceProcessor
- - **Default:** retinaface/ArcFace
- - **Custom:** mtcnn/Facenet512 ✓
-
-5. **GUI Panel Compatibility Test**
- - Simulated identify_panel query and unpacking
- - Simulated auto_match_panel query and tuple indexing
- - Simulated modify_panel query and unpacking
- - **All panels successfully unpack 9-field tuples**
-
----
-
-## File Changes Summary
-
-### Modified Files:
-
-1. **`src/gui/identify_panel.py`** - Added DeepFace metadata display
- - Updated `_get_unidentified_faces()` query to include 4 new columns
- - Updated all tuple unpacking from 5 to 9 elements
- - Enhanced info label to display detection confidence, quality, and detector/model
- - **Lines modified:** ~15 locations (query, unpacking, display)
-
-2. **`src/gui/auto_match_panel.py`** - Added DeepFace metadata retrieval
- - Updated identified faces query to include 3 new columns
- - Metadata now stored and available for future use
- - **Lines modified:** ~6 lines (query only)
-
-3. **`src/gui/modify_panel.py`** - Added DeepFace metadata retrieval
- - Updated person faces query to include 4 new columns
- - Updated tuple unpacking from 5 to 9 elements
- - **Lines modified:** ~8 lines (query and unpacking)
-
-4. **`src/gui/dashboard_gui.py`** - No changes needed
- - DeepFace settings UI already implemented in Phase 2
- - Settings correctly passed to FaceProcessor during processing
-
-### New Files:
-
-1. **`tests/test_phase4_gui.py`** - Comprehensive integration test suite
- - 5 test functions covering all aspects of Phase 4
- - 100% pass rate
- - **Total:** ~530 lines of test code
-
-2. **`PHASE4_COMPLETE.md`** - This documentation file
-
----
-
-## Backward Compatibility
-
-### ✅ Fully Backward Compatible
-
-The Phase 4 changes maintain full backward compatibility:
-
-1. **Location Format:** Both dict and tuple formats are supported
-2. **Database Schema:** New columns have default values (NULL or 0.0)
-3. **Old Queries:** Will continue to work (just won't retrieve new metadata)
-4. **API Signatures:** No changes to method signatures in any panel
-
-### Migration Path
-
-For existing databases:
-1. Columns with default values are automatically added when database is initialized
-2. Old face records will have NULL or 0.0 for new DeepFace columns
-3. New faces processed with DeepFace will have proper metadata
-4. GUI panels handle both old (NULL) and new (populated) metadata gracefully
-
----
-
-## User-Facing Improvements
-
-### Identify Panel
-**Before:** Only showed filename
-**After:** Shows filename + detection confidence + quality score + detector/model
-
-**Example:**
-```
-Before: "Face 1 of 25 - photo.jpg"
-After: "Face 1 of 25 - photo.jpg | Detection: 95.0% | Quality: 85% | retinaface/ArcFace"
-```
-
-**Benefits:**
-- Users can see which faces were detected with high confidence
-- Quality scores help prioritize identification of best faces
-- Detector/model information provides transparency
-
-### Auto-Match Panel
-**Before:** Already showed confidence percentages (from similarity)
-**After:** Same display, but now has access to detection confidence and quality scores for future enhancements
-
-**Future Enhancement Opportunities:**
-- Display face detection confidence in addition to match confidence
-- Filter matches by minimum quality score
-- Show detector/model used for each face
-
-### Modify Panel
-**Before:** Grid of face thumbnails
-**After:** Same display, but metadata available for future enhancements
-
-**Future Enhancement Opportunities:**
-- Add quality score badges to face thumbnails
-- Sort faces by quality score
-- Filter faces by detector or model
-
----
-
-## Performance Impact
-
-### Minimal Performance Impact
-
-1. **Database Queries:**
- - Added 4 columns to SELECT statements
- - Negligible impact (microseconds)
- - No additional JOINs or complex operations
-
-2. **Memory Usage:**
- - 4 additional fields per face tuple
- - Each field is small (float or short string)
- - Impact: ~32 bytes per face (negligible)
-
-3. **UI Rendering:**
- - Info label now displays more text
- - No measurable impact on responsiveness
- - Text rendering is very fast
-
-**Conclusion:** Phase 4 changes have **no measurable performance impact**.
-
----
-
-## Configuration Settings
-
-### Available in `src/core/config.py`:
-
-```python
-# DeepFace Settings
-DEEPFACE_DETECTOR_BACKEND = "retinaface" # Options: retinaface, mtcnn, opencv, ssd
-DEEPFACE_MODEL_NAME = "ArcFace" # Best accuracy model
-DEEPFACE_DISTANCE_METRIC = "cosine" # For similarity calculation
-DEEPFACE_ENFORCE_DETECTION = False # Don't fail if no faces found
-DEEPFACE_ALIGN_FACES = True # Face alignment for better accuracy
-
-# DeepFace Options for GUI
-DEEPFACE_DETECTOR_OPTIONS = ["retinaface", "mtcnn", "opencv", "ssd"]
-DEEPFACE_MODEL_OPTIONS = ["ArcFace", "Facenet", "Facenet512", "VGG-Face"]
-
-# Face tolerance/threshold settings (adjusted for DeepFace)
-DEFAULT_FACE_TOLERANCE = 0.4 # Lower for DeepFace (was 0.6 for face_recognition)
-DEEPFACE_SIMILARITY_THRESHOLD = 60 # Minimum similarity percentage (0-100)
-```
-
-These settings are:
-- ✅ Configurable via GUI (Process panel dropdowns)
-- ✅ Used by FaceProcessor during face detection
-- ✅ Stored in database with each detected face
-- ✅ Displayed in GUI panels for transparency
-
----
-
-## Known Limitations
-
-### Current Limitations:
-
-1. **Modify Panel Display:** Face quality scores not yet displayed in the grid (metadata is stored and available)
-2. **Auto-Match Panel Display:** Detection confidence not yet shown separately from match confidence (metadata is stored and available)
-3. **No Filtering by Metadata:** Cannot yet filter faces by detector, model, or quality threshold in GUI
-
-### Future Enhancement Opportunities:
-
-1. **Quality-Based Filtering:**
- - Add quality score sliders to filter faces
- - Show only faces above a certain detection confidence
- - Filter by specific detector or model
-
-2. **Enhanced Visualizations:**
- - Add quality score badges to face thumbnails
- - Color-code faces by detection confidence
- - Show detector/model icons on faces
-
-3. **Batch Re-processing:**
- - Re-process faces with different detector/model
- - Compare results side-by-side
- - Keep best result automatically
-
-4. **Statistics Dashboard:**
- - Show distribution of detectors used
- - Display average quality scores
- - Compare performance of different models
-
----
-
-## Validation Checklist
-
-- [x] Dashboard has DeepFace detector/model selection UI
-- [x] Dashboard passes settings to FaceProcessor correctly
-- [x] Identify panel retrieves DeepFace metadata
-- [x] Identify panel displays detection confidence and quality
-- [x] Identify panel displays detector/model information
-- [x] Auto-match panel retrieves DeepFace metadata
-- [x] Auto-match panel handles new location format
-- [x] Modify panel retrieves DeepFace metadata
-- [x] Modify panel handles new location format
-- [x] Both location formats (dict and tuple) work correctly
-- [x] FaceProcessor accepts custom detector/model configuration
-- [x] Database schema has all DeepFace columns
-- [x] All queries include DeepFace metadata
-- [x] All tuple unpacking updated to 9 elements (where needed)
-- [x] Comprehensive test suite created and passing (5/5)
-- [x] No linter errors in modified files
-- [x] Backward compatibility maintained
-- [x] Documentation complete
-
----
-
-## Run Tests
-
-```bash
-cd /home/ladmin/Code/punimtag
-source venv/bin/activate
-python3 tests/test_phase4_gui.py
-```
-
-**Expected Output:** All 5 tests pass ✅
-
----
-
-## Migration Status
-
-### Phases Complete:
-
-| Phase | Status | Description |
-|-------|--------|-------------|
-| Phase 1 | ✅ Complete | Database schema updates with DeepFace columns |
-| Phase 2 | ✅ Complete | Configuration updates for DeepFace settings |
-| Phase 3 | ✅ Complete | Core face processing migration to DeepFace |
-| **Phase 4** | ✅ **Complete** | **GUI integration for DeepFace metadata** |
-
-### DeepFace Migration: **100% COMPLETE** 🎉
-
-All planned phases have been successfully implemented. The system now:
-- Uses DeepFace for face detection and recognition
-- Stores DeepFace metadata in the database
-- Displays DeepFace information in all GUI panels
-- Supports multiple detectors and models
-- Maintains backward compatibility
-
----
-
-## Key Metrics
-
-- **Tests Created:** 5 comprehensive integration tests
-- **Test Pass Rate:** 100% (5/5)
-- **Files Modified:** 3 GUI panel files
-- **New Files Created:** 2 (test suite + documentation)
-- **Lines Modified:** ~50 lines across all panels
-- **New Queries:** 3 updated SELECT statements
-- **Linting Errors:** 0
-- **Breaking Changes:** 0 (fully backward compatible)
-- **Performance Impact:** Negligible
-- **User-Visible Improvements:** Enhanced face information display
-
----
-
-## Next Steps (Optional Future Enhancements)
-
-The core DeepFace migration is complete. Optional future enhancements:
-
-### GUI Enhancements (Low Priority)
-- [ ] Display quality scores as badges in modify panel grid
-- [ ] Add quality score filtering sliders
-- [ ] Show detector/model icons on face thumbnails
-- [ ] Add statistics dashboard for DeepFace metrics
-
-### Performance Optimizations (Low Priority)
-- [ ] GPU acceleration for faster processing
-- [ ] Batch processing for multiple images
-- [ ] Face detection caching
-- [ ] Multi-threading for parallel processing
-
-### Advanced Features (Low Priority)
-- [ ] Side-by-side comparison of different detectors
-- [ ] Batch re-processing with new detector/model
-- [ ] Export DeepFace metadata to CSV
-- [ ] Import pre-computed DeepFace embeddings
-
----
-
-## References
-
-- Migration Plan: `.notes/deepface_migration_plan.md`
-- Phase 1 Complete: `PHASE1_COMPLETE.md`
-- Phase 2 Complete: `PHASE2_COMPLETE.md`
-- Phase 3 Complete: `PHASE3_COMPLETE.md`
-- Architecture: `docs/ARCHITECTURE.md`
-- Working Example: `tests/test_deepface_gui.py`
-- Test Results: Run `python3 tests/test_phase4_gui.py`
-
----
-
-**Phase 4 Status: ✅ COMPLETE - GUI Integration SUCCESSFUL!**
-
-All GUI panels now properly display and utilize DeepFace metadata. Users can see detection confidence scores, quality ratings, and detector/model information throughout the application. The migration from face_recognition to DeepFace is now 100% complete across all layers: database, core processing, and GUI.
-
-**🎉 Congratulations! The PunimTag DeepFace migration is fully complete! 🎉**
-
----
-
-**Document Version:** 1.0
-**Last Updated:** October 16, 2025
-**Author:** PunimTag Development Team
-**Status:** Final
-
diff --git a/docs/PHASE5_AND_6_COMPLETE.md b/docs/PHASE5_AND_6_COMPLETE.md
deleted file mode 100644
index 9e2e5c3..0000000
--- a/docs/PHASE5_AND_6_COMPLETE.md
+++ /dev/null
@@ -1,545 +0,0 @@
-# Phase 5 & 6 Implementation Complete: Dependencies and Testing
-
-**Date:** October 16, 2025
-**Status:** ✅ COMPLETE
-**All Tests:** PASSING (5/5)
-
----
-
-## Executive Summary
-
-Phases 5 and 6 of the DeepFace migration have been successfully completed! These phases focused on **dependency management** and **comprehensive integration testing** to ensure the entire DeepFace migration is production-ready.
-
----
-
-## Phase 5: Dependencies and Installation ✅ COMPLETE
-
-### 5.1 Requirements.txt Update
-
-**File:** `requirements.txt`
-
-**Status:** ✅ Already Complete
-
-The requirements file has been updated with all necessary DeepFace dependencies:
-
-```python
-# PunimTag Dependencies - DeepFace Implementation
-
-# Core Dependencies
-numpy>=1.21.0
-pillow>=8.0.0
-click>=8.0.0
-setuptools>=40.0.0
-
-# DeepFace and Deep Learning Stack
-deepface>=0.0.79
-tensorflow>=2.13.0
-opencv-python>=4.8.0
-retina-face>=0.0.13
-```
-
-**Removed (face_recognition dependencies):**
-- ❌ face-recognition==1.3.0
-- ❌ face-recognition-models==0.3.0
-- ❌ dlib>=20.0.0
-
-**Added (DeepFace dependencies):**
-- ✅ deepface>=0.0.79
-- ✅ tensorflow>=2.13.0
-- ✅ opencv-python>=4.8.0
-- ✅ retina-face>=0.0.13
-
----
-
-### 5.2 Migration Script
-
-**File:** `scripts/migrate_to_deepface.py`
-
-**Status:** ✅ Complete and Enhanced
-
-The migration script safely drops all existing tables and recreates them with the new DeepFace schema.
-
-**Key Features:**
-- ⚠️ Safety confirmation required (user must type "DELETE ALL DATA")
-- 🗑️ Drops all tables in correct order (respecting foreign keys)
-- 🔄 Reinitializes database with DeepFace schema
-- 📊 Provides clear next steps for users
-- ✅ Comprehensive error handling
-
-**Usage:**
-```bash
-cd /home/ladmin/Code/punimtag
-source venv/bin/activate
-python3 scripts/migrate_to_deepface.py
-```
-
-**Safety Features:**
-- Explicit user confirmation required
-- Lists all data that will be deleted
-- Handles errors gracefully
-- Provides rollback information
-
----
-
-## Phase 6: Testing and Validation ✅ COMPLETE
-
-### 6.1 Integration Test Suite
-
-**File:** `tests/test_deepface_integration.py`
-
-**Status:** ✅ Complete - All 5 Tests Passing
-
-Created comprehensive integration test suite covering all aspects of DeepFace integration.
-
-### Test Results: 5/5 PASSING ✅
-
-```
-✅ PASS: Face Detection
-✅ PASS: Face Matching
-✅ PASS: Metadata Storage
-✅ PASS: Configuration
-✅ PASS: Cosine Similarity
-
-Tests passed: 5/5
-Tests failed: 0/5
-```
-
----
-
-### Test 1: Face Detection ✅
-
-**What it tests:**
-- DeepFace can detect faces in photos
-- Face encodings are 512-dimensional (ArcFace standard)
-- Faces are stored correctly in database
-
-**Results:**
-- ✓ Detected 4 faces in test image
-- ✓ Encoding size: 4096 bytes (512 floats × 8 bytes)
-- ✓ All faces stored in database
-
-**Test Code:**
-```python
-def test_face_detection():
- """Test face detection with DeepFace"""
- db = DatabaseManager(":memory:", verbose=0)
- processor = FaceProcessor(db, verbose=1)
-
- # Add test photo
- photo_id = db.add_photo(test_image, filename, None)
-
- # Process faces
- count = processor.process_faces(limit=1)
-
- # Verify results
- stats = db.get_statistics()
- assert stats['total_faces'] > 0
- assert encoding_size == 512 * 8 # 4096 bytes
-```
-
----
-
-### Test 2: Face Matching ✅
-
-**What it tests:**
-- Face similarity calculation works
-- Multiple faces can be matched
-- Tolerance thresholds work correctly
-
-**Results:**
-- ✓ Processed 2 photos
-- ✓ Found 11 total faces
-- ✓ Similarity calculation working
-- ✓ Tolerance filtering working
-
-**Test Code:**
-```python
-def test_face_matching():
- """Test face matching with DeepFace"""
- # Process multiple photos
- processor.process_faces(limit=10)
-
- # Find similar faces
- faces = db.get_all_face_encodings()
- matches = processor.find_similar_faces(face_id, tolerance=0.4)
-
- # Verify matching works
- assert len(matches) >= 0
-```
-
----
-
-### Test 3: DeepFace Metadata Storage ✅
-
-**What it tests:**
-- face_confidence is stored correctly
-- quality_score is stored correctly
-- detector_backend is stored correctly
-- model_name is stored correctly
-
-**Results:**
-- ✓ Face Confidence: 1.0 (100%)
-- ✓ Quality Score: 0.687 (68.7%)
-- ✓ Detector Backend: retinaface
-- ✓ Model Name: ArcFace
-
-**Test Code:**
-```python
-def test_deepface_metadata():
- """Test DeepFace metadata storage and retrieval"""
- # Query face metadata
- cursor.execute("""
- SELECT face_confidence, quality_score, detector_backend, model_name
- FROM faces
- """)
-
- # Verify all metadata is present
- assert face_conf is not None
- assert quality is not None
- assert detector is not None
- assert model is not None
-```
-
----
-
-### Test 4: FaceProcessor Configuration ✅
-
-**What it tests:**
-- Default detector/model configuration
-- Custom detector/model configuration
-- Multiple backend combinations
-
-**Results:**
-- ✓ Default: retinaface/ArcFace
-- ✓ Custom: mtcnn/Facenet512
-- ✓ Custom: opencv/VGG-Face
-- ✓ Custom: ssd/ArcFace
-
-**Test Code:**
-```python
-def test_configuration():
- """Test FaceProcessor configuration"""
- # Test default
- processor = FaceProcessor(db, verbose=0)
- assert processor.detector_backend == DEEPFACE_DETECTOR_BACKEND
-
- # Test custom
- processor = FaceProcessor(db, verbose=0,
- detector_backend='mtcnn',
- model_name='Facenet512')
- assert processor.detector_backend == 'mtcnn'
- assert processor.model_name == 'Facenet512'
-```
-
----
-
-### Test 5: Cosine Similarity Calculation ✅
-
-**What it tests:**
-- Identical encodings have distance near 0
-- Different encodings have reasonable distance
-- Mismatched encoding lengths return max distance (2.0)
-
-**Results:**
-- ✓ Identical encodings: distance = 0.000000 (perfect match)
-- ✓ Different encodings: distance = 0.235044 (different)
-- ✓ Mismatched lengths: distance = 2.000000 (max distance)
-
-**Test Code:**
-```python
-def test_cosine_similarity():
- """Test cosine similarity calculation"""
- processor = FaceProcessor(db, verbose=0)
-
- # Test identical encodings
- encoding1 = np.random.rand(512).astype(np.float64)
- encoding2 = encoding1.copy()
- distance = processor._calculate_cosine_similarity(encoding1, encoding2)
- assert distance < 0.01 # Should be very close to 0
-
- # Test mismatched lengths
- encoding3 = np.random.rand(128).astype(np.float64)
- distance = processor._calculate_cosine_similarity(encoding1, encoding3)
- assert distance == 2.0 # Max distance
-```
-
----
-
-## Validation Checklist
-
-### Phase 5: Dependencies ✅
-- [x] requirements.txt updated with DeepFace dependencies
-- [x] face_recognition dependencies removed
-- [x] Migration script created
-- [x] Migration script tested
-- [x] Clear user instructions provided
-- [x] Safety confirmations implemented
-
-### Phase 6: Testing ✅
-- [x] Integration test suite created
-- [x] Face detection tested
-- [x] Face matching tested
-- [x] Metadata storage tested
-- [x] Configuration tested
-- [x] Cosine similarity tested
-- [x] All tests passing (5/5)
-- [x] Test output clear and informative
-
----
-
-## File Changes Summary
-
-### New Files Created:
-
-1. **`tests/test_deepface_integration.py`** - Comprehensive integration test suite
- - 5 test functions
- - ~400 lines of test code
- - 100% pass rate
- - Clear output and error messages
-
-### Files Verified/Updated:
-
-1. **`requirements.txt`** - Dependencies already updated
- - DeepFace stack complete
- - face_recognition removed
- - All necessary packages included
-
-2. **`scripts/migrate_to_deepface.py`** - Migration script already exists
- - Enhanced safety features
- - Clear user instructions
- - Proper error handling
-
----
-
-## Running the Tests
-
-### Run Integration Tests:
-```bash
-cd /home/ladmin/Code/punimtag
-source venv/bin/activate
-python3 tests/test_deepface_integration.py
-```
-
-**Expected Output:**
-```
-======================================================================
-DEEPFACE INTEGRATION TEST SUITE
-======================================================================
-
-✅ PASS: Face Detection
-✅ PASS: Face Matching
-✅ PASS: Metadata Storage
-✅ PASS: Configuration
-✅ PASS: Cosine Similarity
-
-Tests passed: 5/5
-Tests failed: 0/5
-
-🎉 ALL TESTS PASSED! DeepFace integration is working correctly!
-```
-
-### Run All Test Suites:
-```bash
-# Phase 1 Test
-python3 tests/test_phase1_schema.py
-
-# Phase 2 Test
-python3 tests/test_phase2_config.py
-
-# Phase 3 Test
-python3 tests/test_phase3_deepface.py
-
-# Phase 4 Test
-python3 tests/test_phase4_gui.py
-
-# Integration Test (Phase 6)
-python3 tests/test_deepface_integration.py
-```
-
----
-
-## Dependencies Installation
-
-### Fresh Installation:
-```bash
-cd /home/ladmin/Code/punimtag
-python3 -m venv venv
-source venv/bin/activate
-pip install -r requirements.txt
-```
-
-### Verify Installation:
-```bash
-python3 -c "
-import deepface
-import tensorflow
-import cv2
-import retina_face
-print('✅ All DeepFace dependencies installed correctly')
-print(f'DeepFace version: {deepface.__version__}')
-print(f'TensorFlow version: {tensorflow.__version__}')
-print(f'OpenCV version: {cv2.__version__}')
-"
-```
-
----
-
-## Migration Status
-
-### Complete Phases:
-
-| Phase | Status | Description |
-|-------|--------|-------------|
-| Phase 1 | ✅ Complete | Database schema updates |
-| Phase 2 | ✅ Complete | Configuration updates |
-| Phase 3 | ✅ Complete | Core face processing migration |
-| Phase 4 | ✅ Complete | GUI integration updates |
-| **Phase 5** | ✅ **Complete** | **Dependencies and installation** |
-| **Phase 6** | ✅ **Complete** | **Testing and validation** |
-
-### Overall Migration: **100% COMPLETE** 🎉
-
-All technical phases of the DeepFace migration are now complete!
-
----
-
-## Key Achievements
-
-### Phase 5 Achievements:
-- ✅ Clean dependency list with only necessary packages
-- ✅ Safe migration script with user confirmation
-- ✅ Clear documentation for users
-- ✅ No leftover face_recognition dependencies
-
-### Phase 6 Achievements:
-- ✅ Comprehensive test coverage (5 test functions)
-- ✅ 100% test pass rate (5/5)
-- ✅ Tests cover all critical functionality
-- ✅ Clear, informative test output
-- ✅ Easy to run and verify
-
----
-
-## Test Coverage
-
-### What's Tested:
-- ✅ Face detection with DeepFace
-- ✅ Encoding size (512-dimensional)
-- ✅ Face matching and similarity
-- ✅ Metadata storage (confidence, quality, detector, model)
-- ✅ Configuration with different backends
-- ✅ Cosine similarity calculation
-- ✅ Error handling for missing data
-- ✅ Edge cases (mismatched encoding lengths)
-
-### What's Verified:
-- ✅ All DeepFace dependencies work
-- ✅ Database schema supports DeepFace
-- ✅ Face processing produces correct encodings
-- ✅ Metadata is stored and retrieved correctly
-- ✅ Configuration is applied correctly
-- ✅ Similarity calculations are accurate
-
----
-
-## Performance Notes
-
-### Test Execution Time:
-- All 5 tests complete in ~20-30 seconds
-- Face detection: ~5 seconds per image (first run)
-- Face matching: ~10 seconds for 2 images
-- Metadata/configuration tests: instant
-
-### Resource Usage:
-- Memory: ~500MB for TensorFlow/DeepFace
-- Disk: ~1GB for models (downloaded on first run)
-- CPU: Moderate usage during face processing
-
----
-
-## Known Limitations
-
-### Current Test Limitations:
-1. **Demo Photos Required:** Tests require demo_photos directory
-2. **First Run Slow:** Model download on first execution (~100MB)
-3. **In-Memory Database:** Tests use temporary database (don't affect real data)
-4. **Limited Test Images:** Only 2 test images used
-
-### Future Test Enhancements:
-- [ ] Test with more diverse images
-- [ ] Test all detector backends (retinaface, mtcnn, opencv, ssd)
-- [ ] Test all model options (ArcFace, Facenet, Facenet512, VGG-Face)
-- [ ] Performance benchmarks
-- [ ] GPU acceleration tests
-- [ ] Batch processing tests
-
----
-
-## Production Readiness
-
-### ✅ Ready for Production
-
-The system is now fully production-ready with:
-- ✅ Complete DeepFace integration
-- ✅ Comprehensive test coverage
-- ✅ All tests passing
-- ✅ Safe migration path
-- ✅ Clear documentation
-- ✅ No breaking changes
-- ✅ Backward compatibility
-- ✅ Performance validated
-
----
-
-## Next Steps (Optional)
-
-### Optional Enhancements:
-1. **Performance Optimization**
- - GPU acceleration
- - Batch processing
- - Model caching
- - Multi-threading
-
-2. **Additional Testing**
- - Load testing
- - Stress testing
- - Edge case testing
- - Performance benchmarks
-
-3. **Documentation**
- - User guide for DeepFace features
- - API documentation
- - Migration guide for existing users
- - Troubleshooting guide
-
----
-
-## References
-
-- Migration Plan: `.notes/deepface_migration_plan.md`
-- Phase 1 Complete: `PHASE1_COMPLETE.md`
-- Phase 2 Complete: `PHASE2_COMPLETE.md`
-- Phase 3 Complete: `PHASE3_COMPLETE.md`
-- Phase 4 Complete: `PHASE4_COMPLETE.md`
-- Architecture: `docs/ARCHITECTURE.md`
-- Requirements: `requirements.txt`
-- Migration Script: `scripts/migrate_to_deepface.py`
-- Integration Tests: `tests/test_deepface_integration.py`
-
----
-
-**Phase 5 & 6 Status: ✅ COMPLETE - Dependencies and Testing SUCCESSFUL!**
-
-All dependencies are properly managed, and comprehensive testing confirms that the entire DeepFace migration is working correctly. The system is production-ready!
-
-**🎉 The complete DeepFace migration is now FINISHED! 🎉**
-
-All 6 technical phases (Phases 1-6) have been successfully implemented and tested. The PunimTag system now uses state-of-the-art DeepFace technology with full test coverage and production-ready code.
-
----
-
-**Document Version:** 1.0
-**Last Updated:** October 16, 2025
-**Author:** PunimTag Development Team
-**Status:** Final
-
diff --git a/docs/PHASE6_COMPLETE.md b/docs/PHASE6_COMPLETE.md
deleted file mode 100644
index e31c613..0000000
--- a/docs/PHASE6_COMPLETE.md
+++ /dev/null
@@ -1,436 +0,0 @@
-# Phase 6: Testing and Validation - COMPLETE ✅
-
-**Completion Date:** October 16, 2025
-**Phase Status:** ✅ COMPLETE
-**Test Results:** 10/10 PASSED (100%)
-
----
-
-## Phase 6 Summary
-
-Phase 6 of the DeepFace migration focused on comprehensive testing and validation of the integration. This phase has been successfully completed with all automated tests passing and comprehensive documentation created.
-
----
-
-## Deliverables
-
-### 1. Enhanced Test Suite ✅
-
-**File:** `tests/test_deepface_integration.py`
-
-Enhanced the existing test suite with 5 additional tests:
-
-#### New Tests Added:
-1. **Test 6: Database Schema Validation**
- - Validates new DeepFace columns in faces table
- - Validates new columns in person_encodings table
- - Confirms data types and structure
-
-2. **Test 7: Face Location Format**
- - Validates DeepFace dict format {x, y, w, h}
- - Confirms location parsing
- - Verifies format consistency
-
-3. **Test 8: Performance Benchmark**
- - Measures face detection speed
- - Measures similarity search speed
- - Provides performance metrics
-
-4. **Test 9: Adaptive Tolerance**
- - Tests quality-based tolerance adjustment
- - Validates bounds enforcement [0.2, 0.6]
- - Confirms calculation logic
-
-5. **Test 10: Multiple Detectors**
- - Tests opencv detector
- - Tests ssd detector
- - Compares detector results
-
-#### Total Test Suite:
-- **10 comprehensive tests**
-- **100% automated**
-- **~30 second execution time**
-- **All tests passing**
-
----
-
-### 2. Validation Checklist ✅
-
-**File:** `PHASE6_VALIDATION_CHECKLIST.md`
-
-Created comprehensive validation checklist covering:
-
-- ✅ Face Detection Validation (14 items)
-- ✅ Face Matching Validation (13 items)
-- ✅ Database Validation (19 items)
-- ⏳ GUI Integration Validation (23 items - manual testing)
-- ✅ Performance Validation (10 items)
-- ✅ Configuration Validation (11 items)
-- ✅ Error Handling Validation (9 items)
-- ⏳ Documentation Validation (11 items - in progress)
-- ✅ Test Suite Validation (13 items)
-- ⏳ Deployment Validation (13 items - pending)
-
-**Total:** 136 validation items tracked
-
----
-
-### 3. Test Documentation ✅
-
-**File:** `tests/README_TESTING.md`
-
-Created comprehensive testing guide including:
-
-1. **Test Suite Structure**
- - File organization
- - Test categories
- - Execution instructions
-
-2. **Detailed Test Documentation**
- - Purpose and scope of each test
- - Pass/fail criteria
- - Failure modes
- - Expected results
-
-3. **Usage Guide**
- - Running tests
- - Interpreting results
- - Troubleshooting
- - Adding new tests
-
-4. **Performance Benchmarks**
- - Expected performance metrics
- - Hardware references
- - Optimization tips
-
----
-
-### 4. Test Results Report ✅
-
-**File:** `PHASE6_TEST_RESULTS.md`
-
-Documented complete test execution results:
-
-- **Test Environment:** Full specifications
-- **Execution Details:** Timing and metrics
-- **Individual Test Results:** Detailed for each test
-- **Summary Statistics:** Overall pass/fail rates
-- **Component Coverage:** 100% coverage achieved
-- **Recommendations:** Next steps and improvements
-
-**Key Results:**
-- 10/10 tests passed (100% success rate)
-- Total execution time: ~30 seconds
-- All validation criteria met
-- Zero failures, zero skipped tests
-
----
-
-### 5. Phase Completion Document ✅
-
-**File:** `PHASE6_COMPLETE.md` (this document)
-
-Summary of Phase 6 achievements and next steps.
-
----
-
-## Test Results Summary
-
-### Automated Tests: 10/10 PASSED ✅
-
-| Test # | Test Name | Status | Duration |
-|--------|------------------------|--------|----------|
-| 1 | Face Detection | ✅ PASS | ~2s |
-| 2 | Face Matching | ✅ PASS | ~4s |
-| 3 | Metadata Storage | ✅ PASS | ~2s |
-| 4 | Configuration | ✅ PASS | <1s |
-| 5 | Cosine Similarity | ✅ PASS | <1s |
-| 6 | Database Schema | ✅ PASS | <1s |
-| 7 | Face Location Format | ✅ PASS | ~2s |
-| 8 | Performance Benchmark | ✅ PASS | ~12s |
-| 9 | Adaptive Tolerance | ✅ PASS | <1s |
-| 10 | Multiple Detectors | ✅ PASS | ~4s |
-
-**Total:** ~30 seconds
-
----
-
-## Key Achievements
-
-### 1. Comprehensive Test Coverage ✅
-
-- Face detection and encoding validation
-- Face matching and similarity calculation
-- Database schema and data integrity
-- Configuration flexibility
-- Performance benchmarking
-- Multiple detector support
-- Adaptive algorithms
-- Error handling
-
-### 2. Validation Framework ✅
-
-- 136 validation items tracked
-- Automated and manual tests defined
-- Clear pass/fail criteria
-- Reproducible test execution
-- Comprehensive documentation
-
-### 3. Documentation Excellence ✅
-
-- Test suite guide (README_TESTING.md)
-- Validation checklist (PHASE6_VALIDATION_CHECKLIST.md)
-- Test results report (PHASE6_TEST_RESULTS.md)
-- Completion summary (this document)
-
-### 4. Quality Assurance ✅
-
-- 100% automated test pass rate
-- Zero critical issues found
-- Performance within acceptable limits
-- Database integrity confirmed
-- Configuration flexibility validated
-
----
-
-## Validation Status
-
-### ✅ Completed Validations
-
-1. **Face Detection**
- - Multiple detector backends tested
- - 512-dimensional encodings verified
- - Location format validated
- - Quality scoring functional
-
-2. **Face Matching**
- - Cosine similarity accurate
- - Adaptive tolerance working
- - Match filtering correct
- - Confidence scoring operational
-
-3. **Database Operations**
- - Schema correctly updated
- - New columns functional
- - Data integrity maintained
- - CRUD operations working
-
-4. **Configuration System**
- - Detector selection working
- - Model selection working
- - Custom configurations applied
- - Defaults correct
-
-5. **Performance**
- - Benchmarks completed
- - Metrics reasonable
- - No performance blockers
- - Optimization opportunities identified
-
-### ⏳ Pending Validations (Manual Testing Required)
-
-1. **GUI Integration**
- - Dashboard functionality
- - Identify panel
- - Auto-match panel
- - Modify panel
- - Settings/configuration UI
-
-2. **User Acceptance**
- - End-to-end workflows
- - User experience
- - Error handling in UI
- - Performance in real use
-
-3. **Documentation Finalization**
- - README updates
- - Architecture document updates
- - User guide updates
- - Migration guide completion
-
----
-
-## Migration Progress
-
-### Completed Phases
-
-- ✅ **Phase 1:** Database Schema Updates
-- ✅ **Phase 2:** Configuration Updates
-- ✅ **Phase 3:** Face Processing Core Migration
-- ✅ **Phase 4:** GUI Integration Updates
-- ✅ **Phase 5:** Dependencies and Installation
-- ✅ **Phase 6:** Testing and Validation
-
-### Overall Migration Status: ~95% Complete
-
-**Remaining Work:**
-- Manual GUI testing (Phase 4 verification)
-- Final documentation updates
-- User acceptance testing
-- Production deployment preparation
-
----
-
-## Known Issues
-
-**None identified in automated testing.**
-
-All tests passed with no failures, errors, or unexpected behavior.
-
----
-
-## Performance Metrics
-
-### Face Detection
-- **Average time per photo:** 4.04 seconds
-- **Average time per face:** 0.93 seconds
-- **Detector:** RetinaFace (thorough, slower)
-- **Status:** Acceptable for desktop application
-
-### Face Matching
-- **Similarity search:** < 0.01 seconds per comparison
-- **Algorithm:** Cosine similarity
-- **Status:** Excellent performance
-
-### Database Operations
-- **Insert/update:** < 0.01 seconds
-- **Query performance:** Adequate with indices
-- **Status:** No performance concerns
-
----
-
-## Recommendations
-
-### Immediate Next Steps
-
-1. **Manual GUI Testing**
- - Test all panels with DeepFace
- - Verify face thumbnails display
- - Confirm confidence scores accurate
- - Test detector/model selection UI
-
-2. **Documentation Updates**
- - Update main README.md
- - Complete architecture documentation
- - Finalize migration guide
- - Update user documentation
-
-3. **User Acceptance Testing**
- - Import and process real photo collection
- - Test face identification workflow
- - Verify auto-matching accuracy
- - Confirm search functionality
-
-4. **Production Preparation**
- - Create backup procedures
- - Document deployment steps
- - Prepare rollback plan
- - Train users on new features
-
-### Future Enhancements
-
-1. **Extended Testing**
- - Load testing (1000+ photos)
- - Stress testing
- - Concurrent operation testing
- - Edge case testing
-
-2. **Performance Optimization**
- - GPU acceleration
- - Batch processing
- - Result caching
- - Database query optimization
-
-3. **Feature Additions**
- - Additional detector backends
- - Model selection persistence
- - Performance monitoring dashboard
- - Advanced matching algorithms
-
----
-
-## Success Criteria Met
-
-Phase 6 is considered complete because:
-
-1. ✅ All automated tests passing (10/10)
-2. ✅ Comprehensive test suite created
-3. ✅ Validation checklist established
-4. ✅ Test documentation complete
-5. ✅ Test results documented
-6. ✅ Zero critical issues found
-7. ✅ Performance acceptable
-8. ✅ Database integrity confirmed
-9. ✅ Configuration validated
-10. ✅ Code quality maintained
-
----
-
-## Files Created/Modified in Phase 6
-
-### New Files
-- `PHASE6_VALIDATION_CHECKLIST.md` - Comprehensive validation tracking
-- `PHASE6_TEST_RESULTS.md` - Test execution results
-- `PHASE6_COMPLETE.md` - This completion summary
-- `tests/README_TESTING.md` - Testing guide
-
-### Modified Files
-- `tests/test_deepface_integration.py` - Enhanced with 5 new tests
-
-### Supporting Files
-- Test execution logs
-- Performance benchmarks
-- Validation evidence
-
----
-
-## Conclusion
-
-**Phase 6: Testing and Validation is COMPLETE ✅**
-
-The comprehensive test suite has been executed successfully with a 100% pass rate. All critical functionality of the DeepFace integration has been validated through automated testing:
-
-- ✅ Face detection working correctly
-- ✅ Face matching accurate
-- ✅ Database operations functional
-- ✅ Configuration system flexible
-- ✅ Performance acceptable
-- ✅ Quality assured
-
-The DeepFace migration is **functionally complete** and ready for:
-1. Manual GUI integration testing
-2. User acceptance testing
-3. Final documentation
-4. Production deployment
-
-**Overall Migration Status:** ~95% Complete
-
-**Next Major Milestone:** GUI Integration Validation & User Acceptance Testing
-
----
-
-## Sign-Off
-
-**Phase Lead:** AI Assistant
-**Completion Date:** October 16, 2025
-**Test Results:** 10/10 PASSED
-**Status:** ✅ COMPLETE
-
-**Ready for:** Manual GUI testing and user acceptance validation
-
----
-
-## References
-
-- [DeepFace Migration Plan](/.notes/deepface_migration_plan.md)
-- [Phase 6 Validation Checklist](/PHASE6_VALIDATION_CHECKLIST.md)
-- [Phase 6 Test Results](/PHASE6_TEST_RESULTS.md)
-- [Testing Guide](/tests/README_TESTING.md)
-- [Test Suite](/tests/test_deepface_integration.py)
-
----
-
-**Document Status:** Final
-**Review Status:** Ready for Review
-**Approval:** Pending manual validation completion
-
diff --git a/docs/PHASE6_QUICK_REFERENCE.md b/docs/PHASE6_QUICK_REFERENCE.md
deleted file mode 100644
index caa8d0e..0000000
--- a/docs/PHASE6_QUICK_REFERENCE.md
+++ /dev/null
@@ -1,309 +0,0 @@
-# Phase 6 Quick Reference Guide
-
-**Status:** ✅ COMPLETE
-**Last Updated:** October 16, 2025
-
----
-
-## Quick Commands
-
-### Run Full Test Suite
-```bash
-cd /home/ladmin/Code/punimtag
-source venv/bin/activate
-python tests/test_deepface_integration.py
-```
-
-### Run Individual Test
-```python
-from tests.test_deepface_integration import test_face_detection
-result = test_face_detection()
-```
-
-### Check Test Status
-```bash
-cat PHASE6_TEST_RESULTS.md
-```
-
----
-
-## Test Results Summary
-
-**Status:** ✅ 10/10 PASSED (100%)
-**Duration:** ~30 seconds
-**Date:** October 16, 2025
-
-| Test | Status | Duration |
-|------------------------|--------|----------|
-| Face Detection | ✅ | ~2s |
-| Face Matching | ✅ | ~4s |
-| Metadata Storage | ✅ | ~2s |
-| Configuration | ✅ | <1s |
-| Cosine Similarity | ✅ | <1s |
-| Database Schema | ✅ | <1s |
-| Face Location Format | ✅ | ~2s |
-| Performance Benchmark | ✅ | ~12s |
-| Adaptive Tolerance | ✅ | <1s |
-| Multiple Detectors | ✅ | ~4s |
-
----
-
-## Key Findings
-
-### ✅ What's Working
-
-1. **Face Detection**
- - RetinaFace detector: 4 faces detected
- - OpenCV detector: 1 face detected
- - SSD detector: 1 face detected
- - 512-dimensional encodings (ArcFace)
-
-2. **Face Matching**
- - Cosine similarity: Accurate
- - Adaptive tolerance: Functional [0.2, 0.6]
- - Distance range: Correct [0, 2]
-
-3. **Database**
- - Schema: All new columns present
- - Data integrity: 100%
- - Operations: All CRUD working
-
-4. **Performance**
- - ~4s per photo (RetinaFace)
- - ~1s per face
- - <0.01s similarity search
-
-### ⏳ What's Pending
-
-1. **Manual GUI Testing**
- - Dashboard functionality
- - All panels (Identify, Auto-Match, Modify, Tag Manager)
- - Settings/configuration UI
-
-2. **Documentation**
- - Update main README
- - Complete architecture docs
- - Finalize migration guide
-
-3. **User Acceptance**
- - End-to-end workflows
- - Real-world photo processing
- - Performance validation
-
----
-
-## Phase 6 Deliverables
-
-### ✅ Created Documents
-
-1. **PHASE6_VALIDATION_CHECKLIST.md**
- - 136 validation items tracked
- - Automated and manual tests
- - Clear pass/fail criteria
-
-2. **PHASE6_TEST_RESULTS.md**
- - Complete test execution log
- - Detailed results for each test
- - Performance metrics
-
-3. **PHASE6_COMPLETE.md**
- - Phase summary
- - Achievement tracking
- - Next steps
-
-4. **tests/README_TESTING.md**
- - Comprehensive testing guide
- - Usage instructions
- - Troubleshooting
-
-### ✅ Enhanced Code
-
-1. **tests/test_deepface_integration.py**
- - Added 5 new tests (6-10)
- - Total 10 comprehensive tests
- - 100% automated
-
----
-
-## Configuration Reference
-
-### DeepFace Settings (config.py)
-
-```python
-DEEPFACE_DETECTOR_BACKEND = "retinaface" # Options: retinaface, mtcnn, opencv, ssd
-DEEPFACE_MODEL_NAME = "ArcFace" # Best accuracy model
-DEEPFACE_DISTANCE_METRIC = "cosine" # Similarity metric
-DEFAULT_FACE_TOLERANCE = 0.4 # Lower for DeepFace (was 0.6)
-```
-
-### Encoding Details
-
-- **Dimensions:** 512 floats (ArcFace)
-- **Size:** 4096 bytes (512 × 8)
-- **Format:** BLOB in database
-- **Previous:** 128 floats (face_recognition)
-
-### Location Format
-
-**DeepFace:** `{'x': 1098, 'y': 693, 'w': 132, 'h': 166}`
-**Previous:** `(top, right, bottom, left)` tuple
-
----
-
-## Database Schema Changes
-
-### Faces Table - New Columns
-```sql
-detector_backend TEXT DEFAULT 'retinaface'
-model_name TEXT DEFAULT 'ArcFace'
-face_confidence REAL DEFAULT 0.0
-```
-
-### Person_Encodings Table - New Columns
-```sql
-detector_backend TEXT DEFAULT 'retinaface'
-model_name TEXT DEFAULT 'ArcFace'
-```
-
----
-
-## Performance Benchmarks
-
-### Detection Speed (RetinaFace)
-- Per photo: ~4 seconds
-- Per face: ~1 second
-- First run: +2-5 min (model download)
-
-### Matching Speed
-- Similarity search: <0.01 seconds
-- Adaptive tolerance: Instant
-- Database queries: <0.01 seconds
-
-### Memory Usage
-- Model loading: ~500MB
-- Processing: Depends on image size
-- Database: Minimal overhead
-
----
-
-## Troubleshooting
-
-### Test Images Not Found
-```bash
-# Verify demo photos exist
-ls demo_photos/*.jpg
-# Should show: 2019-11-22_0011.jpg, etc.
-```
-
-### DeepFace Not Installed
-```bash
-source venv/bin/activate
-pip install deepface tensorflow opencv-python retina-face
-```
-
-### TensorFlow Warnings
-```python
-# Already suppressed in config.py
-os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
-warnings.filterwarnings('ignore')
-```
-
-### Database Locked
-```bash
-# Close dashboard/other connections
-# Or use in-memory DB for tests
-```
-
----
-
-## Next Steps
-
-### 1. Manual GUI Testing
-```bash
-# Launch dashboard
-source venv/bin/activate
-python run_dashboard.py
-```
-
-**Test:**
-- Import photos
-- Process faces
-- Identify people
-- Auto-match faces
-- Modify persons
-- Search photos
-
-### 2. Documentation Updates
-- [ ] Update README.md with DeepFace info
-- [ ] Complete ARCHITECTURE.md updates
-- [ ] Finalize migration guide
-- [ ] Update user documentation
-
-### 3. User Acceptance
-- [ ] Process real photo collection
-- [ ] Test all workflows end-to-end
-- [ ] Verify accuracy on real data
-- [ ] Collect user feedback
-
----
-
-## Success Criteria
-
-Phase 6 is **COMPLETE** because:
-
-1. ✅ All automated tests passing (10/10)
-2. ✅ Test suite comprehensive
-3. ✅ Documentation complete
-4. ✅ Results documented
-5. ✅ Zero critical issues
-6. ✅ Performance acceptable
-
-**Migration Progress:** ~95% Complete
-
----
-
-## File Locations
-
-### Documentation
-- `/PHASE6_VALIDATION_CHECKLIST.md`
-- `/PHASE6_TEST_RESULTS.md`
-- `/PHASE6_COMPLETE.md`
-- `/PHASE6_QUICK_REFERENCE.md` (this file)
-- `/tests/README_TESTING.md`
-
-### Tests
-- `/tests/test_deepface_integration.py` (main test suite)
-- `/tests/test_deepface_gui.py` (reference)
-- `/tests/test_deepface_only.py` (reference)
-
-### Configuration
-- `/src/core/config.py` (DeepFace settings)
-- `/requirements.txt` (dependencies)
-
-### Migration Plan
-- `/.notes/deepface_migration_plan.md` (full plan)
-
----
-
-## Contact & Support
-
-**Issue Tracker:** Create GitHub issue
-**Documentation:** Check /docs/ directory
-**Migration Plan:** See .notes/deepface_migration_plan.md
-**Test Guide:** See tests/README_TESTING.md
-
----
-
-## Version History
-
-- **v1.0** (Oct 16, 2025): Phase 6 completion
- - 10 tests implemented
- - All tests passing
- - Complete documentation
-
----
-
-**Quick Reference Status:** Current
-**Last Test Run:** October 16, 2025 - ✅ 10/10 PASSED
-**Next Milestone:** GUI Integration Testing
-
diff --git a/docs/PHASE6_TEST_RESULTS.md b/docs/PHASE6_TEST_RESULTS.md
deleted file mode 100644
index 8af8745..0000000
--- a/docs/PHASE6_TEST_RESULTS.md
+++ /dev/null
@@ -1,475 +0,0 @@
-# Phase 6: DeepFace Integration Test Results
-
-**Date:** October 16, 2025
-**Tester:** AI Assistant
-**Environment:** Ubuntu Linux 6.8.0-84-generic
-**Python Version:** 3.x (via venv)
-**Test Suite Version:** 1.0
-
----
-
-## Executive Summary
-
-✅ **ALL TESTS PASSED (10/10)**
-
-The Phase 6 DeepFace integration test suite has been executed successfully. All automated tests passed, confirming that the DeepFace migration is functionally complete and working correctly.
-
-### Key Findings
-
-- ✅ Face detection working with DeepFace/RetinaFace
-- ✅ 512-dimensional encodings (ArcFace) storing correctly
-- ✅ Cosine similarity matching accurate
-- ✅ Database schema updated correctly
-- ✅ Multiple detector backends functional
-- ✅ Performance within acceptable parameters
-- ✅ Configuration system flexible and working
-
----
-
-## Test Execution Details
-
-### Test Environment
-
-**Hardware:**
-- System: Linux workstation
-- Architecture: x86_64
-- Memory: Sufficient for testing
-- Storage: SSD with adequate space
-
-**Software:**
-- OS: Ubuntu Linux (kernel 6.8.0-84-generic)
-- Python: 3.x with virtual environment
-- DeepFace: >=0.0.79
-- TensorFlow: >=2.13.0
-- OpenCV: >=4.8.0
-
-**Test Data:**
-- Test images: demo_photos/2019-11-22_*.jpg
-- Image count: 3 photos used for testing
-- Total faces detected: 15 faces across all tests
-
-### Execution Time
-
-- **Total Duration:** ~30 seconds
-- **Average per test:** ~3 seconds
-- **Performance:** Acceptable for CI/CD
-
----
-
-## Detailed Test Results
-
-### Test 1: Face Detection ✅
-
-**Status:** PASSED
-**Duration:** ~2 seconds
-
-**Results:**
-- Image processed: `2019-11-22_0011.jpg`
-- Faces detected: 4
-- Encoding size: 4096 bytes (512 floats × 8)
-- Database storage: Successful
-
-**Validation:**
-- ✅ Face detection successful
-- ✅ Correct encoding dimensions
-- ✅ Proper database storage
-- ✅ No errors during processing
-
-**Key Metrics:**
-- Face detection accuracy: 100%
-- Encoding format: Correct (512-dim)
-- Storage format: Correct (BLOB)
-
----
-
-### Test 2: Face Matching ✅
-
-**Status:** PASSED
-**Duration:** ~4 seconds
-
-**Results:**
-- Images processed: 2
-- Total faces detected: 11 (4 + 7)
-- Similarity search: Functional
-- Matches found: 0 (within default tolerance 0.4)
-
-**Validation:**
-- ✅ Multiple photo processing works
-- ✅ Similarity calculation functions
-- ✅ Tolerance filtering operational
-- ✅ Results consistent
-
-**Key Metrics:**
-- Processing success rate: 100%
-- Similarity algorithm: Operational
-- Match filtering: Correct
-
-**Note:** Zero matches found indicates faces are sufficiently different or tolerance is appropriately strict.
-
----
-
-### Test 3: Metadata Storage ✅
-
-**Status:** PASSED
-**Duration:** ~2 seconds
-
-**Results:**
-- Face confidence: 1.0
-- Quality score: 0.687
-- Detector backend: retinaface
-- Model name: ArcFace
-
-**Validation:**
-- ✅ All metadata fields populated
-- ✅ Detector matches configuration
-- ✅ Model matches configuration
-- ✅ Values within expected ranges
-
-**Key Metrics:**
-- Metadata completeness: 100%
-- Data accuracy: 100%
-- Schema compliance: 100%
-
----
-
-### Test 4: Configuration ✅
-
-**Status:** PASSED
-**Duration:** <1 second
-
-**Results:**
-- Default detector: retinaface ✓
-- Default model: ArcFace ✓
-- Custom configurations tested: 3
- - mtcnn/Facenet512 ✓
- - opencv/VGG-Face ✓
- - ssd/ArcFace ✓
-
-**Validation:**
-- ✅ Default configuration correct
-- ✅ Custom configurations applied
-- ✅ All detector/model combinations work
-- ✅ Configuration persistence functional
-
-**Key Metrics:**
-- Configuration flexibility: 100%
-- Default accuracy: 100%
-- Custom config support: 100%
-
----
-
-### Test 5: Cosine Similarity ✅
-
-**Status:** PASSED
-**Duration:** <1 second
-
-**Results:**
-- Identical encodings distance: 0.000000
-- Different encodings distance: 0.255897
-- Mismatched lengths distance: 2.000000
-
-**Validation:**
-- ✅ Identical encodings properly matched
-- ✅ Different encodings properly separated
-- ✅ Error handling for mismatches
-- ✅ Distance range [0, 2] maintained
-
-**Key Metrics:**
-- Algorithm accuracy: 100%
-- Edge case handling: 100%
-- Numerical stability: 100%
-
----
-
-### Test 6: Database Schema ✅
-
-**Status:** PASSED
-**Duration:** <1 second
-
-**Results:**
-
-**Faces table columns verified:**
-- id, photo_id, person_id, encoding, location
-- confidence, quality_score, is_primary_encoding
-- detector_backend (TEXT) ✓
-- model_name (TEXT) ✓
-- face_confidence (REAL) ✓
-
-**Person_encodings table columns verified:**
-- id, person_id, face_id, encoding, quality_score
-- detector_backend (TEXT) ✓
-- model_name (TEXT) ✓
-- created_date
-
-**Validation:**
-- ✅ All new columns present
-- ✅ Data types correct
-- ✅ Schema migration successful
-- ✅ No corruption detected
-
-**Key Metrics:**
-- Schema compliance: 100%
-- Data integrity: 100%
-- Migration success: 100%
-
----
-
-### Test 7: Face Location Format ✅
-
-**Status:** PASSED
-**Duration:** ~2 seconds
-
-**Results:**
-- Raw location: `{'x': 1098, 'y': 693, 'w': 132, 'h': 166}`
-- Parsed location: Dictionary with 4 keys
-- Format: DeepFace dict format {x, y, w, h}
-
-**Validation:**
-- ✅ Location stored as dict string
-- ✅ All required keys present (x, y, w, h)
-- ✅ Values are numeric
-- ✅ Format parseable
-
-**Key Metrics:**
-- Format correctness: 100%
-- Parse success rate: 100%
-- Data completeness: 100%
-
----
-
-### Test 8: Performance Benchmark ✅
-
-**Status:** PASSED
-**Duration:** ~12 seconds
-
-**Results:**
-- Photos processed: 3
-- Total time: 12.11 seconds
-- Average per photo: 4.04 seconds
-- Total faces found: 13
-- Average per face: 0.93 seconds
-- Similarity search: 0.00 seconds (minimal)
-
-**Validation:**
-- ✅ Processing completes successfully
-- ✅ Performance metrics reasonable
-- ✅ No crashes or hangs
-- ✅ Consistent across runs
-
-**Key Metrics:**
-- Processing speed: ~4s per photo
-- Face detection: ~1s per face
-- Similarity search: < 0.01s
-- Overall performance: Acceptable
-
-**Performance Notes:**
-- First run includes model loading
-- RetinaFace is thorough but slower
-- OpenCV/SSD detectors faster for speed-critical apps
-- Performance acceptable for desktop application
-
----
-
-### Test 9: Adaptive Tolerance ✅
-
-**Status:** PASSED
-**Duration:** <1 second
-
-**Results:**
-- Base tolerance: 0.4
-- Low quality (0.1): 0.368
-- Medium quality (0.5): 0.400
-- High quality (0.9): 0.432
-- With confidence (0.8): 0.428
-
-**Validation:**
-- ✅ Tolerance adjusts with quality
-- ✅ All values within bounds [0.2, 0.6]
-- ✅ Higher quality = stricter tolerance
-- ✅ Calculation logic correct
-
-**Key Metrics:**
-- Adaptive range: [0.368, 0.432]
-- Adjustment sensitivity: Appropriate
-- Bounds enforcement: 100%
-
----
-
-### Test 10: Multiple Detectors ✅
-
-**Status:** PASSED
-**Duration:** ~4 seconds
-
-**Results:**
-- opencv detector: 1 face found ✓
-- ssd detector: 1 face found ✓
-- (retinaface tested in Test 1: 4 faces) ✓
-
-**Validation:**
-- ✅ Multiple detectors functional
-- ✅ No detector crashes
-- ✅ Results recorded properly
-- ✅ Different detectors work
-
-**Key Metrics:**
-- Detector compatibility: 100%
-- Crash-free operation: 100%
-- Detection success: 100%
-
-**Detector Comparison:**
-- RetinaFace: Most thorough (4 faces)
-- OpenCV: Fastest, basic (1 face)
-- SSD: Balanced (1 face)
-
----
-
-## Test Summary Statistics
-
-### Overall Results
-
-| Metric | Result |
-|---------------------------|------------|
-| Total Tests | 10 |
-| Tests Passed | 10 (100%) |
-| Tests Failed | 0 (0%) |
-| Tests Skipped | 0 (0%) |
-| Overall Success Rate | 100% |
-| Total Execution Time | ~30s |
-
-### Component Coverage
-
-| Component | Coverage | Status |
-|---------------------------|------------|--------|
-| Face Detection | 100% | ✅ |
-| Face Matching | 100% | ✅ |
-| Database Operations | 100% | ✅ |
-| Configuration System | 100% | ✅ |
-| Similarity Calculation | 100% | ✅ |
-| Metadata Storage | 100% | ✅ |
-| Location Format | 100% | ✅ |
-| Performance Monitoring | 100% | ✅ |
-| Adaptive Algorithms | 100% | ✅ |
-| Multi-Detector Support | 100% | ✅ |
-
----
-
-## Validation Checklist Update
-
-Based on test results, the following checklist items are confirmed:
-
-### Automated Tests
-- ✅ All automated tests pass
-- ✅ Face detection working correctly
-- ✅ Face matching accurate
-- ✅ Database schema correct
-- ✅ Configuration flexible
-- ✅ Performance acceptable
-
-### Core Functionality
-- ✅ DeepFace successfully detects faces
-- ✅ Face encodings are 512-dimensional
-- ✅ Encodings stored correctly (4096 bytes)
-- ✅ Face locations in DeepFace format {x, y, w, h}
-- ✅ Cosine similarity working correctly
-- ✅ Adaptive tolerance functional
-
-### Database
-- ✅ New columns present in faces table
-- ✅ New columns present in person_encodings table
-- ✅ Data types correct
-- ✅ Schema migration successful
-- ✅ No data corruption
-
-### Configuration
-- ✅ Multiple detector backends work
-- ✅ Multiple models supported
-- ✅ Default configuration correct
-- ✅ Custom configuration applied
-
----
-
-## Known Issues
-
-None identified during automated testing.
-
----
-
-## Recommendations
-
-### Immediate Actions
-1. ✅ Document test results (this document)
-2. ⏳ Proceed with manual GUI testing
-3. ⏳ Update validation checklist
-4. ⏳ Perform user acceptance testing
-
-### Future Enhancements
-1. Add GUI integration tests
-2. Add load testing (1000+ photos)
-3. Add stress testing (concurrent operations)
-4. Monitor performance on larger datasets
-5. Test GPU acceleration if available
-
-### Performance Optimization
-- Consider using OpenCV/SSD for speed-critical scenarios
-- Implement batch processing for large photo sets
-- Add result caching for repeated operations
-- Monitor and optimize database queries
-
----
-
-## Conclusion
-
-The Phase 6 automated test suite has been successfully executed with a **100% pass rate (10/10 tests)**. All critical functionality of the DeepFace integration is working correctly:
-
-1. ✅ **Face Detection**: Working with multiple detectors
-2. ✅ **Face Encoding**: 512-dimensional ArcFace encodings
-3. ✅ **Face Matching**: Cosine similarity accurate
-4. ✅ **Database**: Schema updated and functional
-5. ✅ **Configuration**: Flexible and working
-6. ✅ **Performance**: Within acceptable parameters
-
-The DeepFace migration is **functionally complete** from an automated testing perspective. The next steps are:
-- Manual GUI integration testing
-- User acceptance testing
-- Documentation finalization
-- Production deployment preparation
-
----
-
-## Appendices
-
-### A. Test Execution Log
-
-See full output in test execution above.
-
-### B. Test Images Used
-
-- `demo_photos/2019-11-22_0011.jpg` - Primary test image (4 faces)
-- `demo_photos/2019-11-22_0012.jpg` - Secondary test image (7 faces)
-- `demo_photos/2019-11-22_0015.jpg` - Additional test image
-
-### C. Dependencies Verified
-
-- ✅ deepface >= 0.0.79
-- ✅ tensorflow >= 2.13.0
-- ✅ opencv-python >= 4.8.0
-- ✅ retina-face >= 0.0.13
-- ✅ numpy >= 1.21.0
-- ✅ pillow >= 8.0.0
-
-### D. Database Schema Confirmed
-
-All required columns present and functioning:
-- faces.detector_backend (TEXT)
-- faces.model_name (TEXT)
-- faces.face_confidence (REAL)
-- person_encodings.detector_backend (TEXT)
-- person_encodings.model_name (TEXT)
-
----
-
-**Test Report Prepared By:** AI Assistant
-**Review Status:** Ready for Review
-**Next Review:** After GUI integration testing
-**Approval:** Pending manual validation
-
diff --git a/docs/PHASE6_VALIDATION_CHECKLIST.md b/docs/PHASE6_VALIDATION_CHECKLIST.md
deleted file mode 100644
index 0ea9f42..0000000
--- a/docs/PHASE6_VALIDATION_CHECKLIST.md
+++ /dev/null
@@ -1,361 +0,0 @@
-# Phase 6: Testing and Validation Checklist
-
-**Version:** 1.0
-**Date:** October 16, 2025
-**Status:** In Progress
-
----
-
-## Overview
-
-This document provides a comprehensive validation checklist for Phase 6 of the DeepFace migration. It ensures all aspects of the migration are tested and validated before considering the migration complete.
-
----
-
-## 1. Face Detection Validation
-
-### 1.1 Basic Detection
-- [x] DeepFace successfully detects faces in test images
-- [x] Face detection works with retinaface detector
-- [ ] Face detection works with mtcnn detector
-- [ ] Face detection works with opencv detector
-- [ ] Face detection works with ssd detector
-- [x] Multiple faces detected in group photos
-- [x] No false positives in non-face images
-
-### 1.2 Face Encoding
-- [x] Face encodings are 512-dimensional (ArcFace model)
-- [x] Encodings stored as 4096-byte BLOBs (512 floats × 8 bytes)
-- [x] Encoding storage and retrieval work correctly
-- [x] Encodings can be converted between numpy arrays and bytes
-
-### 1.3 Face Location Format
-- [x] Face locations stored in DeepFace format: {x, y, w, h}
-- [x] Location parsing handles dict format correctly
-- [x] Face crop extraction works with new format
-- [x] Face thumbnails display correctly in GUI
-
-### 1.4 Quality Assessment
-- [x] Face quality scores calculated correctly
-- [x] Quality scores range from 0.0 to 1.0
-- [x] Higher quality faces ranked higher
-- [x] Quality factors considered: size, sharpness, brightness, contrast
-
----
-
-## 2. Face Matching Validation
-
-### 2.1 Similarity Calculation
-- [x] Cosine similarity implemented correctly
-- [x] Identical encodings return distance near 0
-- [x] Different encodings return appropriate distance
-- [x] Distance range is [0, 2] as expected
-- [x] Similarity calculations consistent across runs
-
-### 2.2 Adaptive Tolerance
-- [x] Adaptive tolerance adjusts based on face quality
-- [x] Tolerance stays within bounds [0.2, 0.6]
-- [x] Higher quality faces use stricter tolerance
-- [x] Lower quality faces use more lenient tolerance
-- [x] Match confidence affects tolerance calculation
-
-### 2.3 Matching Accuracy
-- [x] Similar faces correctly identified
-- [x] Default tolerance (0.4) produces reasonable results
-- [x] No false positives at default threshold
-- [x] Same person across photos matched correctly
-- [ ] Different people not incorrectly matched
-
----
-
-## 3. Database Validation
-
-### 3.1 Schema Updates
-- [x] `faces` table has `detector_backend` column (TEXT)
-- [x] `faces` table has `model_name` column (TEXT)
-- [x] `faces` table has `face_confidence` column (REAL)
-- [x] `person_encodings` table has `detector_backend` column
-- [x] `person_encodings` table has `model_name` column
-- [x] All new columns have appropriate data types
-- [x] Existing data not corrupted by schema changes
-
-### 3.2 Data Operations
-- [x] Face insertion with DeepFace metadata works
-- [x] Face retrieval with all columns works
-- [x] Person encoding storage includes metadata
-- [x] Queries work with new schema
-- [x] Indices improve query performance
-- [x] No SQL errors during operations
-
-### 3.3 Data Integrity
-- [x] Foreign key constraints maintained
-- [x] Unique constraints enforced
-- [x] Default values applied correctly
-- [x] Timestamps recorded accurately
-- [x] BLOB data stored without corruption
-
----
-
-## 4. GUI Integration Validation
-
-### 4.1 Dashboard
-- [ ] Dashboard launches without errors
-- [ ] All panels load correctly
-- [ ] DeepFace status shown in UI
-- [ ] Statistics display accurately
-- [ ] No performance degradation
-
-### 4.2 Identify Panel
-- [ ] Unidentified faces display correctly
-- [ ] Face thumbnails show properly
-- [ ] Similarity matches appear
-- [ ] Confidence percentages accurate
-- [ ] Face identification works
-- [ ] New location format supported
-
-### 4.3 Auto-Match Panel
-- [ ] Auto-match finds similar faces
-- [ ] Confidence scores displayed
-- [ ] Matches can be confirmed/rejected
-- [ ] Bulk identification works
-- [ ] Progress indicators function
-- [ ] Cancel operation works
-
-### 4.4 Modify Panel
-- [ ] Person list displays
-- [ ] Face thumbnails render
-- [ ] Person editing works
-- [ ] Face reassignment works
-- [ ] New format handled correctly
-
-### 4.5 Settings/Configuration
-- [ ] Detector backend selection available
-- [ ] Model selection available
-- [ ] Tolerance adjustment works
-- [ ] Settings persist across sessions
-- [ ] Configuration changes apply immediately
-
----
-
-## 5. Performance Validation
-
-### 5.1 Face Detection Speed
-- [x] Face detection completes in reasonable time
-- [x] Performance tracked per photo
-- [x] Average time per face calculated
-- [ ] Performance acceptable for user workflows
-- [ ] No significant slowdown vs face_recognition
-
-### 5.2 Matching Speed
-- [x] Similarity search completes quickly
-- [x] Performance scales with face count
-- [ ] Large databases (1000+ faces) perform adequately
-- [ ] No memory leaks during extended use
-- [ ] Caching improves performance
-
-### 5.3 Resource Usage
-- [ ] CPU usage reasonable during processing
-- [ ] Memory usage within acceptable limits
-- [ ] GPU utilized if available
-- [ ] Disk space usage acceptable
-- [ ] No resource exhaustion
-
----
-
-## 6. Configuration Validation
-
-### 6.1 FaceProcessor Initialization
-- [x] Default configuration uses correct settings
-- [x] Custom detector backend applied
-- [x] Custom model name applied
-- [x] Configuration parameters validated
-- [x] Invalid configurations rejected gracefully
-
-### 6.2 Config File Settings
-- [x] DEEPFACE_DETECTOR_BACKEND defined
-- [x] DEEPFACE_MODEL_NAME defined
-- [x] DEEPFACE_DISTANCE_METRIC defined
-- [x] DEFAULT_FACE_TOLERANCE adjusted for DeepFace
-- [x] All DeepFace options available
-
-### 6.3 Backward Compatibility
-- [ ] Legacy face_recognition code removed
-- [x] Old tolerance values updated
-- [ ] Migration script available
-- [ ] Documentation updated
-- [ ] No references to old library
-
----
-
-## 7. Error Handling Validation
-
-### 7.1 Graceful Degradation
-- [x] Missing DeepFace dependency handled
-- [x] Invalid image files handled
-- [x] No faces detected handled
-- [x] Database errors caught
-- [x] User-friendly error messages
-
-### 7.2 Recovery
-- [ ] Processing can resume after error
-- [ ] Partial results saved
-- [ ] Database remains consistent
-- [ ] Temporary files cleaned up
-- [ ] Application doesn't crash
-
----
-
-## 8. Documentation Validation
-
-### 8.1 Code Documentation
-- [x] DeepFace methods documented
-- [x] New parameters explained
-- [x] Type hints present
-- [x] Docstrings updated
-- [ ] Comments explain DeepFace specifics
-
-### 8.2 User Documentation
-- [ ] README updated with DeepFace info
-- [ ] Migration guide available
-- [ ] Detector options documented
-- [ ] Model options explained
-- [ ] Troubleshooting guide present
-
-### 8.3 Architecture Documentation
-- [ ] ARCHITECTURE.md updated
-- [ ] Database schema documented
-- [ ] Data flow diagrams current
-- [ ] Technology stack updated
-
----
-
-## 9. Test Suite Validation
-
-### 9.1 Test Coverage
-- [x] Face detection tests
-- [x] Face matching tests
-- [x] Metadata storage tests
-- [x] Configuration tests
-- [x] Cosine similarity tests
-- [x] Database schema tests
-- [x] Face location format tests
-- [x] Performance benchmark tests
-- [x] Adaptive tolerance tests
-- [x] Multiple detector tests
-
-### 9.2 Test Quality
-- [x] Tests are automated
-- [x] Tests are reproducible
-- [x] Tests provide clear pass/fail
-- [x] Tests cover edge cases
-- [x] Tests document expected behavior
-
-### 9.3 Test Execution
-- [ ] All tests pass on fresh install
-- [ ] Tests run without manual intervention
-- [ ] Test results documented
-- [ ] Failed tests investigated
-- [ ] Test suite maintainable
-
----
-
-## 10. Deployment Validation
-
-### 10.1 Installation
-- [ ] requirements.txt includes all dependencies
-- [ ] Installation instructions clear
-- [ ] Virtual environment setup documented
-- [ ] Dependencies install without errors
-- [ ] Version conflicts resolved
-
-### 10.2 Migration Process
-- [ ] Migration script available
-- [ ] Migration script tested
-- [ ] Data backup recommended
-- [ ] Rollback plan documented
-- [ ] Migration steps clear
-
-### 10.3 Verification
-- [ ] Post-migration verification steps defined
-- [ ] Sample workflow tested
-- [ ] Demo data processed successfully
-- [ ] No regression in core functionality
-- [ ] User acceptance criteria met
-
----
-
-## Test Execution Summary
-
-### Automated Tests
-Run: `python tests/test_deepface_integration.py`
-
-**Status:** 🟡 In Progress
-
-**Results:**
-- Total Tests: 10
-- Passed: TBD
-- Failed: TBD
-- Skipped: TBD
-
-**Last Run:** Pending
-
-### Manual Tests
-- [ ] Full GUI workflow
-- [ ] Photo import and processing
-- [ ] Face identification
-- [ ] Auto-matching
-- [ ] Person management
-- [ ] Search functionality
-- [ ] Export/backup
-
----
-
-## Success Criteria
-
-The Phase 6 validation is complete when:
-
-1. ✅ All automated tests pass
-2. ⏳ All critical checklist items checked
-3. ⏳ GUI integration verified
-4. ⏳ Performance acceptable
-5. ⏳ Documentation complete
-6. ⏳ No regression in functionality
-7. ⏳ User acceptance testing passed
-
----
-
-## Known Issues
-
-*(Document any known issues or limitations)*
-
-1. Performance slower than face_recognition (expected - deep learning trade-off)
-2. Larger model downloads required (~500MB)
-3. TensorFlow warnings need suppression
-
----
-
-## Next Steps
-
-1. Run complete test suite
-2. Document test results
-3. Complete GUI integration tests
-4. Update documentation
-5. Perform user acceptance testing
-6. Create migration completion report
-
----
-
-## Notes
-
-- Test with demo_photos/testdeepface/ for known-good results
-- Compare results with test_deepface_gui.py reference
-- Monitor performance on large datasets
-- Verify GPU acceleration if available
-- Test on clean install
-
----
-
-**Validation Lead:** AI Assistant
-**Review Date:** TBD
-**Approved By:** TBD
-
diff --git a/docs/PORTRAIT_DETECTION_PLAN.md b/docs/PORTRAIT_DETECTION_PLAN.md
deleted file mode 100644
index 60b2887..0000000
--- a/docs/PORTRAIT_DETECTION_PLAN.md
+++ /dev/null
@@ -1,1480 +0,0 @@
-# Portrait/Profile Face Detection Plan
-
-**Version:** 1.0
-**Created:** November 2025
-**Status:** Planning Phase
-
----
-
-## Executive Summary
-
-This plan outlines the implementation of automatic face pose detection using RetinaFace directly (not via DeepFace) to identify and mark faces based on their pose/orientation. The system will detect multiple pose modes including profile (yaw), looking up/down (pitch), tilted faces (roll), and their combinations. This enables intelligent filtering in auto-match and other features.
-
-**Key Benefits:**
-- Automatic detection of face pose (yaw, pitch, roll) without user input
-- Ability to filter faces by pose mode in auto-match (profile, looking up, tilted, etc.)
-- Better face matching accuracy by excluding low-quality or extreme-angle views
-- Enhanced user experience with automatic pose classification
-- Support for multiple pose modes: profile, looking up/down, tilted, extreme angles
-
----
-
-## Current State Analysis
-
-### Current Implementation
-
-**Face Detection Method:**
-- Uses DeepFace library which wraps RetinaFace
-- `DeepFace.represent()` provides: `facial_area`, `face_confidence`, `embedding`
-- No access to facial landmarks or pose information
-
-**Data Stored:**
-- Face bounding box: `{x, y, w, h}`
-- Detection confidence: `face_confidence`
-- Face encoding: 512-dimensional embedding
-- Quality score: calculated from image properties
-- No pose/angle information stored
-
-**Database Schema:**
-```sql
-CREATE TABLE faces (
- id INTEGER PRIMARY KEY,
- photo_id INTEGER,
- person_id INTEGER,
- encoding BLOB,
- location TEXT, -- JSON: {"x": x, "y": y, "w": w, "h": h}
- confidence REAL,
- quality_score REAL,
- is_primary_encoding BOOLEAN,
- detector_backend TEXT,
- model_name TEXT,
- face_confidence REAL,
- exif_orientation INTEGER
- -- NO is_portrait field
-)
-```
-
-### Limitations
-
-1. **No Landmark Access:** DeepFace doesn't expose RetinaFace landmarks
-2. **No Pose Estimation:** Cannot calculate yaw, pitch, roll angles
-3. **No Profile Classification:** Cannot automatically identify profile faces
-4. **Manual Filtering Required:** Users cannot filter profile faces in auto-match
-
----
-
-## Requirements
-
-### Functional Requirements
-
-1. **Automatic Pose Detection:**
- - Detect face pose angles (yaw, pitch, roll) during processing
- - Classify faces into pose modes: frontal, profile, looking up, looking down, tilted, extreme angles
- - Store pose information in database
- - No user intervention required
-
-2. **Pose Mode Classification:**
- - **Yaw (left/right):** frontal, profile_left, profile_right, extreme_yaw
- - **Pitch (up/down):** looking_up, looking_down, extreme_pitch
- - **Roll (tilted):** tilted_left, tilted_right, extreme_roll
- - **Combined modes:** e.g., profile_left_looking_up, tilted_profile_right
- - Threshold-based classification using pose angles
-
-3. **Filtering Support:**
- - Filter faces by pose mode in auto-match (exclude profile, looking up, tilted, etc.)
- - Multiple filter options: exclude profile, exclude extreme angles, exclude specific modes
- - Optional filtering in other features (search, identify)
-
-4. **Clean Database:**
- - Starting with fresh database - no migration needed
- - All faces will have pose data from the start
-
-### Technical Requirements
-
-1. **RetinaFace Direct Integration:**
- - Use RetinaFace library directly (not via DeepFace)
- - Extract facial landmarks (5 points: eyes, nose, mouth corners)
- - Calculate all pose angles (yaw, pitch, roll) from landmarks
-
-2. **Performance:**
- - Minimal performance impact (RetinaFace is already used by DeepFace)
- - Reuse existing face detection results where possible
- - Angle calculations are fast (< 1ms per face)
-
-3. **Accuracy:**
- - Pose detection accuracy > 90% for clear frontal/profile views
- - Handle edge cases (slight angles, extreme angles, occlusions)
- - Robust to lighting and image quality variations
-
-4. **Pose Modes Supported:**
- - **Yaw:** Frontal (|yaw| < 30°), Profile Left (yaw < -30°), Profile Right (yaw > 30°), Extreme Yaw (|yaw| > 60°)
- - **Pitch:** Level (|pitch| < 20°), Looking Up (pitch > 20°), Looking Down (pitch < -20°), Extreme Pitch (|pitch| > 45°)
- - **Roll:** Upright (|roll| < 15°), Tilted Left (roll < -15°), Tilted Right (roll > 15°), Extreme Roll (|roll| > 45°)
-
----
-
-## Technical Approach
-
-### Architecture Overview
-
-```
-┌─────────────────────────────────────────────────────────┐
-│ Face Processing │
-├─────────────────────────────────────────────────────────┤
-│ │
-│ 1. Use RetinaFace directly for face detection │
-│ └─> Returns: bounding box, landmarks, confidence │
-│ │
-│ 2. Calculate pose angles from landmarks │
-│ └─> Yaw (left/right rotation) │
-│ └─> Pitch (up/down tilt) │
-│ └─> Roll (rotation around face axis) │
-│ │
-│ 3. Calculate all pose angles from landmarks │
-│ └─> Yaw (left/right rotation): -90° to +90° │
-│ └─> Pitch (up/down tilt): -90° to +90° │
-│ └─> Roll (rotation around face): -90° to +90° │
-│ │
-│ 4. Classify face pose modes │
-│ └─> Yaw: frontal, profile_left, profile_right │
-│ └─> Pitch: level, looking_up, looking_down │
-│ └─> Roll: upright, tilted_left, tilted_right │
-│ └─> Combined: profile_left_looking_up, etc. │
-│ │
-│ 5. Still use DeepFace for encoding generation │
-│ └─> RetinaFace: detection + landmarks │
-│ └─> DeepFace: encoding generation (ArcFace) │
-│ │
-│ 6. Store pose information in database │
-│ └─> pose_mode TEXT (e.g., "frontal", "profile_left")│
-│ └─> yaw_angle, pitch_angle, roll_angle REAL │
-│ │
-└─────────────────────────────────────────────────────────┘
-```
-
-### Pose Estimation from Landmarks
-
-**RetinaFace Landmarks (5 points):**
-- Left eye: `(x1, y1)`
-- Right eye: `(x2, y2)`
-- Nose: `(x3, y3)`
-- Left mouth corner: `(x4, y4)`
-- Right mouth corner: `(x5, y5)`
-
-**Yaw Angle Calculation (Left/Right Rotation):**
-```python
-# Calculate yaw from eye and nose positions
-left_eye = landmarks['left_eye']
-right_eye = landmarks['right_eye']
-nose = landmarks['nose']
-
-# Eye midpoint
-eye_mid_x = (left_eye[0] + right_eye[0]) / 2
-eye_mid_y = (left_eye[1] + right_eye[1]) / 2
-
-# Horizontal offset from nose to eye midpoint
-horizontal_offset = nose[0] - eye_mid_x
-face_width = abs(right_eye[0] - left_eye[0])
-
-# Yaw angle (degrees)
-yaw_angle = atan2(horizontal_offset, face_width) * 180 / π
-# Negative: face turned left (right profile visible)
-# Positive: face turned right (left profile visible)
-```
-
-**Pitch Angle Calculation (Up/Down Tilt):**
-```python
-# Calculate pitch from eye and mouth positions
-left_eye = landmarks['left_eye']
-right_eye = landmarks['right_eye']
-left_mouth = landmarks['left_mouth']
-right_mouth = landmarks['right_mouth']
-nose = landmarks['nose']
-
-# Eye midpoint
-eye_mid_y = (left_eye[1] + right_eye[1]) / 2
-# Mouth midpoint
-mouth_mid_y = (left_mouth[1] + right_mouth[1]) / 2
-# Nose vertical position
-nose_y = nose[1]
-
-# Expected nose position (between eyes and mouth)
-expected_nose_y = eye_mid_y + (mouth_mid_y - eye_mid_y) * 0.6
-face_height = abs(mouth_mid_y - eye_mid_y)
-
-# Vertical offset
-vertical_offset = nose_y - expected_nose_y
-
-# Pitch angle (degrees)
-pitch_angle = atan2(vertical_offset, face_height) * 180 / π
-# Positive: looking up
-# Negative: looking down
-```
-
-**Roll Angle Calculation (Rotation Around Face Axis):**
-```python
-# Calculate roll from eye positions
-left_eye = landmarks['left_eye']
-right_eye = landmarks['right_eye']
-
-# Calculate angle of eye line
-dx = right_eye[0] - left_eye[0]
-dy = right_eye[1] - left_eye[1]
-
-# Roll angle (degrees)
-roll_angle = atan2(dy, dx) * 180 / π
-# Positive: tilted right (clockwise)
-# Negative: tilted left (counterclockwise)
-```
-
-**Combined Pose Mode Classification:**
-```python
-# Classify pose mode based on all three angles
-def classify_pose_mode(yaw, pitch, roll):
- """Classify face pose mode from angles"""
-
- # Yaw classification
- if abs(yaw) < 30:
- yaw_mode = "frontal"
- elif yaw < -30:
- yaw_mode = "profile_right"
- elif yaw > 30:
- yaw_mode = "profile_left"
- else:
- yaw_mode = "slight_yaw"
-
- # Pitch classification
- if abs(pitch) < 20:
- pitch_mode = "level"
- elif pitch > 20:
- pitch_mode = "looking_up"
- elif pitch < -20:
- pitch_mode = "looking_down"
- else:
- pitch_mode = "slight_pitch"
-
- # Roll classification
- if abs(roll) < 15:
- roll_mode = "upright"
- elif roll > 15:
- roll_mode = "tilted_right"
- elif roll < -15:
- roll_mode = "tilted_left"
- else:
- roll_mode = "slight_roll"
-
- # Combine modes
- if yaw_mode == "frontal" and pitch_mode == "level" and roll_mode == "upright":
- return "frontal"
- else:
- return f"{yaw_mode}_{pitch_mode}_{roll_mode}"
-```
-
----
-
-## Implementation Plan
-
-### Phase 1: Database Schema Updates
-
-#### Step 1.1: Add Pose Fields to Database
-
-**Desktop Database (`src/core/database.py`):**
-```python
-# Add to faces table
-ALTER TABLE faces ADD COLUMN pose_mode TEXT DEFAULT 'frontal'; -- e.g., 'frontal', 'profile_left', 'looking_up', etc.
-ALTER TABLE faces ADD COLUMN yaw_angle REAL DEFAULT NULL; -- Yaw angle in degrees
-ALTER TABLE faces ADD COLUMN pitch_angle REAL DEFAULT NULL; -- Pitch angle in degrees
-ALTER TABLE faces ADD COLUMN roll_angle REAL DEFAULT NULL; -- Roll angle in degrees
-```
-
-**Web Database (Alembic Migration):**
-```python
-# Create new Alembic migration
-alembic revision -m "add_pose_detection_to_faces"
-
-# Migration file: alembic/versions/YYYYMMDD_add_pose_to_faces.py
-def upgrade():
- # Add pose fields
- op.add_column('faces', sa.Column('pose_mode', sa.String(50),
- nullable=False, server_default='frontal'))
- op.add_column('faces', sa.Column('yaw_angle', sa.Numeric(), nullable=True))
- op.add_column('faces', sa.Column('pitch_angle', sa.Numeric(), nullable=True))
- op.add_column('faces', sa.Column('roll_angle', sa.Numeric(), nullable=True))
-
- # Create indices
- op.create_index('ix_faces_pose_mode', 'faces', ['pose_mode'])
-
-def downgrade():
- op.drop_index('ix_faces_pose_mode', table_name='faces')
- op.drop_column('faces', 'roll_angle')
- op.drop_column('faces', 'pitch_angle')
- op.drop_column('faces', 'yaw_angle')
- op.drop_column('faces', 'pose_mode')
-```
-
-**SQLAlchemy Model (`src/web/db/models.py`):**
-```python
-class Face(Base):
- # ... existing fields ...
- pose_mode = Column(String(50), default='frontal', nullable=False, index=True) # e.g., 'frontal', 'profile_left'
- yaw_angle = Column(Numeric, nullable=True) # Yaw angle in degrees
- pitch_angle = Column(Numeric, nullable=True) # Pitch angle in degrees
- roll_angle = Column(Numeric, nullable=True) # Roll angle in degrees
-```
-
-#### Step 1.2: Update Database Methods
-
-**`src/core/database.py` - `add_face()` method:**
-```python
-def add_face(self, photo_id: int, encoding: bytes, location: str,
- confidence: float = 0.0, quality_score: float = 0.0,
- person_id: Optional[int] = None,
- detector_backend: str = 'retinaface',
- model_name: str = 'ArcFace',
- face_confidence: float = 0.0,
- pose_mode: str = 'frontal', # Pose mode classification
- yaw_angle: Optional[float] = None, # Yaw angle in degrees
- pitch_angle: Optional[float] = None, # Pitch angle in degrees
- roll_angle: Optional[float] = None) -> int: # Roll angle in degrees
- """Add face to database with pose detection"""
- cursor.execute('''
- INSERT INTO faces (photo_id, person_id, encoding, location,
- confidence, quality_score, is_primary_encoding,
- detector_backend, model_name, face_confidence,
- pose_mode, yaw_angle, pitch_angle, roll_angle)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- ''', (photo_id, person_id, encoding, location, confidence,
- quality_score, False, detector_backend, model_name,
- face_confidence, pose_mode, yaw_angle, pitch_angle, roll_angle))
- return cursor.lastrowid
-```
-
----
-
-### Phase 2: RetinaFace Direct Integration
-
-#### Step 2.1: Install/Verify RetinaFace Library
-
-**Check if RetinaFace is available:**
-```python
-try:
- from retinaface import RetinaFace
- RETINAFACE_AVAILABLE = True
-except ImportError:
- RETINAFACE_AVAILABLE = False
- # RetinaFace is typically installed with DeepFace
- # If not, install: pip install retina-face
-```
-
-**Update `requirements.txt`:**
-```
-retina-face>=0.0.13 # Already included, but verify version
-```
-
-#### Step 2.2: Create Pose Detection Utility
-
-**New file: `src/utils/pose_detection.py`**
-
-```python
-"""Face pose detection (yaw, pitch, roll) using RetinaFace landmarks"""
-
-import numpy as np
-from math import atan2, degrees, pi
-from typing import Dict, Tuple, Optional, List
-
-try:
- from retinaface import RetinaFace
- RETINAFACE_AVAILABLE = True
-except ImportError:
- RETINAFACE_AVAILABLE = False
- RetinaFace = None
-
-
-class PoseDetector:
- """Detect face pose (yaw, pitch, roll) using RetinaFace landmarks"""
-
- # Thresholds for pose detection (in degrees)
- PROFILE_YAW_THRESHOLD = 30.0 # Faces with |yaw| >= 30° are considered profile
- EXTREME_YAW_THRESHOLD = 60.0 # Faces with |yaw| >= 60° are extreme profile
-
- PITCH_THRESHOLD = 20.0 # Faces with |pitch| >= 20° are looking up/down
- EXTREME_PITCH_THRESHOLD = 45.0 # Faces with |pitch| >= 45° are extreme
-
- ROLL_THRESHOLD = 15.0 # Faces with |roll| >= 15° are tilted
- EXTREME_ROLL_THRESHOLD = 45.0 # Faces with |roll| >= 45° are extreme
-
- def __init__(self,
- yaw_threshold: float = None,
- pitch_threshold: float = None,
- roll_threshold: float = None):
- """Initialize pose detector
-
- Args:
- yaw_threshold: Yaw angle threshold for profile detection (degrees)
- Default: 30.0
- pitch_threshold: Pitch angle threshold for up/down detection (degrees)
- Default: 20.0
- roll_threshold: Roll angle threshold for tilt detection (degrees)
- Default: 15.0
- """
- if not RETINAFACE_AVAILABLE:
- raise RuntimeError("RetinaFace not available")
-
- self.yaw_threshold = yaw_threshold or self.PROFILE_YAW_THRESHOLD
- self.pitch_threshold = pitch_threshold or self.PITCH_THRESHOLD
- self.roll_threshold = roll_threshold or self.ROLL_THRESHOLD
-
- @staticmethod
- def detect_faces_with_landmarks(img_path: str) -> Dict:
- """Detect faces using RetinaFace directly
-
- Returns:
- Dictionary with face keys and landmark data:
- {
- 'face_1': {
- 'facial_area': {'x': x, 'y': y, 'w': w, 'h': h},
- 'landmarks': {
- 'left_eye': (x, y),
- 'right_eye': (x, y),
- 'nose': (x, y),
- 'left_mouth': (x, y),
- 'right_mouth': (x, y)
- },
- 'confidence': 0.95
- }
- }
- """
- if not RETINAFACE_AVAILABLE:
- return {}
-
- faces = RetinaFace.detect_faces(img_path)
- return faces
-
- @staticmethod
- def calculate_yaw_from_landmarks(landmarks: Dict) -> Optional[float]:
- """Calculate yaw angle from facial landmarks
-
- Args:
- landmarks: Dictionary with landmark positions:
- {
- 'left_eye': (x, y),
- 'right_eye': (x, y),
- 'nose': (x, y),
- 'left_mouth': (x, y),
- 'right_mouth': (x, y)
- }
-
- Returns:
- Yaw angle in degrees (-90 to +90):
- - Negative: face turned left (right profile)
- - Positive: face turned right (left profile)
- - Zero: frontal face
- - None: if landmarks invalid
- """
- if not landmarks:
- return None
-
- left_eye = landmarks.get('left_eye')
- right_eye = landmarks.get('right_eye')
- nose = landmarks.get('nose')
-
- if not all([left_eye, right_eye, nose]):
- return None
-
- # Calculate eye midpoint
- eye_mid_x = (left_eye[0] + right_eye[0]) / 2
- eye_mid_y = (left_eye[1] + right_eye[1]) / 2
-
- # Calculate horizontal distance from nose to eye midpoint
- nose_x = nose[0]
- eye_midpoint_x = eye_mid_x
-
- # Calculate face width (eye distance)
- face_width = abs(right_eye[0] - left_eye[0])
-
- if face_width == 0:
- return None
-
- # Calculate horizontal offset
- horizontal_offset = nose_x - eye_midpoint_x
-
- # Calculate yaw angle using atan2
- # Normalize by face width to get angle
- yaw_radians = atan2(horizontal_offset, face_width)
- yaw_degrees = degrees(yaw_radians)
-
- return yaw_degrees
-
- @staticmethod
- def calculate_pitch_from_landmarks(landmarks: Dict) -> Optional[float]:
- """Calculate pitch angle from facial landmarks (up/down tilt)
-
- Args:
- landmarks: Dictionary with landmark positions
-
- Returns:
- Pitch angle in degrees (-90 to +90):
- - Positive: looking up
- - Negative: looking down
- - None: if landmarks invalid
- """
- if not landmarks:
- return None
-
- left_eye = landmarks.get('left_eye')
- right_eye = landmarks.get('right_eye')
- left_mouth = landmarks.get('left_mouth')
- right_mouth = landmarks.get('right_mouth')
- nose = landmarks.get('nose')
-
- if not all([left_eye, right_eye, left_mouth, right_mouth, nose]):
- return None
-
- # Eye midpoint
- eye_mid_y = (left_eye[1] + right_eye[1]) / 2
- # Mouth midpoint
- mouth_mid_y = (left_mouth[1] + right_mouth[1]) / 2
- # Nose vertical position
- nose_y = nose[1]
-
- # Expected nose position (typically 60% down from eyes to mouth)
- expected_nose_y = eye_mid_y + (mouth_mid_y - eye_mid_y) * 0.6
- face_height = abs(mouth_mid_y - eye_mid_y)
-
- if face_height == 0:
- return None
-
- # Vertical offset from expected position
- vertical_offset = nose_y - expected_nose_y
-
- # Calculate pitch angle
- pitch_radians = atan2(vertical_offset, face_height)
- pitch_degrees = degrees(pitch_radians)
-
- return pitch_degrees
-
- @staticmethod
- def calculate_roll_from_landmarks(landmarks: Dict) -> Optional[float]:
- """Calculate roll angle from facial landmarks (rotation around face axis)
-
- Args:
- landmarks: Dictionary with landmark positions
-
- Returns:
- Roll angle in degrees (-90 to +90):
- - Positive: tilted right (clockwise)
- - Negative: tilted left (counterclockwise)
- - None: if landmarks invalid
- """
- if not landmarks:
- return None
-
- left_eye = landmarks.get('left_eye')
- right_eye = landmarks.get('right_eye')
-
- if not all([left_eye, right_eye]):
- return None
-
- # Calculate angle of eye line
- dx = right_eye[0] - left_eye[0]
- dy = right_eye[1] - left_eye[1]
-
- if dx == 0:
- return 90.0 if dy > 0 else -90.0 # Vertical line
-
- # Roll angle
- roll_radians = atan2(dy, dx)
- roll_degrees = degrees(roll_radians)
-
- return roll_degrees
-
- @staticmethod
- def classify_pose_mode(yaw: Optional[float],
- pitch: Optional[float],
- roll: Optional[float]) -> str:
- """Classify face pose mode from all three angles
-
- Args:
- yaw: Yaw angle in degrees
- pitch: Pitch angle in degrees
- roll: Roll angle in degrees
-
- Returns:
- Pose mode classification string:
- - 'frontal': frontal, level, upright
- - 'profile_left', 'profile_right': profile views
- - 'looking_up', 'looking_down': pitch variations
- - 'tilted_left', 'tilted_right': roll variations
- - Combined modes: e.g., 'profile_left_looking_up'
- """
- # Default to frontal if angles unknown
- if yaw is None:
- yaw = 0.0
- if pitch is None:
- pitch = 0.0
- if roll is None:
- roll = 0.0
-
- # Yaw classification
- abs_yaw = abs(yaw)
- if abs_yaw < 30.0:
- yaw_mode = "frontal"
- elif yaw < -30.0:
- yaw_mode = "profile_right"
- elif yaw > 30.0:
- yaw_mode = "profile_left"
- else:
- yaw_mode = "slight_yaw"
-
- # Pitch classification
- abs_pitch = abs(pitch)
- if abs_pitch < 20.0:
- pitch_mode = "level"
- elif pitch > 20.0:
- pitch_mode = "looking_up"
- elif pitch < -20.0:
- pitch_mode = "looking_down"
- else:
- pitch_mode = "slight_pitch"
-
- # Roll classification
- abs_roll = abs(roll)
- if abs_roll < 15.0:
- roll_mode = "upright"
- elif roll > 15.0:
- roll_mode = "tilted_right"
- elif roll < -15.0:
- roll_mode = "tilted_left"
- else:
- roll_mode = "slight_roll"
-
- # Combine modes - simple case first
- if yaw_mode == "frontal" and pitch_mode == "level" and roll_mode == "upright":
- return "frontal"
-
- # Build combined mode string
- modes = []
- if yaw_mode != "frontal":
- modes.append(yaw_mode)
- if pitch_mode != "level":
- modes.append(pitch_mode)
- if roll_mode != "upright":
- modes.append(roll_mode)
-
- return "_".join(modes) if modes else "frontal"
-
- def detect_pose_faces(self, img_path: str) -> List[Dict]:
- """Detect all faces and classify pose status (all angles)
-
- Args:
- img_path: Path to image file
-
- Returns:
- List of face dictionaries with pose information:
- [{
- 'facial_area': {...},
- 'landmarks': {...},
- 'confidence': 0.95,
- 'yaw_angle': -45.2,
- 'pitch_angle': 10.5,
- 'roll_angle': -5.2,
- 'pose_mode': 'profile_right_level_upright'
- }, ...]
- """
- faces = self.detect_faces_with_landmarks(img_path)
-
- results = []
- for face_key, face_data in faces.items():
- landmarks = face_data.get('landmarks', {})
-
- # Calculate all three angles
- yaw_angle = self.calculate_yaw_from_landmarks(landmarks)
- pitch_angle = self.calculate_pitch_from_landmarks(landmarks)
- roll_angle = self.calculate_roll_from_landmarks(landmarks)
-
- # Classify pose mode
- pose_mode = self.classify_pose_mode(yaw_angle, pitch_angle, roll_angle)
-
- result = {
- 'facial_area': face_data.get('facial_area', {}),
- 'landmarks': landmarks,
- 'confidence': face_data.get('confidence', 0.0),
- 'yaw_angle': yaw_angle,
- 'pitch_angle': pitch_angle,
- 'roll_angle': roll_angle,
- 'pose_mode': pose_mode
- }
- results.append(result)
-
- return results
-```
-
----
-
-### Phase 3: Integrate Portrait Detection into Face Processing
-
-**Important: Backward Compatibility Requirement**
-- All pose detection must have graceful fallback to defaults (`frontal`, `None` angles)
-- If RetinaFace is unavailable or fails, use defaults and continue processing
-- Do not fail face processing if pose detection fails
-- See "Backward Compatibility & Graceful Degradation" section for details
-
-#### Step 3.1: Update Face Processing Pipeline
-
-**File: `src/core/face_processing.py`**
-
-**Changes:**
-1. Import portrait detection utility
-2. Use RetinaFace for detection + landmarks (with graceful fallback)
-3. Use DeepFace for encoding generation
-4. Store portrait status in database (with defaults if unavailable)
-
-**Modified `process_faces()` method:**
-
-```python
-from src.utils.pose_detection import PoseDetector, RETINAFACE_AVAILABLE
-
-class FaceProcessor:
- def __init__(self, ...):
- # ... existing initialization ...
- self.pose_detector = None
- if RETINAFACE_AVAILABLE:
- try:
- self.pose_detector = PoseDetector()
- except Exception as e:
- print(f"⚠️ Pose detection not available: {e}")
-
- def process_faces(self, ...):
- """Process faces with portrait detection"""
- # ... existing code ...
-
- for photo_id, photo_path, filename in unprocessed_photos:
- # Step 1: Use RetinaFace directly for detection + landmarks
- pose_faces = []
- if self.pose_detector:
- try:
- pose_faces = self.pose_detector.detect_pose_faces(photo_path)
- except Exception as e:
- print(f"⚠️ Pose detection failed for {filename}: {e}")
- pose_faces = []
-
- # Step 2: Use DeepFace for encoding generation
- results = DeepFace.represent(
- img_path=face_detection_path,
- model_name=self.model_name,
- detector_backend=self.detector_backend,
- enforce_detection=DEEPFACE_ENFORCE_DETECTION,
- align=DEEPFACE_ALIGN_FACES
- )
-
- # Step 3: Match RetinaFace results with DeepFace results
- # Match by facial_area position
- for i, deepface_result in enumerate(results):
- facial_area = deepface_result.get('facial_area', {})
-
- # Find matching RetinaFace result
- pose_info = self._find_matching_pose_info(
- facial_area, pose_faces
- )
-
- pose_mode = pose_info.get('pose_mode', 'frontal')
- yaw_angle = pose_info.get('yaw_angle')
- pitch_angle = pose_info.get('pitch_angle')
- roll_angle = pose_info.get('roll_angle')
-
- # Store face with pose information
- face_id = self.db.add_face(
- photo_id=photo_id,
- encoding=encoding.tobytes(),
- location=location_str,
- confidence=0.0,
- quality_score=quality_score,
- person_id=None,
- detector_backend=self.detector_backend,
- model_name=self.model_name,
- face_confidence=face_confidence,
- pose_mode=pose_mode,
- yaw_angle=yaw_angle,
- pitch_angle=pitch_angle,
- roll_angle=roll_angle
- )
-
- def _find_matching_pose_info(self, facial_area: Dict,
- pose_faces: List[Dict]) -> Dict:
- """Match DeepFace result with RetinaFace pose detection result
-
- Args:
- facial_area: DeepFace facial_area {'x': x, 'y': y, 'w': w, 'h': h}
- pose_faces: List of RetinaFace detection results with pose info
-
- Returns:
- Dictionary with pose information, or defaults
- """
- # Match by bounding box overlap
- # Simple approach: find closest match by center point
- if not pose_faces:
- return {
- 'pose_mode': 'frontal',
- 'yaw_angle': None,
- 'pitch_angle': None,
- 'roll_angle': None
- }
-
- deepface_center_x = facial_area.get('x', 0) + facial_area.get('w', 0) / 2
- deepface_center_y = facial_area.get('y', 0) + facial_area.get('h', 0) / 2
-
- best_match = None
- min_distance = float('inf')
-
- for pose_face in pose_faces:
- pose_area = pose_face.get('facial_area', {})
- pose_center_x = (pose_area.get('x', 0) +
- pose_area.get('w', 0) / 2)
- pose_center_y = (pose_area.get('y', 0) +
- pose_area.get('h', 0) / 2)
-
- # Calculate distance between centers
- distance = ((deepface_center_x - pose_center_x) ** 2 +
- (deepface_center_y - pose_center_y) ** 2) ** 0.5
-
- if distance < min_distance:
- min_distance = distance
- best_match = pose_face
-
- # If match is close enough (within 50 pixels), use it
- if best_match and min_distance < 50:
- return {
- 'pose_mode': best_match.get('pose_mode', 'frontal'),
- 'yaw_angle': best_match.get('yaw_angle'),
- 'pitch_angle': best_match.get('pitch_angle'),
- 'roll_angle': best_match.get('roll_angle')
- }
-
- return {
- 'pose_mode': 'frontal',
- 'yaw_angle': None,
- 'pitch_angle': None,
- 'roll_angle': None
- }
-```
-
-#### Step 3.2: Update Web Face Service
-
-**File: `src/web/services/face_service.py`**
-
-Similar changes to integrate portrait detection in web service:
-
-```python
-from src.utils.pose_detection import PoseDetector, RETINAFACE_AVAILABLE
-
-def process_photo_faces(...):
- """Process faces with pose detection"""
- # ... existing code ...
-
- # Step 1: Detect faces with RetinaFace for landmarks
- pose_detector = None
- pose_faces = []
- if RETINAFACE_AVAILABLE:
- try:
- pose_detector = PoseDetector()
- pose_faces = pose_detector.detect_pose_faces(photo_path)
- except Exception as e:
- print(f"[FaceService] Pose detection failed: {e}")
-
- # Step 2: Use DeepFace for encoding
- results = DeepFace.represent(...)
-
- # Step 3: Match and store
- for idx, result in enumerate(results):
- # ... existing processing ...
-
- # Match pose info
- pose_info = _find_matching_pose_info(
- facial_area, pose_faces
- )
-
- # Store face
- face = Face(
- # ... existing fields ...
- pose_mode=pose_info.get('pose_mode', 'frontal'),
- yaw_angle=pose_info.get('yaw_angle'),
- pitch_angle=pose_info.get('pitch_angle'),
- roll_angle=pose_info.get('roll_angle')
- )
-```
-
----
-
-### Phase 4: Update Auto-Match Filtering
-
-#### Step 4.1: Add Portrait Filter to Auto-Match
-
-**File: `src/web/services/face_service.py` - `find_auto_match_matches()`**
-
-```python
-def find_auto_match_matches(
- db: Session,
- tolerance: float = 0.6,
- exclude_portraits: bool = True, # NEW parameter
-) -> List[Tuple[int, int, Face, List[Tuple[Face, float, float]]]]:
- """Find auto-match matches with optional portrait filtering"""
-
- # ... existing code to get identified faces ...
-
- # For each person, find similar faces
- for person_id, reference_face in person_faces.items():
- # Find similar faces
- similar_faces = find_similar_faces(
- db, reference_face.id, limit=100, tolerance=tolerance
- )
-
- # Filter out portraits/extreme angles if requested
- if exclude_portraits:
- similar_faces = [
- (face, distance, confidence_pct)
- for face, distance, confidence_pct in similar_faces
- if face.pose_mode == 'frontal' # Filter non-frontal faces
- ]
-
- # ... rest of matching logic ...
-```
-
-#### Step 4.2: Update Auto-Match API
-
-**File: `src/web/api/faces.py`**
-
-```python
-@router.post("/auto-match", response_model=AutoMatchResponse)
-def auto_match_faces(
- request: AutoMatchRequest,
- db: Session = Depends(get_db),
-) -> AutoMatchResponse:
- """Auto-match with portrait filtering option"""
-
- # Get exclude_portraits from request (default: True)
- exclude_portraits = getattr(request, 'exclude_portraits', True)
-
- matches = find_auto_match_matches(
- db,
- tolerance=request.tolerance,
- exclude_portraits=exclude_portraits, # NEW
- )
- # ... rest of logic ...
-```
-
-**File: `src/web/schemas/faces.py`**
-
-```python
-class AutoMatchRequest(BaseModel):
- tolerance: float = Field(0.6, ge=0.0, le=1.0)
- exclude_portraits: bool = Field(True, description="Exclude portrait/profile faces from matching") # NEW
- exclude_pose_modes: List[str] = Field([], description="Exclude specific pose modes (e.g., ['looking_up', 'tilted'])") # NEW
- exclude_extreme_angles: bool = Field(True, description="Exclude extreme angle faces (|yaw|>60°, |pitch|>45°, |roll|>45°)") # NEW
-```
-
-#### Step 4.3: Update Frontend Auto-Match UI
-
-**File: `frontend/src/pages/AutoMatch.tsx`**
-
-```typescript
-// Add checkbox for excluding portraits
-const [excludePortraits, setExcludePortraits] = useState(true)
-
-const startAutoMatch = async () => {
- // ...
- const response = await facesApi.autoMatch({
- tolerance,
- exclude_portraits: excludePortraits // NEW
- })
- // ...
-}
-
-// Add UI control
-
-
- setExcludePortraits(e.target.checked)}
- />
- Exclude portrait/profile faces
-
-
-```
-
----
-
-### Phase 5: Update Identify Panel Filtering
-
-**File: `src/web/services/face_service.py` - `find_similar_faces()`**
-
-```python
-def find_similar_faces(
- db: Session,
- face_id: int,
- limit: int = 20,
- tolerance: float = 0.6,
- exclude_portraits: bool = False, # NEW: optional filtering
- exclude_pose_modes: List[str] = None, # NEW: exclude specific pose modes
- exclude_extreme_angles: bool = False, # NEW: exclude extreme angles
-) -> List[Tuple[Face, float, float]]:
- """Find similar faces with optional pose filtering"""
-
- # ... existing matching logic ...
-
- # Filter by pose if requested
- if exclude_portraits or exclude_pose_modes or exclude_extreme_angles:
- filtered_matches = []
- for face, distance, confidence_pct in matches:
- # Exclude portraits (non-frontal faces)
- if exclude_portraits and face.pose_mode != 'frontal':
- continue
-
- # Exclude specific pose modes
- if exclude_pose_modes and face.pose_mode in exclude_pose_modes:
- continue
-
- # Exclude extreme angles
- if exclude_extreme_angles:
- yaw = abs(face.yaw_angle) if face.yaw_angle else 0
- pitch = abs(face.pitch_angle) if face.pitch_angle else 0
- roll = abs(face.roll_angle) if face.roll_angle else 0
- if yaw > 60 or pitch > 45 or roll > 45:
- continue
-
- filtered_matches.append((face, distance, confidence_pct))
-
- matches = filtered_matches
-
- return matches[:limit]
-```
-
----
-
-### Phase 6: Testing Strategy
-
-#### Unit Tests
-
-**New file: `tests/test_pose_detection.py`**
-
-```python
-import pytest
-from src.utils.pose_detection import (
- PoseDetector,
- calculate_yaw_from_landmarks,
- calculate_pitch_from_landmarks,
- calculate_roll_from_landmarks,
- classify_pose_mode
-)
-
-def test_pose_detector_initialization():
- """Test pose detector can be initialized"""
- detector = PoseDetector()
- assert detector.yaw_threshold == 30.0
- assert detector.pitch_threshold == 20.0
- assert detector.roll_threshold == 15.0
-
-def test_yaw_calculation():
- """Test yaw angle calculation from landmarks"""
- # Frontal face landmarks
- frontal_landmarks = {
- 'left_eye': (100, 100),
- 'right_eye': (200, 100),
- 'nose': (150, 150),
- 'left_mouth': (120, 200),
- 'right_mouth': (180, 200)
- }
- yaw = calculate_yaw_from_landmarks(frontal_landmarks)
- assert abs(yaw) < 30.0, "Frontal face should have low yaw"
-
- # Profile face landmarks (face turned right)
- profile_landmarks = {
- 'left_eye': (150, 100),
- 'right_eye': (200, 100),
- 'nose': (180, 150), # Nose shifted right
- 'left_mouth': (160, 200),
- 'right_mouth': (190, 200)
- }
- yaw = calculate_yaw_from_landmarks(profile_landmarks)
- assert abs(yaw) >= 30.0, "Profile face should have high yaw"
-
-def test_pitch_calculation():
- """Test pitch angle calculation from landmarks"""
- # Level face landmarks
- level_landmarks = {
- 'left_eye': (100, 100),
- 'right_eye': (200, 100),
- 'nose': (150, 150), # Normal nose position
- 'left_mouth': (120, 200),
- 'right_mouth': (180, 200)
- }
- pitch = calculate_pitch_from_landmarks(level_landmarks)
- assert abs(pitch) < 20.0, "Level face should have low pitch"
-
- # Looking up landmarks (nose higher)
- looking_up_landmarks = {
- 'left_eye': (100, 100),
- 'right_eye': (200, 100),
- 'nose': (150, 120), # Nose higher than expected
- 'left_mouth': (120, 200),
- 'right_mouth': (180, 200)
- }
- pitch = calculate_pitch_from_landmarks(looking_up_landmarks)
- assert pitch > 20.0, "Looking up should have positive pitch"
-
-def test_roll_calculation():
- """Test roll angle calculation from landmarks"""
- # Upright face landmarks
- upright_landmarks = {
- 'left_eye': (100, 100),
- 'right_eye': (200, 100), # Eyes level
- 'nose': (150, 150),
- 'left_mouth': (120, 200),
- 'right_mouth': (180, 200)
- }
- roll = calculate_roll_from_landmarks(upright_landmarks)
- assert abs(roll) < 15.0, "Upright face should have low roll"
-
- # Tilted face landmarks
- tilted_landmarks = {
- 'left_eye': (100, 100),
- 'right_eye': (200, 120), # Right eye lower (tilted right)
- 'nose': (150, 150),
- 'left_mouth': (120, 200),
- 'right_mouth': (180, 200)
- }
- roll = calculate_roll_from_landmarks(tilted_landmarks)
- assert abs(roll) >= 15.0, "Tilted face should have high roll"
-
-def test_pose_mode_classification():
- """Test pose mode classification"""
- # Frontal face
- mode = classify_pose_mode(10.0, 5.0, 3.0)
- assert mode == 'frontal', "Should classify as frontal"
-
- # Profile left
- mode = classify_pose_mode(45.0, 5.0, 3.0)
- assert 'profile_left' in mode, "Should classify as profile_left"
-
- # Looking up
- mode = classify_pose_mode(10.0, 30.0, 3.0)
- assert 'looking_up' in mode, "Should classify as looking_up"
-
- # Tilted right
- mode = classify_pose_mode(10.0, 5.0, 25.0)
- assert 'tilted_right' in mode, "Should classify as tilted_right"
-
- # Combined mode
- mode = classify_pose_mode(45.0, 30.0, 25.0)
- assert 'profile_left' in mode and 'looking_up' in mode, "Should have combined mode"
-
-```
-
-#### Integration Tests
-
-**Test pose detection in face processing pipeline:**
-1. Process test images with frontal faces → `pose_mode = 'frontal'`
-2. Process test images with profile faces → `pose_mode = 'profile_left'` or `'profile_right'`
-3. Process test images with looking up faces → `pose_mode = 'looking_up'`
-4. Process test images with tilted faces → `pose_mode = 'tilted_left'` or `'tilted_right'`
-5. Process test images with combined poses → `pose_mode = 'profile_left_looking_up'`, etc.
-6. Verify pose information (pose_mode, angles) is stored correctly
-7. Test auto-match filtering excludes portraits, extreme angles, and specific pose modes
-
----
-
-
----
-
-## Implementation Checklist
-
-### Phase 1: Database Schema
-- [ ] Create Alembic migration for pose fields (`pose_mode`, `yaw_angle`, `pitch_angle`, `roll_angle`)
-- [ ] Update desktop database schema (`src/core/database.py`)
-- [ ] Update SQLAlchemy model (`src/web/db/models.py`)
-- [ ] Update `DatabaseManager.add_face()` method signature
-- [ ] Run migration on test database
-- [ ] Verify schema changes
-
-### Phase 2: Pose Detection Utility
-- [ ] Create `src/utils/pose_detection.py`
-- [ ] Implement `PoseDetector` class
-- [ ] Implement landmark-based yaw calculation
-- [ ] Implement landmark-based pitch calculation
-- [ ] Implement landmark-based roll calculation
-- [ ] Implement pose mode classification logic
-- [ ] Write unit tests for pose detection (yaw, pitch, roll)
-- [ ] Test with sample images (frontal, profile, looking up/down, tilted, extreme)
-
-### Phase 3: Face Processing Integration
-- [ ] Update `src/core/face_processing.py` to use RetinaFace directly
-- [ ] Integrate pose detection into processing pipeline **with graceful fallback**
-- [ ] Implement face matching logic (RetinaFace ↔ DeepFace) **with defaults if matching fails**
-- [ ] Update `src/web/services/face_service.py` **with graceful fallback**
-- [ ] Test processing with mixed pose faces (frontal, profile, looking up/down, tilted)
-- [ ] Verify pose information in database (pose_mode, angles)
-- [ ] **Test backward compatibility: verify processing continues if RetinaFace unavailable**
-- [ ] **Test error handling: verify processing continues if pose detection fails**
-
-### Phase 4: Auto-Match Filtering
-- [ ] Add pose filtering parameters to auto-match functions (`exclude_portraits`, `exclude_pose_modes`, `exclude_extreme_angles`)
-- [ ] Update auto-match API endpoint
-- [ ] Update auto-match schema
-- [ ] Update frontend auto-match UI with pose filtering options
-- [ ] Test auto-match with various pose filtering options enabled/disabled
-
-### Phase 5: Identify Panel Filtering
-- [ ] Add optional pose filtering to similar faces
-- [ ] Update identify API with pose filtering options (optional)
-- [ ] Test identify panel with pose filtering
-
-### Phase 6: Testing
-- [ ] Write unit tests for pose detection (yaw, pitch, roll)
-- [ ] Write integration tests for face processing with pose detection
-- [ ] Write tests for auto-match filtering (all pose modes)
-- [ ] Test with real-world images (frontal, profile, looking up/down, tilted, extreme)
-- [ ] Performance testing (ensure minimal overhead)
-- [ ] Accuracy testing (verify > 90% correct pose classification)
-- [ ] **Backward compatibility testing: test with existing databases (add columns, verify queries work)**
-- [ ] **Graceful degradation testing: test with RetinaFace unavailable (should use defaults)**
-- [ ] **Error handling testing: test with RetinaFace errors (should use defaults, not fail)**
-- [ ] **Verify existing `add_face()` calls still work without pose parameters**
-- [ ] **Verify face matching still works without pose data**
-
-### Phase 7: Documentation
-- [ ] Update README with pose detection feature
-- [ ] Document pose modes and filtering options
-- [ ] Update API documentation with pose filtering parameters
-- [ ] Create migration guide (if needed)
-- [ ] Document pose mode classifications and thresholds
-
----
-
-## Performance Considerations
-
-### Expected Overhead
-
-1. **Additional RetinaFace Call:**
- - RetinaFace is already used by DeepFace internally
- - Direct call adds ~10-50ms per image (depending on image size)
- - Can be optimized by caching results
-
-2. **Landmark Processing:**
- - Yaw calculation is very fast (< 1ms per face)
- - Negligible performance impact
-
-3. **Database:**
- - New pose_mode field: text string (50 chars max, ~50 bytes per face)
- - Optional yaw_angle, pitch_angle, roll_angle: 8 bytes each if stored
- - Index on `pose_mode` for fast filtering
-
-### Optimization Strategies
-
-1. **Cache RetinaFace Results:**
- - Store RetinaFace detection results temporarily
- - Reuse for both DeepFace and pose detection
-
-2. **Parallel Processing:**
- - Run RetinaFace and DeepFace in parallel (if possible)
- - Combine results afterwards
-
-3. **Lazy Evaluation:**
- - Only run pose detection if explicitly requested
- - Make it optional via configuration
-
----
-
-## Configuration Options
-
-### Add to `src/core/config.py`:
-
-```python
-# Pose Detection Settings
-ENABLE_POSE_DETECTION = True # Enable/disable pose detection
-POSE_YAW_THRESHOLD = 30.0 # Yaw angle threshold for profile detection (degrees)
-POSE_PITCH_THRESHOLD = 20.0 # Pitch angle threshold for up/down detection (degrees)
-POSE_ROLL_THRESHOLD = 15.0 # Roll angle threshold for tilt detection (degrees)
-STORE_POSE_ANGLES = True # Store yaw/pitch/roll angles in database (optional)
-EXCLUDE_NON_FRONTAL_IN_AUTOMATCH = True # Default auto-match behavior (exclude non-frontal faces)
-EXCLUDE_EXTREME_ANGLES_IN_AUTOMATCH = True # Exclude extreme angles by default
-```
-
----
-
-## Success Criteria
-
-1. ✅ Face poses (yaw, pitch, roll) are automatically detected during processing
-2. ✅ Pose information (pose_mode, angles) is stored in database for all faces
-3. ✅ Auto-match can filter faces by pose mode (profile, looking up/down, tilted, extreme angles)
-4. ✅ Performance impact is minimal (< 10% processing time increase)
-5. ✅ Accuracy: > 90% correct classification of pose modes (frontal, profile, looking up/down, tilted)
-6. ✅ Support for combined pose modes (e.g., profile_left_looking_up_tilted_right)
-7. ✅ Clean database implementation - all faces have pose data from the start
-
----
-
-## Risks and Mitigation
-
-### Risk 1: False Positives/Negatives
-- **Risk:** Profile detection may misclassify some faces
-- **Mitigation:** Tune threshold based on testing, allow manual override
-
-### Risk 2: Performance Impact
-- **Risk:** Additional RetinaFace call slows processing
-- **Mitigation:** Optimize by caching results, make it optional
-
-### Risk 3: RetinaFace Dependency
-- **Risk:** RetinaFace may not be available or may fail
-- **Mitigation:** Graceful fallback to default (pose_mode = 'frontal')
-
-### Risk 4: Matching Accuracy
-- **Risk:** Matching RetinaFace and DeepFace results may be inaccurate
-- **Mitigation:** Use bounding box overlap for matching, test thoroughly
-
-### Risk 5: Clean Database Requirements
-- **Risk:** Database will be wiped - all existing data will be lost
-- **Mitigation:** This is intentional - plan assumes fresh database start
-
----
-
-## Backward Compatibility & Graceful Degradation
-
-### Make Pose Detection Optional with Graceful Fallback
-
-To ensure existing functionality continues to work without disruption, pose detection must be implemented with graceful fallback mechanisms:
-
-#### 1. **RetinaFace Availability Check**
-- Check if RetinaFace is available before attempting pose detection
-- If RetinaFace is not available, skip pose detection and use defaults
-- Log warnings but do not fail face processing
-
-```python
-# In face_processing.py
-if RETINAFACE_AVAILABLE:
- try:
- pose_faces = self.pose_detector.detect_pose_faces(photo_path)
- except Exception as e:
- print(f"⚠️ Pose detection failed: {e}, using defaults")
- pose_faces = [] # Fallback to defaults
-else:
- pose_faces = [] # RetinaFace not available, use defaults
-```
-
-#### 2. **Default Values for Missing Pose Data**
-- If pose detection fails or is unavailable, use safe defaults:
- - `pose_mode = 'frontal'` (assumes frontal face)
- - `yaw_angle = None`
- - `pitch_angle = None`
- - `roll_angle = None`
-- All existing faces without pose data will default to `'frontal'`
-- New faces processed without pose detection will also use defaults
-
-#### 3. **Database Schema Defaults**
-- All new columns have default values:
- - `pose_mode TEXT DEFAULT 'frontal'` (NOT NULL with default)
- - `yaw_angle REAL DEFAULT NULL` (nullable)
- - `pitch_angle REAL DEFAULT NULL` (nullable)
- - `roll_angle REAL DEFAULT NULL` (nullable)
-- Existing queries will continue to work (NULL values for angles are acceptable)
-- Existing faces will automatically get `pose_mode = 'frontal'` when schema is updated
-
-#### 4. **Method Signature Compatibility**
-- `add_face()` method signature adds new optional parameters with defaults:
- ```python
- def add_face(self, ...,
- pose_mode: str = 'frontal', # Default value
- yaw_angle: Optional[float] = None, # Optional
- pitch_angle: Optional[float] = None, # Optional
- roll_angle: Optional[float] = None) # Optional
- ```
-- All existing calls to `add_face()` will continue to work without modification
-- New parameters are optional and backward compatible
-
-#### 5. **Error Handling in Face Processing**
-- If RetinaFace detection fails for a specific photo:
- - Log the error but continue processing
- - Use default pose values (`frontal`, `None` angles)
- - Do not fail the entire photo processing
-- If pose matching between RetinaFace and DeepFace fails:
- - Use default pose values
- - Log warning but continue processing
-
-#### 6. **Configuration Flag (Optional)**
-- Add configuration option to enable/disable pose detection:
- ```python
- # In config.py
- ENABLE_POSE_DETECTION = True # Can be disabled if needed
- ```
-- If disabled, skip RetinaFace calls entirely and use defaults
-- Allows users to disable feature if experiencing issues
-
-#### 7. **Graceful Degradation Benefits**
-- **Existing functionality preserved:** All current features continue to work
-- **No breaking changes:** Database queries, face matching, auto-match all work
-- **Progressive enhancement:** Pose detection adds value when available, but doesn't break when unavailable
-- **Performance fallback:** If RetinaFace is slow or unavailable, processing continues without pose data
-
-#### 8. **Testing Backward Compatibility**
-- Test with existing databases (add columns, verify queries still work)
-- Test with RetinaFace unavailable (should use defaults)
-- Test with RetinaFace errors (should use defaults, not fail)
-- Verify existing `add_face()` calls still work
-- Verify face matching still works without pose data
-
----
-
-## Future Enhancements
-
-1. **Landmark Visualization:**
- - Show landmarks in UI for debugging
- - Visualize pose angles (yaw, pitch, roll)
-
-2. **Advanced Filtering:**
- - Filter by specific angle ranges (e.g., yaw between -45° and 45°)
- - Filter by individual pose modes (e.g., only profile_left, exclude looking_up)
- - Custom pose mode combinations
-
-3. **Quality-Based Filtering:**
- - Combine pose information with quality score
- - Filter low-quality faces with extreme angles
- - Prefer frontal faces for matching
-
-4. **Pose Estimation Refinement:**
- - Use more sophisticated algorithms (e.g., 3D face model fitting)
- - Improve accuracy for edge cases
- - Handle occluded faces better
-
-5. **UI Enhancements:**
- - Display pose information in face details
- - Visual indicators for pose modes (icons, colors)
- - Pose-based photo organization
-
----
-
-## Timeline Estimate
-
-- **Phase 1 (Database):** 1-2 days
-- **Phase 2 (Pose Detection Utility):** 3-4 days (includes yaw, pitch, roll calculations)
-- **Phase 3 (Face Processing Integration):** 3-4 days
-- **Phase 4 (Auto-Match Filtering):** 2-3 days (includes all pose filtering options)
-- **Phase 5 (Identify Panel):** 1-2 days
-- **Phase 6 (Testing):** 3-4 days (testing all pose modes)
-- **Phase 7 (Documentation):** 1-2 days
-
-**Total Estimate:** 14-21 days
-
----
-
-## Conclusion
-
-This plan provides a comprehensive approach to implementing automatic face pose detection (yaw, pitch, roll) using RetinaFace directly. The implementation will enable automatic classification of face poses into multiple modes (frontal, profile, looking up/down, tilted, and combinations) and intelligent filtering in auto-match and other features.
-
-**Key Features:**
-- **Multiple Pose Modes:** Detects yaw (profile), pitch (looking up/down), and roll (tilted) angles
-- **Combined Classifications:** Supports combined pose modes (e.g., profile_left_looking_up_tilted_right)
-- **Flexible Filtering:** Multiple filtering options (exclude portraits, exclude specific pose modes, exclude extreme angles)
-- **Clean Database Design:** All faces have pose data from the start - no migration needed
-- **Performance Optimized:** Minimal overhead with efficient angle calculations
-
-The phased approach ensures incremental progress with testing at each stage, minimizing risk and allowing for adjustments based on real-world testing results. This comprehensive pose detection system will significantly improve face matching accuracy and user experience by intelligently filtering out low-quality or difficult-to-match face poses.
-
diff --git a/docs/README_OLD.md b/docs/README_OLD.md
deleted file mode 100644
index afea056..0000000
--- a/docs/README_OLD.md
+++ /dev/null
@@ -1,1048 +0,0 @@
-# PunimTag CLI - Minimal Photo Face Tagger
-
-A simple command-line tool for automatic face recognition and photo tagging. No web interface, no complex dependencies - just the essentials.
-
-## 📋 System Requirements
-
-### Minimum Requirements
-- **Python**: 3.7 or higher
-- **Operating System**: Linux, macOS, or Windows
-- **RAM**: 2GB+ (4GB+ recommended for large photo collections)
-- **Storage**: 100MB for application + space for photos and database
-- **Display**: X11 display server (Linux) or equivalent for image viewing
-
-### Supported Platforms
-- ✅ **Ubuntu/Debian** (fully supported with automatic dependency installation)
-- ✅ **macOS** (manual dependency installation required)
-- ✅ **Windows** (with WSL or manual setup)
-- ⚠️ **Other Linux distributions** (manual dependency installation required)
-
-### What Gets Installed Automatically (Ubuntu/Debian)
-The setup script automatically installs these system packages:
-- **Build tools**: `cmake`, `build-essential`
-- **Math libraries**: `libopenblas-dev`, `liblapack-dev` (for face recognition)
-- **GUI libraries**: `libx11-dev`, `libgtk-3-dev`, `libboost-python-dev`
-- **Image viewer**: `feh` (for face identification interface)
-
-## 🚀 Quick Start
-
-```bash
-# 1. Setup (one time only) - installs all dependencies including image viewer
-git clone
-cd PunimTag
-python3 -m venv venv
-source venv/bin/activate # On Windows: venv\Scripts\activate
-python3 setup.py # Installs system deps + Python packages
-
-# 2. Scan photos (absolute or relative paths work)
-python3 photo_tagger.py scan /path/to/your/photos
-
-# 3. Process faces
-python3 photo_tagger.py process
-
-# 4. Identify faces with visual display
-python3 photo_tagger.py identify --show-faces
-
-# 5. Auto-match faces across photos (with improved algorithm)
-python3 photo_tagger.py auto-match --show-faces
-
-# 6. View and modify identified faces (NEW!)
-python3 photo_tagger.py modifyidentified
-
-# 7. View statistics
-python3 photo_tagger.py stats
-```
-
-## 📦 Installation
-
-### Automatic Setup (Recommended)
-```bash
-# Clone and setup
-git clone
-cd PunimTag
-
-# Create virtual environment (IMPORTANT!)
-python3 -m venv venv
-source venv/bin/activate # On Windows: venv\Scripts\activate
-
-# Run setup script
-python3 setup.py
-```
-
-**⚠️ IMPORTANT**: Always activate the virtual environment before running any commands:
-```bash
-source venv/bin/activate # Run this every time you open a new terminal
-```
-
-### Manual Setup (Alternative)
-```bash
-python3 -m venv venv
-source venv/bin/activate
-pip install -r requirements.txt
-python3 photo_tagger.py stats # Creates database
-```
-
-## 🎯 Commands
-
-### Scan for Photos
-```bash
-# Scan a folder (absolute path recommended)
-python3 photo_tagger.py scan /path/to/photos
-
-# Scan with relative path (auto-converted to absolute)
-python3 photo_tagger.py scan demo_photos
-
-# Scan recursively (recommended)
-python3 photo_tagger.py scan /path/to/photos --recursive
-```
-
-**📁 Path Handling:**
-- **Absolute paths**: Use full paths like `/home/user/photos` or `C:\Users\Photos`
-- **Relative paths**: Automatically converted to absolute paths (e.g., `demo_photos` → `/current/directory/demo_photos`)
-- **Cross-platform**: Works on Windows, Linux, and macOS
-- **Web-app ready**: Absolute paths work perfectly in web applications
-
-### Process Photos for Faces (with Quality Scoring)
-```bash
-# Process 50 photos (default) - now includes face quality scoring
-python3 photo_tagger.py process
-
-# Process 20 photos with CNN model (more accurate)
-python3 photo_tagger.py process --limit 20 --model cnn
-
-# Process with HOG model (faster)
-python3 photo_tagger.py process --limit 100 --model hog
-```
-
-**🔬 Quality Scoring Features:**
-- **Automatic Assessment** - Each face gets a quality score (0.0-1.0) based on multiple factors
-- **Smart Filtering** - Only faces above quality threshold (≥0.2) are used for matching
-- **Quality Metrics** - Evaluates sharpness, brightness, contrast, size, aspect ratio, and position
-- **Verbose Output** - Use `--verbose` to see quality scores during processing
-
-### Identify Faces (GUI-Enhanced!)
-```bash
-# Identify with GUI interface and face display (RECOMMENDED)
-python3 photo_tagger.py identify --show-faces --batch 10
-
-# GUI mode without face crops (coordinates only)
-python3 photo_tagger.py identify --batch 10
-
-# Auto-match faces across photos with GUI
-python3 photo_tagger.py auto-match --show-faces
-
-# Auto-identify high-confidence matches
-python3 photo_tagger.py auto-match --auto --show-faces
-```
-
-**🎯 New GUI-Based Identification Features:**
-- 🖼️ **Visual Face Display** - See individual face crops in the GUI
-- 📝 **Separate Name Fields** - Dedicated text input fields for first name, last name, middle name, and maiden name
-- 🎯 **Direct Field Storage** - Names are stored directly in separate fields for maximum reliability
-- 🔤 **Last Name Autocomplete** - Smart autocomplete for last names with live filtering as you type
-- ⭐ **Required Field Indicators** - Red asterisks (*) mark required fields (first name, last name, date of birth)
-- ☑️ **Compare with Similar Faces** - Compare current face with similar unidentified faces
-- 🎨 **Modern Interface** - Clean, intuitive GUI with buttons and input fields
-- 💾 **Window Size Memory** - Remembers your preferred window size
-- 🚫 **No Terminal Input** - All interaction through the GUI interface
-- ⬅️ **Back Navigation** - Go back to previous faces (shows images and identification status)
-- 🔄 **Re-identification** - Change identifications by going back and re-identifying
-- 💾 **Auto-Save** - All identifications are saved immediately (no need to save manually)
-- ☑️ **Select All/Clear All** - Bulk selection buttons for similar faces (enabled only when Compare is active)
-- ⚠️ **Smart Navigation Warnings** - Prevents accidental loss of selected similar faces
-- 💾 **Quit Confirmation** - Saves pending identifications when closing the application
-- ⚡ **Performance Optimized** - Pre-fetched data for faster similar faces display
-- 🎯 **Clean Database Storage** - Names are stored as separate first_name and last_name fields without commas
-- 🔧 **Improved Data Handling** - Fixed field restoration and quit confirmation logic for better reliability
- - 🧩 **Unique Faces Only Filter (NEW)**
- - Checkbox in the Date Filters section: "Unique faces only (hide duplicates with high/medium confidence)"
- - Applies only to the main face list (left/navigation); the Similar Faces panel (right) remains unfiltered
- - Groups faces with ≥60% confidence matches (Medium/High/Very High) and shows only one representative
- - Takes effect immediately when toggled (no need to click Apply Filter); Apply Filter is only for date filters
- - Uses existing database encodings for fast, non-blocking filtering
-
-**🎯 New Auto-Match GUI Features:**
-- 📊 **Person-Centric View** - Shows matched person on left, all their unidentified faces on right
-- ☑️ **Checkbox Selection** - Select which unidentified faces to identify with this person
-- 📈 **Confidence Percentages** - Color-coded match confidence levels
-- 🖼️ **Side-by-Side Layout** - Matched person on left, unidentified faces on right
-- 📜 **Scrollable Matches** - Handle many potential matches easily
-- 🎮 **Enhanced Controls** - Back, Next, or Quit buttons (navigation only)
-- 💾 **Smart Save Button** - "Save changes for [Person Name]" button in left panel
-- 🔄 **State Persistence** - Checkbox selections preserved when navigating between people
-- 🚫 **Smart Navigation** - Next button disabled on last person, Back button disabled on first
-- 💾 **Bidirectional Changes** - Can both identify and unidentify faces in the same session
-- ⚡ **Optimized Performance** - Efficient database queries and streamlined interface
- - 🔍 **Last Name Search** - Filter matched people by last name (case-insensitive) in the left panel
- - 🎯 **Filter-Aware Navigation** - Auto-selects the first match; Back/Next respect the filtered list
-
-### View & Modify Identified Faces (NEW)
-```bash
-# Open the Modify Identified Faces interface
-python3 photo_tagger.py modifyidentified
-```
-
-This GUI lets you quickly review all identified people, rename them, and temporarily unmatch faces before committing.
-
-### Tag Manager GUI (NEW)
-```bash
-# Open the Tag Management interface
-python3 photo_tagger.py tag-manager
-```
-
-This GUI provides a file explorer-like interface for managing photo tags with advanced column resizing and multiple view modes.
-
-**🎯 Tag Manager Features:**
-- 📊 **Multiple View Modes** - List view, icon view, compact view, and folder view for different needs
-- 📁 **Folder Grouping** - Group photos by directory with expandable/collapsible folders
-- 🔧 **Resizable Columns** - Drag column separators to resize both headers and data rows
-- 👁️ **Column Visibility** - Right-click to show/hide columns in each view mode
-- 🖼️ **Thumbnail Display** - Icon view shows photo thumbnails with metadata
-- 📱 **Responsive Layout** - Adapts to window size with proper scrolling
-- 🎨 **Modern Interface** - Clean, intuitive design with visual feedback
-- ⚡ **Fast Performance** - Optimized for large photo collections
-- 🏷️ **Smart Tag Management** - Duplicate tag prevention with silent handling
-- 🔄 **Accurate Change Tracking** - Only counts photos with actual new tags as "changed"
-- 🎯 **Reliable Tag Operations** - Uses tag IDs internally for consistent, bug-free behavior
-- 🔗 **Enhanced Tag Linking** - Linkage icon (🔗) for intuitive tag management
-- 📋 **Comprehensive Tag Dialog** - Manage tags dialog similar to Manage Tags interface
-- ✅ **Pending Tag System** - Add and remove tags with pending changes until saved
-- 🎯 **Visual Status Indicators** - Clear distinction between saved and pending tags
-- 🗑️ **Smart Tag Removal** - Remove both pending and saved tags with proper tracking
-- 🧩 **Linkage Types (Single vs Bulk)** - Tag links can be added per-photo (single) or for the entire folder (bulk). Bulk links appear on folder headers and follow special rules in dialogs
-
-**📋 Available View Modes:**
-
-**List View:**
-- 📄 **Detailed Information** - Shows ID, filename, path, processed status, date taken, face count, and tags
-- 🔧 **Resizable Columns** - Drag red separators between columns to resize
-- 📊 **Column Management** - Right-click headers to show/hide columns
-- 🎯 **Full Data Access** - Complete photo information in tabular format
-
-**Icon View:**
-- 🖼️ **Photo Thumbnails** - Visual grid of photo thumbnails (150x150px)
-- 📝 **Metadata Overlay** - Shows ID, filename, processed status, date taken, face count, and tags
-- 📱 **Responsive Grid** - Thumbnails wrap to fit window width
-- 🎨 **Visual Navigation** - Easy browsing through photo collection
-
-**Compact View:**
-- 📄 **Essential Info** - Shows filename, face count, and tags only
-- ⚡ **Fast Loading** - Minimal data for quick browsing
-- 🎯 **Focused Display** - Perfect for quick tag management
-
-Folder grouping applies across all views:
-- 📁 **Directory Grouping** - Photos grouped by their directory path
-- 🔽 **Expandable Folders** - Click folder headers to expand/collapse
-- 📊 **Photo Counts** - Shows number of photos in each folder
-- 🏷️ **Folder Bulk Tags** - Folder header shows bulk tags that apply to all photos in that folder (includes pending bulk adds not marked for removal)
-
-**🔧 Column Resizing:**
-- 🖱️ **Drag to Resize** - Click and drag red separators between columns
-- 📏 **Minimum Width** - Columns maintain minimum 50px width
-- 🔄 **Real-time Updates** - Both headers and data rows resize together
-- 💾 **Persistent Settings** - Column widths remembered between sessions
-- 🎯 **Visual Feedback** - Cursor changes and separator highlighting during resize
-
-**👁️ Column Management:**
-- 🖱️ **Right-click Headers** - Access column visibility menu
-- ✅ **Toggle Columns** - Show/hide individual columns in each view mode
-- 🎯 **View-Specific** - Column settings saved per view mode
-- 🔄 **Instant Updates** - Changes apply immediately
-
-**📁 Folder View Usage:**
-- 🖱️ **Click Folder Headers** - Click anywhere on a folder row to expand/collapse
-- 🔽 **Expand/Collapse Icons** - ▶ indicates collapsed, ▼ indicates expanded
-- 📊 **Photo Counts** - Each folder shows "(X photos)" in the header
-- 🎯 **Root Directory** - Photos without a directory path are grouped under "Root"
-- 📁 **Alphabetical Sorting** - Folders are sorted alphabetically by directory name
-- 🖼️ **Photo Details** - Expanded folders show all photos with their metadata
-- 🔄 **Persistent State** - Folder expansion state is maintained while browsing
-
-**🏷️ Enhanced Tag Management System:**
-- 🔗 **Linkage Icon** - Click the 🔗 button next to tags to open the tag management dialog
-- 📋 **Comprehensive Dialog** - Similar interface to Manage Tags with dropdown selection and tag listing
-- ✅ **Pending Changes** - Add and remove tags with changes tracked until "Save Tagging" is clicked
-- 🎯 **Visual Status** - Tags show "(pending)" in blue or "(saved)" in black for clear status indication
-- 🗑️ **Smart Removal** - Remove both pending and saved tags with proper database tracking
-- 📊 **Batch Operations** - Select multiple tags for removal with checkboxes
-- 🔄 **Real-time Updates** - Tag display updates immediately when changes are made
-- 💾 **Save System** - All tag changes (additions and removals) saved atomically when "Save Tagging" is clicked
-- 🔁 **Bulk Overrides Single** - If a tag was previously added as single to some photos, adding the same tag in bulk for the folder upgrades those single links to bulk on save
-- 🚫 **Scoped Deletions** - Single-photo tag dialog can delete saved/pending single links only; Bulk dialog deletes saved bulk links or cancels pending bulk adds only
-- 🎯 **ID-Based Architecture** - Uses tag IDs internally for efficient, reliable operations
-- ⚡ **Performance Optimized** - Fast tag operations with minimal database queries
-
-**Left Panel (People):**
-- 🔍 **Last Name Search** - Search box to filter people by last name (case-insensitive)
-- 🔎 **Search Button** - Apply filter to show only matching people
-- 🧹 **Clear Button** - Reset filter to show all people
-- 👥 **People List** - Shows all identified people with face counts in full name format including middle names, maiden names, and birth dates
-- 🖱️ **Clickable Names** - Click to select a person (selected name is bold)
-- ✏️ **Edit Name Icon** - Comprehensive person editing with all fields; tooltip shows "Update name"
-- 📝 **Complete Person Fields** - Edit with dedicated fields for:
- - **First Name** and **Last Name** (required)
- - **Middle Name** and **Maiden Name** (optional)
- - **Date of Birth** with visual calendar picker (required)
-- 💡 **Smart Validation** - Save button only enabled when all required fields are filled
-- 📅 **Calendar Integration** - Click 📅 button to open visual date picker
-- 🎨 **Enhanced Layout** - Organized grid layout with labels directly under each field
-
-**Right Panel (Faces):**
-- 🧩 **Person Faces** - Thumbnails of all faces identified as the selected person
-- ❌ **X on Each Face** - Temporarily unmatch a face (does not save yet)
-- ↶ **Undo Changes** - Restores unmatched faces for the current person only
-- 🔄 **Responsive Grid** - Faces wrap to the next line when the panel is narrow
-
-**Bottom Controls:**
-- 💾 **Save changes** - Commits all pending unmatched faces across all people to the database
-- ❌ **Quit** - Closes the window (unsaved temporary changes are discarded)
-
-**Performance Features:**
-- ⚡ **Optimized Database Access** - Loads all people data once when opening, saves only when needed
-- 🚫 **No Database Queries During Editing** - All editing operations use pre-loaded data
-- 💾 **Immediate Person Saves** - Person information saved directly to database when clicking save
-- 🔄 **Real-time Validation** - Save button state updates instantly as you type
-- 📅 **Visual Calendar** - Professional date picker with month/year navigation
-
-Notes:
-- **Person Information**: Saved immediately to database when clicking the 💾 save button in edit mode
-- **Face Unmatching**: Changes are temporary until you click "Save changes" at the bottom
-- **Validation**: Save button only enabled when first name, last name, and date of birth are all provided
-- **Calendar**: Date picker opens to existing date when editing, defaults to 25 years ago for new entries
-- **Undo**: Restores only the currently viewed person's unmatched faces
-- **Data Storage**: All person fields stored separately (first_name, last_name, middle_name, maiden_name, date_of_birth)
-
-## 🧠 Advanced Algorithm Features
-
-**🎯 Intelligent Face Matching Engine:**
-- 🔍 **Face Quality Scoring** - Automatically evaluates face quality based on sharpness, brightness, contrast, size, and position
-- 📊 **Adaptive Tolerance** - Adjusts matching strictness based on face quality (higher quality = stricter matching)
-- 🚫 **Quality Filtering** - Only processes faces above minimum quality threshold (≥0.2) for better accuracy
-- 🎯 **Smart Matching** - Uses multiple quality factors to determine the best matches
-- ⚡ **Performance Optimized** - Efficient database queries with quality-based indexing
-
-**🔬 Quality Assessment Metrics:**
-- **Sharpness Detection** - Uses Laplacian variance to detect blurry faces
-- **Brightness Analysis** - Prefers faces with optimal lighting conditions
-- **Contrast Evaluation** - Higher contrast faces score better for recognition
-- **Size Optimization** - Larger, clearer faces get higher quality scores
-- **Aspect Ratio** - Prefers square face crops for better recognition
-- **Position Scoring** - Centered faces in photos score higher
-
-**📈 Confidence Levels:**
-- 🟢 **Very High (80%+)** - Almost Certain match
-- 🟡 **High (70%+)** - Likely Match
-- 🟠 **Medium (60%+)** - Possible Match
-- 🔴 **Low (50%+)** - Questionable
-- ⚫ **Very Low (<50%)** - Unlikely
-
-**GUI Interactive Elements:**
-- **Person Name Dropdown** - Select from known people or type new names
-- **Compare Checkbox** - Compare with similar unidentified faces (persistent setting)
-- **Identify Button** - Confirm the identification (saves immediately)
-- **Back Button** - Go back to previous face (shows image and identification status)
-- **Next Button** - Move to next face
-- **Quit Button** - Exit application (all changes already saved)
-
-### Add Tags
-```bash
-# Tag photos matching pattern
-python3 photo_tagger.py tag --pattern "vacation"
-
-# Tag any photos
-python3 photo_tagger.py tag
-```
-
-### Search
-```bash
-# Find photos with a person
-python3 photo_tagger.py search "John"
-
-# Find photos with partial name match
-python3 photo_tagger.py search "Joh"
-
-# Open the Search GUI
-python3 photo_tagger.py search-gui
-```
-
-**🔍 Enhanced Search GUI Features:**
-
-**🔍 Multiple Search Types:**
-- **Search photos by name**: Find photos containing specific people
-- **Search photos by date**: Find photos within date ranges (with calendar picker)
-- **Search photos by tags**: Find photos with specific tags (with help icon)
-- **Photos without faces**: Find photos with no detected faces
-- **Photos without tags**: Find untagged photos
-
-**📋 Filters Area (Collapsible):**
-- **Folder Location Filter**: Filter results by specific folder path
-- **Browse Button**: Visual folder selection dialog (selects absolute paths)
-- **Clear Button**: Reset folder filter
-- **Apply Filters Button**: Apply folder filter to current search
-- **Expand/Collapse**: Click +/- to show/hide filters
-- **Tooltips**: Hover over +/- for expand/collapse guidance
-
-**📊 Results Display:**
-- **Person Column**: Shows matched person's name (only in name search)
-- **📁 Column**: Click to open file's folder (tooltip: "Open file location")
-- **🏷️ Column**: Click to show photo tags in popup, hover for tag tooltip
-- **Photo Path Column**: Click to open the photo (tooltip: "Open photo")
-- **☑ Column**: Click to select/deselect photos for bulk tagging
-- **Date Taken Column**: Shows when photo was taken
-- **Sortable Columns**: Click column headers to sort results
-
-**🎛️ Interactive Features:**
-- **Tag Help Icon (❓)**: Hover to see all available tags in column format
-- **Calendar Picker**: Click 📅 to select dates (date fields are read-only)
-- **Enter Key Support**: Press Enter in search fields to trigger search
-- **Tag Selected Photos**: Button to open linkage dialog for selected photos
-- **Clear All Selected**: Button to deselect all checkboxes
-
-**🎯 Search GUI Workflow:**
-1. **Search for Photos**: Enter a person's name and press Enter or click Search
-2. **View Results**: See all photos containing that person in a sortable table
-3. **Select Photos**: Click checkboxes (☑) to select photos for bulk operations
-4. **View Tags**: Click 🏷️ icon to see all tags for a photo, or hover for quick preview
-5. **Open Photos**: Click the photo path to open the photo in your default viewer
-6. **Bulk Tagging**: Select multiple photos and click "Tag selected photos" to add tags
-7. **Clear Selection**: Use "Clear all selected" to deselect all photos at once
-
-**🏷️ Tag Management in Search GUI:**
-- **Tag Popup**: Click 🏷️ icon to see all tags for a photo in a scrollable popup
-- **Tag Tooltip**: Hover over 🏷️ icon for quick tag preview (shows up to 5 tags)
-- **Bulk Tag Dialog**: Select multiple photos and use "Tag selected photos" button
-- **Add New Tags**: Type new tag names in the linkage dialog (auto-saves to database)
-- **Remove Tags**: Use checkboxes in the linkage dialog to remove existing tags
-- **Enter Key Support**: Press Enter in tag input field to quickly add tags
-
-### Statistics
-```bash
-# View database statistics
-python3 photo_tagger.py stats
-```
-
-### Tag Manager GUI
-```bash
-# Open tag management interface
-python3 photo_tagger.py tag-manager
-```
-
-### Dashboard GUI
-```bash
-# Open the main dashboard
-python3 photo_tagger.py dashboard
-```
-
-**🎯 Dashboard Features:**
-- **📁 Scan Section**: Add photos to database with folder selection
-- **Browse Button**: Visual folder selection dialog (selects absolute paths)
-- **Recursive Option**: Include photos in subfolders
-- **Path Validation**: Automatic path validation and error handling
-- **Cross-platform**: Works on Windows, Linux, and macOS
-
-**📁 Enhanced Folder Selection:**
-- **Visual Selection**: Click "Browse" to select folders visually
-- **Absolute Paths**: All selected paths are stored as absolute paths
-- **Path Normalization**: Relative paths automatically converted to absolute
-- **Error Handling**: Clear error messages for invalid paths
-
-## 📊 Enhanced Example Workflow
-
-```bash
-# ALWAYS activate virtual environment first!
-source venv/bin/activate
-
-# 1. Scan your photo collection (absolute or relative paths work)
-python3 photo_tagger.py scan ~/Pictures --recursive
-
-# 2. Process photos for faces (start with small batch)
-python3 photo_tagger.py process --limit 20
-
-# 3. Check what we found
-python3 photo_tagger.py stats
-
-# 4. Identify faces with GUI interface (ENHANCED!)
-python3 photo_tagger.py identify --show-faces --batch 10
-
-# 5. Auto-match faces across photos with GUI
-python3 photo_tagger.py auto-match --show-faces
-
-# 6. Search for photos of someone
-python3 photo_tagger.py search "Alice"
-
-# 7. Add some tags
-python3 photo_tagger.py tag --pattern "birthday"
-
-# 8. Manage tags with GUI interface
-python3 photo_tagger.py tag-manager
-```
-
-## 🗃️ Database
-
-The tool uses SQLite database (`data/photos.db` by default) with these tables:
-
-### Core Tables
-- **photos** - Photo file paths and processing status
-- **people** - Known people with separate first_name, last_name, and date_of_birth fields
-- **faces** - Face encodings, locations, and quality scores
-- **tags** - Tag definitions (unique tag names)
-- **phototaglinkage** - Links between photos and tags (many-to-many relationship)
- - Columns: `linkage_id` (PK), `photo_id`, `tag_id`, `linkage_type` (INTEGER: 0=single, 1=bulk), `created_date`
-- **person_encodings** - Face encodings for each person (for matching)
-
-### Database Schema Improvements
-- **Clean Name Storage** - People table uses separate `first_name` and `last_name` fields
-- **Date of Birth Integration** - People table includes `date_of_birth` column for complete identification
-- **Unique Constraint** - Prevents duplicate people with same name and birth date combination
-- **No Comma Issues** - Names are stored without commas, displayed as "Last, First" format
-- **Quality Scoring** - Faces table includes quality scores for better matching
-- **Normalized Tag Structure** - Separate `tags` table for tag definitions and `phototaglinkage` table for photo-tag relationships
-- **No Duplicate Tags** - Unique constraint prevents duplicate tag-photo combinations
-- **Optimized Queries** - Efficient indexing and query patterns for fast performance
-- **Data Integrity** - Proper foreign key relationships and constraints
-- **Tag ID-Based Operations** - All tag operations use efficient ID-based lookups instead of string comparisons
-- **Robust Tag Handling** - Eliminates string parsing issues and edge cases in tag management
-
-## ⚙️ Configuration
-
-### Face Detection Models
-- **hog** - Faster, good for CPU-only systems
-- **cnn** - More accurate, requires more processing power
-
-### Database Location
-```bash
-# Use custom database file
-python3 photo_tagger.py scan /photos --db /path/to/my.db
-```
-
-## 🌐 Path Handling & Web Application Compatibility
-
-### Absolute Path System
-PunimTag now uses a robust absolute path system that ensures consistency across all platforms and deployment scenarios.
-
-**📁 Key Features:**
-- **Automatic Path Normalization**: All paths are converted to absolute paths
-- **Cross-Platform Support**: Works on Windows (`C:\Photos`), Linux (`/home/user/photos`), and macOS
-- **Web Application Ready**: Absolute paths work perfectly in web applications
-- **Browse Buttons**: Visual folder selection in all GUI components
-- **Path Validation**: Automatic validation and error handling
-
-**🔧 Path Utilities:**
-- **`normalize_path()`**: Converts any path to absolute path
-- **`validate_path_exists()`**: Checks if path exists and is accessible
-- **`get_path_info()`**: Provides detailed path information
-- **Cross-platform**: Handles Windows, Linux, and macOS path formats
-
-**🌐 Web Application Integration:**
-```python
-# Example: Flask web application integration
-from path_utils import normalize_path
-
-@app.route('/scan_photos', methods=['POST'])
-def scan_photos():
- upload_dir = request.form['upload_dir']
- absolute_path = normalize_path(upload_dir) # Always absolute
- # Run photo_tagger with absolute path
- subprocess.run(f"python3 photo_tagger.py scan {absolute_path}")
-```
-
-**📋 Path Examples:**
-```bash
-# CLI - relative path auto-converted
-python3 photo_tagger.py scan demo_photos
-# Stored as: /home/user/punimtag/demo_photos/photo.jpg
-
-# CLI - absolute path used as-is
-python3 photo_tagger.py scan /home/user/photos
-# Stored as: /home/user/photos/photo.jpg
-
-# GUI - Browse button selects absolute path
-# User selects folder → absolute path stored in database
-```
-
-## 🔧 System Requirements
-
-### Required System Packages (Ubuntu/Debian)
-```bash
-sudo apt update
-sudo apt install -y cmake build-essential libopenblas-dev liblapack-dev libx11-dev libgtk-3-dev python3-dev python3-venv
-```
-
-### Python Dependencies
-- `face-recognition` - Face detection and recognition
-- `dlib` - Machine learning library
-- `pillow` - Image processing
-- `numpy` - Numerical operations
-- `click` - Command line interface
-- `setuptools` - Package management
-
-## 📁 File Structure
-
-```
-PunimTag/
-├── photo_tagger.py # Main CLI tool
-├── setup.py # Setup script
-├── run.sh # Convenience script (auto-activates venv)
-├── requirements.txt # Python dependencies
-├── README.md # This file
-├── gui_config.json # GUI window size preferences (created automatically)
-├── venv/ # Virtual environment (created by setup)
-├── data/
-│ └── photos.db # Database (created automatically)
-├── data/ # Additional data files
-└── logs/ # Log files
-```
-
-## 🚨 Troubleshooting
-
-### "externally-managed-environment" Error
-**Solution**: Always use a virtual environment!
-```bash
-python3 -m venv venv
-source venv/bin/activate
-python3 setup.py
-```
-
-### Virtual Environment Not Active
-**Problem**: Commands fail or use wrong Python
-**Solution**: Always activate the virtual environment:
-```bash
-source venv/bin/activate
-# You should see (venv) in your prompt
-```
-
-### Image Viewer Not Opening During Identify
-**Problem**: Face crops are saved but don't open automatically
-**Solution**: The setup script installs `feh` (image viewer) automatically on Ubuntu/Debian. For other systems:
-- **Ubuntu/Debian**: `sudo apt install feh`
-- **macOS**: `brew install feh`
-- **Windows**: Install a Linux subsystem or use WSL
-- **Alternative**: Use `--show-faces` flag without auto-opening - face crops will be saved to `/tmp/` for manual viewing
-
-### GUI Interface Issues
-**Problem**: GUI doesn't appear or has issues
-**Solution**: The tool now uses tkinter for all identification interfaces:
-- **Ubuntu/Debian**: `sudo apt install python3-tk` (usually pre-installed)
-- **macOS**: tkinter is included with Python
-- **Windows**: tkinter is included with Python
-- **Fallback**: If GUI fails, the tool will show error messages and continue
-
-**Common GUI Issues:**
-- **Window appears in corner**: The GUI centers itself automatically on first run
-- **Window size not remembered**: Check that `gui_config.json` is writable
-- **"destroy" command error**: Fixed in latest version - window cleanup is now safe
-- **GUI freezes**: Use Ctrl+C to interrupt, then restart the command
-
-### dlib Installation Issues
-```bash
-# Ubuntu/Debian - install system dependencies first
-sudo apt-get install build-essential cmake libopenblas-dev
-
-# Then retry setup
-source venv/bin/activate
-python3 setup.py
-```
-
-### "Please install face_recognition_models" Warning
-This warning is harmless - the application still works correctly. It's a known issue with Python 3.13.
-
-### Memory Issues
-- Use `--model hog` for faster processing
-- Process in smaller batches with `--limit 10`
-- Close other applications to free memory
-
-### No Faces Found
-- Check image quality and lighting
-- Ensure faces are clearly visible
-- Try `--model cnn` for better detection
-
-## 🎨 GUI Interface Guide
-
-### Face Identification GUI
-When you run `python3 photo_tagger.py identify --show-faces`, you'll see:
-
-**Left Panel:**
-- 📁 **Photo Info** - Shows filename and face location
-- 🖼️ **Face Image** - Individual face crop for easy identification
-- 📷 **Photo Icon** - Click the camera icon in the top-right corner of the face to open the original photo in your default image viewer
-- ✅ **Identification Status** - Shows if face is already identified and by whom
-
-**Right Panel:**
-- 📝 **Person Name Fields** - Text input fields for:
- - **First name** (required)
- - **Last name** (required)
- - **Middle name** (optional)
- - **Maiden name** (optional)
-- 📅 **Date of Birth** - Required date field with calendar picker (📅 button)
-- ☑️ **Compare Checkbox** - Compare with similar unidentified faces (persistent across navigation)
-- ☑️ **Select All/Clear All Buttons** - Bulk selection controls (enabled only when Compare is active)
-- 📜 **Similar Faces List** - Scrollable list of similar unidentified faces with:
- - ☑️ **Individual Checkboxes** - Select specific faces to identify together
- - 📈 **Confidence Percentages** - Color-coded match quality
- - 🖼️ **Face Images** - Thumbnail previews of similar faces
- - 📷 **Photo Icons** - Click the camera icon on any similar face to view its original photo
-- 🎮 **Control Buttons**:
- - **✅ Identify** - Confirm the identification (saves immediately) - requires first name, last name, and date of birth
- - **⬅️ Back** - Go back to previous face (shows image and status, repopulates fields)
- - **➡️ Next** - Move to next face (clears date of birth, middle name, and maiden name fields)
- - **❌ Quit** - Exit application (saves complete identifications only)
-
-### Auto-Match GUI (Enhanced with Smart Algorithm)
-When you run `python3 photo_tagger.py auto-match --show-faces`, you'll see an improved interface with:
-
-**🧠 Smart Algorithm Features:**
-- **Quality-Based Matching** - Only high-quality faces are processed for better accuracy
-- **Adaptive Tolerance** - Matching strictness adjusts based on face quality
-- **Confidence Scoring** - Color-coded confidence levels (🟢 Very High, 🟡 High, 🟠 Medium, 🔴 Low, ⚫ Very Low)
-- **Performance Optimized** - Faster processing with quality-based filtering
-
-**Interface Layout:**
-
-**Left Panel:**
-- 👤 **Matched Person** - The already identified person with complete information
-- 🖼️ **Person Face Image** - Individual face crop of the matched person
-- 📷 **Photo Icon** - Click the camera icon in the top-right corner to open the original photo
-- 📁 **Detailed Person Info** - Shows:
- - **Full Name** with middle and maiden names (if available)
- - **Date of Birth** (if available)
- - **Photo filename** and face location
-- 💾 **Save Button** - "Save changes for [Person Name]" - saves all checkbox selections
-
-**Right Panel:**
-- ☑️ **Unidentified Faces** - All unidentified faces that match this person (sorted by confidence):
- - ☑️ **Checkboxes** - Select which faces to identify with this person (pre-selected if previously identified)
- - 📈 **Confidence Percentages** - Color-coded match quality (highest confidence at top)
- - 🖼️ **Face Images** - Face crops of unidentified faces
- - 📷 **Photo Icons** - Click the camera icon on any face to view its original photo
-- 📜 **Scrollable** - Handle many matches easily
-- 🎯 **Smart Ordering** - Highest confidence matches appear first for easy selection
-
-**Bottom Controls (Navigation Only):**
-- **⏮️ Back** - Go back to previous person (disabled on first person)
-- **⏭️ Next** - Move to next person (disabled on last person)
-- **❌ Quit** - Exit auto-match process
-
-### Compare with Similar Faces Workflow
-The Compare feature in the Identify GUI works seamlessly with the main identification process:
-
-1. **Enable Compare**: Check "Compare with similar faces" to see similar unidentified faces
-2. **View Similar Faces**: Right panel shows all similar faces with confidence percentages and thumbnails
-3. **Select Faces**: Use individual checkboxes or Select All/Clear All buttons to choose faces
-4. **Enter Person Name**: Type the person's name in the text input fields
-5. **Identify Together**: Click Identify to identify the current face and all selected similar faces at once
-6. **Smart Navigation**: System warns if you try to navigate away with selected faces but no name
-7. **Quit Protection**: When closing, system offers to save any pending identifications
-
-**Key Benefits:**
-- **Bulk Identification**: Identify multiple similar faces with one action
-- **Visual Confirmation**: See exactly which faces you're identifying together
-- **Smart Warnings**: Prevents accidental loss of work
-- **Performance Optimized**: Instant loading of similar faces
-
-### Unique Faces Only Filter
-- Location: in the "Date Filters" bar at the top of the Identify GUI.
-- Behavior:
- - Filters the main navigation list on the left to avoid showing near-duplicate faces of the same person.
- - The Similar Faces panel on the right is NOT filtered and continues to show all similar faces for comparison.
- - Confidence rule: faces that match at ≥60% (Medium/High/Very High) are grouped; only one shows in the main list.
-- Interaction:
- - Takes effect immediately when toggled. You do NOT need to press Apply Filter.
- - Apply Filter continues to control the date filters only (Taken/Processed ranges).
- - Filtering uses precomputed encodings from the database, so it is fast and non-blocking.
-
-### Auto-Match Workflow
-The auto-match feature now works in a **person-centric** way:
-
-1. **Group by Person**: Faces are grouped by already identified people (not unidentified faces)
-2. **Show Matched Person**: Left side shows the identified person and their face
-3. **Show Unidentified Faces**: Right side shows all unidentified faces that match this person
-4. **Select and Save**: Check the faces you want to identify with this person, then click "Save Changes"
-5. **Navigate**: Use Back/Next to move between different people
-6. **Correct Mistakes**: Go back and uncheck faces to unidentify them
-7. **Pre-selected Checkboxes**: Previously identified faces are automatically checked when you go back
-
-**Key Benefits:**
-- **1-to-Many**: One person can have multiple unidentified faces matched to them
-- **Visual Confirmation**: See exactly what you're identifying before saving
-- **Easy Corrections**: Go back and fix mistakes by unchecking faces
-- **Smart Tracking**: Previously identified faces are pre-selected for easy review
-- **Fast Performance**: Optimized database queries and streamlined interface
-
-### 📅 Calendar Interface Guide
-When you click the 📅 calendar button, you'll see:
-
-**Calendar Features:**
-- **Visual Grid Layout** - Traditional 7x7 calendar with clickable dates
-- **Month/Year Navigation** - Use << >> < > buttons to navigate
-- **Date Selection** - Click any date to select it (doesn't close calendar immediately)
-- **Visual Feedback** - Selected dates highlighted in bright blue, today's date in orange
-- **Future Date Restrictions** - Future dates are disabled and grayed out (birth dates cannot be in the future)
-- **Smart Pre-population** - Opens to existing date when editing previous identifications
-- **Smooth Operation** - Opens centered without flickering
-
-**Calendar Navigation:**
-- **<< >>** - Jump by year (limited to 1900-current year)
-- **< >** - Navigate by month (prevents navigation to future months)
-- **Click Date** - Select any visible date (highlights in blue, doesn't close calendar)
-- **Select Button** - Confirm your date choice and close calendar
-- **Cancel Button** - Close without selecting
-
-**New Calendar Behavior:**
-- **Two-Step Process** - Click date to select, then click "Select" to confirm
-- **Future Date Protection** - Cannot select dates after today (logical for birth dates)
-- **Smart Navigation** - Month/year buttons prevent going to future periods
-- **Visual Clarity** - Selected dates clearly highlighted, future dates clearly disabled
-
-### GUI Tips
-- **Window Resizing**: Resize the window - it remembers your size preference
-- **Keyboard Shortcuts**: Press Enter in the name field to identify
-- **Back Navigation**: Use Back button to return to previous faces - images and identification status are preserved
-- **Re-identification**: Go back to any face and change the identification - all fields are pre-filled
-- **Auto-Save**: All identifications are saved immediately - no need to manually save
-- **Compare Mode**: Enable Compare checkbox to see similar unidentified faces - setting persists across navigation
-- **Bulk Selection**: Use Select All/Clear All buttons to quickly select or clear all similar faces
-- **Smart Buttons**: Select All/Clear All buttons are only enabled when Compare mode is active
-- **Navigation Warnings**: System warns if you try to navigate away with selected faces but no person name
-- **Smart Quit Validation**: Quit button only shows warning when all three required fields are filled (first name, last name, date of birth)
-- **Quit Confirmation**: When closing, system asks if you want to save pending identifications
-- **Cancel Protection**: Clicking "Cancel" in quit warning keeps the main window open
-- **Consistent Results**: Compare mode shows the same faces as auto-match with identical confidence scoring
-- **Multiple Matches**: In auto-match, you can select multiple faces to identify with one person
-- **Smart Navigation**: Back/Next buttons are disabled appropriately (Back disabled on first, Next disabled on last)
-- **State Persistence**: Checkbox selections are preserved when navigating between people
-- **Per-Person States**: Each person's selections are completely independent
-- **Save Button Location**: Save button is in the left panel with the person's name for clarity
-- **Performance**: Similar faces load instantly thanks to pre-fetched data optimization
-- **Bidirectional Changes**: You can both identify and unidentify faces in the same session
-- **Field Requirements**: First name, last name, and date of birth must be filled to identify (middle name and maiden name are optional)
-- **Navigation Memory**: Date field clears on forward navigation, repopulates on back navigation
-- **Confidence Colors**:
- - 🟢 80%+ = Very High (Almost Certain)
- - 🟡 70%+ = High (Likely Match)
- - 🟠 60%+ = Medium (Possible Match)
- - 🔴 50%+ = Low (Questionable)
- - ⚫ <50% = Very Low (Unlikely)
-
-## 🆕 Recent Improvements
-
-### Auto-Match GUI Migration (Latest)
-- **✅ Complete Migration**: Auto-match GUI fully migrated from legacy version to current architecture
-- **🔄 Exact Feature Parity**: All functionality preserved including person-centric view, checkbox selection, and state persistence
-- **🎯 Enhanced Integration**: Seamlessly integrated with new modular architecture while maintaining all original features
-- **⚡ Performance Optimized**: Leverages new face processing and database management systems for better performance
-
-### Auto-Match UX Enhancements (Latest)
-- **💾 Smart Save Button**: "Save changes for [Person Name]" button moved to left panel for better UX
-- **🔄 State Persistence**: Checkbox selections now preserved when navigating between people
-- **🚫 Smart Navigation**: Next button disabled on last person, Back button disabled on first
-- **🎯 Per-Person States**: Each person's checkbox selections are completely independent
-- **⚡ Real-time Saving**: Checkbox states saved immediately when changed
-
-### Consistent Face-to-Face Comparison System
-- **🔄 Unified Logic**: Both auto-match and identify now use the same face comparison algorithm
-- **📊 Consistent Results**: Identical confidence scoring and face matching across both modes
-- **🎯 Same Tolerance**: Both functionalities respect the same tolerance settings
-- **⚡ Performance**: Eliminated code duplication for better maintainability
-- **🔧 Refactored**: Single reusable function for face filtering and sorting
-
-### Compare Checkbox Enhancements
-- **🌐 Global Setting**: Compare checkbox state persists when navigating between faces
-- **🔄 Auto-Update**: Similar faces automatically refresh when using Back/Next buttons
-- **👥 Consistent Display**: Compare mode shows the same faces as auto-match
-- **📈 Smart Filtering**: Only shows faces with 40%+ confidence (same as auto-match)
-- **🎯 Proper Sorting**: Faces sorted by confidence (highest first)
-
-### Back Navigation & Re-identification
-- **⬅️ Back Button**: Navigate back to previous faces with full image display
-- **🔄 Re-identification**: Change any identification by going back and re-identifying
-- **📝 Pre-filled Names**: Name field shows current identification for easy changes
-- **✅ Status Display**: Shows who each face is identified as when going back
-
-### Improved Cleanup & Performance
-- **🧹 Better Cleanup**: Proper cleanup of temporary files and resources
-- **💾 Auto-Save**: All identifications save immediately (removed redundant Save & Quit)
-- **🔄 Code Reuse**: Eliminated duplicate functions for better maintainability
-- **⚡ Optimized**: Faster navigation and better memory management
-
-### Enhanced User Experience
-- **🖼️ Image Preservation**: Face images show correctly when navigating back
-- **🎯 Smart Caching**: Face crops are properly cached and cleaned up
-- **🔄 Bidirectional Changes**: Can both identify and unidentify faces in same session
-- **💾 Window Memory**: Remembers window size and position preferences
-
-## 🎯 What This Tool Does
-
-✅ **Simple**: Single Python file, minimal dependencies
-✅ **Fast**: Efficient face detection and recognition
-✅ **Private**: Everything runs locally, no cloud services
-✅ **Flexible**: Batch processing, interactive identification
-✅ **Lightweight**: No web interface overhead
-✅ **GUI-Enhanced**: Modern interface for face identification
-✅ **User-Friendly**: Back navigation, re-identification, and auto-save
-
-## 📈 Performance Tips
-
-- **Always use virtual environment** to avoid conflicts
-- Start with small batches (`--limit 20`) to test
-- Use `hog` model for speed, `cnn` for accuracy
-- Process photos in smaller folders first
-- Identify faces in batches to avoid fatigue
-
-## 🤝 Contributing
-
-This is now a minimal, focused tool. Key principles:
-- Keep it simple and fast
-- GUI-enhanced interface for identification
-- Minimal dependencies
-- Clear, readable code
-- **Always use python3** commands
-
-## 🆕 Recent Improvements (Latest Version)
-
-### 🔧 Data Storage & Reliability Improvements (NEW!)
-- ✅ **Eliminated Redundant Storage** - Removed unnecessary combined name field for cleaner data structure
-- ✅ **Direct Field Access** - Names stored and accessed directly without parsing/combining logic
-- ✅ **Fixed Quit Confirmation** - Proper detection of pending identifications when quitting
-- ✅ **Improved Error Handling** - Better type consistency prevents runtime errors
-- ✅ **Enhanced Performance** - Eliminated string manipulation overhead for faster operations
-
-### 🔄 Field Navigation & Preservation Fixes
-- ✅ **Fixed Name Field Confusion** - First and last names now stay in correct fields during navigation
-- ✅ **Enhanced Data Storage** - Individual field tracking prevents name swapping issues
-- ✅ **Date of Birth Preservation** - Date of birth now preserved even when entered alone (without names)
-- ✅ **Consistent Field Handling** - All navigation (Next/Back) uses unified field management logic
-- ✅ **Smart Field Population** - Fields correctly repopulate based on original input context
-
-### 📅 Date of Birth Integration
-- ✅ **Required Date of Birth** - All face identifications now require date of birth
-- ✅ **Visual Calendar Picker** - Interactive calendar widget for easy date selection
-- ✅ **Smart Pre-population** - Calendar opens to existing date when editing
-- ✅ **Database Schema Update** - People table now includes date_of_birth column
-- ✅ **Unique Constraint** - Prevents duplicate people with same first name, last name, middle name, maiden name, and birth date
-- ✅ **Field Validation** - First name, last name, and date of birth required; middle name and maiden name optional
-- ✅ **Navigation Memory** - Date field clears on forward navigation, repopulates on back navigation
-
-### 🎨 Enhanced Calendar Interface
-- ✅ **Visual Calendar Grid** - Traditional 7x7 calendar layout with clickable dates
-- ✅ **Month/Year Navigation** - Easy navigation with << >> < > buttons
-- ✅ **Prominent Selection** - Selected dates highlighted in bright blue
-- ✅ **Today Highlighting** - Current date shown in orange when visible
-- ✅ **Smooth Positioning** - Calendar opens centered without flickering
-- ✅ **Isolated Styling** - Calendar styles don't affect other dialog buttons
-- ✅ **Future Date Restrictions** - Future dates are disabled and grayed out (birth dates cannot be in the future)
-- ✅ **Select/Cancel Buttons** - Proper confirmation workflow - click date to select, then click "Select" to confirm
-- ✅ **Smart Navigation Limits** - Month/year navigation prevents going to future months/years
-
-### 🔄 Smart Field Management
-- ✅ **Forward Navigation** - Date of birth, middle name, and maiden name fields clear when moving to next face
-- ✅ **Backward Navigation** - All fields repopulate with previously entered data
-
-### 🛡️ Enhanced Quit Validation (NEW!)
-- ✅ **Smart Form Validation** - Quit button only shows warning when ALL three required fields are filled (first name, last name, date of birth)
-- ✅ **Proper Cancel Behavior** - Clicking "Cancel" in quit warning keeps the main window open instead of closing it
-- ✅ **Unsaved Changes Detection** - Accurately detects when you have complete identification data ready but haven't pressed "Identify" yet
-- ✅ **Improved User Experience** - No more false warnings when only partially filling form fields
-
-### 🆕 Enhanced Person Information (LATEST!)
-- ✅ **Middle Name Field** - Optional middle name input field added to person identification
-- ✅ **Maiden Name Field** - Optional maiden name input field added to person identification
-- ✅ **Simplified Interface** - Removed dropdown functionality for cleaner, faster data entry
-- ✅ **Optimized Field Layout** - Date of birth positioned before maiden name for better workflow
-- ✅ **Enhanced Database Schema** - People table now includes middle_name and maiden_name columns
-- ✅ **Unique Constraint Update** - Prevents duplicate people with same combination of all five fields
-- ✅ **Streamlined Data Entry** - All name fields are now simple text inputs for faster typing
-
-### 🏷️ Tag System Improvements (NEW!)
-- ✅ **Tag ID-Based Architecture** - Complete refactoring to use tag IDs internally instead of tag names
-- ✅ **Eliminated String Parsing Issues** - No more problems with empty strings, whitespace, or parsing errors
-- ✅ **Improved Performance** - Tag ID comparisons are faster than string comparisons
-- ✅ **Better Reliability** - No case sensitivity issues or string parsing bugs
-- ✅ **Database Efficiency** - Direct ID operations instead of string lookups
-- ✅ **Cleaner Architecture** - Clear separation between internal logic (IDs) and display (names)
-- ✅ **Duplicate Prevention** - Silent prevention of duplicate tags without warning messages
-- ✅ **Accurate Change Counting** - Only photos with actual new tags are counted as "changed"
-- ✅ **Robust Tag Parsing** - Handles edge cases like empty tag strings and malformed data
-- ✅ **Consistent Behavior** - All tag operations use the same reliable logic throughout the application
-
-### 🔗 Enhanced Tag Management Interface (LATEST!)
-- ✅ **Linkage Icon** - Replaced "+" button with intuitive 🔗 linkage icon for tag management
-- ✅ **Comprehensive Tag Dialog** - Redesigned tag management dialog similar to Manage Tags interface
-- ✅ **Dropdown Tag Selection** - Select from existing tags or create new ones via dropdown
-- ✅ **Pending Tag System** - Add and remove tags with changes tracked until explicitly saved
-- ✅ **Visual Status Indicators** - Clear distinction between saved tags (black) and pending tags (blue)
-- ✅ **Smart Tag Removal** - Remove both pending and saved tags with proper database tracking
-- ✅ **Batch Tag Operations** - Select multiple tags for removal with checkboxes
-- ✅ **Real-time UI Updates** - Tag display updates immediately when changes are made
-- ✅ **Atomic Save Operations** - All tag changes (additions and removals) saved in single transaction
-- ✅ **Efficient ID-Based Operations** - Uses tag IDs internally for fast, reliable tag management
-- ✅ **Scrollable Tag Lists** - Handle photos with many tags in scrollable interface
-- ✅ **Immediate Visual Feedback** - Removed tags disappear from UI immediately
-- ✅ **Database Integrity** - Proper cleanup of pending changes when tags are deleted
-
-### 🎨 Enhanced Modify Identified Interface (NEW!)
-- ✅ **Complete Person Information** - Shows full names with middle names, maiden names, and birth dates
-- ✅ **Last Name Search** - Filter people by last name with case-insensitive search
-- ✅ **Auto-Selection** - Automatically selects first person in filtered results
-- ✅ **Comprehensive Editing** - Edit all person fields: first, last, middle, maiden names, and date of birth
-- ✅ **Visual Calendar Integration** - Professional date picker with month/year navigation
-
-### 📷 Photo Icon Feature (NEW!)
-- ✅ **Source Photo Access** - Click the 📷 camera icon on any face to open the original photo
-- ✅ **Smart Positioning** - Icons appear exactly in the top-right corner of each face image
-- ✅ **Cross-Platform Support** - Opens photos in properly sized windows on Windows, macOS, and Linux
-- ✅ **Helpful Tooltips** - "Show original photo" tooltip appears on hover
-- ✅ **Available Everywhere** - Works on main faces (left panel) and similar faces (right panel)
-- ✅ **Proper Window Sizing** - Photos open in reasonable window sizes, not fullscreen
-- ✅ **Multiple Viewer Support** - Tries multiple image viewers for optimal experience
-
-### Name Handling & Database
-- ✅ **Fixed Comma Issues** - Names are now stored cleanly without commas in database
-- ✅ **Separate Name Fields** - First name and last name are stored in separate database columns
-- ✅ **Smart Parsing** - Supports "Last, First" input format that gets properly parsed
-- ✅ **Optimized Database Access** - Single load/save operations for better performance
-
-### GUI Enhancements
-- ✅ **Improved Edit Interface** - Separate text boxes for first and last names with help text
-- ✅ **Better Layout** - Help text positioned below input fields for clarity
-- ✅ **Tooltips** - Edit buttons show helpful tooltips
-- ✅ **Responsive Design** - Face grids adapt to window size
-
-### Performance & Reliability
-- ✅ **Efficient Database Operations** - Pre-loads data, saves only when needed
-- ✅ **Fixed Virtual Environment** - Run script now works properly with dependencies
-- ✅ **Clean Code Structure** - Improved error handling and state management
-
----
-
-**Total project size**: ~3,800 lines of Python code
-**Dependencies**: 6 essential packages
-**Setup time**: ~5 minutes
-**Perfect for**: Batch processing personal photo collections with modern GUI interface
-
-## 🔄 Common Commands Cheat Sheet
-
-```bash
-# Setup (one time)
-python3 -m venv venv && source venv/bin/activate && python3 setup.py
-
-# Daily usage - Option 1: Use run script (automatic venv activation)
-./run.sh scan ~/Pictures --recursive
-./run.sh process --limit 50
-./run.sh identify --show-faces --batch 10
-./run.sh auto-match --show-faces
-./run.sh modifyidentified
-./run.sh tag-manager
-./run.sh stats
-
-# Daily usage - Option 2: Manual venv activation (GUI-ENHANCED)
-source venv/bin/activate
-python3 photo_tagger.py scan ~/Pictures --recursive
-python3 photo_tagger.py process --limit 50
-python3 photo_tagger.py identify --show-faces --batch 10 # Opens GUI
-python3 photo_tagger.py auto-match --show-faces # Opens GUI
-python3 photo_tagger.py search-gui # Opens Search GUI
-python3 photo_tagger.py modifyidentified # Opens GUI to view/modify
-python3 photo_tagger.py dashboard # Opens Dashboard with Browse buttons
-python3 photo_tagger.py tag-manager # Opens GUI for tag management
-python3 photo_tagger.py stats
-```
\ No newline at end of file
diff --git a/docs/README_UNIFIED_DASHBOARD.md b/docs/README_UNIFIED_DASHBOARD.md
deleted file mode 100644
index e3bcc51..0000000
--- a/docs/README_UNIFIED_DASHBOARD.md
+++ /dev/null
@@ -1,490 +0,0 @@
-# PunimTag - Unified Photo Face Tagger
-
-A powerful photo face recognition and tagging system with a modern unified dashboard interface. Designed for easy web migration with clean separation between navigation and functionality.
-
-## 🎯 What's New: Unified Dashboard
-
-**PunimTag now features a unified dashboard interface** that brings all functionality into a single, professional window:
-
-- **📱 Single Window Interface** - No more managing multiple windows
-- **🖥️ Full Screen Mode** - Automatically opens maximized for optimal viewing
-- **📐 Responsive Design** - All components adapt dynamically to window resizing
-- **🎛️ Menu Bar Navigation** - All features accessible from the top menu
-- **🔄 Panel Switching** - Seamless transitions between different functions
-- **🌐 Web-Ready Architecture** - Designed for easy migration to web application
-- **📊 Status Updates** - Real-time feedback on current operations
-- **🎨 Enhanced Typography** - Larger, more readable fonts optimized for full screen
-- **🏠 Smart Home Navigation** - Compact home icon for quick return to welcome screen
-- **🚪 Unified Exit Behavior** - All exit buttons navigate to home instead of closing
-- **✅ Complete Integration** - All panels fully functional and integrated
-
-## 📋 System Requirements
-
-### Minimum Requirements
-- **Python**: 3.7 or higher
-- **Operating System**: Linux, macOS, or Windows
-- **RAM**: 2GB+ (4GB+ recommended for large photo collections)
-- **Storage**: 100MB for application + space for photos and database
-- **Display**: X11 display server (Linux) or equivalent for GUI interface
-
-### Supported Platforms
-- ✅ **Ubuntu/Debian** (fully supported with automatic dependency installation)
-- ✅ **macOS** (manual dependency installation required)
-- ✅ **Windows** (with WSL or manual setup)
-- ⚠️ **Other Linux distributions** (manual dependency installation required)
-
-### What Gets Installed Automatically (Ubuntu/Debian)
-The setup script automatically installs these system packages:
-- **Build tools**: `cmake`, `build-essential`
-- **Math libraries**: `libopenblas-dev`, `liblapack-dev` (for face recognition)
-- **GUI libraries**: `libx11-dev`, `libgtk-3-dev`, `libboost-python-dev`
-- **Image viewer**: `feh` (for face identification interface)
-
-## 🚀 Quick Start
-
-```bash
-# 1. Setup (one time only)
-git clone
-cd PunimTag
-python3 -m venv venv
-source venv/bin/activate # On Windows: venv\Scripts\activate
-python3 setup.py # Installs system deps + Python packages
-
-# 2. Launch Unified Dashboard
-python3 photo_tagger.py dashboard
-
-# 3. Use the menu bar to access all features:
-# 🏠 Home - Return to welcome screen (✅ Fully Functional)
-# 📁 Scan - Add photos to your collection (✅ Fully Functional)
-# 🔍 Process - Detect faces in photos (✅ Fully Functional)
-# 👤 Identify - Identify people in photos (✅ Fully Functional)
-# 🔗 Auto-Match - Find matching faces automatically (✅ Fully Functional)
-# 🔎 Search - Find photos by person name (✅ Fully Functional)
-# ✏️ Modify - Edit face identifications (✅ Fully Functional)
-# 🏷️ Tags - Manage photo tags (✅ Fully Functional)
-```
-
-## 📦 Installation
-
-### Automatic Setup (Recommended)
-```bash
-# Clone and setup
-git clone
-cd PunimTag
-
-# Create virtual environment (IMPORTANT!)
-python3 -m venv venv
-source venv/bin/activate # On Windows: venv\Scripts\activate
-
-# Run setup script
-python3 setup.py
-```
-
-**⚠️ IMPORTANT**: Always activate the virtual environment before running any commands:
-```bash
-source venv/bin/activate # Run this every time you open a new terminal
-```
-
-### Manual Setup (Alternative)
-```bash
-python3 -m venv venv
-source venv/bin/activate
-pip install -r requirements.txt
-python3 photo_tagger.py stats # Creates database
-```
-
-## 🎛️ Unified Dashboard Interface
-
-### Launch the Dashboard
-```bash
-# Open the unified dashboard (RECOMMENDED)
-python3 photo_tagger.py dashboard
-```
-
-### 🖥️ Full Screen & Responsive Features
-
-The dashboard automatically opens in full screen mode and provides a fully responsive experience:
-
-#### **Automatic Full Screen**
-- **Cross-Platform Support**: Works on Windows, Linux, and macOS
-- **Smart Maximization**: Uses the best available method for each platform
-- **Fallback Handling**: Gracefully handles systems that don't support maximization
-- **Minimum Size**: Prevents window from becoming too small (800x600 minimum)
-
-#### **Dynamic Responsiveness**
-- **Real-Time Resizing**: All components adapt as you resize the window
-- **Grid-Based Layout**: Uses proper grid weights for optimal expansion
-- **Status Updates**: Status bar shows current window dimensions
-- **Panel Updates**: Active panels update their layout during resize
-- **Canvas Scrolling**: Similar faces and other scrollable areas update automatically
-
-#### **Enhanced Typography**
-- **Full Screen Optimized**: Larger fonts (24pt titles, 14pt content) for better readability
-- **Consistent Styling**: All panels use the same enhanced font sizes
-- **Professional Appearance**: Clean, modern typography throughout
-
-#### **Smart Navigation**
-- **🏠 Home Icon**: Compact home button (🏠) in the leftmost position of the menu bar
-- **Quick Return**: Click the home icon to instantly return to the welcome screen
-- **Exit to Home**: All exit buttons in panels now navigate to home instead of closing
-- **Consistent UX**: Unified navigation experience across all panels
-
-### Dashboard Features
-
-#### **🏠 Home Panel**
-- Welcome screen with feature overview
-- Quick access guide to all functionality
-- Professional, modern interface with large fonts for full screen
-- Responsive layout that adapts to window size
-
-#### **📁 Scan Panel**
-- **Folder Selection**: Browse and select photo directories
-- **Recursive Scanning**: Include photos in subfolders
-- **Path Validation**: Automatic validation and error handling
-- **Real-time Status**: Live updates during scanning process
-- **Responsive Forms**: Form elements expand and contract with window size
-
-#### **🔍 Process Panel**
-- **Batch Processing**: Process photos in configurable batches
-- **Quality Scoring**: Automatic face quality assessment
-- **Model Selection**: Choose between HOG (fast) and CNN (accurate) models
-- **Progress Tracking**: Real-time processing status
-- **Dynamic Layout**: All controls adapt to window resizing
-
-#### **👤 Identify Panel** *(Fully Functional)*
-- **Visual Face Display**: See individual face crops (400x400 pixels for full screen)
-- **Smart Identification**: Separate fields for first name, last name, middle name, maiden name
-- **Similar Face Matching**: Compare with other unidentified faces
-- **Batch Processing**: Handle multiple faces efficiently
-- **Responsive Layout**: Adapts to window resizing with dynamic updates
-- **Enhanced Navigation**: Back/Next buttons with unsaved changes protection
-
-#### **🔗 Auto-Match Panel** *(Fully Functional)*
-- **Person-Centric Workflow**: Groups faces by already identified people
-- **Visual Confirmation**: Left panel shows identified person, right panel shows potential matches
-- **Confidence Scoring**: Color-coded match confidence levels with detailed descriptions
-- **Bulk Selection**: Select multiple faces for identification with Select All/Clear All
-- **Smart Navigation**: Back/Next buttons to move between different people
-- **Search Functionality**: Filter people by last name for large databases
-- **Pre-selection**: Previously identified faces are automatically checked
-- **Unsaved Changes Protection**: Warns before losing unsaved work
-- **Database Integration**: Proper transactions and face encoding updates
-
-##### **Auto-Match Workflow**
-The auto-match feature works in a **person-centric** way:
-
-1. **Group by Person**: Faces are grouped by already identified people (not unidentified faces)
-2. **Show Matched Person**: Left side shows the identified person and their face
-3. **Show Unidentified Faces**: Right side shows all unidentified faces that match this person
-4. **Select and Save**: Check the faces you want to identify with this person, then click "Save Changes"
-5. **Navigate**: Use Back/Next to move between different people
-6. **Correct Mistakes**: Go back and uncheck faces to unidentify them
-7. **Pre-selected Checkboxes**: Previously identified faces are automatically checked when you go back
-
-**Key Benefits:**
-- **1-to-Many**: One person can have multiple unidentified faces matched to them
-- **Visual Confirmation**: See exactly what you're identifying before saving
-- **Easy Corrections**: Go back and fix mistakes by unchecking faces
-- **Smart Tracking**: Previously identified faces are pre-selected for easy review
-
-##### **Auto-Match Configuration**
-- **Tolerance Setting**: Adjust matching sensitivity (lower = stricter matching)
-- **Start Button**: Prominently positioned on the left for easy access
-- **Search Functionality**: Filter people by last name for large databases
-- **Exit Button**: "Exit Auto-Match" with unsaved changes protection
-
-#### **🔎 Search Panel** *(Fully Functional)*
-- **Multiple Search Types**: Search photos by name, date, tags, and special categories
-- **Advanced Filtering**: Filter by folder location with browse functionality
-- **Results Display**: Sortable table with person names, tags, processed status, and dates
-- **Interactive Results**: Click to open photos, browse folders, and view people
-- **Tag Management**: Add and remove tags from selected photos
-- **Responsive Layout**: Adapts to window resizing with proper scrolling
-
-#### **✏️ Modify Panel** *(Fully Functional)*
-- **Review Identifications**: View all identified people with face counts
-- **Edit Names**: Rename people with full name fields (first, last, middle, maiden, date of birth)
-- **Unmatch Faces**: Temporarily remove face associations with visual confirmation
-- **Bulk Operations**: Handle multiple changes efficiently with undo functionality
-- **Search People**: Filter people by last name for large databases
-- **Visual Calendar**: Date of birth selection with intuitive calendar interface
-- **Responsive Layout**: Face grid adapts to window resizing
-- **Unsaved Changes Protection**: Warns before losing unsaved work
-
-#### **🏷️ Tag Manager Panel** *(Fully Functional)*
-- **Photo Explorer**: Browse photos organized by folders with thumbnail previews
-- **Multiple View Modes**: List view, icon view, compact view, and folder view
-- **Tag Management**: Add, remove, and organize tags with bulk operations
-- **People Integration**: View and manage people identified in photos
-- **Bulk Tagging**: Link tags to entire folders or multiple photos at once
-- **Search & Filter**: Find photos by tags, people, or folder location
-- **Responsive Layout**: Adapts to window resizing with proper scrolling
-- **Exit to Home**: Exit button navigates to home screen instead of closing
-
-## 🎯 Command Line Interface (Legacy)
-
-While the unified dashboard is the recommended interface, the command line interface is still available:
-
-### Scan for Photos
-```bash
-# Scan a folder (absolute path recommended)
-python3 photo_tagger.py scan /path/to/photos
-
-# Scan with relative path (auto-converted to absolute)
-python3 photo_tagger.py scan demo_photos
-
-# Scan recursively (recommended)
-python3 photo_tagger.py scan /path/to/photos --recursive
-```
-
-### Process Photos for Faces
-```bash
-# Process 50 photos (default)
-python3 photo_tagger.py process
-
-# Process 20 photos with CNN model (more accurate)
-python3 photo_tagger.py process --limit 20 --model cnn
-
-# Process with HOG model (faster)
-python3 photo_tagger.py process --limit 100 --model hog
-```
-
-### Individual GUI Windows (Legacy)
-```bash
-# Open individual GUI windows (legacy mode)
-python3 photo_tagger.py identify --show-faces --batch 10
-python3 photo_tagger.py auto-match --show-faces
-python3 photo_tagger.py search-gui
-python3 photo_tagger.py modifyidentified
-python3 photo_tagger.py tag-manager
-```
-
-## 🏗️ Architecture: Web Migration Ready
-
-### Current Desktop Architecture
-```
-┌─────────────────────────────────────────────────────────────┐
-│ Unified Dashboard │
-│ ┌─────────────────────────────────────────────────────────┐│
-│ │ Menu Bar ││
-│ │ [🏠] [Scan] [Process] [Identify] [Search] [Tags] [Modify]││
-│ └─────────────────────────────────────────────────────────┘│
-│ ┌─────────────────────────────────────────────────────────┐│
-│ │ Content Area ││
-│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││
-│ │ │Home Panel │ │Identify │ │Search Panel │ ││
-│ │ │(Welcome) │ │Panel │ │ │ ││
-│ │ └─────────────┘ └─────────────┘ └─────────────┘ ││
-│ └─────────────────────────────────────────────────────────┘│
-└─────────────────────────────────────────────────────────────┘
- │
- ┌─────────────────┐
- │ PhotoTagger │
- │ (Business │
- │ Logic) │
- └─────────────────┘
-```
-
-### Future Web Architecture
-```
-┌─────────────────────────────────────────────────────────────┐
-│ Web Browser │
-│ ┌─────────────────────────────────────────────────────────┐│
-│ │ Navigation Bar ││
-│ │ [🏠] [Scan] [Process] [Identify] [Search] [Tags] [Modify]││
-│ └─────────────────────────────────────────────────────────┘│
-│ ┌─────────────────────────────────────────────────────────┐│
-│ │ Main Content Area ││
-│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││
-│ │ │Home Page │ │Identify │ │Search Page │ ││
-│ │ │(Welcome) │ │Page │ │ │ ││
-│ │ └─────────────┘ └─────────────┘ └─────────────┘ ││
-│ └─────────────────────────────────────────────────────────┘│
-└─────────────────────────────────────────────────────────────┘
- │
- ┌─────────────────┐
- │ Web API │
- │ (Flask/FastAPI)│
- └─────────────────┘
- │
- ┌─────────────────┐
- │ PhotoTagger │
- │ (Business │
- │ Logic) │
- └─────────────────┘
-```
-
-### Migration Benefits
-- **Clean Separation**: Navigation (menu bar) and content (panels) are clearly separated
-- **Panel-Based Design**: Each panel can become a web page/route
-- **Service Layer**: Business logic is already separated from GUI components
-- **State Management**: Panel switching system mirrors web routing concepts
-- **API-Ready**: Panel methods can easily become API endpoints
-
-## 🧭 Navigation & User Experience
-
-### Smart Navigation System
-- **🏠 Home Icon**: Compact home button (🏠) positioned at the leftmost side of the menu bar
-- **Quick Return**: Single click to return to the welcome screen from any panel
-- **Exit to Home**: All exit buttons in panels now navigate to home instead of closing the application
-- **Consistent UX**: Unified navigation experience across all panels and features
-- **Tooltip Support**: Hover over the home icon to see "Go to the welcome screen"
-
-### Panel Integration
-- **Seamless Switching**: All panels are fully integrated and functional
-- **State Preservation**: Panel states are maintained when switching between features
-- **Background Processing**: Long operations continue running when switching panels
-- **Memory Management**: Proper cleanup and resource management across panels
-
-### Recent Updates (Latest Version)
-- **✅ Complete Panel Integration**: All 7 panels (Home, Scan, Process, Identify, Auto-Match, Search, Modify, Tags) are fully functional
-- **🏠 Home Navigation**: Added compact home icon for instant return to welcome screen
-- **🚪 Exit Button Enhancement**: All exit buttons now navigate to home instead of closing
-- **🎨 UI Improvements**: Enhanced typography and responsive design for full screen experience
-- **🔧 Code Quality**: Improved architecture with proper callback system for navigation
-
-## 🔧 Advanced Features
-
-### Face Recognition Technology
-- **Quality Scoring**: Automatic assessment of face quality (0.0-1.0)
-- **Smart Filtering**: Only high-quality faces used for matching
-- **Multiple Models**: HOG (fast) and CNN (accurate) detection models
-- **Encoding Caching**: Optimized performance with face encoding caching
-
-### Database Management
-- **SQLite Database**: Lightweight, portable database
-- **Optimized Queries**: Efficient database operations
-- **Connection Pooling**: Thread-safe database access
-- **Automatic Schema**: Self-initializing database structure
-
-### Performance Optimizations
-- **Pre-fetching**: Data loaded in advance for faster UI response
-- **Background Processing**: Long operations run in separate threads
-- **Memory Management**: Efficient cleanup of temporary files and caches
-- **Batch Operations**: Process multiple items efficiently
-
-## 📊 Statistics and Monitoring
-
-```bash
-# View database statistics
-python3 photo_tagger.py stats
-```
-
-**Statistics Include:**
-- Total photos in database
-- Total faces detected
-- Identified vs unidentified faces
-- People count
-- Tag statistics
-- Processing status
-
-## 🔄 Common Commands Cheat Sheet
-
-```bash
-# Setup (one time)
-python3 -m venv venv && source venv/bin/activate && python3 setup.py
-
-# Daily usage - Unified Dashboard (RECOMMENDED)
-source venv/bin/activate
-python3 photo_tagger.py dashboard
-
-# Then use the menu bar in the dashboard:
-# 🏠 Home - Return to welcome screen (✅ Fully Functional)
-# 📁 Scan - Add photos (✅ Fully Functional)
-# 🔍 Process - Detect faces (✅ Fully Functional)
-# 👤 Identify - Identify people (✅ Fully Functional)
-# 🔗 Auto-Match - Find matches (✅ Fully Functional)
-# 🔎 Search - Find photos (✅ Fully Functional)
-# ✏️ Modify - Edit identifications (✅ Fully Functional)
-# 🏷️ Tags - Manage tags (✅ Fully Functional)
-
-# Legacy command line usage
-python3 photo_tagger.py scan ~/Pictures --recursive
-python3 photo_tagger.py process --limit 50
-python3 photo_tagger.py identify --show-faces --batch 10
-python3 photo_tagger.py auto-match --show-faces
-python3 photo_tagger.py search-gui
-python3 photo_tagger.py modifyidentified
-python3 photo_tagger.py tag-manager
-python3 photo_tagger.py stats
-```
-
-## 🚀 Development Roadmap
-
-### Phase 1: Core Panel Integration ✅
-- [x] Unified dashboard structure
-- [x] Menu bar navigation
-- [x] Panel switching system
-- [x] Scan panel (fully functional)
-- [x] Process panel (fully functional)
-- [x] Home panel with welcome screen
-- [x] Full screen mode with automatic maximization
-- [x] Responsive design with dynamic resizing
-- [x] Enhanced typography for full screen viewing
-
-### Phase 2: GUI Panel Integration ✅
-- [x] Identify panel integration (fully functional)
-- [x] Auto-Match panel integration (fully functional)
-- [x] Search panel integration (fully functional)
-- [x] Modify panel integration (fully functional)
-- [x] Tag Manager panel integration (fully functional)
-- [x] Home icon navigation (compact home button in menu)
-- [x] Exit button navigation (all exit buttons navigate to home)
-
-### Phase 3: Web Migration Preparation
-- [ ] Service layer extraction
-- [ ] API endpoint design
-- [ ] State management refactoring
-- [ ] File handling abstraction
-
-### Phase 4: Web Application
-- [ ] Web API implementation
-- [ ] Frontend development
-- [ ] Authentication system
-- [ ] Deployment configuration
-
-## 🎉 Key Benefits
-
-### User Experience
-- **Single Window**: No more managing multiple windows
-- **Full Screen Experience**: Automatically opens maximized for optimal viewing
-- **Responsive Design**: All components adapt when window is resized
-- **Consistent Interface**: All features follow the same design patterns
-- **Professional Look**: Modern, clean interface design with enhanced typography
-- **Intuitive Navigation**: Menu bar makes all features easily accessible
-- **Smart Home Navigation**: Compact home icon (🏠) for quick return to welcome screen
-- **Unified Exit Behavior**: All exit buttons navigate to home instead of closing
-- **Complete Feature Set**: All panels fully functional and integrated
-
-### Developer Experience
-- **Modular Design**: Each panel is independent and maintainable
-- **Web-Ready**: Architecture designed for easy web migration
-- **Clean Code**: Clear separation of concerns
-- **Extensible**: Easy to add new panels and features
-
-### Performance
-- **Optimized Loading**: Panels load only when needed
-- **Background Processing**: Long operations don't block the UI
-- **Memory Efficient**: Proper cleanup and resource management
-- **Responsive**: Fast panel switching and updates
-- **Dynamic Resizing**: Real-time layout updates during window resize
-- **Cross-Platform**: Works on Windows, Linux, and macOS with proper full screen support
-
----
-
-**Total project size**: ~4,000+ lines of Python code
-**Dependencies**: 6 essential packages
-**Setup time**: ~5 minutes
-**Perfect for**: Professional photo management with modern unified interface
-**Status**: All panels fully functional and integrated with smart navigation
-
-## 📞 Support
-
-For issues, questions, or contributions:
-- **GitHub Issues**: Report bugs and request features
-- **Documentation**: Check this README for detailed usage instructions
-- **Community**: Join discussions about photo management and face recognition
-
----
-
-*PunimTag - Making photo face recognition simple, powerful, and web-ready.*
diff --git a/docs/README_UNIFIED_DASHBOARD_OLD.md b/docs/README_UNIFIED_DASHBOARD_OLD.md
deleted file mode 100644
index e3bcc51..0000000
--- a/docs/README_UNIFIED_DASHBOARD_OLD.md
+++ /dev/null
@@ -1,490 +0,0 @@
-# PunimTag - Unified Photo Face Tagger
-
-A powerful photo face recognition and tagging system with a modern unified dashboard interface. Designed for easy web migration with clean separation between navigation and functionality.
-
-## 🎯 What's New: Unified Dashboard
-
-**PunimTag now features a unified dashboard interface** that brings all functionality into a single, professional window:
-
-- **📱 Single Window Interface** - No more managing multiple windows
-- **🖥️ Full Screen Mode** - Automatically opens maximized for optimal viewing
-- **📐 Responsive Design** - All components adapt dynamically to window resizing
-- **🎛️ Menu Bar Navigation** - All features accessible from the top menu
-- **🔄 Panel Switching** - Seamless transitions between different functions
-- **🌐 Web-Ready Architecture** - Designed for easy migration to web application
-- **📊 Status Updates** - Real-time feedback on current operations
-- **🎨 Enhanced Typography** - Larger, more readable fonts optimized for full screen
-- **🏠 Smart Home Navigation** - Compact home icon for quick return to welcome screen
-- **🚪 Unified Exit Behavior** - All exit buttons navigate to home instead of closing
-- **✅ Complete Integration** - All panels fully functional and integrated
-
-## 📋 System Requirements
-
-### Minimum Requirements
-- **Python**: 3.7 or higher
-- **Operating System**: Linux, macOS, or Windows
-- **RAM**: 2GB+ (4GB+ recommended for large photo collections)
-- **Storage**: 100MB for application + space for photos and database
-- **Display**: X11 display server (Linux) or equivalent for GUI interface
-
-### Supported Platforms
-- ✅ **Ubuntu/Debian** (fully supported with automatic dependency installation)
-- ✅ **macOS** (manual dependency installation required)
-- ✅ **Windows** (with WSL or manual setup)
-- ⚠️ **Other Linux distributions** (manual dependency installation required)
-
-### What Gets Installed Automatically (Ubuntu/Debian)
-The setup script automatically installs these system packages:
-- **Build tools**: `cmake`, `build-essential`
-- **Math libraries**: `libopenblas-dev`, `liblapack-dev` (for face recognition)
-- **GUI libraries**: `libx11-dev`, `libgtk-3-dev`, `libboost-python-dev`
-- **Image viewer**: `feh` (for face identification interface)
-
-## 🚀 Quick Start
-
-```bash
-# 1. Setup (one time only)
-git clone
-cd PunimTag
-python3 -m venv venv
-source venv/bin/activate # On Windows: venv\Scripts\activate
-python3 setup.py # Installs system deps + Python packages
-
-# 2. Launch Unified Dashboard
-python3 photo_tagger.py dashboard
-
-# 3. Use the menu bar to access all features:
-# 🏠 Home - Return to welcome screen (✅ Fully Functional)
-# 📁 Scan - Add photos to your collection (✅ Fully Functional)
-# 🔍 Process - Detect faces in photos (✅ Fully Functional)
-# 👤 Identify - Identify people in photos (✅ Fully Functional)
-# 🔗 Auto-Match - Find matching faces automatically (✅ Fully Functional)
-# 🔎 Search - Find photos by person name (✅ Fully Functional)
-# ✏️ Modify - Edit face identifications (✅ Fully Functional)
-# 🏷️ Tags - Manage photo tags (✅ Fully Functional)
-```
-
-## 📦 Installation
-
-### Automatic Setup (Recommended)
-```bash
-# Clone and setup
-git clone
-cd PunimTag
-
-# Create virtual environment (IMPORTANT!)
-python3 -m venv venv
-source venv/bin/activate # On Windows: venv\Scripts\activate
-
-# Run setup script
-python3 setup.py
-```
-
-**⚠️ IMPORTANT**: Always activate the virtual environment before running any commands:
-```bash
-source venv/bin/activate # Run this every time you open a new terminal
-```
-
-### Manual Setup (Alternative)
-```bash
-python3 -m venv venv
-source venv/bin/activate
-pip install -r requirements.txt
-python3 photo_tagger.py stats # Creates database
-```
-
-## 🎛️ Unified Dashboard Interface
-
-### Launch the Dashboard
-```bash
-# Open the unified dashboard (RECOMMENDED)
-python3 photo_tagger.py dashboard
-```
-
-### 🖥️ Full Screen & Responsive Features
-
-The dashboard automatically opens in full screen mode and provides a fully responsive experience:
-
-#### **Automatic Full Screen**
-- **Cross-Platform Support**: Works on Windows, Linux, and macOS
-- **Smart Maximization**: Uses the best available method for each platform
-- **Fallback Handling**: Gracefully handles systems that don't support maximization
-- **Minimum Size**: Prevents window from becoming too small (800x600 minimum)
-
-#### **Dynamic Responsiveness**
-- **Real-Time Resizing**: All components adapt as you resize the window
-- **Grid-Based Layout**: Uses proper grid weights for optimal expansion
-- **Status Updates**: Status bar shows current window dimensions
-- **Panel Updates**: Active panels update their layout during resize
-- **Canvas Scrolling**: Similar faces and other scrollable areas update automatically
-
-#### **Enhanced Typography**
-- **Full Screen Optimized**: Larger fonts (24pt titles, 14pt content) for better readability
-- **Consistent Styling**: All panels use the same enhanced font sizes
-- **Professional Appearance**: Clean, modern typography throughout
-
-#### **Smart Navigation**
-- **🏠 Home Icon**: Compact home button (🏠) in the leftmost position of the menu bar
-- **Quick Return**: Click the home icon to instantly return to the welcome screen
-- **Exit to Home**: All exit buttons in panels now navigate to home instead of closing
-- **Consistent UX**: Unified navigation experience across all panels
-
-### Dashboard Features
-
-#### **🏠 Home Panel**
-- Welcome screen with feature overview
-- Quick access guide to all functionality
-- Professional, modern interface with large fonts for full screen
-- Responsive layout that adapts to window size
-
-#### **📁 Scan Panel**
-- **Folder Selection**: Browse and select photo directories
-- **Recursive Scanning**: Include photos in subfolders
-- **Path Validation**: Automatic validation and error handling
-- **Real-time Status**: Live updates during scanning process
-- **Responsive Forms**: Form elements expand and contract with window size
-
-#### **🔍 Process Panel**
-- **Batch Processing**: Process photos in configurable batches
-- **Quality Scoring**: Automatic face quality assessment
-- **Model Selection**: Choose between HOG (fast) and CNN (accurate) models
-- **Progress Tracking**: Real-time processing status
-- **Dynamic Layout**: All controls adapt to window resizing
-
-#### **👤 Identify Panel** *(Fully Functional)*
-- **Visual Face Display**: See individual face crops (400x400 pixels for full screen)
-- **Smart Identification**: Separate fields for first name, last name, middle name, maiden name
-- **Similar Face Matching**: Compare with other unidentified faces
-- **Batch Processing**: Handle multiple faces efficiently
-- **Responsive Layout**: Adapts to window resizing with dynamic updates
-- **Enhanced Navigation**: Back/Next buttons with unsaved changes protection
-
-#### **🔗 Auto-Match Panel** *(Fully Functional)*
-- **Person-Centric Workflow**: Groups faces by already identified people
-- **Visual Confirmation**: Left panel shows identified person, right panel shows potential matches
-- **Confidence Scoring**: Color-coded match confidence levels with detailed descriptions
-- **Bulk Selection**: Select multiple faces for identification with Select All/Clear All
-- **Smart Navigation**: Back/Next buttons to move between different people
-- **Search Functionality**: Filter people by last name for large databases
-- **Pre-selection**: Previously identified faces are automatically checked
-- **Unsaved Changes Protection**: Warns before losing unsaved work
-- **Database Integration**: Proper transactions and face encoding updates
-
-##### **Auto-Match Workflow**
-The auto-match feature works in a **person-centric** way:
-
-1. **Group by Person**: Faces are grouped by already identified people (not unidentified faces)
-2. **Show Matched Person**: Left side shows the identified person and their face
-3. **Show Unidentified Faces**: Right side shows all unidentified faces that match this person
-4. **Select and Save**: Check the faces you want to identify with this person, then click "Save Changes"
-5. **Navigate**: Use Back/Next to move between different people
-6. **Correct Mistakes**: Go back and uncheck faces to unidentify them
-7. **Pre-selected Checkboxes**: Previously identified faces are automatically checked when you go back
-
-**Key Benefits:**
-- **1-to-Many**: One person can have multiple unidentified faces matched to them
-- **Visual Confirmation**: See exactly what you're identifying before saving
-- **Easy Corrections**: Go back and fix mistakes by unchecking faces
-- **Smart Tracking**: Previously identified faces are pre-selected for easy review
-
-##### **Auto-Match Configuration**
-- **Tolerance Setting**: Adjust matching sensitivity (lower = stricter matching)
-- **Start Button**: Prominently positioned on the left for easy access
-- **Search Functionality**: Filter people by last name for large databases
-- **Exit Button**: "Exit Auto-Match" with unsaved changes protection
-
-#### **🔎 Search Panel** *(Fully Functional)*
-- **Multiple Search Types**: Search photos by name, date, tags, and special categories
-- **Advanced Filtering**: Filter by folder location with browse functionality
-- **Results Display**: Sortable table with person names, tags, processed status, and dates
-- **Interactive Results**: Click to open photos, browse folders, and view people
-- **Tag Management**: Add and remove tags from selected photos
-- **Responsive Layout**: Adapts to window resizing with proper scrolling
-
-#### **✏️ Modify Panel** *(Fully Functional)*
-- **Review Identifications**: View all identified people with face counts
-- **Edit Names**: Rename people with full name fields (first, last, middle, maiden, date of birth)
-- **Unmatch Faces**: Temporarily remove face associations with visual confirmation
-- **Bulk Operations**: Handle multiple changes efficiently with undo functionality
-- **Search People**: Filter people by last name for large databases
-- **Visual Calendar**: Date of birth selection with intuitive calendar interface
-- **Responsive Layout**: Face grid adapts to window resizing
-- **Unsaved Changes Protection**: Warns before losing unsaved work
-
-#### **🏷️ Tag Manager Panel** *(Fully Functional)*
-- **Photo Explorer**: Browse photos organized by folders with thumbnail previews
-- **Multiple View Modes**: List view, icon view, compact view, and folder view
-- **Tag Management**: Add, remove, and organize tags with bulk operations
-- **People Integration**: View and manage people identified in photos
-- **Bulk Tagging**: Link tags to entire folders or multiple photos at once
-- **Search & Filter**: Find photos by tags, people, or folder location
-- **Responsive Layout**: Adapts to window resizing with proper scrolling
-- **Exit to Home**: Exit button navigates to home screen instead of closing
-
-## 🎯 Command Line Interface (Legacy)
-
-While the unified dashboard is the recommended interface, the command line interface is still available:
-
-### Scan for Photos
-```bash
-# Scan a folder (absolute path recommended)
-python3 photo_tagger.py scan /path/to/photos
-
-# Scan with relative path (auto-converted to absolute)
-python3 photo_tagger.py scan demo_photos
-
-# Scan recursively (recommended)
-python3 photo_tagger.py scan /path/to/photos --recursive
-```
-
-### Process Photos for Faces
-```bash
-# Process 50 photos (default)
-python3 photo_tagger.py process
-
-# Process 20 photos with CNN model (more accurate)
-python3 photo_tagger.py process --limit 20 --model cnn
-
-# Process with HOG model (faster)
-python3 photo_tagger.py process --limit 100 --model hog
-```
-
-### Individual GUI Windows (Legacy)
-```bash
-# Open individual GUI windows (legacy mode)
-python3 photo_tagger.py identify --show-faces --batch 10
-python3 photo_tagger.py auto-match --show-faces
-python3 photo_tagger.py search-gui
-python3 photo_tagger.py modifyidentified
-python3 photo_tagger.py tag-manager
-```
-
-## 🏗️ Architecture: Web Migration Ready
-
-### Current Desktop Architecture
-```
-┌─────────────────────────────────────────────────────────────┐
-│ Unified Dashboard │
-│ ┌─────────────────────────────────────────────────────────┐│
-│ │ Menu Bar ││
-│ │ [🏠] [Scan] [Process] [Identify] [Search] [Tags] [Modify]││
-│ └─────────────────────────────────────────────────────────┘│
-│ ┌─────────────────────────────────────────────────────────┐│
-│ │ Content Area ││
-│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││
-│ │ │Home Panel │ │Identify │ │Search Panel │ ││
-│ │ │(Welcome) │ │Panel │ │ │ ││
-│ │ └─────────────┘ └─────────────┘ └─────────────┘ ││
-│ └─────────────────────────────────────────────────────────┘│
-└─────────────────────────────────────────────────────────────┘
- │
- ┌─────────────────┐
- │ PhotoTagger │
- │ (Business │
- │ Logic) │
- └─────────────────┘
-```
-
-### Future Web Architecture
-```
-┌─────────────────────────────────────────────────────────────┐
-│ Web Browser │
-│ ┌─────────────────────────────────────────────────────────┐│
-│ │ Navigation Bar ││
-│ │ [🏠] [Scan] [Process] [Identify] [Search] [Tags] [Modify]││
-│ └─────────────────────────────────────────────────────────┘│
-│ ┌─────────────────────────────────────────────────────────┐│
-│ │ Main Content Area ││
-│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││
-│ │ │Home Page │ │Identify │ │Search Page │ ││
-│ │ │(Welcome) │ │Page │ │ │ ││
-│ │ └─────────────┘ └─────────────┘ └─────────────┘ ││
-│ └─────────────────────────────────────────────────────────┘│
-└─────────────────────────────────────────────────────────────┘
- │
- ┌─────────────────┐
- │ Web API │
- │ (Flask/FastAPI)│
- └─────────────────┘
- │
- ┌─────────────────┐
- │ PhotoTagger │
- │ (Business │
- │ Logic) │
- └─────────────────┘
-```
-
-### Migration Benefits
-- **Clean Separation**: Navigation (menu bar) and content (panels) are clearly separated
-- **Panel-Based Design**: Each panel can become a web page/route
-- **Service Layer**: Business logic is already separated from GUI components
-- **State Management**: Panel switching system mirrors web routing concepts
-- **API-Ready**: Panel methods can easily become API endpoints
-
-## 🧭 Navigation & User Experience
-
-### Smart Navigation System
-- **🏠 Home Icon**: Compact home button (🏠) positioned at the leftmost side of the menu bar
-- **Quick Return**: Single click to return to the welcome screen from any panel
-- **Exit to Home**: All exit buttons in panels now navigate to home instead of closing the application
-- **Consistent UX**: Unified navigation experience across all panels and features
-- **Tooltip Support**: Hover over the home icon to see "Go to the welcome screen"
-
-### Panel Integration
-- **Seamless Switching**: All panels are fully integrated and functional
-- **State Preservation**: Panel states are maintained when switching between features
-- **Background Processing**: Long operations continue running when switching panels
-- **Memory Management**: Proper cleanup and resource management across panels
-
-### Recent Updates (Latest Version)
-- **✅ Complete Panel Integration**: All 7 panels (Home, Scan, Process, Identify, Auto-Match, Search, Modify, Tags) are fully functional
-- **🏠 Home Navigation**: Added compact home icon for instant return to welcome screen
-- **🚪 Exit Button Enhancement**: All exit buttons now navigate to home instead of closing
-- **🎨 UI Improvements**: Enhanced typography and responsive design for full screen experience
-- **🔧 Code Quality**: Improved architecture with proper callback system for navigation
-
-## 🔧 Advanced Features
-
-### Face Recognition Technology
-- **Quality Scoring**: Automatic assessment of face quality (0.0-1.0)
-- **Smart Filtering**: Only high-quality faces used for matching
-- **Multiple Models**: HOG (fast) and CNN (accurate) detection models
-- **Encoding Caching**: Optimized performance with face encoding caching
-
-### Database Management
-- **SQLite Database**: Lightweight, portable database
-- **Optimized Queries**: Efficient database operations
-- **Connection Pooling**: Thread-safe database access
-- **Automatic Schema**: Self-initializing database structure
-
-### Performance Optimizations
-- **Pre-fetching**: Data loaded in advance for faster UI response
-- **Background Processing**: Long operations run in separate threads
-- **Memory Management**: Efficient cleanup of temporary files and caches
-- **Batch Operations**: Process multiple items efficiently
-
-## 📊 Statistics and Monitoring
-
-```bash
-# View database statistics
-python3 photo_tagger.py stats
-```
-
-**Statistics Include:**
-- Total photos in database
-- Total faces detected
-- Identified vs unidentified faces
-- People count
-- Tag statistics
-- Processing status
-
-## 🔄 Common Commands Cheat Sheet
-
-```bash
-# Setup (one time)
-python3 -m venv venv && source venv/bin/activate && python3 setup.py
-
-# Daily usage - Unified Dashboard (RECOMMENDED)
-source venv/bin/activate
-python3 photo_tagger.py dashboard
-
-# Then use the menu bar in the dashboard:
-# 🏠 Home - Return to welcome screen (✅ Fully Functional)
-# 📁 Scan - Add photos (✅ Fully Functional)
-# 🔍 Process - Detect faces (✅ Fully Functional)
-# 👤 Identify - Identify people (✅ Fully Functional)
-# 🔗 Auto-Match - Find matches (✅ Fully Functional)
-# 🔎 Search - Find photos (✅ Fully Functional)
-# ✏️ Modify - Edit identifications (✅ Fully Functional)
-# 🏷️ Tags - Manage tags (✅ Fully Functional)
-
-# Legacy command line usage
-python3 photo_tagger.py scan ~/Pictures --recursive
-python3 photo_tagger.py process --limit 50
-python3 photo_tagger.py identify --show-faces --batch 10
-python3 photo_tagger.py auto-match --show-faces
-python3 photo_tagger.py search-gui
-python3 photo_tagger.py modifyidentified
-python3 photo_tagger.py tag-manager
-python3 photo_tagger.py stats
-```
-
-## 🚀 Development Roadmap
-
-### Phase 1: Core Panel Integration ✅
-- [x] Unified dashboard structure
-- [x] Menu bar navigation
-- [x] Panel switching system
-- [x] Scan panel (fully functional)
-- [x] Process panel (fully functional)
-- [x] Home panel with welcome screen
-- [x] Full screen mode with automatic maximization
-- [x] Responsive design with dynamic resizing
-- [x] Enhanced typography for full screen viewing
-
-### Phase 2: GUI Panel Integration ✅
-- [x] Identify panel integration (fully functional)
-- [x] Auto-Match panel integration (fully functional)
-- [x] Search panel integration (fully functional)
-- [x] Modify panel integration (fully functional)
-- [x] Tag Manager panel integration (fully functional)
-- [x] Home icon navigation (compact home button in menu)
-- [x] Exit button navigation (all exit buttons navigate to home)
-
-### Phase 3: Web Migration Preparation
-- [ ] Service layer extraction
-- [ ] API endpoint design
-- [ ] State management refactoring
-- [ ] File handling abstraction
-
-### Phase 4: Web Application
-- [ ] Web API implementation
-- [ ] Frontend development
-- [ ] Authentication system
-- [ ] Deployment configuration
-
-## 🎉 Key Benefits
-
-### User Experience
-- **Single Window**: No more managing multiple windows
-- **Full Screen Experience**: Automatically opens maximized for optimal viewing
-- **Responsive Design**: All components adapt when window is resized
-- **Consistent Interface**: All features follow the same design patterns
-- **Professional Look**: Modern, clean interface design with enhanced typography
-- **Intuitive Navigation**: Menu bar makes all features easily accessible
-- **Smart Home Navigation**: Compact home icon (🏠) for quick return to welcome screen
-- **Unified Exit Behavior**: All exit buttons navigate to home instead of closing
-- **Complete Feature Set**: All panels fully functional and integrated
-
-### Developer Experience
-- **Modular Design**: Each panel is independent and maintainable
-- **Web-Ready**: Architecture designed for easy web migration
-- **Clean Code**: Clear separation of concerns
-- **Extensible**: Easy to add new panels and features
-
-### Performance
-- **Optimized Loading**: Panels load only when needed
-- **Background Processing**: Long operations don't block the UI
-- **Memory Efficient**: Proper cleanup and resource management
-- **Responsive**: Fast panel switching and updates
-- **Dynamic Resizing**: Real-time layout updates during window resize
-- **Cross-Platform**: Works on Windows, Linux, and macOS with proper full screen support
-
----
-
-**Total project size**: ~4,000+ lines of Python code
-**Dependencies**: 6 essential packages
-**Setup time**: ~5 minutes
-**Perfect for**: Professional photo management with modern unified interface
-**Status**: All panels fully functional and integrated with smart navigation
-
-## 📞 Support
-
-For issues, questions, or contributions:
-- **GitHub Issues**: Report bugs and request features
-- **Documentation**: Check this README for detailed usage instructions
-- **Community**: Join discussions about photo management and face recognition
-
----
-
-*PunimTag - Making photo face recognition simple, powerful, and web-ready.*
diff --git a/docs/RETINAFACE_EYE_BEHAVIOR.md b/docs/RETINAFACE_EYE_BEHAVIOR.md
deleted file mode 100644
index b501500..0000000
--- a/docs/RETINAFACE_EYE_BEHAVIOR.md
+++ /dev/null
@@ -1,144 +0,0 @@
-# RetinaFace Eye Visibility Behavior Analysis
-
-**Date:** 2025-11-06
-**Test:** `scripts/test_eye_visibility.py`
-**Result:** ✅ VERIFIED
-
----
-
-## Key Finding
-
-**RetinaFace always provides both eyes, even for extreme profile views.**
-
-RetinaFace **estimates/guesses** the position of non-visible eyes rather than returning `None`.
-
----
-
-## Test Results
-
-**Test Image:** `demo_photos/2019-11-22_0015.jpg`
-**Faces Detected:** 10 faces
-
-### Results Summary
-
-| Face | Both Eyes Present | Face Width | Yaw Angle | Pose Mode | Notes |
-|------|-------------------|------------|-----------|-----------|-------|
-| face_1 | ✅ Yes | 3.86 px | 16.77° | frontal | ⚠️ Extreme profile (very small width) |
-| face_2 | ✅ Yes | 92.94 px | 3.04° | frontal | Normal frontal face |
-| face_3 | ✅ Yes | 78.95 px | -8.23° | frontal | Normal frontal face |
-| face_4 | ✅ Yes | 6.52 px | -30.48° | profile_right | Profile detected via yaw |
-| face_5 | ✅ Yes | 10.98 px | -1.82° | frontal | ⚠️ Extreme profile (small width) |
-| face_6 | ✅ Yes | 9.09 px | -3.67° | frontal | ⚠️ Extreme profile (small width) |
-| face_7 | ✅ Yes | 7.09 px | 19.48° | frontal | ⚠️ Extreme profile (small width) |
-| face_8 | ✅ Yes | 10.59 px | 1.16° | frontal | ⚠️ Extreme profile (small width) |
-| face_9 | ✅ Yes | 5.24 px | 33.28° | profile_left | Profile detected via yaw |
-| face_10 | ✅ Yes | 7.70 px | -15.40° | frontal | ⚠️ Extreme profile (small width) |
-
-### Key Observations
-
-1. **All 10 faces had both eyes present** - No missing eyes detected
-2. **Extreme profile faces** (face_1, face_5-8, face_10) have very small face widths (3-11 pixels)
-3. **Normal frontal faces** (face_2, face_3) have large face widths (78-93 pixels)
-4. **Some extreme profiles** are misclassified as "frontal" because yaw angle is below 30° threshold
-
----
-
-## Implications
-
-### ❌ Cannot Use Missing Eye Detection
-
-**RetinaFace does NOT return `None` for missing eyes.** It always provides both eye positions, even when one eye is not visible in the image.
-
-**Therefore:**
-- ❌ We **cannot** check `if left_eye is None` to detect profile views
-- ❌ We **cannot** use missing eye as a direct profile indicator
-- ✅ We **must** rely on other indicators (face width, yaw angle)
-
-### ✅ Current Approach is Correct
-
-**Face width (eye distance) is the best indicator for profile detection:**
-
-- **Profile faces:** Face width < 25 pixels (typically 3-15 pixels)
-- **Frontal faces:** Face width > 50 pixels (typically 50-100+ pixels)
-- **Threshold:** 25 pixels is a good separator
-
-**Current implementation already uses this:**
-```python
-# In classify_pose_mode():
-if face_width is not None and face_width < PROFILE_FACE_WIDTH_THRESHOLD: # 25 pixels
- # Small face width indicates profile view
- yaw_mode = "profile_left" or "profile_right"
-```
-
----
-
-## Recommendations
-
-### 1. ✅ Keep Using Face Width
-
-The current face width-based detection is working correctly. Continue using it as the primary indicator for extreme profile views.
-
-### 2. ⚠️ Improve Profile Detection for Edge Cases
-
-Some extreme profile faces are being misclassified as "frontal" because:
-- Face width is small (< 25px) ✅
-- But yaw angle is below 30° threshold ❌
-- Result: Classified as "frontal" instead of "profile"
-
-**Example from test:**
-- face_1: Face width = 3.86px (extreme profile), yaw = 16.77° (< 30°), classified as "frontal" ❌
-- face_5: Face width = 10.98px (extreme profile), yaw = -1.82° (< 30°), classified as "frontal" ❌
-
-**Solution:** The code already handles this! The `classify_pose_mode()` method checks face width **before** yaw angle:
-
-```python
-# Current code (lines 292-306):
-if face_width is not None and face_width < PROFILE_FACE_WIDTH_THRESHOLD:
- # Small face width indicates profile view
- # Determine direction based on yaw (if available) or default to profile_left
- if yaw is not None and yaw != 0.0:
- if yaw < -10.0:
- yaw_mode = "profile_right"
- elif yaw > 10.0:
- yaw_mode = "profile_left"
- else:
- yaw_mode = "profile_left" # Default for extreme profiles
-```
-
-**However**, the test shows some faces are still classified as "frontal". This suggests the face_width might not be passed correctly, or the yaw threshold check is happening first.
-
-### 3. 🔍 Verify Face Width is Being Used
-
-Check that `face_width` is actually being passed to `classify_pose_mode()` in all cases.
-
----
-
-## Conclusion
-
-**RetinaFace Behavior:**
-- ✅ Always returns both eyes (estimates non-visible eye positions)
-- ❌ Never returns `None` for missing eyes
-- ✅ Face width (eye distance) is reliable for profile detection
-
-**Current Implementation:**
-- ✅ Already uses face width for profile detection
-- ⚠️ May need to verify face_width is always passed correctly
-- ✅ Cannot use missing eye detection (not applicable)
-
-**Next Steps:**
-1. Verify `face_width` is always passed to `classify_pose_mode()`
-2. Consider lowering yaw threshold for small face widths
-3. Test on more extreme profile images to validate
-
----
-
-## Test Command
-
-To re-run this test:
-
-```bash
-cd /home/ladmin/Code/punimtag
-source venv/bin/activate
-python3 scripts/test_eye_visibility.py
-```
-
diff --git a/docs/STATUS.md b/docs/STATUS.md
deleted file mode 100644
index cb1e7b6..0000000
--- a/docs/STATUS.md
+++ /dev/null
@@ -1,198 +0,0 @@
-# PunimTag Project Status
-
-**Last Updated**: October 15, 2025
-**Status**: ✅ **FULLY OPERATIONAL**
-
----
-
-## 🎉 Project Restructure: COMPLETE
-
-### ✅ All Tasks Completed
-
-1. **Directory Structure** ✅
- - Professional Python layout implemented
- - Files organized into src/core/, src/gui/, src/utils/
- - Tests separated into tests/
- - Documentation in docs/
- - Project notes in .notes/
-
-2. **Python Packages** ✅
- - __init__.py files created
- - Public APIs defined
- - Proper module hierarchy
-
-3. **Import Statements** ✅
- - 13 source files updated
- - All imports use src.* paths
- - No import errors
-
-4. **Launcher Script** ✅
- - run_dashboard.py created and working
- - Properly initializes all dependencies
- - Uses correct `app.open()` method
-
-5. **Application** ✅
- - Dashboard GUI running successfully
- - All features accessible
- - No errors
-
-6. **Documentation** ✅
- - 11 documentation files created
- - Complete user and developer guides
- - Architecture documented
-
----
-
-## 🚀 How to Run
-
-```bash
-# Activate virtual environment
-source venv/bin/activate
-
-# Run dashboard
-python run_dashboard.py
-```
-
-**That's it!** The application will launch in full-screen mode.
-
----
-
-## 📊 Project Statistics
-
-| Metric | Count |
-|--------|-------|
-| Total Files | 96 |
-| Files Moved | 27 |
-| Imports Fixed | 13 |
-| New Directories | 8 |
-| Documentation Files | 11 |
-| Lines of Code | ~15,000+ |
-
----
-
-## 📁 Current Structure
-
-```
-punimtag/
-├── src/
-│ ├── core/ # 6 business logic modules ✅
-│ ├── gui/ # 6 GUI components ✅
-│ └── utils/ # 1 utility module ✅
-├── tests/ # 8 test files ✅
-├── docs/ # 4 documentation files ✅
-├── .notes/ # 4 planning documents ✅
-├── archive/ # 7 legacy files ✅
-├── run_dashboard.py # Main launcher ✅
-├── README.md # User guide ✅
-├── CONTRIBUTING.md # Dev guidelines ✅
-├── QUICK_START.md # Quick reference ✅
-└── STATUS.md # This file ✅
-```
-
----
-
-## ✨ Key Features Working
-
-- ✅ Photo scanning and import
-- ✅ Face detection and processing
-- ✅ Person identification
-- ✅ Auto-matching
-- ✅ Tag management
-- ✅ Advanced search
-- ✅ Statistics and analytics
-
----
-
-## 📚 Documentation Available
-
-1. **README.md** - Main user documentation
-2. **QUICK_START.md** - Quick reference guide
-3. **CONTRIBUTING.md** - Contribution guidelines
-4. **docs/ARCHITECTURE.md** - System architecture
-5. **docs/DEMO.md** - Demo walkthrough
-6. **RESTRUCTURE_SUMMARY.md** - Restructure details
-7. **IMPORT_FIX_SUMMARY.md** - Import fixes
-8. **.notes/project_overview.md** - Project goals
-9. **.notes/task_list.md** - Task tracking
-10. **.notes/directory_structure.md** - Structure details
-11. **.notes/meeting_notes.md** - Meeting records
-
----
-
-## 🎯 Quality Metrics
-
-| Aspect | Status |
-|--------|--------|
-| Code Organization | ⭐⭐⭐⭐⭐ Excellent |
-| Documentation | ⭐⭐⭐⭐⭐ Comprehensive |
-| Maintainability | ⭐⭐⭐⭐⭐ High |
-| Scalability | ⭐⭐⭐⭐⭐ Ready |
-| Professional | ⭐⭐⭐⭐⭐ World-class |
-
----
-
-## 🔄 Optional Next Steps
-
-- [ ] Update test file imports (tests/*.py)
-- [ ] Update demo scripts (demo.sh, etc.)
-- [ ] Run full test suite
-- [ ] Commit changes to git
-- [ ] Begin DeepFace migration
-
----
-
-## 🐛 Known Issues
-
-**None!** All critical issues resolved. ✅
-
----
-
-## 💡 Tips for Development
-
-1. Always activate venv: `source venv/bin/activate`
-2. Use launcher: `python run_dashboard.py`
-3. Check docs in `docs/` for architecture
-4. Read `.notes/` for planning info
-5. Follow `CONTRIBUTING.md` for guidelines
-
----
-
-## 🎓 Learning Resources
-
-- **Architecture**: See `docs/ARCHITECTURE.md`
-- **Code Style**: See `.cursorrules`
-- **Structure**: See `.notes/directory_structure.md`
-- **Migration**: See `RESTRUCTURE_SUMMARY.md`
-
----
-
-## 🏆 Achievements
-
-✅ Transformed from cluttered to professional
-✅ Implemented Python best practices
-✅ Created comprehensive documentation
-✅ Established scalable architecture
-✅ Ready for team collaboration
-✅ Prepared for future enhancements
-
----
-
-## 📞 Support
-
-For questions or issues:
-1. Check documentation in `docs/`
-2. Read planning notes in `.notes/`
-3. See `CONTRIBUTING.md` for guidelines
-
----
-
-**Project Status**: 🟢 **EXCELLENT**
-
-**Ready for**: Development, Collaboration, Production
-
-**Next Milestone**: DeepFace Migration (see `.notes/task_list.md`)
-
----
-
-*This project is now a professional, maintainable, and scalable Python application!* 🎉
-
diff --git a/docs/TAG_PHOTOS_PERFORMANCE_ANALYSIS.md b/docs/TAG_PHOTOS_PERFORMANCE_ANALYSIS.md
deleted file mode 100644
index e7f36cd..0000000
--- a/docs/TAG_PHOTOS_PERFORMANCE_ANALYSIS.md
+++ /dev/null
@@ -1,234 +0,0 @@
-# Tag Photos Performance Analysis
-
-## Executive Summary
-
-The Tag Photos page has significant performance bottlenecks, primarily in the backend database queries. The current implementation uses an N+1 query pattern that results in thousands of database queries for large photo collections.
-
-## Current Performance Issues
-
-### 1. Backend: N+1 Query Problem (CRITICAL)
-
-**Location:** `src/web/services/tag_service.py::get_photos_with_tags()`
-
-**Problem:**
-- Loads all photos in one query (line 238-242)
-- Then makes **4 separate queries per photo** in a loop:
- 1. Face count query (line 247-251)
- 2. Unidentified face count query (line 254-259)
- 3. Tags query (line 262-269)
- 4. People names query (line 272-280)
-
-**Impact:**
-- For 1,000 photos: **1 + (1,000 × 4) = 4,001 database queries**
-- For 10,000 photos: **1 + (10,000 × 4) = 40,001 database queries**
-- Each query has network latency and database processing time
-- This is the primary cause of slow loading
-
-**Example Timeline (estimated for 1,000 photos):**
-- Initial photo query: ~50ms
-- 1,000 face count queries: ~2,000ms (2ms each)
-- 1,000 unidentified face count queries: ~2,000ms
-- 1,000 tags queries: ~3,000ms (3ms each, includes joins)
-- 1,000 people names queries: ~3,000ms (3ms each, includes joins)
-- **Total: ~10+ seconds** (depending on database performance)
-
-### 2. Backend: Missing Database Indexes
-
-**Potential Issues:**
-- `Face.photo_id` may not be indexed (affects face count queries)
-- `PhotoTagLinkage.photo_id` may not be indexed (affects tag queries)
-- `Face.person_id` may not be indexed (affects people names queries)
-- Composite indexes may be missing for common query patterns
-
-### 3. Frontend: Loading All Data at Once
-
-**Location:** `frontend/src/pages/Tags.tsx::loadData()`
-
-**Problem:**
-- Loads ALL photos and tags in a single request (line 103-106)
-- No pagination or lazy loading
-- For large collections, this means:
- - Large JSON payload (network transfer time)
- - Large JavaScript object in memory
- - Slow initial render
-
-**Impact:**
-- Network transfer time for large datasets
-- Browser memory usage
-- Initial render blocking
-
-### 4. Frontend: Expensive Computations on Every Render
-
-**Location:** `frontend/src/pages/Tags.tsx::folderGroups` (line 134-256)
-
-**Problem:**
-- Complex `useMemo` that:
- - Filters photos
- - Groups by folder
- - Sorts folders
- - Sorts photos within folders
-- Runs on every state change (photos, sortColumn, sortDir, showOnlyUnidentified)
-- For large datasets, this can take 100-500ms
-
-**Impact:**
-- UI freezes during computation
-- Poor user experience when changing filters/sorting
-
-### 5. Frontend: Dialog Loading Performance
-
-**Location:** `frontend/src/pages/Tags.tsx::TagSelectedPhotosDialog` (line 1781-1799)
-
-**Problem:**
-- When opening "Tag Selected Photos" dialog, loads tags for ALL selected photos sequentially
-- Uses a `for` loop with await (line 1784-1792)
-- No batching or parallelization
-
-**Impact:**
-- If 100 photos selected: 100 sequential API calls
-- Each call takes ~50-100ms
-- **Total: 5-10 seconds** just to open the dialog
-
-### 6. Frontend: Unnecessary Re-renders
-
-**Problem:**
-- Multiple `useEffect` hooks that trigger re-renders
-- Folder state changes trigger full re-computation
-- Dialog open/close triggers full data reload (line 1017-1020, 1038-1041, 1108-1112)
-
-**Impact:**
-- Unnecessary API calls
-- Unnecessary computations
-- Poor perceived performance
-
-## Optimization Recommendations
-
-### Priority 1: Fix Backend N+1 Query Problem (HIGHEST IMPACT)
-
-**Solution: Use JOINs and Aggregations**
-
-Replace the loop-based approach with a single optimized query using:
-- LEFT JOINs for related data
-- GROUP BY with aggregations
-- Subqueries or window functions for counts
-
-**Expected Improvement:**
-- From 4,001 queries → **1-3 queries**
-- From 10+ seconds → **< 1 second** (for 1,000 photos)
-
-**Implementation Approach:**
-```python
-# Use SQLAlchemy to build a single query with:
-# - LEFT JOIN for faces (with COUNT aggregation)
-# - LEFT JOIN for tags (with GROUP_CONCAT equivalent)
-# - LEFT JOIN for people (with GROUP_CONCAT equivalent)
-# - Subquery for unidentified face count
-```
-
-### Priority 2: Add Database Indexes
-
-**Required Indexes:**
-- `faces.photo_id` (if not exists)
-- `faces.person_id` (if not exists)
-- `phototaglinkage.photo_id` (if not exists)
-- `phototaglinkage.tag_id` (if not exists)
-- Composite index: `(photo_id, person_id)` on faces table
-
-**Expected Improvement:**
-- 20-50% faster query execution
-- Better scalability
-
-### Priority 3: Implement Pagination
-
-**Backend:**
-- Add `page` and `page_size` parameters to `get_photos_with_tags_endpoint`
-- Return paginated results
-
-**Frontend:**
-- Load photos in pages (e.g., 100 at a time)
-- Implement infinite scroll or "Load More" button
-- Only render visible photos (virtual scrolling)
-
-**Expected Improvement:**
-- Initial load: **< 1 second** (first page only)
-- Better perceived performance
-- Lower memory usage
-
-### Priority 4: Optimize Frontend Computations
-
-**Solutions:**
-1. **Memoization:** Better use of `useMemo` and `useCallback`
-2. **Virtual Scrolling:** Only render visible rows (react-window or similar)
-3. **Debouncing:** Debounce filter/sort changes
-4. **Lazy Loading:** Load folder contents on expand
-
-**Expected Improvement:**
-- Smooth UI interactions
-- No freezing during filter/sort changes
-
-### Priority 5: Batch API Calls in Dialogs
-
-**Solution:**
-- Create a batch endpoint: `GET /api/v1/tags/photos/batch?photo_ids=1,2,3...`
-- Load tags for multiple photos in one request
-- Or use Promise.all() for parallel requests (with limit)
-
-**Expected Improvement:**
-- Dialog open time: From 5-10 seconds → **< 1 second**
-
-### Priority 6: Cache and State Management
-
-**Solutions:**
-1. **SessionStorage:** Cache loaded photos (already partially done)
-2. **Optimistic Updates:** Update UI immediately, sync in background
-3. **Incremental Loading:** Load only changed data after mutations
-
-**Expected Improvement:**
-- Faster subsequent loads
-- Better user experience
-
-## Performance Metrics (Current vs. Optimized)
-
-### Current Performance (1,000 photos)
-- **Initial Load:** 10-15 seconds
-- **Filter/Sort Change:** 500ms-1s (UI freeze)
-- **Dialog Open (100 photos):** 5-10 seconds
-- **Database Queries:** 4,001 queries
-- **Memory Usage:** High (all photos in memory)
-
-### Optimized Performance (1,000 photos)
-- **Initial Load:** < 1 second (first page)
-- **Filter/Sort Change:** < 100ms (smooth)
-- **Dialog Open (100 photos):** < 1 second
-- **Database Queries:** 1-3 queries
-- **Memory Usage:** Low (only visible photos)
-
-## Implementation Priority
-
-1. **Phase 1 (Critical):** ✅ Fix backend N+1 queries - **COMPLETED**
- - Rewrote `get_photos_with_tags()` to use 3 queries instead of 4N+1
- - Query 1: Photos with face counts (LEFT JOIN + GROUP BY)
- - Query 2: All tags for all photos (single query with IN clause)
- - Query 3: All people for all photos (single query with IN clause)
- - Expected improvement: 10+ seconds → < 1 second for 1,000 photos
-
-2. **Phase 2 (High):** Add database indexes
-3. **Phase 3 (High):** Implement pagination
-4. **Phase 4 (Medium):** Optimize frontend computations
-5. **Phase 5 (Medium):** Batch API calls in dialogs
-6. **Phase 6 (Low):** Advanced caching and state management
-
-## Testing Recommendations
-
-1. **Load Testing:** Test with 1,000, 5,000, and 10,000 photos
-2. **Database Profiling:** Use query profiling to identify slow queries
-3. **Frontend Profiling:** Use React DevTools Profiler
-4. **Network Analysis:** Monitor API response times
-5. **User Testing:** Measure perceived performance
-
-## Additional Considerations
-
-1. **Progressive Loading:** Show skeleton screens while loading
-2. **Error Handling:** Graceful degradation if queries fail
-3. **Monitoring:** Add performance metrics/logging
-4. **Documentation:** Document query patterns and indexes
-
diff --git a/docs/TAG_TO_IDENTIFY_ANALYSIS.md b/docs/TAG_TO_IDENTIFY_ANALYSIS.md
deleted file mode 100644
index d6dd486..0000000
--- a/docs/TAG_TO_IDENTIFY_ANALYSIS.md
+++ /dev/null
@@ -1,380 +0,0 @@
-# Analysis: Extract Faces from Tag UI and Navigate to Identify Page
-
-## User Request
-In Tag UI, when selecting a photo, extract faces from it (if processed) and jump to Identify page with only those faces as reference faces (for left panel), possibly in a new tab.
-
-## Current State Analysis
-
-### Tag UI (`frontend/src/pages/Tags.tsx`)
-- **Photo Selection**: Photos can be selected via checkboxes (lines 585-600)
-- **Photo Data Available**:
- - `photo.id` - Photo ID
- - `photo.face_count` - Number of faces detected (line 651)
- - `photo.processed` - Whether photo has been processed (line 641)
-- **Current Actions**:
- - Tag management (add/remove tags)
- - Bulk tagging operations
- - No navigation to Identify page currently
-
-### Identify Page (`frontend/src/pages/Identify.tsx`)
-- **Face Loading**: Uses `facesApi.getUnidentified()` (line 86)
-- **API Endpoint**: `/api/v1/faces/unidentified`
-- **Current Filters Supported**:
- - `page`, `page_size`
- - `min_quality`
- - `date_taken_from`, `date_taken_to`
- - `sort_by`, `sort_dir`
- - `tag_names`, `match_all`
- - **❌ NO `photo_id` filter currently supported**
-
-### Backend API (`src/web/api/faces.py`)
-- **Endpoint**: `GET /api/v1/faces/unidentified` (lines 104-171)
-- **Service Function**: `list_unidentified_faces()` in `face_service.py` (lines 1194-1300)
-- **Current Filters**: Quality, dates, tags
-- **❌ NO `photo_id` parameter in service function**
-
-### Routing (`frontend/src/App.tsx`)
-- Uses React Router v6
-- Identify route: `/identify` (line 42)
-- Can use `useNavigate()` hook for navigation
-- Can pass state via `navigate('/identify', { state: {...} })`
-- Can use URL search params: `/identify?photo_ids=1,2,3`
-- Can open in new tab: `window.open('/identify?photo_ids=1,2,3', '_blank')`
-
-## What's Needed
-
-1. **Get faces for selected photo(s)**
- - Need API endpoint or modify existing to filter by `photo_id`
- - Only get faces if photo is processed (`photo.processed === true`)
- - Only get unidentified faces (no `person_id`)
-
-2. **Navigate to Identify page**
- - Pass face IDs or photo IDs to Identify page
- - Load only those faces in the left panel (reference faces)
- - Optionally open in new tab
-
-3. **Identify page modifications**
- - Check for photo_ids or face_ids in URL params or state
- - If provided, load only those faces instead of all unidentified faces
- - Display them in the left panel as reference faces
-
-## Possible Approaches
-
-### Approach A: Add `photo_id` filter to existing `/unidentified` endpoint
-**Pros:**
-- Minimal changes to existing API
-- Reuses existing filtering logic
-- Consistent with other filters
-
-**Cons:**
-- Only works for unidentified faces
-- Need to support multiple photo_ids (array)
-
-**Implementation:**
-1. Add `photo_ids: Optional[List[int]]` parameter to `list_unidentified_faces()` service
-2. Add `photo_ids: Optional[str]` query param to API endpoint (comma-separated)
-3. Filter query: `query.filter(Face.photo_id.in_(photo_ids))`
-4. Frontend: Pass `photo_ids` in `getUnidentified()` call
-5. Identify page: Check URL params for `photo_ids`, parse and pass to API
-
-### Approach B: Create new endpoint `/api/v1/faces/by-photo/{photo_id}`
-**Pros:**
-- Clean separation of concerns
-- Can return all faces (identified + unidentified) if needed later
-- More explicit purpose
-
-**Cons:**
-- New endpoint to maintain
-- Need to handle multiple photos (could use POST with array)
-
-**Implementation:**
-1. Create `GET /api/v1/faces/by-photo/{photo_id}` endpoint
-2. Or `POST /api/v1/faces/by-photos` with `{photo_ids: [1,2,3]}`
-3. Return `UnidentifiedFacesResponse` format
-4. Frontend: Call new endpoint from Tags page
-5. Navigate with face IDs in state/URL params
-
-### Approach C: Use URL params to pass photo_ids, filter on frontend
-**Pros:**
-- No backend changes needed
-- Quick to implement
-
-**Cons:**
-- Need to load ALL unidentified faces first, then filter
-- Inefficient for large databases
-- Not scalable
-
-**Implementation:**
-1. Tags page: Navigate to `/identify?photo_ids=1,2,3`
-2. Identify page: Load all unidentified faces
-3. Filter faces array: `faces.filter(f => photoIds.includes(f.photo_id))`
-4. ❌ **Not recommended** - inefficient
-
-## Recommended Solution: Approach A (Extend Existing Endpoint)
-
-### Why Approach A?
-- Minimal backend changes
-- Efficient (database-level filtering)
-- Consistent with existing API patterns
-- Supports multiple photos easily
-
-### Implementation Plan
-
-#### 1. Backend Changes
-
-**File: `src/web/services/face_service.py`**
-```python
-def list_unidentified_faces(
- db: Session,
- page: int = 1,
- page_size: int = 50,
- min_quality: float = 0.0,
- date_from: Optional[date] = None,
- date_to: Optional[date] = None,
- date_taken_from: Optional[date] = None,
- date_taken_to: Optional[date] = None,
- date_processed_from: Optional[date] = None,
- date_processed_to: Optional[date] = None,
- sort_by: str = "quality",
- sort_dir: str = "desc",
- tag_names: Optional[List[str]] = None,
- match_all: bool = False,
- photo_ids: Optional[List[int]] = None, # NEW PARAMETER
-) -> Tuple[List[Face], int]:
- # ... existing code ...
-
- # Photo ID filtering (NEW)
- if photo_ids:
- query = query.filter(Face.photo_id.in_(photo_ids))
-
- # ... rest of existing code ...
-```
-
-**File: `src/web/api/faces.py`**
-```python
-@router.get("/unidentified", response_model=UnidentifiedFacesResponse)
-def get_unidentified_faces(
- # ... existing params ...
- photo_ids: str | None = Query(None, description="Comma-separated photo IDs"),
- db: Session = Depends(get_db),
-) -> UnidentifiedFacesResponse:
- # ... existing code ...
-
- # Parse photo_ids
- photo_ids_list = None
- if photo_ids:
- try:
- photo_ids_list = [int(pid.strip()) for pid in photo_ids.split(',') if pid.strip()]
- except ValueError:
- raise HTTPException(status_code=400, detail="Invalid photo_ids format")
-
- faces, total = list_unidentified_faces(
- # ... existing params ...
- photo_ids=photo_ids_list, # NEW PARAMETER
- )
- # ... rest of existing code ...
-```
-
-**File: `frontend/src/api/faces.ts`**
-```typescript
-getUnidentified: async (params: {
- // ... existing params ...
- photo_ids?: string, // NEW: comma-separated photo IDs
-}): Promise => {
- // ... existing code ...
-}
-```
-
-#### 2. Frontend Changes
-
-**File: `frontend/src/pages/Tags.tsx`**
-Add button/action to selected photos:
-```typescript
-// Add state for "Identify Faces" action
-const handleIdentifyFaces = (photoIds: number[]) => {
- // Filter to only processed photos with faces
- const processedPhotos = photos.filter(p =>
- photoIds.includes(p.id) && p.processed && p.face_count > 0
- )
-
- if (processedPhotos.length === 0) {
- alert('No processed photos with faces selected')
- return
- }
-
- // Navigate to Identify page with photo IDs
- const photoIdsStr = processedPhotos.map(p => p.id).join(',')
-
- // Option 1: Same tab
- navigate(`/identify?photo_ids=${photoIdsStr}`)
-
- // Option 2: New tab
- // window.open(`/identify?photo_ids=${photoIdsStr}`, '_blank')
-}
-```
-
-**File: `frontend/src/pages/Identify.tsx`**
-Modify to check for `photo_ids` URL param:
-```typescript
-import { useSearchParams } from 'react-router-dom'
-
-export default function Identify() {
- const [searchParams] = useSearchParams()
- const photoIdsParam = searchParams.get('photo_ids')
-
- // Parse photo IDs from URL
- const photoIds = useMemo(() => {
- if (!photoIdsParam) return null
- return photoIdsParam.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id))
- }, [photoIdsParam])
-
- const loadFaces = async (clearState: boolean = false) => {
- setLoadingFaces(true)
-
- try {
- const res = await facesApi.getUnidentified({
- page: 1,
- page_size: pageSize,
- min_quality: minQuality,
- date_taken_from: dateFrom || undefined,
- date_taken_to: dateTo || undefined,
- sort_by: sortBy,
- sort_dir: sortDir,
- tag_names: selectedTags.length > 0 ? selectedTags.join(', ') : undefined,
- match_all: false,
- photo_ids: photoIds ? photoIds.join(',') : undefined, // NEW
- })
-
- // ... rest of existing code ...
- } finally {
- setLoadingFaces(false)
- }
- }
-
- // ... rest of component ...
-}
-```
-
-#### 3. UI Enhancement in Tags Page
-
-Add a button/action when photos are selected:
-```tsx
-{selectedPhotoIds.size > 0 && (
- {
- const photoIds = Array.from(selectedPhotoIds)
- handleIdentifyFaces(photoIds)
- }}
- className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
- >
- 🔍 Identify Faces ({selectedPhotoIds.size} photo{selectedPhotoIds.size !== 1 ? 's' : ''})
-
-)}
-```
-
-Or add a context menu/button on individual photos:
-```tsx
-{photo.processed && photo.face_count > 0 && (
- {
- e.stopPropagation()
- handleIdentifyFaces([photo.id])
- }}
- className="px-2 py-1 bg-indigo-600 text-white rounded text-xs hover:bg-indigo-700"
- title="Identify faces in this photo"
- >
- 🔍 Identify
-
-)}
-```
-
-## Implementation Considerations
-
-### 1. **Photo Processing Status**
-- Only show action for processed photos (`photo.processed === true`)
-- Only show if `photo.face_count > 0`
-- Show appropriate message if no processed photos selected
-
-### 2. **New Tab vs Same Tab**
-- **Same Tab**: User loses Tag page context, but simpler navigation
-- **New Tab**: Preserves Tag page, better UX for comparison
-- **Recommendation**: Start with same tab, add option for new tab later
-
-### 3. **Multiple Photos**
-- Support multiple photo selection
-- Combine all faces from selected photos
-- Show count: "X faces from Y photos"
-
-### 4. **Empty Results**
-- If no faces found for selected photos, show message
-- Could be because:
- - Photos not processed yet
- - All faces already identified
- - No faces detected
-
-### 5. **URL Parameter Length**
-- For many photos, URL could get long
-- Consider using POST with state instead of URL params
-- Or use sessionStorage to pass photo IDs
-
-### 6. **State Management**
-- Identify page uses sessionStorage for state persistence
-- Need to handle case where photo_ids override normal loading
-- Clear photo_ids filter when user clicks "Refresh" or "Apply Filters"
-
-## Alternative: Using State Instead of URL Params
-
-If URL params become too long or we want to avoid exposing photo IDs:
-
-```typescript
-// Tags page
-navigate('/identify', {
- state: {
- photoIds: processedPhotos.map(p => p.id),
- source: 'tags'
- }
-})
-
-// Identify page
-const location = useLocation()
-const photoIds = location.state?.photoIds
-
-// But this doesn't work for new tabs - would need sessionStorage
-```
-
-## Testing Checklist
-
-- [ ] Select single processed photo with faces → Navigate to Identify
-- [ ] Select multiple processed photos → Navigate to Identify
-- [ ] Select unprocessed photo → Show appropriate message
-- [ ] Select photo with no faces → Show appropriate message
-- [ ] Select mix of processed/unprocessed → Only process processed ones
-- [ ] Navigate with photo_ids → Only those faces shown
-- [ ] Clear filters in Identify → Should clear photo_ids filter
-- [ ] Refresh in Identify → Should maintain photo_ids filter (or clear?)
-- [ ] Open in new tab → Works correctly
-- [ ] URL with many photo_ids → Handles correctly
-
-## Summary
-
-**Feasibility**: ✅ **YES, this is possible**
-
-**Recommended Approach**: Extend existing `/unidentified` endpoint with `photo_ids` filter
-
-**Key Changes Needed**:
-1. Backend: Add `photo_ids` parameter to service and API
-2. Frontend: Add navigation from Tags to Identify with photo_ids
-3. Frontend: Modify Identify page to handle photo_ids URL param
-4. UI: Add button/action in Tags page for selected photos
-
-**Complexity**: Low-Medium
-- Backend: Simple filter addition
-- Frontend: URL param handling + navigation
-- UI: Button/action addition
-
-**Estimated Effort**: 2-4 hours
-
-
-
-
-
diff --git a/docs/VIDEO_PERSON_IDENTIFICATION_ANALYSIS.md b/docs/VIDEO_PERSON_IDENTIFICATION_ANALYSIS.md
deleted file mode 100644
index 9af6e02..0000000
--- a/docs/VIDEO_PERSON_IDENTIFICATION_ANALYSIS.md
+++ /dev/null
@@ -1,642 +0,0 @@
-# Analysis: Identifying People in Videos
-
-**Date:** December 2024
-**Status:** Analysis Only (No Implementation)
-**Feature:** Direct person identification in videos without face detection
-
----
-
-## Executive Summary
-
-This document analyzes how to implement the ability to identify people directly in videos within the "Identify People" tab. Unlike photos where people are identified through detected faces, videos will allow direct person-to-video associations without requiring face detection or frame extraction.
-
-**Key Requirements:**
-- List videos with filtering capabilities
-- Each video can have multiple people identified
-- Can add more people to a video even after some are already identified
-- Located in "Identify People" tab under "Identify People in Videos" sub-tab
-
----
-
-## Current System Architecture
-
-### Database Schema
-
-**Current Relationships:**
-- `Photo` (includes videos via `media_type='video'`) → `Face` → `Person`
-- People are linked to photos **only** through faces
-- No direct Photo-Person relationship exists
-
-**Relevant Models:**
-```python
-class Photo:
- id: int
- path: str
- media_type: str # "image" or "video"
- # ... other fields
-
-class Face:
- id: int
- photo_id: int # FK to Photo
- person_id: int # FK to Person (nullable)
- # ... encoding, location, etc.
-
-class Person:
- id: int
- first_name: str
- last_name: str
- # ... other fields
-```
-
-**Current State:**
-- Videos are stored in `photos` table with `media_type='video'`
-- Videos are marked as `processed=True` but face processing is skipped
-- No faces exist for videos currently
-- People cannot be linked to videos through the existing Face model
-
-### Frontend Structure
-
-**Identify Page (`frontend/src/pages/Identify.tsx`):**
-- Has two tabs: "Identify Faces" and "Identify People in Videos"
-- Videos tab currently shows placeholder: "This functionality will be available in a future update"
-- Faces tab has full functionality for identifying people through faces
-
-### API Endpoints
-
-**Existing Photo/Video Endpoints:**
-- `GET /api/v1/photos` - Search photos/videos with `media_type` filter
-- Supports filtering by `media_type='video'` to get videos
-- Returns `PhotoSearchResult` with video metadata
-
-**No Existing Endpoints For:**
-- Listing videos specifically for person identification
-- Getting people associated with a video
-- Identifying people in videos
-- Managing video-person relationships
-
----
-
-## Proposed Solution
-
-### 1. Database Schema Changes
-
-#### Option A: New PhotoPersonLinkage Table (Recommended)
-
-Create a new junction table to link people directly to photos/videos:
-
-```python
-class PhotoPersonLinkage(Base):
- """Direct linkage between Photo/Video and Person (without faces)."""
-
- __tablename__ = "photo_person_linkage"
-
- id = Column(Integer, primary_key=True, autoincrement=True)
- photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
- person_id = Column(Integer, ForeignKey("people.id"), nullable=False, index=True)
- identified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
- created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
-
- photo = relationship("Photo", back_populates="direct_people")
- person = relationship("Person", back_populates="direct_photos")
-
- __table_args__ = (
- UniqueConstraint("photo_id", "person_id", name="uq_photo_person"),
- Index("idx_photo_person_photo", "photo_id"),
- Index("idx_photo_person_person", "person_id"),
- )
-```
-
-**Update Photo Model:**
-```python
-class Photo(Base):
- # ... existing fields ...
- direct_people = relationship("PhotoPersonLinkage", back_populates="photo", cascade="all, delete-orphan")
-```
-
-**Update Person Model:**
-```python
-class Person(Base):
- # ... existing fields ...
- direct_photos = relationship("PhotoPersonLinkage", back_populates="person", cascade="all, delete-orphan")
-```
-
-**Pros:**
-- ✅ Clean separation: Face-based identification vs. direct identification
-- ✅ Supports both photos and videos (unified approach)
-- ✅ Can track who identified the person (`identified_by_user_id`)
-- ✅ Prevents duplicate person-video associations
-- ✅ Similar pattern to `PhotoTagLinkage` (consistent architecture)
-
-**Cons:**
-- ⚠️ Requires database migration
-- ⚠️ Need to update queries that get "people in photo" to check both Face and PhotoPersonLinkage
-
-#### Option B: Use Face Model with Dummy Faces (Not Recommended)
-
-Create "virtual" faces for videos without encodings.
-
-**Cons:**
-- ❌ Misleading data model (faces without actual face data)
-- ❌ Breaks assumptions about Face model (encoding required)
-- ❌ Confusing for queries and logic
-- ❌ Not semantically correct
-
-**Recommendation:** Option A (PhotoPersonLinkage table)
-
-### 2. API Endpoints
-
-#### 2.1 List Videos for Identification
-
-**Endpoint:** `GET /api/v1/videos`
-
-**Query Parameters:**
-- `page`: int (default: 1)
-- `page_size`: int (default: 50, max: 200)
-- `folder_path`: Optional[str] - Filter by folder
-- `date_from`: Optional[str] - Filter by date taken (from)
-- `date_to`: Optional[str] - Filter by date taken (to)
-- `has_people`: Optional[bool] - Filter videos with/without identified people
-- `person_name`: Optional[str] - Filter videos containing specific person
-- `sort_by`: str (default: "filename") - "filename", "date_taken", "date_added"
-- `sort_dir`: str (default: "asc") - "asc" or "desc"
-
-**Response:**
-```python
-class VideoListItem(BaseModel):
- id: int
- filename: str
- path: str
- date_taken: Optional[date]
- date_added: date
- identified_people: List[PersonInfo] # People identified in this video
- identified_people_count: int
-
-class ListVideosResponse(BaseModel):
- items: List[VideoListItem]
- page: int
- page_size: int
- total: int
-```
-
-#### 2.2 Get People in a Video
-
-**Endpoint:** `GET /api/v1/videos/{video_id}/people`
-
-**Response:**
-```python
-class VideoPersonInfo(BaseModel):
- person_id: int
- first_name: str
- last_name: str
- middle_name: Optional[str]
- maiden_name: Optional[str]
- date_of_birth: Optional[date]
- identified_by: Optional[str] # Username
- identified_date: datetime
-
-class VideoPeopleResponse(BaseModel):
- video_id: int
- people: List[VideoPersonInfo]
-```
-
-#### 2.3 Identify People in Video
-
-**Endpoint:** `POST /api/v1/videos/{video_id}/identify`
-
-**Request:**
-```python
-class IdentifyVideoRequest(BaseModel):
- person_id: Optional[int] = None # Use existing person
- first_name: Optional[str] = None # Create new person
- last_name: Optional[str] = None
- middle_name: Optional[str] = None
- maiden_name: Optional[str] = None
- date_of_birth: Optional[date] = None
-```
-
-**Response:**
-```python
-class IdentifyVideoResponse(BaseModel):
- video_id: int
- person_id: int
- created_person: bool
- message: str
-```
-
-**Behavior:**
-- If `person_id` provided: Link existing person to video
-- If person info provided: Create new person and link to video
-- If person already linked: Return success (idempotent)
-- Track `identified_by_user_id` for audit
-
-#### 2.4 Remove Person from Video
-
-**Endpoint:** `DELETE /api/v1/videos/{video_id}/people/{person_id}`
-
-**Response:**
-```python
-class RemoveVideoPersonResponse(BaseModel):
- video_id: int
- person_id: int
- removed: bool
- message: str
-```
-
-### 3. Service Layer Functions
-
-#### 3.1 Video Service Functions
-
-**File:** `src/web/services/video_service.py` (new file)
-
-```python
-def list_videos_for_identification(
- db: Session,
- folder_path: Optional[str] = None,
- date_from: Optional[date] = None,
- date_to: Optional[date] = None,
- has_people: Optional[bool] = None,
- person_name: Optional[str] = None,
- sort_by: str = "filename",
- sort_dir: str = "asc",
- page: int = 1,
- page_size: int = 50,
-) -> Tuple[List[Photo], int]:
- """List videos for person identification."""
- # Query videos (media_type='video')
- # Apply filters
- # Join with PhotoPersonLinkage to get people count
- # Return paginated results
-
-def get_video_people(
- db: Session,
- video_id: int,
-) -> List[Tuple[Person, PhotoPersonLinkage]]:
- """Get all people identified in a video."""
- # Query PhotoPersonLinkage for video_id
- # Join with Person
- # Return list with identification metadata
-
-def identify_person_in_video(
- db: Session,
- video_id: int,
- person_id: Optional[int] = None,
- person_data: Optional[dict] = None,
- user_id: Optional[int] = None,
-) -> Tuple[Person, bool]:
- """Identify a person in a video.
-
- Returns:
- (Person, created_person: bool)
- """
- # Validate video exists and is actually a video
- # Get or create person
- # Create PhotoPersonLinkage if doesn't exist
- # Return person and created flag
-
-def remove_person_from_video(
- db: Session,
- video_id: int,
- person_id: int,
-) -> bool:
- """Remove person identification from video."""
- # Delete PhotoPersonLinkage
- # Return success
-```
-
-#### 3.2 Update Existing Search Functions
-
-**File:** `src/web/services/search_service.py`
-
-Update `get_photo_person()` to check both:
-1. Face-based identification (existing)
-2. Direct PhotoPersonLinkage (new)
-
-```python
-def get_photo_people(db: Session, photo_id: int) -> List[Person]:
- """Get all people in a photo/video (both face-based and direct)."""
- people = []
-
- # Get people through faces
- face_people = (
- db.query(Person)
- .join(Face, Person.id == Face.person_id)
- .filter(Face.photo_id == photo_id)
- .distinct()
- .all()
- )
- people.extend(face_people)
-
- # Get people through direct linkage
- direct_people = (
- db.query(Person)
- .join(PhotoPersonLinkage, Person.id == PhotoPersonLinkage.person_id)
- .filter(PhotoPersonLinkage.photo_id == photo_id)
- .distinct()
- .all()
- )
- people.extend(direct_people)
-
- # Remove duplicates
- seen_ids = set()
- unique_people = []
- for person in people:
- if person.id not in seen_ids:
- seen_ids.add(person.id)
- unique_people.append(person)
-
- return unique_people
-```
-
-### 4. Frontend Implementation
-
-#### 4.1 Video List Component
-
-**Location:** `frontend/src/pages/Identify.tsx` (videos tab)
-
-**Features:**
-- Video grid/list view with thumbnails
-- Filter panel:
- - Folder path
- - Date range (date taken)
- - Has people / No people
- - Person name search
-- Sort options: filename, date taken, date added
-- Pagination
-- Each video shows:
- - Thumbnail (video poster/first frame)
- - Filename
- - Date taken
- - List of identified people (badges/chips)
- - "Identify People" button
-
-#### 4.2 Video Detail / Identification Panel
-
-**When video is selected:**
-
-**Left Panel:**
-- Video player (HTML5 `` element)
-- Video metadata (filename, path, date taken, etc.)
-
-**Right Panel:**
-- List of currently identified people
- - Person name
- - Remove button
-- "Add Person" section:
- - Search existing people (autocomplete)
- - Or create new person form:
- - First name, last name (required)
- - Middle name, maiden name (optional)
- - Date of birth (optional)
- - "Add" button
-
-**UI Flow:**
-1. User selects video from list
-2. Video loads in player
-3. Right panel shows identified people
-4. User can add more people or remove existing ones
-5. Changes saved immediately (or with "Save" button)
-
-#### 4.3 API Client Functions
-
-**File:** `frontend/src/api/videos.ts` (new file)
-
-```typescript
-export interface VideoListItem {
- id: number
- filename: string
- path: string
- date_taken: string | null
- date_added: string
- identified_people: PersonInfo[]
- identified_people_count: number
-}
-
-export interface ListVideosResponse {
- items: VideoListItem[]
- page: number
- page_size: number
- total: number
-}
-
-export interface IdentifyVideoRequest {
- person_id?: number
- first_name?: string
- last_name?: string
- middle_name?: string
- maiden_name?: string
- date_of_birth?: string
-}
-
-export const videosApi = {
- listVideos: async (params: {
- page?: number
- page_size?: number
- folder_path?: string
- date_from?: string
- date_to?: string
- has_people?: boolean
- person_name?: string
- sort_by?: string
- sort_dir?: string
- }): Promise => {
- // Implementation
- },
-
- getVideoPeople: async (videoId: number): Promise => {
- // Implementation
- },
-
- identifyPerson: async (
- videoId: number,
- request: IdentifyVideoRequest
- ): Promise => {
- // Implementation
- },
-
- removePerson: async (
- videoId: number,
- personId: number
- ): Promise => {
- // Implementation
- },
-}
-```
-
-### 5. Database Migration
-
-**Migration Script:** Add `photo_person_linkage` table
-
-```python
-def add_photo_person_linkage_table(db: Session):
- """Add photo_person_linkage table for direct person-video associations."""
- db.execute(text("""
- CREATE TABLE IF NOT EXISTS photo_person_linkage (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- photo_id INTEGER NOT NULL,
- person_id INTEGER NOT NULL,
- identified_by_user_id INTEGER,
- created_date DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (photo_id) REFERENCES photos (id) ON DELETE CASCADE,
- FOREIGN KEY (person_id) REFERENCES people (id) ON DELETE CASCADE,
- FOREIGN KEY (identified_by_user_id) REFERENCES users (id),
- UNIQUE(photo_id, person_id)
- )
- """))
-
- db.execute(text("""
- CREATE INDEX IF NOT EXISTS idx_photo_person_photo
- ON photo_person_linkage(photo_id)
- """))
-
- db.execute(text("""
- CREATE INDEX IF NOT EXISTS idx_photo_person_person
- ON photo_person_linkage(person_id)
- """))
-
- db.execute(text("""
- CREATE INDEX IF NOT EXISTS idx_photo_person_user
- ON photo_person_linkage(identified_by_user_id)
- """))
-
- db.commit()
-```
-
----
-
-## Implementation Considerations
-
-### 1. Data Consistency
-
-**Question:** Should we allow both face-based and direct identification for the same person-video pair?
-
-**Recommendation:** Yes, allow both. They serve different purposes:
-- Face-based: Person identified through detected faces in video frames
-- Direct: Person identified manually without face detection
-
-**Implementation:** Check for duplicates when querying, but allow both to exist.
-
-### 2. Search Integration
-
-**Update search functions to include direct linkages:**
-- `search_photos_by_name()` - Should find videos with direct person linkages
-- `get_photo_person()` - Should check both Face and PhotoPersonLinkage
-- Photo viewer - Should show people from both sources
-
-### 3. Video Thumbnails
-
-**Current State:** Videos don't have thumbnails generated yet.
-
-**Options:**
-1. Generate thumbnail on-demand when needed
-2. Generate thumbnail during video import
-3. Use first frame as thumbnail (extract on-demand)
-
-**Recommendation:** Extract first frame on-demand for now (simpler, no storage overhead). Can optimize later with caching.
-
-### 4. Performance
-
-**Considerations:**
-- Video list queries with people counts (JOIN with PhotoPersonLinkage)
-- Pagination for large video libraries
-- Video thumbnail generation (can be slow for large videos)
-
-**Optimizations:**
-- Index on `photo_id` and `person_id` in PhotoPersonLinkage
-- Consider caching video thumbnails
-- Use efficient queries (avoid N+1 problems)
-
-### 5. User Experience
-
-**Key UX Points:**
-- Clear indication of which people are identified (badges/chips)
-- Easy to add multiple people quickly
-- Search/filter to find specific videos
-- Visual feedback when person is added/removed
-- Handle edge cases (person already added, etc.)
-
----
-
-## Implementation Phases
-
-### Phase 1: Database & Models
-1. Create PhotoPersonLinkage model
-2. Add migration script
-3. Update Photo and Person models with relationships
-4. Test database changes
-
-### Phase 2: Backend API
-1. Create video service functions
-2. Create API endpoints for videos
-3. Update search functions to include direct linkages
-4. Add API tests
-
-### Phase 3: Frontend API Client
-1. Create `videos.ts` API client
-2. Add TypeScript interfaces
-3. Test API integration
-
-### Phase 4: Frontend UI
-1. Implement video list component
-2. Implement video detail/identification panel
-3. Add filters and sorting
-4. Add video player integration
-5. Test user flows
-
-### Phase 5: Integration & Polish
-1. Update search to include direct linkages
-2. Update photo viewer to show direct linkages
-3. Add error handling
-4. Performance optimization
-5. Documentation
-
----
-
-## Open Questions
-
-1. **Should we prevent duplicate person-video associations?**
- - **Answer:** Yes, use UNIQUE constraint on (photo_id, person_id)
-
-2. **Should direct linkages work for photos too, or only videos?**
- - **Answer:** Could work for both, but focus on videos first. Photos already have face-based identification.
-
-3. **How to handle video thumbnails?**
- - **Answer:** Extract first frame on-demand initially, cache later if needed
-
-4. **Should we show both face-based and direct identifications together?**
- - **Answer:** Yes, merge them in queries and display
-
-5. **What happens if a video is deleted?**
- - **Answer:** CASCADE delete removes PhotoPersonLinkage records (handled by FK constraint)
-
----
-
-## Summary
-
-**Key Design Decisions:**
-1. ✅ New `PhotoPersonLinkage` table for direct person-video associations
-2. ✅ Separate from face-based identification (both can coexist)
-3. ✅ Unified API endpoints under `/api/v1/videos`
-4. ✅ Frontend in existing Identify page videos tab
-5. ✅ Support filtering, sorting, and pagination
-6. ✅ Allow adding multiple people to same video
-7. ✅ Track who identified each person (audit trail)
-
-**Next Steps:**
-1. Review and approve this analysis
-2. Create database migration
-3. Implement backend API
-4. Implement frontend UI
-5. Test and iterate
-
----
-
-**Document Version:** 1.0
-**Last Updated:** December 2024
-**Author:** AI Assistant (Cursor)
-
-
-
-
-
-
-
diff --git a/docs/VIDEO_SUPPORT_ANALYSIS.md b/docs/VIDEO_SUPPORT_ANALYSIS.md
deleted file mode 100644
index a33ae38..0000000
--- a/docs/VIDEO_SUPPORT_ANALYSIS.md
+++ /dev/null
@@ -1,479 +0,0 @@
-# Analysis: Adding Video Support to PunimTag
-
-**Date:** December 2024
-**Status:** Partial Implementation (Scanning Only)
-**Current Phase:** Video scanning implemented, face processing skipped
-
----
-
-## Executive Summary
-
-This document provides a comprehensive analysis of adding video support to PunimTag, a photo management application with facial recognition capabilities. The analysis covers technical feasibility, storage implications, performance considerations, database schema changes, and implementation strategy.
-
-**Current Implementation Status:**
-- ✅ Video scanning and import implemented
-- ✅ Videos are detected and imported into database
-- ✅ Videos are automatically marked as processed (skipped for face detection)
-- ⏸️ Video frame extraction (not implemented)
-- ⏸️ Face detection on video frames (not implemented)
-- ⏸️ Video playback in frontend (not implemented)
-
----
-
-## Current System State
-
-### Supported Formats
-
-**Images:**
-- `.jpg`, `.jpeg`, `.png`, `.bmp`, `.tiff`, `.tif`
-
-**Videos (New):**
-- `.mp4`, `.mov`, `.avi`, `.mkv`, `.webm`, `.m4v`, `.flv`, `.wmv`, `.mpg`, `.mpeg`
-
-### Current Architecture
-
-- **Database:** SQLite/PostgreSQL with `Photo` model
-- **Face Processing:** DeepFace (RetinaFace detector + ArcFace model)
-- **Storage:** Files stored by path reference (not in database)
-- **Frontend:** React with image display using ` ` tags
-- **Processing:** Background jobs using RQ (Redis Queue)
-
----
-
-## Technical Feasibility
-
-### ✅ Feasible with Moderate Changes
-
-DeepFace can process video frames, but requires:
-1. **Video frame extraction** (OpenCV/ffmpeg)
-2. **Frame sampling strategy** (time-based or scene-change detection)
-3. **Database schema updates** (add `media_type` field)
-4. **Frontend video player component** (HTML5 `` element)
-5. **Processing pipeline modifications** (handle video frames)
-
-### DeepFace Video Capabilities
-
-DeepFace processes individual frames (not full videos):
-- Must extract frames first using OpenCV or ffmpeg
-- Each frame is processed like a static image
-- No built-in video file support
-
-**Implementation Approach:**
-```python
-# Pseudo-code for video processing
-import cv2
-from deepface import DeepFace
-
-cap = cv2.VideoCapture(video_path)
-frame_number = 0
-fps = cap.get(cv2.CAP_PROP_FPS)
-
-while cap.isOpened():
- ret, frame = cap.read()
- if not ret:
- break
-
- # Process every Nth frame (e.g., every 1 second)
- if frame_number % int(fps) == 0:
- # Save frame temporarily
- frame_path = f"/tmp/frame_{frame_number}.jpg"
- cv2.imwrite(frame_path, frame)
-
- # Process with DeepFace
- results = DeepFace.represent(
- img_path=frame_path,
- model_name="ArcFace",
- detector_backend="retinaface"
- )
-
- # Store faces with frame_number
- # ...
-
- frame_number += 1
-```
-
----
-
-## Storage Implications
-
-### File Size Impact
-
-| Media Type | Typical Size | Storage Impact |
-|------------|--------------|----------------|
-| Photo (JPEG) | 2-10 MB | Baseline |
-| Video (1080p, 1 min) | 50-150 MB | **5-75x larger** |
-| Video (4K, 1 min) | 200-500 MB | **20-250x larger** |
-| Video (1080p, 10 min) | 500 MB - 1.5 GB | **50-750x larger** |
-
-### Storage Considerations
-
-#### 1. Video File Storage
-- Videos are **much larger** than photos
-- A 1-hour 1080p video can be **3-9 GB**
-- Large video libraries will require **significant storage capacity**
-
-#### 2. Frame Extraction Storage
-
-**Option A: Extract frames to disk (temporary or permanent)**
-- ~1-5 MB per extracted frame (JPEG)
-- For a 10-minute video at 1 fps: ~600 frames = **600 MB - 3 GB**
-- Permanent storage would be massive
-
-**Option B: Extract frames on-demand (no permanent storage)**
-- Minimal storage impact
-- But repeated processing overhead if re-processing needed
-
-**Recommendation:** Extract frames on-demand during processing (don't store permanently)
-
-#### 3. Thumbnail Generation
-- Video thumbnails: ~50-200 KB each
-- Much smaller than full frames
-- **Recommendation:** Generate and cache video thumbnails
-
-#### 4. Storage Recommendations
-
-- ✅ Store original videos by path reference (like photos)
-- ✅ Extract frames on-demand during processing (don't store permanently)
-- ✅ Generate and cache video thumbnails (small storage impact)
-- ⚠️ Consider video compression/transcoding for storage optimization
-
----
-
-## Performance Implications
-
-### Processing Performance
-
-#### 1. Video Decoding Overhead
-- Video decoding is **CPU/GPU intensive**
-- **10x-100x slower** than loading a single image
-- Example: Processing 1 video frame ≈ 0.1-1 second vs. 0.01-0.1s for a photo
-
-#### 2. Frame Extraction Strategy
-
-**Process every frame:**
-- Very slow (e.g., 30 fps = 1800 frames/min)
-- **Not recommended** for most use cases
-
-**Process every Nth frame:**
-- Faster (e.g., 1 fps = 60 frames/min)
-- **Recommended:** 0.5-2 fps for face detection
-- Balance between speed and coverage
-
-**Scene-change detection:**
-- Smart but adds complexity
-- Only process frames with scene changes
-
-#### 3. Face Detection on Video Frames
-- DeepFace processing time per frame: **~0.5-2 seconds**
-- For a 10-minute video at 1 fps: 600 frames × 1s = **~10 minutes processing**
-- For a 1-hour video: **~1 hour processing time**
-
-#### 4. Batch Processing Impact
-- Current system processes photos sequentially
-- Videos will **significantly increase processing time**
-- **Consider:**
- - Parallel processing (multiple workers)
- - Background job queue (already using RQ)
- - Progress tracking per video
-
-### Database Performance
-
-#### 1. Increased Record Count
-- Each video frame with faces = multiple `Face` records
-- A 10-minute video could generate **50-200 face records**
-- Index performance should remain good (existing indices work)
-
-#### 2. Query Performance
-- Current queries filter by `photo_id` (indexed)
-- No significant impact expected
-- Consider adding `media_type` index if separating photos/videos
-
-### Frontend Performance
-
-#### 1. Video Playback
-- Browser video players handle large files well
-- Streaming/range requests needed for large videos
-- Thumbnail generation critical for grid views
-
-#### 2. Network Bandwidth
-- Serving full videos requires **high bandwidth**
-- **Consider:**
- - Video transcoding (multiple quality levels)
- - Progressive loading
- - CDN for large deployments
-
----
-
-## Database Schema Changes
-
-### Option 1: Extend Photo Model (✅ Implemented)
-
-Add a `media_type` field to distinguish photos from videos:
-
-```python
-class Photo(Base):
- # ... existing fields ...
- media_type = Column(Text, default="image", nullable=False) # "image" or "video"
- duration = Column(Integer, nullable=True) # Video duration in seconds (future)
- frame_count = Column(Integer, nullable=True) # Total frames in video (future)
- video_codec = Column(Text, nullable=True) # e.g., "h264", "hevc" (future)
- video_resolution = Column(Text, nullable=True) # e.g., "1920x1080" (future)
-```
-
-**Pros:**
-- ✅ Minimal schema changes
-- ✅ Reuses existing relationships (faces, tags, etc.)
-- ✅ Unified query interface
-
-**Cons:**
-- Model name "Photo" becomes slightly misleading
-- Some fields only apply to videos
-
-**Status:** ✅ Implemented with `media_type` field
-
-### Option 2: Separate Video Model (Not Implemented)
-
-Create a new `Video` model with similar structure:
-
-```python
-class Video(Base):
- # Similar to Photo but video-specific
- # Separate relationships for faces, tags, etc.
-```
-
-**Pros:**
-- Clear separation of concerns
-- Video-specific fields without clutter
-
-**Cons:**
-- Duplicate code/logic
-- More complex queries (union for mixed results)
-- More refactoring needed
-
-**Recommendation:** Option 1 (extend Photo model) for simplicity and unified queries
-
----
-
-## Processing Pipeline Changes
-
-### Current Flow (Photos)
-```
-1. Scan folder → find image files
-2. Import to database (hash, metadata)
-3. Process faces (DeepFace on full image)
-4. Store face encodings
-```
-
-### Proposed Flow (Videos - Future)
-```
-1. Scan folder → find video files
-2. Import to database (hash, metadata, duration, codec)
-3. Extract frames (OpenCV/ffmpeg)
- - Sample at 0.5-2 fps
- - Or use scene-change detection
-4. Process faces on each frame (DeepFace)
-5. Store face encodings with frame timestamp
-6. Generate video thumbnail
-```
-
-### Required Changes (Future Implementation)
-
-#### 1. Video Frame Extraction
-- Use OpenCV (`cv2.VideoCapture`) or ffmpeg-python
-- Extract frames at configurable intervals
-- Handle various video codecs/formats
-
-#### 2. Face Processing Modifications
-- Current: `process_photo_faces()` processes one image
-- New: `process_video_faces()` processes multiple frames
-- Track frame number/timestamp for each face
-
-#### 3. Face Model Updates
-- Add `frame_number` or `timestamp` to `Face` model
-- Link faces to specific moments in video
-
-#### 4. Thumbnail Generation
-- Extract keyframe or generate from first frame
-- Store thumbnail path or generate on-demand
-
----
-
-## Frontend Changes (Future)
-
-### Display Components
-
-#### 1. Photo Grid View
-- Currently: ` `
-- Need: Conditional rendering
- - Photos: ` `
- - Videos: `` with poster/thumbnail
-
-#### 2. Photo Viewer Component
-- Currently: Image viewer with zoom/pan
-- Need: Video player for videos
- - Play/pause controls
- - Seek/scrub
- - Show detected faces at specific timestamps
-
-#### 3. Video Player Requirements
-- HTML5 `` element
-- Controls for playback
-- Face indicators at specific timestamps
-- Thumbnail generation for grid views
-
-### API Changes (Future)
-
-#### 1. Video Serving Endpoint
-- Similar to `/api/v1/photos/{id}/image`
-- `/api/v1/photos/{id}/video` for full video
-- Range request support for streaming
-
-#### 2. Thumbnail Endpoint
-- `/api/v1/photos/{id}/thumbnail` for video thumbnails
-
-#### 3. Frame Extraction Endpoint (Optional)
-- `/api/v1/photos/{id}/frames/{frame_number}` for specific frames
-
----
-
-## Implementation Strategy
-
-### Phase 1: Foundation ✅ COMPLETE
-
-1. ✅ Add `media_type` to Photo model
-2. ✅ Extend `SUPPORTED_IMAGE_FORMATS` to include video formats
-3. ✅ Update scanning to detect video files
-4. ✅ Import videos to database (metadata only, no processing yet)
-5. ✅ Mark videos as processed (skip face processing)
-
-**Status:** ✅ Complete
-
-### Phase 2: Frame Extraction (Future)
-
-1. Implement video frame extraction service
-2. Add frame sampling configuration
-3. Store frame metadata (optional)
-
-### Phase 3: Face Processing (Future)
-
-1. Extend face processing to handle video frames
-2. Add `frame_number`/`timestamp` to Face model
-3. Process videos in background jobs
-
-### Phase 4: Frontend (Future)
-
-1. Add video player component
-2. Update grid view for video thumbnails
-3. Update photo viewer for video playback
-4. Show face timestamps in video player
-
-### Phase 5: Optimization (Future)
-
-1. Implement thumbnail caching
-2. Add video transcoding (optional)
-3. Optimize frame extraction performance
-4. Add progress tracking for video processing
-
----
-
-## Key Considerations Summary
-
-| Aspect | Impact | Mitigation |
-|--------|--------|------------|
-| **Storage** | **High** (10-1000x larger files) | Store by reference, extract frames on-demand, cache thumbnails |
-| **Processing Time** | **High** (10-100x slower) | Background jobs, frame sampling, parallel processing |
-| **Database** | **Low** (similar structure) | Extend Photo model, add video-specific fields |
-| **Frontend** | **Medium** (new components) | Conditional rendering, video player component |
-| **Network** | **High** (large file transfers) | Streaming, range requests, thumbnails |
-
----
-
-## Current Implementation Details
-
-### Files Modified
-
-1. **`src/web/config.py`**
- - Added `SUPPORTED_VIDEO_FORMATS` constant
-
-2. **`src/web/db/models.py`**
- - Added `media_type` field to `Photo` model (defaults to "image")
-
-3. **`src/web/services/photo_service.py`**
- - Updated `find_photos_in_folder()` to detect videos
- - Added `extract_video_date()` function
- - Updated `import_photo_from_path()` to detect and set media type
- - Videos automatically marked as `processed=True`
-
-4. **`src/web/services/face_service.py`**
- - Updated `process_photo_faces()` to skip videos
- - Updated `process_unprocessed_photos()` to filter out videos
-
-5. **`src/web/app.py`**
- - Added `ensure_photo_media_type_column()` migration function
-
-### Behavior
-
-- ✅ Videos are scanned and imported into the database
-- ✅ Videos are marked as `processed=True` so they don't appear in unprocessed photo lists
-- ✅ Videos are skipped during face processing
-- ✅ Video dates are extracted from metadata (if available) or file modification time
-- ✅ All existing photo functionality remains unchanged
-
-### Notes
-
-- The `media_type` field is new. Existing databases will automatically get the column added on next server startup.
-- `ffprobe` (from ffmpeg) is optional for video date extraction. If not available, the system falls back to file modification time.
-- Videos are stored in the same `photos` table with `media_type='video'` for unified querying.
-
----
-
-## Conclusion
-
-Adding video support is **technically feasible** but requires significant consideration of:
-
-- **Storage capacity** (videos are much larger)
-- **Processing time** (much slower than photos)
-- **Frontend changes** (video player components)
-
-The current implementation provides the foundation for video scanning. Future phases can add frame extraction and face processing capabilities as needed.
-
-The architecture can accommodate videos with moderate changes. The main challenges are **storage capacity** and **processing time** for large video libraries.
-
----
-
-## Future Work
-
-### Immediate Next Steps (If Implementing Full Video Support)
-
-1. **Frame Extraction Service**
- - Implement OpenCV-based frame extraction
- - Add configurable frame sampling rate
- - Handle various video codecs
-
-2. **Video Face Processing**
- - Extend `process_photo_faces()` to handle video frames
- - Add frame number tracking to Face model
- - Implement batch frame processing
-
-3. **Frontend Video Player**
- - Create video player component
- - Add thumbnail generation
- - Implement face timestamp indicators
-
-4. **Performance Optimization**
- - Implement parallel video processing
- - Add video transcoding for web delivery
- - Optimize thumbnail caching
-
----
-
-**Document Version:** 1.0
-**Last Updated:** December 2024
-**Author:** AI Assistant (Cursor)
-
-
-
-
-
-
-
diff --git a/docs/WEBSITE_MIGRATION_PLAN.md b/docs/WEBSITE_MIGRATION_PLAN.md
deleted file mode 100644
index 02ecada..0000000
--- a/docs/WEBSITE_MIGRATION_PLAN.md
+++ /dev/null
@@ -1,350 +0,0 @@
-# PunimTag Web Rebuild Plan (Greenfield)
-
-Version: 2.0
-Last Updated: October 31, 2025
-
----
-
-## 1) Direction and Principles
-
-- **Greenfield rewrite**: Build PunimTag as a web application from scratch.
-- **No backward compatibility**: The existing desktop code and database will not be reused.
-- **Fresh database**: The new system initializes a new database; prior data will be erased.
-- **Simple**: Minimal clicks, clean UI, frictionless first-run onboarding.
-- **Fast**: Snappy interactions (<100ms perceived), heavy tasks offloaded to jobs.
-- **Modern**: Type-safe APIs, real-time progress, responsive design, dark/light modes.
-
----
-
-## 2) Target Architecture (Web-Native)
-
-### Backend
-- **Framework**: FastAPI (Python 3.12+)
-- **Database**: PostgreSQL (primary) using async driver (asyncpg) and SQLAlchemy 2.0 ORM
-- **Jobs**: Redis + RQ (or Celery) for background tasks (scan/process/auto-match/thumbnailing)
-- **Storage**: Local disk for images (phase 1); S3-compatible storage option (phase 2)
-- **Auth**: JWT (access/refresh) with optional single-user mode
-- **Schemas**: Pydantic for request/response models; OpenAPI-first
-- **Images**: Streaming endpoints, range support, signed URLs for originals
-
-### Frontend
-- **Framework**: React + Vite + TypeScript
-- **Design**: Tailwind CSS + Headless UI/Radix; dark/light theme toggle
-- **State**: React Query for server state; Zustand for local UI state
-- **Routing**: React Router with code-splitting; keyboard-first UX
-- **Performance**: Virtualized grids, skeletons, optimistic updates
-
-### DevOps / Deployment
-- **Local**: Docker Compose (web, worker, db, redis, frontend)
-- **Prod**: Containers behind Traefik/Nginx, HTTPS, zero-downtime deploys
-- **Observability**: Structured logs, request/job IDs, metrics endpoint, health checks
-
----
-
-## 3) Domain Model (Matches Desktop Schema)
-
-The web version uses the **exact same schema** as the desktop version for full compatibility:
-
-- `photos` (id, path, filename, date_added, date_taken DATE, processed)
-- `people` (id, first_name, last_name, middle_name, maiden_name, date_of_birth, created_date)
-- `faces` (id, photo_id, person_id, encoding BLOB, location TEXT, confidence REAL, quality_score REAL, is_primary_encoding, detector_backend, model_name, face_confidence REAL, exif_orientation)
-- `person_encodings` (id, person_id, face_id, encoding BLOB, quality_score REAL, detector_backend, model_name, created_date)
-- `tags` (id, tag_name, created_date)
-- `phototaglinkage` (linkage_id, photo_id, tag_id, linkage_type, created_date)
-- Indices for common queries (date_taken, quality_score, person, photo, tag)
-
-**Note:**
-- Encodings stored as binary (BLOB) - float32 arrays from DeepFace ArcFace (512 dimensions)
-- Location stored as JSON text string matching desktop format: `"{'x': x, 'y': y, 'w': w, 'h': h}"`
-- Quality scores stored as REAL (0.0-1.0 range)
-- Schema matches desktop version exactly for data portability
-
----
-
-## 4) Core Features (Web MVP)
-
-- Import photos (upload UI and/or server-side folder ingest)
-- EXIF parsing, metadata extraction
-- Face detection and embeddings (DeepFace ArcFace + RetinaFace)
-- **Identify workflow** (manual identification - one face at a time, assign to existing/new person)
-- **Auto-match workflow** (automated bulk matching - matches unidentified faces to identified people with tolerance thresholds)
-- **Modify Identified workflow** (edit person information, unmatch faces from people, undo/save changes)
-- Tags: CRUD and bulk tagging
-- Search: by people, tags, date range, folder; infinite scroll grid
-- Thumbnail generation and caching (100×100, 256×256)
-- Real-time job progress (SSE/WebSocket)
-
-**Note:** Identify, Auto-Match, and Modify Identified are **separate features** with distinct UIs and workflows:
-- **Identify**: Manual process, one unidentified face at a time, user assigns to person
-- **Auto-Match**: Automated process, shows identified person on left, matched unidentified faces on right, bulk accept/reject
-- **Modify Identified**: Edit identified people, view faces per person, unmatch faces, edit person information (names, date of birth)
-
----
-
-## 5) API Design (v1)
-
-Base URL: `/api/v1`
-
-- Auth: POST `/auth/login`, POST `/auth/refresh`, GET `/auth/me`
-- Photos: GET `/photos`, POST `/photos/import` (upload or folder), GET `/photos/{id}`, GET `/photos/{id}/image`
-- Faces: POST `/faces/process`, GET `/faces/unidentified`, POST `/faces/{id}/identify`, POST `/faces/auto-match`, POST `/faces/{id}/unmatch`
-- People: GET `/people`, POST `/people`, GET `/people/{id}`, PUT `/people/{id}`, GET `/people/{id}/faces`
-- Tags: GET `/tags`, POST `/tags`, POST `/photos/{id}/tags`
-- Jobs: GET `/jobs/{id}`, GET `/jobs/stream` (SSE)
-
-All responses are typed; errors use structured codes. Pagination with `page`/`page_size` (default 50, max 200).
-
----
-
-## 6) UX and Navigation
-
-- Shell layout: left nav + top bar; routes: Dashboard, Scan, Process, Search, Identify, Auto-Match, Modify, Tags, Settings
-- **Identify flow**: Manual identification - one face at a time, keyboard (J/K), Enter to accept, quick-create person, similar faces for comparison
-- **Auto-Match flow**: Automated bulk matching - identified person on left, matched faces on right, bulk accept/reject, tolerance threshold configuration
-- **Modify Identified flow**: People list with search, face grid per person, edit person info, unmatch faces, undo/save changes
-- Search: virtualized grid, filters drawer, saved filters
-- Tags: inline edit, bulk apply from grid selection
-- Settings: detector/model selection, thresholds, storage paths
-
-Visual design: neutral palette, clear hierarchy, low-chrome UI, subtle motion (<150ms).
-
----
-
-## 7) Performance Strategy
-
-- Background jobs for heavy ops, job progress surfaced in UI
-- ETag/Cache-Control for images; pre-generate thumbnails lazily
-- Server-side pagination and ordering; stable sorts
-- Connection pooling for DB; prepared statements; indices
-- Optional GPU support later; batch processing tuned by environment
-
----
-
-## 8) Security & Privacy
-
-- JWT auth; role-ready design; CSRF-safe patterns if cookie mode later
-- Validate and sanitize image paths, content-type checks
-- Principle of least privilege for workers
-- HTTPS in production; signed URLs for original image access
-
----
-
-## 9) Delivery Plan (No Migration, Full Rebuild)
-
-### Phase 1: Foundations (1–2 weeks)
-- Create repositories and base directories: `src/web`, `frontend`, `deploy`
-- Initialize FastAPI app structure:
- - `src/web/app.py` (app factory, CORS, middleware)
- - `src/web/api` (routers: `auth`, `photos`, `faces`, `tags`, `people`, `jobs`, `health`)
- - `src/web/schemas` (Pydantic models)
- - `src/web/db` (SQLAlchemy models, session management)
- - `src/web/services` (application services—no DL/GUI logic)
-- Database setup:
- - Add SQLAlchemy models matching desktop schema: `photos`, `faces`, `people`, `person_encodings`, `tags`, `phototaglinkage`
- - Configure Alembic; generate initial migration; enable UUID/created_at defaults
- - Auto-create tables on startup (via `Base.metadata.create_all()` in lifespan)
- - Connect to PostgreSQL via env (`DATABASE_URL`); enable connection pooling
- - Note: Schema matches desktop version exactly for compatibility
-- Auth foundation:
- - Implement JWT issuance/refresh; `/auth/login`, `/auth/refresh`, `/auth/me`
- - Single-user bootstrap via env secrets; password hashing
-- Jobs subsystem:
- - Add Redis + RQ; define job schema/status; `/jobs/{id}` endpoint
- - Worker entrypoint `src/web/worker.py` with graceful shutdown
-- Developer experience:
- - Create Docker Compose with services: `api`, `worker`, `db`, `redis`, `frontend`, `proxy`
- - Logging: request IDs middleware; structured logs (JSON)
- - Health endpoints: `/health`, `/version`, `/metrics` (basic)
-- Frontend scaffold:
- - Vite + React + TypeScript + Tailwind; base layout (left nav + top bar)
- - Auth flow (login page, token storage), API client with interceptors
- - Routes: Dashboard (placeholder), Scan (placeholder), Process (placeholder), Search (placeholder), Identify (placeholder), Auto-Match (placeholder), Modify (placeholder), Tags (placeholder), Settings (placeholder)
-
-### Phase 2: Image Ingestion & Face Processing (2–3 weeks) ✅ **COMPLETE**
-- Image ingestion:
- - ✅ Backend: `/photos/import` supports folder ingest and file upload
- - ✅ Store originals on disk; create DB rows
- - ✅ Generate thumbnails lazily (worker job)
-- ✅ **Scan tab UI:**
- - ✅ Folder selection (browse or drag-and-drop)
- - ✅ Upload progress indicators
- - ✅ Recursive scan toggle
- - ✅ Display scan results (count of photos added)
- - ✅ Trigger background job for photo import
- - ✅ Real-time job status with SSE progress updates
-- ✅ **DeepFace pipeline:**
- - ✅ Worker task: detect faces (RetinaFace), compute embeddings (ArcFace); store embeddings as binary
- - ✅ Persist face bounding boxes (location as JSON), confidence (face_confidence), quality_score; link to photos
- - ✅ Configurable batch size and thresholds via settings
- - ✅ EXIF orientation handling
- - ✅ Face quality scoring and validation
-- ✅ **Process tab UI:**
- - ✅ Start/stop face processing controls
- - ✅ Batch size configuration
- - ✅ Detector/model selection dropdowns (RetinaFace, MTCNN, OpenCV, SSD / ArcFace, Facenet, etc.)
- - ✅ Processing progress bar with current photo count
- - ✅ Job status display (pending, running, completed, failed)
- - ✅ Results summary (faces detected, processing time)
- - ✅ Error handling and retry mechanism
- - ✅ Job cancellation support
- - ✅ Real-time progress updates via SSE
-- Progress & observability:
- - ✅ Expose job progress via SSE `/jobs/stream`; show per-task progress bars
- - ✅ Add worker metrics and error surfacing in UI
-
-### Phase 3: Identify Workflow (2–3 weeks)
-**Manual identification of unidentified faces - one face at a time**
-
-- **Backend APIs:**
- - `GET /api/v1/faces/unidentified` - Get unidentified faces with filters (quality, date ranges, sorting)
- - `GET /api/v1/faces/{id}/similar` - Get similar faces for comparison
- - `POST /api/v1/faces/{id}/identify` - Assign face to existing person or create new person
- - `GET /api/v1/people` - List all people (for dropdown selection)
- - `POST /api/v1/people` - Create new person
- - Implement person creation and linking, plus `person_encodings` insertions (matches desktop schema)
-
-- **Frontend Identify UI:**
- - Left panel: Current unidentified face display with photo info
- - Right panel: Similar faces grid for comparison (if matches found)
- - Filters: Quality slider (min quality %), date ranges (date_from, date_to, date_processed_from, date_processed_to)
- - Sort options: By quality, by date taken, by date processed
- - Batch size configuration
- - Person assignment form:
- - Dropdown to select existing person
- - "Create New Person" button/modal with name fields
- - Accept/Skip buttons
- - Keyboard navigation: J/K to navigate between faces, Enter to accept
- - Progress indicator: Current face X of Y
- - Photo info display: filename, date taken, path
- - Optimistic updates with server confirmation
- - Inline toasts for success/error feedback
-
-### Phase 4: Auto-Match Workflow (1–2 weeks)
-**Automated bulk matching of unidentified faces to identified people**
-
-- **Backend APIs:**
- - `POST /api/v1/faces/auto-match` - Start auto-match process with tolerance threshold
- - `GET /api/v1/faces/auto-match/{match_id}` - Get match results for an identified person
- - `POST /api/v1/faces/auto-match/{match_id}/accept` - Accept bulk matches for a person
- - `POST /api/v1/faces/auto-match/{match_id}/reject` - Reject bulk matches for a person
- - Auto-match engine: For each identified person, find all unidentified faces that match using cosine similarity
- - Quality filters and similarity thresholds (tolerance)
- - Batch processing with progress tracking
-
-- **Frontend Auto-Match UI:**
- - Configuration: Tolerance threshold input (0.0-1.0, lower = stricter)
- - "Start Auto-Match" button to begin process
- - Two-panel layout:
- - Left panel: Identified person with best quality face, person name, stats (X faces already identified)
- - Right panel: Grid of matched unidentified faces for this person
- - Each match shows: Face thumbnail, similarity score/percentage, photo info, checkbox for selection
- - Bulk actions: "Accept Selected", "Accept All", "Reject Selected", "Reject All" buttons
- - Navigation: Previous/Next buttons to navigate through identified people
- - Progress indicator: Person X of Y, N faces matched for current person
- - Match quality display: Similarity percentage for each match
- - Optimistic updates with server confirmation
- - Job-based processing: Auto-match runs as background job with progress tracking
-
-### Phase 5: Modify Identified Workflow (1–2 weeks)
-**Edit identified people, view faces per person, unmatch faces, edit person information**
-
-- **Backend APIs:**
- - `GET /api/v1/people` - Get all identified people with face counts (filtered by last name if provided)
- - `GET /api/v1/people/{id}/faces` - Get all faces for a specific person with photo info
- - `PUT /api/v1/people/{id}` - Update person information (first_name, last_name, middle_name, maiden_name, date_of_birth)
- - `POST /api/v1/faces/{id}/unmatch` - Unmatch a face from its person (set person_id to NULL)
- - `POST /api/v1/faces/batch-unmatch` - Batch unmatch multiple faces (accepts array of face IDs)
- - Implement person update with validation (require first_name and last_name)
- - Support filtering people by last name (case-insensitive search)
- - Return face thumbnails and photo metadata for face grid display
-
-- **Frontend Modify Identified UI:**
- - Two-panel layout:
- - Left panel: People list with search/filter by last name
- - Right panel: Face grid for selected person
- - People list features:
- - Search input for filtering by last name (case-insensitive)
- - Display person name with face count: "John Doe (15)"
- - Click person to load their faces in right panel
- - Edit button (✏️) next to each person to open edit dialog
- - Face grid features:
- - Responsive grid layout (adapts to panel width)
- - Face thumbnails with photo icon overlay
- - "Unmatch" button for each face
- - Visual feedback when faces are unmatched (hidden until save)
- - Edit person dialog:
- - Form fields: First name, Last name, Middle name, Maiden name, Date of birth
- - Date picker for date of birth (calendar widget)
- - Validation: Require first_name and last_name
- - Save/Cancel buttons with keyboard shortcuts (Enter to save, Escape to cancel)
- - Change management:
- - Track unmatched faces (temporary state, not persisted until save)
- - "Undo changes" button (undoes all unmatched faces for current person)
- - "Save changes" button (persists all unmatched faces to database)
- - "Exit Edit Identified" button (warns if unsaved changes exist)
- - Optimistic updates with server confirmation
- - Inline toasts for success/error feedback
- - Keyboard navigation support
-
-### Phase 6: Search & Tags (1–2 weeks)
-- Search APIs:
- - `/photos` with filters: person_ids, tag_ids, date_from/to, min_quality, sort, page/page_size
- - Stable sorting (date_taken desc, id tie-breaker); efficient indices
-- Frontend Search UI:
- - Virtualized masonry/grid with infinite scroll
- - Filters drawer (people, tags, date range, quality); saved searches
- - Photo detail drawer with faces, tags, EXIF; open original
-- Tagging:
- - Endpoints: `/tags` CRUD, `/photos/{id}/tags` bulk add/remove
- - Frontend: multi-select in grid and bulk tag apply; inline tag editor
-- People management:
- - People list with search; merge people flow (optional)
- - Display representative face and stats per person
-
-### Phase 7: Polish & Release (1–2 weeks)
-- Performance polish:
- - HTTP caching headers for images; prefetch thumbnails
- - DB indices review; query timing; N+1 checks; connection pool tuning
- - Web vitals: TTFB, LCP, INP; code-splitting; image lazy-loading
-- Accessibility & design:
- - Keyboard access for all actions; focus outlines; ARIA roles
- - High-contrast theme; color tokens; reduced motion option
- - Consistent spacing/typography; empty states and error states
-- Security & hardening:
- - Rate limits on mutating endpoints; payload size limits; input validation
- - JWT rotation; secure cookies mode option; HTTPS enforcement behind proxy
- - Signed URLs for originals; restrict path traversal; content-type checks
-- Testing & quality:
- - API contract tests, worker unit tests, E2E happy-path (Cypress/Playwright)
- - Load test ingestion and processing pipeline (k6)
- - >80% coverage target for API/workers; lint/type-check CI gates
-- Packaging & deployment:
- - Production Dockerfiles (multi-stage), SBOMs
- - Compose/Helm manifests; Traefik/Nginx config with TLS
- - Runbook docs: operations, backups, environment variables
-
----
-
-## 10) Success Criteria
-
-- Users complete import → process → identify → auto-match → modify → tag → search entirely on the web
-- Identify workflow: Manual identification of faces one-at-a-time with similar faces comparison
-- Auto-match workflow: Automated bulk matching with tolerance thresholds and bulk accept/reject
-- Modify Identified workflow: Edit person information, view faces per person, unmatch faces with undo/save functionality
-- Smooth UX with live job progress; no perceived UI blocking
-- Clean, documented OpenAPI; type-safe FE client; >80% test coverage (API + workers)
-- Production deployment artifacts ready (Docker, env, reverse proxy)
-
----
-
-## 11) First Sprint Checklist
-
-- [ ] Initialize repo structure for web (`src/web`, `frontend/`)
-- [ ] FastAPI app with `/health`, `/version`, `/auth/*`, `/jobs/*`
-- [ ] PostgreSQL schema + migrations (Alembic)
-- [ ] Redis + worker container; background job wiring
-- [ ] Frontend scaffold with auth, layout, routing, theme
-- [ ] CI pipeline: lint, type-check, tests, build
-
-
-
diff --git a/ecosystem.config.js.example b/ecosystem.config.js.example
new file mode 100644
index 0000000..90a0eef
--- /dev/null
+++ b/ecosystem.config.js.example
@@ -0,0 +1,79 @@
+module.exports = {
+ apps: [
+ {
+ name: 'punimtag-api',
+ script: 'venv/bin/uvicorn',
+ args: 'backend.app:app --host 0.0.0.0 --port 8000',
+ cwd: '/opt/punimtag', // CHANGE: Update to your deployment directory
+ interpreter: 'none',
+ env: {
+ PYTHONPATH: '/opt/punimtag', // CHANGE: Update to your deployment directory
+ PATH: '/opt/punimtag/venv/bin:/usr/local/bin:/usr/bin:/bin', // CHANGE: Update paths
+ },
+ error_file: '/home/appuser/.pm2/logs/punimtag-api-error.log', // CHANGE: Update user home directory
+ out_file: '/home/appuser/.pm2/logs/punimtag-api-out.log', // CHANGE: Update user home directory
+ log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
+ merge_logs: true,
+ autorestart: true,
+ watch: false,
+ max_memory_restart: '1G',
+ // Prevent infinite crash loops - stop after 10 failed restarts
+ max_restarts: 10,
+ min_uptime: '10s', // Require 10 seconds of uptime before considering stable
+ restart_delay: 4000, // Wait 4 seconds between restarts
+ kill_timeout: 5000, // Allow 5 seconds for graceful shutdown
+ },
+ {
+ name: 'punimtag-worker',
+ script: 'venv/bin/python',
+ args: '-m backend.worker',
+ cwd: '/opt/punimtag', // CHANGE: Update to your deployment directory
+ interpreter: 'none',
+ env: {
+ PYTHONPATH: '/opt/punimtag', // CHANGE: Update to your deployment directory
+ PATH: '/opt/punimtag/venv/bin:/usr/local/bin:/usr/bin:/bin', // CHANGE: Update paths
+ },
+ error_file: '/home/appuser/.pm2/logs/punimtag-worker-error.log', // CHANGE: Update user home directory
+ out_file: '/home/appuser/.pm2/logs/punimtag-worker-out.log', // CHANGE: Update user home directory
+ log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
+ merge_logs: true,
+ autorestart: true,
+ watch: false,
+ max_memory_restart: '1G',
+ // Prevent infinite crash loops - stop after 10 failed restarts
+ max_restarts: 10,
+ min_uptime: '10s', // Require 10 seconds of uptime before considering stable
+ restart_delay: 4000, // Wait 4 seconds between restarts
+ kill_timeout: 5000, // Allow 5 seconds for graceful shutdown
+ },
+ {
+ name: 'punimtag-admin',
+ script: './serve.sh',
+ cwd: '/opt/punimtag/admin-frontend', // CHANGE: Update to your deployment directory
+ interpreter: 'bash',
+ error_file: '/home/appuser/.pm2/logs/punimtag-admin-error.log', // CHANGE: Update user home directory
+ out_file: '/home/appuser/.pm2/logs/punimtag-admin-out.log', // CHANGE: Update user home directory
+ log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
+ merge_logs: true,
+ autorestart: true,
+ watch: false,
+ },
+ {
+ name: 'punimtag-viewer',
+ script: 'npm',
+ args: 'run start:3001',
+ cwd: '/opt/punimtag/viewer-frontend', // CHANGE: Update to your deployment directory
+ interpreter: 'node',
+ env: {
+ PORT: '3001',
+ },
+ error_file: '/home/appuser/.pm2/logs/punimtag-viewer-error.log', // CHANGE: Update user home directory
+ out_file: '/home/appuser/.pm2/logs/punimtag-viewer-out.log', // CHANGE: Update user home directory
+ log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
+ merge_logs: true,
+ autorestart: true,
+ watch: false,
+ },
+ ],
+};
+
diff --git a/package.json b/package.json
index 4a248a8..d0eeba9 100644
--- a/package.json
+++ b/package.json
@@ -8,12 +8,19 @@
"dev:admin": "npm run dev --prefix admin-frontend",
"dev:viewer": "npm run dev --prefix viewer-frontend",
"dev:backend": "source venv/bin/activate && export PYTHONPATH=$(pwd) && uvicorn backend.app:app --host 127.0.0.1 --port 8000",
+ "dev:all": "./start_all.sh",
"build:admin": "npm run build --prefix admin-frontend",
"build:viewer": "npm run build --prefix viewer-frontend",
"build:all": "npm run build:admin && npm run build:viewer",
"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,W293,E305,F401,F811,W291,W391,E712,W504,F841,E402,F824,E128,E226,F402,F541,E302,E117,E722 || true",
+ "lint:python:syntax": "find backend -name '*.py' -exec python -m py_compile {} \\;",
+ "test:backend": "export PYTHONPATH=$(pwd) && export SKIP_DEEPFACE_IN_TESTS=1 && ./venv/bin/python3 -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/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..74ea07d
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,29 @@
+[pytest]
+# Pytest configuration for PunimTag backend tests
+
+# Test discovery patterns
+python_files = test_*.py
+python_classes = Test*
+python_functions = test_*
+
+# Test paths
+testpaths = tests
+
+# Output options
+addopts =
+ -v
+ --strict-markers
+ --tb=short
+ --disable-warnings
+
+# Markers
+markers =
+ slow: marks tests as slow (dummy marker for future use)
+ integration: marks tests as integration tests (dummy marker for future use)
+
+# Environment variables set before test collection
+# SKIP_DEEPFACE_IN_TESTS is set in conftest.py to prevent DeepFace/TensorFlow
+# from loading during tests (avoids illegal instruction errors on some CPUs)
+
+
+
diff --git a/requirements.txt b/requirements.txt
index 1e73486..47473c7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,18 +1,23 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
-pydantic==2.9.1
+pydantic[email]==2.9.1
SQLAlchemy==2.0.36
psycopg2-binary==2.9.9
redis==5.0.8
rq==1.16.2
-python-jose[cryptography]==3.3.0
-python-multipart==0.0.9
+python-jose[cryptography]>=3.4.0
+python-multipart>=0.0.18
python-dotenv==1.0.0
bcrypt==4.1.2
+# Testing Dependencies
+pytest>=7.4.0
+httpx>=0.24.0
+pytest-cov>=4.1.0
# PunimTag Dependencies - DeepFace Implementation
# Core Dependencies
numpy>=1.21.0
pillow>=8.0.0
+exifread>=3.0.0
click>=8.0.0
setuptools>=40.0.0
diff --git a/run_tests.sh b/run_tests.sh
new file mode 100755
index 0000000..31c1b96
--- /dev/null
+++ b/run_tests.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+# Simple test runner script for PunimTag backend tests
+
+set -e
+
+# Colors for output
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+echo -e "${YELLOW}🧪 Running PunimTag Backend Tests${NC}"
+echo ""
+
+# Set environment variables
+export PYTHONPATH=$(pwd)
+export SKIP_DEEPFACE_IN_TESTS=1
+
+# Check if venv exists
+if [ ! -d "venv" ]; then
+ echo -e "${RED}❌ Virtual environment not found. Please create it first:${NC}"
+ echo " python3 -m venv venv"
+ echo " source venv/bin/activate"
+ echo " pip install -r requirements.txt"
+ exit 1
+fi
+
+# Check if pytest is installed
+if ! ./venv/bin/python3 -m pytest --version > /dev/null 2>&1; then
+ echo -e "${YELLOW}⚠️ pytest not found. Installing dependencies...${NC}"
+ ./venv/bin/pip install -r requirements.txt
+fi
+
+echo -e "${GREEN}✅ Environment ready${NC}"
+echo ""
+echo "Running tests..."
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+
+# Run tests with clear output
+./venv/bin/python3 -m pytest tests/ -v --tb=short
+
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo -e "${GREEN}✅ Tests completed${NC}"
+
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000..3c41676
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,101 @@
+# 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
+
+## Log Management Scripts
+
+Quick access to service logs for troubleshooting:
+- `check-logs.sh` - Check recent errors from all services
+- `tail-errors.sh` - Follow error logs in real-time
+- `view-recent-errors.sh` - View errors from the last N minutes (default: 10)
+- `setup-log-rotation.sh` - Configure PM2 log rotation to prevent log bloat
+
+### Quick Usage
+
+```bash
+# Check all service logs for recent errors
+./scripts/check-logs.sh
+
+# Follow error logs in real-time (Ctrl+C to exit)
+./scripts/tail-errors.sh
+
+# View errors from last 10 minutes
+./scripts/view-recent-errors.sh
+
+# View errors from last 30 minutes
+./scripts/view-recent-errors.sh 30
+
+# Setup log rotation (run once)
+./scripts/setup-log-rotation.sh
+```
+
+**Log Locations:**
+- All logs: `/home/appuser/.pm2/logs/`
+- API errors: `/home/appuser/.pm2/logs/punimtag-api-error.log`
+- Worker errors: `/home/appuser/.pm2/logs/punimtag-worker-error.log`
+- Admin errors: `/home/appuser/.pm2/logs/punimtag-admin-error.log`
+- Viewer errors: `/home/appuser/.pm2/logs/punimtag-viewer-error.log`
+
+## 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/cleanup-click-logs.sh b/scripts/cleanup-click-logs.sh
new file mode 100755
index 0000000..0b77ab4
--- /dev/null
+++ b/scripts/cleanup-click-logs.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+# Cleanup old click log files (older than retention period)
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+LOG_DIR="$PROJECT_ROOT/logs"
+
+if [ ! -d "$LOG_DIR" ]; then
+ echo "Log directory does not exist: $LOG_DIR"
+ exit 0
+fi
+
+# Run Python cleanup function
+cd "$PROJECT_ROOT"
+python3 -c "
+from backend.utils.click_logger import cleanup_old_logs
+cleanup_old_logs()
+print('✅ Old click logs cleaned up')
+" 2>&1
+
diff --git a/scripts/drop_all_tables.py b/scripts/db/drop_all_tables.py
similarity index 100%
rename from scripts/drop_all_tables.py
rename to scripts/db/drop_all_tables.py
diff --git a/scripts/drop_all_tables_web.py b/scripts/db/drop_all_tables_web.py
similarity index 100%
rename from scripts/drop_all_tables_web.py
rename to scripts/db/drop_all_tables_web.py
diff --git a/scripts/grant_auth_db_permissions.py b/scripts/db/grant_auth_db_permissions.py
similarity index 100%
rename from scripts/grant_auth_db_permissions.py
rename to scripts/db/grant_auth_db_permissions.py
diff --git a/scripts/migrate_sqlite_to_postgresql.py b/scripts/db/migrate_sqlite_to_postgresql.py
similarity index 100%
rename from scripts/migrate_sqlite_to_postgresql.py
rename to scripts/db/migrate_sqlite_to_postgresql.py
diff --git a/scripts/recreate_tables_web.py b/scripts/db/recreate_tables_web.py
similarity index 100%
rename from scripts/recreate_tables_web.py
rename to scripts/db/recreate_tables_web.py
diff --git a/scripts/show_db_tables.py b/scripts/db/show_db_tables.py
similarity index 100%
rename from scripts/show_db_tables.py
rename to scripts/db/show_db_tables.py
diff --git a/scripts/analyze_all_faces.py b/scripts/debug/analyze_all_faces.py
similarity index 100%
rename from scripts/analyze_all_faces.py
rename to scripts/debug/analyze_all_faces.py
diff --git a/scripts/analyze_pose_matching.py b/scripts/debug/analyze_pose_matching.py
similarity index 100%
rename from scripts/analyze_pose_matching.py
rename to scripts/debug/analyze_pose_matching.py
diff --git a/scripts/analyze_poses.py b/scripts/debug/analyze_poses.py
similarity index 100%
rename from scripts/analyze_poses.py
rename to scripts/debug/analyze_poses.py
diff --git a/scripts/check_database_tables.py b/scripts/debug/check_database_tables.py
similarity index 100%
rename from scripts/check_database_tables.py
rename to scripts/debug/check_database_tables.py
diff --git a/scripts/check_identified_poses_web.py b/scripts/debug/check_identified_poses_web.py
similarity index 100%
rename from scripts/check_identified_poses_web.py
rename to scripts/debug/check_identified_poses_web.py
diff --git a/scripts/check_two_faces_pose.py b/scripts/debug/check_two_faces_pose.py
similarity index 100%
rename from scripts/check_two_faces_pose.py
rename to scripts/debug/check_two_faces_pose.py
diff --git a/scripts/check_yaw_angles.py b/scripts/debug/check_yaw_angles.py
similarity index 100%
rename from scripts/check_yaw_angles.py
rename to scripts/debug/check_yaw_angles.py
diff --git a/scripts/debug_pose_classification.py b/scripts/debug/debug_pose_classification.py
similarity index 100%
rename from scripts/debug_pose_classification.py
rename to scripts/debug/debug_pose_classification.py
diff --git a/scripts/diagnose_frontend_issues.py b/scripts/debug/diagnose_frontend_issues.py
similarity index 100%
rename from scripts/diagnose_frontend_issues.py
rename to scripts/debug/diagnose_frontend_issues.py
diff --git a/scripts/test_eye_visibility.py b/scripts/debug/test_eye_visibility.py
similarity index 100%
rename from scripts/test_eye_visibility.py
rename to scripts/debug/test_eye_visibility.py
diff --git a/scripts/test_pose_calculation.py b/scripts/debug/test_pose_calculation.py
similarity index 100%
rename from scripts/test_pose_calculation.py
rename to scripts/debug/test_pose_calculation.py
diff --git a/scripts/deploy_from_scratch.sh b/scripts/deploy_from_scratch.sh
new file mode 100755
index 0000000..07210e6
--- /dev/null
+++ b/scripts/deploy_from_scratch.sh
@@ -0,0 +1,206 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+echo "== PunimTag deploy (from scratch) =="
+echo "Project root: ${PROJECT_ROOT}"
+echo ""
+
+if [[ "$(id -u)" -eq 0 ]]; then
+ echo "❌ Do not run as root. Run as a normal user with sudo."
+ exit 1
+fi
+
+command_exists() {
+ command -v "$1" >/dev/null 2>&1
+}
+
+ensure_file_from_example() {
+ local example_path="$1"
+ local target_path="$2"
+
+ if [[ -f "${target_path}" ]]; then
+ echo "✅ Found ${target_path}"
+ return 0
+ fi
+
+ if [[ ! -f "${example_path}" ]]; then
+ echo "❌ Missing ${example_path}"
+ exit 1
+ fi
+
+ cp "${example_path}" "${target_path}"
+ echo "✅ Created ${target_path} from ${example_path}"
+}
+
+echo "== 1) Install system dependencies (Ubuntu/Debian) =="
+if command_exists apt-get; then
+ sudo apt-get update
+ sudo apt-get install -y \
+ python3 python3-venv python3-pip \
+ postgresql-client \
+ redis-server \
+ nodejs npm
+else
+ echo "❌ This script currently supports Ubuntu/Debian (apt-get)."
+ echo " Install dependencies manually, then re-run this script."
+ exit 1
+fi
+
+echo ""
+echo "== 2) Ensure Redis is running =="
+sudo systemctl enable --now redis-server || true
+if command_exists redis-cli; then
+ if ! redis-cli ping >/dev/null 2>&1; then
+ echo "❌ Redis is not reachable on localhost:6379"
+ echo " Fix Redis (service, firewall, config) and retry."
+ exit 1
+ fi
+ echo "✅ Redis OK (redis-cli ping)"
+else
+ echo "❌ redis-cli not found after install"
+ exit 1
+fi
+
+echo ""
+echo "== 3) Configure firewall rules (optional) =="
+if command_exists ufw; then
+ echo "Configure UFW firewall rules for application ports?"
+ echo " - Port 3000 (Admin frontend)"
+ echo " - Port 3001 (Viewer frontend)"
+ echo " - Port 8000 (Backend API)"
+ echo ""
+ read -p "Add firewall rules? [y/N] " -n 1 -r
+ echo ""
+ if [[ $REPLY =~ ^[Yy]$ ]]; then
+ sudo ufw allow 3000/tcp
+ sudo ufw allow 3001/tcp
+ sudo ufw allow 8000/tcp
+ echo "✅ Firewall rules added"
+ else
+ echo "⏭️ Skipped firewall rules (configure manually if needed)"
+ fi
+else
+ echo "⏭️ UFW not found, skipping firewall configuration"
+fi
+
+echo ""
+echo "== 3.5) PostgreSQL Remote Connection Setup (if using remote database) =="
+echo "If your PostgreSQL database is on a separate server, you need to configure"
+echo "PostgreSQL to accept remote connections."
+echo ""
+echo "⚠️ IMPORTANT: This configuration must be done ON THE DATABASE SERVER."
+echo " Configure PostgreSQL before starting services (Step 11)."
+echo ""
+echo "Required steps on the DATABASE SERVER:"
+echo ""
+echo "1. Edit pg_hba.conf:"
+echo " sudo nano /etc/postgresql/*/main/pg_hba.conf"
+echo " Add line: host all all YOUR_APP_SERVER_IP/32 md5"
+echo ""
+echo "2. Edit postgresql.conf:"
+echo " sudo nano /etc/postgresql/*/main/postgresql.conf"
+echo " Set: listen_addresses = '*'"
+echo ""
+echo "3. Restart PostgreSQL:"
+echo " sudo systemctl restart postgresql"
+echo ""
+echo "4. Configure firewall on DB server:"
+echo " sudo ufw allow from YOUR_APP_SERVER_IP to any port 5432"
+echo ""
+echo "5. Test connection from this server:"
+echo " psql -h YOUR_DB_SERVER_IP -U YOUR_DB_USER -d postgres"
+echo ""
+echo "⏭️ Continuing with deployment. Ensure PostgreSQL is configured before Step 11."
+
+echo ""
+echo "== 4) Ensure env files exist (copied from *_example) =="
+ensure_file_from_example "${PROJECT_ROOT}/.env_example" "${PROJECT_ROOT}/.env"
+ensure_file_from_example "${PROJECT_ROOT}/admin-frontend/.env_example" \
+ "${PROJECT_ROOT}/admin-frontend/.env"
+ensure_file_from_example "${PROJECT_ROOT}/viewer-frontend/.env_example" \
+ "${PROJECT_ROOT}/viewer-frontend/.env"
+
+echo ""
+echo "⚠️ IMPORTANT: Edit these files NOW before continuing:"
+echo " - ${PROJECT_ROOT}/.env"
+echo " - ${PROJECT_ROOT}/admin-frontend/.env"
+echo " - ${PROJECT_ROOT}/viewer-frontend/.env"
+echo ""
+echo "Press Enter once they are updated..."
+read -r
+
+echo ""
+echo "== 5) Backend Python venv + deps =="
+cd "${PROJECT_ROOT}"
+python3 -m venv venv
+./venv/bin/pip install --upgrade pip
+./venv/bin/pip install -r requirements.txt
+echo "✅ Backend dependencies installed"
+
+echo ""
+echo "== 6) Admin frontend deps =="
+cd "${PROJECT_ROOT}/admin-frontend"
+npm ci
+echo "✅ Admin dependencies installed"
+
+echo ""
+echo "== 7) Viewer frontend deps + Prisma clients =="
+cd "${PROJECT_ROOT}/viewer-frontend"
+npm ci
+npm run prisma:generate:all
+echo "✅ Viewer dependencies installed and Prisma clients generated"
+
+echo ""
+echo "== 8) Auth DB setup scripts (viewer) =="
+cd "${PROJECT_ROOT}/viewer-frontend"
+npx tsx scripts/setup-auth.ts
+npx tsx scripts/fix-admin-user.ts
+echo "✅ Auth DB setup done"
+
+echo ""
+echo "== 9) Build frontends =="
+echo "Building admin frontend..."
+cd "${PROJECT_ROOT}/admin-frontend"
+npm run build
+echo "✅ Admin frontend built"
+
+echo ""
+echo "Building viewer frontend..."
+cd "${PROJECT_ROOT}/viewer-frontend"
+npm run build
+echo "✅ Viewer frontend built"
+
+echo ""
+echo "== 10) Configure PM2 =="
+if ! command_exists pm2; then
+ echo "Installing PM2..."
+ sudo npm i -g pm2
+fi
+
+cd "${PROJECT_ROOT}"
+ensure_file_from_example \
+ "${PROJECT_ROOT}/ecosystem.config.js.example" \
+ "${PROJECT_ROOT}/ecosystem.config.js"
+
+echo ""
+echo "⚠️ IMPORTANT: Review and edit ${PROJECT_ROOT}/ecosystem.config.js"
+echo " Update paths (cwd, error_file, out_file, PYTHONPATH, PATH) for your server."
+echo ""
+read -p "Press Enter once ecosystem.config.js is configured (or to use defaults)..."
+
+echo ""
+echo "== 11) Start services (PM2) =="
+cd "${PROJECT_ROOT}"
+pm2 start ecosystem.config.js
+pm2 save
+echo "✅ Services started with PM2"
+
+echo ""
+echo "✅ Done."
+echo "Admin: http://:3000"
+echo "Viewer: http://:3001"
+echo "API: http://:8000/docs"
+
+
diff --git a/scripts/drop_auth_database.sh b/scripts/drop_auth_database.sh
index 7f46226..b6689f0 100755
--- a/scripts/drop_auth_database.sh
+++ b/scripts/drop_auth_database.sh
@@ -14,3 +14,4 @@ else
fi
+
diff --git a/scripts/start-api.sh b/scripts/start-api.sh
new file mode 100755
index 0000000..f322b0d
--- /dev/null
+++ b/scripts/start-api.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+# Wrapper script to start the API with port cleanup
+
+# Kill any processes using port 8000 (except our own if we're restarting)
+PORT=8000
+lsof -ti :${PORT} | xargs -r kill -9 2>/dev/null || true
+
+# Wait a moment for port to be released
+sleep 2
+
+# Start uvicorn
+exec /opt/punimtag/venv/bin/uvicorn backend.app:app --host 0.0.0.0 --port 8000
+
diff --git a/scripts/fix_admin_password.py b/scripts/utils/fix_admin_password.py
similarity index 100%
rename from scripts/fix_admin_password.py
rename to scripts/utils/fix_admin_password.py
diff --git a/scripts/update_reported_photo_status.py b/scripts/utils/update_reported_photo_status.py
similarity index 100%
rename from scripts/update_reported_photo_status.py
rename to scripts/utils/update_reported_photo_status.py
diff --git a/src/utils/pose_detection.py b/src/utils/pose_detection.py
index b0b84ce..fb8214e 100644
--- a/src/utils/pose_detection.py
+++ b/src/utils/pose_detection.py
@@ -1,22 +1,28 @@
"""Face pose detection (yaw, pitch, roll) using RetinaFace landmarks"""
+import os
import numpy as np
from math import atan2, degrees
from typing import Dict, Tuple, Optional, List
-try:
- from retinaface import RetinaFace
- RETINAFACE_AVAILABLE = True
-except ImportError:
+# Skip RetinaFace import during tests to avoid illegal instruction errors
+if os.getenv("SKIP_DEEPFACE_IN_TESTS") == "1":
RETINAFACE_AVAILABLE = False
RetinaFace = None
+else:
+ try:
+ from retinaface import RetinaFace
+ RETINAFACE_AVAILABLE = True
+ except ImportError:
+ RETINAFACE_AVAILABLE = False
+ RetinaFace = None
class PoseDetector:
"""Detect face pose (yaw, pitch, roll) using RetinaFace landmarks"""
# Thresholds for pose detection (in degrees)
- PROFILE_YAW_THRESHOLD = 30.0 # Faces with |yaw| >= 30° are considered profile
+ PROFILE_YAW_THRESHOLD = 15.0 # Faces with |yaw| >= 15° are considered profile
EXTREME_YAW_THRESHOLD = 60.0 # Faces with |yaw| >= 60° are extreme profile
PITCH_THRESHOLD = 20.0 # Faces with |pitch| >= 20° are looking up/down
@@ -33,7 +39,7 @@ class PoseDetector:
Args:
yaw_threshold: Yaw angle threshold for profile detection (degrees)
- Default: 30.0
+ Default: 15.0
pitch_threshold: Pitch angle threshold for up/down detection (degrees)
Default: 20.0
roll_threshold: Roll angle threshold for tilt detection (degrees)
@@ -47,17 +53,24 @@ class PoseDetector:
self.roll_threshold = roll_threshold or self.ROLL_THRESHOLD
@staticmethod
- def detect_faces_with_landmarks(img_path: str) -> Dict:
+ def detect_faces_with_landmarks(img_path: str, filter_estimated_landmarks: bool = False) -> Dict:
"""Detect faces using RetinaFace directly
+ Args:
+ img_path: Path to image file
+ filter_estimated_landmarks: If True, remove landmarks that appear to be estimated
+ (e.g., hidden eye in profile views) rather than actually visible.
+ Uses heuristics: if eyes are very close together (< 20px) and
+ yaw calculation suggests extreme profile, mark hidden eye as None.
+
Returns:
Dictionary with face keys and landmark data:
{
'face_1': {
'facial_area': {'x': x, 'y': y, 'w': w, 'h': h},
'landmarks': {
- 'left_eye': (x, y),
- 'right_eye': (x, y),
+ 'left_eye': (x, y) or None,
+ 'right_eye': (x, y) or None,
'nose': (x, y),
'left_mouth': (x, y),
'right_mouth': (x, y)
@@ -70,6 +83,42 @@ class PoseDetector:
return {}
faces = RetinaFace.detect_faces(img_path)
+
+ # Post-process to filter estimated landmarks if requested
+ if filter_estimated_landmarks:
+ for face_key, face_data in faces.items():
+ landmarks = face_data.get('landmarks', {})
+ if not landmarks:
+ continue
+
+ left_eye = landmarks.get('left_eye')
+ right_eye = landmarks.get('right_eye')
+ nose = landmarks.get('nose')
+
+ # Check if both eyes are present and very close together (profile view)
+ if left_eye and right_eye and nose:
+ face_width = abs(right_eye[0] - left_eye[0])
+
+ # If eyes are very close (< 20px), likely a profile view
+ if face_width < 20.0:
+ # Calculate which eye is likely hidden based on nose position
+ eye_mid_x = (left_eye[0] + right_eye[0]) / 2
+ nose_x = nose[0]
+
+ # If nose is closer to left eye, right eye is likely hidden (face turned left)
+ # If nose is closer to right eye, left eye is likely hidden (face turned right)
+ dist_to_left = abs(nose_x - left_eye[0])
+ dist_to_right = abs(nose_x - right_eye[0])
+
+ if dist_to_left < dist_to_right:
+ # Nose closer to left eye = face turned left = right eye hidden
+ landmarks['right_eye'] = None
+ else:
+ # Nose closer to right eye = face turned right = left eye hidden
+ landmarks['left_eye'] = None
+
+ face_data['landmarks'] = landmarks
+
return faces
@staticmethod
@@ -254,7 +303,8 @@ class PoseDetector:
def classify_pose_mode(yaw: Optional[float],
pitch: Optional[float],
roll: Optional[float],
- face_width: Optional[float] = None) -> str:
+ face_width: Optional[float] = None,
+ landmarks: Optional[Dict] = None) -> str:
"""Classify face pose mode from all three angles and optionally face width
Args:
@@ -262,8 +312,10 @@ class PoseDetector:
pitch: Pitch angle in degrees
roll: Roll angle in degrees
face_width: Face width in pixels (eye distance). Used as indicator for profile detection.
- If face_width < 25px, indicates profile view. When yaw is available but < 30°,
+ If face_width < 25px, indicates profile view. When yaw is available but < 15°,
face_width can override yaw if it suggests profile (face_width < 25px).
+ landmarks: Optional facial landmarks dictionary. Used to detect single-eye visibility
+ for extreme profile views where only one eye is visible.
Returns:
Pose mode classification string:
@@ -273,6 +325,28 @@ class PoseDetector:
- 'tilted_left', 'tilted_right': roll variations
- Combined modes: e.g., 'profile_left_looking_up'
"""
+ # Check for single-eye visibility to infer profile direction
+ # This handles extreme profile views where only one eye is visible
+ if landmarks:
+ left_eye = landmarks.get('left_eye')
+ right_eye = landmarks.get('right_eye')
+
+ # Only right eye visible -> face turned left -> profile_left
+ if left_eye is None and right_eye is not None:
+ # Infer profile_left when only right eye is visible
+ inferred_profile = "profile_left"
+ # Only left eye visible -> face turned right -> profile_right
+ elif left_eye is not None and right_eye is None:
+ # Infer profile_right when only left eye is visible
+ inferred_profile = "profile_right"
+ # No eyes visible -> extreme profile, default to profile_left
+ elif left_eye is None and right_eye is None:
+ inferred_profile = "profile_left"
+ else:
+ inferred_profile = None # Both eyes visible, use normal logic
+ else:
+ inferred_profile = None
+
# Default to frontal if angles unknown
yaw_original = yaw
if yaw is None:
@@ -284,20 +358,23 @@ class PoseDetector:
# Face width threshold for profile detection (in pixels)
# Profile faces have very small eye distance (< 25 pixels typically)
- PROFILE_FACE_WIDTH_THRESHOLD = 10.0 #25.0
+ PROFILE_FACE_WIDTH_THRESHOLD = 20.0
# Yaw classification - PRIMARY INDICATOR
- # Use yaw angle as the primary indicator (30° threshold)
+ # Use yaw angle as the primary indicator (15° threshold)
abs_yaw = abs(yaw)
# Primary classification based on yaw angle
- if abs_yaw < 30.0:
+ if abs_yaw < 15.0:
# Yaw indicates frontal view
- # Trust yaw when it's available and reasonable (< 30°)
+ # Trust yaw when it's available and reasonable (< 15°)
# Only use face_width as fallback when yaw is unavailable (None)
if yaw_original is None:
- # Yaw unavailable - use face_width as fallback
- if face_width is not None:
+ # Yaw unavailable - check for single-eye visibility first
+ if inferred_profile is not None:
+ # Single eye visible or no eyes visible -> use inferred profile direction
+ yaw_mode = inferred_profile
+ elif face_width is not None:
if face_width < PROFILE_FACE_WIDTH_THRESHOLD:
# Face width suggests profile view - use it when yaw is unavailable
yaw_mode = "profile_left" # Default direction when yaw unavailable
@@ -305,16 +382,14 @@ class PoseDetector:
# Face width is normal (>= 25px) - likely frontal
yaw_mode = "frontal"
else:
- # Both yaw and face_width unavailable - cannot determine reliably
- # This usually means landmarks are incomplete (missing nose and/or eyes)
- # For extreme profile views, both eyes might not be visible, which would
- # cause face_width to be None. In this case, we cannot reliably determine
- # pose without additional indicators (like face bounding box aspect ratio).
- # Default to frontal (conservative approach), but this might misclassify
- # some extreme profile faces.
- yaw_mode = "frontal"
+ # Both yaw and face_width unavailable - check if we inferred profile from landmarks
+ if inferred_profile is not None:
+ yaw_mode = inferred_profile
+ else:
+ # Cannot determine reliably - default to frontal
+ yaw_mode = "frontal"
else:
- # Yaw is available and < 30° - but still check face_width
+ # Yaw is available and < 15° - but still check face_width
# If face_width is very small (< 25px), it suggests profile even with small yaw
if face_width is not None:
if face_width < PROFILE_FACE_WIDTH_THRESHOLD:
@@ -326,11 +401,11 @@ class PoseDetector:
else:
# No face_width provided - trust yaw, classify as frontal
yaw_mode = "frontal"
- elif yaw <= -30.0:
- # abs_yaw >= 30.0 and yaw is negative - profile left
+ elif yaw <= -15.0:
+ # abs_yaw >= 15.0 and yaw is negative - profile left
yaw_mode = "profile_left" # Negative yaw = face turned left = left profile visible
- elif yaw >= 30.0:
- # abs_yaw >= 30.0 and yaw is positive - profile right
+ elif yaw >= 15.0:
+ # abs_yaw >= 15.0 and yaw is positive - profile right
yaw_mode = "profile_right" # Positive yaw = face turned right = right profile visible
else:
# This should never be reached, but handle edge case
@@ -405,8 +480,8 @@ class PoseDetector:
# Calculate face width (eye distance) for profile detection
face_width = self.calculate_face_width_from_landmarks(landmarks)
- # Classify pose mode (using face width as additional indicator)
- pose_mode = self.classify_pose_mode(yaw_angle, pitch_angle, roll_angle, face_width)
+ # Classify pose mode (using face width and landmarks as additional indicators)
+ pose_mode = self.classify_pose_mode(yaw_angle, pitch_angle, roll_angle, face_width, landmarks)
# Normalize facial_area format (RetinaFace returns list [x, y, w, h] or dict)
facial_area_raw = face_data.get('facial_area', {})
diff --git a/start_all.sh b/start_all.sh
new file mode 100755
index 0000000..fef6f65
--- /dev/null
+++ b/start_all.sh
@@ -0,0 +1,110 @@
+#!/bin/bash
+# Start all three servers: backend, admin-frontend, and viewer-frontend
+
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+# Colors for output
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+echo -e "${BLUE}🚀 Starting all PunimTag servers...${NC}"
+echo ""
+
+# Function to cleanup on exit
+cleanup() {
+ echo ""
+ echo -e "${YELLOW}Shutting down all servers...${NC}"
+ kill $WORKER_PID 2>/dev/null || true
+ kill $BACKEND_PID 2>/dev/null || true
+ kill $ADMIN_PID 2>/dev/null || true
+ kill $VIEWER_PID 2>/dev/null || true
+ exit
+}
+
+trap cleanup SIGINT SIGTERM
+
+# Start backend
+echo -e "${GREEN}📦 Starting backend server...${NC}"
+# Use explicit Python path to avoid Cursor interception
+PYTHON_BIN="/usr/bin/python3"
+if [ ! -f "$PYTHON_BIN" ]; then
+ if command -v python3 >/dev/null 2>&1; then
+ PYTHON_BIN="$(which python3)"
+ elif command -v python >/dev/null 2>&1; then
+ PYTHON_BIN="$(which python)"
+ else
+ echo -e "${YELLOW}❌ Python3 not found${NC}"
+ exit 1
+ fi
+fi
+
+if [ -d "venv" ]; then
+ source venv/bin/activate
+fi
+export PYTHONPATH="$(pwd)"
+
+# Check if Redis is running
+if ! redis-cli ping > /dev/null 2>&1; then
+ echo -e "${YELLOW}⚠️ Redis is not running. Starting Redis...${NC}"
+ redis-server --daemonize yes 2>/dev/null || {
+ echo -e "${YELLOW}❌ Failed to start Redis. Please start it manually: redis-server${NC}"
+ exit 1
+ }
+ sleep 1
+fi
+
+# Start RQ worker in background
+echo -e "${GREEN}🚀 Starting RQ worker...${NC}"
+env PYTHONPATH="$(pwd)" "$PYTHON_BIN" -m backend.worker > /tmp/worker.log 2>&1 &
+WORKER_PID=$!
+
+# Give worker a moment to start
+sleep 2
+
+# Start FastAPI server
+echo -e "${GREEN}🚀 Starting FastAPI server...${NC}"
+"$PYTHON_BIN" -m uvicorn backend.app:app --host 127.0.0.1 --port 8000 --reload > /tmp/backend.log 2>&1 &
+BACKEND_PID=$!
+
+# Wait a moment for backend to start
+sleep 2
+
+# Start admin-frontend
+echo -e "${GREEN}📦 Starting admin-frontend...${NC}"
+cd admin-frontend
+npm run dev > /tmp/admin-frontend.log 2>&1 &
+ADMIN_PID=$!
+cd ..
+
+# Start viewer-frontend
+echo -e "${GREEN}📦 Starting viewer-frontend...${NC}"
+cd viewer-frontend
+npm run dev > /tmp/viewer-frontend.log 2>&1 &
+VIEWER_PID=$!
+cd ..
+
+echo ""
+echo -e "${GREEN}✅ All servers started!${NC}"
+echo ""
+echo -e "${BLUE}📍 Server URLs:${NC}"
+echo -e " Backend API: ${GREEN}http://127.0.0.1:8000${NC}"
+echo -e " API Docs: ${GREEN}http://127.0.0.1:8000/docs${NC}"
+echo -e " Admin Frontend: ${GREEN}http://127.0.0.1:3000${NC}"
+echo -e " Viewer Frontend: ${GREEN}http://127.0.0.1:3001${NC}"
+echo ""
+echo -e "${YELLOW}📋 Logs:${NC}"
+echo -e " Worker: ${BLUE}/tmp/worker.log${NC}"
+echo -e " Backend: ${BLUE}/tmp/backend.log${NC}"
+echo -e " Admin: ${BLUE}/tmp/admin-frontend.log${NC}"
+echo -e " Viewer: ${BLUE}/tmp/viewer-frontend.log${NC}"
+echo ""
+echo -e "${YELLOW}Press Ctrl+C to stop all servers${NC}"
+echo ""
+
+# Wait for all processes
+wait
+
diff --git a/tests/API_TEST_PLAN.md b/tests/API_TEST_PLAN.md
new file mode 100644
index 0000000..4a33f03
--- /dev/null
+++ b/tests/API_TEST_PLAN.md
@@ -0,0 +1,607 @@
+# Backend API Test Plan
+
+This document outlines comprehensive test cases for all backend API endpoints in PunimTag.
+
+## Test Structure Overview
+
+The test suite uses:
+- **pytest** - Testing framework
+- **httpx/TestClient** - For making test requests to FastAPI
+- **pytest-fixtures** - For database setup/teardown
+- **Test database** - Separate PostgreSQL database for testing
+
+## Test Files Organization
+
+### 1. Authentication API Tests (`test_api_auth.py`)
+
+#### Login Endpoints
+- `test_login_success_with_valid_credentials` - Verify successful login with valid username/password
+- `test_login_failure_with_invalid_credentials` - Verify 401 with invalid credentials
+- `test_login_with_inactive_user` - Verify 401 when user account is inactive
+- `test_login_without_password_hash` - Verify error when password_hash is missing
+- `test_login_fallback_to_hardcoded_admin` - Verify fallback to admin/admin works
+- `test_login_updates_last_login` - Verify last_login timestamp is updated
+
+#### Token Refresh Endpoints
+- `test_refresh_token_success` - Verify successful token refresh
+- `test_refresh_token_with_invalid_token` - Verify 401 with invalid refresh token
+- `test_refresh_token_with_access_token` - Verify 401 when using access token instead of refresh token
+- `test_refresh_token_expired` - Verify 401 with expired refresh token
+
+#### Current User Endpoints
+- `test_get_current_user_info_authenticated` - Verify user info retrieval with valid token
+- `test_get_current_user_info_unauthenticated` - Verify 401 without token
+- `test_get_current_user_info_bootstrap_admin` - Verify admin bootstrap when no admins exist
+- `test_get_current_user_info_role_permissions` - Verify role and permissions are returned
+
+#### Password Change Endpoints
+- `test_change_password_success` - Verify successful password change
+- `test_change_password_with_wrong_current_password` - Verify 401 with incorrect current password
+- `test_change_password_clears_password_change_required_flag` - Verify flag is cleared after change
+- `test_change_password_user_not_found` - Verify 404 when user doesn't exist
+
+#### Authentication Middleware
+- `test_get_current_user_without_token` - Verify 401 without Authorization header
+- `test_get_current_user_with_expired_token` - Verify 401 with expired JWT
+- `test_get_current_user_with_invalid_token_format` - Verify 401 with malformed token
+- `test_get_current_user_with_id_creates_user` - Verify user creation in bootstrap scenario
+
+---
+
+### 2. Photos API Tests (`test_api_photos.py`)
+
+#### Photo Search Endpoints
+- `test_search_photos_by_name_success` - Verify search by person name works
+- `test_search_photos_by_name_without_person_name` - Verify 400 when person_name missing
+- `test_search_photos_by_name_with_pagination` - Verify pagination works correctly
+- `test_search_photos_by_date_success` - Verify date range search
+- `test_search_photos_by_date_without_dates` - Verify 400 when both dates missing
+- `test_search_photos_by_date_from_only` - Verify search with only date_from
+- `test_search_photos_by_date_to_only` - Verify search with only date_to
+- `test_search_photos_by_tags_success` - Verify tag search works
+- `test_search_photos_by_tags_match_all` - Verify match_all parameter
+- `test_search_photos_by_tags_match_any` - Verify match_any behavior
+- `test_search_photos_by_tags_without_tags` - Verify 400 when tag_names missing
+- `test_search_photos_no_faces` - Verify photos without faces search
+- `test_search_photos_no_tags` - Verify photos without tags search
+- `test_search_photos_processed` - Verify processed photos search
+- `test_search_photos_unprocessed` - Verify unprocessed photos search
+- `test_search_photos_favorites_authenticated` - Verify favorites search with auth
+- `test_search_photos_favorites_unauthenticated` - Verify 401 without auth
+- `test_search_photos_with_pagination` - Verify page and page_size parameters
+- `test_search_photos_with_invalid_search_type` - Verify 400 with invalid search_type
+- `test_search_photos_with_media_type_filter` - Verify image/video filtering
+- `test_search_photos_with_folder_path_filter` - Verify folder path filtering
+- `test_search_photos_with_date_filters_as_additional_filters` - Verify date filters in non-date searches
+- `test_search_photos_returns_favorite_status` - Verify is_favorite field in results
+
+#### Photo Import Endpoints
+- `test_import_photos_success` - Verify photo import job is queued
+- `test_import_photos_with_invalid_folder_path` - Verify 400 with invalid path
+- `test_import_photos_with_nonexistent_folder` - Verify 400 when folder doesn't exist
+- `test_import_photos_recursive` - Verify recursive import option
+- `test_import_photos_returns_job_id` - Verify job_id is returned
+- `test_import_photos_returns_estimated_count` - Verify estimated_photos count
+
+#### Photo Upload Endpoints
+- `test_upload_photos_success` - Verify single file upload
+- `test_upload_photos_multiple_files` - Verify multiple file upload
+- `test_upload_photos_duplicate_handling` - Verify duplicate detection
+- `test_upload_photos_invalid_file_type` - Verify error handling for invalid files
+- `test_upload_photos_returns_added_existing_counts` - Verify response counts
+
+#### Photo Retrieval Endpoints
+- `test_get_photo_by_id_success` - Verify photo retrieval by ID
+- `test_get_photo_by_id_not_found` - Verify 404 for non-existent photo
+- `test_get_photo_image_success` - Verify image file serving
+- `test_get_photo_image_not_found` - Verify 404 when photo doesn't exist
+- `test_get_photo_image_file_missing` - Verify 404 when file is missing
+- `test_get_photo_image_content_type` - Verify correct Content-Type header
+- `test_get_photo_image_cache_headers` - Verify cache headers are set
+
+#### Photo Favorites Endpoints
+- `test_toggle_favorite_add` - Verify adding favorite
+- `test_toggle_favorite_remove` - Verify removing favorite
+- `test_toggle_favorite_unauthenticated` - Verify 401 without auth
+- `test_toggle_favorite_photo_not_found` - Verify 404 for non-existent photo
+- `test_check_favorite_true` - Verify check returns true for favorited photo
+- `test_check_favorite_false` - Verify check returns false for non-favorited photo
+- `test_bulk_add_favorites_success` - Verify bulk add operation
+- `test_bulk_add_favorites_already_favorites` - Verify handling of already-favorited photos
+- `test_bulk_add_favorites_with_missing_photos` - Verify 404 with missing photo IDs
+- `test_bulk_remove_favorites_success` - Verify bulk remove operation
+- `test_bulk_remove_favorites_not_favorites` - Verify handling of non-favorited photos
+
+#### Photo Deletion Endpoints
+- `test_bulk_delete_photos_success` - Verify bulk delete (admin only)
+- `test_bulk_delete_photos_non_admin` - Verify 403 for non-admin users
+- `test_bulk_delete_photos_with_missing_ids` - Verify handling of missing IDs
+- `test_bulk_delete_photos_cascades_to_faces_tags` - Verify cascade deletion
+- `test_bulk_delete_photos_empty_list` - Verify 400 with empty photo_ids
+
+#### Photo Folder Operations
+- `test_browse_folder_success` - Verify folder picker works (if tkinter available)
+- `test_browse_folder_no_display` - Verify graceful failure without display
+- `test_browse_folder_cancelled` - Verify handling when user cancels
+- `test_open_photo_folder_success` - Verify folder opening works
+- `test_open_photo_folder_photo_not_found` - Verify 404 for non-existent photo
+- `test_open_photo_folder_file_missing` - Verify 404 when file is missing
+
+---
+
+### 3. People API Tests (`test_api_people.py`)
+
+#### People Listing Endpoints
+- `test_list_people_success` - Verify people list retrieval
+- `test_list_people_with_last_name_filter` - Verify last name filtering
+- `test_list_people_case_insensitive_filter` - Verify case-insensitive search
+- `test_list_people_with_faces_success` - Verify people with face counts
+- `test_list_people_with_faces_includes_zero_counts` - Verify zero counts included
+- `test_list_people_with_faces_last_name_filter` - Verify filtering with faces
+- `test_list_people_with_faces_maiden_name_filter` - Verify maiden name filtering
+- `test_list_people_sorted_by_name` - Verify sorting by last_name, first_name
+
+#### People CRUD Endpoints
+- `test_create_person_success` - Verify person creation
+- `test_create_person_with_middle_name` - Verify optional middle_name
+- `test_create_person_with_maiden_name` - Verify optional maiden_name
+- `test_create_person_with_date_of_birth` - Verify date_of_birth handling
+- `test_create_person_strips_whitespace` - Verify name trimming
+- `test_get_person_by_id_success` - Verify person retrieval
+- `test_get_person_by_id_not_found` - Verify 404 for non-existent person
+- `test_update_person_success` - Verify person update
+- `test_update_person_not_found` - Verify 404 when updating non-existent person
+- `test_update_person_strips_whitespace` - Verify whitespace handling
+- `test_delete_person_success` - Verify person deletion
+- `test_delete_person_cascades_to_faces_and_encodings` - Verify cascade behavior
+- `test_delete_person_cascades_to_video_linkages` - Verify video linkage cleanup
+- `test_delete_person_not_found` - Verify 404 for non-existent person
+
+#### People Faces Endpoints
+- `test_get_person_faces_success` - Verify faces retrieval for person
+- `test_get_person_faces_no_faces` - Verify empty list when no faces
+- `test_get_person_faces_person_not_found` - Verify 404 for non-existent person
+- `test_get_person_faces_sorted_by_filename` - Verify sorting
+- `test_get_person_videos_success` - Verify videos linked to person
+- `test_get_person_videos_no_videos` - Verify empty list when no videos
+- `test_get_person_videos_person_not_found` - Verify 404 handling
+
+#### People Match Acceptance Endpoints
+- `test_accept_matches_success` - Verify accepting auto-match matches
+- `test_accept_matches_tracks_user_id` - Verify user tracking
+- `test_accept_matches_person_not_found` - Verify 404 for non-existent person
+- `test_accept_matches_face_not_found` - Verify handling of missing faces
+- `test_accept_matches_creates_person_encodings` - Verify encoding creation
+- `test_accept_matches_updates_existing_encodings` - Verify encoding updates
+
+---
+
+### 4. Faces API Tests (`test_api_faces.py`)
+
+#### Face Processing Endpoints
+- `test_process_faces_success` - Verify face processing job queued
+- `test_process_faces_redis_unavailable` - Verify 503 when Redis unavailable
+- `test_process_faces_with_custom_detector` - Verify custom detector_backend
+- `test_process_faces_with_custom_model` - Verify custom model_name
+- `test_process_faces_with_batch_size` - Verify batch_size parameter
+- `test_process_faces_returns_job_id` - Verify job_id in response
+
+#### Unidentified Faces Endpoints
+- `test_get_unidentified_faces_success` - Verify unidentified faces list
+- `test_get_unidentified_faces_with_pagination` - Verify pagination
+- `test_get_unidentified_faces_with_quality_filter` - Verify min_quality filter
+- `test_get_unidentified_faces_with_date_filters` - Verify date filtering
+- `test_get_unidentified_faces_with_tag_filters` - Verify tag filtering
+- `test_get_unidentified_faces_with_photo_id_filter` - Verify photo ID filtering
+- `test_get_unidentified_faces_include_excluded` - Verify include_excluded parameter
+- `test_get_unidentified_faces_sort_by_quality` - Verify sorting by quality
+- `test_get_unidentified_faces_sort_by_date` - Verify sorting by date
+- `test_get_unidentified_faces_invalid_date_format` - Verify date validation
+- `test_get_unidentified_faces_match_all_tags` - Verify match_all parameter
+
+#### Similar Faces Endpoints
+- `test_get_similar_faces_success` - Verify similar faces retrieval
+- `test_get_similar_faces_include_excluded` - Verify include_excluded parameter
+- `test_get_similar_faces_face_not_found` - Verify 404 for non-existent face
+- `test_get_similar_faces_returns_similarity_scores` - Verify similarity in response
+- `test_batch_similarity_success` - Verify batch similarity calculation
+- `test_batch_similarity_with_min_confidence` - Verify min_confidence filter
+- `test_batch_similarity_empty_list` - Verify handling of empty face_ids
+- `test_batch_similarity_invalid_face_ids` - Verify error handling
+
+#### Face Identification Endpoints
+- `test_identify_face_with_existing_person` - Verify identification with existing person
+- `test_identify_face_create_new_person` - Verify person creation during identification
+- `test_identify_face_with_additional_faces` - Verify batch identification
+- `test_identify_face_face_not_found` - Verify 404 for non-existent face
+- `test_identify_face_person_not_found` - Verify 400 when person_id invalid
+- `test_identify_face_tracks_user_id` - Verify user tracking
+- `test_identify_face_creates_person_encodings` - Verify encoding creation
+- `test_identify_face_requires_name_for_new_person` - Verify validation
+
+#### Face Crop Endpoint
+- `test_get_face_crop_success` - Verify face crop image generation
+- `test_get_face_crop_face_not_found` - Verify 404 for non-existent face
+- `test_get_face_crop_photo_file_missing` - Verify 404 when file missing
+- `test_get_face_crop_invalid_location` - Verify 422 for invalid location
+- `test_get_face_crop_exif_orientation_handling` - Verify EXIF correction
+- `test_get_face_crop_resizes_small_faces` - Verify resizing for small faces
+- `test_get_face_crop_content_type` - Verify correct Content-Type
+
+#### Face Exclusion Endpoints
+- `test_toggle_face_excluded_true` - Verify excluding face
+- `test_toggle_face_excluded_false` - Verify including face
+- `test_toggle_face_excluded_face_not_found` - Verify 404 handling
+
+#### Face Unmatch Endpoints
+- `test_unmatch_face_success` - Verify face unmatching
+- `test_unmatch_face_already_unmatched` - Verify 400 when already unmatched
+- `test_unmatch_face_deletes_person_encodings` - Verify encoding cleanup
+- `test_batch_unmatch_faces_success` - Verify batch unmatch
+- `test_batch_unmatch_faces_none_matched` - Verify 400 when none matched
+- `test_batch_unmatch_faces_some_missing` - Verify 404 with missing faces
+
+#### Auto-Match Endpoints
+- `test_auto_match_faces_success` - Verify auto-match process
+- `test_auto_match_faces_with_tolerance` - Verify tolerance parameter
+- `test_auto_match_faces_auto_accept_enabled` - Verify auto-accept functionality
+- `test_auto_match_faces_auto_accept_with_threshold` - Verify threshold filtering
+- `test_auto_match_faces_auto_accept_filters_by_quality` - Verify quality filtering
+- `test_auto_match_faces_auto_accept_filters_by_pose` - Verify pose filtering
+- `test_get_auto_match_people_success` - Verify people list for auto-match
+- `test_get_auto_match_people_filter_frontal_only` - Verify frontal filter
+- `test_get_auto_match_person_matches_success` - Verify person matches retrieval
+- `test_get_auto_match_person_matches_person_not_found` - Verify 404 handling
+
+#### Face Maintenance Endpoints
+- `test_list_all_faces_success` - Verify all faces listing
+- `test_list_all_faces_with_filters` - Verify filtering options
+- `test_list_all_faces_pagination` - Verify pagination
+- `test_list_all_faces_excluded_filter` - Verify excluded status filter
+- `test_list_all_faces_identified_filter` - Verify identified status filter
+- `test_delete_faces_success` - Verify face deletion
+- `test_delete_faces_with_missing_ids` - Verify 404 with missing IDs
+- `test_delete_faces_deletes_person_encodings` - Verify encoding cleanup
+- `test_delete_faces_empty_list` - Verify 400 with empty list
+
+---
+
+### 5. Tags API Tests (`test_api_tags.py`)
+
+#### Tag Listing Endpoints
+- `test_get_tags_success` - Verify tags list retrieval
+- `test_get_tags_empty_list` - Verify empty list when no tags
+- `test_get_tags_sorted` - Verify sorting behavior
+
+#### Tag CRUD Endpoints
+- `test_create_tag_success` - Verify tag creation
+- `test_create_tag_duplicate` - Verify returns existing tag if duplicate
+- `test_create_tag_strips_whitespace` - Verify whitespace handling
+- `test_update_tag_success` - Verify tag update
+- `test_update_tag_not_found` - Verify 404 for non-existent tag
+- `test_delete_tag_success` - Verify tag deletion
+- `test_delete_tag_with_photos` - Verify cascade or error handling
+- `test_delete_tag_not_found` - Verify 404 handling
+
+#### Photo-Tag Operations
+- `test_add_tags_to_photos_success` - Verify adding tags to photos
+- `test_add_tags_to_photos_empty_photo_ids` - Verify 400 with empty photo_ids
+- `test_add_tags_to_photos_empty_tag_names` - Verify 400 with empty tag_names
+- `test_add_tags_to_photos_creates_missing_tags` - Verify auto-creation
+- `test_remove_tags_from_photos_success` - Verify tag removal
+- `test_get_photo_tags_success` - Verify photo tags retrieval
+- `test_get_photo_tags_empty` - Verify empty list for untagged photo
+- `test_get_photos_with_tags_success` - Verify photos with tags query
+- `test_get_photos_with_tags_multiple_tags` - Verify multiple tag filtering
+- `test_get_photos_with_tags_match_all` - Verify match_all behavior
+
+---
+
+### 6. Users API Tests (`test_api_users.py`)
+
+#### User Listing Endpoints
+- `test_list_users_success` - Verify users list (admin only)
+- `test_list_users_non_admin` - Verify 403 for non-admin users
+- `test_list_users_with_pagination` - Verify pagination
+- `test_list_users_with_search_filter` - Verify search functionality
+- `test_list_users_includes_role_info` - Verify role information
+
+#### User CRUD Endpoints
+- `test_create_user_success` - Verify user creation (admin only)
+- `test_create_user_duplicate_email` - Verify 400 with duplicate email
+- `test_create_user_duplicate_username` - Verify 400 with duplicate username
+- `test_create_user_with_role` - Verify role assignment
+- `test_create_user_creates_auth_user` - Verify auth database sync
+- `test_create_user_password_validation` - Verify password requirements
+- `test_get_user_by_id_success` - Verify user retrieval
+- `test_get_user_by_id_not_found` - Verify 404 for non-existent user
+- `test_update_user_success` - Verify user update
+- `test_update_user_role_change` - Verify role updates
+- `test_update_user_email_conflict` - Verify email uniqueness
+- `test_delete_user_success` - Verify user deletion
+- `test_delete_user_with_linked_data` - Verify graceful handling
+- `test_delete_user_cascades_to_auth_database` - Verify auth DB cleanup
+- `test_delete_user_non_admin` - Verify 403 for non-admin
+
+#### User Activation Endpoints
+- `test_activate_user_success` - Verify user activation
+- `test_deactivate_user_success` - Verify user deactivation
+- `test_activate_user_not_found` - Verify 404 handling
+
+---
+
+### 7. Jobs API Tests (`test_api_jobs.py`)
+
+#### Job Status Endpoints
+- `test_get_job_status_queued` - Verify queued job status
+- `test_get_job_status_started` - Verify started job status
+- `test_get_job_status_progress` - Verify progress status with metadata
+- `test_get_job_status_success` - Verify completed job status
+- `test_get_job_status_failed` - Verify failed job status
+- `test_get_job_status_cancelled` - Verify cancelled job status
+- `test_get_job_status_not_found` - Verify 404 for non-existent job
+- `test_get_job_status_includes_timestamps` - Verify timestamp fields
+
+#### Job Streaming Endpoints
+- `test_stream_job_progress_success` - Verify SSE stream works
+- `test_stream_job_progress_updates` - Verify progress updates in stream
+- `test_stream_job_progress_completion` - Verify completion event
+- `test_stream_job_progress_not_found` - Verify 404 handling
+- `test_stream_job_progress_sse_format` - Verify SSE format compliance
+
+---
+
+### 8. Health & Version API Tests (`test_api_health.py`)
+
+#### Health Check Endpoints
+- `test_health_check_success` - Verify health endpoint returns 200
+- `test_health_check_database_connection` - Verify DB connection check
+- `test_version_endpoint_success` - Verify version information
+- `test_version_endpoint_includes_app_version` - Verify version format
+- `test_metrics_endpoint_success` - Verify metrics endpoint (if applicable)
+
+---
+
+### 9. Integration Tests (`test_api_integration.py`)
+
+#### End-to-End Workflows
+- `test_photo_import_to_face_processing_to_identification_workflow` - Full photo import workflow
+- `test_create_person_identify_faces_auto_match_workflow` - Person creation to auto-match
+- `test_tag_photos_search_by_tags_workflow` - Tagging and search workflow
+- `test_favorite_photos_search_favorites_workflow` - Favorites workflow
+- `test_user_creation_login_role_permissions_workflow` - User management workflow
+- `test_bulk_operations_workflow` - Multiple bulk operations in sequence
+- `test_concurrent_requests_workflow` - Verify concurrent request handling
+
+---
+
+### 10. Error Handling & Edge Cases (`test_api_errors.py`)
+
+#### Error Response Tests
+- `test_404_not_found_responses` - Verify 404 responses across endpoints
+- `test_400_bad_request_validation` - Verify validation error responses
+- `test_401_unauthorized_responses` - Verify authentication errors
+- `test_403_forbidden_responses` - Verify authorization errors
+- `test_422_unprocessable_entity` - Verify unprocessable entity errors
+- `test_500_internal_server_error_handling` - Verify error handling
+- `test_database_connection_failure_handling` - Verify DB failure handling
+- `test_redis_connection_failure_handling` - Verify Redis failure handling
+- `test_file_operation_errors` - Verify file operation error handling
+- `test_concurrent_request_handling` - Verify concurrent operations
+- `test_large_payload_handling` - Verify handling of large requests
+- `test_sql_injection_attempts` - Verify SQL injection protection
+- `test_xss_attempts` - Verify XSS protection
+- `test_path_traversal_attempts` - Verify path traversal protection
+
+---
+
+## Test Infrastructure Setup
+
+### Test Configuration (`conftest.py`)
+
+The test suite requires a `conftest.py` file with the following fixtures:
+
+```python
+# tests/conftest.py
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from backend.app import create_app
+from backend.db.base import Base
+from backend.db.session import get_db
+
+# Test database URL (use separate test database)
+TEST_DATABASE_URL = "postgresql+psycopg2://postgres:postgres@localhost:5432/punimtag_test"
+
+@pytest.fixture(scope="session")
+def test_db_engine():
+ """Create test database engine."""
+ engine = create_engine(TEST_DATABASE_URL)
+ Base.metadata.create_all(bind=engine)
+ yield engine
+ Base.metadata.drop_all(bind=engine)
+
+@pytest.fixture(scope="function")
+def test_db_session(test_db_engine):
+ """Create a test database session with transaction rollback."""
+ connection = test_db_engine.connect()
+ transaction = connection.begin()
+ session = sessionmaker(bind=connection)()
+
+ yield session
+
+ session.close()
+ transaction.rollback()
+ connection.close()
+
+@pytest.fixture(scope="function")
+def test_client(test_db_session):
+ """Create a test client with test database."""
+ app = create_app()
+
+ def override_get_db():
+ yield test_db_session
+
+ app.dependency_overrides[get_db] = override_get_db
+
+ with TestClient(app) as client:
+ yield client
+
+ app.dependency_overrides.clear()
+
+@pytest.fixture
+def auth_token(test_client):
+ """Get authentication token for test user."""
+ response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "admin", "password": "admin"}
+ )
+ return response.json()["access_token"]
+
+@pytest.fixture
+def auth_headers(auth_token):
+ """Get authentication headers."""
+ return {"Authorization": f"Bearer {auth_token}"}
+
+@pytest.fixture
+def admin_user(test_db_session):
+ """Create an admin user for testing."""
+ from backend.db.models import User
+ from backend.utils.password import hash_password
+
+ user = User(
+ username="testadmin",
+ email="testadmin@example.com",
+ password_hash=hash_password("testpass"),
+ is_admin=True,
+ is_active=True,
+ )
+ test_db_session.add(user)
+ test_db_session.commit()
+ return user
+
+@pytest.fixture
+def regular_user(test_db_session):
+ """Create a regular user for testing."""
+ from backend.db.models import User
+ from backend.utils.password import hash_password
+
+ user = User(
+ username="testuser",
+ email="testuser@example.com",
+ password_hash=hash_password("testpass"),
+ is_admin=False,
+ is_active=True,
+ )
+ test_db_session.add(user)
+ test_db_session.commit()
+ return user
+```
+
+### Test Database Setup
+
+1. Create a separate test database:
+ ```sql
+ CREATE DATABASE punimtag_test;
+ ```
+
+2. Set test database URL in environment or test config:
+ ```bash
+ export DATABASE_URL="postgresql+psycopg2://postgres:postgres@localhost:5432/punimtag_test"
+ ```
+
+3. Ensure Redis is available for job-related tests (or mock it)
+
+---
+
+## Priority Recommendations
+
+### High Priority (Core Functionality)
+1. **Authentication** - Login, token refresh, password change
+2. **Photo Search** - All search types and filters
+3. **Face Identification** - Core face matching and identification
+4. **User Management** - Admin operations and role management
+
+### Medium Priority (Important Features)
+1. **Tag Operations** - CRUD and photo-tag relationships
+2. **People CRUD** - Person management
+3. **Job Status Tracking** - Background job monitoring
+4. **Bulk Operations** - Bulk favorites, deletions, etc.
+
+### Lower Priority (Nice to Have)
+1. **File Operations** - Browse folder, open folder (OS-dependent)
+2. **Maintenance Endpoints** - Advanced maintenance features
+3. **Edge Cases** - Comprehensive error handling tests
+
+---
+
+## Testing Best Practices
+
+1. **Use Fixtures** - Leverage pytest fixtures for common setup (database, auth tokens)
+2. **Test Both Paths** - Always test both success and failure scenarios
+3. **Test Authorization** - Verify admin vs regular user permissions
+4. **Test Pagination** - Verify pagination works for all list endpoints
+5. **Test Validation** - Test input validation (empty strings, invalid IDs, etc.)
+6. **Test Transactions** - Verify database transactions and rollbacks
+7. **Use Test Database** - Always use a separate test database
+8. **Clean Up** - Ensure test data is cleaned up after each test
+9. **Test Concurrency** - Test concurrent operations where relevant
+10. **Mock External Dependencies** - Mock Redis, file system when needed
+11. **Test Error Messages** - Verify error messages are helpful
+12. **Test Response Formats** - Verify response schemas match expectations
+13. **Test Edge Cases** - Test boundary conditions and edge cases
+14. **Test Performance** - Consider performance tests for critical endpoints
+15. **Test Security** - Test authentication, authorization, and input sanitization
+
+---
+
+## Running Tests
+
+### Run All Tests
+```bash
+npm run test:backend
+# or
+pytest tests/ -v
+```
+
+### Run Specific Test File
+```bash
+pytest tests/test_api_auth.py -v
+```
+
+### Run Specific Test
+```bash
+pytest tests/test_api_auth.py::test_login_success_with_valid_credentials -v
+```
+
+### Run with Coverage
+```bash
+pytest tests/ --cov=backend --cov-report=html
+```
+
+### Run in CI
+The CI workflow (`.gitea/workflows/ci.yml`) already includes a `test-backend` job that runs:
+```bash
+python -m pytest tests/ -v
+```
+
+---
+
+## Test Coverage Goals
+
+- **Minimum Coverage**: 80% (as per project rules)
+- **Critical Endpoints**: 100% coverage (auth, photo search, face identification)
+- **All Endpoints**: At least basic success/failure tests
+
+---
+
+## Notes
+
+- Tests should be independent and not rely on execution order
+- Use transaction rollback to ensure test isolation
+- Mock external services (Redis, file system) when appropriate
+- Use factories or fixtures for test data creation
+- Keep tests fast - avoid unnecessary I/O operations
+- Document complex test scenarios with comments
+
diff --git a/tests/CI_TEST_SETUP.md b/tests/CI_TEST_SETUP.md
new file mode 100644
index 0000000..42ade07
--- /dev/null
+++ b/tests/CI_TEST_SETUP.md
@@ -0,0 +1,179 @@
+# CI Test Setup Documentation
+
+This document describes how the authentication tests and other backend tests are configured to run in CI.
+
+## CI Workflow Configuration
+
+The CI workflow (`.gitea/workflows/ci.yml`) has been updated to include:
+
+### Test Database Setup
+
+1. **PostgreSQL Service**: The CI uses a PostgreSQL 15 service container
+ - Database: `punimtag_test` (main database)
+ - Auth Database: `punimtag_auth_test` (auth database)
+ - User: `postgres`
+ - Password: `postgres`
+
+2. **Database Creation**: Explicit database creation step ensures databases exist
+ ```yaml
+ - name: Create test databases
+ run: |
+ export PGPASSWORD=postgres
+ psql -h postgres -U postgres -c "CREATE DATABASE punimtag_test;" || true
+ psql -h postgres -U postgres -c "CREATE DATABASE punimtag_auth_test;" || true
+ ```
+
+3. **Schema Initialization**: Database schemas are initialized before tests run
+ - Main database: All tables created via SQLAlchemy Base.metadata
+ - Auth database: Tables created via SQL scripts
+
+### Test Dependencies
+
+The following testing dependencies are installed:
+- `pytest>=7.4.0` - Test framework
+- `httpx>=0.24.0` - HTTP client for FastAPI TestClient
+- `pytest-cov>=4.1.0` - Coverage reporting
+
+These are installed via:
+1. `requirements.txt` (for local development)
+2. Explicit pip install in CI (for redundancy)
+
+### Test Execution
+
+The CI runs tests in two steps:
+
+1. **All Backend Tests**:
+ ```bash
+ pytest tests/ -v --tb=short --cov=backend --cov-report=term-missing --cov-report=xml
+ ```
+ - Runs all tests in the `tests/` directory
+ - Generates coverage report
+ - Uses short traceback format
+
+2. **Authentication Tests** (specific step):
+ ```bash
+ pytest tests/test_api_auth.py -v --tb=short --junit-xml=test-results-auth.xml
+ ```
+ - Runs only authentication tests
+ - Generates JUnit XML for test reporting
+ - Provides focused output for authentication tests
+
+### Environment Variables
+
+The following environment variables are set in CI:
+- `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`
+- `PYTHONPATH`: Set to project root
+
+### Test Results
+
+- Tests use `continue-on-error: true` to allow CI to complete even if tests fail
+- Test results are logged to console
+- JUnit XML output is generated for test reporting tools
+- Coverage reports are generated (terminal and XML formats)
+
+## Running Tests Locally
+
+To run the same tests locally:
+
+1. **Set up test database**:
+ ```bash
+ # Create test database
+ createdb punimtag_test
+ createdb punimtag_auth_test
+ ```
+
+2. **Set environment variables**:
+ ```bash
+ export DATABASE_URL="postgresql+psycopg2://postgres:postgres@localhost:5432/punimtag_test"
+ export DATABASE_URL_AUTH="postgresql+psycopg2://postgres:postgres@localhost:5432/punimtag_auth_test"
+ export PYTHONPATH=$(pwd)
+ ```
+
+3. **Install dependencies**:
+ ```bash
+ pip install -r requirements.txt
+ ```
+
+4. **Run tests**:
+ ```bash
+ # Run all tests
+ pytest tests/ -v
+
+ # Run only authentication tests
+ pytest tests/test_api_auth.py -v
+
+ # Run with coverage
+ pytest tests/ --cov=backend --cov-report=html
+ ```
+
+## Test Structure
+
+### Test Files
+- `tests/conftest.py` - Test fixtures and configuration
+- `tests/test_api_auth.py` - Authentication API tests
+- `tests/API_TEST_PLAN.md` - Comprehensive test plan
+
+### Test Fixtures
+
+The `conftest.py` provides:
+- `test_db_engine` - Database engine (session scope)
+- `test_db_session` - Database session with rollback (function scope)
+- `test_client` - FastAPI test client (function scope)
+- `admin_user` - Admin user fixture
+- `regular_user` - Regular user fixture
+- `inactive_user` - Inactive user fixture
+- `auth_token` - Authentication token for admin
+- `regular_auth_token` - Authentication token for regular user
+- `auth_headers` - Authorization headers for admin
+- `regular_auth_headers` - Authorization headers for regular user
+
+## Troubleshooting
+
+### Tests Fail in CI
+
+1. **Check database connection**:
+ - Verify PostgreSQL service is running
+ - Check database URLs are correct
+ - Ensure databases exist
+
+2. **Check dependencies**:
+ - Verify pytest, httpx, and pytest-cov are installed
+ - Check requirements.txt is up to date
+
+3. **Check test database state**:
+ - Tests use transaction rollback, so database should be clean
+ - If issues persist, check for schema mismatches
+
+### Database Connection Issues
+
+If tests fail with database connection errors:
+- Verify `DATABASE_URL` environment variable is set
+- Check PostgreSQL service is accessible
+- Ensure database exists and user has permissions
+
+### Import Errors
+
+If tests fail with import errors:
+- Verify `PYTHONPATH` is set to project root
+- Check all dependencies are installed
+- Ensure test files are in `tests/` directory
+
+## Next Steps
+
+1. Add more high-priority test files:
+ - `test_api_photos.py` - Photo search tests
+ - `test_api_faces.py` - Face identification tests
+ - `test_api_users.py` - User management tests
+
+2. Improve test coverage:
+ - Add integration tests
+ - Add error handling tests
+ - Add performance tests
+
+3. Enhance CI reporting:
+ - Add test result artifacts
+ - Add coverage badge
+ - Add test summary to PR comments
+
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..3510603
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,114 @@
+# Running Backend API Tests
+
+## Quick Start
+
+### Option 1: Using the test runner script (Recommended)
+```bash
+./run_tests.sh
+```
+
+### Option 2: Using npm script
+```bash
+npm run test:backend
+```
+
+### Option 3: Manual command
+```bash
+export PYTHONPATH=$(pwd)
+export SKIP_DEEPFACE_IN_TESTS=1
+./venv/bin/python3 -m pytest tests/ -v
+```
+
+## Where to See Test Results
+
+**Test results are displayed in your terminal/console** where you run the command.
+
+### Example Output
+
+When tests run successfully, you'll see output like:
+
+```
+tests/test_api_auth.py::TestLogin::test_login_success_with_valid_credentials PASSED
+tests/test_api_auth.py::TestLogin::test_login_failure_with_invalid_credentials PASSED
+tests/test_api_auth.py::TestTokenRefresh::test_refresh_token_success PASSED
+...
+========================= 26 passed in 2.34s =========================
+```
+
+### Understanding the Output
+
+- **PASSED** (green) - Test passed successfully
+- **FAILED** (red) - Test failed (shows error details)
+- **ERROR** (red) - Test had an error during setup/teardown
+- **SKIPPED** (yellow) - Test was skipped
+
+### Verbose Output
+
+The `-v` flag shows:
+- Each test function name
+- Pass/fail status for each test
+- Summary at the end
+
+### Detailed Failure Information
+
+If a test fails, pytest shows:
+- The test that failed
+- The assertion that failed
+- The actual vs expected values
+- A traceback showing where the error occurred
+
+## Test Coverage
+
+To see coverage report:
+```bash
+export PYTHONPATH=$(pwd)
+export SKIP_DEEPFACE_IN_TESTS=1
+./venv/bin/python3 -m pytest tests/ --cov=backend --cov-report=term-missing
+```
+
+This shows:
+- Which lines of code are covered by tests
+- Which lines are missing coverage
+- Overall coverage percentage
+
+## Running Specific Tests
+
+### Run a single test file
+```bash
+./venv/bin/python3 -m pytest tests/test_api_auth.py -v
+```
+
+### Run a specific test class
+```bash
+./venv/bin/python3 -m pytest tests/test_api_auth.py::TestLogin -v
+```
+
+### Run a specific test
+```bash
+./venv/bin/python3 -m pytest tests/test_api_auth.py::TestLogin::test_login_success_with_valid_credentials -v
+```
+
+## CI/CD Test Results
+
+In CI (GitHub Actions/Gitea Actions), test results appear in:
+1. **CI Logs** - Check the "Run backend tests" step in the workflow
+2. **Test Artifacts** - JUnit XML files are generated for test reporting tools
+3. **Coverage Reports** - Coverage XML files are generated
+
+## Troubleshooting
+
+### Tests not showing output?
+- Make sure you're running in a terminal (not an IDE output panel that might hide output)
+- Try adding `-s` flag: `pytest tests/ -v -s` (shows print statements)
+
+### Tests hanging?
+- Check if database is accessible
+- Verify `SKIP_DEEPFACE_IN_TESTS=1` is set (prevents DeepFace from loading)
+
+### Import errors?
+- Make sure virtual environment is activated or use `./venv/bin/python3`
+- Verify all dependencies are installed: `./venv/bin/pip install -r requirements.txt`
+
+
+
+
diff --git a/tests/README_TESTING.md b/tests/README_TESTING.md
deleted file mode 100644
index bf9eab1..0000000
--- a/tests/README_TESTING.md
+++ /dev/null
@@ -1,690 +0,0 @@
-# PunimTag Testing Guide
-
-**Version:** 1.0
-**Date:** October 16, 2025
-**Phase:** 6 - Testing and Validation
-
----
-
-## Table of Contents
-
-1. [Overview](#overview)
-2. [Test Suite Structure](#test-suite-structure)
-3. [Running Tests](#running-tests)
-4. [Test Categories](#test-categories)
-5. [Test Details](#test-details)
-6. [Interpreting Results](#interpreting-results)
-7. [Troubleshooting](#troubleshooting)
-8. [Adding New Tests](#adding-new-tests)
-
----
-
-## Overview
-
-This guide explains the comprehensive test suite for PunimTag's DeepFace integration. The test suite validates all aspects of the migration from face_recognition to DeepFace, ensuring functionality, performance, and reliability.
-
-### Test Philosophy
-
-- **Automated**: Tests run without manual intervention
-- **Comprehensive**: Cover all critical functionality
-- **Fast**: Complete in reasonable time for CI/CD
-- **Reliable**: Consistent results across runs
-- **Informative**: Clear pass/fail with diagnostic info
-
----
-
-## Test Suite Structure
-
-```
-tests/
-├── test_deepface_integration.py # Main Phase 6 test suite (10 tests)
-├── test_deepface_gui.py # GUI comparison tests (reference)
-├── test_deepface_only.py # DeepFace-only tests (reference)
-├── test_face_recognition.py # Legacy tests
-├── README_TESTING.md # This file
-└── demo_photos/ # Test images (required)
-```
-
-### Test Files
-
-- **test_deepface_integration.py**: Primary test suite for Phase 6 validation
-- **test_deepface_gui.py**: Reference implementation with GUI tests
-- **test_deepface_only.py**: DeepFace library tests without GUI
-- **test_face_recognition.py**: Legacy face_recognition tests
-
----
-
-## Running Tests
-
-### Prerequisites
-
-1. **Install Dependencies**
- ```bash
- pip install -r requirements.txt
- ```
-
-2. **Verify Demo Photos**
- ```bash
- ls demo_photos/*.jpg
- # Should show: 2019-11-22_0011.jpg, 2019-11-22_0012.jpg, etc.
- ```
-
-3. **Check DeepFace Installation**
- ```bash
- python -c "from deepface import DeepFace; print('DeepFace OK')"
- ```
-
-### Running the Full Test Suite
-
-```bash
-# Navigate to project root
-cd /home/ladmin/Code/punimtag
-
-# Run Phase 6 integration tests
-python tests/test_deepface_integration.py
-```
-
-### Running Individual Tests
-
-```python
-# In Python shell or script
-from tests.test_deepface_integration import test_face_detection
-
-# Run specific test
-result = test_face_detection()
-print("Passed!" if result else "Failed!")
-```
-
-### Running with Verbose Output
-
-```bash
-# Add debugging output
-python -u tests/test_deepface_integration.py 2>&1 | tee test_results.log
-```
-
-### Expected Runtime
-
-- **Full Suite**: ~30-60 seconds (depends on hardware)
-- **Individual Test**: ~3-10 seconds
-- **With GPU**: Faster inference times
-- **First Run**: +2-5 minutes (model downloads)
-
----
-
-## Test Categories
-
-### 1. Core Functionality Tests
-- Face Detection
-- Face Matching
-- Metadata Storage
-
-### 2. Configuration Tests
-- FaceProcessor Initialization
-- Multiple Detector Backends
-
-### 3. Algorithm Tests
-- Cosine Similarity
-- Adaptive Tolerance
-
-### 4. Data Tests
-- Database Schema
-- Face Location Format
-
-### 5. Performance Tests
-- Performance Benchmark
-
----
-
-## Test Details
-
-### Test 1: Face Detection
-
-**Purpose:** Verify DeepFace detects faces correctly
-
-**What it tests:**
-- Face detection with default detector (retinaface)
-- Photo processing workflow
-- Face encoding generation (512-dimensional)
-- Database storage
-
-**Pass Criteria:**
-- At least 1 face detected in test image
-- Encoding size = 4096 bytes (512 floats × 8)
-- No exceptions during processing
-
-**Failure Modes:**
-- Image file not found
-- No faces detected (possible with poor quality images)
-- Wrong encoding size
-- Database errors
-
----
-
-### Test 2: Face Matching
-
-**Purpose:** Verify face similarity matching works
-
-**What it tests:**
-- Processing multiple photos
-- Finding similar faces
-- Similarity calculation
-- Match confidence scoring
-
-**Pass Criteria:**
-- Multiple photos processed successfully
-- Similar faces found within tolerance
-- Confidence scores reasonable (0-100%)
-- Match results consistent
-
-**Failure Modes:**
-- Not enough test images
-- No faces detected
-- Similarity calculation errors
-- No matches found (tolerance too strict)
-
----
-
-### Test 3: Metadata Storage
-
-**Purpose:** Verify DeepFace metadata stored correctly
-
-**What it tests:**
-- face_confidence column storage
-- detector_backend column storage
-- model_name column storage
-- quality_score calculation
-
-**Pass Criteria:**
-- All metadata fields populated
-- Detector matches configuration
-- Model matches configuration
-- Values within expected ranges
-
-**Failure Modes:**
-- Missing columns
-- NULL values in metadata
-- Mismatched detector/model
-- Invalid data types
-
----
-
-### Test 4: Configuration
-
-**Purpose:** Verify FaceProcessor configuration flexibility
-
-**What it tests:**
-- Default configuration
-- Custom detector backends
-- Custom models
-- Configuration application
-
-**Pass Criteria:**
-- Default values match config.py
-- Custom values applied correctly
-- All detector options work
-- Configuration persists
-
-**Failure Modes:**
-- Configuration not applied
-- Invalid detector/model accepted
-- Configuration mismatch
-- Initialization errors
-
----
-
-### Test 5: Cosine Similarity
-
-**Purpose:** Verify similarity calculation accuracy
-
-**What it tests:**
-- Identical encoding distance (should be ~0)
-- Different encoding distance (should be >0)
-- Mismatched length handling
-- Normalization and scaling
-
-**Pass Criteria:**
-- Identical encodings: distance < 0.01
-- Different encodings: distance > 0.1
-- Mismatched lengths: distance = 2.0
-- No calculation errors
-
-**Failure Modes:**
-- Identical encodings not similar
-- Different encodings too similar
-- Division by zero
-- Numerical instability
-
----
-
-### Test 6: Database Schema
-
-**Purpose:** Verify database schema updates correct
-
-**What it tests:**
-- New columns in faces table
-- New columns in person_encodings table
-- Column data types
-- Schema consistency
-
-**Pass Criteria:**
-- All required columns exist
-- Data types correct (TEXT, REAL)
-- Schema matches migration plan
-- No missing columns
-
-**Failure Modes:**
-- Missing columns
-- Wrong data types
-- Migration not applied
-- Schema corruption
-
----
-
-### Test 7: Face Location Format
-
-**Purpose:** Verify DeepFace location format {x, y, w, h}
-
-**What it tests:**
-- Location stored as dict string
-- Location parsing
-- Required keys present (x, y, w, h)
-- Format consistency
-
-**Pass Criteria:**
-- Location is dict with 4 keys
-- Values are numeric
-- Format parseable
-- Consistent across faces
-
-**Failure Modes:**
-- Wrong format (tuple instead of dict)
-- Missing keys
-- Parse errors
-- Invalid values
-
----
-
-### Test 8: Performance Benchmark
-
-**Purpose:** Measure and validate performance
-
-**What it tests:**
-- Face detection speed
-- Similarity search speed
-- Scaling with photo count
-- Resource usage
-
-**Pass Criteria:**
-- Processing completes in reasonable time
-- No crashes or hangs
-- Performance metrics reported
-- Consistent across runs
-
-**Failure Modes:**
-- Excessive processing time
-- Memory exhaustion
-- Performance degradation
-- Timeout errors
-
----
-
-### Test 9: Adaptive Tolerance
-
-**Purpose:** Verify adaptive tolerance calculation
-
-**What it tests:**
-- Quality-based tolerance adjustment
-- Confidence-based tolerance adjustment
-- Bounds enforcement [0.2, 0.6]
-- Tolerance calculation logic
-
-**Pass Criteria:**
-- Tolerance adjusts with quality
-- Higher quality = stricter tolerance
-- Tolerance stays within bounds
-- Calculation consistent
-
-**Failure Modes:**
-- Tolerance out of bounds
-- No quality adjustment
-- Calculation errors
-- Incorrect formula
-
----
-
-### Test 10: Multiple Detectors
-
-**Purpose:** Verify multiple detector backends work
-
-**What it tests:**
-- opencv detector
-- ssd detector
-- (retinaface tested in Test 1)
-- (mtcnn available but slower)
-- Detector-specific results
-
-**Pass Criteria:**
-- At least one detector finds faces
-- No detector crashes
-- Results recorded
-- Different detectors work
-
-**Failure Modes:**
-- All detectors fail
-- Detector not available
-- Configuration errors
-- Missing dependencies
-
----
-
-## Interpreting Results
-
-### Success Output
-
-```
-======================================================================
-DEEPFACE INTEGRATION TEST SUITE - PHASE 6
-======================================================================
-
-Testing complete DeepFace integration in PunimTag
-This comprehensive test suite validates all aspects of the migration
-
-============================================================
-Test 1: DeepFace Face Detection
-============================================================
-Testing with image: demo_photos/2019-11-22_0011.jpg
-✓ Added photo to database (ID: 1)
-📸 Processing: 2019-11-22_0011.jpg
- 👤 Found 2 faces
-✓ Processed 1 photos
-✓ Found 2 faces in the photo
-✓ Encoding size: 4096 bytes (expected: 4096)
-
-✅ PASS: Face detection working correctly
-
-[... more tests ...]
-
-======================================================================
-TEST SUMMARY
-======================================================================
-✅ PASS: Face Detection
-✅ PASS: Face Matching
-✅ PASS: Metadata Storage
-✅ PASS: Configuration
-✅ PASS: Cosine Similarity
-✅ PASS: Database Schema
-✅ PASS: Face Location Format
-✅ PASS: Performance Benchmark
-✅ PASS: Adaptive Tolerance
-✅ PASS: Multiple Detectors
-======================================================================
-Tests passed: 10/10
-Tests failed: 0/10
-======================================================================
-
-🎉 ALL TESTS PASSED! DeepFace integration is working correctly!
-```
-
-### Failure Output
-
-```
-❌ FAIL: Face detection working correctly
-
-Error: No faces detected in test image
-
-[Traceback ...]
-```
-
-### Warning Output
-
-```
-⚠️ Test image not found: demo_photos/2019-11-22_0011.jpg
- Please ensure demo photos are available
-```
-
----
-
-## Troubleshooting
-
-### Common Issues
-
-#### 1. Test Images Not Found
-
-**Problem:**
-```
-❌ Test image not found: demo_photos/2019-11-22_0011.jpg
-```
-
-**Solution:**
-- Verify demo_photos directory exists
-- Check image filenames
-- Ensure running from project root
-
-#### 2. DeepFace Import Error
-
-**Problem:**
-```
-ImportError: No module named 'deepface'
-```
-
-**Solution:**
-```bash
-pip install deepface tensorflow opencv-python retina-face
-```
-
-#### 3. TensorFlow Warnings
-
-**Problem:**
-```
-TensorFlow: Could not load dynamic library 'libcudart.so.11.0'
-```
-
-**Solution:**
-- Expected on CPU-only systems
-- Warnings suppressed in config.py
-- Does not affect functionality
-
-#### 4. Model Download Timeout
-
-**Problem:**
-```
-TimeoutError: Failed to download ArcFace model
-```
-
-**Solution:**
-- Check internet connection
-- Models stored in ~/.deepface/weights/
-- Retry after network issues resolved
-
-#### 5. Memory Error
-
-**Problem:**
-```
-MemoryError: Unable to allocate array
-```
-
-**Solution:**
-- Close other applications
-- Use smaller test images
-- Increase system memory
-- Process fewer images at once
-
-#### 6. Database Locked
-
-**Problem:**
-```
-sqlite3.OperationalError: database is locked
-```
-
-**Solution:**
-- Close other database connections
-- Stop running dashboard
-- Use in-memory database for tests
-
----
-
-## Adding New Tests
-
-### Test Template
-
-```python
-def test_new_feature():
- """Test X: Description of what this tests"""
- print("\n" + "="*60)
- print("Test X: Test Name")
- print("="*60)
-
- try:
- # Setup
- db = DatabaseManager(":memory:", verbose=0)
- processor = FaceProcessor(db, verbose=0)
-
- # Test logic
- result = some_operation()
-
- # Verification
- if result != expected:
- print(f"❌ FAIL: {explanation}")
- return False
-
- print(f"✓ {success_message}")
- print("\n✅ PASS: Test passed")
- return True
-
- except Exception as e:
- print(f"\n❌ FAIL: {e}")
- import traceback
- traceback.print_exc()
- return False
-```
-
-### Adding to Test Suite
-
-1. Write test function following template
-2. Add to `tests` list in `run_all_tests()`
-3. Update test count in documentation
-4. Run test suite to verify
-
-### Best Practices
-
-- **Clear naming**: `test_what_is_being_tested`
-- **Good documentation**: Explain purpose and expectations
-- **Proper cleanup**: Use in-memory DB or cleanup after test
-- **Informative output**: Print progress and results
-- **Error handling**: Catch and report exceptions
-- **Return boolean**: True = pass, False = fail
-
----
-
-## Test Data Requirements
-
-### Required Files
-
-```
-demo_photos/
-├── 2019-11-22_0011.jpg # Primary test image (required)
-├── 2019-11-22_0012.jpg # Secondary test image (required)
-├── 2019-11-22_0015.jpg # Additional test image (optional)
-└── 2019-11-22_0017.jpg # Additional test image (optional)
-```
-
-### Image Requirements
-
-- **Format**: JPG, JPEG, PNG
-- **Size**: At least 640x480 pixels
-- **Content**: Should contain 1+ faces
-- **Quality**: Good lighting, clear faces
-- **Variety**: Different poses, ages, expressions
-
----
-
-## Continuous Integration
-
-### GitHub Actions Setup
-
-```yaml
-name: DeepFace Tests
-
-on: [push, pull_request]
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-python@v2
- with:
- python-version: '3.12'
- - run: pip install -r requirements.txt
- - run: python tests/test_deepface_integration.py
-```
-
-### Pre-commit Hook
-
-```bash
-#!/bin/bash
-# .git/hooks/pre-commit
-
-echo "Running DeepFace tests..."
-python tests/test_deepface_integration.py
-
-if [ $? -ne 0 ]; then
- echo "Tests failed. Commit aborted."
- exit 1
-fi
-```
-
----
-
-## Performance Benchmarks
-
-### Expected Performance (Reference Hardware)
-
-**System:** Intel i7-10700K, 32GB RAM, RTX 3080
-
-| Operation | Time (avg) | Notes |
-|--------------------------|-----------|--------------------------|
-| Face Detection (1 photo) | 2-3s | RetinaFace detector |
-| Face Detection (1 photo) | 0.5-1s | OpenCV detector |
-| Face Encoding | 0.5s | ArcFace model |
-| Similarity Search | 0.01-0.1s | Per face comparison |
-| Full Test Suite | 30-45s | All 10 tests |
-
-**Note:** First run adds 2-5 minutes for model downloads
-
----
-
-## Test Coverage Report
-
-### Current Coverage
-
-- **Core Functionality**: 100%
-- **Database Operations**: 100%
-- **Configuration**: 100%
-- **Error Handling**: 80%
-- **GUI Integration**: 0% (manual testing required)
-- **Overall**: ~85%
-
-### Future Test Additions
-
-- GUI integration tests
-- Load testing (1000+ photos)
-- Stress testing (concurrent operations)
-- Edge case testing (corrupted images, etc.)
-- Backward compatibility tests
-
----
-
-## References
-
-- [DeepFace Documentation](https://github.com/serengil/deepface)
-- [ArcFace Paper](https://arxiv.org/abs/1801.07698)
-- [Phase 6 Validation Checklist](../PHASE6_VALIDATION_CHECKLIST.md)
-- [DeepFace Migration Plan](../.notes/deepface_migration_plan.md)
-
----
-
-**Last Updated:** October 16, 2025
-**Maintained By:** PunimTag Development Team
-**Questions?** Check troubleshooting or raise an issue
-
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..f28a63a
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,327 @@
+"""Test configuration and fixtures for PunimTag backend tests."""
+
+from __future__ import annotations
+
+import os
+from typing import Generator
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker, Session
+
+# Prevent DeepFace/TensorFlow from loading during tests (causes illegal instruction on some CPUs)
+# Set environment variable BEFORE any backend imports that might trigger DeepFace/TensorFlow
+os.environ["SKIP_DEEPFACE_IN_TESTS"] = "1"
+
+from backend.app import create_app
+from backend.db.base import Base
+from backend.db.session import get_db
+
+# Test database URL - use environment variable or default
+TEST_DATABASE_URL = os.getenv(
+ "DATABASE_URL",
+ "postgresql+psycopg2://postgres:postgres@localhost:5432/punimtag_test"
+)
+
+
+@pytest.fixture(scope="session")
+def test_db_engine():
+ """Create test database engine and initialize schema."""
+ engine = create_engine(TEST_DATABASE_URL, future=True)
+
+ # Create all tables
+ Base.metadata.create_all(bind=engine)
+
+ yield engine
+
+ # Cleanup: drop all tables after tests
+ Base.metadata.drop_all(bind=engine)
+ engine.dispose()
+
+
+@pytest.fixture(scope="function")
+def test_db_session(test_db_engine) -> Generator[Session, None, None]:
+ """Create a test database session with transaction rollback.
+
+ Each test gets a fresh session that rolls back after the test completes.
+ """
+ connection = test_db_engine.connect()
+ transaction = connection.begin()
+ session = sessionmaker(bind=connection, autoflush=False, autocommit=False)()
+
+ yield session
+
+ # Rollback transaction and close connection
+ session.close()
+ transaction.rollback()
+ connection.close()
+
+
+@pytest.fixture(scope="function")
+def test_client(test_db_session: Session) -> Generator[TestClient, None, None]:
+ """Create a test client with test database dependency override."""
+ app = create_app()
+
+ def override_get_db() -> Generator[Session, None, None]:
+ yield test_db_session
+
+ app.dependency_overrides[get_db] = override_get_db
+
+ with TestClient(app) as client:
+ yield client
+
+ # Clear dependency overrides after test
+ app.dependency_overrides.clear()
+
+
+@pytest.fixture
+def admin_user(test_db_session: Session):
+ """Create an admin user for testing."""
+ from backend.db.models import User
+ from backend.utils.password import hash_password
+ from backend.constants.roles import DEFAULT_ADMIN_ROLE
+
+ user = User(
+ username="testadmin",
+ email="testadmin@example.com",
+ password_hash=hash_password("testpass"),
+ full_name="Test Admin",
+ is_admin=True,
+ is_active=True,
+ role=DEFAULT_ADMIN_ROLE,
+ )
+ test_db_session.add(user)
+ test_db_session.commit()
+ test_db_session.refresh(user)
+ return user
+
+
+@pytest.fixture
+def regular_user(test_db_session: Session):
+ """Create a regular user for testing."""
+ from backend.db.models import User
+ from backend.utils.password import hash_password
+ from backend.constants.roles import DEFAULT_USER_ROLE
+
+ user = User(
+ username="testuser",
+ email="testuser@example.com",
+ password_hash=hash_password("testpass"),
+ full_name="Test User",
+ is_admin=False,
+ is_active=True,
+ role=DEFAULT_USER_ROLE,
+ )
+ test_db_session.add(user)
+ test_db_session.commit()
+ test_db_session.refresh(user)
+ return user
+
+
+@pytest.fixture
+def inactive_user(test_db_session: Session):
+ """Create an inactive user for testing."""
+ from backend.db.models import User
+ from backend.utils.password import hash_password
+ from backend.constants.roles import DEFAULT_USER_ROLE
+
+ user = User(
+ username="inactiveuser",
+ email="inactiveuser@example.com",
+ password_hash=hash_password("testpass"),
+ full_name="Inactive User",
+ is_admin=False,
+ is_active=False,
+ role=DEFAULT_USER_ROLE,
+ )
+ test_db_session.add(user)
+ test_db_session.commit()
+ test_db_session.refresh(user)
+ return user
+
+
+@pytest.fixture
+def auth_token(test_client: TestClient, admin_user) -> str:
+ """Get authentication token for admin user."""
+ response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "testadmin", "password": "testpass"}
+ )
+ assert response.status_code == 200
+ return response.json()["access_token"]
+
+
+@pytest.fixture
+def regular_auth_token(test_client: TestClient, regular_user) -> str:
+ """Get authentication token for regular user."""
+ response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "testuser", "password": "testpass"}
+ )
+ assert response.status_code == 200
+ return response.json()["access_token"]
+
+
+@pytest.fixture
+def auth_headers(auth_token: str) -> dict[str, str]:
+ """Get authentication headers for admin user."""
+ return {"Authorization": f"Bearer {auth_token}"}
+
+
+@pytest.fixture
+def regular_auth_headers(regular_auth_token: str) -> dict[str, str]:
+ """Get authentication headers for regular user."""
+ return {"Authorization": f"Bearer {regular_auth_token}"}
+
+
+@pytest.fixture
+def test_photo(test_db_session: Session):
+ """Create a test photo."""
+ from backend.db.models import Photo
+ from datetime import date
+
+ photo = Photo(
+ path="/test/path/photo1.jpg",
+ filename="photo1.jpg",
+ date_taken=date(2024, 1, 15),
+ processed=True,
+ media_type="image",
+ )
+ test_db_session.add(photo)
+ test_db_session.commit()
+ test_db_session.refresh(photo)
+ return photo
+
+
+@pytest.fixture
+def test_photo_2(test_db_session: Session):
+ """Create a second test photo."""
+ from backend.db.models import Photo
+ from datetime import date
+
+ photo = Photo(
+ path="/test/path/photo2.jpg",
+ filename="photo2.jpg",
+ date_taken=date(2024, 1, 16),
+ processed=True,
+ media_type="image",
+ )
+ test_db_session.add(photo)
+ test_db_session.commit()
+ test_db_session.refresh(photo)
+ return photo
+
+
+@pytest.fixture
+def test_face(test_db_session: Session, test_photo):
+ """Create a test face (unidentified)."""
+ from backend.db.models import Face
+ import numpy as np
+
+ # Create a dummy encoding (128-dimensional vector like DeepFace)
+ encoding = np.random.rand(128).astype(np.float32).tobytes()
+
+ face = Face(
+ photo_id=test_photo.id,
+ person_id=None, # Unidentified
+ encoding=encoding,
+ location='{"x": 100, "y": 100, "w": 200, "h": 200}',
+ quality_score=0.85,
+ face_confidence=0.95,
+ detector_backend="retinaface",
+ model_name="VGG-Face",
+ pose_mode="frontal",
+ excluded=False,
+ )
+ test_db_session.add(face)
+ test_db_session.commit()
+ test_db_session.refresh(face)
+ return face
+
+
+@pytest.fixture
+def test_face_2(test_db_session: Session, test_photo_2):
+ """Create a second test face (unidentified)."""
+ from backend.db.models import Face
+ import numpy as np
+
+ # Create a similar encoding (for similarity testing)
+ encoding = np.random.rand(128).astype(np.float32).tobytes()
+
+ face = Face(
+ photo_id=test_photo_2.id,
+ person_id=None, # Unidentified
+ encoding=encoding,
+ location='{"x": 150, "y": 150, "w": 200, "h": 200}',
+ quality_score=0.80,
+ face_confidence=0.90,
+ detector_backend="retinaface",
+ model_name="VGG-Face",
+ pose_mode="frontal",
+ excluded=False,
+ )
+ test_db_session.add(face)
+ test_db_session.commit()
+ test_db_session.refresh(face)
+ return face
+
+
+@pytest.fixture
+def test_person(test_db_session: Session):
+ """Create a test person."""
+ from backend.db.models import Person
+ from datetime import date, datetime
+
+ person = Person(
+ first_name="John",
+ last_name="Doe",
+ middle_name="Middle",
+ maiden_name=None,
+ date_of_birth=date(1990, 1, 1),
+ created_date=datetime.utcnow(),
+ )
+ test_db_session.add(person)
+ test_db_session.commit()
+ test_db_session.refresh(person)
+ return person
+
+
+@pytest.fixture
+def identified_face(test_db_session: Session, test_photo, test_person):
+ """Create an identified face (already linked to a person)."""
+ from backend.db.models import Face, PersonEncoding
+ import numpy as np
+
+ # Create encoding
+ encoding = np.random.rand(128).astype(np.float32).tobytes()
+
+ face = Face(
+ photo_id=test_photo.id,
+ person_id=test_person.id,
+ encoding=encoding,
+ location='{"x": 200, "y": 200, "w": 200, "h": 200}',
+ quality_score=0.90,
+ face_confidence=0.98,
+ detector_backend="retinaface",
+ model_name="VGG-Face",
+ pose_mode="frontal",
+ excluded=False,
+ )
+ test_db_session.add(face)
+ test_db_session.flush()
+
+ # Create person encoding
+ person_encoding = PersonEncoding(
+ person_id=test_person.id,
+ face_id=face.id,
+ encoding=encoding,
+ quality_score=0.90,
+ detector_backend="retinaface",
+ model_name="VGG-Face",
+ )
+ test_db_session.add(person_encoding)
+ test_db_session.commit()
+ test_db_session.refresh(face)
+ return face
+
diff --git a/tests/debug_face_detection.py b/tests/debug_face_detection.py
deleted file mode 100644
index 107404e..0000000
--- a/tests/debug_face_detection.py
+++ /dev/null
@@ -1,64 +0,0 @@
-#!/usr/bin/env python3
-"""
-Debug face detection to see what's happening
-"""
-
-import os
-from pathlib import Path
-from PIL import Image
-import numpy as np
-
-# Suppress TensorFlow warnings
-os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
-
-def debug_face_detection():
- from deepface import DeepFace
-
- # Test with the reference image
- test_image = "demo_photos/testdeepface/2019-11-22_0011.jpg"
-
- if not os.path.exists(test_image):
- print(f"Test image not found: {test_image}")
- return
-
- print(f"Testing face detection on: {test_image}")
-
- # Load and display image info
- img = Image.open(test_image)
- print(f"Image size: {img.size}")
-
- # Try different detection methods
- detectors = ['opencv', 'mtcnn', 'retinaface', 'ssd']
-
- for detector in detectors:
- print(f"\n--- Testing {detector} detector ---")
- try:
- # Try extract_faces first
- faces = DeepFace.extract_faces(
- img_path=test_image,
- detector_backend=detector,
- enforce_detection=False,
- align=True
- )
- print(f"extract_faces found {len(faces)} faces")
-
- # Try represent
- results = DeepFace.represent(
- img_path=test_image,
- model_name='ArcFace',
- detector_backend=detector,
- enforce_detection=False,
- align=True
- )
- print(f"represent found {len(results)} results")
-
- if results:
- for i, result in enumerate(results):
- region = result.get('region', {})
- print(f" Result {i}: region={region}")
-
- except Exception as e:
- print(f"Error with {detector}: {e}")
-
-if __name__ == "__main__":
- debug_face_detection()
diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py
new file mode 100644
index 0000000..a6a5195
--- /dev/null
+++ b/tests/test_api_auth.py
@@ -0,0 +1,487 @@
+"""High priority authentication API tests."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from typing import TYPE_CHECKING
+
+import pytest
+from fastapi.testclient import TestClient
+
+if TYPE_CHECKING:
+ from sqlalchemy.orm import Session
+
+
+class TestLogin:
+ """Test login endpoint."""
+
+ def test_login_success_with_valid_credentials(
+ self, test_client: TestClient, admin_user
+ ):
+ """Verify successful login with valid username/password."""
+ response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "testadmin", "password": "testpass"}
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "access_token" in data
+ assert "refresh_token" in data
+ assert "password_change_required" in data
+ assert isinstance(data["access_token"], str)
+ assert isinstance(data["refresh_token"], str)
+ assert len(data["access_token"]) > 0
+ assert len(data["refresh_token"]) > 0
+
+ def test_login_failure_with_invalid_credentials(
+ self, test_client: TestClient, admin_user
+ ):
+ """Verify 401 with invalid credentials."""
+ response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "testadmin", "password": "wrongpassword"}
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert "detail" in data
+ assert "Incorrect username or password" in data["detail"]
+
+ def test_login_with_inactive_user(
+ self, test_client: TestClient, inactive_user
+ ):
+ """Verify 401 when user account is inactive."""
+ response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "inactiveuser", "password": "testpass"}
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert "detail" in data
+ assert "Account is inactive" in data["detail"]
+
+ def test_login_fallback_to_hardcoded_admin(
+ self, test_client: TestClient
+ ):
+ """Verify fallback to admin/admin works when user not in database."""
+ # Use default hardcoded admin credentials
+ response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "admin", "password": "admin"}
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "access_token" in data
+ assert "refresh_token" in data
+ assert data["password_change_required"] is False
+
+ def test_login_updates_last_login(
+ self, test_client: TestClient, test_db_session: Session, admin_user
+ ):
+ """Verify last_login timestamp is updated on successful login."""
+ initial_last_login = admin_user.last_login
+
+ # Wait a moment to ensure timestamp difference
+ import time
+ time.sleep(0.1)
+
+ response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "testadmin", "password": "testpass"}
+ )
+
+ assert response.status_code == 200
+
+ # Refresh user from database
+ test_db_session.refresh(admin_user)
+
+ # Verify last_login was updated
+ assert admin_user.last_login is not None
+ if initial_last_login:
+ assert admin_user.last_login > initial_last_login
+
+ def test_login_missing_username(self, test_client: TestClient):
+ """Verify 422 when username is missing."""
+ response = test_client.post(
+ "/api/v1/auth/login",
+ json={"password": "testpass"}
+ )
+
+ assert response.status_code == 422
+
+ def test_login_missing_password(self, test_client: TestClient):
+ """Verify 422 when password is missing."""
+ response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "testadmin"}
+ )
+
+ assert response.status_code == 422
+
+
+class TestTokenRefresh:
+ """Test token refresh endpoint."""
+
+ def test_refresh_token_success(
+ self, test_client: TestClient, admin_user
+ ):
+ """Verify successful token refresh."""
+ # Get refresh token from login
+ login_response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "testadmin", "password": "testpass"}
+ )
+ assert login_response.status_code == 200
+ login_data = login_response.json()
+ initial_access_token = login_data["access_token"]
+ refresh_token = login_data["refresh_token"]
+
+ # Use refresh token to get new access token
+ response = test_client.post(
+ "/api/v1/auth/refresh",
+ json={"refresh_token": refresh_token}
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "access_token" in data
+ assert "refresh_token" in data
+ assert data["access_token"] != initial_access_token # Should be different token
+
+ def test_refresh_token_with_invalid_token(self, test_client: TestClient):
+ """Verify 401 with invalid refresh token."""
+ response = test_client.post(
+ "/api/v1/auth/refresh",
+ json={"refresh_token": "invalid_token"}
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert "detail" in data
+ assert "Invalid refresh token" in data["detail"]
+
+ def test_refresh_token_with_access_token(
+ self, test_client: TestClient, auth_token: str
+ ):
+ """Verify 401 when using access token instead of refresh token."""
+ response = test_client.post(
+ "/api/v1/auth/refresh",
+ json={"refresh_token": auth_token} # Using access token
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert "detail" in data
+ assert "Invalid token type" in data["detail"]
+
+ def test_refresh_token_expired(self, test_client: TestClient):
+ """Verify 401 with expired refresh token."""
+ # Create an expired token manually (this is a simplified test)
+ # In practice, we'd need to manipulate JWT expiration
+ # For now, we test with an invalid token format
+ response = test_client.post(
+ "/api/v1/auth/refresh",
+ json={"refresh_token": "expired.token.here"}
+ )
+
+ assert response.status_code == 401
+
+ def test_refresh_token_missing_token(self, test_client: TestClient):
+ """Verify 422 when refresh_token is missing."""
+ response = test_client.post(
+ "/api/v1/auth/refresh",
+ json={}
+ )
+
+ assert response.status_code == 422
+
+
+class TestCurrentUser:
+ """Test current user info endpoint."""
+
+ def test_get_current_user_info_authenticated(
+ self, test_client: TestClient, auth_headers: dict, admin_user
+ ):
+ """Verify user info retrieval with valid token."""
+ response = test_client.get(
+ "/api/v1/auth/me",
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["username"] == "testadmin"
+ assert data["is_admin"] is True
+ assert "role" in data
+ assert "permissions" in data
+
+ def test_get_current_user_info_unauthenticated(
+ self, test_client: TestClient
+ ):
+ """Verify 401 without token."""
+ response = test_client.get("/api/v1/auth/me")
+
+ assert response.status_code == 401
+
+ def test_get_current_user_info_bootstrap_admin(
+ self, test_client: TestClient, test_db_session: Session
+ ):
+ """Verify admin bootstrap when no admins exist."""
+ # Ensure no admin users exist
+ from backend.db.models import User
+ test_db_session.query(User).filter(User.is_admin == True).delete()
+ test_db_session.commit()
+
+ # Login with hardcoded admin
+ login_response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "admin", "password": "admin"}
+ )
+ token = login_response.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ # Get user info - should bootstrap as admin
+ response = test_client.get(
+ "/api/v1/auth/me",
+ headers=headers
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["username"] == "admin"
+ assert data["is_admin"] is True
+
+ def test_get_current_user_info_role_permissions(
+ self, test_client: TestClient, auth_headers: dict, admin_user
+ ):
+ """Verify role and permissions are returned."""
+ response = test_client.get(
+ "/api/v1/auth/me",
+ headers=auth_headers
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "role" in data
+ assert "permissions" in data
+ assert isinstance(data["permissions"], dict)
+
+
+class TestPasswordChange:
+ """Test password change endpoint."""
+
+ def test_change_password_success(
+ self, test_client: TestClient, auth_headers: dict, admin_user
+ ):
+ """Verify successful password change."""
+ response = test_client.post(
+ "/api/v1/auth/change-password",
+ headers=auth_headers,
+ json={
+ "current_password": "testpass",
+ "new_password": "newtestpass123"
+ }
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["success"] is True
+ assert "Password changed successfully" in data["message"]
+
+ # Verify new password works
+ login_response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "testadmin", "password": "newtestpass123"}
+ )
+ assert login_response.status_code == 200
+
+ def test_change_password_with_wrong_current_password(
+ self, test_client: TestClient, auth_headers: dict, admin_user
+ ):
+ """Verify 401 with incorrect current password."""
+ response = test_client.post(
+ "/api/v1/auth/change-password",
+ headers=auth_headers,
+ json={
+ "current_password": "wrongpassword",
+ "new_password": "newtestpass123"
+ }
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert "detail" in data
+ assert "Current password is incorrect" in data["detail"]
+
+ def test_change_password_clears_password_change_required_flag(
+ self, test_client: TestClient, test_db_session: Session
+ ):
+ """Verify flag is cleared after password change."""
+ from backend.db.models import User
+ from backend.utils.password import hash_password
+ from backend.constants.roles import DEFAULT_USER_ROLE
+
+ # Create user with password_change_required flag
+ user = User(
+ username="changepassuser",
+ email="changepass@example.com",
+ password_hash=hash_password("oldpass"),
+ full_name="Change Password User",
+ is_admin=False,
+ is_active=True,
+ password_change_required=True,
+ role=DEFAULT_USER_ROLE,
+ )
+ test_db_session.add(user)
+ test_db_session.commit()
+
+ # Login
+ login_response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "changepassuser", "password": "oldpass"}
+ )
+ token = login_response.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ # Change password
+ response = test_client.post(
+ "/api/v1/auth/change-password",
+ headers=headers,
+ json={
+ "current_password": "oldpass",
+ "new_password": "newpass123"
+ }
+ )
+
+ assert response.status_code == 200
+
+ # Verify flag is cleared
+ test_db_session.refresh(user)
+ assert user.password_change_required is False
+
+ def test_change_password_user_not_found(
+ self, test_client: TestClient, test_db_session: Session
+ ):
+ """Verify 404 when user doesn't exist in database."""
+ # Create a token for a user that doesn't exist in main DB
+ # This is a bit tricky - we'll use the hardcoded admin
+ login_response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "admin", "password": "admin"}
+ )
+ token = login_response.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ # Try to change password - should work for hardcoded admin
+ # But if we delete the user from DB, it should fail
+ from backend.db.models import User
+ user = test_db_session.query(User).filter(User.username == "admin").first()
+ if user:
+ test_db_session.delete(user)
+ test_db_session.commit()
+
+ # Now try to change password
+ response = test_client.post(
+ "/api/v1/auth/change-password",
+ headers=headers,
+ json={
+ "current_password": "admin",
+ "new_password": "newpass123"
+ }
+ )
+
+ # Should fail because user not in database
+ assert response.status_code == 404
+ data = response.json()
+ assert "User not found" in data["detail"]
+
+ def test_change_password_missing_fields(self, test_client: TestClient, auth_headers: dict):
+ """Verify 422 when required fields are missing."""
+ # Missing current_password
+ response = test_client.post(
+ "/api/v1/auth/change-password",
+ headers=auth_headers,
+ json={"new_password": "newpass123"}
+ )
+ assert response.status_code == 422
+
+ # Missing new_password
+ response = test_client.post(
+ "/api/v1/auth/change-password",
+ headers=auth_headers,
+ json={"current_password": "testpass"}
+ )
+ assert response.status_code == 422
+
+
+class TestAuthenticationMiddleware:
+ """Test authentication middleware and token validation."""
+
+ def test_get_current_user_without_token(self, test_client: TestClient):
+ """Verify 401 without Authorization header."""
+ # Try to access protected endpoint
+ response = test_client.get("/api/v1/photos")
+
+ assert response.status_code == 401
+ data = response.json()
+ assert "detail" in data
+
+ def test_get_current_user_with_expired_token(self, test_client: TestClient):
+ """Verify 401 with expired JWT."""
+ # Create an obviously invalid/expired token
+ # Note: This is a test fixture, not a real secret. The token is intentionally invalid.
+ expired_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTYwOTQ1NjgwMH0.invalid"
+
+ response = test_client.get(
+ "/api/v1/photos",
+ headers={"Authorization": f"Bearer {expired_token}"}
+ )
+
+ assert response.status_code == 401
+
+ def test_get_current_user_with_invalid_token_format(
+ self, test_client: TestClient
+ ):
+ """Verify 401 with malformed token."""
+ response = test_client.get(
+ "/api/v1/photos",
+ headers={"Authorization": "Bearer not.a.valid.jwt.token"}
+ )
+
+ assert response.status_code == 401
+
+ def test_get_current_user_with_id_creates_user(
+ self, test_client: TestClient, test_db_session: Session
+ ):
+ """Verify user creation in bootstrap scenario."""
+ from backend.db.models import User
+
+ # Delete user if exists
+ test_db_session.query(User).filter(User.username == "bootstrapuser").delete()
+ test_db_session.commit()
+
+ # Login with hardcoded admin to get token
+ login_response = test_client.post(
+ "/api/v1/auth/login",
+ json={"username": "admin", "password": "admin"}
+ )
+ token = login_response.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ # Access endpoint that uses get_current_user_with_id
+ # This should create the user in the database
+ # Note: This depends on which endpoints use get_current_user_with_id
+ # For now, we'll verify the user can be created via /auth/me
+ response = test_client.get(
+ "/api/v1/auth/me",
+ headers=headers
+ )
+
+ assert response.status_code == 200
+
+ # Verify user exists in database (if bootstrap happened)
+ # This is a simplified test - actual bootstrap logic may vary
+
diff --git a/tests/test_api_faces.py b/tests/test_api_faces.py
new file mode 100644
index 0000000..394fa2d
--- /dev/null
+++ b/tests/test_api_faces.py
@@ -0,0 +1,703 @@
+"""High priority face identification API tests."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from fastapi.testclient import TestClient
+
+if TYPE_CHECKING:
+ from sqlalchemy.orm import Session
+
+
+class TestIdentifyFace:
+ """Test face identification endpoint."""
+
+ def test_identify_face_with_existing_person(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_face,
+ test_person,
+ test_db_session: Session,
+ ):
+ """Verify identification with existing person."""
+ response = test_client.post(
+ f"/api/v1/faces/{test_face.id}/identify",
+ headers=auth_headers,
+ json={"person_id": test_person.id},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["person_id"] == test_person.id
+ assert data["created_person"] is False
+ assert test_face.id in data["identified_face_ids"]
+
+ # Verify face is linked to person
+ test_db_session.refresh(test_face)
+ assert test_face.person_id == test_person.id
+ assert test_face.identified_by_user_id is not None
+
+ # Verify person_encoding was created
+ from backend.db.models import PersonEncoding
+ encoding = test_db_session.query(PersonEncoding).filter(
+ PersonEncoding.face_id == test_face.id
+ ).first()
+ assert encoding is not None
+ assert encoding.person_id == test_person.id
+
+ def test_identify_face_create_new_person(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_face,
+ test_db_session: Session,
+ ):
+ """Verify person creation during identification."""
+ response = test_client.post(
+ f"/api/v1/faces/{test_face.id}/identify",
+ headers=auth_headers,
+ json={
+ "first_name": "Jane",
+ "last_name": "Smith",
+ "middle_name": "Middle",
+ "date_of_birth": "1995-05-15",
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["created_person"] is True
+ assert data["person_id"] is not None
+ assert test_face.id in data["identified_face_ids"]
+
+ # Verify person was created
+ from backend.db.models import Person
+ person = test_db_session.query(Person).filter(
+ Person.id == data["person_id"]
+ ).first()
+ assert person is not None
+ assert person.first_name == "Jane"
+ assert person.last_name == "Smith"
+ assert person.middle_name == "Middle"
+
+ # Verify face is linked
+ test_db_session.refresh(test_face)
+ assert test_face.person_id == person.id
+
+ def test_identify_face_with_additional_faces(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_face,
+ test_face_2,
+ test_person,
+ test_db_session: Session,
+ ):
+ """Verify batch identification with additional faces."""
+ response = test_client.post(
+ f"/api/v1/faces/{test_face.id}/identify",
+ headers=auth_headers,
+ json={
+ "person_id": test_person.id,
+ "additional_face_ids": [test_face_2.id],
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["identified_face_ids"]) == 2
+ assert test_face.id in data["identified_face_ids"]
+ assert test_face_2.id in data["identified_face_ids"]
+
+ # Verify both faces are linked
+ test_db_session.refresh(test_face)
+ test_db_session.refresh(test_face_2)
+ assert test_face.person_id == test_person.id
+ assert test_face_2.person_id == test_person.id
+
+ def test_identify_face_face_not_found(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_person,
+ ):
+ """Verify 404 for non-existent face."""
+ response = test_client.post(
+ "/api/v1/faces/99999/identify",
+ headers=auth_headers,
+ json={"person_id": test_person.id},
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert "not found" in data["detail"].lower()
+
+ def test_identify_face_person_not_found(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_face,
+ ):
+ """Verify 400 when person_id is invalid."""
+ response = test_client.post(
+ f"/api/v1/faces/{test_face.id}/identify",
+ headers=auth_headers,
+ json={"person_id": 99999},
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert "person_id not found" in data["detail"]
+
+ def test_identify_face_tracks_user_id(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_face,
+ test_person,
+ admin_user,
+ test_db_session: Session,
+ ):
+ """Verify user tracking for face identification."""
+ response = test_client.post(
+ f"/api/v1/faces/{test_face.id}/identify",
+ headers=auth_headers,
+ json={"person_id": test_person.id},
+ )
+
+ assert response.status_code == 200
+
+ # Verify identified_by_user_id is set
+ test_db_session.refresh(test_face)
+ assert test_face.identified_by_user_id == admin_user.id
+
+ def test_identify_face_creates_person_encodings(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_face,
+ test_person,
+ test_db_session: Session,
+ ):
+ """Verify person_encodings are created for identified faces."""
+ response = test_client.post(
+ f"/api/v1/faces/{test_face.id}/identify",
+ headers=auth_headers,
+ json={"person_id": test_person.id},
+ )
+
+ assert response.status_code == 200
+
+ # Verify person_encoding exists
+ from backend.db.models import PersonEncoding
+ encoding = test_db_session.query(PersonEncoding).filter(
+ PersonEncoding.face_id == test_face.id,
+ PersonEncoding.person_id == test_person.id,
+ ).first()
+ assert encoding is not None
+ assert encoding.encoding == test_face.encoding
+ assert encoding.quality_score == test_face.quality_score
+ assert encoding.detector_backend == test_face.detector_backend
+ assert encoding.model_name == test_face.model_name
+
+ def test_identify_face_requires_name_for_new_person(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_face,
+ ):
+ """Verify validation when creating new person without required fields."""
+ # Missing first_name
+ response = test_client.post(
+ f"/api/v1/faces/{test_face.id}/identify",
+ headers=auth_headers,
+ json={"last_name": "Smith"},
+ )
+ assert response.status_code == 400
+ assert "first_name and last_name are required" in response.json()["detail"]
+
+ # Missing last_name
+ response = test_client.post(
+ f"/api/v1/faces/{test_face.id}/identify",
+ headers=auth_headers,
+ json={"first_name": "Jane"},
+ )
+ assert response.status_code == 400
+ assert "first_name and last_name are required" in response.json()["detail"]
+
+ def test_identify_face_unauthenticated(
+ self,
+ test_client: TestClient,
+ test_face,
+ test_person,
+ ):
+ """Verify 401 when not authenticated."""
+ response = test_client.post(
+ f"/api/v1/faces/{test_face.id}/identify",
+ json={"person_id": test_person.id},
+ )
+
+ assert response.status_code == 401
+
+
+class TestGetSimilarFaces:
+ """Test similar faces endpoint."""
+
+ def test_get_similar_faces_success(
+ self,
+ test_client: TestClient,
+ test_face,
+ test_face_2,
+ test_db_session: Session,
+ ):
+ """Verify similar faces retrieval."""
+ response = test_client.get(
+ f"/api/v1/faces/{test_face.id}/similar"
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["base_face_id"] == test_face.id
+ assert "items" in data
+ assert isinstance(data["items"], list)
+
+ def test_get_similar_faces_include_excluded(
+ self,
+ test_client: TestClient,
+ test_face,
+ test_db_session: Session,
+ ):
+ """Verify include_excluded parameter."""
+ # Create an excluded face
+ from backend.db.models import Face, Photo
+ import numpy as np
+
+ photo = test_db_session.query(Photo).filter(
+ Photo.id == test_face.photo_id
+ ).first()
+
+ excluded_face = Face(
+ photo_id=photo.id,
+ person_id=None,
+ encoding=np.random.rand(128).astype(np.float32).tobytes(),
+ location='{"x": 50, "y": 50, "w": 100, "h": 100}',
+ quality_score=0.70,
+ face_confidence=0.85,
+ detector_backend="retinaface",
+ model_name="VGG-Face",
+ excluded=True,
+ )
+ test_db_session.add(excluded_face)
+ test_db_session.commit()
+
+ # Test without include_excluded (should exclude excluded faces)
+ response = test_client.get(
+ f"/api/v1/faces/{test_face.id}/similar?include_excluded=false"
+ )
+ assert response.status_code == 200
+
+ # Test with include_excluded=true
+ response = test_client.get(
+ f"/api/v1/faces/{test_face.id}/similar?include_excluded=true"
+ )
+ assert response.status_code == 200
+
+ def test_get_similar_faces_face_not_found(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify 404 for non-existent face."""
+ response = test_client.get("/api/v1/faces/99999/similar")
+
+ assert response.status_code == 404
+ data = response.json()
+ assert "not found" in data["detail"].lower()
+
+ def test_get_similar_faces_returns_similarity_scores(
+ self,
+ test_client: TestClient,
+ test_face,
+ ):
+ """Verify similarity scores in response."""
+ response = test_client.get(
+ f"/api/v1/faces/{test_face.id}/similar"
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+
+ # Check response structure
+ if len(data["items"]) > 0:
+ item = data["items"][0]
+ assert "id" in item
+ assert "photo_id" in item
+ assert "similarity" in item
+ assert "quality_score" in item
+ assert isinstance(item["similarity"], (int, float))
+ assert 0 <= item["similarity"] <= 1
+
+
+class TestBatchSimilarity:
+ """Test batch similarity endpoint."""
+
+ def test_batch_similarity_success(
+ self,
+ test_client: TestClient,
+ test_face,
+ test_face_2,
+ ):
+ """Verify batch similarity calculation."""
+ response = test_client.post(
+ "/api/v1/faces/batch-similarity",
+ json={"face_ids": [test_face.id, test_face_2.id]},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "pairs" in data
+ assert isinstance(data["pairs"], list)
+
+ def test_batch_similarity_with_min_confidence(
+ self,
+ test_client: TestClient,
+ test_face,
+ test_face_2,
+ ):
+ """Verify min_confidence filter."""
+ response = test_client.post(
+ "/api/v1/faces/batch-similarity",
+ json={
+ "face_ids": [test_face.id, test_face_2.id],
+ "min_confidence": 0.5,
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "pairs" in data
+
+ # Verify all pairs meet min_confidence threshold
+ for pair in data["pairs"]:
+ assert pair["confidence_pct"] >= 50 # 0.5 * 100
+
+ def test_batch_similarity_empty_list(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify handling of empty face_ids list."""
+ response = test_client.post(
+ "/api/v1/faces/batch-similarity",
+ json={"face_ids": []},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["pairs"] == []
+
+ def test_batch_similarity_invalid_face_ids(
+ self,
+ test_client: TestClient,
+ test_face,
+ ):
+ """Verify error handling for invalid face IDs."""
+ response = test_client.post(
+ "/api/v1/faces/batch-similarity",
+ json={"face_ids": [test_face.id, 99999]},
+ )
+
+ # Should still return 200, but may have fewer pairs
+ # (implementation dependent - may filter out invalid IDs)
+ assert response.status_code in [200, 400, 404]
+
+
+class TestGetUnidentifiedFaces:
+ """Test unidentified faces listing endpoint."""
+
+ def test_get_unidentified_faces_success(
+ self,
+ test_client: TestClient,
+ test_face,
+ test_face_2,
+ ):
+ """Verify unidentified faces list retrieval."""
+ response = test_client.get("/api/v1/faces/unidentified")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+ assert "page" in data
+ assert "page_size" in data
+ assert "total" in data
+ assert isinstance(data["items"], list)
+ assert data["total"] >= 2 # At least our two test faces
+
+ def test_get_unidentified_faces_with_pagination(
+ self,
+ test_client: TestClient,
+ test_face,
+ test_face_2,
+ ):
+ """Verify pagination works."""
+ response = test_client.get(
+ "/api/v1/faces/unidentified?page=1&page_size=1"
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["page"] == 1
+ assert data["page_size"] == 1
+ assert len(data["items"]) <= 1
+
+ def test_get_unidentified_faces_with_quality_filter(
+ self,
+ test_client: TestClient,
+ test_face,
+ ):
+ """Verify quality filter."""
+ # Test with high quality threshold
+ response = test_client.get(
+ "/api/v1/faces/unidentified?min_quality=0.9"
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+
+ # Verify all returned faces meet quality threshold
+ for item in data["items"]:
+ assert item["quality_score"] >= 0.9
+
+ def test_get_unidentified_faces_excludes_identified(
+ self,
+ test_client: TestClient,
+ test_face,
+ test_person,
+ auth_headers: dict,
+ test_db_session: Session,
+ ):
+ """Verify identified faces are excluded from results."""
+ # First, verify face is in unidentified list
+ response = test_client.get("/api/v1/faces/unidentified")
+ assert response.status_code == 200
+ initial_count = response.json()["total"]
+
+ # Identify the face
+ identify_response = test_client.post(
+ f"/api/v1/faces/{test_face.id}/identify",
+ headers=auth_headers,
+ json={"person_id": test_person.id},
+ )
+ assert identify_response.status_code == 200
+
+ # Verify face is no longer in unidentified list
+ response = test_client.get("/api/v1/faces/unidentified")
+ assert response.status_code == 200
+ new_count = response.json()["total"]
+ assert new_count < initial_count
+
+ def test_get_unidentified_faces_with_date_filters(
+ self,
+ test_client: TestClient,
+ test_face,
+ ):
+ """Verify date filtering."""
+ response = test_client.get(
+ "/api/v1/faces/unidentified?date_taken_from=2024-01-01&date_taken_to=2024-12-31"
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+
+ def test_get_unidentified_faces_invalid_date_format(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify invalid date format handling."""
+ response = test_client.get(
+ "/api/v1/faces/unidentified?date_taken_from=invalid-date"
+ )
+
+ # Should handle gracefully (may return 200 with no results or 400)
+ assert response.status_code in [200, 400]
+
+
+class TestAutoMatch:
+ """Test auto-match functionality."""
+
+ def test_auto_match_faces_success(
+ self,
+ test_client: TestClient,
+ test_face,
+ identified_face,
+ test_person,
+ ):
+ """Verify auto-match process."""
+ response = test_client.post(
+ "/api/v1/faces/auto-match",
+ json={"tolerance": 0.6},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "people" in data
+ assert "total_people" in data
+ assert "total_matches" in data
+ assert isinstance(data["people"], list)
+
+ def test_auto_match_faces_with_tolerance(
+ self,
+ test_client: TestClient,
+ test_face,
+ identified_face,
+ test_person,
+ ):
+ """Verify tolerance parameter affects results."""
+ # Test with high tolerance (more matches)
+ response_high = test_client.post(
+ "/api/v1/faces/auto-match",
+ json={"tolerance": 0.8},
+ )
+ assert response_high.status_code == 200
+
+ # Test with low tolerance (fewer matches)
+ response_low = test_client.post(
+ "/api/v1/faces/auto-match",
+ json={"tolerance": 0.4},
+ )
+ assert response_low.status_code == 200
+
+ # Lower tolerance should generally have fewer matches
+ # (though this depends on actual face similarities)
+ data_high = response_high.json()
+ data_low = response_low.json()
+ # Note: This is a probabilistic assertion - may not always hold
+
+ def test_auto_match_faces_auto_accept_enabled(
+ self,
+ test_client: TestClient,
+ test_face,
+ identified_face,
+ test_person,
+ ):
+ """Verify auto-accept functionality."""
+ response = test_client.post(
+ "/api/v1/faces/auto-match",
+ json={
+ "tolerance": 0.6,
+ "auto_accept": True,
+ "auto_accept_threshold": 0.7,
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["auto_accepted"] is True
+ assert "auto_accepted_faces" in data
+ assert "skipped_persons" in data
+ assert "skipped_matches" in data
+
+
+class TestAcceptMatches:
+ """Test accept matches endpoint (via people API)."""
+
+ def test_accept_matches_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_face,
+ test_face_2,
+ test_person,
+ ):
+ """Verify accepting auto-match matches."""
+ response = test_client.post(
+ f"/api/v1/people/{test_person.id}/accept-matches",
+ headers=auth_headers,
+ json={"face_ids": [test_face.id, test_face_2.id]},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["person_id"] == test_person.id
+ assert "identified_face_ids" in data
+
+ def test_accept_matches_tracks_user_id(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_face,
+ test_person,
+ admin_user,
+ test_db_session: Session,
+ ):
+ """Verify user tracking for accepted matches."""
+ response = test_client.post(
+ f"/api/v1/people/{test_person.id}/accept-matches",
+ headers=auth_headers,
+ json={"face_ids": [test_face.id]},
+ )
+
+ assert response.status_code == 200
+
+ # Verify identified_by_user_id is set
+ test_db_session.refresh(test_face)
+ assert test_face.identified_by_user_id == admin_user.id
+
+ def test_accept_matches_creates_person_encodings(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_face,
+ test_person,
+ test_db_session: Session,
+ ):
+ """Verify person_encodings are created."""
+ response = test_client.post(
+ f"/api/v1/people/{test_person.id}/accept-matches",
+ headers=auth_headers,
+ json={"face_ids": [test_face.id]},
+ )
+
+ assert response.status_code == 200
+
+ # Verify person_encoding exists
+ from backend.db.models import PersonEncoding
+ encoding = test_db_session.query(PersonEncoding).filter(
+ PersonEncoding.face_id == test_face.id,
+ PersonEncoding.person_id == test_person.id,
+ ).first()
+ assert encoding is not None
+
+ def test_accept_matches_person_not_found(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_face,
+ ):
+ """Verify 404 for non-existent person."""
+ response = test_client.post(
+ "/api/v1/people/99999/accept-matches",
+ headers=auth_headers,
+ json={"face_ids": [test_face.id]},
+ )
+
+ assert response.status_code == 404
+
+ def test_accept_matches_face_not_found(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_person,
+ ):
+ """Verify handling of missing faces."""
+ response = test_client.post(
+ f"/api/v1/people/{test_person.id}/accept-matches",
+ headers=auth_headers,
+ json={"face_ids": [99999]},
+ )
+
+ # Implementation may handle gracefully or return error
+ assert response.status_code in [200, 400, 404]
+
diff --git a/tests/test_api_health.py b/tests/test_api_health.py
new file mode 100644
index 0000000..c9a4a0b
--- /dev/null
+++ b/tests/test_api_health.py
@@ -0,0 +1,62 @@
+"""Health and version API tests."""
+
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+
+
+class TestHealthCheck:
+ """Test health check endpoints."""
+
+ def test_health_check_success(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify health endpoint returns 200."""
+ response = test_client.get("/health")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "status" in data
+ assert data["status"] == "ok"
+
+ def test_health_check_database_connection(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify DB connection check."""
+ # Basic health check doesn't necessarily check DB
+ # This is a placeholder for future DB health checks
+ response = test_client.get("/health")
+
+ assert response.status_code == 200
+
+
+class TestVersionEndpoint:
+ """Test version endpoint."""
+
+ def test_version_endpoint_success(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify version information."""
+ response = test_client.get("/version")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "version" in data or "app_version" in data
+
+ def test_version_endpoint_includes_app_version(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify version format."""
+ response = test_client.get("/version")
+
+ assert response.status_code == 200
+ data = response.json()
+ # Version should be a string
+ version_key = "version" if "version" in data else "app_version"
+ assert isinstance(data[version_key], str)
+
diff --git a/tests/test_api_jobs.py b/tests/test_api_jobs.py
new file mode 100644
index 0000000..3c03039
--- /dev/null
+++ b/tests/test_api_jobs.py
@@ -0,0 +1,73 @@
+"""Medium priority job API tests."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from fastapi.testclient import TestClient
+
+if TYPE_CHECKING:
+ from sqlalchemy.orm import Session
+
+
+class TestJobStatus:
+ """Test job status endpoints."""
+
+ def test_get_job_status_not_found(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify 404 for non-existent job."""
+ response = test_client.get("/api/v1/jobs/nonexistent-job-id")
+
+ assert response.status_code == 404
+ data = response.json()
+ assert "not found" in data["detail"].lower()
+
+ def test_get_job_status_includes_timestamps(
+ self,
+ test_client: TestClient,
+ test_db_session: "Session",
+ ):
+ """Verify timestamp fields."""
+ # This test requires a real job to be created
+ # For now, we'll test the error case
+ response = test_client.get("/api/v1/jobs/test-job-id")
+
+ # If job doesn't exist, we get 404
+ # If job exists, we should check for timestamps
+ if response.status_code == 200:
+ data = response.json()
+ assert "created_at" in data
+ assert "updated_at" in data
+
+
+class TestJobStreaming:
+ """Test job streaming endpoints."""
+
+ def test_stream_job_progress_not_found(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify 404 handling."""
+ response = test_client.get("/api/v1/jobs/stream/nonexistent-job-id")
+
+ # Streaming endpoint may return 404 or start streaming
+ # Implementation dependent
+ assert response.status_code in [200, 404]
+
+ def test_stream_job_progress_sse_format(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify SSE format compliance."""
+ # This test requires a real job
+ # For now, we'll test the not found case
+ response = test_client.get("/api/v1/jobs/stream/test-job-id")
+
+ if response.status_code == 200:
+ # Check Content-Type for SSE (may include charset parameter)
+ content_type = response.headers.get("content-type", "")
+ assert content_type.startswith("text/event-stream")
+
diff --git a/tests/test_api_people.py b/tests/test_api_people.py
new file mode 100644
index 0000000..aafb0e9
--- /dev/null
+++ b/tests/test_api_people.py
@@ -0,0 +1,265 @@
+"""High priority people API tests."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from fastapi.testclient import TestClient
+
+if TYPE_CHECKING:
+ from sqlalchemy.orm import Session
+ from backend.db.models import Person, Face, User
+
+
+class TestPeopleListing:
+ """Test people listing endpoints."""
+
+ def test_list_people_success(
+ self,
+ test_client: TestClient,
+ test_person: "Person",
+ ):
+ """Verify people list retrieval."""
+ response = test_client.get("/api/v1/people")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+ assert "total" in data
+ assert len(data["items"]) > 0
+
+ def test_list_people_with_last_name_filter(
+ self,
+ test_client: TestClient,
+ test_person: "Person",
+ ):
+ """Verify last name filtering."""
+ response = test_client.get(
+ "/api/v1/people",
+ params={"last_name": "Doe"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+ # All items should have last_name containing "Doe"
+ for item in data["items"]:
+ assert "Doe" in item["last_name"]
+
+ def test_list_people_with_faces_success(
+ self,
+ test_client: TestClient,
+ test_person: "Person",
+ test_face: "Face",
+ test_db_session: "Session",
+ ):
+ """Verify people with face counts."""
+ test_face.person_id = test_person.id
+ test_db_session.commit()
+
+ response = test_client.get("/api/v1/people/with-faces")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+ # Find our person
+ person_item = next(
+ (item for item in data["items"] if item["id"] == test_person.id),
+ None
+ )
+ if person_item:
+ assert person_item["face_count"] >= 1
+
+
+class TestPeopleCRUD:
+ """Test people CRUD endpoints."""
+
+ def test_create_person_success(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify person creation."""
+ response = test_client.post(
+ "/api/v1/people",
+ json={
+ "first_name": "Jane",
+ "last_name": "Smith",
+ },
+ )
+
+ assert response.status_code == 201
+ data = response.json()
+ assert data["first_name"] == "Jane"
+ assert data["last_name"] == "Smith"
+ assert "id" in data
+
+ def test_create_person_with_middle_name(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify optional middle_name."""
+ response = test_client.post(
+ "/api/v1/people",
+ json={
+ "first_name": "Jane",
+ "last_name": "Smith",
+ "middle_name": "Middle",
+ },
+ )
+
+ assert response.status_code == 201
+ data = response.json()
+ assert data["middle_name"] == "Middle"
+
+ def test_create_person_strips_whitespace(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify name trimming."""
+ response = test_client.post(
+ "/api/v1/people",
+ json={
+ "first_name": " Jane ",
+ "last_name": " Smith ",
+ },
+ )
+
+ assert response.status_code == 201
+ data = response.json()
+ assert data["first_name"] == "Jane"
+ assert data["last_name"] == "Smith"
+
+ def test_get_person_by_id_success(
+ self,
+ test_client: TestClient,
+ test_person: "Person",
+ ):
+ """Verify person retrieval."""
+ response = test_client.get(f"/api/v1/people/{test_person.id}")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == test_person.id
+ assert data["first_name"] == test_person.first_name
+
+ def test_get_person_by_id_not_found(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify 404 for non-existent person."""
+ response = test_client.get("/api/v1/people/99999")
+
+ assert response.status_code == 404
+
+ def test_update_person_success(
+ self,
+ test_client: TestClient,
+ test_person: "Person",
+ ):
+ """Verify person update."""
+ response = test_client.put(
+ f"/api/v1/people/{test_person.id}",
+ json={
+ "first_name": "Updated",
+ "last_name": "Name",
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["first_name"] == "Updated"
+ assert data["last_name"] == "Name"
+
+ def test_update_person_not_found(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify 404 when updating non-existent person."""
+ response = test_client.put(
+ "/api/v1/people/99999",
+ json={
+ "first_name": "Updated",
+ "last_name": "Name",
+ },
+ )
+
+ assert response.status_code == 404
+
+ def test_delete_person_success(
+ self,
+ test_client: TestClient,
+ test_db_session: "Session",
+ ):
+ """Verify person deletion."""
+ from backend.db.models import Person
+ from datetime import datetime
+
+ # Create a person to delete
+ person = Person(
+ first_name="Delete",
+ last_name="Me",
+ created_date=datetime.utcnow(),
+ )
+ test_db_session.add(person)
+ test_db_session.commit()
+ test_db_session.refresh(person)
+
+ response = test_client.delete(f"/api/v1/people/{person.id}")
+
+ # DELETE operations return 204 No Content (standard REST convention)
+ assert response.status_code == 204
+
+ def test_delete_person_not_found(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify 404 for non-existent person."""
+ response = test_client.delete("/api/v1/people/99999")
+
+ assert response.status_code == 404
+
+
+class TestPeopleFaces:
+ """Test people faces endpoints."""
+
+ def test_get_person_faces_success(
+ self,
+ test_client: TestClient,
+ test_person: "Person",
+ test_face: "Face",
+ test_db_session: "Session",
+ ):
+ """Verify faces retrieval for person."""
+ test_face.person_id = test_person.id
+ test_db_session.commit()
+
+ response = test_client.get(f"/api/v1/people/{test_person.id}/faces")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+ assert len(data["items"]) > 0
+
+ def test_get_person_faces_no_faces(
+ self,
+ test_client: TestClient,
+ test_person: "Person",
+ ):
+ """Verify empty list when no faces."""
+ response = test_client.get(f"/api/v1/people/{test_person.id}/faces")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+ # May be empty or have faces depending on test setup
+
+ def test_get_person_faces_person_not_found(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify 404 for non-existent person."""
+ response = test_client.get("/api/v1/people/99999/faces")
+
+ assert response.status_code == 404
+
diff --git a/tests/test_api_photos.py b/tests/test_api_photos.py
new file mode 100644
index 0000000..b1f8802
--- /dev/null
+++ b/tests/test_api_photos.py
@@ -0,0 +1,440 @@
+"""High priority photo API tests."""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import TYPE_CHECKING
+
+import pytest
+from fastapi.testclient import TestClient
+
+if TYPE_CHECKING:
+ from sqlalchemy.orm import Session
+ from backend.db.models import Photo, Person, Face, User
+
+
+class TestPhotoSearch:
+ """Test photo search endpoints."""
+
+ def test_search_photos_by_name_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_photo: "Photo",
+ test_person: "Person",
+ test_face: "Face",
+ test_db_session: "Session",
+ ):
+ """Verify search by person name works."""
+ # Link face to person
+ test_face.person_id = test_person.id
+ test_db_session.commit()
+ test_db_session.refresh(test_face)
+
+ # Verify the link was created
+ assert test_face.person_id == test_person.id
+ assert test_face.photo_id == test_photo.id
+
+ response = test_client.get(
+ "/api/v1/photos",
+ headers=auth_headers,
+ params={"search_type": "name", "person_name": "John"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+ assert "total" in data
+ # With test_person.first_name="John" and face linked, we should find results
+ assert len(data["items"]) > 0
+ # Verify the photo is in the results
+ photo_ids = [item["id"] for item in data["items"]]
+ assert test_photo.id in photo_ids
+
+ def test_search_photos_by_name_without_person_name(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ ):
+ """Verify 400 when person_name missing."""
+ response = test_client.get(
+ "/api/v1/photos",
+ headers=auth_headers,
+ params={"search_type": "name"},
+ )
+
+ assert response.status_code == 400
+ assert "person_name is required" in response.json()["detail"]
+
+ def test_search_photos_by_name_with_pagination(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_photo: "Photo",
+ test_person: "Person",
+ test_face: "Face",
+ test_db_session: "Session",
+ ):
+ """Verify pagination works correctly."""
+ test_face.person_id = test_person.id
+ test_db_session.commit()
+
+ response = test_client.get(
+ "/api/v1/photos",
+ headers=auth_headers,
+ params={
+ "search_type": "name",
+ "person_name": "John Doe",
+ "page": 1,
+ "page_size": 10,
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["page"] == 1
+ assert data["page_size"] == 10
+ assert len(data["items"]) <= 10
+
+ def test_search_photos_by_date_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_photo: "Photo",
+ ):
+ """Verify date range search."""
+ response = test_client.get(
+ "/api/v1/photos",
+ headers=auth_headers,
+ params={
+ "search_type": "date",
+ "date_from": "2024-01-01",
+ "date_to": "2024-12-31",
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+
+ def test_search_photos_by_date_without_dates(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ ):
+ """Verify 400 when both dates missing."""
+ response = test_client.get(
+ "/api/v1/photos",
+ headers=auth_headers,
+ params={"search_type": "date"},
+ )
+
+ assert response.status_code == 400
+ assert "date_from or date_to is required" in response.json()["detail"]
+
+ def test_search_photos_by_tags_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_photo: "Photo",
+ test_db_session: "Session",
+ ):
+ """Verify tag search works."""
+ from backend.db.models import Tag, PhotoTagLinkage
+
+ # Create tag and link to photo
+ tag = Tag(tag_name="test-tag")
+ test_db_session.add(tag)
+ test_db_session.flush()
+
+ photo_tag = PhotoTagLinkage(photo_id=test_photo.id, tag_id=tag.id)
+ test_db_session.add(photo_tag)
+ test_db_session.commit()
+
+ response = test_client.get(
+ "/api/v1/photos",
+ headers=auth_headers,
+ params={"search_type": "tags", "tag_names": "test-tag"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+
+ def test_search_photos_by_tags_without_tags(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ ):
+ """Verify 400 when tag_names missing."""
+ response = test_client.get(
+ "/api/v1/photos",
+ headers=auth_headers,
+ params={"search_type": "tags"},
+ )
+
+ assert response.status_code == 400
+ assert "tag_names is required" in response.json()["detail"]
+
+ def test_search_photos_no_faces(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_photo: "Photo",
+ ):
+ """Verify photos without faces search."""
+ response = test_client.get(
+ "/api/v1/photos",
+ headers=auth_headers,
+ params={"search_type": "no_faces"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+
+ def test_search_photos_returns_favorite_status(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_photo: "Photo",
+ admin_user: "User",
+ test_db_session: "Session",
+ ):
+ """Verify is_favorite field in results."""
+ from backend.db.models import PhotoFavorite
+
+ # Add favorite
+ favorite = PhotoFavorite(photo_id=test_photo.id, username=admin_user.username)
+ test_db_session.add(favorite)
+ test_db_session.commit()
+
+ response = test_client.get(
+ "/api/v1/photos",
+ headers=auth_headers,
+ params={"search_type": "date", "date_from": "2024-01-01"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ if len(data["items"]) > 0:
+ # Check if our photo is in results and has is_favorite
+ photo_ids = [item["id"] for item in data["items"]]
+ if test_photo.id in photo_ids:
+ photo_item = next(item for item in data["items"] if item["id"] == test_photo.id)
+ assert "is_favorite" in photo_item
+
+
+class TestPhotoFavorites:
+ """Test photo favorites endpoints."""
+
+ def test_toggle_favorite_add(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_photo: "Photo",
+ admin_user: "User",
+ test_db_session: "Session",
+ ):
+ """Verify adding favorite."""
+ response = test_client.post(
+ f"/api/v1/photos/{test_photo.id}/toggle-favorite",
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["is_favorite"] is True
+
+ # Verify in database
+ from backend.db.models import PhotoFavorite
+ favorite = test_db_session.query(PhotoFavorite).filter(
+ PhotoFavorite.photo_id == test_photo.id,
+ PhotoFavorite.username == admin_user.username,
+ ).first()
+ assert favorite is not None
+
+ def test_toggle_favorite_remove(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_photo: "Photo",
+ admin_user: "User",
+ test_db_session: "Session",
+ ):
+ """Verify removing favorite."""
+ from backend.db.models import PhotoFavorite
+
+ # Add favorite first
+ favorite = PhotoFavorite(photo_id=test_photo.id, username=admin_user.username)
+ test_db_session.add(favorite)
+ test_db_session.commit()
+
+ # Remove it
+ response = test_client.post(
+ f"/api/v1/photos/{test_photo.id}/toggle-favorite",
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["is_favorite"] is False
+
+ def test_toggle_favorite_unauthenticated(
+ self,
+ test_client: TestClient,
+ test_photo: "Photo",
+ ):
+ """Verify 401 without auth."""
+ response = test_client.post(
+ f"/api/v1/photos/{test_photo.id}/toggle-favorite",
+ )
+
+ assert response.status_code == 401
+
+ def test_toggle_favorite_photo_not_found(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ ):
+ """Verify 404 for non-existent photo."""
+ response = test_client.post(
+ "/api/v1/photos/99999/toggle-favorite",
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 404
+
+ def test_bulk_add_favorites_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_photo: "Photo",
+ test_photo_2: "Photo",
+ ):
+ """Verify bulk add operation."""
+ response = test_client.post(
+ "/api/v1/photos/bulk-add-favorites",
+ headers=auth_headers,
+ json={"photo_ids": [test_photo.id, test_photo_2.id]},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["added_count"] >= 0
+ assert data["already_favorite_count"] >= 0
+
+ def test_bulk_remove_favorites_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_photo: "Photo",
+ admin_user: "User",
+ test_db_session: "Session",
+ ):
+ """Verify bulk remove operation."""
+ from backend.db.models import PhotoFavorite
+
+ # Add favorite first
+ favorite = PhotoFavorite(photo_id=test_photo.id, username=admin_user.username)
+ test_db_session.add(favorite)
+ test_db_session.commit()
+
+ response = test_client.post(
+ "/api/v1/photos/bulk-remove-favorites",
+ headers=auth_headers,
+ json={"photo_ids": [test_photo.id]},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["removed_count"] >= 0
+
+
+class TestPhotoRetrieval:
+ """Test photo retrieval endpoints."""
+
+ def test_get_photo_by_id_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_photo: "Photo",
+ ):
+ """Verify photo retrieval by ID."""
+ response = test_client.get(
+ f"/api/v1/photos/{test_photo.id}",
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == test_photo.id
+ assert data["filename"] == test_photo.filename
+
+ def test_get_photo_by_id_not_found(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ ):
+ """Verify 404 for non-existent photo."""
+ response = test_client.get(
+ "/api/v1/photos/99999",
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 404
+
+
+class TestPhotoDeletion:
+ """Test photo deletion endpoints."""
+
+ def test_bulk_delete_photos_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_photo: "Photo",
+ ):
+ """Verify bulk delete (admin only)."""
+ response = test_client.post(
+ "/api/v1/photos/bulk-delete",
+ headers=auth_headers,
+ json={"photo_ids": [test_photo.id]},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "deleted_count" in data
+ assert data["deleted_count"] >= 0
+
+ def test_bulk_delete_photos_non_admin(
+ self,
+ test_client: TestClient,
+ regular_auth_headers: dict,
+ test_photo: "Photo",
+ admin_user, # Ensure an admin exists to prevent bootstrap
+ ):
+ """Verify 403 for non-admin users."""
+ response = test_client.post(
+ "/api/v1/photos/bulk-delete",
+ headers=regular_auth_headers,
+ json={"photo_ids": [test_photo.id]},
+ )
+
+ # Should be 403 or 401 depending on implementation
+ assert response.status_code in [403, 401]
+
+ def test_bulk_delete_photos_empty_list(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ ):
+ """Verify 400 with empty photo_ids."""
+ response = test_client.post(
+ "/api/v1/photos/bulk-delete",
+ headers=auth_headers,
+ json={"photo_ids": []},
+ )
+
+ # May return 200 with 0 deleted or 400
+ assert response.status_code in [200, 400]
+
diff --git a/tests/test_api_tags.py b/tests/test_api_tags.py
new file mode 100644
index 0000000..dae7f25
--- /dev/null
+++ b/tests/test_api_tags.py
@@ -0,0 +1,297 @@
+"""Medium priority tag API tests."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from fastapi.testclient import TestClient
+
+if TYPE_CHECKING:
+ from sqlalchemy.orm import Session
+ from backend.db.models import Photo, Tag
+
+
+class TestTagListing:
+ """Test tag listing endpoints."""
+
+ def test_get_tags_success(
+ self,
+ test_client: TestClient,
+ test_db_session: "Session",
+ ):
+ """Verify tags list retrieval."""
+ from backend.db.models import Tag
+
+ # Create a test tag
+ tag = Tag(tag_name="test-tag")
+ test_db_session.add(tag)
+ test_db_session.commit()
+
+ response = test_client.get("/api/v1/tags")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+ assert "total" in data
+
+ def test_get_tags_empty_list(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify empty list when no tags."""
+ response = test_client.get("/api/v1/tags")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+ assert isinstance(data["items"], list)
+
+
+class TestTagCRUD:
+ """Test tag CRUD endpoints."""
+
+ def test_create_tag_success(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify tag creation."""
+ response = test_client.post(
+ "/api/v1/tags",
+ json={"tag_name": "new-tag"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["tag_name"] == "new-tag"
+ assert "id" in data
+
+ def test_create_tag_duplicate(
+ self,
+ test_client: TestClient,
+ test_db_session: "Session",
+ ):
+ """Verify returns existing tag if duplicate."""
+ from backend.db.models import Tag
+
+ # Create tag first
+ tag = Tag(tag_name="duplicate-tag")
+ test_db_session.add(tag)
+ test_db_session.commit()
+ test_db_session.refresh(tag)
+
+ # Try to create again
+ response = test_client.post(
+ "/api/v1/tags",
+ json={"tag_name": "duplicate-tag"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == tag.id
+ assert data["tag_name"] == "duplicate-tag"
+
+ def test_create_tag_strips_whitespace(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify whitespace handling."""
+ response = test_client.post(
+ "/api/v1/tags",
+ json={"tag_name": " whitespace-tag "},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ # Tag should be trimmed
+ assert "whitespace-tag" in data["tag_name"]
+
+ def test_update_tag_success(
+ self,
+ test_client: TestClient,
+ test_db_session: "Session",
+ ):
+ """Verify tag update."""
+ from backend.db.models import Tag
+
+ tag = Tag(tag_name="old-name")
+ test_db_session.add(tag)
+ test_db_session.commit()
+ test_db_session.refresh(tag)
+
+ response = test_client.put(
+ f"/api/v1/tags/{tag.id}",
+ json={"tag_name": "new-name"},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["tag_name"] == "new-name"
+
+ def test_update_tag_not_found(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify 404 for non-existent tag."""
+ response = test_client.put(
+ "/api/v1/tags/99999",
+ json={"tag_name": "new-name"},
+ )
+
+ assert response.status_code in [400, 404] # Implementation dependent
+
+ def test_delete_tag_success(
+ self,
+ test_client: TestClient,
+ test_db_session: "Session",
+ ):
+ """Verify tag deletion."""
+ from backend.db.models import Tag
+
+ tag = Tag(tag_name="delete-me")
+ test_db_session.add(tag)
+ test_db_session.commit()
+ test_db_session.refresh(tag)
+
+ response = test_client.post(
+ "/api/v1/tags/delete",
+ json={"tag_ids": [tag.id]},
+ )
+
+ assert response.status_code == 200
+
+ def test_delete_tag_not_found(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify 404 handling."""
+ response = test_client.post(
+ "/api/v1/tags/delete",
+ json={"tag_ids": [99999]},
+ )
+
+ # May return 200 with 0 deleted or error
+ assert response.status_code in [200, 400, 404]
+
+
+class TestPhotoTagOperations:
+ """Test photo-tag operations."""
+
+ def test_add_tags_to_photos_success(
+ self,
+ test_client: TestClient,
+ test_photo: "Photo",
+ ):
+ """Verify adding tags to photos."""
+ response = test_client.post(
+ "/api/v1/tags/photos/add",
+ json={
+ "photo_ids": [test_photo.id],
+ "tag_names": ["test-tag-1", "test-tag-2"],
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "photos_updated" in data
+ assert data["photos_updated"] >= 0
+
+ def test_add_tags_to_photos_empty_photo_ids(
+ self,
+ test_client: TestClient,
+ ):
+ """Verify 400 with empty photo_ids."""
+ response = test_client.post(
+ "/api/v1/tags/photos/add",
+ json={
+ "photo_ids": [],
+ "tag_names": ["test-tag"],
+ },
+ )
+
+ assert response.status_code == 400
+
+ def test_add_tags_to_photos_empty_tag_names(
+ self,
+ test_client: TestClient,
+ test_photo: "Photo",
+ ):
+ """Verify 400 with empty tag_names."""
+ response = test_client.post(
+ "/api/v1/tags/photos/add",
+ json={
+ "photo_ids": [test_photo.id],
+ "tag_names": [],
+ },
+ )
+
+ assert response.status_code == 400
+
+ def test_remove_tags_from_photos_success(
+ self,
+ test_client: TestClient,
+ test_photo: "Photo",
+ test_db_session: "Session",
+ ):
+ """Verify tag removal."""
+ from backend.db.models import Tag, PhotoTagLinkage
+
+ # Add tag first
+ tag = Tag(tag_name="remove-me")
+ test_db_session.add(tag)
+ test_db_session.flush()
+
+ photo_tag = PhotoTagLinkage(photo_id=test_photo.id, tag_id=tag.id)
+ test_db_session.add(photo_tag)
+ test_db_session.commit()
+
+ # Remove it
+ response = test_client.post(
+ "/api/v1/tags/photos/remove",
+ json={
+ "photo_ids": [test_photo.id],
+ "tag_names": ["remove-me"],
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "tags_removed" in data
+
+ def test_get_photo_tags_success(
+ self,
+ test_client: TestClient,
+ test_photo: "Photo",
+ test_db_session: "Session",
+ ):
+ """Verify photo tags retrieval."""
+ from backend.db.models import Tag, PhotoTagLinkage
+
+ tag = Tag(tag_name="photo-tag")
+ test_db_session.add(tag)
+ test_db_session.flush()
+
+ photo_tag = PhotoTagLinkage(photo_id=test_photo.id, tag_id=tag.id)
+ test_db_session.add(photo_tag)
+ test_db_session.commit()
+
+ response = test_client.get(f"/api/v1/tags/photos/{test_photo.id}")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "tags" in data
+ assert len(data["tags"]) > 0
+
+ def test_get_photo_tags_empty(
+ self,
+ test_client: TestClient,
+ test_photo: "Photo",
+ ):
+ """Verify empty list for untagged photo."""
+ response = test_client.get(f"/api/v1/tags/photos/{test_photo.id}")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "tags" in data
+ assert isinstance(data["tags"], list)
+
diff --git a/tests/test_api_users.py b/tests/test_api_users.py
new file mode 100644
index 0000000..0e351f6
--- /dev/null
+++ b/tests/test_api_users.py
@@ -0,0 +1,291 @@
+"""High priority user API tests."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+from fastapi.testclient import TestClient
+
+if TYPE_CHECKING:
+ from sqlalchemy.orm import Session
+ from backend.db.models import User
+
+
+class TestUserListing:
+ """Test user listing endpoints."""
+
+ def test_list_users_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ admin_user: "User",
+ ):
+ """Verify users list (admin only)."""
+ response = test_client.get(
+ "/api/v1/users",
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+ assert "total" in data
+
+ def test_list_users_non_admin(
+ self,
+ test_client: TestClient,
+ regular_auth_headers: dict,
+ admin_user, # Ensure an admin exists to prevent bootstrap
+ ):
+ """Verify 403 for non-admin users."""
+ response = test_client.get(
+ "/api/v1/users",
+ headers=regular_auth_headers,
+ )
+
+ assert response.status_code in [403, 401]
+
+ def test_list_users_with_pagination(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ ):
+ """Verify pagination."""
+ response = test_client.get(
+ "/api/v1/users",
+ headers=auth_headers,
+ params={"page": 1, "page_size": 10},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "items" in data
+
+
+class TestUserCRUD:
+ """Test user CRUD endpoints."""
+
+ def test_create_user_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ ):
+ """Verify user creation (admin only)."""
+ response = test_client.post(
+ "/api/v1/users",
+ headers=auth_headers,
+ json={
+ "username": "newuser",
+ "email": "newuser@example.com",
+ "full_name": "New User",
+ "password": "password123",
+ },
+ )
+
+ assert response.status_code == 201
+ data = response.json()
+ assert data["username"] == "newuser"
+ assert data["email"] == "newuser@example.com"
+
+ def test_create_user_duplicate_email(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ admin_user: "User",
+ ):
+ """Verify 400 with duplicate email."""
+ response = test_client.post(
+ "/api/v1/users",
+ headers=auth_headers,
+ json={
+ "username": "differentuser",
+ "email": admin_user.email, # Duplicate email
+ "full_name": "Different User",
+ "password": "password123",
+ },
+ )
+
+ assert response.status_code == 400
+
+ def test_create_user_duplicate_username(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ admin_user: "User",
+ ):
+ """Verify 400 with duplicate username."""
+ response = test_client.post(
+ "/api/v1/users",
+ headers=auth_headers,
+ json={
+ "username": admin_user.username, # Duplicate username
+ "email": "different@example.com",
+ "full_name": "Different User",
+ "password": "password123",
+ },
+ )
+
+ assert response.status_code == 400
+
+ def test_get_user_by_id_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ admin_user: "User",
+ ):
+ """Verify user retrieval."""
+ response = test_client.get(
+ f"/api/v1/users/{admin_user.id}",
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == admin_user.id
+ assert data["username"] == admin_user.username
+
+ def test_get_user_by_id_not_found(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ ):
+ """Verify 404 for non-existent user."""
+ response = test_client.get(
+ "/api/v1/users/99999",
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 404
+
+ def test_update_user_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ admin_user: "User",
+ ):
+ """Verify user update."""
+ response = test_client.put(
+ f"/api/v1/users/{admin_user.id}",
+ headers=auth_headers,
+ json={
+ "email": admin_user.email,
+ "full_name": "Updated Name",
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["full_name"] == "Updated Name"
+
+ def test_delete_user_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ test_db_session: "Session",
+ ):
+ """Verify user deletion."""
+ from backend.db.models import User
+ from backend.utils.password import hash_password
+ from backend.constants.roles import DEFAULT_USER_ROLE
+
+ # Create a user to delete
+ user = User(
+ username="deleteuser",
+ email="delete@example.com",
+ password_hash=hash_password("password"),
+ full_name="Delete User",
+ is_admin=False,
+ is_active=True,
+ role=DEFAULT_USER_ROLE,
+ )
+ test_db_session.add(user)
+ test_db_session.commit()
+ test_db_session.refresh(user)
+
+ response = test_client.delete(
+ f"/api/v1/users/{user.id}",
+ headers=auth_headers,
+ )
+
+ # Returns 204 when deleted, 200 when set to inactive (has linked data)
+ assert response.status_code in [200, 204]
+
+ def test_delete_user_non_admin(
+ self,
+ test_client: TestClient,
+ regular_auth_headers: dict,
+ admin_user: "User",
+ ):
+ """Verify 403 for non-admin."""
+ response = test_client.delete(
+ f"/api/v1/users/{admin_user.id}",
+ headers=regular_auth_headers,
+ )
+
+ assert response.status_code in [403, 401]
+
+
+class TestUserActivation:
+ """Test user activation endpoints."""
+
+ def test_activate_user_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ inactive_user: "User",
+ ):
+ """Verify user activation."""
+ response = test_client.put(
+ f"/api/v1/users/{inactive_user.id}",
+ headers=auth_headers,
+ json={
+ "email": inactive_user.email,
+ "full_name": inactive_user.full_name or inactive_user.username,
+ "is_active": True,
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["is_active"] is True
+
+ def test_deactivate_user_success(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ regular_user: "User",
+ ):
+ """Verify user deactivation."""
+ response = test_client.put(
+ f"/api/v1/users/{regular_user.id}",
+ headers=auth_headers,
+ json={
+ "email": regular_user.email,
+ "full_name": regular_user.full_name or regular_user.username,
+ "is_active": False,
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["is_active"] is False
+
+ def test_activate_user_not_found(
+ self,
+ test_client: TestClient,
+ auth_headers: dict,
+ ):
+ """Verify 404 handling."""
+ response = test_client.put(
+ "/api/v1/users/99999",
+ headers=auth_headers,
+ json={
+ "email": "nonexistent@example.com",
+ "full_name": "Nonexistent User",
+ "is_active": True,
+ },
+ )
+
+ assert response.status_code == 404
+
diff --git a/tests/test_deepface_only.py b/tests/test_deepface_only.py
deleted file mode 100755
index 8d08ce9..0000000
--- a/tests/test_deepface_only.py
+++ /dev/null
@@ -1,399 +0,0 @@
-#!/usr/bin/env python3
-"""
-DeepFace Only Test Script
-
-Tests only DeepFace on a folder of photos for faster testing.
-
-Usage:
- python test_deepface_only.py /path/to/photos [--save-crops] [--verbose]
-
-Example:
- python test_deepface_only.py demo_photos/ --save-crops --verbose
-"""
-
-import os
-import sys
-import time
-import argparse
-from pathlib import Path
-from typing import List, Dict, Tuple, Optional
-import numpy as np
-import pandas as pd
-from PIL import Image
-
-# DeepFace library only
-from deepface import DeepFace
-
-# Supported image formats
-SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'}
-
-
-class DeepFaceTester:
- """Test DeepFace face recognition"""
-
- def __init__(self, verbose: bool = False):
- self.verbose = verbose
- self.results = {'faces': [], 'times': [], 'encodings': []}
-
- def log(self, message: str, level: str = "INFO"):
- """Print log message with timestamp"""
- if self.verbose or level == "ERROR":
- timestamp = time.strftime("%H:%M:%S")
- print(f"[{timestamp}] {level}: {message}")
-
- def get_image_files(self, folder_path: str) -> List[str]:
- """Get all supported image files from folder"""
- folder = Path(folder_path)
- if not folder.exists():
- raise FileNotFoundError(f"Folder not found: {folder_path}")
-
- image_files = []
- for file_path in folder.rglob("*"):
- if file_path.is_file() and file_path.suffix.lower() in SUPPORTED_FORMATS:
- image_files.append(str(file_path))
-
- self.log(f"Found {len(image_files)} image files")
- return sorted(image_files)
-
- def process_with_deepface(self, image_path: str) -> Dict:
- """Process image with deepface library"""
- start_time = time.time()
-
- try:
- # Use DeepFace to detect and encode faces
- results = DeepFace.represent(
- img_path=image_path,
- model_name='ArcFace', # Best accuracy model
- detector_backend='retinaface', # Best detection
- enforce_detection=False, # Don't fail if no faces
- align=True # Face alignment for better accuracy
- )
-
- if not results:
- return {'faces': [], 'encodings': [], 'processing_time': time.time() - start_time}
-
- # Convert to our format
- faces = []
- encodings = []
-
- for i, result in enumerate(results):
- # Extract face region info
- region = result.get('region', {})
- face_data = {
- 'image_path': image_path,
- 'face_id': f"df_{Path(image_path).stem}_{i}",
- 'location': (region.get('y', 0), region.get('x', 0) + region.get('w', 0),
- region.get('y', 0) + region.get('h', 0), region.get('x', 0)),
- 'bbox': region,
- 'encoding': np.array(result['embedding'])
- }
- faces.append(face_data)
- encodings.append(np.array(result['embedding']))
-
- processing_time = time.time() - start_time
- self.log(f"deepface: Found {len(faces)} faces in {processing_time:.2f}s")
-
- return {
- 'faces': faces,
- 'encodings': encodings,
- 'processing_time': processing_time
- }
-
- except Exception as e:
- self.log(f"deepface error on {image_path}: {e}", "ERROR")
- return {'faces': [], 'encodings': [], 'processing_time': time.time() - start_time}
-
- def calculate_similarity_matrix(self, encodings: List[np.ndarray]) -> np.ndarray:
- """Calculate similarity matrix between all face encodings using cosine distance"""
- n_faces = len(encodings)
- if n_faces == 0:
- return np.array([])
-
- similarity_matrix = np.zeros((n_faces, n_faces))
-
- for i in range(n_faces):
- for j in range(n_faces):
- if i == j:
- similarity_matrix[i, j] = 0.0 # Same face
- else:
- # Use cosine distance for ArcFace embeddings
- enc1_norm = encodings[i] / np.linalg.norm(encodings[i])
- enc2_norm = encodings[j] / np.linalg.norm(encodings[j])
- cosine_sim = np.dot(enc1_norm, enc2_norm)
- cosine_distance = 1 - cosine_sim
- similarity_matrix[i, j] = cosine_distance
-
- return similarity_matrix
-
- def find_top_matches(self, similarity_matrix: np.ndarray, faces: List[Dict],
- top_k: int = 5) -> List[Dict]:
- """Find top matches for each face"""
- top_matches = []
-
- for i, face in enumerate(faces):
- if i >= similarity_matrix.shape[0]:
- continue
-
- # Get distances to all other faces
- distances = similarity_matrix[i, :]
-
- # Find top matches (excluding self) - lower cosine distance = more similar
- sorted_indices = np.argsort(distances)
-
- matches = []
- for idx in sorted_indices[1:top_k+1]: # Skip self (index 0)
- if idx < len(faces):
- other_face = faces[idx]
- distance = distances[idx]
-
- # Convert to confidence percentage for display
- confidence = max(0, (1 - distance) * 100)
-
- matches.append({
- 'face_id': other_face['face_id'],
- 'image_path': other_face['image_path'],
- 'distance': distance,
- 'confidence': confidence
- })
-
- top_matches.append({
- 'query_face': face,
- 'matches': matches
- })
-
- return top_matches
-
- def save_face_crops(self, faces: List[Dict], output_dir: str):
- """Save face crops for manual inspection"""
- crops_dir = Path(output_dir) / "face_crops" / "deepface"
- crops_dir.mkdir(parents=True, exist_ok=True)
-
- for face in faces:
- try:
- # Load original image
- image = Image.open(face['image_path'])
-
- # Extract face region
- bbox = face['bbox']
- left = bbox.get('x', 0)
- top = bbox.get('y', 0)
- right = left + bbox.get('w', 0)
- bottom = top + bbox.get('h', 0)
-
- # Add padding
- padding = 20
- left = max(0, left - padding)
- top = max(0, top - padding)
- right = min(image.width, right + padding)
- bottom = min(image.height, bottom + padding)
-
- # Crop and save
- face_crop = image.crop((left, top, right, bottom))
- crop_path = crops_dir / f"{face['face_id']}.jpg"
- face_crop.save(crop_path, "JPEG", quality=95)
-
- except Exception as e:
- self.log(f"Error saving crop for {face['face_id']}: {e}", "ERROR")
-
- def save_similarity_matrix(self, matrix: np.ndarray, faces: List[Dict], output_dir: str):
- """Save similarity matrix as CSV file"""
- matrices_dir = Path(output_dir) / "similarity_matrices"
- matrices_dir.mkdir(parents=True, exist_ok=True)
-
- if matrix.size > 0:
- df = pd.DataFrame(matrix,
- index=[f['face_id'] for f in faces],
- columns=[f['face_id'] for f in faces])
- df.to_csv(matrices_dir / "deepface_similarity.csv")
-
- def generate_report(self, results: Dict, matches: List[Dict],
- output_dir: Optional[str] = None) -> str:
- """Generate analysis report"""
- report_lines = []
- report_lines.append("=" * 60)
- report_lines.append("DEEPFACE FACE RECOGNITION ANALYSIS")
- report_lines.append("=" * 60)
- report_lines.append("")
-
- # Summary statistics
- total_faces = len(results['faces'])
- total_time = sum(results['times'])
-
- report_lines.append("SUMMARY STATISTICS:")
- report_lines.append(f" Total faces detected: {total_faces}")
- report_lines.append(f" Total processing time: {total_time:.2f}s")
- if total_faces > 0:
- report_lines.append(f" Average time per face: {total_time/total_faces:.2f}s")
- report_lines.append("")
-
- # High confidence matches analysis
- def analyze_high_confidence_matches(matches: List[Dict], threshold: float = 70.0):
- high_conf_matches = []
- for match_data in matches:
- for match in match_data['matches']:
- if match['confidence'] >= threshold:
- high_conf_matches.append({
- 'query': match_data['query_face']['face_id'],
- 'match': match['face_id'],
- 'confidence': match['confidence'],
- 'query_image': match_data['query_face']['image_path'],
- 'match_image': match['image_path']
- })
- return high_conf_matches
-
- high_conf = analyze_high_confidence_matches(matches)
-
- report_lines.append("HIGH CONFIDENCE MATCHES (≥70%):")
- report_lines.append(f" Found: {len(high_conf)} matches")
- report_lines.append("")
-
- # Show top matches for manual inspection
- report_lines.append("TOP MATCHES FOR MANUAL INSPECTION:")
- report_lines.append("")
-
- for i, match_data in enumerate(matches[:5]): # Show first 5 faces
- query_face = match_data['query_face']
- report_lines.append(f"Query: {query_face['face_id']} ({Path(query_face['image_path']).name})")
- for match in match_data['matches'][:3]: # Top 3 matches
- report_lines.append(f" → {match['face_id']}: {match['confidence']:.1f}% ({Path(match['image_path']).name})")
- report_lines.append("")
-
- # Analysis
- report_lines.append("ANALYSIS:")
- if len(high_conf) > total_faces * 0.5:
- report_lines.append(" ⚠️ Many high-confidence matches found")
- report_lines.append(" This may indicate good face detection or potential false positives")
- elif len(high_conf) == 0:
- report_lines.append(" ✅ No high-confidence matches found")
- report_lines.append(" This suggests good separation between different people")
- else:
- report_lines.append(" 📊 Moderate number of high-confidence matches")
- report_lines.append(" Manual inspection recommended for verification")
-
- report_lines.append("")
- report_lines.append("=" * 60)
-
- report_text = "\n".join(report_lines)
-
- # Save report if output directory specified
- if output_dir:
- report_path = Path(output_dir) / "deepface_report.txt"
- with open(report_path, 'w') as f:
- f.write(report_text)
- self.log(f"Report saved to: {report_path}")
-
- return report_text
-
- def run_test(self, folder_path: str, save_crops: bool = False,
- save_matrices: bool = False) -> Dict:
- """Run the DeepFace face recognition test"""
- self.log(f"Starting DeepFace test on: {folder_path}")
-
- # Get image files
- image_files = self.get_image_files(folder_path)
- if not image_files:
- raise ValueError("No image files found in the specified folder")
-
- # Create output directory if needed
- output_dir = None
- if save_crops or save_matrices:
- output_dir = Path(folder_path).parent / "test_results"
- output_dir.mkdir(exist_ok=True)
-
- # Process images with DeepFace
- self.log("Processing images with DeepFace...")
- for image_path in image_files:
- result = self.process_with_deepface(image_path)
- self.results['faces'].extend(result['faces'])
- self.results['times'].append(result['processing_time'])
- self.results['encodings'].extend(result['encodings'])
-
- # Calculate similarity matrix
- self.log("Calculating similarity matrix...")
- matrix = self.calculate_similarity_matrix(self.results['encodings'])
-
- # Find top matches
- matches = self.find_top_matches(matrix, self.results['faces'])
-
- # Save outputs if requested
- if save_crops and output_dir:
- self.log("Saving face crops...")
- self.save_face_crops(self.results['faces'], str(output_dir))
-
- if save_matrices and output_dir:
- self.log("Saving similarity matrix...")
- self.save_similarity_matrix(matrix, self.results['faces'], str(output_dir))
-
- # Generate and display report
- report = self.generate_report(
- self.results, matches, str(output_dir) if output_dir else None
- )
-
- print(report)
-
- return {
- 'faces': self.results['faces'],
- 'matches': matches,
- 'matrix': matrix
- }
-
-
-def main():
- """Main CLI entry point"""
- parser = argparse.ArgumentParser(
- description="Test DeepFace on a folder of photos",
- formatter_class=argparse.RawDescriptionHelpFormatter,
- epilog="""
-Examples:
- python test_deepface_only.py demo_photos/
- python test_deepface_only.py demo_photos/ --save-crops --verbose
- python test_deepface_only.py demo_photos/ --save-matrices --save-crops
- """
- )
-
- parser.add_argument('folder', help='Path to folder containing photos to test')
- parser.add_argument('--save-crops', action='store_true',
- help='Save face crops for manual inspection')
- parser.add_argument('--save-matrices', action='store_true',
- help='Save similarity matrix as CSV file')
- parser.add_argument('--verbose', '-v', action='store_true',
- help='Enable verbose logging')
-
- args = parser.parse_args()
-
- # Validate folder path
- if not os.path.exists(args.folder):
- print(f"Error: Folder not found: {args.folder}")
- sys.exit(1)
-
- # Check dependencies
- try:
- from deepface import DeepFace
- except ImportError as e:
- print(f"Error: Missing required dependency: {e}")
- print("Please install with: pip install deepface")
- sys.exit(1)
-
- # Run test
- try:
- tester = DeepFaceTester(verbose=args.verbose)
- results = tester.run_test(
- args.folder,
- save_crops=args.save_crops,
- save_matrices=args.save_matrices
- )
-
- print("\n✅ DeepFace test completed successfully!")
- if args.save_crops or args.save_matrices:
- print(f"📁 Results saved to: {Path(args.folder).parent / 'test_results'}")
-
- except Exception as e:
- print(f"❌ Test failed: {e}")
- if args.verbose:
- import traceback
- traceback.print_exc()
- sys.exit(1)
-
-
-if __name__ == "__main__":
- main()
diff --git a/tests/test_exif_extraction.py b/tests/test_exif_extraction.py
deleted file mode 100755
index b953252..0000000
--- a/tests/test_exif_extraction.py
+++ /dev/null
@@ -1,115 +0,0 @@
-#!/usr/bin/env python3
-"""
-Test script to debug EXIF date extraction from photos.
-Run this to see what EXIF data is available in your photos.
-"""
-
-import sys
-import os
-from pathlib import Path
-from PIL import Image
-from datetime import datetime
-
-# Add src to path
-sys.path.insert(0, str(Path(__file__).parent.parent))
-
-from src.web.services.photo_service import extract_exif_date
-
-
-def test_exif_extraction(image_path: str):
- """Test EXIF extraction from a single image."""
- print(f"\n{'='*60}")
- print(f"Testing: {image_path}")
- print(f"{'='*60}")
-
- if not os.path.exists(image_path):
- print(f"❌ File not found: {image_path}")
- return
-
- # Try to open with PIL
- try:
- with Image.open(image_path) as img:
- print(f"✅ Image opened successfully")
- print(f" Format: {img.format}")
- print(f" Size: {img.size}")
-
- # Try getexif()
- exifdata = None
- try:
- exifdata = img.getexif()
- print(f"✅ getexif() worked: {len(exifdata) if exifdata else 0} tags")
- except Exception as e:
- print(f"❌ getexif() failed: {e}")
-
- # Try _getexif() (deprecated)
- old_exif = None
- try:
- if hasattr(img, '_getexif'):
- old_exif = img._getexif()
- print(f"✅ _getexif() worked: {len(old_exif) if old_exif else 0} tags")
- else:
- print(f"⚠️ _getexif() not available")
- except Exception as e:
- print(f"❌ _getexif() failed: {e}")
-
- # Check for specific date tags
- date_tags = {
- 306: "DateTime",
- 36867: "DateTimeOriginal",
- 36868: "DateTimeDigitized",
- }
-
- print(f"\n📅 Checking date tags:")
- found_any = False
-
- if exifdata:
- for tag_id, tag_name in date_tags.items():
- try:
- if tag_id in exifdata:
- value = exifdata[tag_id]
- print(f" ✅ {tag_name} ({tag_id}): {value}")
- found_any = True
- else:
- print(f" ❌ {tag_name} ({tag_id}): Not found")
- except Exception as e:
- print(f" ⚠️ {tag_name} ({tag_id}): Error - {e}")
-
- # Try EXIF IFD
- if exifdata and hasattr(exifdata, 'get_ifd'):
- try:
- exif_ifd = exifdata.get_ifd(0x8769)
- if exif_ifd:
- print(f"\n📋 EXIF IFD found: {len(exif_ifd)} tags")
- for tag_id, tag_name in date_tags.items():
- if tag_id in exif_ifd:
- value = exif_ifd[tag_id]
- print(f" ✅ {tag_name} ({tag_id}) in IFD: {value}")
- found_any = True
- except Exception as e:
- print(f" ⚠️ EXIF IFD access failed: {e}")
-
- if not found_any:
- print(f" ⚠️ No date tags found in EXIF data")
-
- # Try our extraction function
- print(f"\n🔍 Testing extract_exif_date():")
- extracted_date = extract_exif_date(image_path)
- if extracted_date:
- print(f" ✅ Extracted date: {extracted_date}")
- else:
- print(f" ❌ No date extracted")
-
- except Exception as e:
- print(f"❌ Error opening image: {e}")
-
-
-if __name__ == "__main__":
- if len(sys.argv) < 2:
- print("Usage: python test_exif_extraction.py ")
- print("\nExample:")
- print(" python test_exif_extraction.py /path/to/photo.jpg")
- sys.exit(1)
-
- image_path = sys.argv[1]
- test_exif_extraction(image_path)
-
diff --git a/tests/test_exif_orientation.py b/tests/test_exif_orientation.py
deleted file mode 100644
index 76dd0fa..0000000
--- a/tests/test_exif_orientation.py
+++ /dev/null
@@ -1,136 +0,0 @@
-#!/usr/bin/env python3
-"""
-Test script for EXIF orientation handling
-"""
-
-import sys
-import os
-sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
-
-from src.utils.exif_utils import EXIFOrientationHandler
-from PIL import Image
-import tempfile
-
-
-def test_exif_orientation_detection():
- """Test EXIF orientation detection"""
- print("🧪 Testing EXIF orientation detection...")
-
- # Test with any available images in the project
- test_dirs = [
- "/home/ladmin/Code/punimtag/demo_photos",
- "/home/ladmin/Code/punimtag/data"
- ]
-
- test_images = []
- for test_dir in test_dirs:
- if os.path.exists(test_dir):
- for file in os.listdir(test_dir):
- if file.lower().endswith(('.jpg', '.jpeg', '.png')):
- test_images.append(os.path.join(test_dir, file))
- if len(test_images) >= 2: # Limit to 2 images for testing
- break
-
- if not test_images:
- print(" ℹ️ No test images found - testing with coordinate transformation only")
- return
-
- for image_path in test_images:
- print(f"\n📸 Testing: {os.path.basename(image_path)}")
-
- # Get orientation info
- orientation = EXIFOrientationHandler.get_exif_orientation(image_path)
- orientation_info = EXIFOrientationHandler.get_orientation_info(image_path)
-
- print(f" Orientation: {orientation}")
- print(f" Description: {orientation_info['description']}")
- print(f" Needs correction: {orientation_info['needs_correction']}")
-
- if orientation and orientation != 1:
- print(f" ✅ EXIF orientation detected: {orientation}")
- else:
- print(f" ℹ️ No orientation correction needed")
-
-
-def test_coordinate_transformation():
- """Test face coordinate transformation"""
- print("\n🧪 Testing coordinate transformation...")
-
- # Test coordinates in DeepFace format
- test_coords = {'x': 100, 'y': 150, 'w': 200, 'h': 200}
- original_width, original_height = 800, 600
-
- print(f" Original coordinates: {test_coords}")
- print(f" Image dimensions: {original_width}x{original_height}")
-
- # Test different orientations
- test_orientations = [1, 3, 6, 8] # Normal, 180°, 90° CW, 90° CCW
-
- for orientation in test_orientations:
- transformed = EXIFOrientationHandler.transform_face_coordinates(
- test_coords, original_width, original_height, orientation
- )
- print(f" Orientation {orientation}: {transformed}")
-
-
-def test_image_correction():
- """Test image orientation correction"""
- print("\n🧪 Testing image orientation correction...")
-
- # Test with any available images
- test_dirs = [
- "/home/ladmin/Code/punimtag/demo_photos",
- "/home/ladmin/Code/punimtag/data"
- ]
-
- test_images = []
- for test_dir in test_dirs:
- if os.path.exists(test_dir):
- for file in os.listdir(test_dir):
- if file.lower().endswith(('.jpg', '.jpeg', '.png')):
- test_images.append(os.path.join(test_dir, file))
- if len(test_images) >= 1: # Limit to 1 image for testing
- break
-
- if not test_images:
- print(" ℹ️ No test images found - skipping image correction test")
- return
-
- for image_path in test_images:
- print(f"\n📸 Testing correction for: {os.path.basename(image_path)}")
-
- try:
- # Load and correct image
- corrected_image, orientation = EXIFOrientationHandler.correct_image_orientation_from_path(image_path)
-
- if corrected_image:
- print(f" ✅ Image loaded and corrected")
- print(f" Original orientation: {orientation}")
- print(f" Corrected dimensions: {corrected_image.size}")
-
- # Save corrected image to temp file for inspection
- with tempfile.NamedTemporaryFile(suffix='_corrected.jpg', delete=False) as tmp_file:
- corrected_image.save(tmp_file.name, quality=95)
- print(f" Corrected image saved to: {tmp_file.name}")
- else:
- print(f" ❌ Failed to load/correct image")
-
- except Exception as e:
- print(f" ❌ Error: {e}")
- break # Only test first image found
-
-
-def main():
- """Run all tests"""
- print("🔍 EXIF Orientation Handling Tests")
- print("=" * 50)
-
- test_exif_orientation_detection()
- test_coordinate_transformation()
- test_image_correction()
-
- print("\n✅ All tests completed!")
-
-
-if __name__ == "__main__":
- main()
diff --git a/tests/test_face_recognition.py b/tests/test_face_recognition.py
deleted file mode 100755
index aeb7b43..0000000
--- a/tests/test_face_recognition.py
+++ /dev/null
@@ -1,529 +0,0 @@
-#!/usr/bin/env python3
-"""
-Face Recognition Comparison Test Script
-
-Compares face_recognition vs deepface on a folder of photos.
-Tests accuracy and performance without modifying existing database.
-
-Usage:
- python test_face_recognition.py /path/to/photos [--save-crops] [--save-matrices] [--verbose]
-
-Example:
- python test_face_recognition.py demo_photos/ --save-crops --verbose
-"""
-
-import os
-import sys
-import time
-import argparse
-import tempfile
-from pathlib import Path
-from typing import List, Dict, Tuple, Optional
-import numpy as np
-import pandas as pd
-from PIL import Image
-
-# Face recognition libraries
-import face_recognition
-from deepface import DeepFace
-
-# Supported image formats
-SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'}
-
-
-class FaceRecognitionTester:
- """Test and compare face recognition libraries"""
-
- def __init__(self, verbose: bool = False):
- self.verbose = verbose
- self.results = {
- 'face_recognition': {'faces': [], 'times': [], 'encodings': []},
- 'deepface': {'faces': [], 'times': [], 'encodings': []}
- }
-
- def log(self, message: str, level: str = "INFO"):
- """Print log message with timestamp"""
- if self.verbose or level == "ERROR":
- timestamp = time.strftime("%H:%M:%S")
- print(f"[{timestamp}] {level}: {message}")
-
- def get_image_files(self, folder_path: str) -> List[str]:
- """Get all supported image files from folder"""
- folder = Path(folder_path)
- if not folder.exists():
- raise FileNotFoundError(f"Folder not found: {folder_path}")
-
- image_files = []
- for file_path in folder.rglob("*"):
- if file_path.is_file() and file_path.suffix.lower() in SUPPORTED_FORMATS:
- image_files.append(str(file_path))
-
- self.log(f"Found {len(image_files)} image files")
- return sorted(image_files)
-
- def process_with_face_recognition(self, image_path: str) -> Dict:
- """Process image with face_recognition library"""
- start_time = time.time()
-
- try:
- # Load image
- image = face_recognition.load_image_file(image_path)
-
- # Detect faces using CNN model (more accurate than HOG)
- face_locations = face_recognition.face_locations(image, model="cnn")
-
- if not face_locations:
- return {'faces': [], 'encodings': [], 'processing_time': time.time() - start_time}
-
- # Get face encodings
- face_encodings = face_recognition.face_encodings(image, face_locations)
-
- # Convert to our format
- faces = []
- encodings = []
-
- for i, (location, encoding) in enumerate(zip(face_locations, face_encodings)):
- # Convert face_recognition format to DeepFace format
- top, right, bottom, left = location
- face_data = {
- 'image_path': image_path,
- 'face_id': f"fr_{Path(image_path).stem}_{i}",
- 'location': location, # Keep original for compatibility
- 'bbox': {'x': left, 'y': top, 'w': right - left, 'h': bottom - top}, # DeepFace format
- 'encoding': encoding
- }
- faces.append(face_data)
- encodings.append(encoding)
-
- processing_time = time.time() - start_time
- self.log(f"face_recognition: Found {len(faces)} faces in {processing_time:.2f}s")
-
- return {
- 'faces': faces,
- 'encodings': encodings,
- 'processing_time': processing_time
- }
-
- except Exception as e:
- self.log(f"face_recognition error on {image_path}: {e}", "ERROR")
- return {'faces': [], 'encodings': [], 'processing_time': time.time() - start_time}
-
- def process_with_deepface(self, image_path: str) -> Dict:
- """Process image with deepface library"""
- start_time = time.time()
-
- try:
- # Use DeepFace to detect and encode faces
- results = DeepFace.represent(
- img_path=image_path,
- model_name='ArcFace', # Best accuracy model
- detector_backend='retinaface', # Best detection
- enforce_detection=False, # Don't fail if no faces
- align=True # Face alignment for better accuracy
- )
-
- if not results:
- return {'faces': [], 'encodings': [], 'processing_time': time.time() - start_time}
-
- # Convert to our format
- faces = []
- encodings = []
-
- for i, result in enumerate(results):
- # Extract face region info
- region = result.get('region', {})
- face_data = {
- 'image_path': image_path,
- 'face_id': f"df_{Path(image_path).stem}_{i}",
- 'location': (region.get('y', 0), region.get('x', 0) + region.get('w', 0),
- region.get('y', 0) + region.get('h', 0), region.get('x', 0)),
- 'bbox': region,
- 'encoding': np.array(result['embedding'])
- }
- faces.append(face_data)
- encodings.append(np.array(result['embedding']))
-
- processing_time = time.time() - start_time
- self.log(f"deepface: Found {len(faces)} faces in {processing_time:.2f}s")
-
- return {
- 'faces': faces,
- 'encodings': encodings,
- 'processing_time': processing_time
- }
-
- except Exception as e:
- self.log(f"deepface error on {image_path}: {e}", "ERROR")
- return {'faces': [], 'encodings': [], 'processing_time': time.time() - start_time}
-
- def calculate_similarity_matrix(self, encodings: List[np.ndarray], method: str) -> np.ndarray:
- """Calculate similarity matrix between all face encodings"""
- n_faces = len(encodings)
- if n_faces == 0:
- return np.array([])
-
- similarity_matrix = np.zeros((n_faces, n_faces))
-
- for i in range(n_faces):
- for j in range(n_faces):
- if i == j:
- similarity_matrix[i, j] = 0.0 # Same face
- else:
- if method == 'face_recognition':
- # Use face_recognition distance (lower = more similar)
- distance = face_recognition.face_distance([encodings[i]], encodings[j])[0]
- similarity_matrix[i, j] = distance
- else: # deepface
- # Use cosine distance for ArcFace embeddings
- enc1_norm = encodings[i] / np.linalg.norm(encodings[i])
- enc2_norm = encodings[j] / np.linalg.norm(encodings[j])
- cosine_sim = np.dot(enc1_norm, enc2_norm)
- cosine_distance = 1 - cosine_sim
- similarity_matrix[i, j] = cosine_distance
-
- return similarity_matrix
-
- def find_top_matches(self, similarity_matrix: np.ndarray, faces: List[Dict],
- method: str, top_k: int = 5) -> List[Dict]:
- """Find top matches for each face"""
- top_matches = []
-
- for i, face in enumerate(faces):
- if i >= similarity_matrix.shape[0]:
- continue
-
- # Get distances to all other faces
- distances = similarity_matrix[i, :]
-
- # Find top matches (excluding self)
- if method == 'face_recognition':
- # Lower distance = more similar
- sorted_indices = np.argsort(distances)
- else: # deepface
- # Lower cosine distance = more similar
- sorted_indices = np.argsort(distances)
-
- matches = []
- for idx in sorted_indices[1:top_k+1]: # Skip self (index 0)
- if idx < len(faces):
- other_face = faces[idx]
- distance = distances[idx]
-
- # Convert to confidence percentage for display
- if method == 'face_recognition':
- confidence = max(0, (1 - distance) * 100)
- else: # deepface
- confidence = max(0, (1 - distance) * 100)
-
- matches.append({
- 'face_id': other_face['face_id'],
- 'image_path': other_face['image_path'],
- 'distance': distance,
- 'confidence': confidence
- })
-
- top_matches.append({
- 'query_face': face,
- 'matches': matches
- })
-
- return top_matches
-
- def save_face_crops(self, faces: List[Dict], output_dir: str, method: str):
- """Save face crops for manual inspection"""
- crops_dir = Path(output_dir) / "face_crops" / method
- crops_dir.mkdir(parents=True, exist_ok=True)
-
- for face in faces:
- try:
- # Load original image
- image = Image.open(face['image_path'])
-
- # Extract face region - use DeepFace format for both
- if method == 'face_recognition':
- # Convert face_recognition format to DeepFace format
- top, right, bottom, left = face['location']
- left = left
- top = top
- right = right
- bottom = bottom
- else: # deepface
- bbox = face['bbox']
- left = bbox.get('x', 0)
- top = bbox.get('y', 0)
- right = left + bbox.get('w', 0)
- bottom = top + bbox.get('h', 0)
-
- # Add padding
- padding = 20
- left = max(0, left - padding)
- top = max(0, top - padding)
- right = min(image.width, right + padding)
- bottom = min(image.height, bottom + padding)
-
- # Crop and save
- face_crop = image.crop((left, top, right, bottom))
- crop_path = crops_dir / f"{face['face_id']}.jpg"
- face_crop.save(crop_path, "JPEG", quality=95)
-
- except Exception as e:
- self.log(f"Error saving crop for {face['face_id']}: {e}", "ERROR")
-
- def save_similarity_matrices(self, fr_matrix: np.ndarray, df_matrix: np.ndarray,
- fr_faces: List[Dict], df_faces: List[Dict], output_dir: str):
- """Save similarity matrices as CSV files"""
- matrices_dir = Path(output_dir) / "similarity_matrices"
- matrices_dir.mkdir(parents=True, exist_ok=True)
-
- # Save face_recognition matrix
- if fr_matrix.size > 0:
- fr_df = pd.DataFrame(fr_matrix,
- index=[f['face_id'] for f in fr_faces],
- columns=[f['face_id'] for f in fr_faces])
- fr_df.to_csv(matrices_dir / "face_recognition_similarity.csv")
-
- # Save deepface matrix
- if df_matrix.size > 0:
- df_df = pd.DataFrame(df_matrix,
- index=[f['face_id'] for f in df_faces],
- columns=[f['face_id'] for f in df_faces])
- df_df.to_csv(matrices_dir / "deepface_similarity.csv")
-
- def generate_report(self, fr_results: Dict, df_results: Dict,
- fr_matches: List[Dict], df_matches: List[Dict],
- output_dir: Optional[str] = None) -> str:
- """Generate comparison report"""
- report_lines = []
- report_lines.append("=" * 60)
- report_lines.append("FACE RECOGNITION COMPARISON REPORT")
- report_lines.append("=" * 60)
- report_lines.append("")
-
- # Summary statistics
- fr_total_faces = len(fr_results['faces'])
- df_total_faces = len(df_results['faces'])
- fr_total_time = sum(fr_results['times'])
- df_total_time = sum(df_results['times'])
-
- report_lines.append("SUMMARY STATISTICS:")
- report_lines.append(f" face_recognition: {fr_total_faces} faces in {fr_total_time:.2f}s")
- report_lines.append(f" deepface: {df_total_faces} faces in {df_total_time:.2f}s")
- report_lines.append(f" Speed ratio: {df_total_time/fr_total_time:.1f}x slower (deepface)")
- report_lines.append("")
-
- # High confidence matches analysis
- def analyze_high_confidence_matches(matches: List[Dict], method: str, threshold: float = 70.0):
- high_conf_matches = []
- for match_data in matches:
- for match in match_data['matches']:
- if match['confidence'] >= threshold:
- high_conf_matches.append({
- 'query': match_data['query_face']['face_id'],
- 'match': match['face_id'],
- 'confidence': match['confidence'],
- 'query_image': match_data['query_face']['image_path'],
- 'match_image': match['image_path']
- })
- return high_conf_matches
-
- fr_high_conf = analyze_high_confidence_matches(fr_matches, 'face_recognition')
- df_high_conf = analyze_high_confidence_matches(df_matches, 'deepface')
-
- report_lines.append("HIGH CONFIDENCE MATCHES (≥70%):")
- report_lines.append(f" face_recognition: {len(fr_high_conf)} matches")
- report_lines.append(f" deepface: {len(df_high_conf)} matches")
- report_lines.append("")
-
- # Show top matches for manual inspection
- report_lines.append("TOP MATCHES FOR MANUAL INSPECTION:")
- report_lines.append("")
-
- # face_recognition top matches
- report_lines.append("face_recognition top matches:")
- for i, match_data in enumerate(fr_matches[:3]): # Show first 3 faces
- query_face = match_data['query_face']
- report_lines.append(f" Query: {query_face['face_id']} ({Path(query_face['image_path']).name})")
- for match in match_data['matches'][:3]: # Top 3 matches
- report_lines.append(f" → {match['face_id']}: {match['confidence']:.1f}% ({Path(match['image_path']).name})")
- report_lines.append("")
-
- # deepface top matches
- report_lines.append("deepface top matches:")
- for i, match_data in enumerate(df_matches[:3]): # Show first 3 faces
- query_face = match_data['query_face']
- report_lines.append(f" Query: {query_face['face_id']} ({Path(query_face['image_path']).name})")
- for match in match_data['matches'][:3]: # Top 3 matches
- report_lines.append(f" → {match['face_id']}: {match['confidence']:.1f}% ({Path(match['image_path']).name})")
- report_lines.append("")
-
- # Recommendations
- report_lines.append("RECOMMENDATIONS:")
- if len(fr_high_conf) > len(df_high_conf) * 1.5:
- report_lines.append(" ⚠️ face_recognition shows significantly more high-confidence matches")
- report_lines.append(" This may indicate more false positives")
- if df_total_time > fr_total_time * 3:
- report_lines.append(" ⚠️ deepface is significantly slower")
- report_lines.append(" Consider GPU acceleration or faster models")
- if df_total_faces > fr_total_faces:
- report_lines.append(" ✅ deepface detected more faces")
- report_lines.append(" Better face detection in difficult conditions")
-
- report_lines.append("")
- report_lines.append("=" * 60)
-
- report_text = "\n".join(report_lines)
-
- # Save report if output directory specified
- if output_dir:
- report_path = Path(output_dir) / "comparison_report.txt"
- with open(report_path, 'w') as f:
- f.write(report_text)
- self.log(f"Report saved to: {report_path}")
-
- return report_text
-
- def run_test(self, folder_path: str, save_crops: bool = False,
- save_matrices: bool = False) -> Dict:
- """Run the complete face recognition comparison test"""
- self.log(f"Starting face recognition test on: {folder_path}")
-
- # Get image files
- image_files = self.get_image_files(folder_path)
- if not image_files:
- raise ValueError("No image files found in the specified folder")
-
- # Create output directory if needed
- output_dir = None
- if save_crops or save_matrices:
- output_dir = Path(folder_path).parent / "test_results"
- output_dir.mkdir(exist_ok=True)
-
- # Process images with both methods
- self.log("Processing images with face_recognition...")
- for image_path in image_files:
- result = self.process_with_face_recognition(image_path)
- self.results['face_recognition']['faces'].extend(result['faces'])
- self.results['face_recognition']['times'].append(result['processing_time'])
- self.results['face_recognition']['encodings'].extend(result['encodings'])
-
- self.log("Processing images with deepface...")
- for image_path in image_files:
- result = self.process_with_deepface(image_path)
- self.results['deepface']['faces'].extend(result['faces'])
- self.results['deepface']['times'].append(result['processing_time'])
- self.results['deepface']['encodings'].extend(result['encodings'])
-
- # Calculate similarity matrices
- self.log("Calculating similarity matrices...")
- fr_matrix = self.calculate_similarity_matrix(
- self.results['face_recognition']['encodings'], 'face_recognition'
- )
- df_matrix = self.calculate_similarity_matrix(
- self.results['deepface']['encodings'], 'deepface'
- )
-
- # Find top matches
- fr_matches = self.find_top_matches(
- fr_matrix, self.results['face_recognition']['faces'], 'face_recognition'
- )
- df_matches = self.find_top_matches(
- df_matrix, self.results['deepface']['faces'], 'deepface'
- )
-
- # Save outputs if requested
- if save_crops and output_dir:
- self.log("Saving face crops...")
- self.save_face_crops(self.results['face_recognition']['faces'], str(output_dir), 'face_recognition')
- self.save_face_crops(self.results['deepface']['faces'], str(output_dir), 'deepface')
-
- if save_matrices and output_dir:
- self.log("Saving similarity matrices...")
- self.save_similarity_matrices(
- fr_matrix, df_matrix,
- self.results['face_recognition']['faces'],
- self.results['deepface']['faces'],
- str(output_dir)
- )
-
- # Generate and display report
- report = self.generate_report(
- self.results['face_recognition'], self.results['deepface'],
- fr_matches, df_matches, str(output_dir) if output_dir else None
- )
-
- print(report)
-
- return {
- 'face_recognition': {
- 'faces': self.results['face_recognition']['faces'],
- 'matches': fr_matches,
- 'matrix': fr_matrix
- },
- 'deepface': {
- 'faces': self.results['deepface']['faces'],
- 'matches': df_matches,
- 'matrix': df_matrix
- }
- }
-
-
-def main():
- """Main CLI entry point"""
- parser = argparse.ArgumentParser(
- description="Compare face_recognition vs deepface on a folder of photos",
- formatter_class=argparse.RawDescriptionHelpFormatter,
- epilog="""
-Examples:
- python test_face_recognition.py demo_photos/
- python test_face_recognition.py demo_photos/ --save-crops --verbose
- python test_face_recognition.py demo_photos/ --save-matrices --save-crops
- """
- )
-
- parser.add_argument('folder', help='Path to folder containing photos to test')
- parser.add_argument('--save-crops', action='store_true',
- help='Save face crops for manual inspection')
- parser.add_argument('--save-matrices', action='store_true',
- help='Save similarity matrices as CSV files')
- parser.add_argument('--verbose', '-v', action='store_true',
- help='Enable verbose logging')
-
- args = parser.parse_args()
-
- # Validate folder path
- if not os.path.exists(args.folder):
- print(f"Error: Folder not found: {args.folder}")
- sys.exit(1)
-
- # Check dependencies
- try:
- import face_recognition
- from deepface import DeepFace
- except ImportError as e:
- print(f"Error: Missing required dependency: {e}")
- print("Please install with: pip install face_recognition deepface")
- sys.exit(1)
-
- # Run test
- try:
- tester = FaceRecognitionTester(verbose=args.verbose)
- results = tester.run_test(
- args.folder,
- save_crops=args.save_crops,
- save_matrices=args.save_matrices
- )
-
- print("\n✅ Test completed successfully!")
- if args.save_crops or args.save_matrices:
- print(f"📁 Results saved to: {Path(args.folder).parent / 'test_results'}")
-
- except Exception as e:
- print(f"❌ Test failed: {e}")
- if args.verbose:
- import traceback
- traceback.print_exc()
- sys.exit(1)
-
-
-if __name__ == "__main__":
- main()
diff --git a/tests/test_pending_linkages_api.py b/tests/test_pending_linkages_api.py
deleted file mode 100644
index db4da7e..0000000
--- a/tests/test_pending_linkages_api.py
+++ /dev/null
@@ -1,264 +0,0 @@
-from __future__ import annotations
-
-from typing import Generator
-
-import pytest
-from fastapi.testclient import TestClient
-from sqlalchemy import create_engine, text
-from sqlalchemy.orm import Session, sessionmaker
-from sqlalchemy.pool import StaticPool
-
-from src.web.app import app
-from src.web.db import models
-from src.web.db.models import Photo, PhotoTagLinkage, Tag, User
-from src.web.db.session import get_auth_db, get_db
-from src.web.constants.roles import DEFAULT_ADMIN_ROLE
-from src.web.api.auth import get_current_user
-
-
-# Create isolated in-memory databases for main and auth stores.
-main_engine = create_engine(
- "sqlite://",
- connect_args={"check_same_thread": False},
- poolclass=StaticPool,
-)
-auth_engine = create_engine(
- "sqlite://",
- connect_args={"check_same_thread": False},
- poolclass=StaticPool,
-)
-
-MainSessionLocal = sessionmaker(
- bind=main_engine, autoflush=False, autocommit=False, future=True
-)
-AuthSessionLocal = sessionmaker(
- bind=auth_engine, autoflush=False, autocommit=False, future=True
-)
-
-models.Base.metadata.create_all(bind=main_engine)
-
-with auth_engine.begin() as connection:
- connection.execute(
- text(
- """
- CREATE TABLE users (
- id INTEGER PRIMARY KEY,
- name TEXT,
- email TEXT
- )
- """
- )
- )
- connection.execute(
- text(
- """
- CREATE TABLE pending_linkages (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- photo_id INTEGER NOT NULL,
- tag_id INTEGER,
- tag_name VARCHAR(255),
- user_id INTEGER NOT NULL,
- status VARCHAR(50) DEFAULT 'pending',
- notes TEXT,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- )
- """
- )
- )
-
-
-def override_get_db() -> Generator[Session, None, None]:
- db = MainSessionLocal()
- try:
- yield db
- finally:
- db.close()
-
-
-def override_get_auth_db() -> Generator[Session, None, None]:
- db = AuthSessionLocal()
- try:
- yield db
- finally:
- db.close()
-
-
-def override_get_current_user() -> dict[str, str]:
- return {"username": "admin"}
-
-
-app.dependency_overrides[get_db] = override_get_db
-app.dependency_overrides[get_auth_db] = override_get_auth_db
-app.dependency_overrides[get_current_user] = override_get_current_user
-
-client = TestClient(app)
-
-
-def _ensure_admin_user() -> None:
- with MainSessionLocal() as session:
- existing = session.query(User).filter(User.username == "admin").first()
- if existing:
- existing.is_admin = True
- existing.role = DEFAULT_ADMIN_ROLE
- session.commit()
- return
-
- admin_user = User(
- username="admin",
- password_hash="test",
- email="admin@example.com",
- full_name="Admin",
- is_active=True,
- is_admin=True,
- role=DEFAULT_ADMIN_ROLE,
- )
- session.add(admin_user)
- session.commit()
-
-
-@pytest.fixture(autouse=True)
-def clean_databases() -> Generator[None, None, None]:
- with MainSessionLocal() as session:
- session.query(PhotoTagLinkage).delete()
- session.query(Tag).delete()
- session.query(Photo).delete()
- session.query(User).filter(User.username != "admin").delete()
- session.commit()
-
- with AuthSessionLocal() as session:
- session.execute(text("DELETE FROM pending_linkages"))
- session.execute(text("DELETE FROM users"))
- session.commit()
-
- _ensure_admin_user()
- yield
-
-
-def _insert_auth_user(user_id: int = 1) -> None:
- with auth_engine.begin() as connection:
- connection.execute(
- text(
- """
- INSERT INTO users (id, name, email)
- VALUES (:id, :name, :email)
- """
- ),
- {"id": user_id, "name": "Tester", "email": "tester@example.com"},
- )
-
-
-def _insert_pending_linkage(
- photo_id: int,
- *,
- tag_id: int | None = None,
- tag_name: str | None = None,
- status: str = "pending",
- user_id: int = 1,
-) -> int:
- with auth_engine.begin() as connection:
- result = connection.execute(
- text(
- """
- INSERT INTO pending_linkages (
- photo_id, tag_id, tag_name, user_id, status, notes
- )
- VALUES (:photo_id, :tag_id, :tag_name, :user_id, :status, 'note')
- """
- ),
- {
- "photo_id": photo_id,
- "tag_id": tag_id,
- "tag_name": tag_name,
- "user_id": user_id,
- "status": status,
- },
- )
- return int(result.lastrowid)
-
-
-def _create_photo(path: str, filename: str, file_hash: str) -> int:
- with MainSessionLocal() as session:
- photo = Photo(path=path, filename=filename, file_hash=file_hash)
- session.add(photo)
- session.commit()
- session.refresh(photo)
- return photo.id
-
-
-def test_list_pending_linkages_returns_existing_rows():
- _ensure_admin_user()
- photo_id = _create_photo("/tmp/photo1.jpg", "photo1.jpg", "hash1")
- _insert_auth_user()
- linkage_id = _insert_pending_linkage(photo_id, tag_name="Beach Day")
-
- response = client.get("/api/v1/pending-linkages")
- assert response.status_code == 200
-
- payload = response.json()
- assert payload["total"] == 1
- item = payload["items"][0]
- assert item["photo_id"] == photo_id
- assert item["proposed_tag_name"] == "Beach Day"
- assert item["status"] == "pending"
-
-
-def test_review_pending_linkages_creates_tag_and_linkage():
- _ensure_admin_user()
- photo_id = _create_photo("/tmp/photo2.jpg", "photo2.jpg", "hash2")
- _insert_auth_user()
- linkage_id = _insert_pending_linkage(photo_id, tag_name="Sunset Crew")
-
- response = client.post(
- "/api/v1/pending-linkages/review",
- json={"decisions": [{"id": linkage_id, "decision": "approve"}]},
- )
- assert response.status_code == 200
-
- payload = response.json()
- assert payload["approved"] == 1
- assert payload["denied"] == 0
- assert payload["tags_created"] == 1
- assert payload["linkages_created"] == 1
-
- with MainSessionLocal() as session:
- tags = session.query(Tag).all()
- assert len(tags) == 1
- assert tags[0].tag_name == "Sunset Crew"
- linkage = session.query(PhotoTagLinkage).first()
- assert linkage is not None
- assert linkage.photo_id == photo_id
- assert linkage.tag_id == tags[0].id
-
- with AuthSessionLocal() as session:
- statuses = session.execute(
- text("SELECT status FROM pending_linkages WHERE id = :id"),
- {"id": linkage_id},
- ).fetchone()
- assert statuses is not None
- assert statuses[0] == "approved"
-
-
-def test_cleanup_pending_linkages_deletes_approved_and_denied():
- _ensure_admin_user()
- photo_id = _create_photo("/tmp/photo3.jpg", "photo3.jpg", "hash3")
- _insert_auth_user()
-
- approved_id = _insert_pending_linkage(photo_id, tag_name="Approved Tag", status="approved")
- denied_id = _insert_pending_linkage(photo_id, tag_name="Denied Tag", status="denied")
- pending_id = _insert_pending_linkage(photo_id, tag_name="Pending Tag", status="pending")
-
- response = client.post("/api/v1/pending-linkages/cleanup")
- assert response.status_code == 200
-
- payload = response.json()
- assert payload["deleted_records"] == 2
-
- with AuthSessionLocal() as session:
- remaining = session.execute(
- text("SELECT id, status FROM pending_linkages ORDER BY id")
- ).fetchall()
- assert len(remaining) == 1
- assert remaining[0][0] == pending_id
- assert remaining[0][1] == "pending"
-
diff --git a/tests/test_phase3_identify_api.py b/tests/test_phase3_identify_api.py
deleted file mode 100644
index 6bf0c11..0000000
--- a/tests/test_phase3_identify_api.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from __future__ import annotations
-
-from fastapi.testclient import TestClient
-
-from src.web.app import app
-
-
-client = TestClient(app)
-
-
-def test_people_list_empty():
- res = client.get('/api/v1/people')
- assert res.status_code == 200
- data = res.json()
- assert 'items' in data and isinstance(data['items'], list)
-
-
-def test_unidentified_faces_empty():
- res = client.get('/api/v1/faces/unidentified')
- assert res.status_code == 200
- data = res.json()
- assert data['total'] >= 0
-
-
-
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/.env_example b/viewer-frontend/.env_example
new file mode 100644
index 0000000..73e83a3
--- /dev/null
+++ b/viewer-frontend/.env_example
@@ -0,0 +1,15 @@
+# Viewer frontend env (copy to ".env" and edit values)
+
+# Prisma DB URLs (note: no "+psycopg2" here)
+DATABASE_URL=postgresql://punimtag:CHANGE_ME@127.0.0.1:5432/punimtag
+DATABASE_URL_AUTH=postgresql://punimtag_auth:CHANGE_ME@127.0.0.1:5432/punimtag_auth
+
+
+# NextAuth
+NEXTAUTH_URL=http://127.0.0.1:3001
+NEXTAUTH_SECRET=CHANGE_ME_TO_A_LONG_RANDOM_STRING
+AUTH_URL=http://127.0.0.1:3001
+
+RESEND_API_KEY=CHANGE_ME_secret-key
+RESEND_FROM_EMAIL="onboarding@resend.dev"
+UPLOAD_DIR="/mnt/db-server-uploads/pending-photos"
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..23c8bc6
--- /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 as any).faces,
+ hasFace: !!(existingPhoto as any).Face,
+ facesCount: (existingPhoto as any).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..e380749
--- /dev/null
+++ b/viewer-frontend/app/admin/users/ManageUsersContent.tsx
@@ -0,0 +1,670 @@
+'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');
+ // Filter out the default admin user (admin@admin.com)
+ const filteredUsers = data.users.filter(
+ (user: User) => user.email?.toLowerCase() !== 'admin@admin.com'
+ );
+ setUsers(filteredUsers);
+ }
+ } 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 (
+
+ );
+ }
+
+ return (
+
+
+
+
Manage Users
+
+ Manage user accounts and permissions
+
+
+
+
+
+ User Status:
+
+ {
+ console.log('[ManageUsers] Filter changed to:', value);
+ setStatusFilter(value as UserStatusFilter);
+ }}
+ >
+
+
+
+
+ All
+ Active only
+ Inactive only
+
+
+
+
setIsAddDialogOpen(true)}>
+
+ Add User
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {successMessage && (
+
+ {successMessage}
+
+ )}
+
+
+
+
+
+
+ Email
+ Name
+ Status
+ Role
+ Write Access
+ Created
+ Actions
+
+
+
+ {users.map((user) => (
+
+ {user.email}
+ {user.name || '-'}
+
+ {user.isActive === false ? (
+ Inactive
+ ) : (
+ Active
+ )}
+
+
+ {user.isAdmin ? (
+ Admin
+ ) : (
+ User
+ )}
+
+
+
+ {user.hasWriteAccess ? 'Yes' : 'No'}
+
+
+
+ {new Date(user.createdAt).toLocaleDateString()}
+
+
+
+ openEditDialog(user)}
+ >
+
+
+ {
+ setUserToDelete(user);
+ setDeleteConfirmOpen(true);
+ }}
+ className="text-red-600 hover:text-red-700"
+ >
+
+
+
+
+
+ ))}
+
+
+
+
+
+ {/* Add User Dialog */}
+
+
+
+ Add New User
+
+ Create a new user account. Write access can be granted later.
+
+
+
+
+
+ Email *
+
+
+ setFormData({ ...formData, email: e.target.value })
+ }
+ placeholder="user@example.com"
+ />
+
+
+
+ Password *
+
+
+ setFormData({ ...formData, password: e.target.value })
+ }
+ placeholder="Minimum 6 characters"
+ />
+
+
+
+ Name *
+
+
+ setFormData({ ...formData, name: e.target.value })
+ }
+ placeholder="Enter full name"
+ required
+ />
+
+
+
+ Role *
+
+
+ setFormData({
+ ...formData,
+ isAdmin: value === 'admin',
+ hasWriteAccess: value === 'admin' ? true : formData.hasWriteAccess
+ })
+ }
+ >
+
+
+
+
+ User
+ Admin
+
+
+
+
+
+ setFormData({ ...formData, hasWriteAccess: !!checked })
+ }
+ />
+
+ Grant Write Access
+
+
+
+
+ {
+ setIsAddDialogOpen(false);
+ setFormData({ email: '', password: '', name: '', hasWriteAccess: false, isAdmin: false, isActive: true });
+ }}
+ >
+ Cancel
+
+ Create User
+
+
+
+
+ {/* Edit User Dialog */}
+
+
+
+ Edit User
+
+ Update user information. Leave password blank to keep current password.
+
+
+
+
+
+ Email *
+
+
+ setFormData({ ...formData, email: e.target.value })
+ }
+ placeholder="user@example.com"
+ />
+
+
+
+ New Password (leave empty to keep current)
+
+
+ setFormData({ ...formData, password: e.target.value })
+ }
+ placeholder="Leave blank to keep current password"
+ />
+
+
+
+ Name *
+
+
+ setFormData({ ...formData, name: e.target.value })
+ }
+ placeholder="Enter full name"
+ required
+ />
+
+
+
+ Role *
+
+
+ setFormData({
+ ...formData,
+ isAdmin: value === 'admin',
+ hasWriteAccess: value === 'admin' ? true : formData.hasWriteAccess
+ })
+ }
+ >
+
+
+
+
+ User
+ Admin
+
+
+
+
+
+ setFormData({ ...formData, hasWriteAccess: !!checked })
+ }
+ />
+
+ Grant Write Access
+
+
+
+
+ setFormData({ ...formData, isActive: !!checked })
+ }
+ />
+
+ Active
+
+
+
+
+ {
+ setIsEditDialogOpen(false);
+ setEditingUser(null);
+ setFormData({ email: '', password: '', name: '', hasWriteAccess: false, isAdmin: false, isActive: true });
+ }}
+ >
+ Cancel
+
+ Save Changes
+
+
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+
+ Delete User
+
+ Are you sure you want to delete {userToDelete?.email}? This action
+ cannot be undone.
+
+
+
+ {
+ setDeleteConfirmOpen(false);
+ setUserToDelete(null);
+ }}
+ >
+ Cancel
+
+
+ Delete
+
+
+
+
+
+ );
+}
+
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 */}
+
+
+
+ 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..244c9b4
--- /dev/null
+++ b/viewer-frontend/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,156 @@
+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,
+ trustHost: true, // Trust all hosts (for development/internal network)
+ 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..ca328b7
--- /dev/null
+++ b/viewer-frontend/app/api/auth/forgot-password/route.ts
@@ -0,0 +1,108 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prismaAuth } from '@/lib/db';
+import { isValidEmail } from '@/lib/utils';
+
+// Force dynamic rendering to prevent Resend initialization during build
+export const dynamic = 'force-dynamic';
+
+export async function POST(request: NextRequest) {
+ try {
+ // Dynamically import email functions to avoid Resend initialization during build
+ const { generatePasswordResetToken, sendPasswordResetEmail } = await import('@/lib/email');
+
+ 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..f4f1567
--- /dev/null
+++ b/viewer-frontend/app/api/auth/register/route.ts
@@ -0,0 +1,110 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prismaAuth } from '@/lib/db';
+import bcrypt from 'bcryptjs';
+import { isValidEmail } from '@/lib/utils';
+
+// Force dynamic rendering to prevent Resend initialization during build
+export const dynamic = 'force-dynamic';
+
+export async function POST(request: NextRequest) {
+ try {
+ // Dynamically import email functions to avoid Resend initialization during build
+ const { generateEmailConfirmationToken, sendEmailConfirmation } = await import('@/lib/email');
+
+ 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..4198cd0
--- /dev/null
+++ b/viewer-frontend/app/api/auth/resend-confirmation/route.ts
@@ -0,0 +1,86 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prismaAuth } from '@/lib/db';
+
+// Force dynamic rendering to prevent Resend initialization during build
+export const dynamic = 'force-dynamic';
+
+export async function POST(request: NextRequest) {
+ try {
+ // Dynamically import email functions to avoid Resend initialization during build
+ const { generateEmailConfirmationToken, sendEmailConfirmationResend } = await import('@/lib/email');
+
+ 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/photos/[id]/adjacent/route.ts b/viewer-frontend/app/api/photos/[id]/adjacent/route.ts
new file mode 100644
index 0000000..d664ebb
--- /dev/null
+++ b/viewer-frontend/app/api/photos/[id]/adjacent/route.ts
@@ -0,0 +1,94 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prisma } from '@/lib/db';
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params;
+ const photoId = parseInt(id, 10);
+
+ if (isNaN(photoId)) {
+ return NextResponse.json(
+ { error: 'Invalid photo ID' },
+ { status: 400 }
+ );
+ }
+
+ // Get the current photo to find its date_taken for ordering
+ const currentPhoto = await prisma.photo.findUnique({
+ where: { id: photoId },
+ select: { date_taken: true },
+ });
+
+ if (!currentPhoto) {
+ return NextResponse.json(
+ { error: 'Photo not found' },
+ { status: 404 }
+ );
+ }
+
+ // Get previous photo (earlier date_taken, or same date_taken with higher ID)
+ const previousPhoto = await prisma.photo.findFirst({
+ where: {
+ processed: true,
+ OR: [
+ {
+ date_taken: {
+ lt: currentPhoto.date_taken || new Date(),
+ },
+ },
+ {
+ date_taken: currentPhoto.date_taken,
+ id: {
+ gt: photoId,
+ },
+ },
+ ],
+ },
+ orderBy: [
+ { date_taken: 'desc' },
+ { id: 'asc' },
+ ],
+ select: { id: true },
+ });
+
+ // Get next photo (later date_taken, or same date_taken with lower ID)
+ const nextPhoto = await prisma.photo.findFirst({
+ where: {
+ processed: true,
+ OR: [
+ {
+ date_taken: {
+ gt: currentPhoto.date_taken || new Date(),
+ },
+ },
+ {
+ date_taken: currentPhoto.date_taken,
+ id: {
+ lt: photoId,
+ },
+ },
+ ],
+ },
+ orderBy: [
+ { date_taken: 'asc' },
+ { id: 'desc' },
+ ],
+ select: { id: true },
+ });
+
+ return NextResponse.json({
+ previous: previousPhoto?.id || null,
+ next: nextPhoto?.id || null,
+ });
+ } catch (error) {
+ console.error('Error fetching adjacent photos:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch adjacent photos' },
+ { status: 500 }
+ );
+ }
+}
+
diff --git a/viewer-frontend/app/api/photos/[id]/favorite/route.ts b/viewer-frontend/app/api/photos/[id]/favorite/route.ts
new file mode 100644
index 0000000..816b608
--- /dev/null
+++ b/viewer-frontend/app/api/photos/[id]/favorite/route.ts
@@ -0,0 +1,161 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prisma, prismaAuth } from '@/lib/db';
+import { auth } from '@/app/api/auth/[...nextauth]/route';
+
+// POST - Toggle favorite (add if not exists, remove if exists)
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { error: 'Authentication required. Please sign in to favorite photos.' },
+ { status: 401 }
+ );
+ }
+
+ const { id } = await params;
+ const photoId = parseInt(id, 10);
+ const userId = parseInt(session.user.id, 10);
+
+ if (isNaN(photoId)) {
+ return NextResponse.json(
+ { error: 'Invalid photo ID' },
+ { status: 400 }
+ );
+ }
+
+ if (isNaN(userId)) {
+ return NextResponse.json(
+ { error: 'Invalid user ID' },
+ { status: 400 }
+ );
+ }
+
+ // Verify photo exists in main database
+ const photo = await prisma.photo.findUnique({
+ where: { id: photoId },
+ select: { id: true, filename: true },
+ });
+
+ if (!photo) {
+ return NextResponse.json(
+ { error: 'Photo not found' },
+ { status: 404 }
+ );
+ }
+
+ // Verify user exists in auth database
+ const user = await prismaAuth.user.findUnique({
+ where: { id: userId },
+ select: { id: true },
+ });
+
+ if (!user) {
+ console.error(`[FAVORITE] User ${userId} not found in auth database`);
+ return NextResponse.json(
+ { error: 'User account not found. Please sign in again.' },
+ { status: 404 }
+ );
+ }
+
+ // Check if favorite already exists
+ const existing = await prismaAuth.photoFavorite.findUnique({
+ where: {
+ uq_photo_user_favorite: {
+ photoId,
+ userId,
+ },
+ },
+ });
+
+ if (existing) {
+ // Remove favorite
+ await prismaAuth.photoFavorite.delete({
+ where: { id: existing.id },
+ });
+ return NextResponse.json({
+ favorited: false,
+ message: 'Photo removed from favorites'
+ });
+ } else {
+ // Add favorite
+ await prismaAuth.photoFavorite.create({
+ data: {
+ photoId,
+ userId,
+ favoritedAt: new Date(),
+ },
+ });
+ return NextResponse.json({
+ favorited: true,
+ message: 'Photo added to favorites'
+ });
+ }
+ } catch (error: any) {
+ console.error('Error toggling favorite:', error);
+
+ // Handle case where table doesn't exist yet (P2021 = table does not exist)
+ if (error.code === 'P2021') {
+ return NextResponse.json(
+ { error: 'Favorites feature is not available yet. Please run the migration: migrations/add-photo-favorites-table.sql' },
+ { status: 503 }
+ );
+ }
+
+ if (error.code === 'P2002') {
+ return NextResponse.json(
+ { error: 'Favorite already exists' },
+ { status: 409 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: 'Failed to toggle favorite', details: error.message },
+ { status: 500 }
+ );
+ }
+}
+
+// GET - Check if photo is favorited
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ favorited: false });
+ }
+
+ const { id } = await params;
+ const photoId = parseInt(id, 10);
+ const userId = parseInt(session.user.id, 10);
+
+ if (isNaN(photoId) || isNaN(userId)) {
+ return NextResponse.json({ favorited: false });
+ }
+
+ const favorite = await prismaAuth.photoFavorite.findUnique({
+ where: {
+ uq_photo_user_favorite: {
+ photoId,
+ userId,
+ },
+ },
+ });
+
+ return NextResponse.json({ favorited: !!favorite });
+ } catch (error: any) {
+ // Handle case where table doesn't exist yet (P2021 = table does not exist)
+ if (error.code === 'P2021') {
+ // Table doesn't exist yet, return false (not favorited)
+ return NextResponse.json({ favorited: false });
+ }
+ console.error('Error checking favorite:', error);
+ return NextResponse.json({ favorited: false });
+ }
+}
+
diff --git a/viewer-frontend/app/api/photos/[id]/image/route.ts b/viewer-frontend/app/api/photos/[id]/image/route.ts
new file mode 100644
index 0000000..e73e78e
--- /dev/null
+++ b/viewer-frontend/app/api/photos/[id]/image/route.ts
@@ -0,0 +1,327 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prisma } from '@/lib/db';
+import { readFile } from 'fs/promises';
+import { createReadStream } from 'fs';
+import { existsSync, statSync } from 'fs';
+import path from 'path';
+
+// Conditionally import sharp - handle case where libvips is not installed
+let sharp: any = null;
+try {
+ sharp = require('sharp');
+} catch (error) {
+ console.warn('Sharp library not available. Watermarking and image processing will be disabled.');
+ console.warn('To enable image processing, install libvips: sudo apt-get install libvips-dev');
+}
+
+const WATERMARK_BUCKET_SIZE = 200;
+const watermarkCache = new Map();
+
+async function getWatermarkOverlay(baseWidth: number, baseHeight: number) {
+ if (!sharp) {
+ throw new Error('Sharp library not available');
+ }
+
+ const bucketWidth = Math.ceil(baseWidth / WATERMARK_BUCKET_SIZE) * WATERMARK_BUCKET_SIZE;
+ const bucketHeight = Math.ceil(baseHeight / WATERMARK_BUCKET_SIZE) * WATERMARK_BUCKET_SIZE;
+ const cacheKey = `${bucketWidth}x${bucketHeight}`;
+
+ let cached: Buffer | undefined = watermarkCache.get(cacheKey);
+ if (!cached) {
+ const diagonal = Math.sqrt(bucketWidth * bucketWidth + bucketHeight * bucketHeight);
+ const watermarkWidth = Math.round(diagonal * 1.05);
+ const watermarkHeight = Math.round((watermarkWidth * 120) / 360);
+ const watermarkSvg = `
+
+
+
+
+
+
+
+
+
+ J
+ M
+
+
+
+ Jewish
+ and Modern
+
+
+ `;
+
+ const rotationAngle = -Math.atan(bucketHeight / bucketWidth) * (180 / Math.PI);
+ const newCached = await sharp(Buffer.from(watermarkSvg))
+ .resize({
+ width: watermarkWidth,
+ height: watermarkHeight,
+ fit: 'inside',
+ withoutEnlargement: true,
+ })
+ .rotate(rotationAngle, { background: { r: 0, g: 0, b: 0, alpha: 0 } })
+ .resize({
+ width: bucketWidth,
+ height: bucketHeight,
+ fit: 'cover',
+ position: 'centre',
+ })
+ .ensureAlpha()
+ .toBuffer();
+
+ watermarkCache.set(cacheKey, newCached);
+ cached = newCached;
+ }
+
+ // cached is guaranteed to be defined here (either from cache or just created)
+ return sharp(cached)
+ .resize({
+ width: baseWidth,
+ height: baseHeight,
+ fit: 'cover',
+ position: 'centre',
+ })
+ .ensureAlpha()
+ .toBuffer();
+}
+
+/**
+ * Get content type from file extension
+ */
+function getContentType(filePath: string, mediaType?: string): string {
+ const ext = path.extname(filePath).toLowerCase();
+
+ // If media_type is video, prioritize video MIME types
+ if (mediaType === 'video') {
+ if (ext === '.mp4') return 'video/mp4';
+ if (ext === '.webm') return 'video/webm';
+ if (ext === '.mov') return 'video/quicktime';
+ if (ext === '.avi') return 'video/x-msvideo';
+ if (ext === '.mkv') return 'video/x-matroska';
+ return 'video/mp4'; // default
+ }
+
+ // Image types
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
+ if (ext === '.png') return 'image/png';
+ if (ext === '.gif') return 'image/gif';
+ if (ext === '.webp') return 'image/webp';
+ if (ext === '.heic') return 'image/heic';
+ if (ext === '.heif') return 'image/heif';
+ return 'image/jpeg'; // default
+}
+
+/**
+ * Handle HTTP range requests for video streaming
+ */
+function parseRange(range: string | null, fileSize: number): { start: number; end: number } | null {
+ if (!range) return null;
+
+ const parts = range.replace(/bytes=/, '').split('-');
+ const start = parseInt(parts[0], 10);
+ const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
+
+ if (isNaN(start) || isNaN(end) || start > end || start < 0) {
+ return null;
+ }
+
+ return { start, end };
+}
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params;
+ const photoId = parseInt(id);
+
+ if (isNaN(photoId)) {
+ return new NextResponse('Invalid photo ID', { status: 400 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const thumbnail = searchParams.get('thumbnail') === 'true';
+ const watermark = searchParams.get('watermark') === 'true';
+
+ // Fetch photo from database
+ const photo = await prisma.photo.findUnique({
+ where: { id: photoId },
+ select: { path: true, filename: true, media_type: true },
+ });
+
+ if (!photo?.path) {
+ return new NextResponse('Photo not found', { status: 404 });
+ }
+
+ const filePath = photo.path;
+ const mediaType = photo.media_type || 'image';
+
+ // Check if file exists
+ if (!existsSync(filePath)) {
+ console.error(`File not found: ${filePath}`);
+ return new NextResponse('File not found on server', { status: 404 });
+ }
+
+ // Handle video thumbnail request
+ if (thumbnail && mediaType === 'video') {
+ const backendBaseUrl = process.env.BACKEND_BASE_URL || 'http://127.0.0.1:8000';
+
+ try {
+ const backendResponse = await fetch(
+ `${backendBaseUrl}/api/v1/videos/${photoId}/thumbnail`,
+ { headers: { Accept: 'image/*' } }
+ );
+
+ if (!backendResponse.ok) {
+ console.error(
+ `Backend thumbnail fetch failed for video ${photoId}: ` +
+ `${backendResponse.status} ${backendResponse.statusText}`
+ );
+ return new NextResponse('Failed to fetch video thumbnail', { status: 502 });
+ }
+
+ const arrayBuffer = await backendResponse.arrayBuffer();
+ const contentType = backendResponse.headers.get('content-type') || 'image/jpeg';
+
+ return new NextResponse(arrayBuffer as unknown as BodyInit, {
+ headers: {
+ 'Content-Type': contentType,
+ // Keep caching modest: backend thumbnails can be regenerated/updated.
+ 'Cache-Control': 'public, max-age=86400',
+ },
+ });
+ } catch (error) {
+ console.error(`Error proxying backend thumbnail for video ${photoId}:`, error);
+ return new NextResponse('Failed to fetch video thumbnail', { status: 502 });
+ }
+ }
+
+ // Handle video streaming
+ if (mediaType === 'video') {
+ const fileStats = statSync(filePath);
+ const fileSize = fileStats.size;
+ const range = request.headers.get('range');
+
+ const contentType = getContentType(filePath, mediaType);
+
+ // Handle range requests for video seeking
+ if (range) {
+ const rangeData = parseRange(range, fileSize);
+ if (!rangeData) {
+ return new NextResponse('Range Not Satisfiable', {
+ status: 416,
+ headers: {
+ 'Content-Range': `bytes */${fileSize}`,
+ },
+ });
+ }
+
+ const { start, end } = rangeData;
+ const chunkSize = end - start + 1;
+
+ const fileBuffer = await readFile(filePath);
+ const chunk = fileBuffer.slice(start, end + 1);
+
+ return new NextResponse(chunk, {
+ status: 206,
+ headers: {
+ 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
+ 'Accept-Ranges': 'bytes',
+ 'Content-Length': chunkSize.toString(),
+ 'Content-Type': contentType,
+ 'Cache-Control': 'public, max-age=3600',
+ },
+ });
+ }
+
+ // Full video file (no range request)
+ const videoBuffer = await readFile(filePath);
+ return new NextResponse(videoBuffer, {
+ headers: {
+ 'Content-Type': contentType,
+ 'Content-Length': fileSize.toString(),
+ 'Accept-Ranges': 'bytes',
+ 'Cache-Control': 'public, max-age=3600',
+ },
+ });
+ }
+
+ // Handle images (existing logic)
+ const imageBuffer = await readFile(filePath);
+ const contentType = getContentType(filePath, mediaType);
+
+ const createOriginalResponse = (cacheControl = 'public, max-age=31536000, immutable') =>
+ new NextResponse(imageBuffer, {
+ headers: {
+ 'Content-Type': contentType,
+ 'Cache-Control': cacheControl,
+ },
+ });
+
+ if (!watermark) {
+ return createOriginalResponse();
+ }
+
+ // If sharp is not available, return original image without watermark
+ if (!sharp) {
+ console.warn('Sharp library not available, returning original image without watermark');
+ return createOriginalResponse('public, max-age=60');
+ }
+
+ try {
+ const metadata = await sharp(imageBuffer).metadata();
+ const baseWidth = metadata.width ?? 2000;
+ const baseHeight = metadata.height ?? 2000;
+ const overlayBuffer = await getWatermarkOverlay(baseWidth, baseHeight);
+ const ext = path.extname(filePath).toLowerCase();
+ const outputFormat =
+ ext === '.png' ? 'png' :
+ ext === '.webp' ? 'webp' :
+ 'jpeg';
+ const pipeline = sharp(imageBuffer)
+ .composite([
+ {
+ input: overlayBuffer,
+ gravity: 'center',
+ blend: 'hard-light',
+ },
+ ])
+ .toColorspace('srgb');
+
+ const result = await pipeline
+ .toFormat(
+ outputFormat,
+ outputFormat === 'png'
+ ? { compressionLevel: 5 }
+ : outputFormat === 'webp'
+ ? { quality: 90 }
+ : { quality: 90 }
+ )
+ .toBuffer();
+
+ const responseContentType =
+ outputFormat === 'png'
+ ? 'image/png'
+ : outputFormat === 'webp'
+ ? 'image/webp'
+ : 'image/jpeg';
+
+ return new NextResponse(result as unknown as BodyInit, {
+ headers: {
+ 'Content-Type': responseContentType,
+ 'Cache-Control': 'public, max-age=3600',
+ },
+ });
+ } catch (error) {
+ console.error('Error applying watermark:', error);
+ return createOriginalResponse('public, max-age=60');
+ }
+ } catch (error) {
+ console.error('Error serving media:', error);
+ return new NextResponse('Internal server error', { status: 500 });
+ }
+}
+
diff --git a/viewer-frontend/app/api/photos/[id]/report/route.ts b/viewer-frontend/app/api/photos/[id]/report/route.ts
new file mode 100644
index 0000000..e9171e7
--- /dev/null
+++ b/viewer-frontend/app/api/photos/[id]/report/route.ts
@@ -0,0 +1,426 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prisma, prismaAuth } from '@/lib/db';
+import { auth } from '@/app/api/auth/[...nextauth]/route';
+
+// In-memory cache for report statuses
+// Key: `${userId}:${photoId}`, Value: { data, expiresAt }
+interface CacheEntry {
+ data: {
+ reported: boolean;
+ status?: string;
+ reportedAt?: Date;
+ };
+ expiresAt: number;
+}
+
+const reportStatusCache = new Map();
+const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
+const MAX_COMMENT_LENGTH = 300;
+
+// Helper function to get cache key
+function getCacheKey(userId: number, photoId: number): string {
+ return `${userId}:${photoId}`;
+}
+
+// Helper function to get cached report status
+function getCachedStatus(userId: number, photoId: number): CacheEntry['data'] | null {
+ const key = getCacheKey(userId, photoId);
+ const entry = reportStatusCache.get(key);
+
+ if (!entry) {
+ return null;
+ }
+
+ // Check if cache entry has expired
+ if (Date.now() > entry.expiresAt) {
+ reportStatusCache.delete(key);
+ return null;
+ }
+
+ return entry.data;
+}
+
+// Helper function to set cache
+function setCachedStatus(
+ userId: number,
+ photoId: number,
+ data: CacheEntry['data']
+): void {
+ const key = getCacheKey(userId, photoId);
+ reportStatusCache.set(key, {
+ data,
+ expiresAt: Date.now() + CACHE_TTL,
+ });
+}
+
+// Helper function to invalidate cache for a specific user/photo combination
+function invalidateCache(userId: number, photoId: number): void {
+ const key = getCacheKey(userId, photoId);
+ reportStatusCache.delete(key);
+}
+
+// Helper function to fetch report status from database
+async function fetchReportStatusFromDB(
+ userId: number,
+ photoId: number
+): Promise {
+ const existingReport = await prismaAuth.inappropriatePhotoReport.findUnique({
+ where: {
+ uq_photo_user_report: {
+ photoId,
+ userId,
+ },
+ },
+ select: {
+ id: true,
+ status: true,
+ reportedAt: true,
+ },
+ });
+
+ const result: CacheEntry['data'] = existingReport
+ ? {
+ reported: true,
+ status: existingReport.status,
+ reportedAt: existingReport.reportedAt,
+ }
+ : {
+ reported: false,
+ };
+
+ // Cache the result
+ setCachedStatus(userId, photoId, result);
+
+ return result;
+}
+
+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 report photos.' },
+ { status: 401 }
+ );
+ }
+
+ const { id } = await params;
+ const photoId = parseInt(id, 10);
+
+ if (isNaN(photoId)) {
+ return NextResponse.json(
+ { error: 'Invalid photo ID' },
+ { status: 400 }
+ );
+ }
+
+ // Verify photo exists
+ const photo = await prisma.photo.findUnique({
+ where: { id: photoId },
+ select: { id: true, filename: true },
+ });
+
+ if (!photo) {
+ return NextResponse.json(
+ { error: 'Photo not found' },
+ { status: 404 }
+ );
+ }
+
+ const userId = parseInt(session.user.id, 10);
+ if (isNaN(userId)) {
+ return NextResponse.json(
+ { error: 'Invalid user ID' },
+ { status: 400 }
+ );
+ }
+
+ // Verify user exists in auth database before proceeding
+ const user = await prismaAuth.user.findUnique({
+ where: { id: userId },
+ select: { id: true },
+ });
+
+ if (!user) {
+ console.error(`[REPORT] User ${userId} not found in auth database`);
+ return NextResponse.json(
+ { error: 'User account not found. Please sign in again.' },
+ { status: 404 }
+ );
+ }
+
+ // Parse optional comment payload
+ let reportComment: string | null = null;
+ try {
+ const body = await request.json();
+ if (body && typeof body === 'object' && !Array.isArray(body)) {
+ if (body.comment !== undefined && typeof body.comment !== 'string' && body.comment !== null) {
+ return NextResponse.json(
+ { error: 'Comment must be a string' },
+ { status: 400 }
+ );
+ }
+ if (typeof body.comment === 'string') {
+ const trimmed = body.comment.trim();
+ if (trimmed.length > MAX_COMMENT_LENGTH) {
+ return NextResponse.json(
+ { error: `Comment must be ${MAX_COMMENT_LENGTH} characters or less` },
+ { status: 400 }
+ );
+ }
+ reportComment = trimmed.length > 0 ? trimmed : null;
+ }
+ }
+ } catch (error: any) {
+ // If the request body is empty or invalid JSON, treat as no comment
+ if (error.type !== 'invalid-json') {
+ console.warn('Invalid report payload received', error);
+ }
+ }
+
+ // Check if user has already reported this photo (get all info in one query)
+ const existingReport = await prismaAuth.inappropriatePhotoReport.findUnique({
+ where: {
+ uq_photo_user_report: {
+ photoId,
+ userId,
+ },
+ },
+ });
+
+ let report;
+
+ if (existingReport) {
+ // If report exists and is still pending, return error
+ if (existingReport.status === 'pending') {
+ return NextResponse.json(
+ { error: 'You have already reported this photo' },
+ { status: 409 }
+ );
+ }
+
+ // If report was dismissed, cannot re-report
+ if (existingReport.status === 'dismissed') {
+ return NextResponse.json(
+ { error: 'Cannot re-report a dismissed report' },
+ { status: 403 }
+ );
+ }
+
+ // If report was reviewed, update it back to pending (re-report)
+ // Keep reviewNotes but clear reviewedAt and reviewedBy
+ if (existingReport.status === 'reviewed') {
+ report = await prismaAuth.inappropriatePhotoReport.update({
+ where: {
+ id: existingReport.id,
+ },
+ data: {
+ status: 'pending',
+ reviewedAt: null,
+ reviewedBy: null,
+ reportComment,
+ // Keep reviewNotes - don't clear them
+ },
+ });
+ // Invalidate cache after update
+ invalidateCache(userId, photoId);
+ }
+ } else {
+ // Create new report
+ report = await prismaAuth.inappropriatePhotoReport.create({
+ data: {
+ photoId,
+ userId,
+ status: 'pending',
+ reportComment,
+ },
+ });
+ // Invalidate cache after creation
+ invalidateCache(userId, photoId);
+ }
+
+ if (!report) {
+ return NextResponse.json(
+ { error: 'Failed to create report' },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json(
+ {
+ message: 'Photo reported successfully',
+ report: {
+ id: report.id,
+ photoId: report.photoId,
+ status: report.status,
+ reportedAt: report.reportedAt,
+ reportComment: report.reportComment,
+ },
+ },
+ { status: 201 }
+ );
+ } catch (error: any) {
+ console.error('Error reporting photo:', error);
+
+ // Handle unique constraint violation
+ if (error.code === 'P2002') {
+ return NextResponse.json(
+ { error: 'You have already reported this photo' },
+ { status: 409 }
+ );
+ }
+
+ // Handle foreign key constraint violation
+ if (error.code === 'P2003') {
+ console.error('[REPORT] Foreign key constraint violation:', error.meta);
+ return NextResponse.json(
+ { error: 'User account not found. Please sign in again.' },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: 'Failed to report photo', details: error.message },
+ { status: 500 }
+ );
+ }
+}
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ // Check authentication
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { error: 'Authentication required' },
+ { status: 401 }
+ );
+ }
+
+ const { id } = await params;
+ const photoId = parseInt(id, 10);
+
+ if (isNaN(photoId)) {
+ return NextResponse.json(
+ { error: 'Invalid photo ID' },
+ { status: 400 }
+ );
+ }
+
+ const userId = parseInt(session.user.id, 10);
+ if (isNaN(userId)) {
+ return NextResponse.json(
+ { error: 'Invalid user ID' },
+ { status: 400 }
+ );
+ }
+
+ // Check cache first
+ const cachedStatus = getCachedStatus(userId, photoId);
+ if (cachedStatus !== null) {
+ return NextResponse.json(cachedStatus);
+ }
+
+ // Cache miss - fetch from database (this will also cache the result)
+ const reportStatus = await fetchReportStatusFromDB(userId, photoId);
+
+ return NextResponse.json(reportStatus);
+ } catch (error: any) {
+ console.error('Error checking report status:', error);
+ return NextResponse.json(
+ { error: 'Failed to check report status', details: error.message },
+ { status: 500 }
+ );
+ }
+}
+
+export async function DELETE(
+ 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 undo reports.' },
+ { status: 401 }
+ );
+ }
+
+ const { id } = await params;
+ const photoId = parseInt(id, 10);
+
+ if (isNaN(photoId)) {
+ return NextResponse.json(
+ { error: 'Invalid photo ID' },
+ { status: 400 }
+ );
+ }
+
+ const userId = parseInt(session.user.id, 10);
+ if (isNaN(userId)) {
+ return NextResponse.json(
+ { error: 'Invalid user ID' },
+ { status: 400 }
+ );
+ }
+
+ // Find the report
+ const existingReport = await prismaAuth.inappropriatePhotoReport.findUnique({
+ where: {
+ uq_photo_user_report: {
+ photoId,
+ userId,
+ },
+ },
+ });
+
+ if (!existingReport) {
+ return NextResponse.json(
+ { error: 'Report not found' },
+ { status: 404 }
+ );
+ }
+
+ // Only allow undo if status is still pending
+ if (existingReport.status !== 'pending') {
+ return NextResponse.json(
+ { error: 'Cannot undo report that has already been reviewed' },
+ { status: 403 }
+ );
+ }
+
+ // Delete the report
+ await prismaAuth.inappropriatePhotoReport.delete({
+ where: {
+ id: existingReport.id,
+ },
+ });
+
+ // Invalidate cache after deletion
+ invalidateCache(userId, photoId);
+
+ return NextResponse.json(
+ {
+ message: 'Report undone successfully',
+ },
+ { status: 200 }
+ );
+ } catch (error: any) {
+ console.error('Error undoing report:', error);
+
+ return NextResponse.json(
+ { error: 'Failed to undo report', details: error.message },
+ { status: 500 }
+ );
+ }
+}
+
diff --git a/viewer-frontend/app/api/photos/[id]/route.ts b/viewer-frontend/app/api/photos/[id]/route.ts
new file mode 100644
index 0000000..a386bd7
--- /dev/null
+++ b/viewer-frontend/app/api/photos/[id]/route.ts
@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prisma } from '@/lib/db';
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params;
+ const photoId = parseInt(id, 10);
+
+ if (isNaN(photoId)) {
+ return NextResponse.json(
+ { error: 'Invalid photo ID' },
+ { status: 400 }
+ );
+ }
+
+ const photo = await prisma.photo.findUnique({
+ where: { id: photoId },
+ include: {
+ Face: {
+ include: {
+ Person: true,
+ },
+ },
+ PhotoTagLinkage: {
+ include: {
+ Tag: true,
+ },
+ },
+ },
+ });
+
+ if (!photo) {
+ return NextResponse.json(
+ { error: 'Photo not found' },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json(photo);
+ } catch (error) {
+ console.error('Error fetching photo:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch photo' },
+ { status: 500 }
+ );
+ }
+}
+
diff --git a/viewer-frontend/app/api/photos/favorites/batch/route.ts b/viewer-frontend/app/api/photos/favorites/batch/route.ts
new file mode 100644
index 0000000..059d4cc
--- /dev/null
+++ b/viewer-frontend/app/api/photos/favorites/batch/route.ts
@@ -0,0 +1,53 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prismaAuth } from '@/lib/db';
+import { auth } from '@/app/api/auth/[...nextauth]/route';
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ results: {} });
+ }
+
+ const userId = parseInt(session.user.id, 10);
+ if (isNaN(userId)) {
+ return NextResponse.json({ results: {} });
+ }
+
+ const { photoIds } = await request.json();
+
+ if (!Array.isArray(photoIds) || photoIds.length === 0) {
+ return NextResponse.json({ results: {} });
+ }
+
+ // Fetch all favorites for this user and these photos
+ const favorites = await prismaAuth.photoFavorite.findMany({
+ where: {
+ userId,
+ photoId: { in: photoIds },
+ },
+ select: {
+ photoId: true,
+ },
+ });
+
+ // Build result map
+ const favoriteSet = new Set(favorites.map(f => f.photoId));
+ const results: Record = {};
+
+ photoIds.forEach((photoId: number) => {
+ results[photoId.toString()] = favoriteSet.has(photoId);
+ });
+
+ return NextResponse.json({ results });
+ } 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');
+ return NextResponse.json({ results: {} });
+ }
+ console.error('Error fetching batch favorites:', error);
+ return NextResponse.json({ results: {} });
+ }
+}
+
diff --git a/viewer-frontend/app/api/photos/favorites/bulk/route.ts b/viewer-frontend/app/api/photos/favorites/bulk/route.ts
new file mode 100644
index 0000000..62124c8
--- /dev/null
+++ b/viewer-frontend/app/api/photos/favorites/bulk/route.ts
@@ -0,0 +1,180 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prisma, prismaAuth } from '@/lib/db';
+import { auth } from '@/app/api/auth/[...nextauth]/route';
+
+// POST - Bulk toggle favorites (add if not exists, remove if exists)
+export async function POST(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { error: 'Authentication required. Please sign in to favorite photos.' },
+ { status: 401 }
+ );
+ }
+
+ const { photoIds, action } = await request.json();
+ const userId = parseInt(session.user.id, 10);
+
+ if (isNaN(userId)) {
+ return NextResponse.json(
+ { error: 'Invalid user ID' },
+ { status: 400 }
+ );
+ }
+
+ if (!Array.isArray(photoIds) || photoIds.length === 0) {
+ return NextResponse.json(
+ { error: 'Photo IDs array is required' },
+ { status: 400 }
+ );
+ }
+
+ // Validate action
+ if (action && action !== 'add' && action !== 'remove' && action !== 'toggle') {
+ return NextResponse.json(
+ { error: 'Action must be "add", "remove", or "toggle"' },
+ { status: 400 }
+ );
+ }
+
+ const validPhotoIds = photoIds
+ .map((id: any) => parseInt(id, 10))
+ .filter((id: number) => !isNaN(id));
+
+ if (validPhotoIds.length === 0) {
+ return NextResponse.json(
+ { error: 'No valid photo IDs provided' },
+ { status: 400 }
+ );
+ }
+
+ // Verify user exists in auth database
+ const user = await prismaAuth.user.findUnique({
+ where: { id: userId },
+ select: { id: true },
+ });
+
+ if (!user) {
+ return NextResponse.json(
+ { error: 'User account not found. Please sign in again.' },
+ { status: 404 }
+ );
+ }
+
+ // Verify photos exist in main database
+ const existingPhotos = await prisma.photo.findMany({
+ where: { id: { in: validPhotoIds } },
+ select: { id: true },
+ });
+
+ const existingPhotoIds = new Set(existingPhotos.map(p => p.id));
+ const validIds = validPhotoIds.filter((id: number) => existingPhotoIds.has(id));
+
+ if (validIds.length === 0) {
+ return NextResponse.json(
+ { error: 'No valid photos found' },
+ { status: 404 }
+ );
+ }
+
+ // Get current favorites for these photos
+ const currentFavorites = await prismaAuth.photoFavorite.findMany({
+ where: {
+ userId,
+ photoId: { in: validIds },
+ },
+ select: {
+ photoId: true,
+ id: true,
+ },
+ });
+
+ const favoritedPhotoIds = new Set(currentFavorites.map(f => f.photoId));
+ const favoriteIdsToDelete = new Set(currentFavorites.map(f => f.id));
+
+ let added = 0;
+ let removed = 0;
+
+ if (action === 'add') {
+ // Only add favorites that don't exist
+ const toAdd = validIds.filter(id => !favoritedPhotoIds.has(id));
+
+ if (toAdd.length > 0) {
+ await prismaAuth.photoFavorite.createMany({
+ data: toAdd.map(photoId => ({
+ photoId,
+ userId,
+ favoritedAt: new Date(),
+ })),
+ skipDuplicates: true,
+ });
+ added = toAdd.length;
+ }
+ } else if (action === 'remove') {
+ // Only remove favorites that exist
+ if (favoriteIdsToDelete.size > 0) {
+ await prismaAuth.photoFavorite.deleteMany({
+ where: {
+ id: { in: Array.from(favoriteIdsToDelete) },
+ },
+ });
+ removed = favoriteIdsToDelete.size;
+ }
+ } else {
+ // Toggle: add if not favorited, remove if favorited
+ const toAdd = validIds.filter(id => !favoritedPhotoIds.has(id));
+ const toRemove = Array.from(favoriteIdsToDelete);
+
+ if (toAdd.length > 0) {
+ await prismaAuth.photoFavorite.createMany({
+ data: toAdd.map(photoId => ({
+ photoId,
+ userId,
+ favoritedAt: new Date(),
+ })),
+ skipDuplicates: true,
+ });
+ added = toAdd.length;
+ }
+
+ if (toRemove.length > 0) {
+ await prismaAuth.photoFavorite.deleteMany({
+ where: {
+ id: { in: toRemove },
+ },
+ });
+ removed = toRemove.length;
+ }
+ }
+
+ return NextResponse.json({
+ success: true,
+ added,
+ removed,
+ total: validIds.length,
+ message: `Favorites updated: ${added} added, ${removed} removed`,
+ });
+ } catch (error: any) {
+ console.error('Error bulk toggling favorites:', error);
+
+ // Handle case where table doesn't exist yet (P2021 = table does not exist)
+ if (error.code === 'P2021') {
+ return NextResponse.json(
+ { error: 'Favorites feature is not available yet. Please run the migration: migrations/add-photo-favorites-table.sql' },
+ { status: 503 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: 'Failed to update favorites', details: error.message },
+ { status: 500 }
+ );
+ }
+}
+
+
+
+
+
+
diff --git a/viewer-frontend/app/api/photos/reports/batch/route.ts b/viewer-frontend/app/api/photos/reports/batch/route.ts
new file mode 100644
index 0000000..bdc3645
--- /dev/null
+++ b/viewer-frontend/app/api/photos/reports/batch/route.ts
@@ -0,0 +1,206 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { auth } from '@/app/api/auth/[...nextauth]/route';
+import { prismaAuth } from '@/lib/db';
+
+// In-memory cache for report statuses
+// Key: `${userId}:${photoId}`, Value: { data, expiresAt }
+interface CacheEntry {
+ data: {
+ reported: boolean;
+ status?: string;
+ reportedAt?: Date;
+ };
+ expiresAt: number;
+}
+
+const reportStatusCache = new Map();
+const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
+
+// Helper function to get cache key
+function getCacheKey(userId: number, photoId: number): string {
+ return `${userId}:${photoId}`;
+}
+
+// Helper function to get cached report status
+function getCachedStatus(userId: number, photoId: number): CacheEntry['data'] | null {
+ const key = getCacheKey(userId, photoId);
+ const entry = reportStatusCache.get(key);
+
+ if (!entry) {
+ return null;
+ }
+
+ // Check if cache entry has expired
+ if (Date.now() > entry.expiresAt) {
+ reportStatusCache.delete(key);
+ return null;
+ }
+
+ return entry.data;
+}
+
+// Helper function to set cache
+function setCachedStatus(
+ userId: number,
+ photoId: number,
+ data: CacheEntry['data']
+): void {
+ const key = getCacheKey(userId, photoId);
+ reportStatusCache.set(key, {
+ data,
+ expiresAt: Date.now() + CACHE_TTL,
+ });
+}
+
+// Helper function to fetch report status from database
+async function fetchReportStatusFromDB(
+ userId: number,
+ photoId: number
+): Promise {
+ const existingReport = await prismaAuth.inappropriatePhotoReport.findUnique({
+ where: {
+ uq_photo_user_report: {
+ photoId,
+ userId,
+ },
+ },
+ select: {
+ id: true,
+ status: true,
+ reportedAt: true,
+ },
+ });
+
+ const result: CacheEntry['data'] = existingReport
+ ? {
+ reported: true,
+ status: existingReport.status,
+ reportedAt: existingReport.reportedAt,
+ }
+ : {
+ reported: false,
+ };
+
+ // Cache the result
+ setCachedStatus(userId, photoId, result);
+
+ return result;
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ // Check authentication
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { error: 'Authentication required' },
+ { status: 401 }
+ );
+ }
+
+ const userId = parseInt(session.user.id, 10);
+ if (isNaN(userId)) {
+ return NextResponse.json(
+ { error: 'Invalid user ID' },
+ { status: 400 }
+ );
+ }
+
+ // Parse request body
+ const body = await request.json();
+ const { photoIds } = body;
+
+ if (!Array.isArray(photoIds) || photoIds.length === 0) {
+ return NextResponse.json(
+ { error: 'photoIds must be a non-empty array' },
+ { status: 400 }
+ );
+ }
+
+ // Validate photo IDs are numbers
+ const validPhotoIds = photoIds
+ .map((id) => parseInt(String(id), 10))
+ .filter((id) => !isNaN(id) && id > 0);
+
+ if (validPhotoIds.length === 0) {
+ return NextResponse.json(
+ { error: 'No valid photo IDs provided' },
+ { status: 400 }
+ );
+ }
+
+ // Limit batch size to prevent abuse
+ const MAX_BATCH_SIZE = 100;
+ const photoIdsToCheck = validPhotoIds.slice(0, MAX_BATCH_SIZE);
+
+ // Check cache first for all photos
+ const results: Record = {};
+ const uncachedPhotoIds: number[] = [];
+
+ for (const photoId of photoIdsToCheck) {
+ const cached = getCachedStatus(userId, photoId);
+ if (cached !== null) {
+ results[photoId] = cached;
+ } else {
+ uncachedPhotoIds.push(photoId);
+ }
+ }
+
+ // Fetch uncached report statuses from database in a single query
+ if (uncachedPhotoIds.length > 0) {
+ const reports = await prismaAuth.inappropriatePhotoReport.findMany({
+ where: {
+ userId,
+ photoId: { in: uncachedPhotoIds },
+ },
+ select: {
+ photoId: true,
+ status: true,
+ reportedAt: true,
+ },
+ });
+
+ // Create a map of photoId -> report status
+ const reportMap = new Map();
+ for (const report of reports) {
+ reportMap.set(report.photoId, {
+ status: report.status,
+ reportedAt: report.reportedAt,
+ });
+ }
+
+ // Build results for uncached photos
+ for (const photoId of uncachedPhotoIds) {
+ const report = reportMap.get(photoId);
+ const result: CacheEntry['data'] = report
+ ? {
+ reported: true,
+ status: report.status,
+ reportedAt: report.reportedAt,
+ }
+ : {
+ reported: false,
+ };
+
+ results[photoId] = result;
+ // Cache the result
+ setCachedStatus(userId, photoId, result);
+ }
+ }
+
+ return NextResponse.json({ results });
+ } catch (error: any) {
+ console.error('Error fetching batch report statuses:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch report statuses', details: error.message },
+ { status: 500 }
+ );
+ }
+}
+
+
+
+
+
+
+
diff --git a/viewer-frontend/app/api/photos/tag-linkages/route.ts b/viewer-frontend/app/api/photos/tag-linkages/route.ts
new file mode 100644
index 0000000..3e5b059
--- /dev/null
+++ b/viewer-frontend/app/api/photos/tag-linkages/route.ts
@@ -0,0 +1,279 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prisma, prismaAuth } from '@/lib/db';
+import { auth } from '@/app/api/auth/[...nextauth]/route';
+
+const MAX_TAG_NAME_LENGTH = 100;
+const MAX_NOTES_LENGTH = 500;
+const MAX_CUSTOM_TAGS = 10;
+
+interface TagLinkagePayload {
+ photoIds?: number[];
+ tagIds?: number[];
+ newTagName?: string;
+ newTagNames?: string[];
+ notes?: string;
+}
+
+function parseIds(values: unknown, label: string) {
+ if (!Array.isArray(values)) {
+ throw new Error(`${label} must be an array`);
+ }
+
+ const parsed = Array.from(
+ new Set(
+ values.map((value) => {
+ const num = Number(value);
+ if (!Number.isInteger(num) || num <= 0) {
+ throw new Error(`${label} must contain positive integers`);
+ }
+ return num;
+ })
+ )
+ );
+
+ if (parsed.length === 0) {
+ throw new Error(`${label} cannot be empty`);
+ }
+
+ return parsed;
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { error: 'Authentication required. Please sign in to tag photos.' },
+ { status: 401 }
+ );
+ }
+
+ const userId = parseInt(session.user.id, 10);
+ if (!Number.isInteger(userId)) {
+ return NextResponse.json(
+ { error: 'Invalid user session' },
+ { status: 401 }
+ );
+ }
+
+ let payload: TagLinkagePayload;
+ try {
+ payload = await request.json();
+ } catch (error: any) {
+ if (error?.type === 'invalid-json') {
+ return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 });
+ }
+ throw error;
+ }
+
+ if (!payload || typeof payload !== 'object') {
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
+ }
+
+ if (!payload.photoIds) {
+ return NextResponse.json({ error: 'photoIds are required' }, { status: 400 });
+ }
+
+ const photoIds = parseIds(payload.photoIds, 'photoIds');
+ const tagIds =
+ Array.isArray(payload.tagIds) && payload.tagIds.length > 0
+ ? parseIds(payload.tagIds, 'tagIds')
+ : [];
+
+ const incomingNewNames: string[] = [];
+ if (Array.isArray(payload.newTagNames)) {
+ incomingNewNames.push(...payload.newTagNames);
+ }
+ if (typeof payload.newTagName === 'string') {
+ incomingNewNames.push(payload.newTagName);
+ }
+
+ const newNameMap = new Map();
+ for (const rawName of incomingNewNames) {
+ if (typeof rawName !== 'string') continue;
+ const trimmed = rawName.trim().replace(/\s+/g, ' ');
+ if (trimmed.length === 0) continue;
+ if (trimmed.length > MAX_TAG_NAME_LENGTH) {
+ return NextResponse.json(
+ { error: `Tag name must be ${MAX_TAG_NAME_LENGTH} characters or less` },
+ { status: 400 }
+ );
+ }
+ const key = trimmed.toLowerCase();
+ if (!newNameMap.has(key)) {
+ newNameMap.set(key, trimmed);
+ }
+ }
+ const normalizedNewNames = Array.from(newNameMap.values());
+
+ if (normalizedNewNames.length > MAX_CUSTOM_TAGS) {
+ return NextResponse.json(
+ { error: `You can submit up to ${MAX_CUSTOM_TAGS} new tag names at a time` },
+ { status: 400 }
+ );
+ }
+
+ if (tagIds.length === 0 && normalizedNewNames.length === 0) {
+ return NextResponse.json(
+ { error: 'Select at least one existing tag or provide a new tag name' },
+ { status: 400 }
+ );
+ }
+
+ let notes: string | null = null;
+ if (typeof payload.notes === 'string') {
+ notes = payload.notes.trim();
+ if (notes.length > MAX_NOTES_LENGTH) {
+ return NextResponse.json(
+ { error: `Notes must be ${MAX_NOTES_LENGTH} characters or less` },
+ { status: 400 }
+ );
+ }
+ if (notes.length === 0) {
+ notes = null;
+ }
+ }
+
+ // Ensure all photos exist
+ const photos = await prisma.photo.findMany({
+ where: { id: { in: photoIds } },
+ select: { id: true },
+ });
+
+ if (photos.length !== photoIds.length) {
+ return NextResponse.json(
+ { error: 'One or more photos were not found' },
+ { status: 404 }
+ );
+ }
+
+ // Ensure all tag IDs exist
+ if (tagIds.length > 0) {
+ const tags = await prisma.tag.findMany({
+ where: { id: { in: tagIds } },
+ select: { id: true },
+ });
+
+ if (tags.length !== tagIds.length) {
+ return NextResponse.json(
+ { error: 'One or more tags were not found' },
+ { status: 404 }
+ );
+ }
+ }
+
+ // Skip combinations that are already applied
+ let appliedCombinationKeys = new Set();
+ if (tagIds.length > 0) {
+ const applied = await prisma.photoTagLinkage.findMany({
+ where: {
+ photo_id: { in: photoIds },
+ tag_id: { in: tagIds },
+ },
+ select: {
+ photo_id: true,
+ tag_id: true,
+ },
+ });
+
+ appliedCombinationKeys = new Set(
+ applied.map((link) => `${link.photo_id}:${link.tag_id}`)
+ );
+ }
+
+ // Skip combinations that are already pending for the same user
+ const pendingConditions: any[] = [];
+ if (tagIds.length > 0) {
+ pendingConditions.push({
+ photoId: { in: photoIds },
+ tagId: { in: tagIds },
+ });
+ }
+ normalizedNewNames.forEach((name) => {
+ pendingConditions.push({
+ photoId: { in: photoIds },
+ tagName: name,
+ });
+ });
+
+ let pendingCombinationKeys = new Set();
+ if (pendingConditions.length > 0) {
+ const existingPending = await prismaAuth.pendingLinkage.findMany({
+ where: {
+ userId,
+ status: 'pending',
+ OR: pendingConditions,
+ },
+ select: {
+ photoId: true,
+ tagId: true,
+ tagName: true,
+ },
+ });
+
+ pendingCombinationKeys = new Set(
+ existingPending.map((item) =>
+ item.tagId
+ ? `${item.photoId}:${item.tagId}`
+ : `${item.photoId}:name:${item.tagName?.toLowerCase()}`
+ )
+ );
+ }
+
+ type PendingEntry = { photoId: number; tagId?: number; tagName?: string | null };
+ const entries: PendingEntry[] = [];
+
+ for (const photoId of photoIds) {
+ for (const tagId of tagIds) {
+ const key = `${photoId}:${tagId}`;
+ if (appliedCombinationKeys.has(key)) {
+ continue;
+ }
+ if (pendingCombinationKeys.has(key)) {
+ continue;
+ }
+ entries.push({ photoId, tagId });
+ }
+
+ for (const tagName of normalizedNewNames) {
+ const key = `${photoId}:name:${tagName.toLowerCase()}`;
+ if (!pendingCombinationKeys.has(key)) {
+ entries.push({ photoId, tagName });
+ }
+ }
+ }
+
+ if (entries.length === 0) {
+ return NextResponse.json(
+ { error: 'Selected tag linkages are already applied or pending' },
+ { status: 409 }
+ );
+ }
+
+ await prismaAuth.pendingLinkage.createMany({
+ data: entries.map((entry) => ({
+ photoId: entry.photoId,
+ tagId: entry.tagId ?? null,
+ tagName: entry.tagName ?? null,
+ userId,
+ status: 'pending',
+ notes,
+ })),
+ });
+
+ return NextResponse.json({
+ message: 'Tags were submitted and are pending approval',
+ pendingCount: entries.length,
+ });
+ } catch (error: any) {
+ console.error('Error submitting tag linkages:', error);
+ if (error.message?.includes('must')) {
+ return NextResponse.json({ error: error.message }, { status: 400 });
+ }
+ return NextResponse.json(
+ { error: 'Failed to submit tag linkages', details: error.message },
+ { status: 500 }
+ );
+ }
+}
+
diff --git a/viewer-frontend/app/api/photos/upload/route.ts b/viewer-frontend/app/api/photos/upload/route.ts
new file mode 100644
index 0000000..5b1def2
--- /dev/null
+++ b/viewer-frontend/app/api/photos/upload/route.ts
@@ -0,0 +1,260 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { auth } from '@/app/api/auth/[...nextauth]/route';
+import { prismaAuth } from '@/lib/db';
+import { writeFile, mkdir, access } from 'fs/promises';
+import { existsSync } from 'fs';
+import { constants } from 'fs';
+import path from 'path';
+
+// Maximum file size: 50MB for images, 500MB for videos
+const MAX_IMAGE_SIZE = 50 * 1024 * 1024;
+const MAX_VIDEO_SIZE = 500 * 1024 * 1024;
+// Allowed image types
+const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
+// Allowed video types
+const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/mpeg', 'video/quicktime', 'video/x-msvideo', 'video/webm'];
+// Combined allowed types
+const ALLOWED_TYPES = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_VIDEO_TYPES];
+
+// Get upload directory from environment variable
+// REQUIRED: Must be set to a network share path accessible by both this app and the approval system
+// Examples:
+// - Linux: /mnt/shared/pending-photos
+// - Windows: \\server\share\pending-photos (mapped to drive or use UNC path)
+// - SMB/CIFS: /mnt/smb/photos/pending
+const getUploadDir = (): string => {
+ const uploadDir = process.env.UPLOAD_DIR || process.env.PENDING_PHOTOS_DIR;
+
+ if (!uploadDir) {
+ throw new Error(
+ 'UPLOAD_DIR or PENDING_PHOTOS_DIR environment variable must be set. ' +
+ 'This should point to a network share accessible by both the web server and approval system.'
+ );
+ }
+
+ // Use absolute path (network shares should already be absolute)
+ return path.resolve(uploadDir);
+};
+
+export async function POST(request: NextRequest) {
+ try {
+ // Check authentication
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { error: 'Authentication required. Please sign in to upload files.' },
+ { status: 401 }
+ );
+ }
+
+ const formData = await request.formData();
+ const photos = formData.getAll('photos') as File[];
+
+ if (photos.length === 0) {
+ return NextResponse.json(
+ { error: 'No files provided' },
+ { status: 400 }
+ );
+ }
+
+ // Validate files
+ const errors: string[] = [];
+ const validPhotos: Array<{ file: File; name: string; size: number; type: string }> = [];
+
+ for (const photo of photos) {
+ // Check file type
+ if (!ALLOWED_TYPES.includes(photo.type)) {
+ errors.push(`${photo.name}: Invalid file type. Only images (JPEG, PNG, GIF, WebP) and videos (MP4, MOV, AVI, WebM) are allowed.`);
+ continue;
+ }
+
+ // Check file size based on type
+ const isVideo = ALLOWED_VIDEO_TYPES.includes(photo.type);
+ const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE;
+ const maxSizeMB = isVideo ? 500 : 50;
+
+ if (photo.size > maxSize) {
+ errors.push(`${photo.name}: File size exceeds ${maxSizeMB}MB limit.`);
+ continue;
+ }
+
+ if (photo.size === 0) {
+ errors.push(`${photo.name}: File is empty.`);
+ continue;
+ }
+
+ validPhotos.push({
+ file: photo,
+ name: photo.name,
+ size: photo.size,
+ type: photo.type,
+ });
+ }
+
+ if (validPhotos.length === 0) {
+ return NextResponse.json(
+ {
+ error: 'No valid files to upload',
+ details: errors.length > 0 ? errors : ['No files provided']
+ },
+ { status: 400 }
+ );
+ }
+
+ const userId = parseInt(session.user.id, 10);
+ if (isNaN(userId)) {
+ return NextResponse.json(
+ { error: 'Invalid user ID' },
+ { status: 400 }
+ );
+ }
+
+ // Get upload directory and ensure it exists
+ let uploadBaseDir: string;
+ try {
+ uploadBaseDir = getUploadDir();
+ } catch (error: any) {
+ console.error('[PHOTO UPLOAD] Configuration error:', error.message);
+ return NextResponse.json(
+ {
+ error: 'Upload directory not configured',
+ details: error.message + '. Please set UPLOAD_DIR or PENDING_PHOTOS_DIR environment variable to a network share path.'
+ },
+ { status: 500 }
+ );
+ }
+
+ const userUploadDir = path.join(uploadBaseDir, userId.toString());
+
+ // Check if parent directory (mount point) exists and is accessible
+ const parentDir = path.dirname(uploadBaseDir);
+ if (!existsSync(parentDir)) {
+ console.error(`[PHOTO UPLOAD] Parent directory (mount point) does not exist: ${parentDir}`);
+ return NextResponse.json(
+ {
+ error: 'Upload directory mount point not found',
+ details: `The mount point ${parentDir} does not exist. Please ensure the network share is mounted. See docs/NETWORK_SHARE_SETUP.md for setup instructions.`
+ },
+ { status: 500 }
+ );
+ }
+
+ // Check if parent directory is accessible (not just exists)
+ try {
+ await access(parentDir, constants.R_OK | constants.W_OK);
+ } catch (accessError: any) {
+ console.error(`[PHOTO UPLOAD] Cannot access parent directory: ${parentDir}`, accessError);
+ return NextResponse.json(
+ {
+ error: 'Upload directory not accessible',
+ details: `Cannot access mount point ${parentDir}. Please check permissions and ensure the network share is properly mounted. Error: ${accessError.message}`
+ },
+ { status: 500 }
+ );
+ }
+
+ // Create directories if they don't exist
+ // Note: Network shares should already exist, but we create user subdirectories
+ try {
+ if (!existsSync(uploadBaseDir)) {
+ console.warn(`[PHOTO UPLOAD] Upload directory does not exist, attempting to create: ${uploadBaseDir}`);
+ await mkdir(uploadBaseDir, { recursive: true });
+ }
+ if (!existsSync(userUploadDir)) {
+ await mkdir(userUploadDir, { recursive: true });
+ }
+ } catch (error: any) {
+ // Check if error is permission-related
+ if (error.code === 'EACCES' || error.errno === -13) {
+ console.error(`[PHOTO UPLOAD] Permission denied creating directory: ${uploadBaseDir}`, error);
+ return NextResponse.json(
+ {
+ error: 'Permission denied',
+ details: `Permission denied when creating ${uploadBaseDir}. The mount point ${parentDir} exists but the application does not have write permissions. Please check:\n1. The network share is mounted with correct permissions\n2. The web server user has write access to the mount point\n3. See docs/NETWORK_SHARE_SETUP.md for troubleshooting`
+ },
+ { status: 500 }
+ );
+ }
+ console.error(`[PHOTO UPLOAD] Failed to access/create upload directory: ${uploadBaseDir}`, error);
+ return NextResponse.json(
+ {
+ error: 'Failed to access upload directory',
+ details: `Cannot access network share at ${uploadBaseDir}. Please verify the path is correct and the server has read/write permissions. Error: ${error.message}`
+ },
+ { status: 500 }
+ );
+ }
+
+ // Save files and create database records
+ const savedPhotos = [];
+ const saveErrors: string[] = [];
+
+ for (const photo of validPhotos) {
+ try {
+ // Generate unique filename: timestamp-originalname
+ const timestamp = Date.now();
+ const sanitizedOriginalName = photo.name.replace(/[^a-zA-Z0-9._-]/g, '_');
+ const fileExtension = path.extname(photo.name);
+ const baseName = path.basename(sanitizedOriginalName, fileExtension);
+ const uniqueFilename = `${timestamp}-${baseName}${fileExtension}`;
+ const filePath = path.join(userUploadDir, uniqueFilename);
+
+ // Read file buffer
+ const arrayBuffer = await photo.file.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ // Write file to disk
+ await writeFile(filePath, buffer);
+
+ // Create database record
+ const pendingPhoto = await prismaAuth.pendingPhoto.create({
+ data: {
+ userId,
+ filename: uniqueFilename,
+ originalFilename: photo.name,
+ filePath: filePath,
+ fileSize: photo.size,
+ mimeType: photo.type,
+ status: 'pending',
+ },
+ });
+
+ savedPhotos.push({
+ id: pendingPhoto.id,
+ filename: photo.name,
+ size: photo.size,
+ });
+ } catch (error: any) {
+ console.error(`Error saving photo ${photo.name}:`, error);
+ saveErrors.push(`${photo.name}: ${error.message || 'Failed to save file'}`);
+ }
+ }
+
+ if (savedPhotos.length === 0) {
+ return NextResponse.json(
+ {
+ error: 'Failed to save any photos',
+ details: saveErrors.length > 0 ? saveErrors : ['Unknown error occurred']
+ },
+ { status: 500 }
+ );
+ }
+
+ console.log(`[FILE UPLOAD] User ${userId} (${session.user.email}) submitted ${savedPhotos.length} file(s) for review`);
+
+ return NextResponse.json({
+ message: `Successfully submitted ${savedPhotos.length} file(s) for admin review`,
+ photos: savedPhotos,
+ errors: errors.length > 0 || saveErrors.length > 0
+ ? [...errors, ...saveErrors]
+ : undefined,
+ });
+ } catch (error: any) {
+ console.error('Error uploading photos:', error);
+ return NextResponse.json(
+ { error: 'Failed to upload files', 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..cf1382d
--- /dev/null
+++ b/viewer-frontend/app/api/search/route.ts
@@ -0,0 +1,396 @@
+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: { photoId: number }) => 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) {
+ // Cast param to DATE to avoid "operator does not exist: date >= text" in Postgres
+ whereConditions.push(`date_taken >= $${paramIndex}::date`);
+ params.push(dateFrom);
+ paramIndex++;
+ }
+ if (dateTo) {
+ // Cast param to DATE to avoid "operator does not exist: date <= text" in Postgres
+ whereConditions.push(`date_taken <= $${paramIndex}::date`);
+ 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..47e5092
--- /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: number) => 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 0000000..718d6fe
Binary files /dev/null and b/viewer-frontend/app/favicon.ico differ
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..e9e605b
--- /dev/null
+++ b/viewer-frontend/app/login/page.tsx
@@ -0,0 +1,234 @@
+'use client';
+
+import { useState, Suspense } 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';
+
+function LoginForm() {
+ 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
+
+
+
+
+
+
+ );
+}
+
+export default function LoginPage() {
+ return (
+
+
+
+
+ Sign in to your account
+
+
Loading...
+
+
+
+ }>
+
+
+ );
+}
+
diff --git a/viewer-frontend/app/page.tsx b/viewer-frontend/app/page.tsx
new file mode 100644
index 0000000..03e02a7
--- /dev/null
+++ b/viewer-frontend/app/page.tsx
@@ -0,0 +1,248 @@
+import { Suspense } from 'react';
+import { prisma } from '@/lib/db';
+import { HomePageContent } from './HomePageContent';
+import { Photo } from '@prisma/client';
+import { serializePhotos, serializePeople, serializeTags } from '@/lib/serialize';
+
+// Force dynamic rendering to prevent database queries during build
+export const dynamic = 'force-dynamic';
+
+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..dd63e87
--- /dev/null
+++ b/viewer-frontend/app/photo/[id]/page.tsx
@@ -0,0 +1,109 @@
+import { notFound } from 'next/navigation';
+import { PhotoViewerClient } from '@/components/PhotoViewerClient';
+import { prisma } from '@/lib/db';
+import { serializePhoto, serializePhotos } from '@/lib/serialize';
+
+// Force dynamic rendering to prevent database queries during build
+export const dynamic = 'force-dynamic';
+
+async function getPhoto(id: number) {
+ try {
+ const photo = await prisma.photo.findUnique({
+ where: { id },
+ include: {
+ Face: {
+ include: {
+ Person: true,
+ },
+ },
+ PhotoTagLinkage: {
+ 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: {
+ Face: {
+ include: {
+ Person: true,
+ },
+ },
+ PhotoTagLinkage: {
+ include: {
+ Tag: true,
+ },
+ },
+ },
+ orderBy: { date_taken: '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
+
+
+
+
+
+
+ );
+}
+
+
+
diff --git a/viewer-frontend/app/reset-password/page.tsx b/viewer-frontend/app/reset-password/page.tsx
new file mode 100644
index 0000000..65b39da
--- /dev/null
+++ b/viewer-frontend/app/reset-password/page.tsx
@@ -0,0 +1,203 @@
+'use client';
+
+import { useState, useEffect, Suspense } 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';
+
+function ResetPasswordForm() {
+ 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
+
+
+
+
+
+ );
+}
+
+export default function ResetPasswordPage() {
+ return (
+
+
+
+
+ Reset your password
+
+
Loading...
+
+
+
+ }>
+
+
+ );
+}
+
+
+
+
+
+
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 && (
+
+ setPage((p) => Math.max(1, p - 1))}
+ >
+ Previous
+
+
+ Page {page} of {Math.ceil(total / 30)}
+
+ = Math.ceil(total / 30)}
+ onClick={() => setPage((p) => p + 1)}
+ >
+ Next
+
+
+ )}
+ >
+ ) : 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..379cddd
--- /dev/null
+++ b/viewer-frontend/app/search/page.tsx
@@ -0,0 +1,120 @@
+import { Suspense } from 'react';
+import { prisma } from '@/lib/db';
+import { SearchContent } from './SearchContent';
+import { PhotoGrid } from '@/components/PhotoGrid';
+
+// Force dynamic rendering to prevent database queries during build
+export const dynamic = 'force-dynamic';
+
+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, but include all required fields for type compatibility
+ 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 (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, but include all required fields for type compatibility
+ return await prisma.tag.findMany({
+ select: {
+ id: true,
+ tag_name: true,
+ created_date: true,
+ },
+ 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..4516386
--- /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
+ */
+// Force dynamic rendering to avoid prerendering issues with query strings
+export const dynamic = 'force-dynamic';
+
+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',
+ date_added: new Date(),
+ date_taken: null,
+ processed: true,
+ media_type: 'image',
+ },
+ // Test 2: Another direct URL
+ {
+ id: 9992,
+ path: 'https://picsum.photos/800/600?random=2',
+ filename: 'test-direct-url-2.jpg',
+ date_added: new Date(),
+ date_taken: null,
+ processed: true,
+ media_type: 'image',
+ },
+ // Test 3: File system path (will use API proxy)
+ {
+ id: 9993,
+ path: '/nonexistent/path/test.jpg',
+ filename: 'test-file-system.jpg',
+ date_added: new Date(),
+ date_taken: null,
+ processed: true,
+ media_type: 'image',
+ },
+ ];
+
+ return (
+
+
+
+ Image Source Test Page
+
+
+ Testing direct URL access vs API proxy
+
+
+
+
+
+ Test Instructions:
+
+
+ Open browser DevTools (F12) → Network tab
+ Filter by "Img" to see image requests
+
+ Direct URL images should show requests to{' '}
+
+ picsum.photos
+
+
+
+ File system images should show requests to{' '}
+
+ /api/photos/...
+
+
+
+
+
+
+
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 ? (
+ <>
+
setIsPlaying(true)}
+ onPause={() => setIsPlaying(false)}
+ onEnded={() => setIsPlaying(false)}
+ onLoadedMetadata={() => {
+ // Video is ready to play
+ }}
+ />
+ {/* Play/Pause Button Overlay */}
+ {uploadedFile.status === 'pending' && (
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+ )}
+ >
+ ) : (
+
+ )}
+ {!isVideo && (
+
+ )}
+
+ {/* Status Overlay */}
+ {uploadedFile.status !== 'pending' && (
+
+ {uploadedFile.status === 'uploading' && (
+
+ )}
+ {uploadedFile.status === 'success' && (
+
+ )}
+ {uploadedFile.status === 'error' && (
+
+ )}
+
+ )}
+
+ {/* Remove Button */}
+ {uploadedFile.status === 'pending' && (
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ onRemove(uploadedFile.id);
+ }}
+ className="absolute right-2 top-2 z-30 rounded-full bg-red-500 p-1.5 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600"
+ aria-label="Remove file"
+ type="button"
+ >
+
+
+ )}
+
+ {/* File Name */}
+
+
+ {uploadedFile.file.name}
+
+ {uploadedFile.error && (
+
+ {uploadedFile.error}
+
+ )}
+
+
+ );
+}
+
+export function UploadContent() {
+ const { data: session } = useSession();
+ const [files, setFiles] = useState([]);
+ const [isDragging, setIsDragging] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const handleFileSelect = useCallback((selectedFiles: FileList | null) => {
+ if (!selectedFiles) return;
+
+ const newFiles: UploadedFile[] = Array.from(selectedFiles)
+ .filter((file) => file.type.startsWith('image/') || file.type.startsWith('video/'))
+ .map((file) => ({
+ file,
+ preview: URL.createObjectURL(file),
+ id: `${Date.now()}-${Math.random()}`,
+ status: 'pending' as const,
+ }));
+
+ setFiles((prev) => [...prev, ...newFiles]);
+ }, []);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(true);
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+ }, []);
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+ handleFileSelect(e.dataTransfer.files);
+ },
+ [handleFileSelect]
+ );
+
+ const removeFile = useCallback((id: string) => {
+ setFiles((prev) => {
+ const file = prev.find((f) => f.id === id);
+ if (file) {
+ URL.revokeObjectURL(file.preview);
+ }
+ return prev.filter((f) => f.id !== id);
+ });
+ }, []);
+
+ const handleSubmit = useCallback(async () => {
+ if (files.length === 0 || !session?.user) return;
+
+ setIsSubmitting(true);
+
+ try {
+ const formData = new FormData();
+ files.forEach((uploadedFile) => {
+ formData.append('photos', uploadedFile.file);
+ });
+
+ // Update files to uploading status
+ setFiles((prev) =>
+ prev.map((f) => ({ ...f, status: 'uploading' as const }))
+ );
+
+ const response = await fetch('/api/photos/upload', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Failed to upload files');
+ }
+
+ const result = await response.json();
+
+ // Update files to success status
+ setFiles((prev) =>
+ prev.map((f) => ({ ...f, status: 'success' as const }))
+ );
+
+ // Clear files after 3 seconds
+ setTimeout(() => {
+ setFiles((currentFiles) => {
+ // Revoke object URLs to free memory
+ currentFiles.forEach((f) => URL.revokeObjectURL(f.preview));
+ return [];
+ });
+ }, 3000);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : 'Failed to upload files';
+
+ // Update files to error status
+ setFiles((prev) =>
+ prev.map((f) => ({
+ ...f,
+ status: 'error' as const,
+ error: errorMessage,
+ }))
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [files, session]);
+
+ const pendingFiles = files.filter((f) => f.status === 'pending');
+ const hasPendingFiles = pendingFiles.length > 0;
+ const allSuccess = files.length > 0 && files.every((f) => f.status === 'success');
+
+ return (
+
+ {/* Upload Area */}
+
+
handleFileSelect(e.target.files)}
+ />
+
+
+
+
+ Drop photos and videos here or click to browse
+
+
+ Images: JPEG, PNG, GIF, WebP (max 50MB) | Videos: MP4, MOV, AVI, WebM (max 500MB)
+
+
+ {
+ event.preventDefault();
+ fileInputRef.current?.click();
+ }}
+ >
+ Select Files
+
+
+
+
+ {/* File List */}
+ {files.length > 0 && (
+
+
+
+ Selected Files ({files.length})
+
+ {!allSuccess && (
+
+ {isSubmitting ? (
+ <>
+
+ Submitting...
+ >
+ ) : (
+ <>
+
+ Submit for Review
+ >
+ )}
+
+ )}
+
+
+
+ {files.map((uploadedFile) => (
+
+ ))}
+
+
+ {allSuccess && (
+
+
+
+
+ Files submitted successfully! They are now pending admin review.
+
+
+
+ )}
+
+ )}
+
+ );
+}
+
diff --git a/viewer-frontend/app/upload/UploadPageClient.tsx b/viewer-frontend/app/upload/UploadPageClient.tsx
new file mode 100644
index 0000000..fd40515
--- /dev/null
+++ b/viewer-frontend/app/upload/UploadPageClient.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { X } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { UploadContent } from './UploadContent';
+import Image from 'next/image';
+import Link from 'next/link';
+import UserMenu from '@/components/UserMenu';
+
+export function UploadPageClient() {
+ const router = useRouter();
+
+ const handleClose = () => {
+ router.push('/');
+ };
+
+ return (
+
+
+ {/* Close button */}
+
+
+
+
+
+
+ {/* Header */}
+
+
+
+ Browse our photo collection
+
+
+
+ {/* Upload content */}
+
+
+
+ Upload Photos & Videos
+
+
+ Upload your photos and videos for admin review. Once approved, they will be added to the collection.
+
+
+
+
+
+
+ );
+}
+
diff --git a/viewer-frontend/app/upload/page.tsx b/viewer-frontend/app/upload/page.tsx
new file mode 100644
index 0000000..c9598d9
--- /dev/null
+++ b/viewer-frontend/app/upload/page.tsx
@@ -0,0 +1,14 @@
+import { auth } from '@/app/api/auth/[...nextauth]/route';
+import { redirect } from 'next/navigation';
+import { UploadPageClient } from './UploadPageClient';
+
+export default async function UploadPage() {
+ const session = await auth();
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ return ;
+}
+
diff --git a/viewer-frontend/components.json b/viewer-frontend/components.json
new file mode 100644
index 0000000..b7b9791
--- /dev/null
+++ b/viewer-frontend/components.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/viewer-frontend/components/ActionButtons.tsx b/viewer-frontend/components/ActionButtons.tsx
new file mode 100644
index 0000000..6e26afd
--- /dev/null
+++ b/viewer-frontend/components/ActionButtons.tsx
@@ -0,0 +1,104 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { Play, Heart } from 'lucide-react';
+
+interface ActionButtonsProps {
+ photosCount: number;
+ isLoggedIn: boolean;
+ selectedPhotoIds: number[];
+ selectionMode: boolean;
+ isBulkFavoriting: boolean;
+ isPreparingDownload: boolean;
+ onStartSlideshow: () => void;
+ onTagSelected: () => void;
+ onBulkFavorite: () => void;
+ onDownloadSelected: () => void;
+ onToggleSelectionMode: () => void;
+}
+
+export function ActionButtons({
+ photosCount,
+ isLoggedIn,
+ selectedPhotoIds,
+ selectionMode,
+ isBulkFavoriting,
+ isPreparingDownload,
+ onStartSlideshow,
+ onTagSelected,
+ onBulkFavorite,
+ onDownloadSelected,
+ onToggleSelectionMode,
+}: ActionButtonsProps) {
+ if (photosCount === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ Play Slides
+
+ {isLoggedIn && (
+ <>
+
+ Tag selected
+ {selectedPhotoIds.length > 0
+ ? ` (${selectedPhotoIds.length})`
+ : ''}
+
+
+
+ {isBulkFavoriting ? 'Updating...' : 'Favorite selected'}
+ {!isBulkFavoriting && selectedPhotoIds.length > 0
+ ? ` (${selectedPhotoIds.length})`
+ : ''}
+
+
+ {isPreparingDownload ? 'Preparing download...' : 'Download selected'}
+ {!isPreparingDownload && selectedPhotoIds.length > 0
+ ? ` (${selectedPhotoIds.length})`
+ : ''}
+
+
+ {selectionMode ? 'Done selecting' : 'Select'}
+
+ >
+ )}
+
+ );
+}
+
+
+
+
+
+
diff --git a/viewer-frontend/components/ForgotPasswordDialog.tsx b/viewer-frontend/components/ForgotPasswordDialog.tsx
new file mode 100644
index 0000000..45babe1
--- /dev/null
+++ b/viewer-frontend/components/ForgotPasswordDialog.tsx
@@ -0,0 +1,154 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { isValidEmail } from '@/lib/utils';
+
+interface ForgotPasswordDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function ForgotPasswordDialog({
+ open,
+ onOpenChange,
+}: ForgotPasswordDialogProps) {
+ const [email, setEmail] = useState('');
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // Reset state when dialog opens
+ useEffect(() => {
+ if (open) {
+ setEmail('');
+ setError('');
+ setSuccess(false);
+ setIsLoading(false);
+ }
+ }, [open]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setSuccess(false);
+
+ if (!email || !isValidEmail(email)) {
+ setError('Please enter a valid email address');
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const response = await fetch('/api/auth/forgot-password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ setError(data.error || 'Failed to send password reset email');
+ } else {
+ setSuccess(true);
+ setEmail('');
+ }
+ } catch (err) {
+ setError('An error occurred. Please try again.');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ setEmail('');
+ setError('');
+ setSuccess(false);
+ }
+ onOpenChange(newOpen);
+ };
+
+ return (
+
+
+
+ Reset your password
+
+ Enter your email address and we'll send you a link to reset your password.
+
+
+ {success ? (
+
+
+
+ Password reset email sent! Please check your inbox and follow the instructions to reset your password.
+
+
+
+ handleOpenChange(false)} className="w-full">
+ Close
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+
+
+
+
+
diff --git a/viewer-frontend/components/Header.tsx b/viewer-frontend/components/Header.tsx
new file mode 100644
index 0000000..f908e88
--- /dev/null
+++ b/viewer-frontend/components/Header.tsx
@@ -0,0 +1,191 @@
+'use client';
+
+import { useState } from 'react';
+import { useSession, signOut } from 'next-auth/react';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { User, LogIn, UserPlus, Users, Home, Upload } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { LoginDialog } from '@/components/LoginDialog';
+import { RegisterDialog } from '@/components/RegisterDialog';
+import { ManageUsersPageClient } from '@/app/admin/users/ManageUsersPageClient';
+
+export function Header() {
+ const { data: session, status } = useSession();
+ const router = useRouter();
+ const [loginDialogOpen, setLoginDialogOpen] = useState(false);
+ const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
+ const [manageUsersOpen, setManageUsersOpen] = useState(false);
+ const [popoverOpen, setPopoverOpen] = useState(false);
+
+ const handleSignOut = async () => {
+ await signOut({ callbackUrl: '/' });
+ };
+
+ return (
+
+
+
+ {/* Home button - commented out for future use */}
+ {/*
+
+
+
+
+
+
+
+
+ Go to Home
+
+ */}
+
+
+
+ {session?.user && (
+
+
+
+
+
+
+
+
+
+ Upload your own photos
+
+
+ )}
+ {status === 'loading' ? (
+
+ ) : session?.user ? (
+
+
+
+
+
+
+
+
+
+
+ {session.user.name || 'User'}
+
+
+ {session.user.email}
+
+
+
+ {
+ setPopoverOpen(false);
+ router.push('/upload');
+ }}
+ >
+
+ Upload Photos
+
+ {session.user.isAdmin && (
+ {
+ setPopoverOpen(false);
+ setManageUsersOpen(true);
+ }}
+ >
+
+ Manage Users
+
+ )}
+ {
+ setPopoverOpen(false);
+ handleSignOut();
+ }}
+ >
+ Sign out
+
+
+
+
+
+ ) : (
+
+ setLoginDialogOpen(true)}
+ className="flex items-center gap-2"
+ >
+
+ Sign in
+
+ setRegisterDialogOpen(true)}
+ className="flex items-center gap-2"
+ >
+
+ Sign up
+
+
+ )}
+
+
+ {
+ setLoginDialogOpen(open);
+ }}
+ onOpenRegister={() => {
+ setLoginDialogOpen(false);
+ setRegisterDialogOpen(true);
+ }}
+ />
+ {
+ setRegisterDialogOpen(open);
+ }}
+ onOpenLogin={() => {
+ setRegisterDialogOpen(false);
+ setLoginDialogOpen(true);
+ }}
+ />
+ {manageUsersOpen && (
+ setManageUsersOpen(false)} />
+ )}
+
+ );
+}
+
diff --git a/viewer-frontend/components/IdentifyFaceDialog.tsx b/viewer-frontend/components/IdentifyFaceDialog.tsx
new file mode 100644
index 0000000..f4f9acd
--- /dev/null
+++ b/viewer-frontend/components/IdentifyFaceDialog.tsx
@@ -0,0 +1,604 @@
+'use client';
+
+import { useState, useEffect, useRef } from 'react';
+import { useSession } from 'next-auth/react';
+import { useRouter } from 'next/navigation';
+import { Button } from '@/components/ui/button';
+import { LoginDialog } from '@/components/LoginDialog';
+import { RegisterDialog } from '@/components/RegisterDialog';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import { Search } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+interface IdentifyFaceDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ faceId: number;
+ existingPerson?: {
+ firstName: string;
+ lastName: string;
+ middleName?: string | null;
+ maidenName?: string | null;
+ dateOfBirth?: Date | null;
+ } | null;
+ onSave: (data: {
+ personId?: number;
+ firstName?: string;
+ lastName?: string;
+ middleName?: string;
+ maidenName?: string;
+ dateOfBirth?: Date;
+ }) => Promise;
+}
+
+export function IdentifyFaceDialog({
+ open,
+ onOpenChange,
+ faceId,
+ existingPerson,
+ onSave,
+}: IdentifyFaceDialogProps) {
+ const { data: session, status, update } = useSession();
+ const router = useRouter();
+ const [firstName, setFirstName] = useState(existingPerson?.firstName || '');
+ const [lastName, setLastName] = useState(existingPerson?.lastName || '');
+ const [middleName, setMiddleName] = useState(existingPerson?.middleName || '');
+ const [maidenName, setMaidenName] = useState(existingPerson?.maidenName || '');
+ const [isSaving, setIsSaving] = useState(false);
+ const [errors, setErrors] = useState<{
+ firstName?: string;
+ lastName?: string;
+ }>({});
+
+ const isAuthenticated = status === 'authenticated';
+ const hasWriteAccess = session?.user?.hasWriteAccess === true;
+ const isLoading = status === 'loading';
+ const [mounted, setMounted] = useState(false);
+ const [loginDialogOpen, setLoginDialogOpen] = useState(false);
+ const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
+ const [showRegisteredMessage, setShowRegisteredMessage] = useState(false);
+ const [mode, setMode] = useState<'existing' | 'new'>('existing');
+ const [people, setPeople] = useState>([]);
+ const [selectedPersonId, setSelectedPersonId] = useState(null);
+ const [peopleSearchQuery, setPeopleSearchQuery] = useState('');
+ const [peoplePopoverOpen, setPeoplePopoverOpen] = useState(false);
+ const [loadingPeople, setLoadingPeople] = useState(false);
+
+ // Dragging state
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
+ const dialogRef = useRef(null);
+
+ // Prevent hydration mismatch by only rendering on client
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ // Reset position when dialog opens
+ useEffect(() => {
+ if (open) {
+ setPosition({ x: 0, y: 0 });
+ // Reset mode and selected person when dialog opens
+ setMode('existing');
+ setSelectedPersonId(null);
+ setPeopleSearchQuery('');
+ }
+ }, [open]);
+
+ // Fetch people when dialog opens
+ useEffect(() => {
+ if (open && mode === 'existing' && people.length === 0) {
+ fetchPeople();
+ }
+ }, [open, mode]);
+
+ const fetchPeople = async () => {
+ setLoadingPeople(true);
+ try {
+ const response = await fetch('/api/people');
+ if (!response.ok) throw new Error('Failed to fetch people');
+ const data = await response.json();
+ setPeople(data.people);
+ } catch (error) {
+ console.error('Error fetching people:', error);
+ } finally {
+ setLoadingPeople(false);
+ }
+ };
+
+ // Handle drag start
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault(); // Prevent text selection and other default behaviors
+ if (dialogRef.current) {
+ setIsDragging(true);
+ const rect = dialogRef.current.getBoundingClientRect();
+ // Calculate the center of the dialog
+ const centerX = rect.left + rect.width / 2;
+ const centerY = rect.top + rect.height / 2;
+ // Store the offset from mouse to dialog center
+ setDragStart({
+ x: e.clientX - centerX,
+ y: e.clientY - centerY,
+ });
+ }
+ };
+
+ // Handle dragging
+ useEffect(() => {
+ if (!isDragging) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ // Calculate new position relative to center (50%, 50%)
+ const newX = e.clientX - window.innerWidth / 2 - dragStart.x;
+ const newY = e.clientY - window.innerHeight / 2 - dragStart.y;
+ setPosition({ x: newX, y: newY });
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ };
+
+ window.addEventListener('mousemove', handleMouseMove);
+ window.addEventListener('mouseup', handleMouseUp);
+
+ return () => {
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [isDragging, dragStart]);
+
+ const handleSave = async () => {
+ // Reset errors
+ setErrors({});
+
+ if (mode === 'existing') {
+ // Validate person selection
+ if (!selectedPersonId) {
+ alert('Please select a person');
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ await onSave({ personId: selectedPersonId });
+ // Show success message
+ alert('Identification submitted successfully! It will be reviewed by an administrator before being applied.');
+ onOpenChange(false);
+ } catch (error: any) {
+ console.error('Error saving face identification:', error);
+ alert(error.message || 'Failed to submit identification. Please try again.');
+ } finally {
+ setIsSaving(false);
+ }
+ } else {
+ // Validate required fields for new person
+ const newErrors: typeof errors = {};
+ if (!firstName.trim()) {
+ newErrors.firstName = 'First name is required';
+ }
+ if (!lastName.trim()) {
+ newErrors.lastName = 'Last name is required';
+ }
+
+ if (Object.keys(newErrors).length > 0) {
+ setErrors(newErrors);
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ await onSave({
+ firstName: firstName.trim(),
+ lastName: lastName.trim(),
+ middleName: middleName.trim() || undefined,
+ maidenName: maidenName.trim() || undefined,
+ });
+ // Show success message
+ alert('Identification submitted successfully! It will be reviewed by an administrator before being applied.');
+ onOpenChange(false);
+ // Reset form after successful save
+ if (!existingPerson) {
+ setFirstName('');
+ setLastName('');
+ setMiddleName('');
+ setMaidenName('');
+ }
+ } catch (error: any) {
+ console.error('Error saving face identification:', error);
+ setErrors({
+ ...errors,
+ // Show error message
+ });
+ alert(error.message || 'Failed to submit identification. Please try again.');
+ } finally {
+ setIsSaving(false);
+ }
+ }
+ };
+
+ // Prevent hydration mismatch - don't render until mounted
+ if (!mounted) {
+ return null;
+ }
+
+ // Handle successful login/register - refresh session
+ const handleAuthSuccess = async () => {
+ await update();
+ router.refresh();
+ };
+
+ // Show login prompt if not authenticated
+ if (!isLoading && !isAuthenticated) {
+ return (
+ <>
+
+
+
+ Sign In Required
+
+ You need to be signed in to identify faces. Your identifications will be submitted for approval.
+
+
+
+
+ Please sign in or create an account to continue.
+
+
+ {
+ setLoginDialogOpen(true);
+ }}
+ className="flex-1"
+ >
+ Sign in
+
+ {
+ setRegisterDialogOpen(true);
+ }}
+ className="flex-1"
+ >
+ Register
+
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+
+
+ {
+ setLoginDialogOpen(open);
+ if (!open) {
+ setShowRegisteredMessage(false);
+ }
+ }}
+ onSuccess={handleAuthSuccess}
+ onOpenRegister={() => {
+ setLoginDialogOpen(false);
+ setRegisterDialogOpen(true);
+ }}
+ registered={showRegisteredMessage}
+ callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
+ />
+ {
+ setRegisterDialogOpen(open);
+ if (!open) {
+ setShowRegisteredMessage(false);
+ }
+ }}
+ onSuccess={handleAuthSuccess}
+ onOpenLogin={() => {
+ setShowRegisteredMessage(true);
+ setRegisterDialogOpen(false);
+ setLoginDialogOpen(true);
+ }}
+ callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
+ />
+ >
+ );
+ }
+
+ // Show write access required message if authenticated but no write access
+ if (!isLoading && isAuthenticated && !hasWriteAccess) {
+ return (
+
+
+
+ Write Access Required
+
+ You need write access to identify faces.
+
+
+
+
+ Only users with write access can identify faces. Please contact an administrator to request write access.
+
+
+
+ onOpenChange(false)}>
+ Close
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Identify Face
+
+ Choose an existing person or add a new person to identify this face. Your identification will be submitted for approval.
+
+
+ {isLoading ? (
+ Loading...
+ ) : (
+
+ {/* Mode selector */}
+
+ {
+ // Clear new person form data when switching to existing mode
+ setFirstName('');
+ setLastName('');
+ setMiddleName('');
+ setMaidenName('');
+ setErrors({});
+ setMode('existing');
+ }}
+ className="flex-1"
+ >
+ Select Existing Person
+
+ {
+ // Clear selected person when switching to new person mode
+ setSelectedPersonId(null);
+ setPeopleSearchQuery('');
+ setPeoplePopoverOpen(false);
+ setMode('new');
+ }}
+ className="flex-1"
+ >
+ Add New Person
+
+
+
+ {mode === 'existing' ? (
+
+
+ Select Person *
+
+
+
+
+
+ {selectedPersonId
+ ? (() => {
+ const person = people.find((p) => p.id === selectedPersonId);
+ return person
+ ? `${person.firstName} ${person.lastName}`
+ : 'Select a person...';
+ })()
+ : loadingPeople
+ ? 'Loading people...'
+ : 'Select a person...'}
+
+
+ {
+ event.stopPropagation();
+ }}
+ >
+
+
setPeopleSearchQuery(e.target.value)}
+ className="mb-2"
+ />
+
event.stopPropagation()}
+ >
+ {people.filter((person) => {
+ const fullName = `${person.firstName} ${person.lastName} ${person.middleName || ''} ${person.maidenName || ''}`.toLowerCase();
+ return fullName.includes(peopleSearchQuery.toLowerCase());
+ }).length === 0 ? (
+
No people found
+ ) : (
+
+ {people
+ .filter((person) => {
+ const fullName = `${person.firstName} ${person.lastName} ${person.middleName || ''} ${person.maidenName || ''}`.toLowerCase();
+ return fullName.includes(peopleSearchQuery.toLowerCase());
+ })
+ .map((person) => {
+ const isSelected = selectedPersonId === person.id;
+ return (
+
{
+ setSelectedPersonId(person.id);
+ setPeoplePopoverOpen(false);
+ }}
+ >
+
+
+ {person.firstName} {person.lastName}
+
+ {(person.middleName || person.maidenName) && (
+
+ {[person.middleName, person.maidenName].filter(Boolean).join(' • ')}
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ ) : (
+ <>
+
+
+ First Name *
+
+
setFirstName(e.target.value)}
+ placeholder="Enter first name"
+ className={cn(errors.firstName && 'border-red-500')}
+ />
+ {errors.firstName && (
+
{errors.firstName}
+ )}
+
+
+
+
+ Last Name *
+
+
setLastName(e.target.value)}
+ placeholder="Enter last name"
+ className={cn(errors.lastName && 'border-red-500')}
+ />
+ {errors.lastName && (
+
{errors.lastName}
+ )}
+
+
+
+
+ Middle Name
+
+ setMiddleName(e.target.value)}
+ placeholder="Enter middle name (optional)"
+ />
+
+
+
+
+ Maiden Name
+
+ setMaidenName(e.target.value)}
+ placeholder="Enter maiden name (optional)"
+ />
+
+
+ >
+ )}
+
+ )}
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {isSaving ? 'Saving...' : 'Submit for Approval'}
+
+
+
+
+ );
+}
+
diff --git a/viewer-frontend/components/IdleLogoutHandler.tsx b/viewer-frontend/components/IdleLogoutHandler.tsx
new file mode 100644
index 0000000..77a3c61
--- /dev/null
+++ b/viewer-frontend/components/IdleLogoutHandler.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import { useIdleLogout } from '@/hooks/useIdleLogout';
+
+/**
+ * Component that handles idle logout functionality
+ * Must be rendered inside SessionProvider to use useSession hook
+ */
+export function IdleLogoutHandler() {
+ // Log out users after 2 hours of inactivity
+ useIdleLogout(2 * 60 * 60 * 1000); // 2 hours in milliseconds
+
+ // This component doesn't render anything
+ return null;
+}
+
+
+
+
+
+
+
+
diff --git a/viewer-frontend/components/LoginDialog.tsx b/viewer-frontend/components/LoginDialog.tsx
new file mode 100644
index 0000000..0604613
--- /dev/null
+++ b/viewer-frontend/components/LoginDialog.tsx
@@ -0,0 +1,304 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { signIn } from 'next-auth/react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { Eye, EyeOff } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import Link from 'next/link';
+import { ForgotPasswordDialog } from '@/components/ForgotPasswordDialog';
+
+interface LoginDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess?: () => void;
+ onOpenRegister?: () => void;
+ callbackUrl?: string;
+ registered?: boolean;
+}
+
+export function LoginDialog({
+ open,
+ onOpenChange,
+ onSuccess,
+ onOpenRegister,
+ callbackUrl: initialCallbackUrl,
+ registered: initialRegistered,
+}: LoginDialogProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const callbackUrl = initialCallbackUrl || searchParams.get('callbackUrl') || '/';
+ const registered = initialRegistered || searchParams.get('registered') === 'true';
+
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [emailNotVerified, setEmailNotVerified] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isResending, setIsResending] = useState(false);
+ const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false);
+ const [showPassword, setShowPassword] = useState(false);
+
+ // Reset all form state when dialog opens
+ useEffect(() => {
+ if (open) {
+ setEmail('');
+ setPassword('');
+ setError('');
+ setEmailNotVerified(false);
+ setIsLoading(false);
+ setIsResending(false);
+ setShowPassword(false);
+ }
+ }, [open]);
+
+ const handleResendConfirmation = async () => {
+ setIsResending(true);
+ try {
+ const response = await fetch('/api/auth/resend-confirmation', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email }),
+ });
+ const data = await response.json();
+ if (response.ok) {
+ setError('');
+ setEmailNotVerified(false);
+ alert('Confirmation email sent! Please check your inbox.');
+ } else {
+ alert(data.error || 'Failed to resend confirmation email');
+ }
+ } catch (err) {
+ alert('An error occurred. Please try again.');
+ } finally {
+ setIsResending(false);
+ }
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setEmailNotVerified(false);
+ setIsLoading(true);
+
+ try {
+ // First check if email is verified
+ const checkResponse = await fetch('/api/auth/check-verification', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password }),
+ });
+ const checkData = await checkResponse.json();
+
+ if (!checkData.exists) {
+ setError('Invalid email or password');
+ setIsLoading(false);
+ return;
+ }
+
+ if (!checkData.passwordValid) {
+ setError('Invalid email or password');
+ setIsLoading(false);
+ return;
+ }
+
+ if (!checkData.verified) {
+ setEmailNotVerified(true);
+ setIsLoading(false);
+ return;
+ }
+
+ // Email is verified, proceed with login
+ const result = await signIn('credentials', {
+ email,
+ password,
+ redirect: false,
+ });
+
+ if (result?.error) {
+ setError('Invalid email or password');
+ } else {
+ onOpenChange(false);
+ if (onSuccess) {
+ onSuccess();
+ } else {
+ router.push(callbackUrl);
+ router.refresh();
+ }
+ }
+ } catch (err) {
+ setError('An error occurred. Please try again.');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ // Reset form when closing
+ setEmail('');
+ setPassword('');
+ setError('');
+ setEmailNotVerified(false);
+ setIsResending(false);
+ }
+ onOpenChange(newOpen);
+ };
+
+ return (
+
+
+
+ Sign in to your account
+
+ Or{' '}
+ {onOpenRegister ? (
+ {
+ handleOpenChange(false);
+ onOpenRegister();
+ }}
+ >
+ create a new account
+
+ ) : (
+ handleOpenChange(false)}
+ >
+ create a new account
+
+ )}
+
+
+
+
+
+
+ );
+}
+
diff --git a/viewer-frontend/components/PageHeader.tsx b/viewer-frontend/components/PageHeader.tsx
new file mode 100644
index 0000000..cdc210a
--- /dev/null
+++ b/viewer-frontend/components/PageHeader.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import Image from 'next/image';
+import Link from 'next/link';
+import UserMenu from '@/components/UserMenu';
+import { ActionButtons } from '@/components/ActionButtons';
+
+interface PageHeaderProps {
+ photosCount: number;
+ isLoggedIn: boolean;
+ selectedPhotoIds: number[];
+ selectionMode: boolean;
+ isBulkFavoriting: boolean;
+ isPreparingDownload: boolean;
+ onStartSlideshow: () => void;
+ onTagSelected: () => void;
+ onBulkFavorite: () => void;
+ onDownloadSelected: () => void;
+ onToggleSelectionMode: () => void;
+}
+
+export function PageHeader({
+ photosCount,
+ isLoggedIn,
+ selectedPhotoIds,
+ selectionMode,
+ isBulkFavoriting,
+ isPreparingDownload,
+ onStartSlideshow,
+ onTagSelected,
+ onBulkFavorite,
+ onDownloadSelected,
+ onToggleSelectionMode,
+}: PageHeaderProps) {
+ return (
+
+
+
+
+ Browse our photo collection
+
+
+
+
+ );
+}
+
+
+
+
+
+
diff --git a/viewer-frontend/components/PhotoGrid.tsx b/viewer-frontend/components/PhotoGrid.tsx
new file mode 100644
index 0000000..7c03270
--- /dev/null
+++ b/viewer-frontend/components/PhotoGrid.tsx
@@ -0,0 +1,918 @@
+'use client';
+
+import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
+import { useRouter } from 'next/navigation';
+import { useSession } from 'next-auth/react';
+import { Photo, Person } from '@prisma/client';
+import Image from 'next/image';
+import { Check, Flag, Play, Heart, Download } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ TooltipProvider,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import * as TooltipPrimitive from '@radix-ui/react-tooltip';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { parseFaceLocation, isPointInFace } from '@/lib/face-utils';
+import { isUrl, isVideo, getImageSrc } from '@/lib/photo-utils';
+import { LoginDialog } from '@/components/LoginDialog';
+import { RegisterDialog } from '@/components/RegisterDialog';
+
+interface FaceWithLocation {
+ id: number;
+ personId: number | null;
+ location: string;
+ person: Person | null;
+}
+
+interface PhotoWithPeople extends Photo {
+ faces?: FaceWithLocation[];
+}
+
+interface PhotoGridProps {
+ photos: PhotoWithPeople[];
+ selectionMode?: boolean;
+ selectedPhotoIds?: number[];
+ onToggleSelect?: (photoId: number) => void;
+ refreshFavoritesKey?: number;
+}
+
+
+/**
+ * Gets unique people names from photo faces
+ */
+function getPeopleNames(photo: PhotoWithPeople): string[] {
+ if (!photo.faces) return [];
+
+ const people = photo.faces
+ .map((face) => face.person)
+ .filter((person): person is Person => person !== null)
+ .map((person: any) => {
+ // Handle both camelCase and snake_case
+ const firstName = person.firstName || person.first_name || '';
+ const lastName = person.lastName || person.last_name || '';
+ return `${firstName} ${lastName}`.trim();
+ });
+
+ // Remove duplicates
+ return Array.from(new Set(people));
+}
+
+const REPORT_COMMENT_MAX_LENGTH = 300;
+
+const getPhotoFilename = (photo: Photo) => {
+ if (photo?.filename) {
+ return photo.filename;
+ }
+
+ if (photo?.path) {
+ const segments = photo.path.split(/[/\\]/);
+ const lastSegment = segments.pop();
+ if (lastSegment) {
+ return lastSegment;
+ }
+ }
+
+ return `photo-${photo?.id ?? 'download'}.jpg`;
+};
+
+const getPhotoDownloadUrl = (
+ photo: Photo,
+ options?: { forceProxy?: boolean; watermark?: boolean }
+) => {
+ const path = photo.path || '';
+ const isExternal = path.startsWith('http://') || path.startsWith('https://');
+
+ if (isExternal && !options?.forceProxy) {
+ return path;
+ }
+
+ const params = new URLSearchParams();
+ if (options?.watermark) {
+ params.set('watermark', 'true');
+ }
+ const query = params.toString();
+
+ return `/api/photos/${photo.id}/image${query ? `?${query}` : ''}`;
+};
+
+export function PhotoGrid({
+ photos,
+ selectionMode = false,
+ selectedPhotoIds = [],
+ onToggleSelect,
+ refreshFavoritesKey = 0,
+}: PhotoGridProps) {
+ const router = useRouter();
+ const { data: session, update } = useSession();
+ const isLoggedIn = Boolean(session);
+ const hasWriteAccess = session?.user?.hasWriteAccess === true;
+
+ // Normalize photos: ensure faces is always available (handle Face vs faces)
+ const normalizePhoto = (photo: PhotoWithPeople): PhotoWithPeople => {
+ const normalized = { ...photo };
+ // If photo has Face (capital F) but no faces (lowercase), convert it
+ if (!normalized.faces && (normalized as any).Face) {
+ normalized.faces = (normalized as any).Face.map((face: any) => ({
+ id: face.id,
+ personId: face.person_id || face.personId,
+ location: face.location,
+ person: face.Person ? {
+ id: face.Person.id,
+ firstName: face.Person.first_name,
+ lastName: face.Person.last_name,
+ middleName: face.Person.middle_name,
+ maidenName: face.Person.maiden_name,
+ dateOfBirth: face.Person.date_of_birth,
+ } : null,
+ }));
+ }
+ return normalized;
+ };
+
+ // Normalize all photos
+ const normalizedPhotos = useMemo(() => {
+ return photos.map(normalizePhoto);
+ }, [photos]);
+ const [hoveredFace, setHoveredFace] = useState<{
+ photoId: number;
+ faceId: number;
+ personId: number | null;
+ personName: string | null;
+ } | null>(null);
+ const [reportingPhotoId, setReportingPhotoId] = useState(null);
+ const [reportedPhotos, setReportedPhotos] = useState>(new Map());
+ const [favoritingPhotoId, setFavoritingPhotoId] = useState(null);
+ const [favoritedPhotos, setFavoritedPhotos] = useState>(new Map());
+ const [showSignInRequiredDialog, setShowSignInRequiredDialog] = useState(false);
+ const [loginDialogOpen, setLoginDialogOpen] = useState(false);
+ const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
+ const [showRegisteredMessage, setShowRegisteredMessage] = useState(false);
+ const [reportDialogPhotoId, setReportDialogPhotoId] = useState(null);
+ const [reportDialogComment, setReportDialogComment] = useState('');
+ const [reportDialogError, setReportDialogError] = useState(null);
+ const imageRefs = useRef>(new Map());
+
+ const handleMouseMove = useCallback((
+ e: React.MouseEvent,
+ photo: PhotoWithPeople
+ ) => {
+ // Skip face detection for videos
+ if (isVideo(photo)) {
+ setHoveredFace(null);
+ return;
+ }
+
+ if (!photo.faces || photo.faces.length === 0) {
+ setHoveredFace(null);
+ return;
+ }
+
+ const container = e.currentTarget;
+ const rect = container.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+ const mouseY = e.clientY - rect.top;
+
+ // Get image dimensions from cache
+ const imageData = imageRefs.current.get(photo.id);
+ if (!imageData) {
+ setHoveredFace(null);
+ return;
+ }
+
+ const { naturalWidth, naturalHeight } = imageData;
+ const containerWidth = rect.width;
+ const containerHeight = rect.height;
+
+ // Check each face to see if mouse is over it
+ for (const face of photo.faces) {
+ const location = parseFaceLocation(face.location);
+ if (!location) continue;
+
+ if (
+ isPointInFace(
+ mouseX,
+ mouseY,
+ location,
+ naturalWidth,
+ naturalHeight,
+ containerWidth,
+ containerHeight
+ )
+ ) {
+ // Face detected!
+ const person = face.person as any; // Handle both camelCase and snake_case
+ const personName = person
+ ? `${person.firstName || person.first_name || ''} ${person.lastName || person.last_name || ''}`.trim()
+ : null;
+
+ setHoveredFace({
+ photoId: photo.id,
+ faceId: face.id,
+ personId: face.personId,
+ personName: personName || null,
+ });
+ return;
+ }
+ }
+
+ // No face detected
+ setHoveredFace(null);
+ }, []);
+
+ const handleImageLoad = useCallback((photoId: number, img: HTMLImageElement) => {
+ imageRefs.current.set(photoId, {
+ naturalWidth: img.naturalWidth,
+ naturalHeight: img.naturalHeight,
+ });
+ }, []);
+
+ const handleDownloadPhoto = useCallback((event: React.MouseEvent, photo: Photo) => {
+ event.stopPropagation();
+ const link = document.createElement('a');
+ link.href = getPhotoDownloadUrl(photo, { watermark: !isLoggedIn });
+ link.download = getPhotoFilename(photo);
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }, [isLoggedIn]);
+
+ // Remove duplicates by ID to prevent React key errors
+ // Memoized to prevent recalculation on every render
+ // Must be called before any early returns to maintain hooks order
+ const uniquePhotos = useMemo(() => {
+ return normalizedPhotos.filter((photo, index, self) =>
+ index === self.findIndex((p) => p.id === photo.id)
+ );
+ }, [normalizedPhotos]);
+
+ // Fetch report status for all photos when component mounts or photos change
+ // Uses batch API to reduce N+1 query problem
+ // Must be called before any early returns to maintain hooks order
+ useEffect(() => {
+ if (!session?.user?.id) {
+ setReportedPhotos(new Map());
+ return;
+ }
+
+ const fetchReportStatuses = async () => {
+ const photoIds = uniquePhotos.map(p => p.id);
+
+ if (photoIds.length === 0) {
+ return;
+ }
+
+ try {
+ // Batch API call - single request for all photos
+ const response = await fetch('/api/photos/reports/batch', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ photoIds }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch report statuses');
+ }
+
+ const data = await response.json();
+ const statusMap = new Map();
+
+ // Process batch results
+ if (data.results) {
+ for (const [photoIdStr, result] of Object.entries(data.results)) {
+ const photoId = parseInt(photoIdStr, 10);
+ const reportData = result as { reported: boolean; status?: string };
+ if (reportData.reported && reportData.status) {
+ statusMap.set(photoId, { status: reportData.status });
+ }
+ }
+ }
+
+ setReportedPhotos(statusMap);
+ } catch (error) {
+ console.error('Error fetching batch report statuses:', error);
+ // Fallback: set empty map on error
+ setReportedPhotos(new Map());
+ }
+ };
+
+ fetchReportStatuses();
+ }, [uniquePhotos, session?.user?.id]);
+
+ // Fetch favorite status for all photos when component mounts or photos change
+ // Uses batch API to reduce N+1 query problem
+ useEffect(() => {
+ if (!session?.user?.id) {
+ setFavoritedPhotos(new Map());
+ return;
+ }
+
+ const fetchFavoriteStatuses = async () => {
+ const photoIds = uniquePhotos.map(p => p.id);
+
+ if (photoIds.length === 0) {
+ return;
+ }
+
+ try {
+ // Batch API call - single request for all photos
+ const response = await fetch('/api/photos/favorites/batch', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ photoIds }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch favorite statuses');
+ }
+
+ const data = await response.json();
+ const favoriteMap = new Map();
+
+ // Process batch results
+ if (data.results) {
+ for (const [photoIdStr, isFavorited] of Object.entries(data.results)) {
+ const photoId = parseInt(photoIdStr, 10);
+ favoriteMap.set(photoId, isFavorited as boolean);
+ }
+ }
+
+ setFavoritedPhotos(favoriteMap);
+ } catch (error) {
+ console.error('Error fetching batch favorite statuses:', error);
+ // Fallback: set empty map on error
+ setFavoritedPhotos(new Map());
+ }
+ };
+
+ fetchFavoriteStatuses();
+ }, [uniquePhotos, session?.user?.id, refreshFavoritesKey]);
+
+ // Filter out videos for slideshow navigation (only images)
+ // Note: This is only used for slideshow context, not for navigation
+ // Memoized to maintain consistent hook order
+ const imageOnlyPhotos = useMemo(() => {
+ return uniquePhotos.filter((p) => !isVideo(p));
+ }, [uniquePhotos]);
+
+ const handlePhotoClick = (photoId: number, index: number) => {
+ const photo = uniquePhotos.find((p) => p.id === photoId);
+ if (!photo) return;
+
+ // Use the full photos list (including videos) for navigation
+ // This ensures consistent navigation whether clicking a photo or video
+ const allPhotoIds = uniquePhotos.map((p) => p.id).join(',');
+ const photoIndex = uniquePhotos.findIndex((p) => p.id === photoId);
+
+ if (photoIndex === -1) return;
+
+ // Update URL with photo query param while preserving existing params (filters, etc.)
+ const params = new URLSearchParams(window.location.search);
+ params.set('photo', photoId.toString());
+ params.set('photos', allPhotoIds);
+ params.set('index', photoIndex.toString());
+ router.push(`/?${params.toString()}`, { scroll: false });
+ };
+
+ const handlePhotoInteraction = (photoId: number, index: number) => {
+ if (selectionMode && onToggleSelect) {
+ onToggleSelect(photoId);
+ return;
+ }
+
+ handlePhotoClick(photoId, index);
+ };
+
+ const resetReportDialog = () => {
+ setReportDialogPhotoId(null);
+ setReportDialogComment('');
+ setReportDialogError(null);
+ };
+
+ const handleUndoReport = async (photoId: number) => {
+ const reportInfo = reportedPhotos.get(photoId);
+ const isReported = reportInfo && reportInfo.status === 'pending';
+
+ if (!isReported || reportingPhotoId === photoId) {
+ return;
+ }
+
+ setReportingPhotoId(photoId);
+
+ try {
+ const response = await fetch(`/api/photos/${photoId}/report`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ if (response.status === 401) {
+ alert('Please sign in to report photos');
+ } else if (response.status === 403) {
+ alert('Cannot undo report that has already been reviewed');
+ } else if (response.status === 404) {
+ alert('Report not found');
+ } else {
+ alert(error.error || 'Failed to undo report');
+ }
+ return;
+ }
+
+ const newMap = new Map(reportedPhotos);
+ newMap.delete(photoId);
+ setReportedPhotos(newMap);
+ alert('Report undone successfully.');
+ } catch (error) {
+ console.error('Error undoing photo report:', error);
+ alert('Failed to undo report. Please try again.');
+ } finally {
+ setReportingPhotoId(null);
+ }
+ };
+
+ const handleReportButtonClick = async (e: React.MouseEvent, photoId: number) => {
+ e.stopPropagation(); // Prevent photo click from firing
+
+ if (!session) {
+ setShowSignInRequiredDialog(true);
+ return;
+ }
+
+ if (reportingPhotoId === photoId) return; // Already processing
+
+ const reportInfo = reportedPhotos.get(photoId);
+ const isPending = reportInfo && reportInfo.status === 'pending';
+ const isDismissed = reportInfo && reportInfo.status === 'dismissed';
+
+ if (isDismissed) {
+ alert('This report was dismissed by an administrator and cannot be resubmitted.');
+ return;
+ }
+
+ if (isPending) {
+ await handleUndoReport(photoId);
+ return;
+ }
+
+ setReportDialogPhotoId(photoId);
+ setReportDialogComment('');
+ setReportDialogError(null);
+ };
+
+ const handleSubmitReport = async () => {
+ if (reportDialogPhotoId === null) {
+ return;
+ }
+
+ const trimmedComment = reportDialogComment.trim();
+ if (trimmedComment.length > REPORT_COMMENT_MAX_LENGTH) {
+ setReportDialogError(`Comment must be ${REPORT_COMMENT_MAX_LENGTH} characters or less.`);
+ return;
+ }
+
+ setReportDialogError(null);
+ setReportingPhotoId(reportDialogPhotoId);
+
+ try {
+ const response = await fetch(`/api/photos/${reportDialogPhotoId}/report`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ comment: trimmedComment.length > 0 ? trimmedComment : null,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => null);
+ if (response.status === 401) {
+ setShowSignInRequiredDialog(true);
+ } else if (response.status === 403) {
+ alert(error?.error || 'Cannot re-report this photo.');
+ } else if (response.status === 409) {
+ alert('You have already reported this photo');
+ } else if (response.status === 400) {
+ setReportDialogError(error?.error || 'Invalid comment');
+ return;
+ } else {
+ alert(error?.error || 'Failed to report photo. Please try again.');
+ }
+ return;
+ }
+
+ const newMap = new Map(reportedPhotos);
+ newMap.set(reportDialogPhotoId, { status: 'pending' });
+ setReportedPhotos(newMap);
+
+ const previousReport = reportedPhotos.get(reportDialogPhotoId);
+ alert(
+ previousReport && previousReport.status === 'reviewed'
+ ? 'Photo re-reported successfully. Thank you for your report.'
+ : 'Photo reported successfully. Thank you for your report.'
+ );
+ resetReportDialog();
+ } catch (error) {
+ console.error('Error reporting photo:', error);
+ alert('Failed to create report. Please try again.');
+ } finally {
+ setReportingPhotoId(null);
+ }
+ };
+
+ const handleToggleFavorite = async (e: React.MouseEvent, photoId: number) => {
+ e.stopPropagation(); // Prevent photo click from firing
+
+ if (!session) {
+ setShowSignInRequiredDialog(true);
+ return;
+ }
+
+ if (favoritingPhotoId === photoId) return; // Already processing
+
+ setFavoritingPhotoId(photoId);
+
+ try {
+ const response = await fetch(`/api/photos/${photoId}/favorite`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ if (response.status === 401) {
+ setShowSignInRequiredDialog(true);
+ } else {
+ alert(error.error || 'Failed to toggle favorite');
+ }
+ return;
+ }
+
+ const data = await response.json();
+ const newMap = new Map(favoritedPhotos);
+ newMap.set(photoId, data.favorited);
+ setFavoritedPhotos(newMap);
+ } catch (error) {
+ console.error('Error toggling favorite:', error);
+ alert('Failed to toggle favorite. Please try again.');
+ } finally {
+ setFavoritingPhotoId(null);
+ }
+ };
+
+ return (
+
+
+ {uniquePhotos.map((photo, index) => {
+ const hoveredFaceForPhoto = hoveredFace?.photoId === photo.id ? hoveredFace : null;
+ const isSelected = selectionMode && selectedPhotoIds.includes(photo.id);
+
+ // Determine tooltip text while respecting auth visibility rules
+ let tooltipText: string = photo.filename; // Default fallback
+ const isVideoPhoto = isVideo(photo);
+
+ if (isVideoPhoto) {
+ tooltipText = `Video: ${photo.filename}`;
+ } else if (hoveredFaceForPhoto) {
+ // Hovering over a specific face
+ if (hoveredFaceForPhoto.personName) {
+ // Face is identified - show person name (only if logged in)
+ tooltipText = isLoggedIn ? hoveredFaceForPhoto.personName : photo.filename;
+ } else {
+ // Face is not identified - show "Identify" if user has write access or is not logged in
+ tooltipText = (!session || hasWriteAccess) ? 'Identify' : photo.filename;
+ }
+ } else if (isLoggedIn) {
+ // Hovering over photo (not a face) - show "People: " + names
+ const peopleNames = getPeopleNames(photo);
+ tooltipText = peopleNames.length > 0
+ ? `People: ${peopleNames.join(', ')}`
+ : photo.filename;
+ }
+
+ return (
+
+
+
+ handlePhotoInteraction(photo.id, index)}
+ aria-pressed={isSelected}
+ className={`relative w-full h-full overflow-hidden rounded-lg bg-gray-100 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-900 ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2' : ''}`}
+ onMouseMove={(e) => !isVideoPhoto && handleMouseMove(e, photo)}
+ onMouseLeave={() => setHoveredFace(null)}
+ >
+ !isVideoPhoto && handleImageLoad(photo.id, e.currentTarget)}
+ />
+
+ {/* Video play icon overlay */}
+ {isVideoPhoto && (
+
+ )}
+ {selectionMode && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+ {/* Download Button - Top Left Corner */}
+
handleDownloadPhoto(e, photo)}
+ className="absolute left-2 top-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
+ aria-label="Download photo"
+ title="Download photo"
+ >
+
+
+
+ {/* Report Button - Left Bottom Corner - Show always */}
+ {(() => {
+ if (!session) {
+ // Not logged in - show basic report button
+ return (
+
handleReportButtonClick(e, photo.id)}
+ className="absolute left-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
+ aria-label="Report inappropriate photo"
+ title="Report inappropriate photo"
+ >
+
+
+ );
+ }
+
+ // Logged in - show button with status
+ const reportInfo = reportedPhotos.get(photo.id);
+ const isReported = reportInfo && reportInfo.status === 'pending';
+ const isReviewed = reportInfo && reportInfo.status === 'reviewed';
+ const isDismissed = reportInfo && reportInfo.status === 'dismissed';
+
+ let tooltipText: string;
+ let buttonClass: string;
+
+ if (isReported) {
+ tooltipText = 'Reported as inappropriate. Click to undo';
+ buttonClass = 'bg-red-600/70 hover:bg-red-600/90';
+ } else if (isReviewed) {
+ tooltipText = 'Report reviewed and kept. Click to report again';
+ buttonClass = 'bg-green-600/70 hover:bg-green-600/90';
+ } else if (isDismissed) {
+ tooltipText = 'Report dismissed';
+ buttonClass = 'bg-gray-600/70 hover:bg-gray-600/90';
+ } else {
+ tooltipText = 'Report inappropriate photo';
+ buttonClass = 'bg-black/50 hover:bg-black/70';
+ }
+
+ return (
+
handleReportButtonClick(e, photo.id)}
+ disabled={reportingPhotoId === photo.id || isDismissed}
+ className={`absolute left-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed ${buttonClass}`}
+ aria-label={tooltipText}
+ title={tooltipText}
+ >
+
+
+ );
+ })()}
+
+ {/* Favorite Button - Right Bottom Corner - Show always */}
+ {(() => {
+ if (!session) {
+ // Not logged in - show basic favorite button
+ return (
+
handleToggleFavorite(e, photo.id)}
+ className="absolute right-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
+ aria-label="Add to favorites"
+ title="Add to favorites (sign in required)"
+ >
+
+
+ );
+ }
+
+ // Logged in - show button with favorite status
+ const isFavorited = favoritedPhotos.get(photo.id) || false;
+
+ return (
+
handleToggleFavorite(e, photo.id)}
+ disabled={favoritingPhotoId === photo.id}
+ className={`absolute right-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed ${
+ isFavorited
+ ? 'bg-red-600/70 hover:bg-red-600/90'
+ : 'bg-black/50 hover:bg-black/70'
+ }`}
+ aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
+ title={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
+ >
+
+
+ );
+ })()}
+
+
+ {tooltipText || photo.filename}
+
+
+ );
+ })}
+
+
+ {/* Report Comment Dialog */}
+ {
+ if (!open) {
+ resetReportDialog();
+ }
+ }}
+ >
+
+
+ Report Photo
+
+ Optionally include a short comment to help administrators understand the issue.
+
+
+
+
+ Comment (optional)
+
+
+
+ {
+ resetReportDialog();
+ }}
+ >
+ Cancel
+
+
+ {reportDialogPhotoId !== null && reportingPhotoId === reportDialogPhotoId
+ ? 'Reporting...'
+ : 'Report photo'}
+
+
+
+
+
+ {/* Sign In Required Dialog for Report */}
+
+
+
+ Sign In Required
+
+ You need to be signed in to report photos. Your reports will be reviewed by administrators.
+
+
+
+
+ Please sign in or create an account to continue.
+
+
+ {
+ setLoginDialogOpen(true);
+ }}
+ className="flex-1"
+ >
+ Sign in
+
+ {
+ setRegisterDialogOpen(true);
+ }}
+ className="flex-1"
+ >
+ Register
+
+
+
+
+ setShowSignInRequiredDialog(false)}>
+ Cancel
+
+
+
+
+
+ {/* Login Dialog */}
+ {
+ setLoginDialogOpen(open);
+ if (!open) {
+ setShowRegisteredMessage(false);
+ }
+ }}
+ onSuccess={async () => {
+ await update();
+ router.refresh();
+ setShowSignInRequiredDialog(false);
+ }}
+ onOpenRegister={() => {
+ setLoginDialogOpen(false);
+ setRegisterDialogOpen(true);
+ }}
+ registered={showRegisteredMessage}
+ callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
+ />
+
+ {/* Register Dialog */}
+ {
+ setRegisterDialogOpen(open);
+ if (!open) {
+ setShowRegisteredMessage(false);
+ }
+ }}
+ onSuccess={async () => {
+ await update();
+ router.refresh();
+ setShowSignInRequiredDialog(false);
+ }}
+ onOpenLogin={() => {
+ setShowRegisteredMessage(true);
+ setRegisterDialogOpen(false);
+ setLoginDialogOpen(true);
+ }}
+ callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
+ />
+
+ );
+}
+
+
+
diff --git a/viewer-frontend/components/PhotoViewer.tsx b/viewer-frontend/components/PhotoViewer.tsx
new file mode 100644
index 0000000..6c19370
--- /dev/null
+++ b/viewer-frontend/components/PhotoViewer.tsx
@@ -0,0 +1,172 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useSession } from 'next-auth/react';
+import { useRouter } from 'next/navigation';
+import Image from 'next/image';
+import { Photo, Person } from '@prisma/client';
+import { ChevronLeft, ChevronRight, X } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { isUrl, getImageSrc } from '@/lib/photo-utils';
+
+interface PhotoWithDetails extends Photo {
+ faces?: Array<{
+ person: Person | null;
+ }>;
+ photoTags?: Array<{
+ tag: {
+ tagName: string;
+ };
+ }>;
+}
+
+interface PhotoViewerProps {
+ photo: PhotoWithDetails;
+ previousId: number | null;
+ nextId: number | null;
+}
+
+
+export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) {
+ const router = useRouter();
+ const [loading, setLoading] = useState(false);
+ const { data: session } = useSession();
+ const isLoggedIn = Boolean(session);
+
+ // Keyboard navigation
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'ArrowLeft' && previousId) {
+ navigateToPhoto(previousId);
+ } else if (e.key === 'ArrowRight' && nextId) {
+ navigateToPhoto(nextId);
+ } else if (e.key === 'Escape') {
+ router.back();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [previousId, nextId, router]);
+
+ const navigateToPhoto = (photoId: number) => {
+ setLoading(true);
+ router.push(`/photo/${photoId}`);
+ };
+
+ const handlePrevious = () => {
+ if (previousId) {
+ navigateToPhoto(previousId);
+ }
+ };
+
+ const handleNext = () => {
+ if (nextId) {
+ navigateToPhoto(nextId);
+ }
+ };
+
+ const handleClose = () => {
+ // Use router.back() to return to the previous page without reloading
+ // This preserves filters, pagination, and scroll position
+ router.back();
+ };
+
+ const peopleNames = (photo as any).faces
+ ?.map((face: any) => face.Person)
+ .filter((person: any): person is Person => person !== null)
+ .map((person: Person) => `${person.first_name} ${person.last_name}`.trim()) || [];
+
+ const tags = (photo as any).PhotoTagLinkage?.map((pt: any) => pt.Tag.tag_name) || [];
+
+ return (
+
+ {/* Close Button */}
+
+
+
+
+ {/* Previous Button */}
+ {previousId && (
+
+
+
+ )}
+
+ {/* Next Button */}
+ {nextId && (
+
+
+
+ )}
+
+ {/* Photo Container */}
+
+ {loading ? (
+
Loading...
+ ) : (
+
+
+
+ )}
+
+
+ {/* Photo Info Overlay */}
+
+
+
{photo.filename}
+ {photo.date_taken && (
+
+ {new Date(photo.date_taken).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })}
+
+ )}
+ {peopleNames.length > 0 && (
+
+ People:
+ {peopleNames.join(', ')}
+
+ )}
+ {tags.length > 0 && (
+
+ Tags:
+ {tags.join(', ')}
+
+ )}
+
+
+
+ );
+}
+
diff --git a/viewer-frontend/components/PhotoViewerClient.tsx b/viewer-frontend/components/PhotoViewerClient.tsx
new file mode 100644
index 0000000..ed2d7e0
--- /dev/null
+++ b/viewer-frontend/components/PhotoViewerClient.tsx
@@ -0,0 +1,1680 @@
+'use client';
+
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import { useSession } from 'next-auth/react';
+import Image from 'next/image';
+import { Photo, Person } from '@prisma/client';
+import { ChevronLeft, ChevronRight, X, Play, Pause, ZoomIn, ZoomOut, RotateCcw, Flag, Heart, Download } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { parseFaceLocation, isPointInFaceWithFit } from '@/lib/face-utils';
+import { isUrl, isVideo, getImageSrc, getVideoSrc } from '@/lib/photo-utils';
+import { IdentifyFaceDialog } from '@/components/IdentifyFaceDialog';
+import { LoginDialog } from '@/components/LoginDialog';
+import { RegisterDialog } from '@/components/RegisterDialog';
+
+interface FaceWithLocation {
+ id: number;
+ personId: number | null;
+ location: string;
+ person: Person | null;
+}
+
+interface PhotoWithDetails extends Photo {
+ faces?: FaceWithLocation[];
+ photoTags?: Array<{
+ tag: {
+ tagName: string;
+ };
+ }>;
+}
+
+interface PhotoViewerClientProps {
+ initialPhoto: PhotoWithDetails;
+ allPhotos: PhotoWithDetails[];
+ currentIndex: number;
+ onClose?: () => void;
+ autoPlay?: boolean;
+ slideInterval?: number; // in milliseconds
+}
+
+const REPORT_COMMENT_MAX_LENGTH = 300;
+
+const getPhotoFilename = (photo: Photo) => {
+ if (photo?.filename) {
+ return photo.filename;
+ }
+
+ if (photo?.path) {
+ const segments = photo.path.split(/[/\\]/);
+ const lastSegment = segments.pop();
+ if (lastSegment) {
+ return lastSegment;
+ }
+ }
+
+ return `photo-${photo?.id ?? 'download'}.jpg`;
+};
+
+const getPhotoDownloadUrl = (
+ photo: Photo,
+ options?: { forceProxy?: boolean; watermark?: boolean }
+) => {
+ const path = photo.path || '';
+ const isExternal = path.startsWith('http://') || path.startsWith('https://');
+
+ if (isExternal && !options?.forceProxy) {
+ return path;
+ }
+
+ const params = new URLSearchParams();
+ if (options?.watermark) {
+ params.set('watermark', 'true');
+ }
+ const query = params.toString();
+
+ return `/api/photos/${photo.id}/image${query ? `?${query}` : ''}`;
+};
+
+
+export function PhotoViewerClient({
+ initialPhoto,
+ allPhotos,
+ currentIndex,
+ onClose,
+ autoPlay = false,
+ slideInterval = 5000, // 5 seconds default
+}: PhotoViewerClientProps) {
+ const router = useRouter();
+ const { data: session, update } = useSession();
+ const isLoggedIn = Boolean(session);
+
+ // Check if user has write access
+ const hasWriteAccess = session?.user?.hasWriteAccess === true;
+
+ // Debug logging
+ useEffect(() => {
+ if (session) {
+ console.log('[PhotoViewerClient] Session:', {
+ email: session.user?.email,
+ hasWriteAccess: session.user?.hasWriteAccess,
+ isAdmin: session.user?.isAdmin,
+ computedHasWriteAccess: hasWriteAccess,
+ });
+ }
+ }, [session, hasWriteAccess]);
+ // Normalize photo data: ensure faces is always available (handle Face vs faces)
+ const normalizePhoto = (photo: PhotoWithDetails): PhotoWithDetails => {
+ const normalized = { ...photo };
+ // If photo has Face (capital F) but no faces (lowercase), convert it
+ if (!normalized.faces && (normalized as any).Face) {
+ normalized.faces = (normalized as any).Face.map((face: any) => ({
+ id: face.id,
+ personId: face.person_id || face.personId,
+ location: face.location,
+ person: face.Person ? {
+ id: face.Person.id,
+ firstName: face.Person.first_name,
+ lastName: face.Person.last_name,
+ middleName: face.Person.middle_name,
+ maidenName: face.Person.maiden_name,
+ dateOfBirth: face.Person.date_of_birth,
+ } : null,
+ }));
+ }
+ return normalized;
+ };
+
+ const [currentPhoto, setCurrentPhoto] = useState(normalizePhoto(initialPhoto));
+ const [currentIdx, setCurrentIdx] = useState(currentIndex);
+ const [imageLoading, setImageLoading] = useState(false);
+ const [isPlaying, setIsPlaying] = useState(autoPlay);
+ const [currentInterval, setCurrentInterval] = useState(slideInterval);
+ const slideTimerRef = useRef(null);
+ const [zoom, setZoom] = useState(1);
+ const [panX, setPanX] = useState(0);
+ const [panY, setPanY] = useState(0);
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
+ const [hoveredFace, setHoveredFace] = useState<{
+ faceId: number;
+ personId: number | null;
+ personName: string | null;
+ mouseX: number;
+ mouseY: number;
+ } | null>(null);
+ const [clickedFace, setClickedFace] = useState<{
+ faceId: number;
+ person: Person | null;
+ } | null>(null);
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [reportingPhotoId, setReportingPhotoId] = useState(null);
+ const [isReported, setIsReported] = useState(false);
+ const [reportStatus, setReportStatus] = useState(null);
+ const [reportDialogPhotoId, setReportDialogPhotoId] = useState(null);
+ const [reportDialogComment, setReportDialogComment] = useState('');
+ const [reportDialogError, setReportDialogError] = useState(null);
+ const [favoritingPhotoId, setFavoritingPhotoId] = useState(null);
+ const [isFavorited, setIsFavorited] = useState(false);
+ const [showSignInRequiredDialog, setShowSignInRequiredDialog] = useState(false);
+ const [loginDialogOpen, setLoginDialogOpen] = useState(false);
+ const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
+ const [showRegisteredMessage, setShowRegisteredMessage] = useState(false);
+ const imageRef = useRef(null);
+ const videoRef = useRef(null);
+ const [isVideoPlaying, setIsVideoPlaying] = useState(false);
+ const videoAutoPlayAttemptedRef = useRef(null);
+ const containerRef = useRef(null);
+ const hoveredFaceTooltip = hoveredFace
+ ? hoveredFace.personName
+ ? (isLoggedIn ? hoveredFace.personName : null)
+ : (!session || hasWriteAccess ? 'Identify' : null)
+ : null;
+
+ // Debug: Log hoveredFace state changes
+ useEffect(() => {
+ console.log('[PhotoViewerClient] hoveredFace state changed:', {
+ hoveredFace,
+ hasHoveredFace: !!hoveredFace,
+ tooltip: hoveredFaceTooltip,
+ });
+ }, [hoveredFace, hoveredFaceTooltip]);
+
+ // Debug: Log tooltip calculation
+ useEffect(() => {
+ if (hoveredFace) {
+ console.log('[PhotoViewerClient] Tooltip calculation:', {
+ hoveredFace,
+ hasPersonName: !!hoveredFace.personName,
+ personName: hoveredFace.personName,
+ isLoggedIn,
+ hasSession: !!session,
+ hasWriteAccess,
+ tooltip: hoveredFaceTooltip,
+ tooltipLogic: hoveredFace.personName
+ ? `personName exists, isLoggedIn=${isLoggedIn} → ${isLoggedIn ? hoveredFace.personName : 'null'}`
+ : `no personName, !session=${!session} || hasWriteAccess=${hasWriteAccess} → ${(!session || hasWriteAccess) ? 'Identify' : 'null'}`,
+ });
+ }
+ }, [hoveredFace, hoveredFaceTooltip, isLoggedIn, session, hasWriteAccess]);
+
+ // Update current photo when index changes (client-side navigation)
+ useEffect(() => {
+ if (allPhotos.length > 0 && currentIdx >= 0 && currentIdx < allPhotos.length) {
+ const newPhoto = allPhotos[currentIdx];
+ if (newPhoto && newPhoto.id !== currentPhoto.id) {
+ setImageLoading(true);
+ const normalizedPhoto = normalizePhoto(newPhoto);
+ setCurrentPhoto(normalizedPhoto);
+ setHoveredFace(null); // Reset face detection when photo changes
+ imageRef.current = null; // Reset image ref
+ // Reset video state when photo changes
+ setIsVideoPlaying(false);
+ videoAutoPlayAttemptedRef.current = null;
+ if (videoRef.current) {
+ videoRef.current.pause();
+ videoRef.current.currentTime = 0;
+ }
+ // Reset zoom and pan when photo changes
+ setZoom(1);
+ setPanX(0);
+ setPanY(0);
+ }
+ }
+ }, [currentIdx, allPhotos, currentPhoto.id]);
+
+ // Debug: Log photo data structure when currentPhoto changes
+ useEffect(() => {
+ console.log('[PhotoViewerClient] Current photo changed:', {
+ photoId: currentPhoto.id,
+ filename: currentPhoto.filename,
+ hasFaces: !!currentPhoto.faces,
+ facesCount: currentPhoto.faces?.length || 0,
+ faces: currentPhoto.faces,
+ facesStructure: currentPhoto.faces?.map((face, idx) => {
+ const person = face.person as any; // Use any to check both camelCase and snake_case
+ return {
+ index: idx,
+ id: face.id,
+ personId: face.personId,
+ hasLocation: !!face.location,
+ location: face.location,
+ hasPerson: !!face.person,
+ person: face.person ? {
+ id: person.id,
+ // Check both camelCase and snake_case
+ firstName: person.firstName || person.first_name,
+ lastName: person.lastName || person.last_name,
+ // Show raw person object to see actual structure
+ rawPerson: person,
+ } : null,
+ };
+ }),
+ });
+ }, [currentPhoto.id, currentPhoto.faces]);
+
+ // Auto-play videos when navigated to (only once per photo)
+ useEffect(() => {
+ if (isVideo(currentPhoto) && videoRef.current && videoAutoPlayAttemptedRef.current !== currentPhoto.id) {
+ // Mark that we've attempted auto-play for this photo
+ videoAutoPlayAttemptedRef.current = currentPhoto.id;
+ // Ensure controls are enabled
+ if (videoRef.current) {
+ videoRef.current.controls = true;
+ }
+ // Small delay to ensure video element is ready
+ const timer = setTimeout(() => {
+ if (videoRef.current && videoAutoPlayAttemptedRef.current === currentPhoto.id) {
+ videoRef.current.play().catch((error) => {
+ // Autoplay may fail due to browser policies, that's okay
+ console.log('Video autoplay prevented:', error);
+ });
+ }
+ }, 100);
+ return () => clearTimeout(timer);
+ }
+ }, [currentPhoto]);
+
+ // Check report status when photo changes or session changes
+ useEffect(() => {
+ const checkReportStatus = async () => {
+ if (!session?.user?.id || !currentPhoto) {
+ setIsReported(false);
+ setReportStatus(null);
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/photos/${currentPhoto.id}/report`);
+ if (response.ok) {
+ const data = await response.json();
+ if (data.reported && data.status === 'pending') {
+ setIsReported(true);
+ setReportStatus(data.status);
+ } else {
+ setIsReported(false);
+ setReportStatus(data.status || null);
+ }
+ } else {
+ setIsReported(false);
+ setReportStatus(null);
+ }
+ } catch (error) {
+ console.error('Error checking report status:', error);
+ setIsReported(false);
+ setReportStatus(null);
+ }
+ };
+
+ checkReportStatus();
+ }, [currentPhoto.id, session?.user?.id]);
+
+ // Check favorite status when photo changes or session changes
+ useEffect(() => {
+ const checkFavoriteStatus = async () => {
+ if (!session?.user?.id || !currentPhoto) {
+ setIsFavorited(false);
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/photos/${currentPhoto.id}/favorite`);
+ if (response.ok) {
+ const data = await response.json();
+ setIsFavorited(data.favorited || false);
+ } else {
+ setIsFavorited(false);
+ }
+ } catch (error) {
+ console.error('Error checking favorite status:', error);
+ setIsFavorited(false);
+ }
+ };
+
+ checkFavoriteStatus();
+ }, [currentPhoto.id, session?.user?.id]);
+
+ // Keyboard navigation
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'ArrowLeft') {
+ handlePrevious();
+ } else if (e.key === 'ArrowRight') {
+ handleNext(false);
+ } else if (e.key === 'Escape') {
+ handleClose();
+ } else if (e.key === '+' || e.key === '=') {
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ handleZoomIn();
+ }
+ } else if (e.key === '-' || e.key === '_') {
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ handleZoomOut();
+ }
+ } else if (e.key === '0' && (e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ handleResetZoom();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [currentIdx, allPhotos.length]);
+
+ const handlePrevious = () => {
+ // Stop slideshow when user manually navigates
+ if (isPlaying) {
+ setIsPlaying(false);
+ }
+
+ if (allPhotos.length > 0 && currentIdx > 0) {
+ const newIdx = currentIdx - 1;
+ setCurrentIdx(newIdx);
+
+ // Update URL if in modal mode (onClose is provided)
+ if (onClose && allPhotos[newIdx]) {
+ const photoIds = allPhotos.map((p) => p.id).join(',');
+ // Preserve existing query params (filters, etc.)
+ const params = new URLSearchParams(window.location.search);
+ params.set('photo', allPhotos[newIdx].id.toString());
+ params.set('photos', photoIds);
+ params.set('index', newIdx.toString());
+ router.replace(`/?${params.toString()}`, { scroll: false });
+ }
+ }
+ };
+
+ const handleNext = (fromSlideshow = false) => {
+ // Stop slideshow when user manually navigates (unless it's from the slideshow itself)
+ if (!fromSlideshow && isPlaying) {
+ setIsPlaying(false);
+ }
+
+ if (allPhotos.length > 0 && currentIdx < allPhotos.length - 1) {
+ // Find next non-video photo for slideshow
+ let newIdx = currentIdx + 1;
+ if (fromSlideshow) {
+ // Skip videos in slideshow mode
+ while (newIdx < allPhotos.length && isVideo(allPhotos[newIdx])) {
+ newIdx++;
+ }
+ // If we've reached the end (only videos remaining), stop slideshow
+ if (newIdx >= allPhotos.length) {
+ setIsPlaying(false);
+ return;
+ }
+ }
+
+ setCurrentIdx(newIdx);
+
+ // Update URL if in modal mode (onClose is provided)
+ if (onClose && allPhotos[newIdx]) {
+ const photoIds = allPhotos.map((p) => p.id).join(',');
+ // Preserve existing query params (filters, etc.)
+ const params = new URLSearchParams(window.location.search);
+ params.set('photo', allPhotos[newIdx].id.toString());
+ params.set('photos', photoIds);
+ params.set('index', newIdx.toString());
+ router.replace(`/?${params.toString()}`, { scroll: false });
+ }
+ } else if (allPhotos.length > 0 && currentIdx === allPhotos.length - 1 && isPlaying) {
+ // If at the end and playing, stop the slideshow
+ setIsPlaying(false);
+ }
+ };
+
+ // Auto-advance slideshow
+ useEffect(() => {
+ // If slideshow is playing and current photo is a video, skip to next non-video immediately
+ if (isPlaying && isVideo(currentPhoto) && allPhotos.length > 0) {
+ // Find next non-video photo
+ let nextIdx = currentIdx + 1;
+ while (nextIdx < allPhotos.length && isVideo(allPhotos[nextIdx])) {
+ nextIdx++;
+ }
+ // If we found a non-video photo, advance to it
+ if (nextIdx < allPhotos.length) {
+ setCurrentIdx(nextIdx);
+ if (onClose && allPhotos[nextIdx]) {
+ const photoIds = allPhotos.map((p) => p.id).join(',');
+ const params = new URLSearchParams(window.location.search);
+ params.set('photo', allPhotos[nextIdx].id.toString());
+ params.set('photos', photoIds);
+ params.set('index', nextIdx.toString());
+ router.replace(`/?${params.toString()}`, { scroll: false });
+ }
+ return;
+ } else {
+ // No more non-video photos, stop slideshow
+ setIsPlaying(false);
+ return;
+ }
+ }
+
+ if (isPlaying && !imageLoading && allPhotos.length > 0 && currentIdx < allPhotos.length - 1 && !isVideo(currentPhoto)) {
+ slideTimerRef.current = setTimeout(() => {
+ handleNext(true); // Pass true to indicate this is from slideshow
+ }, currentInterval);
+ } else {
+ if (slideTimerRef.current) {
+ clearTimeout(slideTimerRef.current);
+ slideTimerRef.current = null;
+ }
+ }
+
+ return () => {
+ if (slideTimerRef.current) {
+ clearTimeout(slideTimerRef.current);
+ }
+ };
+ }, [isPlaying, currentIdx, imageLoading, allPhotos, currentInterval, currentPhoto, onClose, router]);
+
+ const toggleSlideshow = () => {
+ setIsPlaying(!isPlaying);
+ };
+
+ // Zoom functions
+ const handleZoomIn = (e?: React.MouseEvent) => {
+ if (e) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ // Skip zoom for videos
+ if (isVideo(currentPhoto)) {
+ return;
+ }
+ // Pause slideshow when user zooms
+ if (isPlaying) {
+ setIsPlaying(false);
+ }
+ setZoom((prev) => {
+ const newZoom = Math.min(prev + 0.25, 5); // Max zoom 5x
+ console.log('Zoom in:', newZoom);
+ return newZoom;
+ });
+ };
+
+ const handleZoomOut = (e?: React.MouseEvent) => {
+ if (e) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ // Skip zoom for videos
+ if (isVideo(currentPhoto)) {
+ return;
+ }
+ // Pause slideshow when user zooms
+ if (isPlaying) {
+ setIsPlaying(false);
+ }
+ setZoom((prev) => {
+ const newZoom = Math.max(prev - 0.25, 0.5); // Min zoom 0.5x
+ console.log('Zoom out:', newZoom);
+ if (newZoom === 1) {
+ // Reset pan when zoom returns to 1
+ setPanX(0);
+ setPanY(0);
+ }
+ return newZoom;
+ });
+ };
+
+ const handleResetZoom = (e?: React.MouseEvent) => {
+ if (e) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ // Skip zoom for videos
+ if (isVideo(currentPhoto)) {
+ return;
+ }
+ console.log('Reset zoom');
+ setZoom(1);
+ setPanX(0);
+ setPanY(0);
+ };
+
+ // Mouse wheel zoom
+ useEffect(() => {
+ const handleWheel = (e: WheelEvent) => {
+ // Skip zoom for videos
+ if (isVideo(currentPhoto)) {
+ return;
+ }
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ if (e.deltaY < 0) {
+ handleZoomIn();
+ } else {
+ handleZoomOut();
+ }
+ }
+ };
+
+ const container = containerRef.current;
+ if (container) {
+ container.addEventListener('wheel', handleWheel, { passive: false });
+ return () => container.removeEventListener('wheel', handleWheel);
+ }
+ }, [currentPhoto]);
+
+ // Pan when zoomed
+ const handleMouseMovePan = useCallback((e: React.MouseEvent) => {
+ if (isDragging && zoom > 1) {
+ setPanX(e.clientX - dragStart.x);
+ setPanY(e.clientY - dragStart.y);
+ }
+ }, [isDragging, zoom, dragStart]);
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ if (zoom > 1 && e.button === 0) {
+ e.preventDefault();
+ setIsDragging(true);
+ setDragStart({ x: e.clientX - panX, y: e.clientY - panY });
+ }
+ };
+
+ const handleMouseUp = useCallback(() => {
+ setIsDragging(false);
+ }, []);
+
+ // Global mouse up handler for dragging
+ useEffect(() => {
+ if (isDragging) {
+ const handleGlobalMouseUp = () => {
+ setIsDragging(false);
+ };
+ window.addEventListener('mouseup', handleGlobalMouseUp);
+ return () => window.removeEventListener('mouseup', handleGlobalMouseUp);
+ }
+ }, [isDragging]);
+
+ // Update interval handler
+ const handleIntervalChange = (value: string) => {
+ const newInterval = parseInt(value, 10) * 1000; // Convert seconds to milliseconds
+ setCurrentInterval(newInterval);
+ };
+
+ const handleClose = () => {
+ // Stop slideshow when closing
+ setIsPlaying(false);
+ if (slideTimerRef.current) {
+ clearTimeout(slideTimerRef.current);
+ slideTimerRef.current = null;
+ }
+
+ if (onClose) {
+ // Use provided callback (for modal mode)
+ onClose();
+ } else {
+ // Fallback to router.back() for route-based navigation
+ router.back();
+ }
+ };
+
+ const handleImageLoad = (e: React.SyntheticEvent) => {
+ setImageLoading(false);
+ imageRef.current = e.currentTarget;
+ console.log('[PhotoViewerClient] Image loaded, imageRef set:', {
+ hasImageRef: !!imageRef.current,
+ naturalWidth: imageRef.current?.naturalWidth,
+ naturalHeight: imageRef.current?.naturalHeight,
+ src: imageRef.current?.src,
+ currentPhotoId: currentPhoto.id,
+ });
+ };
+
+ const handleImageError = () => {
+ setImageLoading(false);
+ };
+
+ const handleDownloadPhoto = useCallback(() => {
+ const link = document.createElement('a');
+ link.href = getPhotoDownloadUrl(currentPhoto, { watermark: !isLoggedIn });
+ link.download = getPhotoFilename(currentPhoto);
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }, [currentPhoto, isLoggedIn]);
+
+ const findFaceAtPoint = useCallback((x: number, y: number) => {
+ console.log('[PhotoViewerClient] findFaceAtPoint called:', {
+ x,
+ y,
+ hasFaces: !!currentPhoto.faces,
+ facesCount: currentPhoto.faces?.length || 0,
+ hasImageRef: !!imageRef.current,
+ hasContainerRef: !!containerRef.current,
+ });
+
+ if (!currentPhoto.faces || currentPhoto.faces.length === 0) {
+ console.log('[PhotoViewerClient] findFaceAtPoint: No faces in photo');
+ return null;
+ }
+
+ if (!imageRef.current || !containerRef.current) {
+ console.log('[PhotoViewerClient] findFaceAtPoint: Missing refs', {
+ imageRef: !!imageRef.current,
+ containerRef: !!containerRef.current,
+ });
+ return null;
+ }
+
+ const container = containerRef.current;
+ const rect = container.getBoundingClientRect();
+ const mouseX = x - rect.left;
+ const mouseY = y - rect.top;
+
+ const img = imageRef.current;
+ const naturalWidth = img.naturalWidth;
+ const naturalHeight = img.naturalHeight;
+ const containerWidth = rect.width;
+ const containerHeight = rect.height;
+
+ console.log('[PhotoViewerClient] findFaceAtPoint: Image dimensions:', {
+ naturalWidth,
+ naturalHeight,
+ containerWidth,
+ containerHeight,
+ mouseX,
+ mouseY,
+ });
+
+ if (!naturalWidth || !naturalHeight) {
+ console.log('[PhotoViewerClient] findFaceAtPoint: Invalid image dimensions');
+ return null;
+ }
+
+ // Check each face to see if point is over it
+ for (const face of currentPhoto.faces) {
+ if (!face.location) {
+ console.log('[PhotoViewerClient] findFaceAtPoint: Face missing location', { faceId: face.id });
+ continue;
+ }
+
+ const location = parseFaceLocation(face.location);
+ if (!location) {
+ console.log('[PhotoViewerClient] findFaceAtPoint: Failed to parse location', {
+ faceId: face.id,
+ location: face.location
+ });
+ continue;
+ }
+
+ const isInFace = isPointInFaceWithFit(
+ mouseX,
+ mouseY,
+ location,
+ naturalWidth,
+ naturalHeight,
+ containerWidth,
+ containerHeight,
+ 'contain' // PhotoViewer uses object-contain
+ );
+
+ if (isInFace) {
+ const person = face.person as any; // Use any to check both camelCase and snake_case
+ const firstName = person?.firstName || person?.first_name;
+ const lastName = person?.lastName || person?.last_name;
+ const personName = firstName && lastName ? `${firstName} ${lastName}` : null;
+ console.log('[PhotoViewerClient] findFaceAtPoint: Face found!', {
+ faceId: face.id,
+ personId: face.personId,
+ hasPerson: !!face.person,
+ personName,
+ personRaw: person, // Show raw person to see actual structure
+ });
+ return face;
+ }
+ }
+
+ console.log('[PhotoViewerClient] findFaceAtPoint: No face found at point');
+ return null;
+ }, [currentPhoto.faces]);
+
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
+ // Skip face detection for videos
+ if (isVideo(currentPhoto)) {
+ return;
+ }
+
+ // Handle pan if dragging and zoomed
+ if (isDragging && zoom > 1) {
+ handleMouseMovePan(e);
+ return;
+ }
+
+ const face = findFaceAtPoint(e.clientX, e.clientY);
+
+ if (face) {
+ const person = face.person as any;
+ const firstName = person?.first_name || person?.firstName;
+ const lastName = person?.last_name || person?.lastName;
+ const personName = firstName && lastName ? `${firstName} ${lastName}`.trim() : null;
+
+ console.log('[PhotoViewerClient] handleMouseMove: Face detected on hover', {
+ faceId: face.id,
+ personId: face.personId,
+ personName,
+ mouseX: e.clientX,
+ mouseY: e.clientY,
+ });
+
+ // Update hovered face (or set if different face)
+ setHoveredFace((prev) => {
+ // If same face, just update position
+ if (prev && prev.faceId === face.id) {
+ return {
+ ...prev,
+ mouseX: e.clientX,
+ mouseY: e.clientY,
+ };
+ }
+ // New face detected
+ return {
+ faceId: face.id,
+ personId: face.personId,
+ personName,
+ mouseX: e.clientX,
+ mouseY: e.clientY,
+ };
+ });
+ } else {
+ // Only log when clearing hoveredFace to avoid spam
+ if (hoveredFace) {
+ console.log('[PhotoViewerClient] handleMouseMove: No face detected, clearing hoveredFace');
+ }
+ setHoveredFace(null);
+ }
+ }, [findFaceAtPoint, isDragging, zoom, currentPhoto, hoveredFace]);
+
+ const handleClick = useCallback((e: React.MouseEvent) => {
+ console.log('[PhotoViewerClient] handleClick called:', {
+ isVideo: isVideo(currentPhoto),
+ clientX: e.clientX,
+ clientY: e.clientY,
+ hasSession: !!session,
+ hasWriteAccess,
+ isDragging,
+ zoom,
+ });
+
+ // Handle video play/pause on click
+ if (isVideo(currentPhoto)) {
+ if (videoRef.current) {
+ if (isVideoPlaying) {
+ videoRef.current.pause();
+ setIsVideoPlaying(false);
+ } else {
+ videoRef.current.play();
+ setIsVideoPlaying(true);
+ }
+ }
+ return;
+ }
+
+ const face = findFaceAtPoint(e.clientX, e.clientY);
+
+ // Only allow clicking on unidentified faces (when tooltip shows "Identify")
+ // Click is allowed if: face exists, face is NOT identified, and user has write access (or is not signed in)
+ const isUnidentified = face && !face.person;
+ const canClick = isUnidentified && (!session || hasWriteAccess);
+
+ console.log('[PhotoViewerClient] handleClick: Face detection result:', {
+ foundFace: !!face,
+ faceId: face?.id,
+ facePersonId: face?.personId,
+ faceHasPerson: !!face?.person,
+ isUnidentified,
+ session: !!session,
+ hasWriteAccess,
+ canClick,
+ conditionBreakdown: face ? {
+ 'face exists': !!face,
+ 'face.person (identified)': !!face.person,
+ 'isUnidentified': isUnidentified,
+ '!session': !session,
+ 'hasWriteAccess': hasWriteAccess,
+ 'canClick': canClick,
+ } : null,
+ });
+
+ if (canClick) {
+ console.log('[PhotoViewerClient] handleClick: Opening identify dialog', {
+ faceId: face.id,
+ person: face.person,
+ });
+ setClickedFace({
+ faceId: face.id,
+ person: face.person,
+ });
+ setIsDialogOpen(true);
+ } else {
+ console.log('[PhotoViewerClient] handleClick: Click blocked', {
+ reason: !face ? 'no face found' : face?.person ? 'face already identified' : 'insufficient permissions',
+ });
+ }
+ }, [findFaceAtPoint, session, hasWriteAccess, currentPhoto, isVideoPlaying, isDragging, zoom]);
+
+ const handleSaveFace = async (data: {
+ personId?: number;
+ firstName?: string;
+ lastName?: string;
+ middleName?: string;
+ maidenName?: string;
+ dateOfBirth?: Date;
+ }) => {
+ if (!clickedFace) return;
+
+ const response = await fetch(`/api/faces/${clickedFace.faceId}/identify`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ if (response.status === 401) {
+ // Authentication error - the dialog should handle this
+ throw new Error('Please sign in to identify faces');
+ }
+ throw new Error(error.error || 'Failed to save face identification');
+ }
+
+ const result = await response.json();
+ // Success - identification is pending approval
+ // Note: We don't refresh the photo data since it's pending approval
+ // The face won't show the identification until approved by admin
+ };
+
+ const resetReportDialog = () => {
+ setReportDialogPhotoId(null);
+ setReportDialogComment('');
+ setReportDialogError(null);
+ };
+
+ const handleUndoReport = async (photoId: number) => {
+ if (reportingPhotoId === photoId) {
+ return;
+ }
+
+ setReportingPhotoId(photoId);
+
+ try {
+ const response = await fetch(`/api/photos/${photoId}/report`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ if (response.status === 401) {
+ alert('Please sign in to report photos');
+ } else if (response.status === 403) {
+ alert('Cannot undo report that has already been reviewed');
+ } else if (response.status === 404) {
+ alert('Report not found');
+ } else {
+ alert(error.error || 'Failed to undo report');
+ }
+ return;
+ }
+
+ setIsReported(false);
+ setReportStatus(null);
+ alert('Report undone successfully.');
+ } catch (error) {
+ console.error('Error undoing report:', error);
+ alert('Failed to undo report. Please try again.');
+ } finally {
+ setReportingPhotoId(null);
+ }
+ };
+
+ const handleReportPhoto = async () => {
+ // Check if user is logged in
+ if (!session) {
+ setShowSignInRequiredDialog(true);
+ return;
+ }
+
+ if (reportingPhotoId === currentPhoto.id) return; // Already processing
+
+ const isPending = isReported && reportStatus === 'pending';
+ const isDismissed = isReported && reportStatus === 'dismissed';
+
+ if (isDismissed) {
+ alert('This report was dismissed by an administrator and cannot be resubmitted.');
+ return;
+ }
+
+ if (isPending) {
+ await handleUndoReport(currentPhoto.id);
+ return;
+ }
+
+ // Open dialog to enter comment
+ setReportDialogPhotoId(currentPhoto.id);
+ setReportDialogComment('');
+ setReportDialogError(null);
+ };
+
+ const handleSubmitReport = async () => {
+ if (reportDialogPhotoId === null) {
+ return;
+ }
+
+ const trimmedComment = reportDialogComment.trim();
+ if (trimmedComment.length > REPORT_COMMENT_MAX_LENGTH) {
+ setReportDialogError(`Comment must be ${REPORT_COMMENT_MAX_LENGTH} characters or less.`);
+ return;
+ }
+
+ setReportDialogError(null);
+ setReportingPhotoId(reportDialogPhotoId);
+
+ try {
+ const response = await fetch(`/api/photos/${reportDialogPhotoId}/report`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ comment: trimmedComment.length > 0 ? trimmedComment : null,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => null);
+ if (response.status === 401) {
+ setShowSignInRequiredDialog(true);
+ } else if (response.status === 403) {
+ alert(error?.error || 'Cannot re-report this photo.');
+ } else if (response.status === 409) {
+ alert('You have already reported this photo');
+ } else if (response.status === 400) {
+ setReportDialogError(error?.error || 'Invalid comment');
+ return;
+ } else {
+ alert(error?.error || 'Failed to report photo. Please try again.');
+ }
+ return;
+ }
+
+ setIsReported(true);
+ setReportStatus('pending');
+
+ const wasReReported = isReported && reportStatus === 'reviewed';
+ alert(
+ wasReReported
+ ? 'Photo re-reported successfully. Thank you for your report.'
+ : 'Photo reported successfully. Thank you for your report.'
+ );
+ resetReportDialog();
+ } catch (error) {
+ console.error('Error reporting photo:', error);
+ alert('Failed to create report. Please try again.');
+ } finally {
+ setReportingPhotoId(null);
+ }
+ };
+
+ const handleToggleFavorite = async () => {
+ // Check if user is logged in
+ if (!session) {
+ setShowSignInRequiredDialog(true);
+ return;
+ }
+
+ if (favoritingPhotoId === currentPhoto.id) return; // Already processing
+
+ setFavoritingPhotoId(currentPhoto.id);
+
+ try {
+ const response = await fetch(`/api/photos/${currentPhoto.id}/favorite`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ if (response.status === 401) {
+ setShowSignInRequiredDialog(true);
+ } else {
+ alert(error.error || 'Failed to toggle favorite');
+ }
+ return;
+ }
+
+ const data = await response.json();
+ setIsFavorited(data.favorited);
+ } catch (error) {
+ console.error('Error toggling favorite:', error);
+ alert('Failed to toggle favorite. Please try again.');
+ } finally {
+ setFavoritingPhotoId(null);
+ }
+ };
+
+ const peopleNames = (currentPhoto as any).faces
+ ?.map((face: any) => face.Person)
+ .filter((person: any): person is Person => person != null)
+ .map((person: Person) => `${person.first_name} ${person.last_name}`.trim()) || [];
+
+ const tags = (currentPhoto as any).PhotoTagLinkage?.map((pt: any) => pt.Tag.tag_name) || [];
+
+ const hasPrevious = allPhotos.length > 0 && currentIdx > 0;
+ const hasNext = allPhotos.length > 0 && currentIdx < allPhotos.length - 1;
+
+ return (
+
+ {/* Close Button */}
+
+
+
+
+ {/* Play/Pause Button and Interval Selector */}
+ {allPhotos.length > 1 && (
+
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ 1s
+ 2s
+ 3s
+ 5s
+ 7s
+ 10s
+ 15s
+ 20s
+ 30s
+ 60s
+
+
+
+ )}
+
+ {/* Zoom Controls - Hide for videos */}
+ {!isVideo(currentPhoto) && (
+
e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ onMouseUp={(e) => e.stopPropagation()}
+ >
+ {
+ console.log('Zoom out button clicked');
+ handleZoomOut(e);
+ }}
+ disabled={zoom <= 0.5}
+ aria-label="Zoom out"
+ type="button"
+ >
+
+
+
+ {Math.round(zoom * 100)}%
+
+ {
+ console.log('Zoom in button clicked');
+ handleZoomIn(e);
+ }}
+ disabled={zoom >= 5}
+ aria-label="Zoom in"
+ type="button"
+ >
+
+
+ {zoom !== 1 && (
+ {
+ console.log('Reset zoom button clicked');
+ handleResetZoom(e);
+ }}
+ aria-label="Reset zoom"
+ type="button"
+ >
+
+
+ )}
+
+ )}
+
+ {/* Previous Button */}
+ {hasPrevious && (
+
+
+
+ )}
+
+ {/* Next Button */}
+ {hasNext && (
+
handleNext(false)}
+ disabled={imageLoading}
+ aria-label="Next photo"
+ >
+
+
+ )}
+
+ {/* Photo Container */}
+
+ {isVideo(currentPhoto) ? (
+ // For videos, render in a completely isolated container with no event handlers
+
+ {
+ setImageLoading(false);
+ if (videoRef.current) {
+ videoRef.current.controls = true;
+ }
+ }}
+ onError={() => {
+ setImageLoading(false);
+ }}
+ onPlay={() => setIsVideoPlaying(true)}
+ onPause={() => setIsVideoPlaying(false)}
+ onEnded={() => setIsVideoPlaying(false)}
+ />
+
+ ) : (
+
1 ? 'cursor-grab active:cursor-grabbing' : ''}`}
+ style={{
+ transform: zoom > 1 ? `translate(${panX}px, ${panY}px)` : undefined,
+ transition: zoom === 1 ? 'transform 0.2s' : undefined,
+ }}
+ onMouseMove={handleMouseMove}
+ onMouseLeave={() => {
+ console.log('[PhotoViewerClient] Mouse left container, clearing hoveredFace');
+ setHoveredFace(null);
+ setIsDragging(false);
+ }}
+ onMouseDown={handleMouseDown}
+ onMouseUp={handleMouseUp}
+ onClick={(e) => {
+ // Don't handle click if it's on a button or zoom controls
+ const target = e.target as HTMLElement;
+ const isButton = target.closest('button');
+ const isZoomControl = target.closest('[aria-label*="Zoom"]') || target.closest('[aria-label*="Reset zoom"]');
+
+ console.log('[PhotoViewerClient] Container onClick:', {
+ isButton: !!isButton,
+ isZoomControl: !!isZoomControl,
+ isDragging,
+ zoom,
+ willHandleClick: !isButton && !isZoomControl && (!isDragging || zoom === 1),
+ });
+
+ if (isButton || isZoomControl) {
+ return;
+ }
+ // For images, only handle click if not dragging
+ if (!isDragging || zoom === 1) {
+ handleClick(e);
+ } else {
+ console.log('[PhotoViewerClient] Click ignored due to dragging/zoom state');
+ }
+ }}
+ >
+ {imageLoading && (
+
+ Loading...
+
+ )}
+
+
+
+
+ )}
+
+
+ {/* Face Tooltip - Show for identified faces, or for unidentified faces if not signed in or has write access */}
+ {hoveredFace && hoveredFaceTooltip && (
+
+
+
+ {hoveredFaceTooltip}
+
+
+
+ )}
+
+ {/* Report Button - Left Bottom Corner - Aligned with filename, above video controls */}
+ {(() => {
+ // Position based on whether it's a video (to align with filename above controls)
+ // For videos: position above the controls area (controls are ~60-70px, plus padding)
+ const bottomPosition = isVideo(currentPhoto) ? '160px' : '96px'; // 160px for videos to be well above controls, 96px (bottom-24) for images
+
+ if (!session) {
+ // Not logged in - show basic report button
+ return (
+
+
+
+ );
+ }
+
+ // Logged in - show button with status
+ const isPending = isReported && reportStatus === 'pending';
+ const isReviewed = isReported && reportStatus === 'reviewed';
+ const isDismissed = isReported && reportStatus === 'dismissed';
+
+ let tooltipText: string;
+ let buttonClass: string;
+
+ if (isPending) {
+ tooltipText = 'Reported as inappropriate. Click to undo';
+ buttonClass = 'bg-red-600/70 hover:bg-red-600/90';
+ } else if (isReviewed) {
+ tooltipText = 'Report reviewed and kept. Click to report again';
+ buttonClass = 'bg-green-600/70 hover:bg-green-600/90';
+ } else if (isDismissed) {
+ tooltipText = 'Report dismissed';
+ buttonClass = 'bg-gray-600/70 hover:bg-gray-600/90';
+ } else {
+ tooltipText = 'Report inappropriate photo';
+ buttonClass = 'bg-black/50';
+ }
+
+ return (
+
+
+
+ );
+ })()}
+
+ {/* Download Button - Right Bottom Corner (next to favorite), above controls */}
+ {(() => {
+ const bottomPosition = isVideo(currentPhoto) ? '160px' : '96px';
+ return (
+
+
+
+ );
+ })()}
+
+ {/* Favorite Button - Right Bottom Corner - Aligned with filename, above video controls */}
+ {(() => {
+ // Position based on whether it's a video (to align with filename above controls)
+ const bottomPosition = isVideo(currentPhoto) ? '160px' : '96px';
+
+ if (!session) {
+ // Not logged in - show basic favorite button
+ return (
+
+
+
+ );
+ }
+
+ // Logged in - show button with favorite status
+ return (
+
+
+
+ );
+ })()}
+
+ {/* Photo Info Overlay - Only covers text area, not video controls */}
+
+
+
{currentPhoto.filename}
+ {currentPhoto.date_taken && (
+
+ {new Date(currentPhoto.date_taken).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })}
+
+ )}
+ {peopleNames.length > 0 && (
+
+ People:
+ {peopleNames.join(', ')}
+
+ )}
+ {tags.length > 0 && (
+
+ Tags:
+ {tags.join(', ')}
+
+ )}
+ {allPhotos.length > 0 && (
+
+ {currentIdx + 1} of {allPhotos.length}
+
+ )}
+
+
+
+ {/* Identify Face Dialog */}
+ {clickedFace && (
+
{
+ setIsDialogOpen(open);
+ if (!open) {
+ setClickedFace(null);
+ }
+ }}
+ faceId={clickedFace.faceId}
+ existingPerson={clickedFace.person ? {
+ firstName: (clickedFace.person as any).first_name || (clickedFace.person as any).firstName,
+ lastName: (clickedFace.person as any).last_name || (clickedFace.person as any).lastName,
+ middleName: (clickedFace.person as any).middle_name || (clickedFace.person as any).middleName,
+ maidenName: (clickedFace.person as any).maiden_name || (clickedFace.person as any).maidenName,
+ dateOfBirth: (clickedFace.person as any).date_of_birth || (clickedFace.person as any).dateOfBirth,
+ } : null}
+ onSave={handleSaveFace}
+ />
+ )}
+
+ {/* Report Comment Dialog */}
+ {
+ if (!open) {
+ resetReportDialog();
+ }
+ }}
+ >
+
+
+ Report Photo
+
+ Optionally include a short comment to help administrators understand the issue.
+
+
+
+
+ Comment (optional)
+
+
+
+ {
+ resetReportDialog();
+ }}
+ >
+ Cancel
+
+
+ {reportDialogPhotoId !== null && reportingPhotoId === reportDialogPhotoId
+ ? 'Reporting...'
+ : 'Report photo'}
+
+
+
+
+
+ {/* Sign In Required Dialog for Report */}
+
+
+
+ Sign In Required
+
+ You need to be signed in to report photos. Your reports will be reviewed by administrators.
+
+
+
+
+ Please sign in or create an account to continue.
+
+
+ {
+ setLoginDialogOpen(true);
+ }}
+ className="flex-1"
+ >
+ Sign in
+
+ {
+ setRegisterDialogOpen(true);
+ }}
+ className="flex-1"
+ >
+ Register
+
+
+
+
+ setShowSignInRequiredDialog(false)}>
+ Cancel
+
+
+
+
+
+ {/* Login Dialog */}
+ {
+ setLoginDialogOpen(open);
+ if (!open) {
+ setShowRegisteredMessage(false);
+ }
+ }}
+ onSuccess={async () => {
+ await update();
+ router.refresh();
+ setShowSignInRequiredDialog(false);
+ }}
+ onOpenRegister={() => {
+ setLoginDialogOpen(false);
+ setRegisterDialogOpen(true);
+ }}
+ registered={showRegisteredMessage}
+ callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
+ />
+
+ {/* Register Dialog */}
+ {
+ setRegisterDialogOpen(open);
+ if (!open) {
+ setShowRegisteredMessage(false);
+ }
+ }}
+ onSuccess={async () => {
+ await update();
+ router.refresh();
+ setShowSignInRequiredDialog(false);
+ }}
+ onOpenLogin={() => {
+ setShowRegisteredMessage(true);
+ setRegisterDialogOpen(false);
+ setLoginDialogOpen(true);
+ }}
+ callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
+ />
+
+ );
+}
+
diff --git a/viewer-frontend/components/RegisterDialog.tsx b/viewer-frontend/components/RegisterDialog.tsx
new file mode 100644
index 0000000..09bf729
--- /dev/null
+++ b/viewer-frontend/components/RegisterDialog.tsx
@@ -0,0 +1,281 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { Eye, EyeOff } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import Link from 'next/link';
+import { isValidEmail } from '@/lib/utils';
+
+interface RegisterDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess?: () => void;
+ onOpenLogin?: () => void;
+ callbackUrl?: string;
+}
+
+export function RegisterDialog({
+ open,
+ onOpenChange,
+ onSuccess,
+ onOpenLogin,
+ callbackUrl: initialCallbackUrl,
+}: RegisterDialogProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const callbackUrl = initialCallbackUrl || searchParams.get('callbackUrl') || '/';
+
+ const [name, setName] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [error, setError] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
+
+ // Clear form when dialog opens
+ useEffect(() => {
+ if (open) {
+ setName('');
+ setEmail('');
+ setPassword('');
+ setConfirmPassword('');
+ setError('');
+ setShowPassword(false);
+ setShowConfirmPassword(false);
+ }
+ }, [open]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+
+ if (!name || name.trim().length === 0) {
+ setError('Name is required');
+ return;
+ }
+
+ if (!email || !isValidEmail(email)) {
+ setError('Please enter a valid email address');
+ return;
+ }
+
+ if (password !== confirmPassword) {
+ setError('Passwords do not match');
+ return;
+ }
+
+ if (password.length < 6) {
+ setError('Password must be at least 6 characters');
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const response = await fetch('/api/auth/register', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ email, password, name }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ setError(data.error || 'Failed to create account');
+ return;
+ }
+
+ // Registration successful - clear form and show success message
+ setName('');
+ setEmail('');
+ setPassword('');
+ setConfirmPassword('');
+ setError('');
+
+ // Show success state
+ alert('Account created successfully! Please check your email to confirm your account before signing in.');
+
+ onOpenChange(false);
+ if (onOpenLogin) {
+ // Open login dialog with registered flag
+ onOpenLogin();
+ } else if (onSuccess) {
+ onSuccess();
+ } else {
+ // Redirect to login with registered flag
+ router.push(`/login?registered=true&callbackUrl=${encodeURIComponent(callbackUrl)}`);
+ }
+ } catch (err) {
+ setError('An error occurred. Please try again.');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ // Reset form when closing
+ setName('');
+ setEmail('');
+ setPassword('');
+ setConfirmPassword('');
+ setError('');
+ setShowPassword(false);
+ setShowConfirmPassword(false);
+ }
+ onOpenChange(newOpen);
+ };
+
+ return (
+
+
+
+ Create your account
+
+ Or{' '}
+ {
+ handleOpenChange(false);
+ if (onOpenLogin) {
+ onOpenLogin();
+ }
+ }}
+ >
+ sign in to your existing account
+
+
+
+
+
+
+ );
+}
+
diff --git a/viewer-frontend/components/SessionProviderWrapper.tsx b/viewer-frontend/components/SessionProviderWrapper.tsx
new file mode 100644
index 0000000..1cc55d8
--- /dev/null
+++ b/viewer-frontend/components/SessionProviderWrapper.tsx
@@ -0,0 +1,20 @@
+'use client';
+
+import { SessionProvider } from 'next-auth/react';
+import { IdleLogoutHandler } from '@/components/IdleLogoutHandler';
+
+export function SessionProviderWrapper({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+
+
diff --git a/viewer-frontend/components/SimpleHeader.tsx b/viewer-frontend/components/SimpleHeader.tsx
new file mode 100644
index 0000000..c7a3e05
--- /dev/null
+++ b/viewer-frontend/components/SimpleHeader.tsx
@@ -0,0 +1,36 @@
+'use client';
+
+import Image from 'next/image';
+import Link from 'next/link';
+import UserMenu from '@/components/UserMenu';
+
+export function SimpleHeader() {
+ return (
+
+
+
+ Browse our photo collection
+
+
+ );
+}
+
+
+
+
+
+
diff --git a/viewer-frontend/components/TagSelectionDialog.tsx b/viewer-frontend/components/TagSelectionDialog.tsx
new file mode 100644
index 0000000..f0f5352
--- /dev/null
+++ b/viewer-frontend/components/TagSelectionDialog.tsx
@@ -0,0 +1,340 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Badge } from '@/components/ui/badge';
+import { Loader2, Tag as TagIcon, X } from 'lucide-react';
+
+interface Tag {
+ id: number;
+ tagName?: string;
+ tag_name?: string;
+ created_date?: Date | string | null;
+}
+
+interface TagSelectionDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ photoIds: number[];
+ tags: Tag[];
+ onSuccess?: () => void;
+}
+
+export function TagSelectionDialog({
+ open,
+ onOpenChange,
+ photoIds,
+ tags,
+ onSuccess,
+}: TagSelectionDialogProps) {
+ const [selectedTagIds, setSelectedTagIds] = useState([]);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [customTags, setCustomTags] = useState([]);
+ const [customTagInput, setCustomTagInput] = useState('');
+ const [notes, setNotes] = useState('');
+ const [error, setError] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const filteredTags = useMemo(() => {
+ if (!searchQuery.trim()) {
+ return tags;
+ }
+ const query = searchQuery.toLowerCase();
+ return tags.filter((tag) => (tag.tagName || tag.tag_name || '').toLowerCase().includes(query));
+ }, [searchQuery, tags]);
+
+ useEffect(() => {
+ if (!open) {
+ setSelectedTagIds([]);
+ setSearchQuery('');
+ setCustomTags([]);
+ setCustomTagInput('');
+ setNotes('');
+ setError(null);
+ }
+ }, [open]);
+
+ useEffect(() => {
+ setSelectedTagIds((prev) =>
+ prev.filter((id) => tags.some((tag) => tag.id === id))
+ );
+ }, [tags]);
+
+ const toggleTagSelection = (tagId: number) => {
+ setSelectedTagIds((prev) =>
+ prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]
+ );
+ };
+
+ const canSubmit =
+ photoIds.length > 0 &&
+ (selectedTagIds.length > 0 ||
+ customTags.length > 0 ||
+ customTagInput.trim().length > 0);
+
+ const normalizeTagName = (value: string) => value.trim().replace(/\s+/g, ' ');
+
+ const addCustomTag = () => {
+ const candidate = normalizeTagName(customTagInput);
+ if (!candidate) {
+ setCustomTagInput('');
+ return;
+ }
+ const exists = customTags.some(
+ (tag) => tag.toLowerCase() === candidate.toLowerCase()
+ );
+ if (!exists) {
+ setCustomTags((prev) => [...prev, candidate]);
+ }
+ setCustomTagInput('');
+ };
+
+ const removeCustomTag = (tag_name: string) => {
+ setCustomTags((prev) =>
+ prev.filter((tag) => tag.toLowerCase() !== tag_name.toLowerCase())
+ );
+ };
+
+ const handleSubmit = async () => {
+ setError(null);
+
+ if (photoIds.length === 0) {
+ setError('Select at least one photo before tagging.');
+ return;
+ }
+
+ const normalizedInput = normalizeTagName(customTagInput);
+ const proposedTags = [
+ ...customTags,
+ ...(normalizedInput ? [normalizedInput] : []),
+ ];
+ const uniqueNewTags = Array.from(
+ new Map(
+ proposedTags.map((tag) => [tag.toLowerCase(), tag])
+ ).values()
+ );
+ const payload = {
+ photoIds,
+ tagIds: selectedTagIds.length > 0 ? selectedTagIds : undefined,
+ newTagNames: uniqueNewTags.length > 0 ? uniqueNewTags : undefined,
+ notes: notes.trim() || undefined,
+ };
+
+ try {
+ setIsSubmitting(true);
+ const response = await fetch('/api/photos/tag-linkages', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(payload),
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || 'Failed to submit tag linkages');
+ }
+
+ alert(
+ data.message ||
+ 'Tag submissions sent for approval. An administrator will review them soon.'
+ );
+ onOpenChange(false);
+ onSuccess?.();
+ setCustomTags([]);
+ setCustomTagInput('');
+ } catch (submissionError: any) {
+ setError(submissionError.message || 'Failed to submit tag linkages');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ Tag selected photos
+
+ Choose existing tags or propose a new tag. Your request goes to the
+ pending queue for admin approval before it appears on the site.
+
+
+
+
+
+ Tagging{' '}
+
+ {photoIds.length}
+ {' '}
+ photo{photoIds.length === 1 ? '' : 's'}. Pending linkages require
+ administrator approval.
+
+
+
+
Choose existing tags
+
setSearchQuery(event.target.value)}
+ />
+
+ {filteredTags.length === 0 ? (
+
+ No tags match your search.
+
+ ) : (
+ filteredTags.map((tag) => (
+
+ toggleTagSelection(tag.id)}
+ />
+ {tag.tagName || tag.tag_name}
+
+ ))
+ )}
+
+ {selectedTagIds.length > 0 && (
+
+ {selectedTagIds.map((id) => {
+ const tag = tags.find((item) => item.id === id);
+ if (!tag) return null;
+ return (
+
+
+ {tag.tagName || tag.tag_name}
+
+ );
+ })}
+
+ )}
+
+
+
+
+ Add a new tag
+
+
+
setCustomTagInput(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ addCustomTag();
+ }
+ }}
+ />
+
+
+ Add
+
+ {
+ setCustomTags([]);
+ setCustomTagInput('');
+ }}
+ disabled={customTags.length === 0 && !customTagInput.trim()}
+ >
+ Clear
+
+
+
+ {customTags.length > 0 && (
+
+ {customTags.map((tag) => (
+
+
+ {tag}
+ removeCustomTag(tag)}
+ aria-label={`Remove ${tag}`}
+ >
+
+
+
+ ))}
+
+ )}
+
+ Add as many missing tags as you need. Admins will create them during
+ review.
+
+
+
+
+
+ Notes for admins (optional)
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ Cancel
+
+
+ {isSubmitting ? (
+ <>
+
+ Submitting...
+ >
+ ) : (
+ 'Submit for review'
+ )}
+
+
+
+
+ );
+}
+
diff --git a/viewer-frontend/components/UserMenu.tsx b/viewer-frontend/components/UserMenu.tsx
new file mode 100644
index 0000000..6bc0506
--- /dev/null
+++ b/viewer-frontend/components/UserMenu.tsx
@@ -0,0 +1,169 @@
+'use client';
+
+import { useState } from 'react';
+import { useSession, signOut } from 'next-auth/react';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { User, Upload, Users, LogIn, UserPlus } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { LoginDialog } from '@/components/LoginDialog';
+import { RegisterDialog } from '@/components/RegisterDialog';
+import { ManageUsersPageClient } from '@/app/admin/users/ManageUsersPageClient';
+
+function UserMenu() {
+ const { data: session, status } = useSession();
+ const router = useRouter();
+ const [loginDialogOpen, setLoginDialogOpen] = useState(false);
+ const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
+ const [manageUsersOpen, setManageUsersOpen] = useState(false);
+ const [popoverOpen, setPopoverOpen] = useState(false);
+
+ const handleSignOut = async () => {
+ await signOut({ callbackUrl: '/' });
+ };
+
+ if (status === 'loading') {
+ return
;
+ }
+
+ if (session?.user) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ Upload your own photos
+
+
+
+
+
+
+
+
+
+
+
+
+ {session.user.name || 'User'}
+
+
+ {session.user.email}
+
+
+
+ {
+ setPopoverOpen(false);
+ router.push('/upload');
+ }}
+ >
+
+ Upload Photos
+
+ {session.user.isAdmin && (
+ {
+ setPopoverOpen(false);
+ setManageUsersOpen(true);
+ }}
+ >
+
+ Manage Users
+
+ )}
+ {
+ setPopoverOpen(false);
+ handleSignOut();
+ }}
+ >
+ Sign out
+
+
+
+
+
+ {manageUsersOpen && (
+ setManageUsersOpen(false)} />
+ )}
+ >
+ );
+ }
+
+ return (
+ <>
+ setLoginDialogOpen(true)}
+ className="flex items-center gap-2"
+ >
+
+ Sign in
+
+ setRegisterDialogOpen(true)}
+ className="flex items-center gap-2"
+ >
+
+ Sign up
+
+ {
+ setLoginDialogOpen(open);
+ }}
+ onOpenRegister={() => {
+ setLoginDialogOpen(false);
+ setRegisterDialogOpen(true);
+ }}
+ />
+ {
+ setRegisterDialogOpen(open);
+ }}
+ onOpenLogin={() => {
+ setRegisterDialogOpen(false);
+ setLoginDialogOpen(true);
+ }}
+ />
+ >
+ );
+}
+
+export default UserMenu;
diff --git a/viewer-frontend/components/search/CollapsibleSearch.tsx b/viewer-frontend/components/search/CollapsibleSearch.tsx
new file mode 100644
index 0000000..e556892
--- /dev/null
+++ b/viewer-frontend/components/search/CollapsibleSearch.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import { useState } from 'react';
+import { Person, Tag } from '@prisma/client';
+import { FilterPanel, SearchFilters } from './FilterPanel';
+import { Button } from '@/components/ui/button';
+import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+interface CollapsibleSearchProps {
+ people: Person[];
+ tags: Tag[];
+ filters: SearchFilters;
+ onFiltersChange: (filters: SearchFilters) => void;
+}
+
+export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: CollapsibleSearchProps) {
+ const [isExpanded, setIsExpanded] = useState(true);
+
+ const hasActiveFilters =
+ filters.people.length > 0 ||
+ filters.tags.length > 0 ||
+ filters.dateFrom ||
+ filters.dateTo;
+
+ return (
+
+ {/* Collapse/Expand Button */}
+
+ {isExpanded ? (
+ <>
+
+
+ Search & Filter
+ {hasActiveFilters && (
+
+ {[
+ filters.people.length,
+ filters.tags.length,
+ filters.dateFrom || filters.dateTo ? 1 : 0,
+ ].reduce((a, b) => a + b, 0)}
+
+ )}
+
+
setIsExpanded(false)}
+ className="h-8 w-8 p-0"
+ >
+
+
+ >
+ ) : (
+
+ setIsExpanded(true)}
+ className="h-8 w-8 p-0 relative"
+ title="Expand search"
+ >
+
+ {hasActiveFilters && (
+
+ )}
+
+
+ )}
+
+
+ {/* Expanded Filter Panel */}
+ {isExpanded && (
+
+
+
+ )}
+
+ );
+}
+
diff --git a/viewer-frontend/components/search/DateRangeFilter.tsx b/viewer-frontend/components/search/DateRangeFilter.tsx
new file mode 100644
index 0000000..b1ce05f
--- /dev/null
+++ b/viewer-frontend/components/search/DateRangeFilter.tsx
@@ -0,0 +1,182 @@
+'use client';
+
+import { useState } from 'react';
+import { Calendar } from '@/components/ui/calendar';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { Button } from '@/components/ui/button';
+import { CalendarIcon, X } from 'lucide-react';
+import { format } from 'date-fns';
+import { cn } from '@/lib/utils';
+import { Badge } from '@/components/ui/badge';
+
+interface DateRangeFilterProps {
+ dateFrom?: Date;
+ dateTo?: Date;
+ onDateChange: (dateFrom?: Date, dateTo?: Date) => void;
+}
+
+const datePresets = [
+ { label: 'Today', getDates: () => {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ return { from: today, to: new Date() };
+ }},
+ { label: 'This Week', getDates: () => {
+ const today = new Date();
+ const weekStart = new Date(today);
+ weekStart.setDate(today.getDate() - today.getDay());
+ weekStart.setHours(0, 0, 0, 0);
+ return { from: weekStart, to: new Date() };
+ }},
+ { label: 'This Month', getDates: () => {
+ const today = new Date();
+ const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
+ return { from: monthStart, to: new Date() };
+ }},
+ { label: 'This Year', getDates: () => {
+ const today = new Date();
+ const yearStart = new Date(today.getFullYear(), 0, 1);
+ return { from: yearStart, to: new Date() };
+ }},
+];
+
+export function DateRangeFilter({ dateFrom, dateTo, onDateChange }: DateRangeFilterProps) {
+ const [open, setOpen] = useState(false);
+
+ const applyPreset = (preset: typeof datePresets[0]) => {
+ const { from, to } = preset.getDates();
+ onDateChange(from, to);
+ setOpen(false);
+ };
+
+ const clearDates = () => {
+ onDateChange(undefined, undefined);
+ };
+
+ return (
+
+
Date Range
+
+
+
+
+ {dateFrom && dateTo ? (
+ <>
+ {format(dateFrom, 'MMM d, yyyy')} - {format(dateTo, 'MMM d, yyyy')}
+ >
+ ) : (
+ 'Select date range...'
+ )}
+
+
+
+
+
+
Quick Presets
+
+ {datePresets.map((preset) => (
+ applyPreset(preset)}
+ className="text-xs"
+ >
+ {preset.label}
+
+ ))}
+
+
+
+
Custom Range
+
{
+ if (!range) {
+ onDateChange(undefined, undefined);
+ return;
+ }
+
+ // If both from and to are set, check if they're different dates
+ if (range.from && range.to) {
+ // Check if dates are on the same day
+ const fromDate = new Date(range.from);
+ fromDate.setHours(0, 0, 0, 0);
+ const toDate = new Date(range.to);
+ toDate.setHours(0, 0, 0, 0);
+ const sameDay = fromDate.getTime() === toDate.getTime();
+
+ if (!sameDay) {
+ // Valid range with different dates - complete selection and close
+ onDateChange(range.from, range.to);
+ setOpen(false);
+ } else {
+ // Same day - treat as "from" only, keep popover open for "to" selection
+ onDateChange(range.from, undefined);
+ }
+ } else if (range.from) {
+ // Only "from" is selected - keep popover open for "to" selection
+ onDateChange(range.from, undefined);
+ } else if (range.to) {
+ // Only "to" is selected (shouldn't happen in range mode, but handle it)
+ onDateChange(undefined, range.to);
+ }
+ }}
+ numberOfMonths={2}
+ />
+
+ {(dateFrom || dateTo) && (
+
+ {
+ clearDates();
+ setOpen(false);
+ }}
+ className="w-full text-xs"
+ >
+
+ Clear Dates
+
+
+ )}
+
+
+
+ {(dateFrom || dateTo) && (
+
+ {dateFrom && dateTo ? (
+ <>
+ {format(dateFrom, 'MMM d')} - {format(dateTo, 'MMM d, yyyy')}
+ >
+ ) : dateFrom ? (
+ `From ${format(dateFrom, 'MMM d, yyyy')}`
+ ) : (
+ `Until ${format(dateTo!, 'MMM d, yyyy')}`
+ )}
+
+
+
+
+ )}
+
+ );
+}
+
diff --git a/viewer-frontend/components/search/FavoritesFilter.tsx b/viewer-frontend/components/search/FavoritesFilter.tsx
new file mode 100644
index 0000000..edb24db
--- /dev/null
+++ b/viewer-frontend/components/search/FavoritesFilter.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { Checkbox } from '@/components/ui/checkbox';
+import { Heart } from 'lucide-react';
+
+interface FavoritesFilterProps {
+ value: boolean;
+ onChange: (value: boolean) => void;
+ disabled?: boolean;
+}
+
+export function FavoritesFilter({ value, onChange, disabled }: FavoritesFilterProps) {
+ return (
+
+
Favorites
+
+ onChange(checked === true)}
+ disabled={disabled}
+ />
+
+
+ Show favorites only
+
+
+
+ );
+}
+
diff --git a/viewer-frontend/components/search/FilterPanel.tsx b/viewer-frontend/components/search/FilterPanel.tsx
new file mode 100644
index 0000000..08b90fe
--- /dev/null
+++ b/viewer-frontend/components/search/FilterPanel.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import { Person, Tag } from '@prisma/client';
+import { useSession } from 'next-auth/react';
+import { PeopleFilter } from './PeopleFilter';
+import { DateRangeFilter } from './DateRangeFilter';
+import { TagFilter } from './TagFilter';
+import { MediaTypeFilter } from './MediaTypeFilter';
+import { FavoritesFilter } from './FavoritesFilter';
+import { Button } from '@/components/ui/button';
+import { X } from 'lucide-react';
+
+export interface SearchFilters {
+ people: number[];
+ peopleMode?: 'any' | 'all';
+ tags: number[];
+ tagsMode?: 'any' | 'all';
+ dateFrom?: Date;
+ dateTo?: Date;
+ mediaType?: 'all' | 'photos' | 'videos';
+ favoritesOnly?: boolean;
+}
+
+interface FilterPanelProps {
+ people: Person[];
+ tags: Tag[];
+ filters: SearchFilters;
+ onFiltersChange: (filters: SearchFilters) => void;
+}
+
+export function FilterPanel({ people, tags, filters, onFiltersChange }: FilterPanelProps) {
+ const { data: session } = useSession();
+ const isLoggedIn = Boolean(session);
+
+ const updateFilters = (updates: Partial) => {
+ onFiltersChange({ ...filters, ...updates });
+ };
+
+ const clearAllFilters = () => {
+ onFiltersChange({
+ people: [],
+ peopleMode: 'any',
+ tags: [],
+ tagsMode: 'any',
+ dateFrom: undefined,
+ dateTo: undefined,
+ mediaType: 'all',
+ favoritesOnly: false,
+ });
+ };
+
+ const hasActiveFilters =
+ filters.people.length > 0 ||
+ filters.tags.length > 0 ||
+ filters.dateFrom ||
+ filters.dateTo ||
+ (filters.mediaType && filters.mediaType !== 'all') ||
+ filters.favoritesOnly === true;
+
+ return (
+
+
+
Filters
+ {hasActiveFilters && (
+
+
+ Clear All
+
+ )}
+
+
+ {isLoggedIn && (
+
updateFilters({ people: selected })}
+ onModeChange={(mode) => updateFilters({ peopleMode: mode })}
+ />
+ )}
+
+ updateFilters({ mediaType: value })}
+ />
+
+ {isLoggedIn && (
+ updateFilters({ favoritesOnly: value })}
+ />
+ )}
+
+ updateFilters({ dateFrom, dateTo })}
+ />
+
+ updateFilters({ tags: selected })}
+ onModeChange={(mode) => updateFilters({ tagsMode: mode })}
+ />
+
+ );
+}
+
diff --git a/viewer-frontend/components/search/MediaTypeFilter.tsx b/viewer-frontend/components/search/MediaTypeFilter.tsx
new file mode 100644
index 0000000..0955b3b
--- /dev/null
+++ b/viewer-frontend/components/search/MediaTypeFilter.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+
+export type MediaType = 'all' | 'photos' | 'videos';
+
+interface MediaTypeFilterProps {
+ value: MediaType;
+ onChange: (value: MediaType) => void;
+}
+
+export function MediaTypeFilter({ value, onChange }: MediaTypeFilterProps) {
+ return (
+
+ Media type
+ onChange(val as MediaType)}>
+
+
+
+
+ All
+ Photos
+ Videos
+
+
+
+ );
+}
+
+
diff --git a/viewer-frontend/components/search/PeopleFilter.tsx b/viewer-frontend/components/search/PeopleFilter.tsx
new file mode 100644
index 0000000..2960a65
--- /dev/null
+++ b/viewer-frontend/components/search/PeopleFilter.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import { useState } from 'react';
+import { Person } from '@prisma/client';
+import { Input } from '@/components/ui/input';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Badge } from '@/components/ui/badge';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { Button } from '@/components/ui/button';
+import { Search, X } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+
+interface PeopleFilterProps {
+ people: Person[];
+ selected: number[];
+ mode: 'any' | 'all';
+ onSelectionChange: (selected: number[]) => void;
+ onModeChange: (mode: 'any' | 'all') => void;
+}
+
+export function PeopleFilter({ people, selected, mode, onSelectionChange, onModeChange }: PeopleFilterProps) {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [open, setOpen] = useState(false);
+
+ const filteredPeople = people.filter((person) => {
+ const fullName = `${person.first_name} ${person.last_name}`.toLowerCase();
+ return fullName.includes(searchQuery.toLowerCase());
+ });
+
+ const togglePerson = (personId: number) => {
+ if (selected.includes(personId)) {
+ onSelectionChange(selected.filter((id) => id !== personId));
+ } else {
+ onSelectionChange([...selected, personId]);
+ }
+ };
+
+ const selectedPeople = people.filter((p) => selected.includes(p.id));
+
+ return (
+
+
+ People
+ {selected.length > 1 && (
+ onModeChange(value as 'any' | 'all')}>
+
+
+
+
+ Any
+ All
+
+
+ )}
+
+
+
+
+
+ {selected.length === 0 ? 'Select people...' : `${selected.length} selected`}
+
+
+
+
+
setSearchQuery(e.target.value)}
+ className="mb-2"
+ />
+
+ {filteredPeople.length === 0 ? (
+
No people found
+ ) : (
+
+ {filteredPeople.map((person) => {
+ const isSelected = selected.includes(person.id);
+ return (
+
togglePerson(person.id)}
+ >
+ e.stopPropagation()}>
+ togglePerson(person.id)}
+ />
+
+
+ {person.first_name} {person.last_name}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {selectedPeople.length > 0 && (
+
+ {selectedPeople.map((person) => (
+
+ {person.first_name} {person.last_name}
+ togglePerson(person.id)}
+ className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
+ >
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
diff --git a/viewer-frontend/components/search/SearchBar.tsx b/viewer-frontend/components/search/SearchBar.tsx
new file mode 100644
index 0000000..217e42d
--- /dev/null
+++ b/viewer-frontend/components/search/SearchBar.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Input } from '@/components/ui/input';
+import { Search } from 'lucide-react';
+
+interface SearchBarProps {
+ onSearch: (query: string) => void;
+ placeholder?: string;
+ defaultValue?: string;
+}
+
+export function SearchBar({ onSearch, placeholder = 'Search photos...', defaultValue = '' }: SearchBarProps) {
+ const [query, setQuery] = useState(defaultValue);
+
+ // Debounce search
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ onSearch(query);
+ }, 300);
+
+ return () => clearTimeout(timer);
+ }, [query, onSearch]);
+
+ return (
+
+
+ setQuery(e.target.value)}
+ className="pl-10"
+ />
+
+ );
+}
+
diff --git a/viewer-frontend/components/search/TagFilter.tsx b/viewer-frontend/components/search/TagFilter.tsx
new file mode 100644
index 0000000..cbbc359
--- /dev/null
+++ b/viewer-frontend/components/search/TagFilter.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { useState } from 'react';
+import { Tag } from '@prisma/client';
+import { Input } from '@/components/ui/input';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Badge } from '@/components/ui/badge';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { Button } from '@/components/ui/button';
+import { Search, X } from 'lucide-react';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+
+interface TagFilterProps {
+ tags: Tag[];
+ selected: number[];
+ mode: 'any' | 'all';
+ onSelectionChange: (selected: number[]) => void;
+ onModeChange: (mode: 'any' | 'all') => void;
+}
+
+export function TagFilter({ tags, selected, mode, onSelectionChange, onModeChange }: TagFilterProps) {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [open, setOpen] = useState(false);
+
+ // Helper to safely get tag name, handling potential type mismatches
+ const getTagName = (tag: Tag | any): string => {
+ // Try multiple possible field names
+ return tag.tag_name || tag.tagName || tag.name || '';
+ };
+
+ const filteredTags = tags.filter((tag) => {
+ const tagName = getTagName(tag);
+ return tagName.toLowerCase().includes(searchQuery.toLowerCase());
+ });
+
+ const toggleTag = (tagId: number) => {
+ if (selected.includes(tagId)) {
+ onSelectionChange(selected.filter((id) => id !== tagId));
+ } else {
+ onSelectionChange([...selected, tagId]);
+ }
+ };
+
+ const selectedTags = tags.filter((t) => selected.includes(t.id));
+
+ return (
+
+
+ Tags
+ {selected.length > 1 && (
+ onModeChange(value as 'any' | 'all')}>
+
+
+
+
+ Any
+ All
+
+
+ )}
+
+
+
+
+
+ {selected.length === 0 ? 'Select tags...' : `${selected.length} selected`}
+
+
+
+
+
setSearchQuery(e.target.value)}
+ className="mb-2"
+ />
+
+ {filteredTags.length === 0 ? (
+
No tags found
+ ) : (
+
+ {filteredTags.map((tag) => {
+ const isSelected = selected.includes(tag.id);
+ return (
+
toggleTag(tag.id)}
+ >
+ e.stopPropagation()}>
+ toggleTag(tag.id)}
+ />
+
+
+ {getTagName(tag) || 'Unnamed Tag'}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {selectedTags.length > 0 && (
+
+ {selectedTags.map((tag) => (
+
+ {getTagName(tag) || 'Unnamed Tag'}
+ toggleTag(tag.id)}
+ className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
+ >
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
diff --git a/viewer-frontend/components/ui/badge.tsx b/viewer-frontend/components/ui/badge.tsx
new file mode 100644
index 0000000..fd3a406
--- /dev/null
+++ b/viewer-frontend/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/viewer-frontend/components/ui/button.tsx b/viewer-frontend/components/ui/button.tsx
new file mode 100644
index 0000000..21409a0
--- /dev/null
+++ b/viewer-frontend/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/viewer-frontend/components/ui/calendar.tsx b/viewer-frontend/components/ui/calendar.tsx
new file mode 100644
index 0000000..6f304b5
--- /dev/null
+++ b/viewer-frontend/components/ui/calendar.tsx
@@ -0,0 +1,216 @@
+"use client"
+
+import * as React from "react"
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from "lucide-react"
+import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"]
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString("default", { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "flex gap-4 flex-col md:flex-row relative",
+ defaultClassNames.months
+ ),
+ month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
+ nav: cn(
+ "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "absolute bg-popover inset-0 opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
+ defaultClassNames.weekday
+ ),
+ week: cn("flex w-full mt-2", defaultClassNames.week),
+ week_number_header: cn(
+ "select-none w-(--cell-size)",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-[0.8rem] select-none text-muted-foreground",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
+ props.showWeekNumber
+ ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
+ : "[&:first-child[data-selected=true]_button]:rounded-l-md",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "rounded-l-md bg-accent",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
+ today: cn(
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ )
+ }
+
+ if (orientation === "right") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+ span]:text-xs [&>span]:opacity-70",
+ defaultClassNames.day,
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Calendar, CalendarDayButton }
diff --git a/viewer-frontend/components/ui/checkbox.tsx b/viewer-frontend/components/ui/checkbox.tsx
new file mode 100644
index 0000000..cb0b07b
--- /dev/null
+++ b/viewer-frontend/components/ui/checkbox.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { CheckIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/viewer-frontend/components/ui/dialog.tsx b/viewer-frontend/components/ui/dialog.tsx
new file mode 100644
index 0000000..b998189
--- /dev/null
+++ b/viewer-frontend/components/ui/dialog.tsx
@@ -0,0 +1,144 @@
+"use client"
+
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { XIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Dialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+const DialogContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps & {
+ showCloseButton?: boolean
+ overlayClassName?: string
+ }
+>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ )
+})
+DialogContent.displayName = "DialogContent"
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/viewer-frontend/components/ui/input.tsx b/viewer-frontend/components/ui/input.tsx
new file mode 100644
index 0000000..8916905
--- /dev/null
+++ b/viewer-frontend/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/viewer-frontend/components/ui/popover.tsx b/viewer-frontend/components/ui/popover.tsx
new file mode 100644
index 0000000..01e468b
--- /dev/null
+++ b/viewer-frontend/components/ui/popover.tsx
@@ -0,0 +1,48 @@
+"use client"
+
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+
+import { cn } from "@/lib/utils"
+
+function Popover({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function PopoverAnchor({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
diff --git a/viewer-frontend/components/ui/select.tsx b/viewer-frontend/components/ui/select.tsx
new file mode 100644
index 0000000..25e5439
--- /dev/null
+++ b/viewer-frontend/components/ui/select.tsx
@@ -0,0 +1,187 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "popper",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/viewer-frontend/components/ui/tooltip.tsx b/viewer-frontend/components/ui/tooltip.tsx
new file mode 100644
index 0000000..0ab7e55
--- /dev/null
+++ b/viewer-frontend/components/ui/tooltip.tsx
@@ -0,0 +1,69 @@
+"use client"
+
+import * as React from "react"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ delayDuration = 300,
+ ...props
+}: React.ComponentProps & {
+ delayDuration?: number;
+}) {
+ return (
+
+
+
+ )
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ arrowColor = 'orange-600',
+ ...props
+}: React.ComponentProps & {
+ arrowColor?: string;
+}) {
+ const arrowBgClass = arrowColor === 'blue-400' ? 'bg-blue-400 fill-blue-400' : 'bg-orange-600 fill-orange-600';
+
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/viewer-frontend/create_auth_tables.sql b/viewer-frontend/create_auth_tables.sql
new file mode 100644
index 0000000..970706d
--- /dev/null
+++ b/viewer-frontend/create_auth_tables.sql
@@ -0,0 +1,55 @@
+-- Create tables for authentication and pending identifications
+-- Run this script as a PostgreSQL superuser (e.g., postgres user)
+-- Make sure you're connected to the punimtag database
+
+-- Create users table
+CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ email VARCHAR(255) UNIQUE NOT NULL,
+ name VARCHAR(255),
+ password_hash VARCHAR(255) NOT NULL,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Create pending_identifications table
+CREATE TABLE IF NOT EXISTS pending_identifications (
+ id SERIAL PRIMARY KEY,
+ face_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ first_name VARCHAR(255) NOT NULL,
+ last_name VARCHAR(255) NOT NULL,
+ middle_name VARCHAR(255),
+ maiden_name VARCHAR(255),
+ date_of_birth DATE,
+ status VARCHAR(50) DEFAULT 'pending',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY (face_id) REFERENCES faces(id)
+);
+
+-- Create indexes
+CREATE INDEX IF NOT EXISTS idx_pending_identifications_face_id ON pending_identifications(face_id);
+CREATE INDEX IF NOT EXISTS idx_pending_identifications_user_id ON pending_identifications(user_id);
+CREATE INDEX IF NOT EXISTS idx_pending_identifications_status ON pending_identifications(status);
+
+-- Grant permissions to write user (if using separate write user)
+-- Uncomment and adjust 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 USAGE, SELECT ON SEQUENCE users_id_seq TO viewer_write;
+-- GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO viewer_write;
+
+-- Or grant to existing user (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 USAGE, SELECT ON SEQUENCE users_id_seq TO viewer_readonly;
+-- GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO viewer_readonly;
+
+-- Display success message
+\echo 'Authentication tables created successfully!'
+\echo 'Remember to grant appropriate permissions to your database user.'
+
+
diff --git a/viewer-frontend/create_viewer_user.sql b/viewer-frontend/create_viewer_user.sql
new file mode 100644
index 0000000..f384d7f
--- /dev/null
+++ b/viewer-frontend/create_viewer_user.sql
@@ -0,0 +1,31 @@
+-- Create read-only user for PunimTag Photo Viewer
+-- Run this script as a PostgreSQL superuser (e.g., postgres user)
+
+-- Check if user exists, create if not
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT FROM pg_catalog.pg_user WHERE usename = 'viewer_readonly') THEN
+ CREATE USER viewer_readonly WITH PASSWORD 'your_secure_password';
+ RAISE NOTICE 'User viewer_readonly created';
+ ELSE
+ RAISE NOTICE 'User viewer_readonly already exists';
+ END IF;
+END
+$$;
+
+-- 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;
+
+-- Display success message
+\echo 'User viewer_readonly setup complete!'
+\echo 'IMPORTANT: Update the password in .env file to match the password set above'
+
diff --git a/viewer-frontend/create_write_user.sql b/viewer-frontend/create_write_user.sql
new file mode 100644
index 0000000..7391cb0
--- /dev/null
+++ b/viewer-frontend/create_write_user.sql
@@ -0,0 +1,45 @@
+-- Create a separate write-capable user for PunimTag Photo Viewer
+-- Run this script as a PostgreSQL superuser (e.g., postgres user)
+--
+-- Option 2: Create a separate user with write permissions
+-- (Use this if you want to keep viewer_readonly as read-only)
+
+-- Check if user exists, create if not
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT FROM pg_catalog.pg_user WHERE usename = 'viewer_write') THEN
+ CREATE USER viewer_write WITH PASSWORD 'your_secure_password_here';
+ RAISE NOTICE 'User viewer_write created';
+ ELSE
+ RAISE NOTICE 'User viewer_write already exists';
+ END IF;
+END
+$$;
+
+-- Grant connect permission
+GRANT CONNECT ON DATABASE punimtag TO viewer_write;
+
+-- Grant usage on schema
+GRANT USAGE ON SCHEMA public TO viewer_write;
+
+-- Grant SELECT on all tables (for reading existing data)
+GRANT SELECT ON ALL TABLES IN SCHEMA public TO viewer_write;
+
+-- Grant INSERT and UPDATE on specific tables needed for face identification
+GRANT INSERT, UPDATE ON TABLE people TO viewer_write;
+GRANT INSERT, UPDATE ON TABLE faces TO viewer_write;
+
+-- Grant usage on sequences (needed for auto-increment IDs)
+GRANT USAGE, SELECT ON SEQUENCE people_id_seq TO viewer_write;
+
+-- Grant on future tables
+ALTER DEFAULT PRIVILEGES IN SCHEMA public
+GRANT SELECT ON TABLES TO viewer_write;
+
+-- Display success message
+\echo 'User viewer_write setup complete!'
+\echo 'IMPORTANT: Update the password in .env file:'
+\echo 'DATABASE_URL_WRITE="postgresql://viewer_write:your_secure_password_here@localhost:5432/punimtag"'
+
+
+
diff --git a/viewer-frontend/docs/FFMPEG_SETUP.md b/viewer-frontend/docs/FFMPEG_SETUP.md
new file mode 100644
index 0000000..03919f0
--- /dev/null
+++ b/viewer-frontend/docs/FFMPEG_SETUP.md
@@ -0,0 +1,82 @@
+# FFmpeg Setup for Video Thumbnails
+
+Video thumbnails require FFmpeg to be installed on the server. This guide explains how to install it.
+
+## Installation
+
+### Ubuntu/Debian
+```bash
+sudo apt update
+sudo apt install ffmpeg
+```
+
+### CentOS/RHEL
+```bash
+sudo yum install ffmpeg
+# or for newer versions:
+sudo dnf install ffmpeg
+```
+
+### macOS
+```bash
+brew install ffmpeg
+```
+
+### Windows
+Download from [FFmpeg official website](https://ffmpeg.org/download.html) or use:
+```bash
+choco install ffmpeg
+```
+
+## Verification
+
+After installation, verify FFmpeg is available:
+```bash
+ffmpeg -version
+```
+
+You should see version information. If you get "command not found", make sure FFmpeg is in your PATH.
+
+## What Happens Without FFmpeg?
+
+If FFmpeg is not installed:
+- Video thumbnails will not be generated
+- Videos will display a placeholder image (gray box with play icon)
+- Videos will still be viewable, but without thumbnails in the grid
+- The application will continue to work normally
+
+## Troubleshooting
+
+### FFmpeg not found in PATH
+If FFmpeg is installed but not found:
+1. Find where FFmpeg is installed: `which ffmpeg` or `whereis ffmpeg`
+2. Add it to your PATH or create a symlink:
+ ```bash
+ sudo ln -s /path/to/ffmpeg /usr/local/bin/ffmpeg
+ ```
+
+### Permission Issues
+Make sure the web server user has permission to execute FFmpeg:
+```bash
+ls -l $(which ffmpeg)
+```
+
+### Test Thumbnail Generation
+You can test thumbnail generation manually:
+```bash
+ffmpeg -i /path/to/video.mp4 -ss 00:00:01 -vframes 1 -vf "scale=800:-1" -q:v 85 output.jpg
+```
+
+## Notes
+
+- Thumbnails are cached in `.cache/video-thumbnails/` directory
+- Thumbnails are generated on-demand (lazy loading)
+- Once generated, thumbnails are reused (cached)
+- Thumbnail generation happens in the background and doesn't block the UI
+
+
+
+
+
+
+
diff --git a/viewer-frontend/docs/NETWORK_SHARE_SETUP.md b/viewer-frontend/docs/NETWORK_SHARE_SETUP.md
new file mode 100644
index 0000000..faad52a
--- /dev/null
+++ b/viewer-frontend/docs/NETWORK_SHARE_SETUP.md
@@ -0,0 +1,230 @@
+# Network Share Setup for Photo Uploads
+
+Since this is a web application accessed from multiple devices, uploaded photos must be stored on a **network-accessible location** that is accessible by both:
+1. The web server (Next.js application)
+2. The approval system (separate project)
+
+## ✅ Recommended: Use the Same Server as Your Database
+
+**Simplest approach**: If your PostgreSQL database is already on a network-accessible server, store the photos on that same server's filesystem. This is the easiest setup since you already have network access to that server.
+
+### Step 1: Create Upload Directory on Database Server
+
+```bash
+# SSH into your database server
+ssh user@db-server.example.com
+
+# Create the upload directory
+sudo mkdir -p /var/punimtag/uploads/pending-photos
+sudo chown -R postgres:postgres /var/punimtag/uploads
+sudo chmod -R 755 /var/punimtag/uploads
+```
+
+### Step 2: Mount Database Server Directory on Web Server
+
+Choose the method that matches your setup:
+
+#### Option A: SSHFS (Recommended - Easiest)
+
+If your database server is accessible via SSH (most common):
+
+```bash
+# On your web server, install SSHFS
+sudo apt-get install sshfs # Debian/Ubuntu
+sudo yum install fuse-sshfs # RHEL/CentOS
+
+# Create mount point
+sudo mkdir -p /mnt/db-server-uploads
+
+# Mount the database server's directory
+sudo sshfs user@db-server.example.com:/var/punimtag/uploads /mnt/db-server-uploads \
+ -o allow_other,default_permissions
+
+# Make it permanent (add to /etc/fstab)
+# First, set up SSH key for passwordless access:
+ssh-keygen -t rsa -b 4096
+ssh-copy-id user@db-server.example.com
+
+# Then add to /etc/fstab:
+user@db-server.example.com:/var/punimtag/uploads /mnt/db-server-uploads fuse.sshfs allow_other,default_permissions,_netdev 0 0
+```
+
+**Benefits:**
+- ✅ Uses existing SSH access (no additional setup)
+- ✅ Secure (encrypted)
+- ✅ Works with any database server you can SSH into
+- ✅ No need for separate network share setup
+
+#### Option B: NFS
+
+If your database server has NFS configured:
+
+```bash
+# On database server, export the directory (if not already done)
+# Add to /etc/exports:
+/var/punimtag/uploads *(rw,sync,no_subtree_check)
+
+# On web server, install NFS client
+sudo apt-get install nfs-common # Debian/Ubuntu
+sudo yum install nfs-utils # RHEL/CentOS
+
+# Create mount point
+sudo mkdir -p /mnt/db-server-uploads
+
+# Mount the share
+sudo mount -t nfs db-server.example.com:/var/punimtag/uploads /mnt/db-server-uploads
+
+# Make it permanent (add to /etc/fstab)
+db-server.example.com:/var/punimtag/uploads /mnt/db-server-uploads nfs defaults 0 0
+```
+
+#### Option C: SMB/CIFS
+
+If your database server has SMB/CIFS configured:
+
+```bash
+# On web server, install CIFS utilities
+sudo apt-get install cifs-utils # Debian/Ubuntu
+sudo yum install cifs-utils # RHEL/CentOS
+
+# Create mount point
+sudo mkdir -p /mnt/db-server-uploads
+
+# Mount the share
+sudo mount -t cifs //db-server.example.com/uploads /mnt/db-server-uploads \
+ -o username=your-username,password=your-password,uid=$(id -u),gid=$(id -g)
+
+# Or use credentials file (more secure)
+echo "username=your-username
+password=your-password" | sudo tee /etc/cifs-credentials
+sudo chmod 600 /etc/cifs-credentials
+
+sudo mount -t cifs //db-server.example.com/uploads /mnt/db-server-uploads \
+ -o credentials=/etc/cifs-credentials,uid=$(id -u),gid=$(id -g)
+
+# Make it permanent (add to /etc/fstab)
+//db-server.example.com/uploads /mnt/db-server-uploads cifs credentials=/etc/cifs-credentials,uid=1000,gid=1000 0 0
+```
+
+### Step 3: Configure Environment Variable
+
+In your `.env` file:
+
+```bash
+# Required: Path to mounted database server directory
+UPLOAD_DIR="/mnt/db-server-uploads/pending-photos"
+
+# Alternative variable name (both work)
+# PENDING_PHOTOS_DIR="/mnt/db-server-uploads/pending-photos"
+```
+
+### Step 4: Test Access
+
+Test that the web server can write to the mounted directory:
+
+```bash
+# Test write access
+touch /mnt/db-server-uploads/pending-photos/test-write.txt
+rm /mnt/db-server-uploads/pending-photos/test-write.txt
+
+# If successful, the mount is working correctly
+```
+
+---
+
+## Alternative: Separate Network Share
+
+If your database is on a managed service (e.g., AWS RDS, Google Cloud SQL) and you can't access the server filesystem, use a separate network share:
+
+### Setup Separate Network Share
+
+1. **Set up a network share** (SMB/CIFS, NFS, etc.) on a server accessible by both systems
+2. **Mount it on your web server** using the same methods above
+3. **Set `UPLOAD_DIR`** to the mounted path
+
+Example:
+```bash
+# Mount separate share
+sudo mount -t cifs //file-server.example.com/photos /mnt/shared-photos \
+ -o credentials=/etc/cifs-credentials
+
+# In .env
+UPLOAD_DIR="/mnt/shared-photos/pending-photos"
+```
+
+---
+
+## Directory Structure
+
+Photos are organized by user ID:
+```
+/var/punimtag/uploads/pending-photos/ (on database server)
+ or
+/mnt/db-server-uploads/pending-photos/ (mounted on web server)
+ ├── 1/ # User ID 1
+ │ ├── 1704067200000-photo1.jpg
+ │ └── 1704067300000-photo2.png
+ ├── 2/ # User ID 2
+ │ └── 1704067400000-photo3.jpg
+ └── ...
+```
+
+## Troubleshooting
+
+### "Upload directory not configured"
+- **Solution**: Set `UPLOAD_DIR` or `PENDING_PHOTOS_DIR` in your `.env` file
+
+### "Failed to access upload directory"
+- **Check**: Network mount is active (`mount | grep db-server`)
+- **Check**: Web server process has read/write permissions
+- **Check**: Path is correct and absolute (not relative)
+- **Test**: Try creating a file manually in the directory
+
+### "Permission denied"
+- **Solution**: Check file permissions on the database server directory
+- **Solution**: Ensure mount options include correct `uid` and `gid`
+- **Solution**: For SSHFS, verify SSH key authentication works
+
+### Network mount not accessible
+- **Check**: Network connectivity between servers (`ping db-server.example.com`)
+- **Check**: SSH access works (`ssh user@db-server.example.com`)
+- **Check**: Firewall rules allow the connection
+- **Check**: Mount is active (`df -h | grep db-server`)
+
+### SSHFS connection issues
+- **Check**: SSH key is set up correctly (`ssh -v user@db-server.example.com`)
+- **Check**: SSHFS is installed (`which sshfs`)
+- **Check**: FUSE is enabled (`lsmod | grep fuse`)
+
+## Security Considerations
+
+1. **SSH Keys**: Use SSH key authentication (not passwords) for SSHFS
+2. **Permissions**: Limit write access to the web server process only
+3. **Network**: Use VPN or private network for database server access
+4. **Backup**: Ensure database server is backed up regularly
+5. **Credentials**: Store SMB/CIFS credentials securely (use credentials file)
+
+## Integration with Approval System
+
+The approval system should:
+1. **Access the same database server** (via SSH, NFS, or SMB) to read files
+2. **Query the database** for pending photos:
+ ```sql
+ SELECT * FROM pending_photos WHERE status = 'pending' ORDER BY submitted_at;
+ ```
+3. **Read files** using the `file_path` from the database:
+ ```bash
+ # If approval system is on same server as database
+ cat /var/punimtag/uploads/pending-photos/1/1704067200000-photo1.jpg
+
+ # If approval system mounts the database server
+ cat /mnt/db-server-uploads/pending-photos/1/1704067200000-photo1.jpg
+ ```
+4. **Update status** after review:
+ ```sql
+ UPDATE pending_photos
+ SET status = 'approved', reviewed_at = NOW(), reviewed_by = {admin_user_id}
+ WHERE id = {photo_id};
+ ```
+
+The approval system can access files directly using the `file_path` stored in the database, which points to the database server's filesystem.
diff --git a/viewer-frontend/docs/PHOTO_VIEWER_ARCHITECTURE.md b/viewer-frontend/docs/PHOTO_VIEWER_ARCHITECTURE.md
new file mode 100644
index 0000000..f3814b3
--- /dev/null
+++ b/viewer-frontend/docs/PHOTO_VIEWER_ARCHITECTURE.md
@@ -0,0 +1,821 @@
+# PunimTag Photo Viewer - Architecture Overview
+
+## System Architecture Diagram
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ EXISTING PUNIMTAG SYSTEM │
+│ ┌────────────────────┐ ┌─────────────────┐ │
+│ │ Desktop App │ │ Web Admin │ │
+│ │ (Tkinter) │ │ Interface │ │
+│ │ │ │ (React+FastAPI)│ │
+│ └──────────┬─────────┘ └────────┬────────┘ │
+│ │ │ │
+│ │ ┌──────────────────┘ │
+│ │ │ │
+│ ▼ ▼ │
+│ ┌──────────────────────────────────┐ │
+│ │ PostgreSQL Database │ │
+│ │ ┌────────────────────────────┐ │ │
+│ │ │ Tables: │ │ │
+│ │ │ • photos │ │ │
+│ │ │ • faces │ │ │
+│ │ │ • people │ │ │
+│ │ │ • person_encodings │ │ │
+│ │ │ • tags │ │ │
+│ │ │ • phototaglinkage │ │ │
+│ │ │ • photo_favorites │ │ │
+│ │ └────────────────────────────┘ │ │
+│ └──────────────────┬─────────────────┘ │
+└─────────────────────┼────────────────────────────────────────────────────┘
+ │
+ │ READ-ONLY ACCESS
+ │
+┌─────────────────────▼──────────────────────────────────────────────────┐
+│ NEW PHOTO VIEWER WEBSITE │
+│ │
+│ ┌────────────────────────────────────────────────────────────────┐ │
+│ │ User's Web Browser │ │
+│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
+│ │ │ Desktop │ │ Tablet │ │ Mobile │ │ │
+│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
+│ └──────────────────────────┬─────────────────────────────────────┘ │
+│ │ HTTPS │
+│ ┌──────────────────────────▼─────────────────────────────────────┐ │
+│ │ Next.js 14 Frontend │ │
+│ │ ┌────────────────────────────────────────────────────────┐ │ │
+│ │ │ React Server Components (App Router) │ │ │
+│ │ │ • Photo Grid (Infinite Scroll) │ │ │
+│ │ │ • Search & Filters │ │ │
+│ │ │ • Lightbox Viewer │ │ │
+│ │ │ • People & Tags Browsers │ │ │
+│ │ └────────────────────────────────────────────────────────┘ │ │
+│ │ ┌────────────────────────────────────────────────────────┐ │ │
+│ │ │ Client Components (Interactive) │ │ │
+│ │ │ • TanStack Query (State Management) │ │ │
+│ │ │ • Framer Motion (Animations) │ │ │
+│ │ │ • React Photo Album (Grid Layout) │ │ │
+│ │ └────────────────────────────────────────────────────────┘ │ │
+│ └──────────────────────────┬─────────────────────────────────────┘ │
+│ │ │
+│ ┌──────────────────────────▼─────────────────────────────────────┐ │
+│ │ Next.js API Routes (Optional) │ │
+│ │ • /api/photos - List and search photos │ │
+│ │ • /api/photos/[id] - Get photo details │ │
+│ │ • /api/people - List people with photo counts │ │
+│ │ • /api/tags - List tags with photo counts │ │
+│ │ • /api/search - Combined search │ │
+│ └──────────────────────────┬─────────────────────────────────────┘ │
+│ │ │
+│ ┌──────────────────────────▼─────────────────────────────────────┐ │
+│ │ Prisma ORM Client │ │
+│ │ • Type-safe database queries │ │
+│ │ • Read-only operations │ │
+│ │ • Connection pooling │ │
+│ └──────────────────────────┬─────────────────────────────────────┘ │
+│ │ │
+│ ┌──────────────────────────▼─────────────────────────────────────┐ │
+│ │ PostgreSQL (Read-Only User) │ │
+│ │ Username: viewer_readonly │ │
+│ │ Permissions: SELECT only on all tables │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
+│ │
+└──────────────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Data Flow Diagram
+
+### Photo Gallery Loading
+
+```
+User Opens Browser
+ │
+ ▼
+┌─────────────────┐
+│ Next.js Server │
+│ (App Router) │
+└────────┬────────┘
+ │
+ │ 1. Server Component Renders
+ │ await prisma.photo.findMany()
+ │
+ ▼
+┌─────────────────┐
+│ Prisma Client │
+└────────┬────────┘
+ │
+ │ 2. SQL Query (Read-Only)
+ │ SELECT * FROM photos
+ │ WHERE processed = true
+ │ ORDER BY date_taken DESC
+ │ LIMIT 30
+ │
+ ▼
+┌─────────────────┐
+│ PostgreSQL │
+│ Database │
+└────────┬────────┘
+ │
+ │ 3. Return Photo Records
+ │ [{id, path, filename, date_taken, ...}, ...]
+ │
+ ▼
+┌─────────────────┐
+│ Next.js Server │
+│ (Pre-renders) │
+└────────┬────────┘
+ │
+ │ 4. Generate HTML with Photo Data
+ │
+ ▼
+┌─────────────────┐
+│ User Browser │
+│ (Receives HTML)│
+└────────┬────────┘
+ │
+ │ 5. Hydrate React Components
+ │
+ ▼
+┌─────────────────┐
+│ Photo Grid │
+│ (Rendered) │
+└────────┬────────┘
+ │
+ │ 6. Load Images (Lazy)
+ │
+ │
+ ▼
+┌─────────────────┐
+│ Next.js Image │
+│ Optimization │
+└────────┬────────┘
+ │
+ │ 7. Serve Optimized Images
+ │ (WebP, responsive sizes)
+ │
+ ▼
+┌─────────────────┐
+│ User Sees Photos│
+└─────────────────┘
+```
+
+### Search Flow
+
+```
+User Types in Search
+ │
+ ▼
+┌─────────────────┐
+│ SearchBar │
+│ (Debounced) │
+└────────┬────────┘
+ │ 300ms delay
+ ▼
+┌─────────────────┐
+│ TanStack Query │
+│ (React Query) │
+└────────┬────────┘
+ │
+ │ Check cache first
+ │ If not cached:
+ │
+ ▼
+┌─────────────────┐
+│ API Route │
+│ /api/search │
+└────────┬────────┘
+ │
+ │ Build Query
+ │ {people: [1,2], tags: [3], dateFrom: '2024-01-01'}
+ │
+ ▼
+┌─────────────────┐
+│ Prisma Query │
+│ Builder │
+└────────┬────────┘
+ │
+ │ WHERE processed = true
+ │ AND faces.person_id IN (1,2)
+ │ AND photo_tags.tag_id IN (3)
+ │ AND date_taken >= '2024-01-01'
+ │
+ ▼
+┌─────────────────┐
+│ PostgreSQL │
+│ (Indexed) │
+└────────┬────────┘
+ │
+ │ Return Matching Photos
+ │
+ ▼
+┌─────────────────┐
+│ TanStack Query │
+│ (Cache Result) │
+└────────┬────────┘
+ │
+ │ staleTime: 5 min
+ │ cacheTime: 10 min
+ │
+ ▼
+┌─────────────────┐
+│ Photo Grid │
+│ (Update UI) │
+└─────────────────┘
+```
+
+---
+
+## Component Hierarchy
+
+```
+App Layout
+├── Header
+│ ├── Logo
+│ ├── Navigation
+│ │ ├── Home Link
+│ │ ├── People Link
+│ │ ├── Tags Link
+│ │ └── Timeline Link
+│ ├── SearchButton
+│ └── UserMenu
+│ ├── Login/Logout
+│ └── Settings
+│
+├── Main Content (Page)
+│ │
+│ ├── HomePage (/)
+│ │ ├── Hero Section
+│ │ ├── QuickFilters
+│ │ └── PhotoGrid
+│ │ ├── PhotoCard (x30)
+│ │ │ ├── Image (Next.js)
+│ │ │ ├── Overlay
+│ │ │ └── Actions
+│ │ └── InfiniteScroll Trigger
+│ │
+│ ├── SearchPage (/search)
+│ │ ├── SearchBar
+│ │ ├── FilterPanel
+│ │ │ ├── PeopleFilter
+│ │ │ ├── DateRangePicker
+│ │ │ └── TagFilter
+│ │ └── SearchResults
+│ │ └── PhotoGrid
+│ │
+│ ├── PhotoDetailPage (/photo/[id])
+│ │ ├── Lightbox
+│ │ │ ├── FullSizeImage
+│ │ │ ├── NavigationButtons
+│ │ │ └── CloseButton
+│ │ └── MetadataPanel
+│ │ ├── DateTaken
+│ │ ├── PeopleList
+│ │ ├── TagList
+│ │ └── Actions
+│ │
+│ ├── PeoplePage (/people)
+│ │ ├── PeopleGrid
+│ │ │ └── PersonCard (x many)
+│ │ │ ├── RepresentativePhoto
+│ │ │ ├── PersonName
+│ │ │ └── PhotoCount
+│ │ └── SearchBar
+│ │
+│ └── TagsPage (/tags)
+│ ├── TagCloud
+│ │ └── TagBadge (x many)
+│ │ ├── TagName
+│ │ └── PhotoCount
+│ └── SearchBar
+│
+└── Footer
+ ├── Copyright
+ ├── Links
+ └── Version
+```
+
+---
+
+## Technology Stack Layers
+
+```
+┌──────────────────────────────────────────────────────┐
+│ Presentation Layer │
+│ ┌────────────────────────────────────────────────┐ │
+│ │ React Server Components + Client Components │ │
+│ │ • shadcn/ui (UI Components) │ │
+│ │ • Tailwind CSS (Styling) │ │
+│ │ • Framer Motion (Animations) │ │
+│ │ • Lucide React (Icons) │ │
+│ └────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────┐
+│ Application Layer │
+│ ┌────────────────────────────────────────────────┐ │
+│ │ Next.js 14 (App Router) │ │
+│ │ • Server-Side Rendering │ │
+│ │ • API Routes │ │
+│ │ • Image Optimization │ │
+│ │ • Route Handlers │ │
+│ └────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────┐
+│ State Management Layer │
+│ ┌────────────────────────────────────────────────┐ │
+│ │ TanStack Query (React Query) │ │
+│ │ • Server State Caching │ │
+│ │ • Automatic Refetching │ │
+│ │ • Optimistic Updates │ │
+│ │ • Infinite Scroll │ │
+│ └────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────┐
+│ Data Access Layer │
+│ ┌────────────────────────────────────────────────┐ │
+│ │ Prisma ORM │ │
+│ │ • Type-Safe Queries │ │
+│ │ • Connection Pooling │ │
+│ │ • Query Builder │ │
+│ │ • Migration Management │ │
+│ └────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────┐
+│ Database Layer │
+│ ┌────────────────────────────────────────────────┐ │
+│ │ PostgreSQL │ │
+│ │ • Relational Database │ │
+│ │ • ACID Transactions │ │
+│ │ • Indices for Performance │ │
+│ │ • Read-Only User (viewer_readonly) │ │
+│ └────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────┘
+```
+
+---
+
+## Deployment Architecture
+
+### Option 1: Vercel (Recommended)
+
+```
+┌──────────────────────────────────────────────────────────┐
+│ Vercel Edge Network │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ Global CDN (Automatic) │ │
+│ │ • Caches static assets │ │
+│ │ │ - HTML, CSS, JS │ │
+│ │ │ - Optimized images │ │
+│ │ └─ Serves from nearest edge location │ │
+│ └────────────────────┬───────────────────────────────┘ │
+│ │ │
+│ ┌────────────────────▼───────────────────────────────┐ │
+│ │ Next.js Server (Serverless Functions) │ │
+│ │ • API Routes │ │
+│ │ │ - /api/photos │ │
+│ │ │ - /api/search │ │
+│ │ │ - /api/people │ │
+│ │ └─ Server Components rendering │ │
+│ └────────────────────┬───────────────────────────────┘ │
+└────────────────────────┼──────────────────────────────────┘
+ │
+ │ Database Connection
+ │ (Secure, encrypted)
+ │
+┌────────────────────────▼──────────────────────────────────┐
+│ Your PostgreSQL Server │
+│ • On your local network OR │
+│ • Hosted database (Railway, Supabase, etc.) │
+│ • Connection via DATABASE_URL env variable │
+└───────────────────────────────────────────────────────────┘
+```
+
+### Option 2: Self-Hosted (Docker)
+
+```
+┌──────────────────────────────────────────────────────────┐
+│ Your Server / VPS │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ Caddy (Reverse Proxy + Auto HTTPS) │ │
+│ │ • Automatic SSL certificates │ │
+│ │ • HTTPS redirection │ │
+│ │ • Static file serving │ │
+│ └────────────────────┬───────────────────────────────┘ │
+│ │ │
+│ ┌────────────────────▼───────────────────────────────┐ │
+│ │ Docker Container: punimtag-viewer │ │
+│ │ ┌──────────────────────────────────────────────┐ │ │
+│ │ │ Next.js Production Server │ │ │
+│ │ │ • Node.js 20 │ │ │
+│ │ │ • Optimized build │ │ │
+│ │ │ • Port 3000 │ │ │
+│ │ └────────────────┬─────────────────────────────┘ │ │
+│ └───────────────────┼────────────────────────────────┘ │
+│ │ │
+│ ┌───────────────────▼────────────────────────────────┐ │
+│ │ Docker Container: PostgreSQL (Optional) │ │
+│ │ • Or connect to existing PunimTag database │ │
+│ └────────────────────────────────────────────────────┘ │
+└───────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Security Architecture
+
+```
+┌──────────────────────────────────────────────────────────┐
+│ User Browser │
+└───────────────────────┬──────────────────────────────────┘
+ │ HTTPS Only
+ │ (TLS 1.3)
+ ▼
+┌──────────────────────────────────────────────────────────┐
+│ Authentication Layer │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ NextAuth.js (Optional) │ │
+│ │ • JWT Sessions │ │
+│ │ • OAuth Providers (Google, etc.) │ │
+│ │ • OR Simple Password Protection │ │
+│ └────────────────────┬───────────────────────────────┘ │
+└────────────────────────┼──────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────┐
+│ Authorization Layer │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ Read-Only Access │ │
+│ │ • No write operations allowed │ │
+│ │ • No photo uploads │ │
+│ │ • No deletion │ │
+│ │ • Favorites only write operation (per user) │ │
+│ └────────────────────┬───────────────────────────────┘ │
+└────────────────────────┼──────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────┐
+│ API Security Layer │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ • Rate Limiting (10 req/10s per IP) │ │
+│ │ • Input Validation (Zod schemas) │ │
+│ │ • SQL Injection Prevention (Prisma ORM) │ │
+│ │ • XSS Prevention (React auto-escaping) │ │
+│ │ • CSRF Protection (SameSite cookies) │ │
+│ └────────────────────┬───────────────────────────────┘ │
+└────────────────────────┼──────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────┐
+│ Database Security │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ Read-Only PostgreSQL User │ │
+│ │ • Username: viewer_readonly │ │
+│ │ • SELECT permission only │ │
+│ │ • No INSERT, UPDATE, DELETE │ │
+│ │ • Connection over encrypted channel │ │
+│ └────────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Performance Optimization Strategy
+
+```
+┌──────────────────────────────────────────────────────────┐
+│ Browser Layer │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ Browser Caching │ │
+│ │ • Static assets: 1 year │ │
+│ │ • Images: 1 month │ │
+│ │ • API responses: 5 minutes │ │
+│ └────────────────────────────────────────────────────┘ │
+└───────────────────────┬──────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────┐
+│ React Query Layer │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ In-Memory Cache │ │
+│ │ • Query results cached │ │
+│ │ • staleTime: 5 minutes │ │
+│ │ • cacheTime: 10 minutes │ │
+│ │ • Automatic background refetching │ │
+│ └────────────────────────────────────────────────────┘ │
+└───────────────────────┬──────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────┐
+│ Next.js Layer │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ Image Optimization │ │
+│ │ • Automatic WebP/AVIF conversion │ │
+│ │ • Responsive images (srcset) │ │
+│ │ • Lazy loading │ │
+│ │ • Blur placeholders │ │
+│ ├────────────────────────────────────────────────────┤ │
+│ │ Code Splitting │ │
+│ │ • Route-based splitting │ │
+│ │ • Dynamic imports │ │
+│ │ • Component lazy loading │ │
+│ ├────────────────────────────────────────────────────┤ │
+│ │ Server-Side Rendering │ │
+│ │ • Initial render on server │ │
+│ │ • Faster first contentful paint │ │
+│ └────────────────────────────────────────────────────┘ │
+└───────────────────────┬──────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────┐
+│ Database Layer │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ Prisma Connection Pooling │ │
+│ │ • Reuse database connections │ │
+│ │ • Max connections: 10 │ │
+│ ├────────────────────────────────────────────────────┤ │
+│ │ PostgreSQL Indices │ │
+│ │ • idx_photos_date_taken │ │
+│ │ • idx_photos_processed │ │
+│ │ • idx_faces_person_id │ │
+│ │ • idx_photo_tags_tag │ │
+│ ├────────────────────────────────────────────────────┤ │
+│ │ Query Optimization │ │
+│ │ • Cursor-based pagination │ │
+│ │ • SELECT only needed columns │ │
+│ │ • Minimize JOINs │ │
+│ └────────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Comparison: Admin Interface vs Photo Viewer
+
+| Aspect | Admin Interface | Photo Viewer |
+|--------|----------------|--------------|
+| **Purpose** | Manage & Process | View & Browse |
+| **Tech Stack** | React + FastAPI | Next.js 14 |
+| **Rendering** | Client-Side (SPA) | Server + Client (SSR) |
+| **State** | React Context | TanStack Query |
+| **Database Access** | Full (R/W) via API | Read-Only via Prisma |
+| **Photo Upload** | ✅ Yes | ❌ No |
+| **Face Processing** | ✅ Yes | ❌ No |
+| **Search** | ✅ Advanced | ✅ User-friendly |
+| **Performance** | Good | Excellent |
+| **Mobile UX** | Functional | Optimized |
+| **Auth** | Required | Optional |
+| **Deployment** | Backend + Frontend | Single Next.js App |
+| **Target Users** | Admin (1-2) | All Users (5-50+) |
+| **Complexity** | High | Low |
+
+---
+
+## Database Read Patterns
+
+### Admin Interface (Write-Heavy)
+
+```
+┌─────────────────────────────────────┐
+│ Photo Upload │
+│ INSERT INTO photos │
+│ (Heavy write operations) │
+└─────────────────────────────────────┘
+
+┌─────────────────────────────────────┐
+│ Face Processing │
+│ INSERT INTO faces │
+│ UPDATE photos SET processed │
+│ (Batch operations) │
+└─────────────────────────────────────┘
+
+┌─────────────────────────────────────┐
+│ Person Identification │
+│ UPDATE faces SET person_id │
+│ INSERT INTO person_encodings │
+└─────────────────────────────────────┘
+```
+
+### Photo Viewer (Read-Heavy)
+
+```
+┌─────────────────────────────────────┐
+│ Browse Photos │
+│ SELECT * FROM photos │
+│ WHERE processed = true │
+│ ORDER BY date_taken DESC │
+│ LIMIT 30 OFFSET X │
+└─────────────────────────────────────┘
+
+┌─────────────────────────────────────┐
+│ Search by Person │
+│ SELECT p.* FROM photos p │
+│ JOIN faces f ON p.id = f.photo_id │
+│ WHERE f.person_id = X │
+└─────────────────────────────────────┘
+
+┌─────────────────────────────────────┐
+│ Get Photo Details │
+│ SELECT * FROM photos WHERE id = X │
+│ + JOIN faces + people + tags │
+└─────────────────────────────────────┘
+```
+
+---
+
+## Image Serving Strategy (Hybrid Approach)
+
+### For HTTP/HTTPS URLs (SharePoint, CDN, etc.)
+
+```
+┌──────────────────────────────────────────────────────────┐
+│ Photo URLs in Database │
+│ https://sharepoint.com/sites/Photos/IMG_1234.jpg │
+│ OR │
+│ https://cdn.example.com/photos/IMG_1234.jpg │
+└───────────────────────┬──────────────────────────────────┘
+ │
+ │ Automatic Detection (starts with http/https)
+ │
+┌───────────────────────▼──────────────────────────────────┐
+│ Next.js Image Component │
+│ (Direct URL) │
+└───────────────────────┬──────────────────────────────────┘
+ │
+ │ Optimization Request
+ │
+┌───────────────────────▼──────────────────────────────────┐
+│ Next.js Image Optimization │
+│ ┌────────────────────────────────────────────────────┐ │
+│ │ 1. Load original image from URL │ │
+│ │ 2. Resize to requested dimensions │ │
+│ │ 3. Convert to WebP/AVIF (browser support) │ │
+│ │ 4. Apply quality compression (85%) │ │
+│ │ 5. Generate blur placeholder (base64) │ │
+│ │ 6. Cache optimized image │ │
+│ └────────────────────────────────────────────────────┘ │
+└───────────────────────┬──────────────────────────────────┘
+ │
+ │ Serve Optimized
+ │
+┌───────────────────────▼──────────────────────────────────┐
+│ Browser Display │
+└──────────────────────────────────────────────────────────┘
+```
+
+### For File System Paths
+
+```
+┌──────────────────────────────────────────────────────────┐
+│ Photo File System Paths │
+│ /path/to/photos/2024/01/IMG_1234.jpg │
+│ Size: 4000x3000, 8MB │
+└───────────────────────┬──────────────────────────────────┘
+ │
+ │ Automatic Detection (not a URL)
+ │
+┌───────────────────────▼──────────────────────────────────┐
+│ PhotoGrid Component │
+│ Uses API Proxy: /api/photos/[id]/image?path=... │
+└───────────────────────┬──────────────────────────────────┘
+ │
+ │ API Route (No DB Query - path in URL)
+ │
+┌───────────────────────▼──────────────────────────────────┐
+│ File System Read │
+│ Read file from disk using path from query param │
+└───────────────────────┬──────────────────────────────────┘
+ │
+ │ Return Image Buffer
+ │
+┌───────────────────────▼──────────────────────────────────┐
+│ Next.js Image Component │
+│ │
+└───────────────────────┬──────────────────────────────────┘
+ │
+ │ Serve with Cache Headers
+ │
+┌───────────────────────▼──────────────────────────────────┐
+│ Browser Display │
+└──────────────────────────────────────────────────────────┘
+```
+
+**Key Benefits:**
+- ✅ Automatic detection: URLs → direct access, file paths → API proxy
+- ✅ No N+1 queries: Path passed via query parameter
+- ✅ Works with SharePoint, CDN, and local file system
+- ✅ Optimal performance for both storage types
+
+---
+
+## Responsive Design Strategy
+
+### Mobile First Approach
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ Mobile (<640px) │
+│ ┌───────────────────────────────────────────────────┐ │
+│ │ [☰] PunimTag [🔍] │ │
+│ ├───────────────────────────────────────────────────┤ │
+│ │ ┌──────────┐ ┌──────────┐ │ │
+│ │ │ Photo │ │ Photo │ 2 columns │ │
+│ │ └──────────┘ └──────────┘ │ │
+│ │ ┌──────────┐ ┌──────────┐ │ │
+│ │ │ Photo │ │ Photo │ Compact │ │
+│ │ └──────────┘ └──────────┘ │ │
+│ └───────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────┘
+
+┌─────────────────────────────────────────────────────────┐
+│ Tablet (640px - 1024px) │
+│ ┌───────────────────────────────────────────────────┐ │
+│ │ [Logo] Home | People | Tags [Search] [👤] │ │
+│ ├───────────────────────────────────────────────────┤ │
+│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │
+│ │ │Photo│ │Photo│ │Photo│ 3-4 columns │ │
+│ │ └─────┘ └─────┘ └─────┘ │ │
+│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │
+│ │ │Photo│ │Photo│ │Photo│ Medium size │ │
+│ │ └─────┘ └─────┘ └─────┘ │ │
+│ └───────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────┘
+
+┌─────────────────────────────────────────────────────────┐
+│ Desktop (>1024px) │
+│ ┌───────────────────────────────────────────────────┐ │
+│ │ [Logo] Home | People | Tags [Search Bar] [👤] │ │
+│ ├──────┬────────────────────────────────────────────┤ │
+│ │Filter│ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │ │
+│ │ │ │Ph│ │Ph│ │Ph│ │Ph│ │Ph│ 5-6 columns │ │
+│ │People│ └──┘ └──┘ └──┘ └──┘ └──┘ │ │
+│ │☐ John│ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │ │
+│ │☐ Jane│ │Ph│ │Ph│ │Ph│ │Ph│ │Ph│ Large grid │ │
+│ │ │ └──┘ └──┘ └──┘ └──┘ └──┘ │ │
+│ │Tags │ │ │
+│ │☐ Fam │ │ │
+│ └──────┴────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Error Handling Flow
+
+```
+User Request
+ │
+ ▼
+┌──────────────────┐
+│ Try Operation │
+└────────┬─────────┘
+ │
+ ├─ Success ────────────────┐
+ │ ▼
+ │ ┌─────────────────┐
+ │ │ Return Data │
+ │ │ Update UI │
+ │ └─────────────────┘
+ │
+ └─ Error ──────────────────┐
+ ▼
+ ┌──────────────────┐
+ │ Catch Error │
+ └────────┬─────────┘
+ │
+ ┌───────────────┼───────────────┐
+ │ │ │
+ ▼ ▼ ▼
+ ┌───────────────┐ ┌─────────────┐ ┌────────────┐
+ │ Network Error │ │ 404 Not │ │ 500 Server │
+ │ (Offline) │ │ Found │ │ Error │
+ └───────┬───────┘ └──────┬──────┘ └─────┬──────┘
+ │ │ │
+ │ │ │
+ └────────────────┼──────────────┘
+ ▼
+ ┌──────────────────┐
+ │ Log Error │
+ │ (Sentry) │
+ └────────┬─────────┘
+ ▼
+ ┌──────────────────┐
+ │ Show Error UI │
+ │ • Toast │
+ │ • Error Page │
+ │ • Retry Button │
+ └──────────────────┘
+```
+
+---
+
+This architecture ensures a clean separation between the admin interface (for management) and the photo viewer (for browsing), while sharing the same database and maintaining data consistency.
+
diff --git a/viewer-frontend/docs/PHOTO_VIEWER_EXECUTIVE_SUMMARY.md b/viewer-frontend/docs/PHOTO_VIEWER_EXECUTIVE_SUMMARY.md
new file mode 100644
index 0000000..66e5015
--- /dev/null
+++ b/viewer-frontend/docs/PHOTO_VIEWER_EXECUTIVE_SUMMARY.md
@@ -0,0 +1,425 @@
+# PunimTag Photo Viewer - Executive Summary
+
+## 📋 Overview
+
+This document provides a high-level summary of the plan to create a new photo viewing website for PunimTag. This summary is designed for decision-makers and provides key recommendations.
+
+---
+
+## 🎯 What Are We Building?
+
+A **modern, fast, beautiful photo gallery website** that allows family members to browse and search photos from your PunimTag database without needing admin access.
+
+### Key Features
+- ✅ Browse all photos in a beautiful grid layout
+- ✅ Search by people, dates, and tags
+- ✅ View full-size photos with metadata
+- ✅ Mobile-optimized responsive design
+- ✅ Fast loading with modern optimizations
+- ✅ Optional authentication for privacy
+
+### What It's NOT
+- ❌ Not a replacement for the admin interface
+- ❌ No photo uploads (read-only)
+- ❌ No face processing or management
+- ❌ No bulk operations
+
+---
+
+## 💡 Why Do We Need This?
+
+### Current Situation
+You have a powerful admin interface for managing photos, but it's:
+- Too complex for casual family browsing
+- Requires admin credentials
+- Focused on management, not viewing
+- Not optimized for mobile devices
+
+### The Solution
+A separate, simplified photo viewer that:
+- Focuses solely on viewing and searching
+- Easy for anyone in the family to use
+- Optimized for browsing experience
+- Beautiful, modern design
+- Works great on all devices
+
+---
+
+## 🏗️ Technical Approach
+
+### Recommended Technology Stack
+
+| Component | Technology | Why? |
+|-----------|-----------|------|
+| **Framework** | Next.js 14 | Best-in-class for performance, SEO, and developer experience |
+| **UI Library** | shadcn/ui + Tailwind | Modern, beautiful, customizable components |
+| **Database** | Existing PostgreSQL | Reuse your database with read-only access |
+| **ORM** | Prisma | Type-safe database queries, excellent PostgreSQL support |
+| **State** | TanStack Query | Perfect for server data caching and infinite scroll |
+| **Images** | Next.js Image | Automatic optimization, WebP conversion, lazy loading |
+| **Hosting** | Vercel (recommended) | Zero-config deployment, global CDN, free tier available |
+
+### Architecture Overview
+
+```
+User Browser → Next.js Frontend → Prisma ORM → PostgreSQL Database
+ (Read-Only User)
+```
+
+---
+
+## 📊 Decision Matrix
+
+### 🌟 Recommended Approach: Next.js with Vercel
+
+**Pros:**
+- ⭐ Fastest time to market (6-8 weeks)
+- ⭐ Best performance (Lighthouse score >90)
+- ⭐ Easiest deployment (zero config)
+- ⭐ Free hosting tier available
+- ⭐ Automatic image optimization
+- ⭐ Built-in SEO and accessibility
+
+**Cons:**
+- 💰 Can be expensive for high traffic (but free tier is generous)
+- 🌐 Requires database to be network-accessible (or use hosted DB)
+
+**Best For:** Most users, especially if you want the best UX and fastest development
+
+---
+
+### Alternative 1: Self-Hosted Docker
+
+**Pros:**
+- 💰 Lower ongoing costs (after initial setup)
+- 🔒 Full control over hosting
+- 🏠 Can run on local network
+- 🔐 More privacy (no third-party hosting)
+
+**Cons:**
+- 🛠️ Requires DevOps knowledge
+- ⏰ Longer setup time
+- 📈 Manual scaling
+- 🔧 More maintenance
+
+**Best For:** Technical users who want full control and have server management experience
+
+---
+
+### Alternative 2: Astro + React Islands
+
+**Pros:**
+- ⚡ Even faster than Next.js
+- 💾 Lower bandwidth usage
+- 🎨 Great for content-heavy sites
+
+**Cons:**
+- 📚 Smaller ecosystem than Next.js
+- 🆕 Less mature (but stable)
+- 🔧 More manual configuration
+
+**Best For:** Users who prioritize absolute maximum performance
+
+---
+
+## 💰 Cost Analysis
+
+### Development Costs
+
+| Approach | Timeline | Estimated Cost |
+|----------|----------|---------------|
+| **DIY (Self-Development)** | 12 weeks | Your time (200-300 hours) |
+| **Freelancer** | 8-12 weeks | $18,000 - $28,000 |
+| **Agency** | 6-10 weeks | $30,000 - $50,000 |
+
+### Monthly Hosting Costs
+
+| Option | Cost/Month | Best For |
+|--------|------------|----------|
+| **Vercel (Free Tier)** | $0 | Small families (<100 users) |
+| **Vercel (Pro)** | $20 | Medium usage (100-1000 users) |
+| **Railway** | $5-20 | Hobby projects |
+| **VPS (DigitalOcean)** | $12-24 | Self-hosted, full control |
+| **Home Server** | $0* | After hardware cost, local network |
+
+**Recommendation:** Start with Vercel Free Tier, upgrade if needed
+
+---
+
+## ⏱️ Timeline
+
+### Fast Track (6 weeks)
+
+| Week | Phase | Deliverables |
+|------|-------|--------------|
+| 1-2 | Foundation | Project setup, database connection, basic layout |
+| 3-4 | Core Features | Photo grid, lightbox, search by people/tags |
+| 5 | Polish | Responsive design, animations, performance |
+| 6 | Launch | Deploy, test, documentation |
+
+### Standard Track (12 weeks)
+
+| Week | Phase | Deliverables |
+|------|-------|--------------|
+| 1-2 | Foundation | Setup + authentication |
+| 3-4 | Core Features | Photo browsing |
+| 5-6 | Search | Advanced search and filters |
+| 7-8 | Polish | Optimization and UX |
+| 9-10 | Advanced | Favorites, sharing, timeline |
+| 11-12 | Launch | Testing, deployment, docs |
+
+**Recommendation:** Standard track for best quality and features
+
+---
+
+## 🎨 Design Approach
+
+### Modern, Clean, Fast
+
+- **Inspiration:** Google Photos, iCloud Photos
+- **Style:** Minimalist, photo-first design
+- **Colors:** Modern blues and clean whites/darks
+- **Typography:** Inter font family (clean, readable)
+- **Layout:** Responsive grid (masonry or justified)
+- **Animations:** Subtle, smooth, meaningful
+
+### Mobile First
+- Works beautifully on phones and tablets
+- Touch-optimized interactions
+- Fast loading on cellular networks
+- Responsive images for all screen sizes
+
+---
+
+## 🔒 Security Considerations
+
+### Database Security
+- ✅ Read-only PostgreSQL user (no write access)
+- ✅ Separate credentials from admin interface
+- ✅ Encrypted database connections
+- ✅ Network isolation (if self-hosted)
+
+### Application Security
+- ✅ Optional authentication (NextAuth.js)
+- ✅ Rate limiting to prevent abuse
+- ✅ Input validation on all queries
+- ✅ XSS and CSRF protection (built-in)
+- ✅ HTTPS only (automatic with Vercel/Caddy)
+
+### Privacy Options
+- ✅ Strip GPS data from EXIF
+- ✅ Configurable download permissions
+- ✅ Per-user favorites (privacy-preserving)
+- ✅ Optional authentication requirement
+
+---
+
+## 📈 Success Metrics
+
+### Technical Goals
+- 🎯 Lighthouse Performance Score: >90
+- 🎯 Page Load Time: <2 seconds
+- 🎯 Image Load Time: <1 second
+- 🎯 API Response Time: <200ms
+- 🎯 Mobile Experience: Excellent
+
+### User Experience Goals
+- 🎯 User Adoption: 90% of family members
+- 🎯 Session Duration: >5 minutes
+- 🎯 Return Visits: >60% weekly
+- 🎯 User Satisfaction: >4.5/5 stars
+- 🎯 Mobile Usage: >50% of traffic
+
+---
+
+## 🚦 Go/No-Go Criteria
+
+### ✅ Proceed If:
+- You have a PostgreSQL database with photos
+- You want to share photos with family/friends
+- You have budget for development OR time to DIY
+- You value user experience and modern design
+- You want mobile-optimized photo browsing
+
+### ❌ Don't Proceed If:
+- Your database has <100 photos (not worth it yet)
+- You're happy with current admin interface for viewing
+- Budget constraints (<$500 for hosting/development)
+- You don't care about mobile experience
+- You need write operations (upload, edit, delete)
+
+---
+
+## 🎬 Next Steps
+
+### Option A: Hire Developer/Agency
+1. ✅ Approve this plan
+2. ✅ Choose technology stack (recommend: Next.js + Vercel)
+3. ✅ Set budget and timeline
+4. ✅ Find and hire developer/agency
+5. ✅ Provide database access (read-only user)
+6. ✅ Review progress weekly
+7. ✅ Test and provide feedback
+8. ✅ Launch and train users
+
+### Option B: DIY Development
+1. ✅ Review technical requirements
+2. ✅ Follow Quick Start Guide (`docs/PHOTO_VIEWER_QUICKSTART.md`)
+3. ✅ Set up development environment
+4. ✅ Follow phase-by-phase plan (`docs/PHOTO_VIEWER_PLAN.md`)
+5. ✅ Build incrementally (one feature at a time)
+6. ✅ Test with real users frequently
+7. ✅ Deploy when stable
+8. ✅ Iterate based on feedback
+
+### Option C: Hybrid Approach
+1. ✅ Hire freelancer for initial setup (Weeks 1-4)
+2. ✅ Learn and customize yourself (Weeks 5+)
+3. ✅ Freelancer available for support
+4. ✅ Best of both worlds
+
+**Recommendation:** Option A or C for fastest, highest-quality results
+
+---
+
+## ❓ Frequently Asked Questions
+
+### Q: Can I keep using the admin interface?
+**A:** Yes! The photo viewer is a separate application. Your admin interface continues working exactly as before.
+
+### Q: Will this modify my database?
+**A:** No. The photo viewer uses a read-only database user. It can't modify your photos, faces, or people data. The only exception is the optional favorites feature, which adds per-user favorites to a separate table.
+
+### Q: Do I need to migrate data?
+**A:** No. The photo viewer reads directly from your existing PunimTag PostgreSQL database. No data migration needed.
+
+### Q: Can I customize the design?
+**A:** Absolutely! The design is fully customizable. You can change colors, fonts, layouts, and components to match your preferences.
+
+### Q: What if I have 100,000 photos?
+**A:** The architecture is designed to scale. With proper indexing and pagination, it can handle hundreds of thousands of photos. Performance may require some optimization for very large databases.
+
+### Q: Can users upload photos?
+**A:** Not in the initial design (read-only). However, this can be added as a future enhancement if needed. The admin interface remains the primary way to add photos.
+
+### Q: Is it mobile-friendly?
+**A:** Yes! Mobile-first design with responsive layouts, touch-optimized interactions, and fast loading on cellular networks.
+
+### Q: What about authentication?
+**A:** Optional. You can:
+- Make it completely public (no login)
+- Add simple password protection
+- Use OAuth (Google, Facebook)
+- Use email/password authentication
+
+Choose based on your privacy needs.
+
+### Q: Can I host it on my home server?
+**A:** Yes! You can self-host using Docker. The plan includes instructions for both cloud (Vercel) and self-hosted (Docker) deployment.
+
+### Q: What if my database is not network-accessible?
+**A:** For Vercel hosting, you'll need network access. For self-hosted, you can run it on the same network as your database.
+
+### Q: How do I update it when new photos are added?
+**A:** It's automatic! The viewer reads from the live database, so new photos appear immediately after they're processed in the admin interface.
+
+---
+
+## 🎯 Recommendation Summary
+
+### For Most Users: Next.js + Vercel
+- ⭐ Best performance and user experience
+- ⭐ Fastest development (6-8 weeks)
+- ⭐ Easiest deployment and maintenance
+- ⭐ Free hosting tier available
+- ⭐ Proven, mature ecosystem
+
+### Cost: $0-20/month hosting + $18K-28K development (or DIY)
+
+### Timeline: 6-12 weeks depending on approach
+
+### Next Action: Review full plan, make go/no-go decision, allocate budget
+
+---
+
+## 📚 Documentation Index
+
+| Document | Purpose | Audience |
+|----------|---------|----------|
+| **This Document** | Executive summary and decision guide | Decision makers |
+| `PHOTO_VIEWER_PLAN.md` | Complete detailed plan (20+ pages) | Developers, project managers |
+| `PHOTO_VIEWER_QUICKSTART.md` | Quick setup guide (5 minutes to start) | Developers |
+| `PHOTO_VIEWER_ARCHITECTURE.md` | Technical architecture and diagrams | Developers, architects |
+
+---
+
+## ✅ Action Items
+
+### For You (Decision Maker)
+- [ ] Review this executive summary
+- [ ] Read the full detailed plan if needed
+- [ ] Make go/no-go decision
+- [ ] Allocate budget (development + hosting)
+- [ ] Choose deployment approach (Vercel vs self-hosted)
+- [ ] Decide on authentication requirement
+- [ ] Approve timeline and milestones
+
+### For Developer/Agent
+- [ ] Read all documentation
+- [ ] Set up development environment
+- [ ] Create read-only database user
+- [ ] Initialize Next.js project
+- [ ] Follow Phase 1 tasks
+- [ ] Provide weekly progress updates
+
+---
+
+## 🎊 Expected Outcome
+
+After completion, you'll have:
+
+✅ **A beautiful, modern photo gallery** that family members love to use
+
+✅ **Fast, responsive browsing** on all devices (desktop, tablet, mobile)
+
+✅ **Powerful search capabilities** (people, dates, tags)
+
+✅ **Read-only access** that keeps your database safe
+
+✅ **Separate from admin interface** so you can manage photos independently
+
+✅ **Scalable architecture** that grows with your photo collection
+
+✅ **Low maintenance** with automatic updates and optimizations
+
+---
+
+## 📧 Questions?
+
+If you have questions or need clarification on any part of this plan:
+
+1. **Review the detailed plan** (`PHOTO_VIEWER_PLAN.md`)
+2. **Check the architecture** (`PHOTO_VIEWER_ARCHITECTURE.md`)
+3. **Try the quick start** (`PHOTO_VIEWER_QUICKSTART.md`)
+4. **Consult Next.js docs** (https://nextjs.org/docs)
+
+---
+
+**Ready to proceed?** 🚀
+
+Choose your path:
+- **Option A:** Hire a developer and provide them with this plan
+- **Option B:** Build it yourself using the Quick Start Guide
+- **Option C:** Hybrid approach (developer + your customizations)
+
+**This is a well-planned project with clear deliverables, proven technologies, and realistic timelines.**
+
+Let's build something amazing! 🎉
+
+---
+
+**Document Version:** 1.0
+**Last Updated:** November 14, 2025
+**Status:** Ready for Decision
+**Recommended Action:** Proceed with Next.js + Vercel approach
+
diff --git a/viewer-frontend/docs/PHOTO_VIEWER_PLAN.md b/viewer-frontend/docs/PHOTO_VIEWER_PLAN.md
new file mode 100644
index 0000000..7d77968
--- /dev/null
+++ b/viewer-frontend/docs/PHOTO_VIEWER_PLAN.md
@@ -0,0 +1,2002 @@
+# PunimTag Photo Viewer - Frontend Website Plan
+
+## Executive Summary
+
+This document outlines the detailed plan for creating a new, lightweight, public-facing photo viewing website that connects to the existing PunimTag PostgreSQL database. This website will be separate from the admin interface and focused on providing a beautiful, fast, and intuitive photo browsing experience.
+
+---
+
+## 1. Project Overview
+
+### 1.1 Purpose
+Create a read-only photo gallery website that allows users to:
+- Browse photos from the PunimTag database
+- Search by people names, dates taken, and tags
+- View high-quality photos with smooth navigation
+- Experience a modern, responsive design
+
+### 1.2 Key Differentiators from Admin Interface
+| Feature | Admin Interface | Photo Viewer |
+|---------|----------------|--------------|
+| Purpose | Photo management & AI processing | Photo viewing & browsing |
+| Authentication | Required | Optional (configurable) |
+| Operations | Read/Write (CRUD) | Read-only |
+| Target Users | Administrators | End users/Family members |
+| Complexity | Feature-rich, complex | Simple, elegant |
+| Performance | Batch operations | Optimized for browsing |
+
+---
+
+## 2. Technology Stack
+
+### 2.1 Frontend Framework
+**Next.js 14+ (App Router)**
+- **Why Next.js?**
+ - Built-in SSR/SSG for excellent performance
+ - Image optimization out of the box
+ - File-based routing
+ - API routes for backend proxy
+ - Excellent SEO support
+ - Great TypeScript support
+
+**Alternative: Astro 4+ with React Islands**
+- Even faster page loads
+- Partial hydration
+- Great for content-heavy sites
+- Zero JS by default
+
+**Recommendation: Next.js 14** (more mature ecosystem, better for dynamic data)
+
+### 2.2 UI Framework & Styling
+**shadcn/ui + Tailwind CSS**
+- Modern, accessible components
+- Highly customizable
+- Built on Radix UI primitives
+- Consistent design system
+- Copy-paste components (no npm bloat)
+
+**Additional UI Libraries:**
+- **Framer Motion** - Smooth animations and transitions
+- **Lucide React** - Beautiful, consistent icons
+- **React Photo Album** - Optimized photo grid layouts
+- **Yet Another React Lightbox** - Photo viewing/slideshow
+
+### 2.3 State Management
+**TanStack Query (React Query) v5**
+- Perfect for server state management
+- Built-in caching and invalidation
+- Loading and error states
+- Infinite scroll support
+- Optimistic updates
+
+**Zustand** (optional, for client state)
+- Lightweight alternative to Redux
+- Simple API
+- Only if needed for complex client state
+
+### 2.4 Backend Architecture
+
+#### Option A: Direct Database Access (Recommended for simplicity)
+```
+Next.js App → Prisma ORM → PostgreSQL Database
+```
+
+**Prisma ORM**
+- Type-safe database client
+- Auto-generated types from schema
+- Excellent PostgreSQL support
+- Read-only mode for safety
+
+#### Option B: Backend Proxy (Recommended for security)
+```
+Next.js App → Lightweight API Layer → PostgreSQL Database
+```
+
+**Lightweight API Options:**
+1. **Next.js API Routes** - Built-in, simple
+2. **tRPC** - End-to-end typesafe APIs
+3. **Separate FastAPI microservice** - Reuse existing backend code
+
+**Recommendation: Next.js API Routes with Prisma** (simplest, most integrated)
+
+### 2.5 Database Strategy
+**Read-Only PostgreSQL Access**
+- Connect to existing PunimTag PostgreSQL database
+- Read-only database user for security
+- Use connection pooling (PgBouncer or Prisma Accelerate)
+- No schema changes needed
+
+---
+
+## 3. Architecture Design
+
+### 3.1 Project Structure
+```
+punimtag-viewer/
+├── src/
+│ ├── app/ # Next.js 14 App Router
+│ │ ├── (marketing)/ # Public pages (landing, about)
+│ │ ├── (viewer)/ # Main photo viewer pages
+│ │ │ ├── layout.tsx # Viewer layout with nav
+│ │ │ ├── page.tsx # Home/Gallery grid
+│ │ │ ├── photo/[id]/ # Individual photo view
+│ │ │ ├── people/ # Browse by people
+│ │ │ ├── tags/ # Browse by tags
+│ │ │ └── search/ # Search results
+│ │ ├── api/ # API routes (if needed)
+│ │ └── layout.tsx # Root layout
+│ ├── components/ # React components
+│ │ ├── ui/ # shadcn/ui components
+│ │ ├── gallery/ # Gallery-specific components
+│ │ │ ├── PhotoGrid.tsx
+│ │ │ ├── PhotoCard.tsx
+│ │ │ ├── Lightbox.tsx
+│ │ │ └── InfiniteScroll.tsx
+│ │ ├── search/ # Search components
+│ │ │ ├── SearchBar.tsx
+│ │ │ ├── FilterPanel.tsx
+│ │ │ └── DateRangePicker.tsx
+│ │ └── layout/ # Layout components
+│ │ ├── Header.tsx
+│ │ ├── Sidebar.tsx
+│ │ └── Footer.tsx
+│ ├── lib/ # Utilities and configs
+│ │ ├── db.ts # Prisma client
+│ │ ├── queries.ts # Database queries
+│ │ ├── utils.ts # Helper functions
+│ │ └── constants.ts # Constants
+│ ├── hooks/ # Custom React hooks
+│ │ ├── usePhotos.ts
+│ │ ├── usePeople.ts
+│ │ ├── useTags.ts
+│ │ └── useInfiniteScroll.ts
+│ └── types/ # TypeScript types
+│ └── database.ts
+├── prisma/
+│ └── schema.prisma # Prisma schema (read-only)
+├── public/
+│ └── assets/ # Static assets
+├── .env.local # Environment variables
+├── next.config.js
+├── tailwind.config.ts
+├── package.json
+└── README.md
+```
+
+### 3.2 Data Flow
+
+```
+User Browser
+ ↓
+Next.js Frontend (React Components)
+ ↓
+TanStack Query (Caching & State)
+ ↓
+Next.js API Routes (Optional Proxy)
+ ↓
+Prisma ORM (Type-safe queries)
+ ↓
+PostgreSQL Database (Read-Only)
+ ↓
+Return Photo Data + URLs
+ ↓
+Next.js Image Optimization
+ ↓
+Optimized Images to User
+```
+
+### 3.3 Image Serving Strategy
+
+**Hybrid Approach (Implemented):** Automatically detects URL vs file system path
+
+#### Direct File Access (For URLs - SharePoint, CDN, etc.)
+- Next.js serves images directly from HTTP/HTTPS URLs
+- Use Next.js Image component for optimization
+- Configure `remotePatterns` in `next.config.ts` for external sources
+- Works with SharePoint Online, SharePoint Server, CDNs, and other HTTP-accessible sources
+- **Automatic detection:** If `photo.path` starts with `http://` or `https://`, uses direct access
+
+#### API Proxy (For File System Paths)
+- API endpoint serves images from database-referenced file system paths
+- Supports image resizing and caching
+- Can add watermarks, metadata stripping
+- Used automatically when `photo.path` is a file system path (not a URL)
+
+**Implementation:**
+- PhotoGrid component automatically detects URL vs file system path
+- URLs (SharePoint, CDN) → Direct access with Next.js Image optimization
+- File system paths → API proxy route (`/api/photos/[id]/image`)
+- **No database queries needed:** Path is passed via query parameter to avoid N+1 queries
+
+**Benefits:**
+- ✅ Works with both SharePoint URLs and local file system
+- ✅ Automatic detection - no configuration needed per photo
+- ✅ Optimal performance for both storage types
+- ✅ Backward compatible with existing file system paths
+
+---
+
+## 4. Core Features & Pages
+
+### 4.1 Home/Gallery Page
+**URL:** `/`
+
+**Features:**
+- Infinite scroll photo grid (masonry or justified layout)
+- Lazy loading with blur placeholders
+- Quick filters: All, Favorites, Recent
+- Photo count badge
+- Smooth animations on load
+
+**Components:**
+- `PhotoGrid` - Main grid container
+- `PhotoCard` - Individual photo card with hover effects
+- `InfiniteScroll` - Load more on scroll
+- `FilterBar` - Quick filter chips
+
+**Performance:**
+- Initial load: 30 photos
+- Lazy load: 30 photos per page
+- Virtual scrolling for 10,000+ photos
+
+### 4.2 Search Page
+**URL:** `/search`
+
+**Search Criteria:**
+1. **People Search**
+ - Autocomplete dropdown
+ - Multiple people selection
+ - "Match all" vs "Match any" toggle
+
+2. **Date Search**
+ - Date range picker
+ - Preset ranges (Today, This week, This month, This year)
+ - Custom date range
+
+3. **Tag Search**
+ - Tag cloud with counts
+ - Multi-select tags
+ - Hierarchical tags (if implemented)
+
+4. **Combined Search**
+ - All filters work together (AND logic)
+ - Clear filters button
+ - Search result count
+
+**Components:**
+- `SearchBar` - Main search input
+- `PeopleFilter` - People selection
+- `DateRangePicker` - Date picker
+- `TagFilter` - Tag selection
+- `SearchResults` - Results grid
+
+### 4.3 Photo Detail Page
+**URL:** `/photo/[id]`
+
+**Features:**
+- Full-size photo viewer
+- Photo metadata panel
+ - Date taken
+ - Dimensions
+ - Tagged people (with links)
+ - Tags (with links)
+- Previous/Next navigation
+- Keyboard shortcuts (←/→ arrows, Esc)
+- Download button (optional, configurable)
+- Share button (copy link)
+
+**Components:**
+- `Lightbox` - Full-screen photo viewer
+- `MetadataPanel` - Photo information
+- `PeopleList` - Tagged people
+- `TagList` - Photo tags
+
+### 4.4 People Browser
+**URL:** `/people`
+
+**Features:**
+- Grid of people with representative photo
+- Person name and photo count
+- Sort by: Name, Photo count, Recent
+- Search/filter people
+
+**Detail Page:** `/people/[id]`
+- Person's name and details
+- Grid of all photos featuring this person
+- Timeline view option
+- Photo count
+
+### 4.5 Tags Browser
+**URL:** `/tags`
+
+**Features:**
+- Tag cloud or list view
+- Photo count per tag
+- Color-coded tags (if applicable)
+- Search tags
+
+**Detail Page:** `/tags/[id]`
+- All photos with this tag
+- Related tags
+- Tag description (if available)
+
+### 4.6 Timeline View (Optional)
+**URL:** `/timeline`
+
+**Features:**
+- Chronological photo layout
+- Group by year/month/day
+- Timeline scrubber
+- Jump to date
+
+---
+
+## 5. UI/UX Design
+
+### 5.1 Design System
+
+**Color Palette (Modern & Clean):**
+```
+Primary:
+ - Light: #3b82f6 (Blue 500)
+ - Dark: #60a5fa (Blue 400)
+
+Secondary:
+ - Light: #8b5cf6 (Violet 500)
+ - Dark: #a78bfa (Violet 400)
+
+Neutral:
+ - Background Light: #ffffff
+ - Background Dark: #0f172a (Slate 900)
+ - Surface Light: #f8fafc (Slate 50)
+ - Surface Dark: #1e293b (Slate 800)
+
+Text:
+ - Primary Light: #0f172a (Slate 900)
+ - Primary Dark: #f8fafc (Slate 50)
+ - Secondary Light: #64748b (Slate 500)
+ - Secondary Dark: #94a3b8 (Slate 400)
+```
+
+**Typography:**
+```
+Font Family:
+ - Headings: Inter (Google Fonts)
+ - Body: Inter
+ - Mono: JetBrains Mono (for metadata)
+
+Font Sizes:
+ - Display: 4rem (64px)
+ - H1: 3rem (48px)
+ - H2: 2.25rem (36px)
+ - H3: 1.875rem (30px)
+ - H4: 1.5rem (24px)
+ - Body: 1rem (16px)
+ - Small: 0.875rem (14px)
+ - Tiny: 0.75rem (12px)
+```
+
+**Spacing Scale:**
+- 4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96 (px)
+
+**Border Radius:**
+- Small: 4px
+- Medium: 8px
+- Large: 12px
+- XLarge: 16px
+
+### 5.2 Layout & Navigation
+
+**Header (Desktop):**
+```
+┌────────────────────────────────────────────────────┐
+│ [Logo] PunimTag [Search] [User Menu] │
+│ Home | People | Tags | Timeline │
+└────────────────────────────────────────────────────┘
+```
+
+**Header (Mobile):**
+```
+┌─────────────────────────────┐
+│ [☰] PunimTag [Search] │
+└─────────────────────────────┘
+```
+
+**Sidebar Filter Panel (Desktop):**
+```
+┌──────────────┐
+│ Filters │
+│ │
+│ People │
+│ □ John │
+│ □ Jane │
+│ │
+│ Date Range │
+│ [Picker] │
+│ │
+│ Tags │
+│ □ Family │
+│ □ Vacation │
+│ │
+│ [Apply] │
+└──────────────┘
+```
+
+### 5.3 Responsive Design
+
+**Breakpoints:**
+- Mobile: < 640px (1 column)
+- Tablet: 640px - 1024px (2-3 columns)
+- Desktop: > 1024px (4-5 columns)
+- Wide: > 1536px (5-6 columns)
+
+**Photo Grid:**
+- Mobile: 1-2 columns
+- Tablet: 3-4 columns
+- Desktop: 4-5 columns
+- Wide: 6+ columns
+
+### 5.4 Animation & Transitions
+
+**Principles:**
+- Subtle and smooth (no jarring animations)
+- Fast transitions (150-300ms)
+- Meaningful motion (guide user attention)
+
+**Key Animations:**
+1. Photo Grid
+ - Fade in on load
+ - Scale on hover
+ - Smooth masonry layout shifts
+
+2. Page Transitions
+ - Fade between pages
+ - Slide in sidebars
+
+3. Lightbox
+ - Zoom in from thumbnail
+ - Smooth previous/next transitions
+
+**Framer Motion Variants:**
+```typescript
+const photoCardVariants = {
+ hidden: { opacity: 0, y: 20 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.3 }
+ },
+ hover: {
+ scale: 1.05,
+ transition: { duration: 0.2 }
+ }
+}
+```
+
+---
+
+## 6. Performance Optimization
+
+### 6.1 Image Optimization
+
+**Next.js Image Component:**
+```typescript
+
+```
+
+**Strategies:**
+1. **Responsive Images**
+ - Generate multiple sizes (thumbnail, medium, large, full)
+ - Serve appropriate size based on viewport
+ - Use `srcset` and `sizes` attributes
+
+2. **Blur Placeholder**
+ - Generate blur hash on backend
+ - Show blur while loading
+ - Smooth transition to full image
+
+3. **Lazy Loading**
+ - Load images as they enter viewport
+ - Prefetch next page of images
+ - Intersection Observer API
+
+4. **CDN Integration (Optional)**
+ - Cloudflare Images
+ - Vercel Image Optimization
+ - imgix or Cloudinary
+
+### 6.2 Data Fetching Optimization
+
+**React Query Configuration:**
+```typescript
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ cacheTime: 10 * 60 * 1000, // 10 minutes
+ refetchOnWindowFocus: false,
+ retry: 1,
+ },
+ },
+});
+```
+
+**Strategies:**
+1. **Prefetching**
+ - Prefetch next page on current page load
+ - Prefetch photo details on hover
+
+2. **Infinite Scroll**
+ - Use React Query's `useInfiniteQuery`
+ - Load 30 photos per page
+ - Keep 3 pages in memory
+
+3. **Debouncing**
+ - Debounce search inputs (300ms)
+ - Debounce filter changes
+
+4. **Parallel Queries**
+ - Fetch photos and metadata simultaneously
+ - Batch API requests
+
+### 6.3 Database Optimization
+
+**Indexing Strategy:**
+```sql
+-- Existing indices (already in PunimTag)
+CREATE INDEX idx_photos_date_taken ON photos(date_taken);
+CREATE INDEX idx_photos_processed ON photos(processed);
+CREATE INDEX idx_faces_person_id ON faces(person_id);
+CREATE INDEX idx_faces_photo_id ON faces(photo_id);
+CREATE INDEX idx_photo_tags_tag ON phototaglinkage(tag_id);
+CREATE INDEX idx_photo_tags_photo ON phototaglinkage(photo_id);
+
+-- Additional indices for viewer
+CREATE INDEX idx_photos_date_added_desc ON photos(date_added DESC);
+CREATE INDEX idx_people_names ON people(first_name, last_name);
+CREATE INDEX idx_tags_name ON tags(tag_name);
+```
+
+**Query Optimization:**
+1. **Pagination**
+ - Use cursor-based pagination for infinite scroll
+ - Avoid OFFSET for large datasets
+
+2. **Joins**
+ - Minimize JOIN operations
+ - Use SELECT only needed columns
+ - Aggregate queries for counts
+
+3. **Connection Pooling**
+ - Use PgBouncer or Prisma connection pooling
+ - Limit max connections
+
+### 6.4 Caching Strategy
+
+**Multi-Layer Caching:**
+
+1. **Browser Cache**
+ - Cache static assets (1 year)
+ - Cache images (1 month)
+ - Cache API responses (5 minutes)
+
+2. **React Query Cache**
+ - Cache query results in memory
+ - Automatic invalidation
+ - Background refetching
+
+3. **Next.js Cache**
+ - Static page generation (ISR)
+ - API route caching
+ - Image optimization cache
+
+4. **Database Cache (Optional)**
+ - Redis for frequently accessed data
+ - Cache photo counts, tag lists
+ - Cache search results
+
+### 6.5 Performance Targets
+
+| Metric | Target | Measurement |
+|--------|--------|-------------|
+| First Contentful Paint | < 1.5s | Lighthouse |
+| Largest Contentful Paint | < 2.5s | Lighthouse |
+| Time to Interactive | < 3.5s | Lighthouse |
+| Cumulative Layout Shift | < 0.1 | Lighthouse |
+| API Response Time | < 200ms | Server logs |
+| Photo Load Time | < 1s | Network tab |
+| Infinite Scroll Delay | < 500ms | User testing |
+
+---
+
+## 7. Search Implementation
+
+### 7.1 Search API Endpoints
+
+**Existing PunimTag API:** `/api/photos`
+```typescript
+// Search photos by multiple criteria
+GET /api/photos?search_type=name&person_name=John&page=1&page_size=30
+GET /api/photos?search_type=date&date_from=2024-01-01&date_to=2024-12-31
+GET /api/photos?search_type=tags&tag_names=family,vacation&match_all=false
+```
+
+**New Viewer API (Recommended):**
+```typescript
+// Combined search endpoint
+GET /api/viewer/photos?people=1,2&tags=3,4&date_from=2024-01-01&page=1
+
+// Get all people with photo counts
+GET /api/viewer/people
+
+// Get all tags with photo counts
+GET /api/viewer/tags
+
+// Get photo by ID with full metadata
+GET /api/viewer/photos/:id
+```
+
+### 7.2 Search UI Components
+
+**SearchBar Component:**
+```typescript
+interface SearchBarProps {
+ onSearch: (query: string) => void;
+ placeholder?: string;
+}
+
+// Features:
+// - Debounced input (300ms)
+// - Clear button
+// - Search icon
+// - Keyboard shortcuts (Cmd+K, Ctrl+K)
+```
+
+**FilterPanel Component:**
+```typescript
+interface FilterPanelProps {
+ people: Person[];
+ tags: Tag[];
+ dateRange: DateRange;
+ onFilterChange: (filters: Filters) => void;
+}
+
+// Features:
+// - Collapsible sections
+// - Multi-select with checkboxes
+// - Date range picker
+// - Clear all filters
+// - Active filter count badge
+```
+
+**PeopleAutocomplete:**
+```typescript
+interface PeopleAutocompleteProps {
+ onSelect: (people: Person[]) => void;
+ multiple?: boolean;
+}
+
+// Features:
+// - Fuzzy search
+// - Avatar thumbnails
+// - Photo count badges
+// - Keyboard navigation
+```
+
+### 7.3 Search Query Building
+
+**Prisma Query Example:**
+```typescript
+async function searchPhotos(filters: SearchFilters) {
+ const where = {
+ processed: true,
+ AND: [
+ // Date filter
+ filters.dateFrom ? { date_taken: { gte: filters.dateFrom } } : {},
+ filters.dateTo ? { date_taken: { lte: filters.dateTo } } : {},
+
+ // People filter (photo has face with person_id in list)
+ filters.people?.length ? {
+ faces: {
+ some: {
+ person_id: { in: filters.people }
+ }
+ }
+ } : {},
+
+ // Tags filter
+ filters.tags?.length ? {
+ photo_tags: {
+ some: {
+ tag_id: { in: filters.tags }
+ }
+ }
+ } : {},
+ ]
+ };
+
+ return await prisma.photo.findMany({
+ where,
+ include: {
+ faces: {
+ include: { person: true }
+ },
+ photo_tags: {
+ include: { tag: true }
+ }
+ },
+ orderBy: { date_taken: 'desc' },
+ take: 30,
+ skip: (page - 1) * 30,
+ });
+}
+```
+
+### 7.4 Search Performance
+
+**Optimization Techniques:**
+1. **Full-Text Search (Future Enhancement)**
+ ```sql
+ -- Add tsvector column for fast text search
+ ALTER TABLE photos ADD COLUMN search_vector tsvector;
+ CREATE INDEX idx_photos_search ON photos USING gin(search_vector);
+ ```
+
+2. **Materialized Views (For Aggregate Counts)**
+ ```sql
+ -- Precompute photo counts per person
+ CREATE MATERIALIZED VIEW person_photo_counts AS
+ SELECT person_id, COUNT(*) as photo_count
+ FROM faces
+ WHERE person_id IS NOT NULL
+ GROUP BY person_id;
+ ```
+
+3. **Elasticsearch Integration (Advanced)**
+ - Index photos in Elasticsearch
+ - Fast full-text search
+ - Faceted search
+ - Relevance scoring
+
+---
+
+## 8. Security & Access Control
+
+### 8.1 Authentication (Optional but Recommended)
+
+**Auth Options:**
+
+1. **Simple Password Protection**
+ - Single shared password
+ - Session-based auth
+ - Cookie storage
+
+2. **Email/Password Auth**
+ - User registration
+ - Password hashing (bcrypt)
+ - JWT tokens
+
+3. **OAuth/Social Login**
+ - Google, Facebook, GitHub
+ - NextAuth.js integration
+ - Easy for users
+
+4. **Family Sharing Model**
+ - Invite codes
+ - Family groups
+ - Role-based access
+
+**Recommendation:** Start with NextAuth.js for flexibility
+
+**NextAuth.js Configuration:**
+```typescript
+// app/api/auth/[...nextauth]/route.ts
+import NextAuth from "next-auth";
+import CredentialsProvider from "next-auth/providers/credentials";
+import GoogleProvider from "next-auth/providers/google";
+
+export const authOptions = {
+ providers: [
+ GoogleProvider({
+ clientId: process.env.GOOGLE_ID,
+ clientSecret: process.env.GOOGLE_SECRET,
+ }),
+ CredentialsProvider({
+ // Simple password or email/password
+ credentials: {
+ password: { label: "Password", type: "password" }
+ },
+ authorize: async (credentials) => {
+ // Verify password
+ if (credentials.password === process.env.VIEWER_PASSWORD) {
+ return { id: "viewer", name: "Family Member" };
+ }
+ return null;
+ }
+ })
+ ],
+ pages: {
+ signIn: '/login',
+ },
+ session: {
+ strategy: "jwt",
+ }
+};
+```
+
+### 8.2 Read-Only Database Access
+
+**PostgreSQL Read-Only User:**
+```sql
+-- Create read-only user
+CREATE USER viewer_readonly WITH PASSWORD 'secure_password_here';
+
+-- Grant connect permission
+GRANT CONNECT ON DATABASE punimtag TO viewer_readonly;
+
+-- Grant usage on schema
+GRANT USAGE ON SCHEMA public TO viewer_readonly;
+
+-- Grant SELECT on all tables
+GRANT SELECT ON ALL TABLES IN SCHEMA public TO viewer_readonly;
+
+-- Grant SELECT on future tables
+ALTER DEFAULT PRIVILEGES IN SCHEMA public
+GRANT SELECT ON TABLES TO viewer_readonly;
+
+-- Ensure no write permissions
+REVOKE INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public FROM viewer_readonly;
+```
+
+**Prisma Configuration:**
+```typescript
+// prisma/schema.prisma
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL_READONLY")
+}
+
+// .env.local
+DATABASE_URL_READONLY="postgresql://viewer_readonly:secure_password@localhost:5432/punimtag"
+```
+
+### 8.3 API Security
+
+**Rate Limiting:**
+```typescript
+// middleware.ts
+import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+
+const ratelimit = new Ratelimit({
+ redis: Redis.fromEnv(),
+ limiter: Ratelimit.slidingWindow(10, "10 s"),
+});
+
+export async function middleware(request: Request) {
+ const ip = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
+ const { success } = await ratelimit.limit(ip);
+
+ if (!success) {
+ return new Response("Too Many Requests", { status: 429 });
+ }
+}
+```
+
+**Input Validation:**
+```typescript
+import { z } from "zod";
+
+const searchParamsSchema = z.object({
+ people: z.array(z.number().int().positive()).optional(),
+ tags: z.array(z.number().int().positive()).optional(),
+ dateFrom: z.string().datetime().optional(),
+ dateTo: z.string().datetime().optional(),
+ page: z.number().int().positive().default(1),
+ pageSize: z.number().int().positive().max(100).default(30),
+});
+```
+
+**Content Security Policy:**
+```typescript
+// next.config.js
+const securityHeaders = [
+ {
+ key: 'Content-Security-Policy',
+ value: `
+ default-src 'self';
+ img-src 'self' data: https:;
+ script-src 'self' 'unsafe-eval' 'unsafe-inline';
+ style-src 'self' 'unsafe-inline';
+ `.replace(/\s{2,}/g, ' ').trim()
+ },
+ {
+ key: 'X-Frame-Options',
+ value: 'DENY'
+ },
+ {
+ key: 'X-Content-Type-Options',
+ value: 'nosniff'
+ },
+];
+```
+
+### 8.4 Privacy Considerations
+
+**Configuration Options:**
+1. **Public vs Private Mode**
+ - Public: No auth required
+ - Private: Auth required for all pages
+ - Hybrid: Auth required for specific people/tags
+
+2. **Download Controls**
+ - Enable/disable photo downloads
+ - Watermark on downloads
+ - Resolution limits
+
+3. **EXIF Data**
+ - Strip GPS coordinates
+ - Remove camera metadata
+ - Keep only date_taken
+
+4. **Favorites & User Data**
+ - Per-user favorites
+ - View history
+ - User-specific albums
+
+---
+
+## 9. Development Roadmap
+
+### Phase 1: Foundation (Week 1-2)
+**Goal:** Basic project setup and infrastructure
+
+**Tasks:**
+1. **Project Initialization**
+ - [ ] Create Next.js 14 project with TypeScript
+ - [ ] Install dependencies (TailwindCSS, shadcn/ui, React Query)
+ - [ ] Configure ESLint, Prettier
+ - [ ] Set up Git repository
+
+2. **Database Setup**
+ - [ ] Create Prisma schema from existing PostgreSQL schema
+ - [ ] Generate Prisma client
+ - [ ] Test read-only database connection
+ - [ ] Create database query helpers
+
+3. **Design System**
+ - [ ] Install shadcn/ui components
+ - [ ] Configure Tailwind theme
+ - [ ] Create color palette
+ - [ ] Set up typography
+ - [ ] Create base layout components (Header, Footer)
+
+4. **Authentication (Optional)**
+ - [ ] Set up NextAuth.js
+ - [ ] Create login page
+ - [ ] Implement simple password protection
+ - [ ] Add session management
+
+**Deliverables:**
+- Working Next.js app with database connection
+- Basic layout and navigation
+- Authentication (if required)
+
+### Phase 2: Core Features (Week 3-4)
+**Goal:** Photo gallery and basic navigation
+
+**Tasks:**
+1. **Photo Grid**
+ - [ ] Create PhotoGrid component
+ - [ ] Implement infinite scroll
+ - [ ] Add lazy loading
+ - [ ] Create photo card with hover effects
+
+2. **Photo Detail Page**
+ - [ ] Create Lightbox component
+ - [ ] Add metadata panel
+ - [ ] Implement previous/next navigation
+ - [ ] Add keyboard shortcuts
+
+3. **Image Optimization**
+ - [ ] Configure Next.js Image component
+ - [ ] Generate blur placeholders
+ - [ ] Test responsive images
+ - [ ] Implement lazy loading
+
+4. **API Routes**
+ - [ ] Create `/api/photos` endpoint
+ - [ ] Create `/api/photos/[id]` endpoint
+ - [ ] Add pagination support
+ - [ ] Implement error handling
+
+**Deliverables:**
+- Working photo gallery with infinite scroll
+- Photo detail page with lightbox
+- Optimized image loading
+
+### Phase 3: Search & Filtering (Week 5-6)
+**Goal:** Comprehensive search functionality
+
+**Tasks:**
+1. **People Search**
+ - [ ] Create people browser page
+ - [ ] Add people filter component
+ - [ ] Implement people autocomplete
+ - [ ] Create person detail page
+
+2. **Tag Search**
+ - [ ] Create tags browser page
+ - [ ] Add tag filter component
+ - [ ] Implement tag cloud
+ - [ ] Create tag detail page
+
+3. **Date Search**
+ - [ ] Add date range picker
+ - [ ] Implement date presets
+ - [ ] Create timeline view (optional)
+
+4. **Combined Search**
+ - [ ] Create search page
+ - [ ] Implement filter panel
+ - [ ] Add URL query parameter sync
+ - [ ] Create search results view
+
+**Deliverables:**
+- Working search with all filter types
+- People and tags browsers
+- Combined search functionality
+
+### Phase 4: Polish & Optimization (Week 7-8)
+**Goal:** Performance, UX, and visual polish
+
+**Tasks:**
+1. **Performance Optimization**
+ - [ ] Audit Lighthouse scores
+ - [ ] Optimize bundle size
+ - [ ] Implement code splitting
+ - [ ] Add service worker (PWA)
+
+2. **UX Improvements**
+ - [ ] Add loading skeletons
+ - [ ] Implement error boundaries
+ - [ ] Add toast notifications
+ - [ ] Create empty states
+
+3. **Animations**
+ - [ ] Add page transitions
+ - [ ] Implement photo grid animations
+ - [ ] Add hover effects
+ - [ ] Create lightbox transitions
+
+4. **Responsive Design**
+ - [ ] Test on mobile devices
+ - [ ] Optimize touch interactions
+ - [ ] Add mobile navigation
+ - [ ] Test tablet layouts
+
+**Deliverables:**
+- Lighthouse score > 90
+- Smooth animations and transitions
+- Fully responsive design
+
+### Phase 5: Advanced Features (Week 9-10)
+**Goal:** Nice-to-have features
+
+**Tasks:**
+1. **User Features**
+ - [ ] Favorites system
+ - [ ] Create albums (optional)
+ - [ ] Share links
+ - [ ] Download photos (configurable)
+
+2. **Social Features**
+ - [ ] Comments on photos (optional)
+ - [ ] Photo ratings (optional)
+ - [ ] Share to social media
+
+3. **Advanced Search**
+ - [ ] Full-text search
+ - [ ] Save searches
+ - [ ] Search suggestions
+ - [ ] Similar photos
+
+4. **Admin Panel (Light)**
+ - [ ] Basic configuration
+ - [ ] View usage stats
+ - [ ] Manage access
+
+**Deliverables:**
+- Enhanced user experience
+- Social and sharing features
+- Advanced search capabilities
+
+### Phase 6: Testing & Deployment (Week 11-12)
+**Goal:** Production-ready application
+
+**Tasks:**
+1. **Testing**
+ - [ ] Unit tests (Vitest)
+ - [ ] Integration tests (Playwright)
+ - [ ] Performance testing
+ - [ ] Security audit
+
+2. **Documentation**
+ - [ ] User guide
+ - [ ] Admin documentation
+ - [ ] API documentation
+ - [ ] Deployment guide
+
+3. **Deployment**
+ - [ ] Set up production environment
+ - [ ] Configure environment variables
+ - [ ] Deploy to Vercel/Railway/VPS
+ - [ ] Set up monitoring
+
+4. **Production Optimization**
+ - [ ] Configure CDN
+ - [ ] Set up error tracking (Sentry)
+ - [ ] Add analytics (optional)
+ - [ ] Create backup strategy
+
+**Deliverables:**
+- Deployed, production-ready application
+- Complete documentation
+- Monitoring and analytics
+
+---
+
+## 10. Deployment Options
+
+### 10.1 Vercel (Recommended - Easiest)
+
+**Pros:**
+- Zero-config deployment for Next.js
+- Automatic HTTPS
+- Global CDN
+- Image optimization built-in
+- Free tier available
+
+**Cons:**
+- Requires internet access to database
+- Can be expensive for high traffic
+
+**Setup:**
+```bash
+# Install Vercel CLI
+npm i -g vercel
+
+# Deploy
+vercel deploy
+
+# Set environment variables
+vercel env add DATABASE_URL_READONLY
+vercel env add NEXTAUTH_SECRET
+vercel env add NEXTAUTH_URL
+```
+
+**Configuration:**
+```json
+// vercel.json
+{
+ "buildCommand": "npm run build",
+ "outputDirectory": ".next",
+ "devCommand": "npm run dev",
+ "installCommand": "npm install",
+ "framework": "nextjs",
+ "regions": ["iad1"]
+}
+```
+
+### 10.2 Docker (Self-Hosted)
+
+**Pros:**
+- Full control
+- Can run on local network
+- One-time cost
+- No vendor lock-in
+
+**Cons:**
+- Requires server management
+- Need to handle HTTPS, CDN
+
+**Dockerfile:**
+```dockerfile
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+
+COPY . .
+RUN npx prisma generate
+RUN npm run build
+
+FROM node:20-alpine AS runner
+WORKDIR /app
+
+ENV NODE_ENV production
+
+COPY --from=builder /app/next.config.js ./
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/.next/standalone ./
+COPY --from=builder /app/.next/static ./.next/static
+
+EXPOSE 3000
+ENV PORT 3000
+
+CMD ["node", "server.js"]
+```
+
+**docker-compose.yml:**
+```yaml
+version: '3.8'
+
+services:
+ viewer:
+ build: .
+ ports:
+ - "3000:3000"
+ environment:
+ - DATABASE_URL_READONLY=postgresql://viewer_readonly:password@postgres:5432/punimtag
+ - NEXTAUTH_SECRET=your_secret_here
+ - NEXTAUTH_URL=http://localhost:3000
+ restart: unless-stopped
+ depends_on:
+ - postgres
+
+ postgres:
+ image: postgres:16
+ # Use existing PunimTag database
+ environment:
+ - POSTGRES_DB=punimtag
+ - POSTGRES_USER=punimtag
+ - POSTGRES_PASSWORD=punimtag_password
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+
+volumes:
+ postgres_data:
+```
+
+### 10.3 Railway
+
+**Pros:**
+- Easy deployment from GitHub
+- Built-in database support
+- Affordable pricing
+- Good for hobby projects
+
+**Cons:**
+- Smaller network than Vercel
+- Less mature platform
+
+### 10.4 Netlify
+
+**Pros:**
+- Great for static sites
+- Free tier
+- Good CDN
+
+**Cons:**
+- Less optimized for Next.js than Vercel
+- Limited API route capabilities
+
+### 10.5 VPS (DigitalOcean, Linode, AWS EC2)
+
+**Pros:**
+- Full control
+- Predictable pricing
+- Can run on private network
+
+**Cons:**
+- Requires DevOps knowledge
+- Manual scaling
+
+**Recommended:** Use Docker + Caddy for automatic HTTPS
+
+---
+
+## 11. Configuration & Customization
+
+### 11.1 Environment Variables
+
+**.env.local:**
+```bash
+# Database (Read-Only)
+DATABASE_URL_READONLY="postgresql://viewer_readonly:password@localhost:5432/punimtag"
+
+# Authentication
+NEXTAUTH_SECRET="generate-with-openssl-rand-base64-32"
+NEXTAUTH_URL="http://localhost:3000"
+
+# Optional: OAuth Providers
+GOOGLE_ID="your-google-client-id"
+GOOGLE_SECRET="your-google-client-secret"
+
+# Site Configuration
+NEXT_PUBLIC_SITE_NAME="Family Photos"
+NEXT_PUBLIC_SITE_DESCRIPTION="Our family photo collection"
+NEXT_PUBLIC_REQUIRE_AUTH="true"
+
+# Feature Flags
+NEXT_PUBLIC_ENABLE_DOWNLOADS="false"
+NEXT_PUBLIC_ENABLE_FAVORITES="true"
+NEXT_PUBLIC_ENABLE_COMMENTS="false"
+
+# Image Serving
+NEXT_PUBLIC_PHOTO_BASE_PATH="/path/to/photos"
+
+# Optional: Analytics
+NEXT_PUBLIC_ANALYTICS_ID=""
+
+# Optional: Sentry
+SENTRY_DSN=""
+```
+
+### 11.2 Feature Flags
+
+**config/features.ts:**
+```typescript
+export const features = {
+ auth: {
+ enabled: process.env.NEXT_PUBLIC_REQUIRE_AUTH === "true",
+ providers: ['google', 'credentials'],
+ },
+ photos: {
+ allowDownload: process.env.NEXT_PUBLIC_ENABLE_DOWNLOADS === "true",
+ maxDownloadSize: 1920, // max width/height for downloads
+ showExif: false, // Don't expose EXIF data in UI
+ },
+ social: {
+ favorites: process.env.NEXT_PUBLIC_ENABLE_FAVORITES === "true",
+ comments: process.env.NEXT_PUBLIC_ENABLE_COMMENTS === "true",
+ sharing: true,
+ },
+ search: {
+ fuzzySearch: true,
+ fullTextSearch: false, // Requires Elasticsearch
+ },
+ ui: {
+ theme: 'system', // 'light' | 'dark' | 'system'
+ defaultView: 'grid', // 'grid' | 'masonry' | 'timeline'
+ photosPerPage: 30,
+ },
+};
+```
+
+### 11.3 Branding Customization
+
+**config/branding.ts:**
+```typescript
+export const branding = {
+ siteName: process.env.NEXT_PUBLIC_SITE_NAME || "PunimTag Viewer",
+ siteDescription: process.env.NEXT_PUBLIC_SITE_DESCRIPTION || "Photo Gallery",
+ logo: "/logo.svg",
+ favicon: "/favicon.ico",
+ colors: {
+ primary: "#3b82f6",
+ secondary: "#8b5cf6",
+ },
+ fonts: {
+ heading: "Inter",
+ body: "Inter",
+ },
+};
+```
+
+---
+
+## 12. Testing Strategy
+
+### 12.1 Unit Tests (Vitest)
+
+**Components:**
+```typescript
+// __tests__/components/PhotoCard.test.tsx
+import { render, screen } from '@testing-library/react';
+import { PhotoCard } from '@/components/gallery/PhotoCard';
+
+describe('PhotoCard', () => {
+ it('renders photo correctly', () => {
+ const photo = {
+ id: 1,
+ path: '/photos/test.jpg',
+ filename: 'test.jpg',
+ };
+
+ render( );
+ expect(screen.getByAlt('test.jpg')).toBeInTheDocument();
+ });
+});
+```
+
+**Utilities:**
+```typescript
+// __tests__/lib/queries.test.ts
+import { searchPhotos } from '@/lib/queries';
+
+describe('searchPhotos', () => {
+ it('filters by date range', async () => {
+ const results = await searchPhotos({
+ dateFrom: new Date('2024-01-01'),
+ dateTo: new Date('2024-12-31'),
+ });
+
+ expect(results.length).toBeGreaterThan(0);
+ });
+});
+```
+
+### 12.2 Integration Tests (Playwright)
+
+**E2E Tests:**
+```typescript
+// e2e/photo-gallery.spec.ts
+import { test, expect } from '@playwright/test';
+
+test('should load photo gallery', async ({ page }) => {
+ await page.goto('/');
+
+ // Wait for photos to load
+ await expect(page.locator('[data-testid="photo-card"]').first()).toBeVisible();
+
+ // Check infinite scroll
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ await page.waitForTimeout(1000);
+
+ const photoCount = await page.locator('[data-testid="photo-card"]').count();
+ expect(photoCount).toBeGreaterThan(30);
+});
+
+test('should search by person', async ({ page }) => {
+ await page.goto('/');
+
+ // Open search
+ await page.click('[data-testid="search-button"]');
+
+ // Select person
+ await page.fill('[data-testid="people-search"]', 'John');
+ await page.click('[data-testid="person-option-1"]');
+
+ // Apply filter
+ await page.click('[data-testid="apply-filters"]');
+
+ // Verify results
+ await expect(page.locator('[data-testid="person-badge"]')).toContainText('John');
+});
+```
+
+### 12.3 Performance Tests
+
+**Lighthouse CI:**
+```yaml
+# .github/workflows/lighthouse.yml
+name: Lighthouse CI
+on: [push]
+
+jobs:
+ lighthouse:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
+ - run: npm ci
+ - run: npm run build
+ - run: npm run start &
+ - uses: treosh/lighthouse-ci-action@v9
+ with:
+ urls: |
+ http://localhost:3000
+ http://localhost:3000/people
+ http://localhost:3000/search
+ uploadArtifacts: true
+ temporaryPublicStorage: true
+```
+
+### 12.4 Load Testing (K6)
+
+```javascript
+// load-test.js
+import http from 'k6/http';
+import { check, sleep } from 'k6';
+
+export let options = {
+ stages: [
+ { duration: '1m', target: 50 },
+ { duration: '3m', target: 50 },
+ { duration: '1m', target: 0 },
+ ],
+};
+
+export default function () {
+ // Test photo gallery
+ let res = http.get('http://localhost:3000/api/photos?page=1&pageSize=30');
+ check(res, {
+ 'status is 200': (r) => r.status === 200,
+ 'response time < 200ms': (r) => r.timings.duration < 200,
+ });
+
+ sleep(1);
+}
+```
+
+---
+
+## 13. Monitoring & Analytics
+
+### 13.1 Error Tracking (Sentry)
+
+**Setup:**
+```bash
+npm install @sentry/nextjs
+npx @sentry/wizard -i nextjs
+```
+
+**Configuration:**
+```typescript
+// sentry.client.config.ts
+import * as Sentry from '@sentry/nextjs';
+
+Sentry.init({
+ dsn: process.env.SENTRY_DSN,
+ tracesSampleRate: 0.1,
+ environment: process.env.NODE_ENV,
+ beforeSend(event, hint) {
+ // Don't send errors in development
+ if (process.env.NODE_ENV === 'development') {
+ return null;
+ }
+ return event;
+ },
+});
+```
+
+### 13.2 Analytics (Vercel Analytics or Plausible)
+
+**Vercel Analytics:**
+```typescript
+// app/layout.tsx
+import { Analytics } from '@vercel/analytics/react';
+
+export default function RootLayout({ children }) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+```
+
+**Plausible (Privacy-Friendly):**
+```typescript
+// app/layout.tsx
+import Script from 'next/script';
+
+export default function RootLayout({ children }) {
+ return (
+
+
+
+
+ {children}
+
+ );
+}
+```
+
+### 13.3 Performance Monitoring
+
+**Custom Metrics:**
+```typescript
+// lib/monitoring.ts
+import { performance } from 'perf_hooks';
+
+export function measurePhotoLoad() {
+ const start = performance.now();
+
+ return () => {
+ const duration = performance.now() - start;
+
+ // Send to analytics
+ if (typeof window !== 'undefined' && window.gtag) {
+ window.gtag('event', 'photo_load_time', {
+ value: Math.round(duration),
+ event_category: 'performance',
+ });
+ }
+ };
+}
+```
+
+---
+
+## 14. Maintenance & Updates
+
+### 14.1 Database Schema Changes
+
+**If PunimTag Schema Changes:**
+1. Update Prisma schema
+2. Regenerate Prisma client
+3. Update TypeScript types
+4. Test queries
+5. Deploy new version
+
+```bash
+# Update schema
+npx prisma db pull
+
+# Generate client
+npx prisma generate
+
+# Restart app
+npm run build
+npm run start
+```
+
+### 14.2 Dependency Updates
+
+**Regular Updates:**
+```bash
+# Check outdated packages
+npm outdated
+
+# Update dependencies
+npm update
+
+# Update major versions (carefully)
+npx npm-check-updates -u
+npm install
+```
+
+### 14.3 Backup Strategy
+
+**Database:**
+- Use existing PunimTag backup strategy
+- Read-only user can't modify data
+
+**Configuration:**
+```bash
+# Backup environment variables
+cp .env.local .env.backup
+
+# Backup custom configuration
+tar -czf config-backup.tar.gz config/
+```
+
+---
+
+## 15. Future Enhancements
+
+### 15.1 Short-Term (3-6 months)
+
+1. **Mobile App**
+ - React Native or Capacitor
+ - Offline support
+ - Push notifications
+
+2. **Albums & Collections**
+ - Create custom albums
+ - Share albums with links
+ - Album covers
+
+3. **Advanced Search**
+ - Visual similarity search
+ - AI-powered search (search by description)
+ - Face-based search
+
+### 15.2 Long-Term (6-12 months)
+
+1. **AI Features**
+ - Auto-tagging with AI
+ - Scene recognition
+ - Object detection
+ - Smart cropping
+
+2. **Collaboration**
+ - Multi-user albums
+ - Comments and reactions
+ - Photo requests
+
+3. **Integration**
+ - Apple Photos import
+ - Google Photos import
+ - Auto-backup from phone
+
+---
+
+## 16. Cost Estimation
+
+### 16.1 Development Costs
+
+**Freelancer/Agency:**
+- Phase 1-2 (Foundation): $5,000 - $8,000
+- Phase 3-4 (Features): $8,000 - $12,000
+- Phase 5-6 (Polish & Deploy): $5,000 - $8,000
+- **Total: $18,000 - $28,000**
+
+**In-House Developer:**
+- 12 weeks × 40 hours = 480 hours
+- At $50/hour: $24,000
+- At $100/hour: $48,000
+
+**DIY (Self-Development):**
+- Time investment: 200-300 hours
+- Learning curve: High (if new to Next.js)
+- Cost: Time + tool subscriptions
+
+### 16.2 Hosting Costs (Monthly)
+
+**Option 1: Vercel (Hobby - Free)**
+- Cost: $0
+- 100 GB bandwidth
+- Unlimited requests
+- Good for small families
+
+**Option 2: Vercel (Pro)**
+- Cost: $20/month
+- 1 TB bandwidth
+- Priority support
+- Good for medium usage
+
+**Option 3: Railway**
+- Cost: $5-20/month
+- Based on usage
+- Includes database
+
+**Option 4: VPS (DigitalOcean)**
+- Cost: $12-24/month (2-4 GB RAM)
+- Full control
+- Unlimited bandwidth
+
+**Option 5: Self-Hosted (Home Server)**
+- Cost: $0/month (after hardware)
+- Requires: Raspberry Pi 4 or old laptop
+- No recurring costs
+
+### 16.3 Additional Costs
+
+**Domain Name:** $10-15/year
+**SSL Certificate:** Free (Let's Encrypt)
+**CDN (Cloudflare):** Free tier sufficient
+**Email (Transactional):** $0 (Resend free tier)
+**Error Tracking (Sentry):** $0 (free tier: 5k events/month)
+**Analytics (Plausible):** $9/month or self-hosted (free)
+
+**Total Minimum:** $0-10/month (using free tiers)
+**Total Recommended:** $30-50/month (paid hosting + services)
+
+---
+
+## 17. Success Metrics
+
+### 17.1 Technical Metrics
+
+- **Lighthouse Score:** > 90 in all categories
+- **API Response Time:** < 200ms (p95)
+- **Image Load Time:** < 1s (p95)
+- **Page Load Time:** < 2s (p95)
+- **Error Rate:** < 0.1%
+- **Uptime:** > 99.9%
+
+### 17.2 User Experience Metrics
+
+- **Time to First Photo:** < 1.5s
+- **Photos Viewed per Session:** > 20
+- **Search Success Rate:** > 80%
+- **Mobile Responsiveness:** 100%
+- **Accessibility Score:** > 90 (WCAG AA)
+
+### 17.3 Business Metrics
+
+- **User Adoption:** 90% of family members
+- **Daily Active Users:** > 50%
+- **Session Duration:** > 5 minutes
+- **Return Visit Rate:** > 60% weekly
+- **User Satisfaction:** > 4.5/5
+
+---
+
+## 18. Risks & Mitigations
+
+### 18.1 Technical Risks
+
+| Risk | Impact | Likelihood | Mitigation |
+|------|--------|------------|------------|
+| Database connection issues | High | Medium | Connection pooling, retry logic, fallback to cache |
+| Image loading performance | High | High | CDN, image optimization, lazy loading, blur placeholders |
+| Search query performance | Medium | Medium | Database indices, query optimization, caching |
+| Browser compatibility | Low | Low | Use modern standards, test on multiple browsers |
+| Mobile performance | Medium | Medium | Responsive images, code splitting, lazy loading |
+
+### 18.2 Security Risks
+
+| Risk | Impact | Likelihood | Mitigation |
+|------|--------|------------|------------|
+| Unauthorized database access | High | Low | Read-only user, network isolation, strong passwords |
+| Photo file access | Medium | Medium | Serve through API, check permissions, rate limiting |
+| XSS attacks | Medium | Low | Input sanitization, CSP headers, secure frameworks |
+| DDoS attacks | Medium | Medium | Rate limiting, CDN, Cloudflare protection |
+| Data breaches | High | Low | HTTPS only, secure sessions, no sensitive data storage |
+
+### 18.3 Operational Risks
+
+| Risk | Impact | Likelihood | Mitigation |
+|------|--------|------------|------------|
+| Hosting costs exceed budget | Medium | Medium | Monitor usage, set alerts, use free tiers |
+| Developer unavailability | Medium | Low | Good documentation, clean code, backup developer |
+| Data migration issues | High | Low | Test thoroughly, backup database, rollback plan |
+| User adoption failure | High | Low | User testing, feedback loop, training materials |
+
+---
+
+## 19. Comparison with Admin Interface
+
+| Feature | Admin Interface | Photo Viewer |
+|---------|----------------|--------------|
+| **Purpose** | Photo management | Photo viewing |
+| **Users** | Admin only | All family members |
+| **Authentication** | Required | Optional |
+| **Photo Upload** | ✅ Yes | ❌ No |
+| **Face Processing** | ✅ Yes | ❌ No |
+| **Person Management** | ✅ Full CRUD | 👁️ View only |
+| **Tag Management** | ✅ Full CRUD | 👁️ View only |
+| **Search** | ✅ Advanced | ✅ User-friendly |
+| **Photo Editing** | ✅ Yes | ❌ No |
+| **Bulk Operations** | ✅ Yes | ❌ No |
+| **Performance** | ⚡ Good | ⚡⚡ Excellent |
+| **UI Complexity** | 🎚️ High | 🎚️ Low |
+| **Mobile Experience** | 📱 Functional | 📱 Optimized |
+| **Favorites** | ✅ Yes | ✅ Yes |
+| **Comments** | ❌ No | ⚠️ Optional |
+| **Sharing** | ❌ No | ✅ Yes |
+| **Download** | ✅ Yes | ⚠️ Configurable |
+
+---
+
+## 20. Getting Started Checklist
+
+### For the Developer/Agent
+
+- [ ] Read entire plan document
+- [ ] Understand PunimTag database schema
+- [ ] Set up development environment
+ - [ ] Install Node.js 20+
+ - [ ] Install PostgreSQL client
+ - [ ] Install Git
+- [ ] Create Next.js project
+ - [ ] Initialize with TypeScript
+ - [ ] Install dependencies
+ - [ ] Configure Tailwind CSS
+- [ ] Set up database connection
+ - [ ] Create read-only PostgreSQL user
+ - [ ] Test database connection
+ - [ ] Set up Prisma
+- [ ] Create basic layout
+ - [ ] Header component
+ - [ ] Footer component
+ - [ ] Navigation
+- [ ] Implement photo grid
+ - [ ] Fetch photos from database
+ - [ ] Display in grid
+ - [ ] Add lazy loading
+- [ ] Follow Phase 1 tasks
+
+### For the Project Owner
+
+- [ ] Review and approve plan
+- [ ] Provide database access credentials
+- [ ] Decide on authentication requirement
+- [ ] Choose hosting platform
+- [ ] Set budget and timeline expectations
+- [ ] Prepare test data
+- [ ] Identify key users for feedback
+
+---
+
+## 21. Conclusion
+
+This plan provides a comprehensive roadmap for creating a modern, performant, and user-friendly photo viewing website that complements the existing PunimTag admin interface. By leveraging Next.js 14, modern UI libraries, and the existing PostgreSQL database, we can deliver a fast, beautiful, and maintainable solution.
+
+**Key Success Factors:**
+1. **Performance First:** Optimize images and queries from day one
+2. **User Experience:** Simple, intuitive interface
+3. **Security:** Read-only access, proper authentication
+4. **Maintainability:** Clean code, good documentation
+5. **Scalability:** Built to handle thousands of photos
+
+**Next Steps:**
+1. Review and approve this plan
+2. Set up development environment
+3. Begin Phase 1 implementation
+4. Iterate based on user feedback
+
+---
+
+## Appendix
+
+### A. Technology Alternatives
+
+**Frontend Frameworks:**
+- Next.js 14 ⭐ (Recommended)
+- Astro 4
+- SvelteKit 2
+- Remix
+- Nuxt 3 (Vue)
+
+**UI Libraries:**
+- shadcn/ui + Tailwind ⭐ (Recommended)
+- Material-UI (MUI)
+- Chakra UI
+- Ant Design
+- DaisyUI
+
+**Database ORM:**
+- Prisma ⭐ (Recommended)
+- Drizzle ORM
+- TypeORM
+- Kysely
+
+**Image Libraries:**
+- Next.js Image ⭐ (Recommended)
+- React Photo Album
+- React Image Gallery
+- PhotoSwipe
+
+### B. Reference Links
+
+**Documentation:**
+- Next.js: https://nextjs.org/docs
+- Prisma: https://www.prisma.io/docs
+- shadcn/ui: https://ui.shadcn.com/
+- TanStack Query: https://tanstack.com/query/latest
+- Tailwind CSS: https://tailwindcss.com/docs
+
+**Inspiration (Photo Gallery Sites):**
+- Google Photos
+- iCloud Photos
+- Flickr
+- 500px
+- Unsplash
+
+**GitHub Repositories (Examples):**
+- https://github.com/vercel/next.js/tree/canary/examples/with-prisma
+- https://github.com/shadcn-ui/taxonomy
+- https://github.com/steven-tey/nextjs-postgres-auth-starter
+
+### C. Glossary
+
+- **SSR:** Server-Side Rendering
+- **SSG:** Static Site Generation
+- **ISR:** Incremental Static Regeneration
+- **CDN:** Content Delivery Network
+- **ORM:** Object-Relational Mapping
+- **PWA:** Progressive Web App
+- **CSP:** Content Security Policy
+- **EXIF:** Exchangeable Image File Format
+- **WCAG:** Web Content Accessibility Guidelines
+
+---
+
+**Document Version:** 1.0
+**Last Updated:** November 14, 2025
+**Author:** AI Assistant (Claude Sonnet 4.5)
+**Status:** Ready for Implementation
+
diff --git a/viewer-frontend/docs/PHOTO_VIEWER_QUICKSTART.md b/viewer-frontend/docs/PHOTO_VIEWER_QUICKSTART.md
new file mode 100644
index 0000000..b820276
--- /dev/null
+++ b/viewer-frontend/docs/PHOTO_VIEWER_QUICKSTART.md
@@ -0,0 +1,470 @@
+# PunimTag Photo Viewer - Quick Start Guide
+
+## 🎯 What Is This?
+
+A **modern, fast, beautiful photo viewing website** that connects to your existing PunimTag PostgreSQL database. Perfect for sharing photos with family members without giving them admin access.
+
+---
+
+## 🚀 Quick Setup (5 Minutes)
+
+### 1. Create the Project
+
+```bash
+# Navigate to your projects folder
+cd /home/ladmin/Code
+
+# Create Next.js project
+npx create-next-app@latest punimtag-viewer --typescript --tailwind --app --no-src-dir
+
+# Navigate into project
+cd punimtag-viewer
+```
+
+When prompted, choose:
+- TypeScript: **Yes**
+- ESLint: **Yes**
+- Tailwind CSS: **Yes**
+- `src/` directory: **No** (we'll use app directory)
+- App Router: **Yes**
+- Import alias: **Yes** (@/*)
+
+### 2. Install Core Dependencies
+
+```bash
+# UI Components & Styling
+npx shadcn-ui@latest init
+
+# Database
+npm install @prisma/client
+npm install -D prisma
+
+# State Management & Data Fetching
+npm install @tanstack/react-query
+
+# Image Gallery
+npm install react-photo-album yet-another-react-lightbox
+
+# Icons & Animations
+npm install lucide-react framer-motion
+
+# Date Handling
+npm install date-fns
+
+# Authentication (Optional)
+npm install next-auth
+```
+
+### 3. Set Up Database Connection
+
+```bash
+# Initialize Prisma
+npx prisma init
+```
+
+Edit `.env`:
+```bash
+DATABASE_URL="postgresql://viewer_readonly:password@localhost:5432/punimtag"
+```
+
+### 4. Create Prisma Schema
+
+Create `prisma/schema.prisma`:
+
+```prisma
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+model Photo {
+ id Int @id @default(autoincrement())
+ path String @unique
+ filename String
+ dateAdded DateTime @default(now()) @map("date_added")
+ dateTaken DateTime? @map("date_taken")
+ processed Boolean @default(false)
+
+ faces Face[]
+ photoTags PhotoTagLinkage[]
+ favorites PhotoFavorite[]
+
+ @@map("photos")
+}
+
+model Person {
+ id Int @id @default(autoincrement())
+ firstName String @map("first_name")
+ lastName String @map("last_name")
+ middleName String? @map("middle_name")
+ maidenName String? @map("maiden_name")
+ dateOfBirth DateTime? @map("date_of_birth") @db.Date
+ createdDate DateTime @default(now()) @map("created_date")
+
+ faces Face[]
+ personEncodings PersonEncoding[]
+
+ @@unique([firstName, lastName, middleName, maidenName, dateOfBirth], name: "uq_people_names_dob")
+ @@map("people")
+}
+
+model Face {
+ id Int @id @default(autoincrement())
+ photoId Int @map("photo_id")
+ personId Int? @map("person_id")
+ encoding Bytes
+ location String
+ confidence Decimal @default(0) @db.Decimal
+ qualityScore Decimal @default(0) @map("quality_score") @db.Decimal
+ detectorBackend String @default("retinaface") @map("detector_backend")
+ modelName String @default("ArcFace") @map("model_name")
+ faceConfidence Decimal @default(0) @map("face_confidence") @db.Decimal
+ poseMode String @default("frontal") @map("pose_mode")
+
+ photo Photo @relation(fields: [photoId], references: [id])
+ person Person? @relation(fields: [personId], references: [id])
+ personEncodings PersonEncoding[]
+
+ @@index([photoId])
+ @@index([personId])
+ @@map("faces")
+}
+
+model PersonEncoding {
+ id Int @id @default(autoincrement())
+ personId Int @map("person_id")
+ faceId Int @map("face_id")
+ encoding Bytes
+ qualityScore Decimal @default(0) @map("quality_score") @db.Decimal
+ detectorBackend String @default("retinaface") @map("detector_backend")
+ modelName String @default("ArcFace") @map("model_name")
+ createdDate DateTime @default(now()) @map("created_date")
+
+ person Person @relation(fields: [personId], references: [id])
+ face Face @relation(fields: [faceId], references: [id])
+
+ @@index([personId])
+ @@map("person_encodings")
+}
+
+model Tag {
+ id Int @id @default(autoincrement())
+ tagName String @unique @map("tag_name")
+ createdDate DateTime @default(now()) @map("created_date")
+
+ photoTags PhotoTagLinkage[]
+
+ @@map("tags")
+}
+
+model PhotoTagLinkage {
+ linkageId Int @id @default(autoincrement()) @map("linkage_id")
+ photoId Int @map("photo_id")
+ tagId Int @map("tag_id")
+ linkageType Int @default(0) @map("linkage_type")
+ createdDate DateTime @default(now()) @map("created_date")
+
+ photo Photo @relation(fields: [photoId], references: [id])
+ tag Tag @relation(fields: [tagId], references: [id])
+
+ @@unique([photoId, tagId], name: "uq_photo_tag")
+ @@index([photoId])
+ @@index([tagId])
+ @@map("phototaglinkage")
+}
+
+model PhotoFavorite {
+ id Int @id @default(autoincrement())
+ username String
+ photoId Int @map("photo_id")
+ createdDate DateTime @default(now()) @map("created_date")
+
+ photo Photo @relation(fields: [photoId], references: [id])
+
+ @@unique([username, photoId], name: "uq_user_photo_favorite")
+ @@index([username])
+ @@index([photoId])
+ @@map("photo_favorites")
+}
+```
+
+### 5. Generate Prisma Client
+
+```bash
+npx prisma generate
+```
+
+### 6. Test Database Connection
+
+```bash
+npx prisma db pull # Verify schema matches database
+npx prisma studio # Open database browser (optional)
+```
+
+### 7. Create First Component
+
+Create `app/page.tsx`:
+
+```typescript
+import { prisma } from '@/lib/db';
+import { PhotoGrid } from '@/components/PhotoGrid';
+
+export default async function HomePage() {
+ const photos = await prisma.photo.findMany({
+ where: { processed: true },
+ orderBy: { dateTaken: 'desc' },
+ take: 30,
+ });
+
+ return (
+
+ Family Photos
+
+
+ );
+}
+```
+
+Create `lib/db.ts`:
+
+```typescript
+import { PrismaClient } from '@prisma/client';
+
+const globalForPrisma = global as unknown as { prisma: PrismaClient };
+
+export const prisma =
+ globalForPrisma.prisma ||
+ new PrismaClient({
+ log: ['error'],
+ });
+
+if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
+```
+
+### 8. Run Development Server
+
+```bash
+npm run dev
+```
+
+Open http://localhost:3000
+
+---
+
+## 📁 Project Structure
+
+```
+punimtag-viewer/
+├── app/ # Next.js 14 App Router
+│ ├── layout.tsx # Root layout
+│ ├── page.tsx # Home page (photo grid)
+│ ├── photo/[id]/ # Photo detail page
+│ ├── people/ # People browser
+│ ├── tags/ # Tags browser
+│ └── search/ # Search page
+├── components/ # React components
+│ ├── ui/ # shadcn components
+│ ├── PhotoGrid.tsx # Photo grid
+│ ├── PhotoCard.tsx # Photo card
+│ └── Lightbox.tsx # Photo viewer
+├── lib/ # Utilities
+│ ├── db.ts # Prisma client
+│ ├── queries.ts # Database queries
+│ └── utils.ts # Helpers
+├── prisma/
+│ └── schema.prisma # Database schema
+├── .env # Environment variables
+└── package.json
+```
+
+---
+
+## 🎨 Install UI Components
+
+```bash
+# Install shadcn/ui components as needed
+npx shadcn-ui@latest add button
+npx shadcn-ui@latest add card
+npx shadcn-ui@latest add input
+npx shadcn-ui@latest add dialog
+npx shadcn-ui@latest add dropdown-menu
+npx shadcn-ui@latest add popover
+npx shadcn-ui@latest add select
+npx shadcn-ui@latest add calendar
+npx shadcn-ui@latest add badge
+npx shadcn-ui@latest add skeleton
+```
+
+---
+
+## 🔐 Set Up Read-Only Database User
+
+On your PostgreSQL server:
+
+```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;
+```
+
+---
+
+## 🚢 Deploy to Vercel (5 Minutes)
+
+### 1. Push to GitHub
+
+```bash
+git init
+git add .
+git commit -m "Initial commit"
+git remote add origin https://github.com/yourusername/punimtag-viewer.git
+git push -u origin main
+```
+
+### 2. Deploy to Vercel
+
+```bash
+# Install Vercel CLI
+npm i -g vercel
+
+# Login
+vercel login
+
+# Deploy
+vercel
+
+# Set environment variables
+vercel env add DATABASE_URL
+```
+
+Or use the Vercel dashboard:
+1. Go to https://vercel.com
+2. Import your GitHub repository
+3. Add environment variable `DATABASE_URL`
+4. Deploy!
+
+---
+
+## 🎯 Next Steps
+
+### Essential Features (Week 1)
+- [ ] Create photo grid with infinite scroll
+- [ ] Add photo lightbox/viewer
+- [ ] Implement search by people
+- [ ] Add date range filter
+- [ ] Create tag browser
+
+### Nice-to-Have Features (Week 2-3)
+- [ ] Add favorites system
+- [ ] Implement timeline view
+- [ ] Add photo sharing
+- [ ] Create mobile navigation
+- [ ] Add authentication (if needed)
+
+### Polish (Week 4)
+- [ ] Optimize performance (Lighthouse > 90)
+- [ ] Add animations
+- [ ] Test responsive design
+- [ ] Add error handling
+- [ ] Write documentation
+
+---
+
+## 📚 Key Documentation
+
+- **Full Plan:** `docs/PHOTO_VIEWER_PLAN.md`
+- **Next.js Docs:** https://nextjs.org/docs
+- **Prisma Docs:** https://www.prisma.io/docs
+- **shadcn/ui:** https://ui.shadcn.com/
+
+---
+
+## 🆘 Troubleshooting
+
+### "Can't connect to database"
+```bash
+# Test connection manually
+psql -U viewer_readonly -d punimtag -h localhost
+
+# Check .env file has correct credentials
+cat .env | grep DATABASE_URL
+```
+
+### "Module not found: @/..."
+```bash
+# Check tsconfig.json has paths configured
+# Should have: "@/*": ["./*"]
+```
+
+### "Prisma Client not generated"
+```bash
+npx prisma generate
+```
+
+### "Images not loading"
+```bash
+# Check file paths in database
+# Configure Next.js image domains in next.config.js
+```
+
+---
+
+## 💡 Pro Tips
+
+1. **Use React Server Components** - They're faster and simpler
+2. **Enable Image Optimization** - Configure Next.js Image component
+3. **Add Loading States** - Use Suspense and loading.tsx files
+4. **Implement Infinite Scroll** - Use TanStack Query's useInfiniteQuery
+5. **Cache Aggressively** - Photos don't change often
+6. **Test Mobile First** - Most users will view on phones
+7. **Add Dark Mode** - Use next-themes package
+8. **Monitor Performance** - Use Vercel Analytics
+
+---
+
+## 🎨 Design Resources
+
+**Color Schemes:**
+- Modern Blue: `#3b82f6`
+- Clean White: `#ffffff`
+- Dark Mode: `#0f172a`
+
+**Fonts:**
+- Inter (Google Fonts)
+- SF Pro (System font)
+
+**Inspiration:**
+- Google Photos
+- iCloud Photos
+- Unsplash
+
+---
+
+## 🤝 Need Help?
+
+- Read the full plan: `docs/PHOTO_VIEWER_PLAN.md`
+- Check Next.js docs: https://nextjs.org/docs
+- Join Next.js Discord: https://nextjs.org/discord
+- Check shadcn/ui examples: https://ui.shadcn.com/examples
+
+---
+
+**Ready to build something awesome!** 🚀
+
+Start with Phase 1 in the full plan and iterate based on user feedback.
+
diff --git a/viewer-frontend/docs/PHOTO_VIEWER_README.md b/viewer-frontend/docs/PHOTO_VIEWER_README.md
new file mode 100644
index 0000000..3697230
--- /dev/null
+++ b/viewer-frontend/docs/PHOTO_VIEWER_README.md
@@ -0,0 +1,384 @@
+# PunimTag Photo Viewer - Documentation Index
+
+## 📖 Welcome
+
+This directory contains comprehensive documentation for the **PunimTag Photo Viewer** project - a modern, fast, and beautiful photo gallery website that connects to your existing PunimTag PostgreSQL database.
+
+---
+
+## 📑 Documentation Structure
+
+### 🎯 Start Here
+
+| Document | Description | Who Should Read |
+|----------|-------------|-----------------|
+| **[Executive Summary](PHOTO_VIEWER_EXECUTIVE_SUMMARY.md)** | High-level overview, recommendations, and decision guide | 👔 Decision makers, Project sponsors |
+| **[Quick Start Guide](PHOTO_VIEWER_QUICKSTART.md)** | Get up and running in 5 minutes | 👨💻 Developers (DIY approach) |
+
+### 📚 Detailed Documentation
+
+| Document | Description | Who Should Read |
+|----------|-------------|-----------------|
+| **[Complete Plan](PHOTO_VIEWER_PLAN.md)** | 20+ page detailed plan with all features, phases, and specifications | 👨💻 Developers, 👷 Project managers, 🏗️ Architects |
+| **[Architecture](PHOTO_VIEWER_ARCHITECTURE.md)** | System architecture, diagrams, data flows, and technical design | 👨💻 Developers, 🏗️ Architects |
+
+---
+
+## 🚀 Quick Navigation
+
+### I want to...
+
+| Goal | Document | Section |
+|------|----------|---------|
+| **Understand what this is** | [Executive Summary](PHOTO_VIEWER_EXECUTIVE_SUMMARY.md) | Overview |
+| **Make a decision on whether to proceed** | [Executive Summary](PHOTO_VIEWER_EXECUTIVE_SUMMARY.md) | Decision Matrix |
+| **Estimate costs** | [Executive Summary](PHOTO_VIEWER_EXECUTIVE_SUMMARY.md) | Cost Analysis |
+| **See the timeline** | [Executive Summary](PHOTO_VIEWER_EXECUTIVE_SUMMARY.md) | Timeline |
+| **Start building right now** | [Quick Start Guide](PHOTO_VIEWER_QUICKSTART.md) | All |
+| **Understand the technology choices** | [Complete Plan](PHOTO_VIEWER_PLAN.md) | Technology Stack |
+| **See the architecture** | [Architecture](PHOTO_VIEWER_ARCHITECTURE.md) | All |
+| **Learn about security** | [Complete Plan](PHOTO_VIEWER_PLAN.md) | Security & Access Control |
+| **Understand deployment options** | [Complete Plan](PHOTO_VIEWER_PLAN.md) | Deployment Options |
+| **See the roadmap** | [Complete Plan](PHOTO_VIEWER_PLAN.md) | Development Roadmap |
+| **Compare with admin interface** | [Architecture](PHOTO_VIEWER_ARCHITECTURE.md) | Comparison Table |
+
+---
+
+## 🎯 Project Overview
+
+### What Is This?
+
+A **modern, lightweight photo viewing website** that allows family members to browse and search photos from your PunimTag database without admin access.
+
+### Key Features
+
+- ✅ **Beautiful Photo Grid** - Responsive, infinite scroll gallery
+- ✅ **Powerful Search** - Search by people, dates, tags
+- ✅ **Photo Lightbox** - Full-size viewing with metadata
+- ✅ **Mobile Optimized** - Works great on all devices
+- ✅ **Fast Loading** - Modern optimizations (Lighthouse >90)
+- ✅ **Read-Only** - Safe access to your database
+
+### Technology Stack
+
+- **Frontend:** Next.js 14 + React + TypeScript
+- **UI:** shadcn/ui + Tailwind CSS
+- **Database:** PostgreSQL (existing PunimTag DB)
+- **ORM:** Prisma (type-safe queries)
+- **State:** TanStack Query (React Query)
+- **Hosting:** Vercel (recommended) or Self-hosted
+
+---
+
+## 📊 Documentation at a Glance
+
+### Executive Summary (5 min read)
+**File:** `PHOTO_VIEWER_EXECUTIVE_SUMMARY.md`
+
+**Contains:**
+- ✅ What we're building and why
+- ✅ Technical approach and recommendations
+- ✅ Cost analysis ($0-20/month hosting)
+- ✅ Timeline (6-12 weeks)
+- ✅ Decision criteria (go/no-go)
+- ✅ FAQ and next steps
+
+**Best For:** Decision makers who need high-level overview
+
+---
+
+### Quick Start Guide (10 min read)
+**File:** `PHOTO_VIEWER_QUICKSTART.md`
+
+**Contains:**
+- ✅ 5-minute project setup
+- ✅ Step-by-step installation
+- ✅ Database configuration
+- ✅ First component creation
+- ✅ Deployment instructions
+- ✅ Troubleshooting tips
+
+**Best For:** Developers ready to start coding
+
+---
+
+### Complete Plan (60 min read)
+**File:** `PHOTO_VIEWER_PLAN.md`
+
+**Contains:**
+- ✅ Comprehensive feature specifications
+- ✅ Detailed technology stack justifications
+- ✅ Complete architecture design
+- ✅ UI/UX design system
+- ✅ Performance optimization strategies
+- ✅ Security implementation
+- ✅ 6-phase development roadmap
+- ✅ Testing strategy
+- ✅ Deployment options
+- ✅ Cost estimates
+- ✅ Risk analysis
+
+**Best For:** Developers, project managers, technical leads
+
+---
+
+### Architecture (30 min read)
+**File:** `PHOTO_VIEWER_ARCHITECTURE.md`
+
+**Contains:**
+- ✅ System architecture diagrams
+- ✅ Data flow diagrams
+- ✅ Component hierarchy
+- ✅ Technology stack layers
+- ✅ Deployment architectures
+- ✅ Security architecture
+- ✅ Performance optimization
+- ✅ Image serving strategy
+- ✅ Responsive design strategy
+- ✅ Error handling flow
+
+**Best For:** Developers, architects, technical reviewers
+
+---
+
+## 🎨 Project Highlights
+
+### Design Philosophy
+
+**Modern. Clean. Fast.**
+
+- Photo-first design (content is king)
+- Minimalist interface (photos speak for themselves)
+- Smooth animations (delightful interactions)
+- Mobile-first approach (works everywhere)
+- Accessible (WCAG AA compliant)
+
+### Performance Targets
+
+| Metric | Target |
+|--------|--------|
+| Lighthouse Score | >90 |
+| Page Load Time | <2s |
+| Image Load Time | <1s |
+| API Response Time | <200ms |
+
+### Browser Support
+
+- ✅ Chrome/Edge (last 2 versions)
+- ✅ Firefox (last 2 versions)
+- ✅ Safari (last 2 versions)
+- ✅ Mobile Safari (iOS 14+)
+- ✅ Chrome Mobile (Android 10+)
+
+---
+
+## 💰 Cost Summary
+
+### Development
+
+| Approach | Cost | Timeline |
+|----------|------|----------|
+| DIY | Your time (200-300 hrs) | 12 weeks |
+| Freelancer | $18K-28K | 8-12 weeks |
+| Agency | $30K-50K | 6-10 weeks |
+
+### Hosting (Monthly)
+
+| Option | Cost | Best For |
+|--------|------|----------|
+| Vercel Free | $0 | Small families |
+| Vercel Pro | $20 | Medium usage |
+| VPS | $12-24 | Self-hosted |
+| Home Server | $0* | Local network |
+
+**Recommended:** Vercel Free Tier → upgrade if needed
+
+---
+
+## ⏱️ Timeline
+
+### Fast Track: 6 Weeks
+1. **Weeks 1-2:** Foundation (setup, DB, layout)
+2. **Weeks 3-4:** Core Features (gallery, search)
+3. **Week 5:** Polish (responsive, animations)
+4. **Week 6:** Launch (deploy, test)
+
+### Standard Track: 12 Weeks
+1. **Weeks 1-2:** Foundation + Auth
+2. **Weeks 3-4:** Core Features
+3. **Weeks 5-6:** Search & Filters
+4. **Weeks 7-8:** Polish & Optimization
+5. **Weeks 9-10:** Advanced Features
+6. **Weeks 11-12:** Testing & Launch
+
+---
+
+## 🚦 Decision Guide
+
+### ✅ Proceed If:
+- You have PostgreSQL database with photos
+- You want to share photos with family
+- You have budget/time for development
+- You value UX and modern design
+- You need mobile-optimized browsing
+
+### ❌ Wait If:
+- Database has <100 photos
+- Happy with admin interface for viewing
+- Budget constraints (<$500)
+- Don't need mobile experience
+- Need write operations (upload/edit)
+
+---
+
+## 🎬 Getting Started
+
+### Option A: Hire Developer
+1. Share this documentation with developer
+2. Approve budget and timeline
+3. Provide read-only database access
+4. Review progress weekly
+5. Test and provide feedback
+6. Launch!
+
+### Option B: DIY
+1. Read [Quick Start Guide](PHOTO_VIEWER_QUICKSTART.md)
+2. Follow setup instructions
+3. Build phase by phase
+4. Test with real users
+5. Deploy and iterate
+
+### Option C: Hybrid
+1. Hire developer for initial setup
+2. Learn and customize yourself
+3. Developer available for support
+
+---
+
+## 📚 Additional Resources
+
+### External Documentation
+- **Next.js:** https://nextjs.org/docs
+- **Prisma:** https://www.prisma.io/docs
+- **shadcn/ui:** https://ui.shadcn.com/
+- **TanStack Query:** https://tanstack.com/query/latest
+- **Tailwind CSS:** https://tailwindcss.com/docs
+
+### Inspiration
+- **Google Photos:** https://photos.google.com
+- **iCloud Photos:** https://www.icloud.com/photos
+- **Unsplash:** https://unsplash.com
+
+### Example Projects
+- Next.js + Prisma: https://github.com/vercel/next.js/tree/canary/examples/with-prisma
+- Photo Gallery: https://github.com/topics/photo-gallery
+
+---
+
+## 🆘 Support & Help
+
+### Common Questions
+
+**Q: Can I use this with SQLite?**
+A: Yes, but PostgreSQL is recommended for better performance with large photo collections.
+
+**Q: Do I need to migrate my database?**
+A: No! The viewer reads from your existing PunimTag database.
+
+**Q: Can I customize the design?**
+A: Absolutely! All colors, fonts, and layouts are customizable.
+
+**Q: What if I have 100K photos?**
+A: The architecture scales well with proper indexing and pagination.
+
+**Q: Is authentication required?**
+A: No, it's optional. Configure based on your privacy needs.
+
+### Getting Help
+
+1. Check the documentation (you're reading it!)
+2. Review the FAQ in Executive Summary
+3. Check Next.js documentation
+4. Search GitHub issues/discussions
+5. Ask in Next.js Discord community
+
+---
+
+## 📝 Version History
+
+| Version | Date | Changes |
+|---------|------|---------|
+| 1.0 | Nov 14, 2025 | Initial comprehensive plan |
+
+---
+
+## 🙏 Credits
+
+**Planned and documented by:** AI Assistant (Claude Sonnet 4.5)
+**For:** PunimTag Project
+**Purpose:** Family photo sharing and browsing
+
+---
+
+## ✅ Checklist: Before Starting Development
+
+### Planning Phase
+- [ ] Read Executive Summary
+- [ ] Review Complete Plan
+- [ ] Understand Architecture
+- [ ] Make go/no-go decision
+- [ ] Approve budget
+- [ ] Approve timeline
+
+### Technical Preparation
+- [ ] Verify PostgreSQL database access
+- [ ] Create read-only database user
+- [ ] Test database connection
+- [ ] Identify photo storage location
+- [ ] Choose hosting platform (Vercel vs self-hosted)
+- [ ] Set up development environment
+
+### Team Preparation
+- [ ] Assign developer(s)
+- [ ] Set up project management (GitHub, etc.)
+- [ ] Schedule regular check-ins
+- [ ] Identify test users
+- [ ] Plan feedback process
+
+### Ready to Start? 🚀
+- [ ] Follow [Quick Start Guide](PHOTO_VIEWER_QUICKSTART.md)
+- [ ] Begin Phase 1 of [Complete Plan](PHOTO_VIEWER_PLAN.md)
+
+---
+
+## 🎯 Success Criteria
+
+This project will be successful when:
+
+✅ **Users love it** - Family members prefer it over admin interface for browsing
+✅ **It's fast** - Lighthouse score >90, loads in <2 seconds
+✅ **It's beautiful** - Modern design, smooth animations
+✅ **It works everywhere** - Great experience on mobile, tablet, desktop
+✅ **It's reliable** - 99.9% uptime, handles errors gracefully
+✅ **It's maintainable** - Clean code, good documentation
+
+---
+
+## 🎊 Let's Build Something Amazing!
+
+You now have everything you need to create a world-class photo viewing experience for your family. The plan is comprehensive, the technology is proven, and the path is clear.
+
+**Choose your starting point:**
+- 👔 **Decision maker?** → Read [Executive Summary](PHOTO_VIEWER_EXECUTIVE_SUMMARY.md)
+- 👨💻 **Developer?** → Follow [Quick Start Guide](PHOTO_VIEWER_QUICKSTART.md)
+- 🏗️ **Architect?** → Study [Architecture](PHOTO_VIEWER_ARCHITECTURE.md)
+- 👷 **Project manager?** → Review [Complete Plan](PHOTO_VIEWER_PLAN.md)
+
+**Ready when you are!** 🚀
+
+---
+
+**Questions?** Start with the Executive Summary and work your way through the documentation. Everything you need is here.
+
+**Good luck!** 🎉
+
diff --git a/viewer-frontend/docs/PREREQUISITES.md b/viewer-frontend/docs/PREREQUISITES.md
new file mode 100644
index 0000000..224eabc
--- /dev/null
+++ b/viewer-frontend/docs/PREREQUISITES.md
@@ -0,0 +1,316 @@
+# Prerequisites
+
+This document lists all software, tools, and services required to run the PunimTag Photo Viewer application.
+
+## Required Prerequisites
+
+### 1. Node.js
+- **Version:** Node.js 20.9.0 or higher
+- **Why:** Next.js 16 requires Node.js >=20.9.0
+- **Installation:**
+ ```bash
+ # Using nvm (recommended)
+ nvm install 20
+ nvm use 20
+
+ # Or download from https://nodejs.org/
+ ```
+- **Verification:**
+ ```bash
+ node --version # Should show v20.x.x or higher
+ npm --version
+ ```
+
+### 2. PostgreSQL Database
+- **Version:** PostgreSQL 12 or higher
+- **Why:** Required for storing photo metadata, faces, people, tags, and authentication data
+- **Installation:**
+ ```bash
+ # Ubuntu/Debian
+ sudo apt update
+ sudo apt install postgresql postgresql-contrib
+
+ # macOS
+ brew install postgresql
+
+ # Windows
+ # Download from https://www.postgresql.org/download/windows/
+ ```
+- **Verification:**
+ ```bash
+ psql --version
+ ```
+- **Requirements:**
+ - PunimTag database schema must be set up
+ - Database must contain the `photos` table with `media_type` field
+ - See [Database Setup](../README.md#database-setup) for details
+
+### 3. Database Users
+- **Read-only user** (required): For querying photos, faces, people, tags
+- **Write user** (optional): For face identification and photo uploads
+- **Setup:** See [Database Setup](../README.md#database-setup) in README
+
+### 4. npm (Node Package Manager)
+- **Included with:** Node.js installation
+- **Why:** Required to install project dependencies
+- **Verification:**
+ ```bash
+ npm --version
+ ```
+
+## Optional Prerequisites
+
+### 5. FFmpeg (for Video Thumbnails)
+- **Version:** Any recent version
+- **Why:** Generates thumbnails for video files in the photo grid
+- **Installation:**
+ ```bash
+ # Ubuntu/Debian
+ sudo apt update
+ sudo apt install ffmpeg
+
+ # macOS
+ brew install ffmpeg
+
+ # Windows
+ # Download from https://ffmpeg.org/download.html
+ # Or: choco install ffmpeg
+ ```
+- **Verification:**
+ ```bash
+ ffmpeg -version
+ ```
+- **What happens without it:**
+ - Videos will display a placeholder image (gray box with play icon)
+ - Videos will still be viewable, but without thumbnails in the grid
+ - Application continues to work normally
+- **See:** [FFmpeg Setup Guide](./FFMPEG_SETUP.md) for detailed instructions
+
+### 6. libvips (for Image Watermarking)
+- **Version:** Any recent version
+- **Why:** Required by the `sharp` npm package for image processing and watermarking
+- **Installation:**
+ ```bash
+ # Ubuntu/Debian
+ sudo apt update
+ sudo apt install libvips-dev
+
+ # macOS
+ brew install vips
+
+ # Windows
+ # Download from https://www.libvips.org/install.html
+ # Or: choco install vips
+ ```
+- **Verification:**
+ ```bash
+ # Check if library is available
+ ldconfig -p | grep vips
+
+ # Or check sharp installation
+ cd viewer-frontend
+ npm rebuild sharp
+ ```
+- **What happens without it:**
+ - Image watermarking will be disabled
+ - Images will be served without watermarks (original images still work)
+ - Application continues to work normally
+ - You'll see a warning in the console: "Sharp library not available"
+- **Note:** After installing libvips, you may need to rebuild the sharp package:
+ ```bash
+ cd viewer-frontend
+ npm rebuild sharp
+ ```
+
+### 7. Resend API Key (for Email Verification)
+- **Why:** Required for email verification when users register
+- **Setup:**
+ 1. Sign up at [resend.com](https://resend.com)
+ 2. Create an API key in your dashboard
+ 3. Add to `.env`:
+ ```bash
+ RESEND_API_KEY="re_your_api_key_here"
+ RESEND_FROM_EMAIL="noreply@yourdomain.com"
+ ```
+- **What happens without it:**
+ - Email verification will not work
+ - Users cannot verify their email addresses
+ - Registration may fail or require manual verification
+
+### 8. Network-Accessible Storage (for Photo Uploads)
+- **Why:** Required for storing uploaded photos before admin approval
+- **Options:**
+ - Database server via SSHFS
+ - Network share (SMB/NFS)
+ - Local directory (if database and web server are on same machine)
+- **Configuration:** Set `UPLOAD_DIR` or `PENDING_PHOTOS_DIR` in `.env`
+- **See:** [Network Share Setup](./NETWORK_SHARE_SETUP.md) for detailed instructions
+
+## Development Tools (Optional)
+
+### 9. Git
+- **Why:** Version control (if cloning from repository)
+- **Installation:**
+ ```bash
+ # Ubuntu/Debian
+ sudo apt install git
+
+ # macOS
+ brew install git
+
+ # Windows
+ # Download from https://git-scm.com/download/win
+ ```
+
+### 10. OpenSSL (for Generating Secrets)
+- **Why:** Used to generate secure `NEXTAUTH_SECRET`
+- **Usually pre-installed** on Linux/macOS
+- **Windows:** Usually included with Git for Windows
+- **Usage:**
+ ```bash
+ openssl rand -base64 32
+ ```
+
+## System Requirements
+
+### Operating System
+- **Linux:** Ubuntu 20.04+, Debian 11+, or similar
+- **macOS:** macOS 11 (Big Sur) or later
+- **Windows:** Windows 10 or later (WSL2 recommended for development)
+
+### Memory
+- **Minimum:** 2GB RAM
+- **Recommended:** 4GB+ RAM (for image processing and video thumbnail generation)
+
+### Disk Space
+- **Minimum:** 500MB for application
+- **Additional:** Space for:
+ - Video thumbnail cache (`.cache/video-thumbnails/`)
+ - Uploaded photos (if using local storage)
+ - Node modules (~500MB)
+
+### Network
+- **Required:** Access to PostgreSQL database
+- **Optional:** Internet access for:
+ - npm package installation
+ - Resend API (email verification)
+ - External photo storage (SharePoint, CDN, etc.)
+
+## Quick Verification Checklist
+
+Run these commands to verify all prerequisites:
+
+```bash
+# Node.js
+node --version # Should be v20.x.x or higher
+npm --version
+
+# PostgreSQL
+psql --version
+
+# FFmpeg (optional)
+ffmpeg -version # Should show version or "command not found" (OK if optional)
+
+# libvips (optional, for image watermarking)
+ldconfig -p | grep vips # Should show libvips or nothing (OK if optional)
+
+# OpenSSL (for generating secrets)
+openssl version
+
+# Git (optional)
+git --version
+```
+
+## Installation Order
+
+1. **Install Node.js 20+**
+2. **Install PostgreSQL** and set up database
+3. **Create database users** (read-only, write if needed)
+4. **Clone/download project**
+5. **Install all dependencies** (recommended):
+ ```bash
+ npm run install:deps
+ ```
+ This automated script will:
+ - Install npm dependencies
+ - Set up Sharp library with proper library paths
+ - Generate Prisma clients
+ - Check and optionally install system dependencies (libvips, FFmpeg)
+
+ **Or manually:**
+ ```bash
+ npm install
+ npm run prisma:generate:all
+ # Optional: Install system dependencies
+ sudo apt install libvips-dev ffmpeg # Ubuntu/Debian
+ ```
+6. **Set up environment variables** (`.env` file)
+7. **Set up Resend API** (optional, for email verification)
+8. **Configure upload directory** (if using photo uploads)
+
+## Troubleshooting
+
+### Node.js Version Issues
+If you see errors about Node.js version:
+```bash
+# Check current version
+node --version
+
+# Install/switch to Node.js 20 using nvm
+nvm install 20
+nvm use 20
+```
+
+### PostgreSQL Connection Issues
+- Verify PostgreSQL is running: `sudo systemctl status postgresql`
+- Check database exists: `psql -U postgres -l`
+- Verify connection string in `.env`
+
+### FFmpeg Not Found
+- See [FFmpeg Setup Guide](./FFMPEG_SETUP.md)
+- Application will work without it (placeholders will be shown)
+
+### Sharp/libvips Errors
+If you see errors like "Could not load the sharp module" or "libvips-cpp.so.8.17.3: cannot open shared object file":
+
+**Solution:** The install script (`npm run install:deps`) automatically sets up Sharp with the correct library paths. If you installed manually:
+
+```bash
+# Install libvips development libraries
+sudo apt install libvips-dev # Ubuntu/Debian
+brew install vips # macOS
+
+# The wrapper script (scripts/with-sharp-libpath.sh) handles library paths automatically
+# No manual rebuild needed - it's configured in package.json scripts
+```
+
+- The application uses a wrapper script to set `LD_LIBRARY_PATH` automatically
+- If Sharp still fails, watermarking will be disabled but the app will continue working
+- Images will be served without watermarks if Sharp is unavailable
+
+### Permission Denied Errors
+- Check database user permissions: `npm run check:permissions`
+- See [Database Setup](../README.md#database-setup) in README
+
+## Next Steps
+
+After installing prerequisites:
+1. Follow the [Quick Start Guide](../README.md#-quick-start) in README
+2. Set up [Environment Variables](../README.md#installation)
+3. Configure [Database Permissions](../README.md#database-setup)
+4. See [FFmpeg Setup](./FFMPEG_SETUP.md) if you want video thumbnails
+5. See [Network Share Setup](./NETWORK_SHARE_SETUP.md) if using photo uploads
+
+## Related Documentation
+
+- [README.md](../README.md) - Main setup guide
+- [FFmpeg Setup Guide](./FFMPEG_SETUP.md) - Video thumbnail setup
+- [Network Share Setup](./NETWORK_SHARE_SETUP.md) - Photo upload storage
+- [Video Viewing Analysis](./VIDEO_VIEWING_ANALYSIS.md) - Video feature details
+
+
+
+
+
+
+
diff --git a/viewer-frontend/docs/VIDEO_VIEWING_ANALYSIS.md b/viewer-frontend/docs/VIDEO_VIEWING_ANALYSIS.md
new file mode 100644
index 0000000..8922732
--- /dev/null
+++ b/viewer-frontend/docs/VIDEO_VIEWING_ANALYSIS.md
@@ -0,0 +1,267 @@
+# Video Viewing Analysis
+
+## Overview
+This document analyzes how videos can be viewed in the PunimTag Viewer application, now that the `photos` table has a `media_type` field that can be either "image" or "video".
+
+## Current Architecture
+
+### Database Schema
+- **Photo Model**: Currently missing `media_type` field in Prisma schema (needs to be added)
+- **Database**: Has `media_type` field at line 44 (Text, Default: "image", Values: "image" or "video", Indexed)
+
+### Current Photo Display Flow
+
+1. **PhotoGrid Component** (`components/PhotoGrid.tsx`)
+ - Displays photos in a grid using Next.js `Image` component
+ - Uses `getImageSrc()` to determine image source (URL vs API proxy)
+ - Handles face detection hover tooltips
+ - Clicking opens photo in viewer
+
+2. **PhotoViewerClient Component** (`components/PhotoViewerClient.tsx`)
+ - Full-screen photo viewer with zoom, pan, navigation
+ - Uses Next.js `Image` component exclusively
+ - Supports slideshow, face identification, reporting
+ - Keyboard navigation (arrows, zoom, escape)
+
+3. **Image API Route** (`app/api/photos/[id]/image/route.ts`)
+ - Serves images from file system or URLs
+ - Applies watermarks for non-logged-in users
+ - Uses Sharp for image processing
+ - Returns image buffers with appropriate content types
+
+4. **Data Flow**
+ - Photos queried via Prisma from `photos` table
+ - Includes faces, tags, and related data
+ - Serialized and sent to frontend
+ - Path can be HTTP/HTTPS URL or file system path
+
+## Required Changes for Video Support
+
+### 1. Database Schema Update
+
+**File**: `prisma/schema.prisma`
+
+Add `media_type` field to Photo model:
+```prisma
+model Photo {
+ id Int @id @default(autoincrement())
+ path String @unique
+ filename String
+ mediaType String @default("image") @map("media_type") // "image" or "video"
+ dateAdded DateTime @default(now()) @map("date_added")
+ dateTaken DateTime? @map("date_taken") @db.Date
+ processed Boolean @default(false)
+ // ... rest of fields
+}
+```
+
+**Action Required**: Run `npx prisma db pull` to sync schema, then `npx prisma generate`
+
+### 2. PhotoGrid Component Updates
+
+**File**: `components/PhotoGrid.tsx`
+
+**Changes Needed**:
+- Detect `media_type` field from photo data
+- For videos: Render video thumbnail or `` element with `poster` attribute
+- For images: Keep existing `Image` component
+- Add video play icon overlay on thumbnails
+- Handle video click to open in viewer (same as images)
+
+**Key Considerations**:
+- Video thumbnails: Use first frame or generate poster image
+- Grid layout: Videos should maintain aspect ratio like images
+- Hover effects: May need different behavior for videos
+- Face detection: Only applies to images (videos may have faces but detection is image-based)
+
+### 3. PhotoViewerClient Component Updates
+
+**File**: `components/PhotoViewerClient.tsx`
+
+**Changes Needed**:
+- Conditional rendering: `` for images, `` for videos
+- Video player controls: Native HTML5 video controls or custom controls
+- Video features:
+ - Play/pause
+ - Seek bar
+ - Volume control
+ - Fullscreen
+ - Playback speed
+- Zoom/pan: May not apply to videos (or only to video frame, not playback)
+- Slideshow: Videos should auto-play when in slideshow mode
+- Navigation: Previous/next should work with mixed media types
+
+**Key Considerations**:
+- Video autoplay: Browser policies may prevent autoplay
+- Video loading: Larger files, need loading states
+- Video formats: Support common formats (MP4, WebM, MOV)
+- Video metadata: Duration, resolution, codec info
+- Face detection: Disable for videos (or handle differently)
+
+### 4. API Route Updates
+
+**File**: `app/api/photos/[id]/image/route.ts`
+
+**Option A: Rename to `/api/photos/[id]/media/route.ts`**
+- Handle both images and videos
+- Return appropriate content type based on `media_type`
+- For videos: Stream video file directly
+- For images: Keep existing logic
+
+**Option B: Create separate route `/api/photos/[id]/video/route.ts`**
+- Dedicated video serving endpoint
+- Stream video files
+- Support range requests for video seeking
+- Handle video formats and codecs
+
+**Recommended**: Option A (unified media route) for consistency
+
+**Key Considerations**:
+- Video streaming: Support HTTP range requests (206 Partial Content)
+- Video formats: Detect and serve appropriate MIME types
+- Watermarks: May need video watermarking (more complex than images)
+- Caching: Different cache strategies for videos vs images
+- File size: Videos are larger, need efficient streaming
+
+### 5. Video Thumbnail Generation
+
+**Options**:
+1. **Generate on upload**: Extract first frame during upload process
+2. **Generate on demand**: Extract first frame when needed (slower)
+3. **Use video poster**: HTML5 video `poster` attribute
+4. **Store thumbnail path**: Add `thumbnail_path` field to Photo model
+
+**Recommended**: Use HTML5 video `poster` attribute initially, generate thumbnails on demand if needed
+
+### 6. TypeScript Type Updates
+
+**Files**: All files using Photo type
+
+**Changes Needed**:
+- Update Photo interface to include `mediaType: string`
+- Create type guards: `isVideo(photo: Photo): boolean`
+- Update PhotoWithDetails, PhotoWithPeople interfaces
+
+### 7. Query Updates
+
+**Files**: `lib/queries.ts`, `app/api/search/route.ts`, etc.
+
+**Changes Needed**:
+- Include `mediaType` in Prisma queries
+- Filter by media type if needed (future feature)
+- Ensure `mediaType` is serialized and sent to frontend
+
+## Technical Considerations
+
+### Video Formats Support
+- **MP4 (H.264)**: Widely supported, recommended
+- **WebM**: Good browser support, smaller files
+- **MOV**: Apple format, may need conversion
+- **Codec**: H.264 for compatibility
+
+### Performance Considerations
+- **Lazy loading**: Videos should load on demand, not all at once
+- **Thumbnails**: Use lightweight thumbnails in grid
+- **Streaming**: Implement proper video streaming with range requests
+- **Caching**: Cache video thumbnails, not full videos
+- **CDN**: Consider CDN for video delivery if many videos
+
+### Browser Compatibility
+- **HTML5 Video**: Modern browsers support `` element
+- **Autoplay policies**: Browsers restrict autoplay, need user interaction
+- **Mobile**: Ensure video controls work on touch devices
+- **Bandwidth**: Consider adaptive bitrate streaming for large videos
+
+### User Experience
+- **Loading states**: Show loading indicators for videos
+- **Error handling**: Handle video load errors gracefully
+- **Playback controls**: Intuitive controls for play/pause/seek
+- **Mixed media**: Smooth transitions between images and videos in slideshow
+- **Keyboard shortcuts**: Space for play/pause, arrows for navigation
+
+### Security Considerations
+- **File validation**: Ensure video files are safe
+- **Path validation**: Prevent path traversal attacks
+- **Content type**: Verify actual file type matches `media_type`
+- **Access control**: Same permissions as images
+
+## Implementation Approach
+
+### Phase 1: Basic Video Display
+1. Update Prisma schema with `mediaType` field
+2. Update PhotoGrid to detect and render videos
+3. Update PhotoViewerClient to show video player
+4. Create/update API route to serve videos
+5. Test with sample videos
+
+### Phase 2: Enhanced Features
+1. Video thumbnails/poster images
+2. Custom video controls
+3. Video metadata display
+4. Improved loading states
+5. Error handling
+
+### Phase 3: Optimization
+1. Video streaming optimization
+2. Thumbnail generation
+3. Caching strategies
+4. Performance monitoring
+
+## Files That Need Changes
+
+### Core Components
+- `components/PhotoGrid.tsx` - Grid display with video support
+- `components/PhotoViewerClient.tsx` - Video player in viewer
+- `prisma/schema.prisma` - Add mediaType field
+
+### API Routes
+- `app/api/photos/[id]/image/route.ts` - Rename/update to handle videos
+- OR `app/api/photos/[id]/video/route.ts` - New video route
+
+### Utilities
+- `lib/queries.ts` - Include mediaType in queries
+- `lib/serialize.ts` - Serialize mediaType field
+
+### Types
+- All files importing Photo type from Prisma
+
+## Testing Considerations
+
+1. **Mixed media**: Test grid with both images and videos
+2. **Video formats**: Test MP4, WebM, MOV
+3. **Video sizes**: Test small and large video files
+4. **Network**: Test with slow connections
+5. **Mobile**: Test on mobile devices
+6. **Browser**: Test on Chrome, Firefox, Safari, Edge
+7. **Slideshow**: Test slideshow with mixed media
+8. **Navigation**: Test prev/next with videos
+9. **Watermarks**: Test watermark behavior (if applicable to videos)
+
+## Open Questions
+
+1. **Video watermarks**: Should videos have watermarks? (More complex than images)
+2. **Face detection**: Should videos support face detection? (Requires frame extraction)
+3. **Video editing**: Any video editing features needed?
+4. **Video metadata**: Display duration, resolution, codec?
+5. **Video upload**: How are videos uploaded? (Same as images?)
+6. **Video processing**: Any server-side video processing needed?
+7. **Thumbnail storage**: Where to store video thumbnails?
+8. **Video transcoding**: Need to transcode videos to different formats?
+
+## Conclusion
+
+Videos can be viewed by:
+1. Adding `mediaType` field to Prisma schema
+2. Conditionally rendering `` elements instead of `` for videos
+3. Creating/updating API route to serve video files with proper streaming
+4. Updating grid and viewer components to handle both media types
+5. Implementing video-specific features (controls, playback, etc.)
+
+The architecture is well-positioned to support videos with minimal changes, as the existing photo viewing infrastructure can be extended to handle videos.
+
+
+
+
+
+
+
diff --git a/viewer-frontend/eslint.config.mjs b/viewer-frontend/eslint.config.mjs
new file mode 100644
index 0000000..b609011
--- /dev/null
+++ b/viewer-frontend/eslint.config.mjs
@@ -0,0 +1,28 @@
+import { defineConfig, globalIgnores } from "eslint/config";
+import nextVitals from "eslint-config-next/core-web-vitals";
+import nextTs from "eslint-config-next/typescript";
+
+const eslintConfig = defineConfig([
+ ...nextVitals,
+ ...nextTs,
+ // Override default ignores of eslint-config-next.
+ globalIgnores([
+ // Default ignores of eslint-config-next:
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ ]),
+ {
+ linterOptions: {
+ reportUnusedDisableDirectives: false,
+ },
+ rules: {
+ 'max-len': 'off',
+ '@typescript-eslint/no-unused-vars': 'warn',
+ 'no-unused-vars': 'warn',
+ },
+ },
+]);
+
+export default eslintConfig;
diff --git a/viewer-frontend/grant-auth-permissions.sql b/viewer-frontend/grant-auth-permissions.sql
new file mode 100644
index 0000000..2710c62
--- /dev/null
+++ b/viewer-frontend/grant-auth-permissions.sql
@@ -0,0 +1,39 @@
+-- Grant permissions for punimtag_auth database
+-- Run this as PostgreSQL superuser: sudo -u postgres psql -f grant-auth-permissions.sql
+--
+-- BEFORE RUNNING: Edit this file and replace 'your_user' with your actual database username
+-- (e.g., 'viewer_readonly', 'viewer_write', or 'postgres')
+
+-- Step 1: Grant connect permission to the auth database
+-- Replace 'your_user' below with your actual username
+GRANT CONNECT ON DATABASE punimtag_auth TO your_user;
+
+-- Step 2: Connect to the auth database
+\c punimtag_auth
+
+-- Step 3: Grant usage on schema
+GRANT USAGE ON SCHEMA public TO your_user;
+
+-- Step 4: Grant permissions on tables
+GRANT SELECT, INSERT, UPDATE ON TABLE users TO your_user;
+GRANT SELECT, INSERT, UPDATE ON TABLE pending_identifications TO your_user;
+GRANT SELECT, INSERT, UPDATE ON TABLE pending_photos TO your_user;
+GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE inappropriate_photo_reports TO your_user;
+
+-- Step 5: Grant usage on sequences (needed for auto-increment IDs)
+GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO your_user;
+GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO your_user;
+GRANT USAGE, SELECT ON SEQUENCE pending_photos_id_seq TO your_user;
+GRANT USAGE, SELECT ON SEQUENCE inappropriate_photo_reports_id_seq TO your_user;
+
+-- Step 6: Grant on future tables
+ALTER DEFAULT PRIVILEGES IN SCHEMA public
+GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO your_user;
+
+ALTER DEFAULT PRIVILEGES IN SCHEMA public
+GRANT USAGE, SELECT ON SEQUENCES TO your_user;
+
+\echo '✅ Permissions granted!'
+\echo ''
+\echo 'Update your .env file with:'
+\echo 'DATABASE_URL_AUTH="postgresql://your_user:your_password@localhost:5432/punimtag_auth"'
diff --git a/viewer-frontend/grant-delete-permission.sql b/viewer-frontend/grant-delete-permission.sql
new file mode 100644
index 0000000..81e42c2
--- /dev/null
+++ b/viewer-frontend/grant-delete-permission.sql
@@ -0,0 +1,15 @@
+-- Quick fix: Grant DELETE permission on inappropriate_photo_reports table
+-- Run this as PostgreSQL superuser: sudo -u postgres psql -d punimtag_auth -f grant-delete-permission.sql
+--
+-- BEFORE RUNNING: Edit this file and replace 'your_user' with your actual database username
+-- (Check your DATABASE_URL_AUTH environment variable to see which user you're using)
+
+\c punimtag_auth
+
+-- Grant DELETE permission on inappropriate_photo_reports table
+GRANT DELETE ON TABLE inappropriate_photo_reports TO your_user;
+
+\echo '✅ DELETE permission granted on inappropriate_photo_reports table!'
+\echo ''
+\echo 'Note: Replace "your_user" with your actual database username before running this script.'
+
diff --git a/viewer-frontend/grant-favorites-permissions.sql b/viewer-frontend/grant-favorites-permissions.sql
new file mode 100644
index 0000000..64bd88d
--- /dev/null
+++ b/viewer-frontend/grant-favorites-permissions.sql
@@ -0,0 +1,27 @@
+-- Grant permissions for photo_favorites table in punimtag_auth database
+-- Run this as PostgreSQL superuser: sudo -u postgres psql -d punimtag_auth -f grant-favorites-permissions.sql
+--
+-- BEFORE RUNNING: Edit this file and replace 'your_user' with your actual database username
+-- (Check your DATABASE_URL_AUTH environment variable to see which user you're using)
+
+-- Connect to the auth database (if not already connected)
+\c punimtag_auth
+
+-- Grant permissions on photo_favorites table
+-- Replace 'your_user' with your actual username (e.g., the user from DATABASE_URL_AUTH)
+GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE photo_favorites TO your_user;
+
+-- Grant usage on sequence (needed for auto-increment IDs)
+GRANT USAGE, SELECT ON SEQUENCE photo_favorites_id_seq TO your_user;
+
+\echo '✅ Permissions granted on photo_favorites table!'
+\echo ''
+\echo 'The following permissions were granted:'
+\echo ' - SELECT, INSERT, UPDATE, DELETE on photo_favorites'
+\echo ' - USAGE, SELECT on photo_favorites_id_seq sequence'
+
+
+
+
+
+
diff --git a/viewer-frontend/grant_permissions_now.sql b/viewer-frontend/grant_permissions_now.sql
new file mode 100644
index 0000000..c6087ca
--- /dev/null
+++ b/viewer-frontend/grant_permissions_now.sql
@@ -0,0 +1,25 @@
+-- Quick permission grant script
+--
+-- WORKING METHOD (tested and confirmed):
+-- PGPASSWORD=punimtag_password psql -h localhost -U punimtag -d punimtag -f grant_permissions_now.sql
+--
+-- Alternative methods:
+-- 1. Using postgres user: PGPASSWORD=postgres_password psql -h localhost -U postgres -d punimtag -f grant_permissions_now.sql
+-- 2. Using sudo: sudo -u postgres psql -d punimtag -f grant_permissions_now.sql
+-- 3. Manual connection: psql -U punimtag -d punimtag (then paste the GRANT commands below)
+
+\c punimtag
+
+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;
+
+\echo '✅ Permissions granted!'
+
diff --git a/viewer-frontend/grant_readonly_permissions.sql b/viewer-frontend/grant_readonly_permissions.sql
new file mode 100644
index 0000000..f84e79e
--- /dev/null
+++ b/viewer-frontend/grant_readonly_permissions.sql
@@ -0,0 +1,49 @@
+-- Grant read-only permissions on main punimtag database tables
+-- Run this script as a PostgreSQL superuser (e.g., postgres user)
+--
+-- This script grants SELECT permissions on all main tables needed for the photo viewer
+-- Replace 'viewer_readonly' with your actual read-only username from DATABASE_URL
+--
+-- WORKING METHOD (tested and confirmed):
+-- PGPASSWORD=punimtag_password psql -h localhost -U punimtag -d punimtag -f grant_readonly_permissions.sql
+--
+-- Alternative methods if punimtag user doesn't work:
+-- 1. Using postgres user: PGPASSWORD=postgres_password psql -h localhost -U postgres -d punimtag -f grant_readonly_permissions.sql
+-- 2. Using sudo: sudo -u postgres psql -d punimtag -f grant_readonly_permissions.sql
+-- 3. Manual connection: psql -U punimtag -d punimtag (then paste the GRANT commands)
+
+-- Step 1: Grant connect permission to the database
+GRANT CONNECT ON DATABASE punimtag TO viewer_readonly;
+
+-- Step 2: Grant usage on schema
+GRANT USAGE ON SCHEMA public TO viewer_readonly;
+
+-- Step 3: Grant SELECT on all main tables
+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;
+
+-- Step 4: Grant USAGE on sequences (needed for some queries)
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO viewer_readonly;
+
+-- Step 5: Grant on future tables (optional, for tables created later)
+ALTER DEFAULT PRIVILEGES IN SCHEMA public
+GRANT SELECT ON TABLES TO viewer_readonly;
+
+ALTER DEFAULT PRIVILEGES IN SCHEMA public
+GRANT USAGE, SELECT ON SEQUENCES TO viewer_readonly;
+
+-- Display success message
+\echo '✅ Read-only permissions granted to viewer_readonly user!'
+\echo ''
+\echo 'The following permissions were granted:'
+\echo ' - SELECT on photos, people, faces, person_encodings, tags, phototaglinkage, photo_favorites'
+\echo ' - USAGE on all sequences'
+\echo ''
+\echo 'Your DATABASE_URL should be:'
+\echo 'DATABASE_URL="postgresql://viewer_readonly:your_password@localhost:5432/punimtag"'
+
diff --git a/viewer-frontend/grant_write_permissions.sql b/viewer-frontend/grant_write_permissions.sql
new file mode 100644
index 0000000..be016d7
--- /dev/null
+++ b/viewer-frontend/grant_write_permissions.sql
@@ -0,0 +1,18 @@
+-- Grant write permissions to viewer_readonly user
+-- Run this script as a PostgreSQL superuser (e.g., postgres user)
+--
+-- Option 1: Grant write permissions to existing viewer_readonly user
+-- (Use this if you want to use the same user for read and write)
+
+GRANT INSERT, UPDATE ON TABLE people TO viewer_readonly;
+GRANT INSERT, UPDATE ON TABLE faces TO viewer_readonly;
+
+-- Grant usage on sequences (needed for auto-increment IDs)
+GRANT USAGE, SELECT ON SEQUENCE people_id_seq TO viewer_readonly;
+
+-- Display success message
+\echo 'Write permissions granted to viewer_readonly user!'
+\echo 'You can now use DATABASE_URL for both read and write operations.'
+
+
+
diff --git a/viewer-frontend/hooks/useIdleLogout.ts b/viewer-frontend/hooks/useIdleLogout.ts
new file mode 100644
index 0000000..281ab73
--- /dev/null
+++ b/viewer-frontend/hooks/useIdleLogout.ts
@@ -0,0 +1,72 @@
+'use client';
+
+import { useEffect, useRef, useCallback } from 'react';
+import { signOut, useSession } from 'next-auth/react';
+
+/**
+ * Custom hook to automatically log out users after a period of inactivity
+ *
+ * @param timeoutMs - Timeout in milliseconds (default: 2 hours = 7,200,000ms)
+ * @param enabled - Whether the idle logout is enabled (default: true)
+ */
+export function useIdleLogout(timeoutMs: number = 2 * 60 * 60 * 1000, enabled: boolean = true) {
+ const timerRef = useRef(null);
+ const { data: session } = useSession();
+
+ const resetTimer = useCallback(() => {
+ // Clear existing timer
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+
+ // Only set timer if user is logged in and hook is enabled
+ if (session && enabled) {
+ timerRef.current = setTimeout(() => {
+ console.log('[IDLE_LOGOUT] User inactive for', timeoutMs / 1000 / 60, 'minutes, logging out...');
+ signOut({ callbackUrl: '/' });
+ }, timeoutMs);
+ }
+ }, [session, enabled, timeoutMs]);
+
+ useEffect(() => {
+ // Don't set up listeners if not enabled or no session
+ if (!enabled || !session) {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ return;
+ }
+
+ // Events that indicate user activity
+ const events = [
+ 'mousemove',
+ 'mousedown',
+ 'keydown',
+ 'scroll',
+ 'touchstart',
+ 'click',
+ 'keypress',
+ ];
+
+ // Add event listeners
+ events.forEach((event) => {
+ window.addEventListener(event, resetTimer, { passive: true });
+ });
+
+ // Initialize the timer
+ resetTimer();
+
+ // Cleanup function
+ return () => {
+ events.forEach((event) => {
+ window.removeEventListener(event, resetTimer);
+ });
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ };
+ }, [session, enabled, resetTimer]);
+}
+
diff --git a/viewer-frontend/lib/db.ts b/viewer-frontend/lib/db.ts
new file mode 100644
index 0000000..a4198ba
--- /dev/null
+++ b/viewer-frontend/lib/db.ts
@@ -0,0 +1,44 @@
+import { PrismaClient } from '@prisma/client';
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+
+const globalForPrisma = global as unknown as {
+ prisma: PrismaClient;
+ prismaWrite: PrismaClient;
+ prismaAuth: PrismaClientAuth;
+};
+
+// Read-only client (uses DATABASE_URL) - connects to punimtag database
+export const prisma =
+ globalForPrisma.prisma ||
+ new PrismaClient({
+ log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
+ });
+
+// Write-capable client (uses DATABASE_URL_WRITE if available, otherwise DATABASE_URL)
+// This is kept for backward compatibility but should not be used for auth operations
+export const prismaWrite =
+ globalForPrisma.prismaWrite ||
+ new PrismaClient({
+ datasourceUrl: process.env.DATABASE_URL_WRITE || process.env.DATABASE_URL,
+ log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
+ });
+
+// Auth client - connects to separate punimtag_auth database (uses DATABASE_URL_AUTH)
+// This database contains users and pending_identifications tables
+export const prismaAuth =
+ globalForPrisma.prismaAuth ||
+ new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+ log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
+ });
+
+if (process.env.NODE_ENV !== 'production') {
+ globalForPrisma.prisma = prisma;
+ globalForPrisma.prismaWrite = prismaWrite;
+ globalForPrisma.prismaAuth = prismaAuth;
+}
+
+
+
+
+
diff --git a/viewer-frontend/lib/email.ts b/viewer-frontend/lib/email.ts
new file mode 100644
index 0000000..048905b
--- /dev/null
+++ b/viewer-frontend/lib/email.ts
@@ -0,0 +1,196 @@
+import { Resend } from 'resend';
+import crypto from 'crypto';
+
+const resend = new Resend(process.env.RESEND_API_KEY);
+
+export function generateEmailConfirmationToken(): string {
+ return crypto.randomBytes(32).toString('hex');
+}
+
+export async function sendEmailConfirmation(
+ email: string,
+ name: string,
+ token: string
+): Promise {
+ const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001';
+ const confirmationUrl = `${baseUrl}/api/auth/verify-email?token=${token}`;
+
+ try {
+ await resend.emails.send({
+ from: process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev',
+ to: email,
+ subject: 'Confirm your email address',
+ html: `
+
+
+
+
+
+ Confirm your email
+
+
+
+
Confirm your email address
+
Hi ${name},
+
Thank you for signing up! Please confirm your email address by clicking the button below:
+
+
Or copy and paste this link into your browser:
+
${confirmationUrl}
+
+ If you didn't create an account, you can safely ignore this email.
+
+
+
+
+ `,
+ });
+ } catch (error) {
+ console.error('Error sending confirmation email:', error);
+ throw new Error('Failed to send confirmation email');
+ }
+}
+
+export async function sendEmailConfirmationResend(
+ email: string,
+ name: string,
+ token: string
+): Promise {
+ const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001';
+ const confirmationUrl = `${baseUrl}/api/auth/verify-email?token=${token}`;
+
+ try {
+ await resend.emails.send({
+ from: process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev',
+ to: email,
+ subject: 'Confirm your email address',
+ html: `
+
+
+
+
+
+ Confirm your email
+
+
+
+
Confirm your email address
+
Hi ${name},
+
You requested a new confirmation email. Please confirm your email address by clicking the button below:
+
+
Or copy and paste this link into your browser:
+
${confirmationUrl}
+
+ This link will expire in 24 hours. If you didn't request this email, you can safely ignore it.
+
+
+
+
+ `,
+ });
+ } catch (error) {
+ console.error('Error sending confirmation email:', error);
+ throw new Error('Failed to send confirmation email');
+ }
+}
+
+export function generatePasswordResetToken(): string {
+ return crypto.randomBytes(32).toString('hex');
+}
+
+export async function sendPasswordResetEmail(
+ email: string,
+ name: string,
+ token: string
+): Promise {
+ const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001';
+ const resetUrl = `${baseUrl}/reset-password?token=${token}`;
+ const fromEmail = process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev';
+ const replyTo = process.env.RESEND_REPLY_TO || fromEmail;
+
+ // Plain text version for better deliverability
+ const text = `Hi ${name},
+
+You requested to reset your password. Click the link below to create a new password:
+
+${resetUrl}
+
+This link will expire in 1 hour.
+
+If you didn't request a password reset, you can safely ignore this email.
+
+Best regards,
+PunimTag Viewer Team`;
+
+ try {
+ console.log('[EMAIL] Sending password reset email:', {
+ from: fromEmail,
+ to: email,
+ replyTo: replyTo,
+ });
+
+ const result = await resend.emails.send({
+ from: fromEmail,
+ to: email,
+ replyTo: replyTo,
+ subject: 'Reset your password - PunimTag Viewer',
+ text: text,
+ html: `
+
+
+
+
+
+
+ Reset your password
+
+
+
+
Reset your password
+
Hi ${name},
+
You requested to reset your password for your PunimTag Viewer account. Click the button below to create a new password:
+
+
Or copy and paste this link into your browser:
+
${resetUrl}
+
+ Important: This link will expire in 1 hour for security reasons.
+
+
+ If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
+
+
+ This is an automated message from PunimTag Viewer. Please do not reply to this email.
+
+
+
+
+ `,
+ });
+
+ if (result.error) {
+ console.error('[EMAIL] Resend API error:', result.error);
+ throw new Error(`Resend API error: ${result.error.message || 'Unknown error'}`);
+ }
+
+ console.log('[EMAIL] Password reset email sent successfully:', {
+ emailId: result.data?.id,
+ to: email,
+ });
+ } catch (error: any) {
+ console.error('[EMAIL] Error sending password reset email:', error);
+ console.error('[EMAIL] Error details:', {
+ message: error?.message,
+ name: error?.name,
+ response: error?.response,
+ statusCode: error?.statusCode,
+ });
+ throw error;
+ }
+}
+
+
diff --git a/viewer-frontend/lib/face-utils.ts b/viewer-frontend/lib/face-utils.ts
new file mode 100644
index 0000000..7c1c232
--- /dev/null
+++ b/viewer-frontend/lib/face-utils.ts
@@ -0,0 +1,234 @@
+/**
+ * Utilities for face detection and location parsing
+ */
+
+export interface FaceLocation {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+/**
+ * Parses face location string from database
+ * Supports multiple formats:
+ * - JSON object: {"x": 100, "y": 200, "width": 150, "height": 150}
+ * - JSON array: [100, 200, 150, 150] or [x1, y1, x2, y2]
+ * - Comma-separated: "100,200,150,150" (x,y,width,height or x1,y1,x2,y2)
+ */
+export function parseFaceLocation(location: string): FaceLocation | null {
+ if (!location) return null;
+
+ // If location is already an object (shouldn't happen but handle it)
+ if (typeof location === 'object' && location !== null) {
+ const loc = location as any;
+ if (typeof loc.x === 'number' && typeof loc.y === 'number' &&
+ typeof loc.width === 'number' && typeof loc.height === 'number') {
+ return { x: loc.x, y: loc.y, width: loc.width, height: loc.height };
+ }
+ }
+
+ // If it's not a string, try to convert
+ const locationStr = String(location).trim();
+ if (!locationStr) return null;
+
+ try {
+ // Try JSON format first (object or array)
+ const parsed = JSON.parse(locationStr);
+
+ // Handle JSON object: {"x": 100, "y": 200, "width": 150, "height": 150}
+ // or {"x": 100, "y": 200, "w": 150, "h": 150}
+ if (typeof parsed === 'object' && parsed !== null) {
+ // Handle width/height format
+ if (
+ typeof parsed.x === 'number' &&
+ typeof parsed.y === 'number' &&
+ typeof parsed.width === 'number' &&
+ typeof parsed.height === 'number'
+ ) {
+ return {
+ x: parsed.x,
+ y: parsed.y,
+ width: parsed.width,
+ height: parsed.height,
+ };
+ }
+
+ // Handle w/h format (shorthand)
+ if (
+ typeof parsed.x === 'number' &&
+ typeof parsed.y === 'number' &&
+ typeof parsed.w === 'number' &&
+ typeof parsed.h === 'number'
+ ) {
+ return {
+ x: parsed.x,
+ y: parsed.y,
+ width: parsed.w,
+ height: parsed.h,
+ };
+ }
+
+ // Handle object with x1, y1, x2, y2 format
+ if (
+ typeof parsed.x1 === 'number' &&
+ typeof parsed.y1 === 'number' &&
+ typeof parsed.x2 === 'number' &&
+ typeof parsed.y2 === 'number'
+ ) {
+ return {
+ x: parsed.x1,
+ y: parsed.y1,
+ width: parsed.x2 - parsed.x1,
+ height: parsed.y2 - parsed.y1,
+ };
+ }
+ }
+
+ // Handle JSON array: [x, y, width, height] or [x1, y1, x2, y2]
+ if (Array.isArray(parsed) && parsed.length === 4) {
+ const [a, b, c, d] = parsed.map(Number);
+ if (parsed.every((n: any) => typeof n === 'number' && !isNaN(n))) {
+ // Check if it's x1,y1,x2,y2 format (width/height would be negative if x2 a && d > b) {
+ // Likely x1, y1, x2, y2 format
+ return {
+ x: a,
+ y: b,
+ width: c - a,
+ height: d - b,
+ };
+ } else {
+ // Likely x, y, width, height format
+ return {
+ x: a,
+ y: b,
+ width: c,
+ height: d,
+ };
+ }
+ }
+ }
+ } catch {
+ // Not JSON, try comma-separated format
+ }
+
+ // Try comma-separated format: "x,y,width,height" or "x1,y1,x2,y2"
+ const parts = locationStr.split(',').map((s) => s.trim()).map(Number);
+ if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
+ const [a, b, c, d] = parts;
+ // Check if it's x1,y1,x2,y2 format (width/height would be negative if x2 a && d > b) {
+ // Likely x1, y1, x2, y2 format
+ return {
+ x: a,
+ y: b,
+ width: c - a,
+ height: d - b,
+ };
+ } else {
+ // Likely x, y, width, height format
+ return {
+ x: a,
+ y: b,
+ width: c,
+ height: d,
+ };
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Checks if a mouse point is within a face bounding box
+ * Handles image scaling (object-fit: cover) correctly
+ */
+export function isPointInFace(
+ mouseX: number,
+ mouseY: number,
+ faceLocation: FaceLocation,
+ imageNaturalWidth: number,
+ imageNaturalHeight: number,
+ containerWidth: number,
+ containerHeight: number
+): boolean {
+ return isPointInFaceWithFit(
+ mouseX,
+ mouseY,
+ faceLocation,
+ imageNaturalWidth,
+ imageNaturalHeight,
+ containerWidth,
+ containerHeight,
+ 'cover'
+ );
+}
+
+/**
+ * Checks if a mouse point is within a face bounding box
+ * Handles both object-fit: cover and object-fit: contain
+ */
+export function isPointInFaceWithFit(
+ mouseX: number,
+ mouseY: number,
+ faceLocation: FaceLocation,
+ imageNaturalWidth: number,
+ imageNaturalHeight: number,
+ containerWidth: number,
+ containerHeight: number,
+ objectFit: 'cover' | 'contain' = 'cover'
+): boolean {
+ if (!imageNaturalWidth || !imageNaturalHeight) return false;
+
+ const imageAspect = imageNaturalWidth / imageNaturalHeight;
+ const containerAspect = containerWidth / containerHeight;
+
+ let scale: number;
+ let offsetX = 0;
+ let offsetY = 0;
+
+ if (objectFit === 'cover') {
+ // object-fit: cover - image covers entire container, may be cropped
+ if (imageAspect > containerAspect) {
+ // Image is wider - scale based on height
+ scale = containerHeight / imageNaturalHeight;
+ const scaledWidth = imageNaturalWidth * scale;
+ offsetX = (containerWidth - scaledWidth) / 2;
+ } else {
+ // Image is taller - scale based on width
+ scale = containerWidth / imageNaturalWidth;
+ const scaledHeight = imageNaturalHeight * scale;
+ offsetY = (containerHeight - scaledHeight) / 2;
+ }
+ } else {
+ // object-fit: contain - image fits entirely within container, may have empty space
+ if (imageAspect > containerAspect) {
+ // Image is wider - scale based on width
+ scale = containerWidth / imageNaturalWidth;
+ const scaledHeight = imageNaturalHeight * scale;
+ offsetY = (containerHeight - scaledHeight) / 2;
+ } else {
+ // Image is taller - scale based on height
+ scale = containerHeight / imageNaturalHeight;
+ const scaledWidth = imageNaturalWidth * scale;
+ offsetX = (containerWidth - scaledWidth) / 2;
+ }
+ }
+
+ // Scale face location to container coordinates
+ const scaledX = faceLocation.x * scale + offsetX;
+ const scaledY = faceLocation.y * scale + offsetY;
+ const scaledWidth = faceLocation.width * scale;
+ const scaledHeight = faceLocation.height * scale;
+
+ // Check if mouse is within face bounds
+ return (
+ mouseX >= scaledX &&
+ mouseX <= scaledX + scaledWidth &&
+ mouseY >= scaledY &&
+ mouseY <= scaledY + scaledHeight
+ );
+}
+
diff --git a/viewer-frontend/lib/permissions.ts b/viewer-frontend/lib/permissions.ts
new file mode 100644
index 0000000..dbb0e96
--- /dev/null
+++ b/viewer-frontend/lib/permissions.ts
@@ -0,0 +1,49 @@
+import { auth } from '@/app/api/auth/[...nextauth]/route';
+import { prismaAuth } from './db';
+
+/**
+ * Check if the current user is an admin
+ */
+export async function isAdmin(): Promise {
+ try {
+ const session = await auth();
+
+ if (!session?.user?.id) {
+ return false;
+ }
+
+ // First check if isAdmin is already in the session (faster, no DB query needed)
+ if (session.user.isAdmin !== undefined) {
+ return session.user.isAdmin === true;
+ }
+
+ // Fallback to database query if session doesn't have isAdmin
+ const userId = parseInt(session.user.id, 10);
+ if (isNaN(userId)) {
+ return false;
+ }
+
+ const user = await prismaAuth.user.findUnique({
+ where: { id: userId },
+ select: { isAdmin: true, isActive: true },
+ });
+
+ // User must be active to have admin permissions (treat null/undefined as true)
+ if (user?.isActive === false) {
+ return false;
+ }
+
+ return user?.isAdmin ?? false;
+ } catch (error: any) {
+ console.error('[isAdmin] Error checking admin status:', error);
+ return false;
+ }
+}
+
+/**
+ * Check if the current user can approve identifications (admin only)
+ */
+export async function canApproveIdentifications(): Promise {
+ return isAdmin();
+}
+
diff --git a/viewer-frontend/lib/photo-utils.ts b/viewer-frontend/lib/photo-utils.ts
new file mode 100644
index 0000000..41b3c08
--- /dev/null
+++ b/viewer-frontend/lib/photo-utils.ts
@@ -0,0 +1,59 @@
+import { Photo } from '@prisma/client';
+
+/**
+ * Determines if a path is a URL (http/https) or a file system path
+ */
+export function isUrl(path: string): boolean {
+ return path.startsWith('http://') || path.startsWith('https://');
+}
+
+/**
+ * Check if photo is a video
+ */
+export function isVideo(photo: Photo): boolean {
+ // Handle both camelCase (Prisma client) and snake_case (direct DB access)
+ return (photo as any).mediaType === 'video' || (photo as any).media_type === 'video';
+}
+
+/**
+ * Gets the appropriate image source URL
+ * - URLs (SharePoint, CDN, etc.) → use directly
+ * - File system paths → use API proxy
+ * - Videos → use thumbnail endpoint (for grid display)
+ */
+export function getImageSrc(photo: Photo, options?: { watermark?: boolean; thumbnail?: boolean }): string {
+ // For videos, use thumbnail endpoint if requested (for grid display)
+ if (options?.thumbnail && isVideo(photo)) {
+ return `/api/photos/${photo.id}/image?thumbnail=true`;
+ }
+
+ if (isUrl(photo.path)) {
+ if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
+ console.log(`✅ Photo ${photo.id}: Using DIRECT access for URL:`, photo.path);
+ }
+ return photo.path;
+ }
+
+ const params = new URLSearchParams();
+ if (options?.watermark) {
+ params.set('watermark', 'true');
+ }
+ const query = params.toString();
+
+ if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
+ console.log(`📁 Photo ${photo.id}: Using API PROXY for file path:`, photo.path);
+ }
+
+ return `/api/photos/${photo.id}/image${query ? `?${query}` : ''}`;
+}
+
+/**
+ * Gets the appropriate video source URL
+ */
+export function getVideoSrc(photo: Photo): string {
+ if (isUrl(photo.path)) {
+ return photo.path;
+ }
+ return `/api/photos/${photo.id}/image`;
+}
+
diff --git a/viewer-frontend/lib/queries.ts b/viewer-frontend/lib/queries.ts
new file mode 100644
index 0000000..929135f
--- /dev/null
+++ b/viewer-frontend/lib/queries.ts
@@ -0,0 +1,115 @@
+import { prisma } from './db';
+
+export interface SearchFilters {
+ people?: number[];
+ tags?: number[];
+ dateFrom?: Date;
+ dateTo?: Date;
+ mediaType?: 'all' | 'photos' | 'videos';
+ page?: number;
+ pageSize?: number;
+}
+
+export async function searchPhotos(filters: SearchFilters) {
+ const {
+ people = [],
+ tags = [],
+ dateFrom,
+ dateTo,
+ mediaType = 'all',
+ page = 1,
+ pageSize = 30,
+ } = filters;
+
+ const skip = (page - 1) * pageSize;
+
+ // 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 = dateFrom;
+ }
+ if (dateTo) {
+ where.date_taken.lte = dateTo;
+ }
+ }
+
+ // People filter (photo has face with person_id in list)
+ if (people.length > 0) {
+ where.faces = {
+ some: {
+ personId: { in: people },
+ },
+ };
+ }
+
+ // Tags filter
+ if (tags.length > 0) {
+ where.PhotoTagLinkage = {
+ some: {
+ tagId: { in: tags },
+ },
+ };
+ }
+
+ // Execute query
+ const [photos, total] = await Promise.all([
+ prisma.photo.findMany({
+ where,
+ include: {
+ Face: {
+ include: {
+ Person: true,
+ },
+ },
+ PhotoTagLinkage: {
+ include: {
+ Tag: true,
+ },
+ },
+ },
+ orderBy: { date_taken: 'desc' },
+ skip,
+ take: pageSize,
+ }),
+ prisma.photo.count({ where }),
+ ]);
+
+ return {
+ photos,
+ total,
+ page,
+ pageSize,
+ totalPages: Math.ceil(total / pageSize),
+ };
+}
+
+export async function getAllPeople() {
+ return prisma.person.findMany({
+ orderBy: [
+ { first_name: 'asc' },
+ { last_name: 'asc' },
+ ],
+ });
+}
+
+export async function getAllTags() {
+ return prisma.tag.findMany({
+ orderBy: { tag_name: 'asc' },
+ });
+}
+
diff --git a/viewer-frontend/lib/serialize.ts b/viewer-frontend/lib/serialize.ts
new file mode 100644
index 0000000..0cd07c8
--- /dev/null
+++ b/viewer-frontend/lib/serialize.ts
@@ -0,0 +1,233 @@
+/**
+ * Serialization utilities for converting Prisma objects to plain JavaScript objects
+ * that can be safely passed from Server Components to Client Components in Next.js.
+ *
+ * Handles:
+ * - Decimal objects -> numbers
+ * - Date objects -> ISO strings
+ * - Nested structures (photos, faces, people, tags)
+ */
+
+type Decimal = {
+ toNumber(): number;
+ toString(): string;
+};
+
+/**
+ * Checks if a value is a Prisma Decimal object
+ */
+function isDecimal(value: any): value is Decimal {
+ return (
+ value !== null &&
+ typeof value === 'object' &&
+ typeof value.toNumber === 'function' &&
+ typeof value.toString === 'function'
+ );
+}
+
+/**
+ * Converts a Decimal to a number, handling null/undefined
+ */
+function decimalToNumber(value: any): number | null {
+ if (value === null || value === undefined) {
+ return null;
+ }
+ if (isDecimal(value)) {
+ return value.toNumber();
+ }
+ if (typeof value === 'number') {
+ return value;
+ }
+ // Fallback: try to parse as number
+ const parsed = Number(value);
+ return isNaN(parsed) ? null : parsed;
+}
+
+/**
+ * Serializes a Date object to an ISO string
+ */
+function serializeDate(value: any): string | null {
+ if (value === null || value === undefined) {
+ return null;
+ }
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+ if (typeof value === 'string') {
+ return value;
+ }
+ return null;
+}
+
+/**
+ * Serializes a Person object
+ */
+function serializePerson(person: any): any {
+ if (!person) {
+ return null;
+ }
+
+ return {
+ id: person.id,
+ first_name: person.first_name,
+ last_name: person.last_name,
+ middle_name: person.middle_name ?? null,
+ maiden_name: person.maiden_name ?? null,
+ date_of_birth: serializeDate(person.date_of_birth),
+ created_date: serializeDate(person.created_date),
+ };
+}
+
+/**
+ * Serializes a Face object, converting Decimal fields to numbers
+ */
+function serializeFace(face: any): any {
+ if (!face) {
+ return null;
+ }
+
+ const serialized: any = {
+ id: face.id,
+ photo_id: face.photo_id,
+ person_id: face.person_id ?? null,
+ location: face.location,
+ confidence: decimalToNumber(face.confidence) ?? 0,
+ quality_score: decimalToNumber(face.quality_score) ?? 0,
+ is_primary_encoding: face.is_primary_encoding ?? false,
+ detector_backend: face.detector_backend,
+ model_name: face.model_name,
+ face_confidence: decimalToNumber(face.face_confidence) ?? 0,
+ exif_orientation: face.exif_orientation ?? null,
+ pose_mode: face.pose_mode,
+ yaw_angle: decimalToNumber(face.yaw_angle),
+ pitch_angle: decimalToNumber(face.pitch_angle),
+ roll_angle: decimalToNumber(face.roll_angle),
+ landmarks: face.landmarks ?? null,
+ identified_by_user_id: face.identified_by_user_id ?? null,
+ excluded: face.excluded ?? false,
+ };
+
+ // Handle nested Person object (if present)
+ if (face.Person) {
+ serialized.Person = serializePerson(face.Person);
+ } else if (face.person) {
+ serialized.person = serializePerson(face.person);
+ }
+
+ return serialized;
+}
+
+/**
+ * Serializes a Tag object
+ */
+function serializeTag(tag: any): any {
+ if (!tag) {
+ return null;
+ }
+
+ return {
+ id: tag.id,
+ tagName: tag.tag_name || tag.tagName,
+ created_date: serializeDate(tag.created_date),
+ };
+}
+
+/**
+ * Serializes a PhotoTagLinkage object
+ */
+function serializePhotoTagLinkage(linkage: any): any {
+ if (!linkage) {
+ return null;
+ }
+
+ const serialized: any = {
+ linkage_id: linkage.linkage_id ?? linkage.id,
+ photo_id: linkage.photo_id,
+ tag_id: linkage.tag_id,
+ linkage_type: linkage.linkage_type ?? 0,
+ created_date: serializeDate(linkage.created_date),
+ };
+
+ // Handle nested Tag object (if present)
+ if (linkage.Tag || linkage.tag) {
+ serialized.tag = serializeTag(linkage.Tag || linkage.tag);
+ // Also keep Tag for backward compatibility
+ serialized.Tag = serialized.tag;
+ }
+ if (linkage.Tag || linkage.tag) {
+ // Also keep Tag for backward compatibility
+ serialized.Tag = serialized.tag;
+ }
+
+ return serialized;
+}
+
+/**
+ * Serializes a single Photo object with all nested structures
+ */
+export function serializePhoto(photo: any): any {
+ if (!photo) {
+ return null;
+ }
+
+ const serialized: any = {
+ id: photo.id,
+ path: photo.path,
+ filename: photo.filename,
+ date_added: serializeDate(photo.date_added),
+ date_taken: serializeDate(photo.date_taken),
+ processed: photo.processed ?? false,
+ media_type: photo.media_type ?? null,
+ };
+
+ // Handle Face array (can be named Face or faces)
+ if (photo.Face && Array.isArray(photo.Face)) {
+ serialized.Face = photo.Face.map((face: any) => serializeFace(face));
+ } else if (photo.faces && Array.isArray(photo.faces)) {
+ serialized.faces = photo.faces.map((face: any) => serializeFace(face));
+ }
+
+ // Handle PhotoTagLinkage array (can be named PhotoTagLinkage or photoTags)
+ if (photo.PhotoTagLinkage && Array.isArray(photo.PhotoTagLinkage)) {
+ serialized.PhotoTagLinkage = photo.PhotoTagLinkage.map((linkage: any) =>
+ serializePhotoTagLinkage(linkage)
+ );
+ } else if (photo.photoTags && Array.isArray(photo.photoTags)) {
+ serialized.photoTags = photo.photoTags.map((linkage: any) =>
+ serializePhotoTagLinkage(linkage)
+ );
+ }
+
+ return serialized;
+}
+
+/**
+ * Serializes an array of Photo objects
+ */
+export function serializePhotos(photos: any[]): any[] {
+ if (!Array.isArray(photos)) {
+ return [];
+ }
+
+ return photos.map((photo) => serializePhoto(photo));
+}
+
+/**
+ * Serializes an array of Person objects
+ */
+export function serializePeople(people: any[]): any[] {
+ if (!Array.isArray(people)) {
+ return [];
+ }
+ return people.map((person) => serializePerson(person));
+}
+
+/**
+ * Serializes an array of Tag objects
+ */
+export function serializeTags(tags: any[]): any[] {
+ if (!Array.isArray(tags)) {
+ return [];
+ }
+ return tags.map((tag) => serializeTag(tag));
+}
diff --git a/viewer-frontend/lib/utils.ts b/viewer-frontend/lib/utils.ts
new file mode 100644
index 0000000..b5f86ee
--- /dev/null
+++ b/viewer-frontend/lib/utils.ts
@@ -0,0 +1,86 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+/**
+ * Validates an email address format
+ * @param email - The email address to validate
+ * @returns true if the email is valid, false otherwise
+ */
+export function isValidEmail(email: string): boolean {
+ if (!email || typeof email !== 'string') {
+ return false;
+ }
+
+ // Trim whitespace
+ const trimmedEmail = email.trim();
+
+ // Basic email regex pattern
+ // Matches: local-part@domain
+ // - Local part: alphanumeric, dots, hyphens, underscores, plus signs
+ // - Domain: alphanumeric, dots, hyphens
+ // - Must have @ symbol
+ // - Domain must have at least one dot
+ const emailRegex = /^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
+
+ // Check length constraints (RFC 5321)
+ if (trimmedEmail.length > 254) {
+ return false;
+ }
+
+ // Check for valid format
+ if (!emailRegex.test(trimmedEmail)) {
+ return false;
+ }
+
+ // Additional checks
+ // - Cannot start or end with dot
+ // - Cannot have consecutive dots
+ // - Must have valid domain part
+ const parts = trimmedEmail.split('@');
+ if (parts.length !== 2) {
+ return false;
+ }
+
+ const [localPart, domain] = parts;
+
+ // Validate local part
+ if (localPart.length === 0 || localPart.length > 64) {
+ return false;
+ }
+ if (localPart.startsWith('.') || localPart.endsWith('.')) {
+ return false;
+ }
+ if (localPart.includes('..')) {
+ return false;
+ }
+
+ // Validate domain part
+ if (domain.length === 0 || domain.length > 253) {
+ return false;
+ }
+ if (domain.startsWith('.') || domain.endsWith('.')) {
+ return false;
+ }
+ if (domain.includes('..')) {
+ return false;
+ }
+ if (!domain.includes('.')) {
+ return false;
+ }
+
+ // Domain must have at least one TLD (top-level domain)
+ const domainParts = domain.split('.');
+ if (domainParts.length < 2) {
+ return false;
+ }
+ const tld = domainParts[domainParts.length - 1];
+ if (tld.length < 2) {
+ return false;
+ }
+
+ return true;
+}
diff --git a/viewer-frontend/lib/video-thumbnail.ts b/viewer-frontend/lib/video-thumbnail.ts
new file mode 100644
index 0000000..2c23c5c
--- /dev/null
+++ b/viewer-frontend/lib/video-thumbnail.ts
@@ -0,0 +1,166 @@
+import { existsSync, mkdirSync } from 'fs';
+import { readFile, writeFile } from 'fs/promises';
+import path from 'path';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+
+const execAsync = promisify(exec);
+
+// Thumbnail cache directory (relative to project root)
+const THUMBNAIL_CACHE_DIR = path.join(process.cwd(), '.cache', 'video-thumbnails');
+const THUMBNAIL_QUALITY = 85; // JPEG quality
+const THUMBNAIL_MAX_WIDTH = 800; // Max width for thumbnails
+
+// Ensure cache directory exists
+function ensureCacheDir() {
+ if (!existsSync(THUMBNAIL_CACHE_DIR)) {
+ mkdirSync(THUMBNAIL_CACHE_DIR, { recursive: true });
+ }
+}
+
+/**
+ * Get thumbnail path for a video file
+ */
+function getThumbnailPath(videoPath: string): string {
+ ensureCacheDir();
+ // Create a hash-like filename from the video path
+ // Use a simple approach: replace path separators and special chars
+ const safePath = videoPath.replace(/[^a-zA-Z0-9]/g, '_');
+ const hash = Buffer.from(safePath).toString('base64').slice(0, 32);
+ return path.join(THUMBNAIL_CACHE_DIR, `${hash}.jpg`);
+}
+
+/**
+ * Check if ffmpeg is available
+ */
+async function isFfmpegAvailable(): Promise {
+ try {
+ await execAsync('ffmpeg -version');
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Generate video thumbnail using ffmpeg
+ * Extracts frame at 1 second (or first frame if video is shorter)
+ */
+async function generateThumbnailWithFfmpeg(
+ videoPath: string,
+ thumbnailPath: string
+): Promise {
+ // Escape paths properly for shell commands
+ const escapedVideoPath = videoPath.replace(/'/g, "'\"'\"'");
+ const escapedThumbnailPath = thumbnailPath.replace(/'/g, "'\"'\"'");
+
+ // Extract frame at 1 second, or first frame if video is shorter
+ // Scale to max width while maintaining aspect ratio
+ // Use -loglevel error to suppress ffmpeg output unless there's an error
+ const command = `ffmpeg -i '${escapedVideoPath}' -ss 00:00:01 -vframes 1 -vf "scale=${THUMBNAIL_MAX_WIDTH}:-1" -q:v ${THUMBNAIL_QUALITY} -y '${escapedThumbnailPath}' -loglevel error`;
+
+ try {
+ const { stdout, stderr } = await execAsync(command, { maxBuffer: 10 * 1024 * 1024 });
+
+ if (stderr && !stderr.includes('frame=')) {
+ console.warn('ffmpeg stderr:', stderr);
+ }
+
+ // Wait a bit for file to be written
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ // Read the generated thumbnail
+ if (existsSync(thumbnailPath)) {
+ const buffer = await readFile(thumbnailPath);
+ if (buffer.length > 0) {
+ return buffer;
+ }
+ throw new Error('Thumbnail file is empty');
+ }
+ throw new Error('Thumbnail file was not created');
+ } catch (error) {
+ console.error(`Error extracting frame at 1s for ${videoPath}:`, error);
+
+ // If extraction at 1s fails, try first frame
+ try {
+ const fallbackCommand = `ffmpeg -i '${escapedVideoPath}' -vframes 1 -vf "scale=${THUMBNAIL_MAX_WIDTH}:-1" -q:v ${THUMBNAIL_QUALITY} -y '${escapedThumbnailPath}' -loglevel error`;
+ const { stdout, stderr } = await execAsync(fallbackCommand, { maxBuffer: 10 * 1024 * 1024 });
+
+ if (stderr && !stderr.includes('frame=')) {
+ console.warn('ffmpeg fallback stderr:', stderr);
+ }
+
+ // Wait a bit for file to be written
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ if (existsSync(thumbnailPath)) {
+ const buffer = await readFile(thumbnailPath);
+ if (buffer.length > 0) {
+ return buffer;
+ }
+ throw new Error('Fallback thumbnail file is empty');
+ }
+ } catch (fallbackError) {
+ console.error(`Error generating video thumbnail (fallback failed) for ${videoPath}:`, fallbackError);
+ throw new Error(`Failed to generate video thumbnail: ${fallbackError instanceof Error ? fallbackError.message : 'Unknown error'}`);
+ }
+ throw error;
+ }
+}
+
+/**
+ * Generate or retrieve cached video thumbnail
+ * Returns thumbnail buffer if successful, null if ffmpeg is not available
+ */
+export async function getVideoThumbnail(videoPath: string): Promise {
+ // Check if ffmpeg is available
+ const ffmpegAvailable = await isFfmpegAvailable();
+ if (!ffmpegAvailable) {
+ console.warn('ffmpeg is not available. Video thumbnails will not be generated.');
+ return null;
+ }
+
+ const thumbnailPath = getThumbnailPath(videoPath);
+
+ // Check if thumbnail already exists in cache
+ if (existsSync(thumbnailPath)) {
+ try {
+ return await readFile(thumbnailPath);
+ } catch (error) {
+ console.error('Error reading cached thumbnail:', error);
+ // Continue to regenerate
+ }
+ }
+
+ // Check if video file exists
+ if (!existsSync(videoPath)) {
+ console.error(`Video file not found: ${videoPath}`);
+ return null;
+ }
+
+ // Generate new thumbnail
+ try {
+ const thumbnailBuffer = await generateThumbnailWithFfmpeg(videoPath, thumbnailPath);
+ return thumbnailBuffer;
+ } catch (error) {
+ console.error(`Error generating thumbnail for ${videoPath}:`, error);
+ return null;
+ }
+}
+
+/**
+ * Check if a thumbnail exists in cache
+ */
+export function hasCachedThumbnail(videoPath: string): boolean {
+ const thumbnailPath = getThumbnailPath(videoPath);
+ return existsSync(thumbnailPath);
+}
+
+/**
+ * Get cached thumbnail path (for direct file serving)
+ */
+export function getCachedThumbnailPath(videoPath: string): string | null {
+ const thumbnailPath = getThumbnailPath(videoPath);
+ return existsSync(thumbnailPath) ? thumbnailPath : null;
+}
+
diff --git a/viewer-frontend/migrations/add-email-verification-columns.sql b/viewer-frontend/migrations/add-email-verification-columns.sql
new file mode 100644
index 0000000..1800f4a
--- /dev/null
+++ b/viewer-frontend/migrations/add-email-verification-columns.sql
@@ -0,0 +1,30 @@
+-- Migration: Add email verification columns to users table
+-- Run this migration on the punimtag_auth database
+
+-- Add email_verified column (defaults to false for new users, true for existing users)
+ALTER TABLE users
+ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT true;
+
+-- Add email_confirmation_token column
+ALTER TABLE users
+ADD COLUMN IF NOT EXISTS email_confirmation_token VARCHAR(255) UNIQUE;
+
+-- Add email_confirmation_token_expiry column
+ALTER TABLE users
+ADD COLUMN IF NOT EXISTS email_confirmation_token_expiry TIMESTAMP;
+
+-- Create index on email_confirmation_token for faster lookups
+CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_token ON users(email_confirmation_token);
+
+-- Update existing users to have verified emails (for backward compatibility)
+-- Only update users that don't have the token set (meaning they were created before this migration)
+UPDATE users
+SET email_verified = true
+WHERE email_confirmation_token IS NULL;
+
+
+
+
+
+
+
diff --git a/viewer-frontend/migrations/add-inappropriate-photo-reports-table.sql b/viewer-frontend/migrations/add-inappropriate-photo-reports-table.sql
new file mode 100644
index 0000000..d013de2
--- /dev/null
+++ b/viewer-frontend/migrations/add-inappropriate-photo-reports-table.sql
@@ -0,0 +1,32 @@
+-- Migration: Add inappropriate_photo_reports table for reporting inappropriate photos
+-- This table tracks photos reported by users as inappropriate, pending admin review
+-- Run this in the punimtag_auth database
+
+CREATE TABLE IF NOT EXISTS inappropriate_photo_reports (
+ id SERIAL PRIMARY KEY,
+ photo_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ status VARCHAR(50) DEFAULT 'pending' CHECK (status IN ('pending', 'reviewed', 'dismissed')),
+ reported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ reviewed_at TIMESTAMP,
+ reviewed_by INTEGER,
+ review_notes TEXT,
+
+ CONSTRAINT fk_inappropriate_photo_reports_user
+ FOREIGN KEY (user_id)
+ REFERENCES users(id)
+ ON DELETE CASCADE,
+
+ -- Prevent duplicate reports from same user for same photo
+ CONSTRAINT uq_photo_user_report UNIQUE (photo_id, user_id)
+);
+
+-- Create indexes for efficient queries
+CREATE INDEX IF NOT EXISTS idx_inappropriate_photo_reports_photo_id ON inappropriate_photo_reports(photo_id);
+CREATE INDEX IF NOT EXISTS idx_inappropriate_photo_reports_user_id ON inappropriate_photo_reports(user_id);
+CREATE INDEX IF NOT EXISTS idx_inappropriate_photo_reports_status ON inappropriate_photo_reports(status);
+CREATE INDEX IF NOT EXISTS idx_inappropriate_photo_reports_reported_at ON inappropriate_photo_reports(reported_at);
+
+-- Add comment to table
+COMMENT ON TABLE inappropriate_photo_reports IS 'Stores reports of inappropriate photos submitted by users, pending admin review';
+
diff --git a/viewer-frontend/migrations/add-is-active-column.sql b/viewer-frontend/migrations/add-is-active-column.sql
new file mode 100644
index 0000000..e097ec7
--- /dev/null
+++ b/viewer-frontend/migrations/add-is-active-column.sql
@@ -0,0 +1,17 @@
+-- Migration: Add is_active column to users table
+-- Run this migration on the punimtag_auth database
+
+-- Add is_active column (defaults to true for all users)
+ALTER TABLE users
+ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
+
+-- Update existing users to be active (for backward compatibility)
+UPDATE users
+SET is_active = true
+WHERE is_active IS NULL;
+
+
+
+
+
+
diff --git a/viewer-frontend/migrations/add-password-reset-columns.sql b/viewer-frontend/migrations/add-password-reset-columns.sql
new file mode 100644
index 0000000..a59f9d2
--- /dev/null
+++ b/viewer-frontend/migrations/add-password-reset-columns.sql
@@ -0,0 +1,19 @@
+-- Migration: Add password reset token columns to users table
+-- Run this migration on the punimtag_auth database
+
+-- Add password_reset_token column
+ALTER TABLE users
+ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255) UNIQUE;
+
+-- Add password_reset_token_expiry column
+ALTER TABLE users
+ADD COLUMN IF NOT EXISTS password_reset_token_expiry TIMESTAMP;
+
+-- Create index on password_reset_token for faster lookups
+CREATE INDEX IF NOT EXISTS idx_users_password_reset_token ON users(password_reset_token);
+
+
+
+
+
+
diff --git a/viewer-frontend/migrations/add-pending-photos-table.sql b/viewer-frontend/migrations/add-pending-photos-table.sql
new file mode 100644
index 0000000..a628e10
--- /dev/null
+++ b/viewer-frontend/migrations/add-pending-photos-table.sql
@@ -0,0 +1,32 @@
+-- Migration: Add pending_photos table for photo uploads
+-- This table tracks photos uploaded by users that are pending admin approval
+-- Run this in the punimtag_auth database
+
+CREATE TABLE IF NOT EXISTS pending_photos (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ filename VARCHAR(255) NOT NULL,
+ original_filename VARCHAR(255) NOT NULL,
+ file_path TEXT NOT NULL,
+ file_size INTEGER NOT NULL,
+ mime_type VARCHAR(100) NOT NULL,
+ status VARCHAR(50) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
+ submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ reviewed_at TIMESTAMP,
+ reviewed_by INTEGER,
+ rejection_reason TEXT,
+
+ CONSTRAINT fk_pending_photos_user
+ FOREIGN KEY (user_id)
+ REFERENCES users(id)
+ ON DELETE CASCADE
+);
+
+-- Create indexes for efficient queries
+CREATE INDEX IF NOT EXISTS idx_pending_photos_user_id ON pending_photos(user_id);
+CREATE INDEX IF NOT EXISTS idx_pending_photos_status ON pending_photos(status);
+CREATE INDEX IF NOT EXISTS idx_pending_photos_submitted_at ON pending_photos(submitted_at);
+
+-- Add comment to table
+COMMENT ON TABLE pending_photos IS 'Stores photos uploaded by users that are pending admin approval';
+
diff --git a/viewer-frontend/migrations/add-photo-favorites-table.sql b/viewer-frontend/migrations/add-photo-favorites-table.sql
new file mode 100644
index 0000000..e0fdf69
--- /dev/null
+++ b/viewer-frontend/migrations/add-photo-favorites-table.sql
@@ -0,0 +1,32 @@
+-- Migration: Add photo_favorites table for user favorites
+-- This table tracks photos favorited by users
+-- Run this in the punimtag_auth database
+
+CREATE TABLE IF NOT EXISTS photo_favorites (
+ id SERIAL PRIMARY KEY,
+ photo_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ favorited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT fk_photo_favorites_user
+ FOREIGN KEY (user_id)
+ REFERENCES users(id)
+ ON DELETE CASCADE,
+
+ -- Prevent duplicate favorites from same user for same photo
+ CONSTRAINT uq_photo_user_favorite UNIQUE (photo_id, user_id)
+);
+
+-- Create indexes for efficient queries
+CREATE INDEX IF NOT EXISTS idx_photo_favorites_photo_id ON photo_favorites(photo_id);
+CREATE INDEX IF NOT EXISTS idx_photo_favorites_user_id ON photo_favorites(user_id);
+CREATE INDEX IF NOT EXISTS idx_photo_favorites_favorited_at ON photo_favorites(favorited_at);
+
+-- Add comment to table
+COMMENT ON TABLE photo_favorites IS 'Stores user favorites for photos';
+
+
+
+
+
+
diff --git a/viewer-frontend/migrations/add-report-comment-to-inappropriate-photo-reports.sql b/viewer-frontend/migrations/add-report-comment-to-inappropriate-photo-reports.sql
new file mode 100644
index 0000000..cf2d101
--- /dev/null
+++ b/viewer-frontend/migrations/add-report-comment-to-inappropriate-photo-reports.sql
@@ -0,0 +1,16 @@
+-- Migration: Add report_comment column to inappropriate_photo_reports table
+-- Run this in the punimtag_auth database
+
+ALTER TABLE inappropriate_photo_reports
+ADD COLUMN IF NOT EXISTS report_comment TEXT;
+
+COMMENT ON COLUMN inappropriate_photo_reports.report_comment IS 'Optional short message submitted by the reporting user';
+
+
+
+
+
+
+
+
+
diff --git a/viewer-frontend/migrations/add-write-access-column.sql b/viewer-frontend/migrations/add-write-access-column.sql
new file mode 100644
index 0000000..539152c
--- /dev/null
+++ b/viewer-frontend/migrations/add-write-access-column.sql
@@ -0,0 +1,21 @@
+-- Migration: Add has_write_access column to users table
+-- Run this as a PostgreSQL superuser or with appropriate permissions
+
+-- Add the has_write_access column with default false
+ALTER TABLE users
+ADD COLUMN IF NOT EXISTS has_write_access BOOLEAN NOT NULL DEFAULT false;
+
+-- Create an index on has_write_access for faster queries
+CREATE INDEX IF NOT EXISTS idx_users_has_write_access ON users(has_write_access);
+
+-- Update existing users: only admins should have write access initially
+-- (You may want to adjust this based on your needs)
+UPDATE users
+SET has_write_access = false
+WHERE has_write_access IS NULL;
+
+-- Verify the column was added
+SELECT column_name, data_type, column_default
+FROM information_schema.columns
+WHERE table_name = 'users' AND column_name = 'has_write_access';
+
diff --git a/viewer-frontend/migrations/make-name-required.sql b/viewer-frontend/migrations/make-name-required.sql
new file mode 100644
index 0000000..4ccf51b
--- /dev/null
+++ b/viewer-frontend/migrations/make-name-required.sql
@@ -0,0 +1,19 @@
+-- Migration: Make name column required (NOT NULL) in users table
+-- Run this as a PostgreSQL superuser or with appropriate permissions
+-- Prerequisites: All existing users must have a non-null name value
+
+-- First, ensure all existing users have a name (safety check)
+-- This should not update any rows if all users already have names
+UPDATE users
+SET name = email
+WHERE name IS NULL;
+
+-- Alter the column to be NOT NULL
+ALTER TABLE users
+ALTER COLUMN name SET NOT NULL;
+
+-- Verify the column constraint
+SELECT column_name, data_type, is_nullable, column_default
+FROM information_schema.columns
+WHERE table_name = 'users' AND column_name = 'name';
+
diff --git a/viewer-frontend/next.config.ts b/viewer-frontend/next.config.ts
new file mode 100644
index 0000000..384a05f
--- /dev/null
+++ b/viewer-frontend/next.config.ts
@@ -0,0 +1,27 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ images: {
+ // Configure remote patterns for external image sources (SharePoint, CDN, etc.)
+ remotePatterns: [
+ // SharePoint Online (Microsoft 365)
+ {
+ protocol: 'https',
+ hostname: '**.sharepoint.com',
+ },
+ // SharePoint Server (on-premises) - update with your domain
+ // Uncomment and update if using on-premises SharePoint:
+ // {
+ // protocol: 'https',
+ // hostname: 'sharepoint.yourcompany.com',
+ // },
+ // Add other CDN or image hosting domains as needed
+ ],
+
+ // Enable image optimization in production
+ // In development, images are unoptimized for faster iteration
+ unoptimized: process.env.NODE_ENV === 'development',
+ },
+};
+
+export default nextConfig;
diff --git a/viewer-frontend/package-lock.json b/viewer-frontend/package-lock.json
new file mode 100644
index 0000000..3f9432f
--- /dev/null
+++ b/viewer-frontend/package-lock.json
@@ -0,0 +1,8759 @@
+{
+ "name": "punimtag-viewer",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "punimtag-viewer",
+ "version": "0.1.0",
+ "dependencies": {
+ "@prisma/client": "^6.19.0",
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@tanstack/react-query": "^5.90.9",
+ "@types/bcryptjs": "^2.4.6",
+ "@types/jszip": "^3.4.0",
+ "bcryptjs": "^3.0.3",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "framer-motion": "^12.23.24",
+ "jszip": "^3.10.1",
+ "lucide-react": "^0.553.0",
+ "next": "^16.1.1",
+ "next-auth": "^5.0.0-beta.30",
+ "prisma": "^6.19.0",
+ "react": "19.2.0",
+ "react-day-picker": "^9.11.1",
+ "react-dom": "19.2.0",
+ "react-photo-album": "^3.2.1",
+ "resend": "^6.5.2",
+ "sharp": "^0.34.5",
+ "tailwind-merge": "^3.4.0",
+ "yet-another-react-lightbox": "^3.25.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "dotenv": "^17.2.3",
+ "eslint": "^9",
+ "eslint-config-next": "16.0.3",
+ "node-addon-api": "^8.5.0",
+ "node-gyp": "^12.1.0",
+ "tailwindcss": "^4",
+ "tsx": "^4.20.6",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@auth/core": {
+ "version": "0.41.0",
+ "license": "ISC",
+ "dependencies": {
+ "@panva/hkdf": "^1.2.1",
+ "jose": "^6.0.6",
+ "oauth4webapi": "^3.3.0",
+ "preact": "10.24.3",
+ "preact-render-to-string": "6.5.11"
+ },
+ "peerDependencies": {
+ "@simplewebauthn/browser": "^9.0.1",
+ "@simplewebauthn/server": "^9.0.2",
+ "nodemailer": "^6.8.0"
+ },
+ "peerDependenciesMeta": {
+ "@simplewebauthn/browser": {
+ "optional": true
+ },
+ "@simplewebauthn/server": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.28.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.28.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.5",
+ "@babel/helper-compilation-targets": "^7.27.2",
+ "@babel/helper-module-transforms": "^7.28.3",
+ "@babel/helpers": "^7.28.4",
+ "@babel/parser": "^7.28.5",
+ "@babel/template": "^7.27.2",
+ "@babel/traverse": "^7.28.5",
+ "@babel/types": "^7.28.5",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.28.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.5",
+ "@babel/types": "^7.28.5",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.27.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.27.2",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "@babel/traverse": "^7.28.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.28.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.5"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.27.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/parser": "^7.27.2",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.28.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.5",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.28.5",
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.5",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.28.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@date-fns/tz": {
+ "version": "1.4.1",
+ "license": "MIT"
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.7.1",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.1.0",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.7.1",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.2",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.3",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.4",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.3",
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.6",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.4"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.10",
+ "license": "MIT"
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64/node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@isaacs/balanced-match": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
+ "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@isaacs/brace-expansion": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
+ "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@isaacs/balanced-match": "^4.0.1"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@isaacs/fs-minipass": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
+ "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "0.2.12",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.4.3",
+ "@emnapi/runtime": "^1.4.3",
+ "@tybys/wasm-util": "^0.10.0"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz",
+ "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==",
+ "license": "MIT"
+ },
+ "node_modules/@next/eslint-plugin-next": {
+ "version": "16.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-glob": "3.3.1"
+ }
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz",
+ "integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz",
+ "integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz",
+ "integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz",
+ "integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz",
+ "integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz",
+ "integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz",
+ "integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz",
+ "integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nolyfill/is-core-module": {
+ "version": "1.0.39",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.4.0"
+ }
+ },
+ "node_modules/@npmcli/agent": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz",
+ "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.1",
+ "lru-cache": "^11.2.1",
+ "socks-proxy-agent": "^8.0.3"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/@npmcli/agent/node_modules/lru-cache": {
+ "version": "11.2.4",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
+ "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@npmcli/fs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz",
+ "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/@npmcli/fs/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@panva/hkdf": {
+ "version": "1.2.1",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/@prisma/client": {
+ "version": "6.19.1",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.1.tgz",
+ "integrity": "sha512-4SXj4Oo6HyQkLUWT8Ke5R0PTAfVOKip5Roo+6+b2EDTkFg5be0FnBWiuRJc0BC0sRQIWGMLKW1XguhVfW/z3/A==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "peerDependencies": {
+ "prisma": "*",
+ "typescript": ">=5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/config": {
+ "version": "6.19.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "c12": "3.1.0",
+ "deepmerge-ts": "7.1.5",
+ "effect": "3.18.4",
+ "empathic": "2.0.0"
+ }
+ },
+ "node_modules/@prisma/debug": {
+ "version": "6.19.1",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/engines": {
+ "version": "6.19.1",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.1",
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "@prisma/fetch-engine": "6.19.1",
+ "@prisma/get-platform": "6.19.1"
+ }
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/fetch-engine": {
+ "version": "6.19.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.1",
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "@prisma/get-platform": "6.19.1"
+ }
+ },
+ "node_modules/@prisma/get-platform": {
+ "version": "6.19.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.1"
+ }
+ },
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.1",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.7",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-checkbox": {
+ "version": "1.3.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.1.7",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.15",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.11",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.3",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popover": {
+ "version": "1.1.15",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.2.8",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-rect": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1",
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.9",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select": {
+ "version": "2.2.6",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.2.4",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.2.8",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-visually-hidden": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-previous": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-visually-hidden": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/rect": {
+ "version": "1.1.1",
+ "license": "MIT"
+ },
+ "node_modules/@rtsao/scc": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@stablelib/base64": {
+ "version": "1.0.1",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "license": "MIT"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.18",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "enhanced-resolve": "^5.18.3",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.30.2",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.18",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-x64": "4.1.18",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.18",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.1.18",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
+ "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
+ "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
+ "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
+ "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
+ "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
+ "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
+ "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
+ "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
+ "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.0",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
+ "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
+ "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.1.18",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.1.18",
+ "@tailwindcss/oxide": "4.1.18",
+ "postcss": "^8.4.41",
+ "tailwindcss": "4.1.18"
+ }
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.90.15",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.90.15",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.90.15"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/bcryptjs": {
+ "version": "2.4.6",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json5": {
+ "version": "0.0.29",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/jszip": {
+ "version": "3.4.0",
+ "license": "MIT",
+ "dependencies": {
+ "jszip": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.27",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.7",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "devOptional": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.51.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "8.51.0",
+ "@typescript-eslint/type-utils": "8.51.0",
+ "@typescript-eslint/utils": "8.51.0",
+ "@typescript-eslint/visitor-keys": "8.51.0",
+ "ignore": "^7.0.0",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.51.0",
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.51.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.51.0",
+ "@typescript-eslint/types": "8.51.0",
+ "@typescript-eslint/typescript-estree": "8.51.0",
+ "@typescript-eslint/visitor-keys": "8.51.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.51.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.51.0",
+ "@typescript-eslint/types": "^8.51.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.51.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.51.0",
+ "@typescript-eslint/visitor-keys": "8.51.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.51.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.51.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.51.0",
+ "@typescript-eslint/typescript-estree": "8.51.0",
+ "@typescript-eslint/utils": "8.51.0",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^2.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.51.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.51.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.51.0",
+ "@typescript-eslint/tsconfig-utils": "8.51.0",
+ "@typescript-eslint/types": "8.51.0",
+ "@typescript-eslint/visitor-keys": "8.51.0",
+ "debug": "^4.3.4",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.7.3",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.51.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.7.0",
+ "@typescript-eslint/scope-manager": "8.51.0",
+ "@typescript-eslint/types": "8.51.0",
+ "@typescript-eslint/typescript-estree": "8.51.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.51.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.51.0",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
+ "version": "1.11.1",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/abbrev": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
+ "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array-includes": {
+ "version": "3.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.0",
+ "es-object-atoms": "^1.1.1",
+ "get-intrinsic": "^1.3.0",
+ "is-string": "^1.1.1",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlast": {
+ "version": "1.2.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlastindex": {
+ "version": "1.2.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-shim-unscopables": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flat": {
+ "version": "1.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flatmap": {
+ "version": "1.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.tosorted": {
+ "version": "1.1.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3",
+ "es-errors": "^1.3.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/arraybuffer.prototype.slice": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.1",
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "is-array-buffer": "^3.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/ast-types-flow": {
+ "version": "0.0.8",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/async-function": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/axe-core": {
+ "version": "4.11.0",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.9.11",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
+ "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
+ "node_modules/bcryptjs": {
+ "version": "3.0.3",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/c12": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^4.0.3",
+ "confbox": "^0.2.2",
+ "defu": "^6.1.4",
+ "dotenv": "^16.6.1",
+ "exsolve": "^1.0.7",
+ "giget": "^2.0.0",
+ "jiti": "^2.4.2",
+ "ohash": "^2.0.11",
+ "pathe": "^2.0.3",
+ "perfect-debounce": "^1.0.0",
+ "pkg-types": "^2.2.0",
+ "rc9": "^2.1.2"
+ },
+ "peerDependencies": {
+ "magicast": "^0.3.5"
+ },
+ "peerDependenciesMeta": {
+ "magicast": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/c12/node_modules/dotenv": {
+ "version": "16.6.1",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/cacache": {
+ "version": "20.0.3",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz",
+ "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/fs": "^5.0.0",
+ "fs-minipass": "^3.0.0",
+ "glob": "^13.0.0",
+ "lru-cache": "^11.1.0",
+ "minipass": "^7.0.3",
+ "minipass-collect": "^2.0.1",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "p-map": "^7.0.2",
+ "ssri": "^13.0.0",
+ "unique-filename": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/cacache/node_modules/lru-cache": {
+ "version": "11.2.4",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
+ "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001761",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/citty": {
+ "version": "0.1.6",
+ "license": "MIT",
+ "dependencies": {
+ "consola": "^3.2.3"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/confbox": {
+ "version": "0.2.2",
+ "license": "MIT"
+ },
+ "node_modules/consola": {
+ "version": "3.4.2",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.18.0 || >=16.10.0"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/damerau-levenshtein": {
+ "version": "1.0.8",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/data-view-buffer": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-length": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/inspect-js"
+ }
+ },
+ "node_modules/data-view-byte-offset": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/date-fns-jalali": {
+ "version": "4.1.0-0",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deepmerge-ts": {
+ "version": "7.1.5",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/defu": {
+ "version": "6.1.4",
+ "license": "MIT"
+ },
+ "node_modules/destr": {
+ "version": "2.0.5",
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "license": "MIT"
+ },
+ "node_modules/doctrine": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "17.2.3",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/effect": {
+ "version": "3.18.4",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "fast-check": "^3.23.1"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.267",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/empathic": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/encoding": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
+ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "iconv-lite": "^0.6.2"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.18.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/err-code": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
+ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-abstract": {
+ "version": "1.24.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.2",
+ "arraybuffer.prototype.slice": "^1.0.4",
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "data-view-buffer": "^1.0.2",
+ "data-view-byte-length": "^1.0.2",
+ "data-view-byte-offset": "^1.0.1",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-set-tostringtag": "^2.1.0",
+ "es-to-primitive": "^1.3.0",
+ "function.prototype.name": "^1.1.8",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "get-symbol-description": "^1.1.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "internal-slot": "^1.1.0",
+ "is-array-buffer": "^3.0.5",
+ "is-callable": "^1.2.7",
+ "is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
+ "is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
+ "is-shared-array-buffer": "^1.0.4",
+ "is-string": "^1.1.1",
+ "is-typed-array": "^1.1.15",
+ "is-weakref": "^1.1.1",
+ "math-intrinsics": "^1.1.0",
+ "object-inspect": "^1.13.4",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.7",
+ "own-keys": "^1.0.1",
+ "regexp.prototype.flags": "^1.5.4",
+ "safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
+ "safe-regex-test": "^1.1.0",
+ "set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
+ "string.prototype.trim": "^1.2.10",
+ "string.prototype.trimend": "^1.0.9",
+ "string.prototype.trimstart": "^1.0.8",
+ "typed-array-buffer": "^1.0.3",
+ "typed-array-byte-length": "^1.0.3",
+ "typed-array-byte-offset": "^1.0.4",
+ "typed-array-length": "^1.0.7",
+ "unbox-primitive": "^1.1.0",
+ "which-typed-array": "^1.1.19"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-iterator-helpers": {
+ "version": "1.2.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.1",
+ "es-errors": "^1.3.0",
+ "es-set-tostringtag": "^2.1.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.3.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "iterator.prototype": "^1.1.5",
+ "safe-array-concat": "^1.1.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-shim-unscopables": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7",
+ "is-date-object": "^1.0.5",
+ "is-symbol": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es6-promise": {
+ "version": "4.2.8",
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.2",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.2",
+ "@esbuild/android-arm": "0.27.2",
+ "@esbuild/android-arm64": "0.27.2",
+ "@esbuild/android-x64": "0.27.2",
+ "@esbuild/darwin-arm64": "0.27.2",
+ "@esbuild/darwin-x64": "0.27.2",
+ "@esbuild/freebsd-arm64": "0.27.2",
+ "@esbuild/freebsd-x64": "0.27.2",
+ "@esbuild/linux-arm": "0.27.2",
+ "@esbuild/linux-arm64": "0.27.2",
+ "@esbuild/linux-ia32": "0.27.2",
+ "@esbuild/linux-loong64": "0.27.2",
+ "@esbuild/linux-mips64el": "0.27.2",
+ "@esbuild/linux-ppc64": "0.27.2",
+ "@esbuild/linux-riscv64": "0.27.2",
+ "@esbuild/linux-s390x": "0.27.2",
+ "@esbuild/linux-x64": "0.27.2",
+ "@esbuild/netbsd-arm64": "0.27.2",
+ "@esbuild/netbsd-x64": "0.27.2",
+ "@esbuild/openbsd-arm64": "0.27.2",
+ "@esbuild/openbsd-x64": "0.27.2",
+ "@esbuild/openharmony-arm64": "0.27.2",
+ "@esbuild/sunos-x64": "0.27.2",
+ "@esbuild/win32-arm64": "0.27.2",
+ "@esbuild/win32-ia32": "0.27.2",
+ "@esbuild/win32-x64": "0.27.2"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+ "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/android-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+ "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/android-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+ "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/android-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+ "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+ "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+ "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+ "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+ "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+ "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+ "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+ "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+ "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+ "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+ "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+ "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+ "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+ "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+ "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+ "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild/node_modules/@esbuild/win32-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+ "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.1",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.1",
+ "@eslint/js": "9.39.2",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-config-next": {
+ "version": "16.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@next/eslint-plugin-next": "16.0.3",
+ "eslint-import-resolver-node": "^0.3.6",
+ "eslint-import-resolver-typescript": "^3.5.2",
+ "eslint-plugin-import": "^2.32.0",
+ "eslint-plugin-jsx-a11y": "^6.10.0",
+ "eslint-plugin-react": "^7.37.0",
+ "eslint-plugin-react-hooks": "^7.0.0",
+ "globals": "16.4.0",
+ "typescript-eslint": "^8.46.0"
+ },
+ "peerDependencies": {
+ "eslint": ">=9.0.0",
+ "typescript": ">=3.3.1"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-config-next/node_modules/globals": {
+ "version": "16.4.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint-import-resolver-node": {
+ "version": "0.3.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.2.7",
+ "is-core-module": "^2.13.0",
+ "resolve": "^1.22.4"
+ }
+ },
+ "node_modules/eslint-import-resolver-node/node_modules/debug": {
+ "version": "3.2.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-import-resolver-typescript": {
+ "version": "3.10.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@nolyfill/is-core-module": "1.0.39",
+ "debug": "^4.4.0",
+ "get-tsconfig": "^4.10.0",
+ "is-bun-module": "^2.0.0",
+ "stable-hash": "^0.0.5",
+ "tinyglobby": "^0.2.13",
+ "unrs-resolver": "^1.6.2"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-import-resolver-typescript"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "eslint-plugin-import": "*",
+ "eslint-plugin-import-x": "*"
+ },
+ "peerDependenciesMeta": {
+ "eslint-plugin-import": {
+ "optional": true
+ },
+ "eslint-plugin-import-x": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-module-utils": {
+ "version": "2.12.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.2.7"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-module-utils/node_modules/debug": {
+ "version": "3.2.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-import": {
+ "version": "2.32.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rtsao/scc": "^1.1.0",
+ "array-includes": "^3.1.9",
+ "array.prototype.findlastindex": "^1.2.6",
+ "array.prototype.flat": "^1.3.3",
+ "array.prototype.flatmap": "^1.3.3",
+ "debug": "^3.2.7",
+ "doctrine": "^2.1.0",
+ "eslint-import-resolver-node": "^0.3.9",
+ "eslint-module-utils": "^2.12.1",
+ "hasown": "^2.0.2",
+ "is-core-module": "^2.16.1",
+ "is-glob": "^4.0.3",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "object.groupby": "^1.0.3",
+ "object.values": "^1.2.1",
+ "semver": "^6.3.1",
+ "string.prototype.trimend": "^1.0.9",
+ "tsconfig-paths": "^3.15.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/debug": {
+ "version": "3.2.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-jsx-a11y": {
+ "version": "6.10.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "aria-query": "^5.3.2",
+ "array-includes": "^3.1.8",
+ "array.prototype.flatmap": "^1.3.2",
+ "ast-types-flow": "^0.0.8",
+ "axe-core": "^4.10.0",
+ "axobject-query": "^4.1.0",
+ "damerau-levenshtein": "^1.0.8",
+ "emoji-regex": "^9.2.2",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^3.3.5",
+ "language-tags": "^1.0.9",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "safe-regex-test": "^1.0.3",
+ "string.prototype.includes": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
+ }
+ },
+ "node_modules/eslint-plugin-react": {
+ "version": "7.37.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.8",
+ "array.prototype.findlast": "^1.2.5",
+ "array.prototype.flatmap": "^1.3.3",
+ "array.prototype.tosorted": "^1.1.4",
+ "doctrine": "^2.1.0",
+ "es-iterator-helpers": "^1.2.1",
+ "estraverse": "^5.3.0",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^2.4.1 || ^3.0.0",
+ "minimatch": "^3.1.2",
+ "object.entries": "^1.1.9",
+ "object.fromentries": "^2.0.8",
+ "object.values": "^1.2.1",
+ "prop-types": "^15.8.1",
+ "resolve": "^2.0.0-next.5",
+ "semver": "^6.3.1",
+ "string.prototype.matchall": "^4.0.12",
+ "string.prototype.repeat": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/resolve": {
+ "version": "2.0.0-next.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.13.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.6.0",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/exponential-backoff": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
+ "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/exsolve": {
+ "version": "1.0.8",
+ "license": "MIT"
+ },
+ "node_modules/fast-check": {
+ "version": "3.23.2",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "pure-rand": "^6.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-sha256": {
+ "version": "1.3.0",
+ "license": "Unlicense"
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.3",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/framer-motion": {
+ "version": "12.23.26",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.23.23",
+ "motion-utils": "^12.23.6",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fs-minipass": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz",
+ "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/function.prototype.name": {
+ "version": "1.1.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "functions-have-names": "^1.2.3",
+ "hasown": "^2.0.2",
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/generator-function": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/giget": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.1.6",
+ "consola": "^3.4.0",
+ "defu": "^6.1.4",
+ "node-fetch-native": "^1.6.6",
+ "nypm": "^0.6.0",
+ "pathe": "^2.0.3"
+ },
+ "bin": {
+ "giget": "dist/cli.mjs"
+ }
+ },
+ "node_modules/glob": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
+ "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "minimatch": "^10.1.1",
+ "minipass": "^7.1.2",
+ "path-scurry": "^2.0.0"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
+ "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/brace-expansion": "^5.0.0"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/globals": {
+ "version": "14.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "license": "MIT"
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "license": "ISC"
+ },
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ip-address": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
+ "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-async-function": {
+ "version": "2.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async-function": "^1.0.0",
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bun-module": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.7.1"
+ }
+ },
+ "node_modules/is-bun-module/node_modules/semver": {
+ "version": "7.7.3",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-view": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "is-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-finalizationregistry": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4",
+ "generator-function": "^2.0.0",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakref": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/iterator.prototype": {
+ "version": "1.1.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "get-proto": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/jose": {
+ "version": "6.1.3",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsx-ast-utils": {
+ "version": "3.3.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.6",
+ "array.prototype.flat": "^1.3.1",
+ "object.assign": "^4.1.4",
+ "object.values": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/language-subtag-registry": {
+ "version": "0.3.23",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/language-tags": {
+ "version": "1.0.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "language-subtag-registry": "^0.3.20"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.30.2",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.30.2",
+ "lightningcss-darwin-arm64": "1.30.2",
+ "lightningcss-darwin-x64": "1.30.2",
+ "lightningcss-freebsd-x64": "1.30.2",
+ "lightningcss-linux-arm-gnueabihf": "1.30.2",
+ "lightningcss-linux-arm64-gnu": "1.30.2",
+ "lightningcss-linux-arm64-musl": "1.30.2",
+ "lightningcss-linux-x64-gnu": "1.30.2",
+ "lightningcss-linux-x64-musl": "1.30.2",
+ "lightningcss-win32-arm64-msvc": "1.30.2",
+ "lightningcss-win32-x64-msvc": "1.30.2"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.2",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss/node_modules/lightningcss-android-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+ "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss/node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+ "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss/node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+ "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss/node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+ "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss/node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+ "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss/node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+ "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss/node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+ "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss/node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+ "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss/node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+ "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss/node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+ "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.553.0",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/make-fetch-happen": {
+ "version": "15.0.3",
+ "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz",
+ "integrity": "sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@npmcli/agent": "^4.0.0",
+ "cacache": "^20.0.1",
+ "http-cache-semantics": "^4.1.1",
+ "minipass": "^7.0.2",
+ "minipass-fetch": "^5.0.0",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "negotiator": "^1.0.0",
+ "proc-log": "^6.0.0",
+ "promise-retry": "^2.0.1",
+ "ssri": "^13.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/micromatch/node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minipass-collect": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz",
+ "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minipass-fetch": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.0.tgz",
+ "integrity": "sha512-fiCdUALipqgPWrOVTz9fw0XhcazULXOSU6ie40DDbX1F49p1dBrSRBuswndTx1x3vEb/g0FT7vC4c4C2u/mh3A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.0.3",
+ "minipass-sized": "^1.0.3",
+ "minizlib": "^3.0.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ },
+ "optionalDependencies": {
+ "encoding": "^0.1.13"
+ }
+ },
+ "node_modules/minipass-flush": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz",
+ "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-flush/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-flush/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/minipass-pipeline": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
+ "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-pipeline/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-pipeline/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/minipass-sized": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz",
+ "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-sized/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-sized/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/minizlib": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
+ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "12.23.23",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.23.6"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.23.6",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/napi-postinstall": {
+ "version": "0.3.4",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "napi-postinstall": "lib/cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/napi-postinstall"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/next": {
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz",
+ "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.1.1",
+ "@swc/helpers": "0.5.15",
+ "baseline-browser-mapping": "^2.8.3",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "16.1.1",
+ "@next/swc-darwin-x64": "16.1.1",
+ "@next/swc-linux-arm64-gnu": "16.1.1",
+ "@next/swc-linux-arm64-musl": "16.1.1",
+ "@next/swc-linux-x64-gnu": "16.1.1",
+ "@next/swc-linux-x64-musl": "16.1.1",
+ "@next/swc-win32-arm64-msvc": "16.1.1",
+ "@next/swc-win32-x64-msvc": "16.1.1",
+ "sharp": "^0.34.4"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next-auth": {
+ "version": "5.0.0-beta.30",
+ "license": "ISC",
+ "dependencies": {
+ "@auth/core": "0.41.0"
+ },
+ "peerDependencies": {
+ "@simplewebauthn/browser": "^9.0.1",
+ "@simplewebauthn/server": "^9.0.2",
+ "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
+ "nodemailer": "^7.0.7",
+ "react": "^18.2.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@simplewebauthn/browser": {
+ "optional": true
+ },
+ "@simplewebauthn/server": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
+ "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
+ "node_modules/node-fetch-native": {
+ "version": "1.6.7",
+ "license": "MIT"
+ },
+ "node_modules/node-gyp": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.1.0.tgz",
+ "integrity": "sha512-W+RYA8jBnhSr2vrTtlPYPc1K+CSjGpVDRZxcqJcERZ8ND3A1ThWPHRwctTx3qC3oW99jt726jhdz3Y6ky87J4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "env-paths": "^2.2.0",
+ "exponential-backoff": "^3.1.1",
+ "graceful-fs": "^4.2.6",
+ "make-fetch-happen": "^15.0.0",
+ "nopt": "^9.0.0",
+ "proc-log": "^6.0.0",
+ "semver": "^7.3.5",
+ "tar": "^7.5.2",
+ "tinyglobby": "^0.2.12",
+ "which": "^6.0.0"
+ },
+ "bin": {
+ "node-gyp": "bin/node-gyp.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/node-gyp/node_modules/isexe": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
+ "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/node-gyp/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-gyp/node_modules/which": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz",
+ "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^3.1.1"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nopt": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz",
+ "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "^4.0.0"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/nypm": {
+ "version": "0.6.2",
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.1.6",
+ "consola": "^3.4.2",
+ "pathe": "^2.0.3",
+ "pkg-types": "^2.3.0",
+ "tinyexec": "^1.0.1"
+ },
+ "bin": {
+ "nypm": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": "^14.16.0 || >=16.10.0"
+ }
+ },
+ "node_modules/oauth4webapi": {
+ "version": "3.8.3",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.entries": {
+ "version": "1.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.fromentries": {
+ "version": "2.0.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.groupby": {
+ "version": "1.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.values": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/ohash": {
+ "version": "2.0.11",
+ "license": "MIT"
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/own-keys": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-intrinsic": "^1.2.6",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-map": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
+ "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-scurry": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
+ "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "11.2.4",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
+ "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "license": "MIT"
+ },
+ "node_modules/perfect-debounce": {
+ "version": "1.0.0",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pkg-types": {
+ "version": "2.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.2.2",
+ "exsolve": "^1.0.7",
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/preact": {
+ "version": "10.24.3",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/preact-render-to-string": {
+ "version": "6.5.11",
+ "license": "MIT",
+ "peerDependencies": {
+ "preact": ">=10"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prisma": {
+ "version": "6.19.1",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/config": "6.19.1",
+ "@prisma/engines": "6.19.1"
+ },
+ "bin": {
+ "prisma": "build/index.js"
+ },
+ "engines": {
+ "node": ">=18.18"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/proc-log": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
+ "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "license": "MIT"
+ },
+ "node_modules/promise-retry": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
+ "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "err-code": "^2.0.2",
+ "retry": "^0.12.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/pure-rand": {
+ "version": "6.1.0",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/querystringify": {
+ "version": "2.2.0",
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/rc9": {
+ "version": "2.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "defu": "^6.1.4",
+ "destr": "^2.0.3"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-day-picker": {
+ "version": "9.13.0",
+ "license": "MIT",
+ "dependencies": {
+ "@date-fns/tz": "^1.4.1",
+ "date-fns": "^4.1.0",
+ "date-fns-jalali": "^4.1.0-0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/gpbl"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/react-photo-album": {
+ "version": "3.4.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/igordanchenko"
+ },
+ "peerDependencies": {
+ "@types/react": "^18 || ^19",
+ "react": "^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.2",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/reflect.getprototypeof": {
+ "version": "1.0.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.7",
+ "get-proto": "^1.0.1",
+ "which-builtin-type": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/requires-port": {
+ "version": "1.0.0",
+ "license": "MIT"
+ },
+ "node_modules/resend": {
+ "version": "6.6.0",
+ "license": "MIT",
+ "dependencies": {
+ "svix": "1.76.1"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@react-email/render": "*"
+ },
+ "peerDependenciesMeta": {
+ "@react-email/render": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/safe-array-concat": {
+ "version": "1.1.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "has-symbols": "^1.1.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">=0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-array-concat/node_modules/isarray": {
+ "version": "2.0.5",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "license": "MIT"
+ },
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-push-apply/node_modules/isarray": {
+ "version": "2.0.5",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-proto": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "license": "MIT"
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/sharp/node_modules/semver": {
+ "version": "7.7.3",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.7",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
+ "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.0.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
+ "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "socks": "^2.8.3"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ssri": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz",
+ "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/stable-hash": {
+ "version": "0.0.5",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/string.prototype.includes": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string.prototype.matchall": {
+ "version": "4.0.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "regexp.prototype.flags": "^1.5.3",
+ "set-function-name": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.repeat": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.17.5"
+ }
+ },
+ "node_modules/string.prototype.trim": {
+ "version": "1.2.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-data-property": "^1.1.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-object-atoms": "^1.0.0",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/strip-bom": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/svix": {
+ "version": "1.76.1",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/base64": "^1.0.0",
+ "@types/node": "^22.7.5",
+ "es6-promise": "^4.2.8",
+ "fast-sha256": "^1.3.0",
+ "url-parse": "^1.5.10",
+ "uuid": "^10.0.0"
+ }
+ },
+ "node_modules/svix/node_modules/@types/node": {
+ "version": "22.19.3",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "3.4.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.1.18",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tar": {
+ "version": "7.5.2",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz",
+ "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/fs-minipass": "^4.0.0",
+ "chownr": "^3.0.0",
+ "minipass": "^7.1.2",
+ "minizlib": "^3.1.0",
+ "yallist": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tar/node_modules/chownr": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
+ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tar/node_modules/yallist": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
+ "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.3.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/tsconfig-paths": {
+ "version": "3.15.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/json5": "^0.0.29",
+ "json5": "^1.0.2",
+ "minimist": "^1.2.6",
+ "strip-bom": "^3.0.0"
+ }
+ },
+ "node_modules/tsconfig-paths/node_modules/json5": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "license": "0BSD"
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/tsx/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/tw-animate-css": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Wombosvideo"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typed-array-byte-length": {
+ "version": "1.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-byte-offset": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "reflect.getprototypeof": "^1.0.9"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-length": {
+ "version": "1.0.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "for-each": "^0.3.3",
+ "gopd": "^1.0.1",
+ "is-typed-array": "^1.1.13",
+ "possible-typed-array-names": "^1.0.0",
+ "reflect.getprototypeof": "^1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.51.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.51.0",
+ "@typescript-eslint/parser": "8.51.0",
+ "@typescript-eslint/typescript-estree": "8.51.0",
+ "@typescript-eslint/utils": "8.51.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/unbox-primitive": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "which-boxed-primitive": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "license": "MIT"
+ },
+ "node_modules/unique-filename": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz",
+ "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "unique-slug": "^6.0.0"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/unique-slug": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz",
+ "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "imurmurhash": "^0.1.4"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/unrs-resolver": {
+ "version": "1.11.1",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "napi-postinstall": "^0.3.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/unrs-resolver"
+ },
+ "optionalDependencies": {
+ "@unrs/resolver-binding-android-arm-eabi": "1.11.1",
+ "@unrs/resolver-binding-android-arm64": "1.11.1",
+ "@unrs/resolver-binding-darwin-arm64": "1.11.1",
+ "@unrs/resolver-binding-darwin-x64": "1.11.1",
+ "@unrs/resolver-binding-freebsd-x64": "1.11.1",
+ "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1",
+ "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1",
+ "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-arm64-musl": "1.11.1",
+ "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1",
+ "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-x64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-x64-musl": "1.11.1",
+ "@unrs/resolver-binding-wasm32-wasi": "1.11.1",
+ "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1",
+ "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1",
+ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
+ }
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-android-arm-eabi": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
+ "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-android-arm64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
+ "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-darwin-arm64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
+ "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-darwin-x64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
+ "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-freebsd-x64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
+ "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
+ "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
+ "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
+ "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-linux-arm64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
+ "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
+ "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
+ "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
+ "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
+ "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-linux-x64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
+ "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-wasm32-wasi": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
+ "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^0.2.11"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
+ "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
+ "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-win32-x64-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
+ "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/url-parse": {
+ "version": "1.5.10",
+ "license": "MIT",
+ "dependencies": {
+ "querystringify": "^2.1.1",
+ "requires-port": "^1.0.0"
+ }
+ },
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "license": "MIT"
+ },
+ "node_modules/uuid": {
+ "version": "10.0.0",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-builtin-type": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "function.prototype.name": "^1.1.6",
+ "has-tostringtag": "^1.0.2",
+ "is-async-function": "^2.0.0",
+ "is-date-object": "^1.1.0",
+ "is-finalizationregistry": "^1.1.0",
+ "is-generator-function": "^1.0.10",
+ "is-regex": "^1.2.1",
+ "is-weakref": "^1.0.2",
+ "isarray": "^2.0.5",
+ "which-boxed-primitive": "^1.1.0",
+ "which-collection": "^1.0.2",
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-builtin-type/node_modules/isarray": {
+ "version": "2.0.5",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.19",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yet-another-react-lightbox": {
+ "version": "3.28.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/igordanchenko"
+ },
+ "peerDependencies": {
+ "@types/react": "^16 || ^17 || ^18 || ^19",
+ "@types/react-dom": "^16 || ^17 || ^18 || ^19",
+ "react": "^16.8.0 || ^17 || ^18 || ^19",
+ "react-dom": "^16.8.0 || ^17 || ^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.2.1",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ }
+ }
+}
diff --git a/viewer-frontend/package.json b/viewer-frontend/package.json
new file mode 100644
index 0000000..4c98fed
--- /dev/null
+++ b/viewer-frontend/package.json
@@ -0,0 +1,66 @@
+{
+ "name": "punimtag-viewer",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "./scripts/with-sharp-libpath.sh next dev -p 3001",
+ "prebuild": "npm run prisma:generate:all",
+ "build": "./scripts/with-sharp-libpath.sh next build",
+ "start": "./scripts/with-sharp-libpath.sh next start",
+ "start:3001": "PORT=3001 ./scripts/with-sharp-libpath.sh next start",
+ "lint": "next lint",
+ "type-check": "tsc --noEmit",
+ "prisma:generate": "prisma generate",
+ "prisma:generate:auth": "prisma generate --schema=prisma/schema-auth.prisma",
+ "prisma:generate:all": "prisma generate && prisma generate --schema=prisma/schema-auth.prisma",
+ "check:permissions": "tsx scripts/check-database-permissions.ts",
+ "setup:databases": "./scripts/check-and-create-databases.sh",
+ "install:deps": "./scripts/install-dependencies.sh",
+ "setup": "./run-setup.sh"
+ },
+ "dependencies": {
+ "@prisma/client": "^6.19.0",
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@tanstack/react-query": "^5.90.9",
+ "@types/bcryptjs": "^2.4.6",
+ "@types/jszip": "^3.4.0",
+ "bcryptjs": "^3.0.3",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "framer-motion": "^12.23.24",
+ "jszip": "^3.10.1",
+ "lucide-react": "^0.553.0",
+ "next": "^16.1.1",
+ "next-auth": "^5.0.0-beta.30",
+ "prisma": "^6.19.0",
+ "react": "19.2.0",
+ "react-day-picker": "^9.11.1",
+ "react-dom": "19.2.0",
+ "react-photo-album": "^3.2.1",
+ "resend": "^6.5.2",
+ "sharp": "^0.34.5",
+ "tailwind-merge": "^3.4.0",
+ "yet-another-react-lightbox": "^3.25.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "dotenv": "^17.2.3",
+ "eslint": "^9",
+ "eslint-config-next": "16.0.3",
+ "node-addon-api": "^8.5.0",
+ "node-gyp": "^12.1.0",
+ "tailwindcss": "^4",
+ "tsx": "^4.20.6",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5"
+ }
+}
diff --git a/viewer-frontend/postcss.config.mjs b/viewer-frontend/postcss.config.mjs
new file mode 100644
index 0000000..61e3684
--- /dev/null
+++ b/viewer-frontend/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/viewer-frontend/prisma.config.ts.bak b/viewer-frontend/prisma.config.ts.bak
new file mode 100644
index 0000000..6b6d3b6
--- /dev/null
+++ b/viewer-frontend/prisma.config.ts.bak
@@ -0,0 +1,12 @@
+import { defineConfig, env } from "prisma/config";
+
+export default defineConfig({
+ schema: "prisma/schema.prisma",
+ migrations: {
+ path: "prisma/migrations",
+ },
+ engine: "classic",
+ datasource: {
+ url: env("DATABASE_URL"),
+ },
+});
diff --git a/viewer-frontend/prisma/schema-auth.prisma b/viewer-frontend/prisma/schema-auth.prisma
new file mode 100644
index 0000000..f325ca5
--- /dev/null
+++ b/viewer-frontend/prisma/schema-auth.prisma
@@ -0,0 +1,143 @@
+generator client {
+ provider = "prisma-client-js"
+ output = "../node_modules/.prisma/client-auth"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL_AUTH")
+}
+
+model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ name String
+ passwordHash String @map("password_hash")
+ isAdmin Boolean @default(false) @map("is_admin")
+ hasWriteAccess Boolean @default(false) @map("has_write_access")
+ emailVerified Boolean @default(false) @map("email_verified")
+ emailConfirmationToken String? @unique @map("email_confirmation_token")
+ emailConfirmationTokenExpiry DateTime? @map("email_confirmation_token_expiry")
+ passwordResetToken String? @unique @map("password_reset_token")
+ passwordResetTokenExpiry DateTime? @map("password_reset_token_expiry")
+ isActive Boolean @default(true) @map("is_active")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ pendingIdentifications PendingIdentification[]
+ pendingPhotos PendingPhoto[]
+ inappropriatePhotoReports InappropriatePhotoReport[]
+ pendingLinkages PendingLinkage[]
+ photoFavorites PhotoFavorite[]
+
+ @@map("users")
+}
+
+model PendingIdentification {
+ id Int @id @default(autoincrement())
+ faceId Int @map("face_id")
+ userId Int @map("user_id")
+ firstName String @map("first_name")
+ lastName String @map("last_name")
+ middleName String? @map("middle_name")
+ maidenName String? @map("maiden_name")
+ dateOfBirth DateTime? @map("date_of_birth") @db.Date
+ status String @default("pending") // pending, approved, rejected
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ // Note: faceId references faces in the punimtag database, but we can't use a foreign key
+ // across databases. We'll validate the face exists in application code.
+
+ @@index([faceId])
+ @@index([userId])
+ @@index([status])
+ @@map("pending_identifications")
+}
+
+model PendingPhoto {
+ id Int @id @default(autoincrement())
+ userId Int @map("user_id")
+ filename String
+ originalFilename String @map("original_filename")
+ filePath String @map("file_path")
+ fileSize Int @map("file_size")
+ mimeType String @map("mime_type")
+ status String @default("pending") // pending, approved, rejected
+ submittedAt DateTime @default(now()) @map("submitted_at")
+ reviewedAt DateTime? @map("reviewed_at")
+ reviewedBy Int? @map("reviewed_by") // Admin user ID who reviewed
+ rejectionReason String? @map("rejection_reason")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+ @@index([status])
+ @@index([submittedAt])
+ @@map("pending_photos")
+}
+
+model InappropriatePhotoReport {
+ id Int @id @default(autoincrement())
+ photoId Int @map("photo_id")
+ userId Int @map("user_id")
+ status String @default("pending") // pending, reviewed, dismissed
+ reportedAt DateTime @default(now()) @map("reported_at")
+ reviewedAt DateTime? @map("reviewed_at")
+ reviewedBy Int? @map("reviewed_by") // Admin user ID who reviewed
+ reviewNotes String? @map("review_notes")
+ reportComment String? @map("report_comment")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ // Note: photoId references photos in the punimtag database, but we can't use a foreign key
+ // across databases. We'll validate the photo exists in application code.
+
+ @@unique([photoId, userId], name: "uq_photo_user_report") // Prevent duplicate reports from same user
+ @@index([photoId])
+ @@index([userId])
+ @@index([status])
+ @@index([reportedAt])
+ @@map("inappropriate_photo_reports")
+}
+
+model PendingLinkage {
+ id Int @id @default(autoincrement())
+ photoId Int @map("photo_id")
+ tagId Int? @map("tag_id")
+ tagName String? @map("tag_name")
+ userId Int @map("user_id")
+ status String @default("pending")
+ notes String? @map("notes")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([photoId])
+ @@index([tagId])
+ @@index([userId])
+ @@index([status])
+ @@map("pending_linkages")
+}
+
+model PhotoFavorite {
+ id Int @id @default(autoincrement())
+ photoId Int @map("photo_id")
+ userId Int @map("user_id")
+ favoritedAt DateTime @default(now()) @map("favorited_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ // Note: photoId references photos in the punimtag database, but we can't use a foreign key
+ // across databases. We'll validate the photo exists in application code.
+
+ @@unique([photoId, userId], name: "uq_photo_user_favorite")
+ @@index([photoId])
+ @@index([userId])
+ @@index([favoritedAt])
+ @@map("photo_favorites")
+}
+
diff --git a/viewer-frontend/prisma/schema.prisma b/viewer-frontend/prisma/schema.prisma
new file mode 100644
index 0000000..4983657
--- /dev/null
+++ b/viewer-frontend/prisma/schema.prisma
@@ -0,0 +1,214 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+model AlembicVersion {
+ @@map("alembic_version")
+ version_num String @id
+}
+
+model Face {
+ @@map("faces")
+ id Int @id @default(autoincrement())
+ photo_id Int
+ person_id Int?
+ encoding Bytes
+ location String
+ confidence Decimal
+ quality_score Decimal
+ is_primary_encoding Boolean
+ detector_backend String
+ model_name String
+ face_confidence Decimal
+ exif_orientation Int?
+ pose_mode String
+ yaw_angle Decimal?
+ pitch_angle Decimal?
+ roll_angle Decimal?
+ landmarks String?
+ identified_by_user_id Int?
+ excluded Boolean @default(false)
+ Person Person? @relation(fields: [person_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
+ Photo Photo @relation(fields: [photo_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
+ User User? @relation(fields: [identified_by_user_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
+ PersonEncoding PersonEncoding[]
+
+ @@index([excluded], map: "idx_faces_excluded")
+ @@index([identified_by_user_id], map: "idx_faces_identified_by")
+ @@index([pose_mode], map: "idx_faces_pose_mode")
+ @@index([photo_id], map: "ix_faces_photo_id")
+ @@index([quality_score], map: "idx_faces_quality")
+ @@index([photo_id], map: "idx_faces_photo_id")
+ @@index([id], map: "ix_faces_id")
+ @@index([person_id], map: "idx_faces_person_id")
+ @@index([person_id], map: "ix_faces_person_id")
+ @@index([quality_score], map: "ix_faces_quality_score")
+ @@index([pose_mode], map: "ix_faces_pose_mode")
+}
+
+model Person {
+ @@map("people")
+ id Int @id @default(autoincrement())
+ first_name String
+ last_name String
+ middle_name String?
+ maiden_name String?
+ date_of_birth DateTime?
+ created_date DateTime
+ Face Face[]
+ PersonEncoding PersonEncoding[]
+ PhotoPersonLinkage PhotoPersonLinkage[]
+
+ @@unique([first_name, last_name, middle_name, maiden_name, date_of_birth], map: "sqlite_autoindex_people_1")
+ @@index([id], map: "ix_people_id")
+}
+
+model PersonEncoding {
+ @@map("person_encodings")
+ id Int @id @default(autoincrement())
+ person_id Int
+ face_id Int
+ encoding Bytes
+ quality_score Decimal
+ detector_backend String
+ model_name String
+ created_date DateTime
+ Face Face @relation(fields: [face_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
+ Person Person @relation(fields: [person_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
+
+ @@index([person_id], map: "ix_person_encodings_person_id")
+ @@index([quality_score], map: "idx_person_encodings_quality")
+ @@index([id], map: "ix_person_encodings_id")
+ @@index([face_id], map: "ix_person_encodings_face_id")
+ @@index([person_id], map: "idx_person_encodings_person_id")
+ @@index([quality_score], map: "ix_person_encodings_quality_score")
+}
+
+model PhotoFavorite {
+ @@map("photo_favorites")
+ id Int @id @default(autoincrement())
+ username String
+ photo_id Int
+ created_date DateTime
+ Photo Photo @relation(fields: [photo_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
+
+ @@unique([username, photo_id], map: "sqlite_autoindex_photo_favorites_1")
+ @@index([username], map: "ix_photo_favorites_username")
+ @@index([username], map: "idx_favorites_username")
+ @@index([photo_id], map: "idx_favorites_photo")
+ @@index([photo_id], map: "ix_photo_favorites_photo_id")
+}
+
+model PhotoPersonLinkage {
+ @@map("photo_person_linkage")
+ id Int @id @default(autoincrement())
+ photo_id Int
+ person_id Int
+ identified_by_user_id Int?
+ created_date DateTime
+ User User? @relation(fields: [identified_by_user_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
+ Person Person @relation(fields: [person_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
+ Photo Photo @relation(fields: [photo_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
+
+ @@unique([photo_id, person_id], map: "sqlite_autoindex_photo_person_linkage_1")
+ @@index([identified_by_user_id], map: "idx_photo_person_user")
+ @@index([person_id], map: "idx_photo_person_person")
+ @@index([photo_id], map: "idx_photo_person_photo")
+ @@index([photo_id], map: "ix_photo_person_linkage_photo_id")
+ @@index([person_id], map: "ix_photo_person_linkage_person_id")
+ @@index([identified_by_user_id], map: "ix_photo_person_linkage_identified_by_user_id")
+}
+
+model Photo {
+ @@map("photos")
+ id Int @id @default(autoincrement())
+ path String @unique(map: "ix_photos_path")
+ filename String
+ date_added DateTime
+ date_taken DateTime?
+ processed Boolean
+ media_type String?
+ Face Face[]
+ PhotoFavorite PhotoFavorite[]
+ PhotoPersonLinkage PhotoPersonLinkage[]
+ PhotoTagLinkage PhotoTagLinkage[]
+
+ @@index([media_type], map: "idx_photos_media_type")
+ @@index([date_added], map: "idx_photos_date_added")
+ @@index([date_taken], map: "idx_photos_date_taken")
+ @@index([processed], map: "ix_photos_processed")
+ @@index([processed], map: "idx_photos_processed")
+ @@index([date_taken], map: "ix_photos_date_taken")
+ @@index([id], map: "ix_photos_id")
+}
+
+model PhotoTagLinkage {
+ @@map("phototaglinkage")
+ linkage_id Int @id @default(autoincrement())
+ photo_id Int
+ tag_id Int
+ linkage_type Int @default(0)
+ created_date DateTime
+ Tag Tag @relation(fields: [tag_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
+ Photo Photo @relation(fields: [photo_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
+
+ @@unique([photo_id, tag_id], map: "sqlite_autoindex_phototaglinkage_1")
+ @@index([tag_id], map: "ix_phototaglinkage_tag_id")
+ @@index([photo_id], map: "ix_phototaglinkage_photo_id")
+ @@index([tag_id], map: "idx_photo_tags_tag")
+ @@index([photo_id], map: "idx_photo_tags_photo")
+}
+
+model role_permissions {
+ id Int @id @default(autoincrement())
+ role String
+ feature_key String
+ allowed Boolean @default(false)
+
+ @@unique([role, feature_key], map: "sqlite_autoindex_role_permissions_1")
+ @@index([feature_key], map: "ix_role_permissions_feature_key")
+ @@index([role], map: "ix_role_permissions_role")
+ @@index([role, feature_key], map: "idx_role_permissions_role_feature")
+}
+
+model Tag {
+ @@map("tags")
+ id Int @id @default(autoincrement())
+ tag_name String @unique(map: "ix_tags_tag_name")
+ created_date DateTime
+ PhotoTagLinkage PhotoTagLinkage[]
+
+ @@index([id], map: "ix_tags_id")
+}
+
+model User {
+ @@map("users")
+ id Int @id @default(autoincrement())
+ username String @unique(map: "ix_users_username")
+ password_hash String
+ email String @unique(map: "ix_users_email")
+ full_name String
+ is_active Boolean
+ is_admin Boolean
+ role String @default("viewer")
+ password_change_required Boolean
+ created_date DateTime
+ last_login DateTime?
+ Face Face[]
+ PhotoPersonLinkage PhotoPersonLinkage[]
+
+ @@index([email], map: "idx_users_email")
+ @@index([username], map: "idx_users_username")
+ @@index([id], map: "ix_users_id")
+ @@index([password_change_required], map: "ix_users_password_change_required")
+ @@index([role], map: "idx_users_role")
+ @@index([role], map: "ix_users_role")
+ @@index([password_change_required], map: "idx_users_password_change_required")
+ @@index([is_admin], map: "idx_users_is_admin")
+ @@index([is_admin], map: "ix_users_is_admin")
+}
diff --git a/viewer-frontend/public/file.svg b/viewer-frontend/public/file.svg
new file mode 100644
index 0000000..004145c
--- /dev/null
+++ b/viewer-frontend/public/file.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/viewer-frontend/public/globe.svg b/viewer-frontend/public/globe.svg
new file mode 100644
index 0000000..567f17b
--- /dev/null
+++ b/viewer-frontend/public/globe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/viewer-frontend/public/jam-watermark.svg b/viewer-frontend/public/jam-watermark.svg
new file mode 100644
index 0000000..710ba44
--- /dev/null
+++ b/viewer-frontend/public/jam-watermark.svg
@@ -0,0 +1,27 @@
+
+ JAM Jewish and Modern watermark
+ Stylized JAM logo with Star of David and the words Jewish and Modern
+
+
+
+
+
+
+
+
+ J
+ M
+
+
+
+ Jewish
+ and Modern
+
+
+
+
+
+
+
+
+
diff --git a/viewer-frontend/public/next.svg b/viewer-frontend/public/next.svg
new file mode 100644
index 0000000..5174b28
--- /dev/null
+++ b/viewer-frontend/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/viewer-frontend/public/vercel.svg b/viewer-frontend/public/vercel.svg
new file mode 100644
index 0000000..7705396
--- /dev/null
+++ b/viewer-frontend/public/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/viewer-frontend/public/window.svg b/viewer-frontend/public/window.svg
new file mode 100644
index 0000000..b2b2a44
--- /dev/null
+++ b/viewer-frontend/public/window.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/viewer-frontend/reset_viewer_password.sql b/viewer-frontend/reset_viewer_password.sql
new file mode 100644
index 0000000..1bbe221
--- /dev/null
+++ b/viewer-frontend/reset_viewer_password.sql
@@ -0,0 +1,20 @@
+-- Reset password for viewer_readonly user
+-- Run this script as a PostgreSQL superuser (e.g., postgres user)
+-- This will set the password to match what's in your .env file: punimtag_password
+
+-- Reset the password
+ALTER USER viewer_readonly WITH PASSWORD 'punimtag_password';
+
+-- Verify permissions are still correct
+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;
+
+-- Display success message
+\echo 'Password for viewer_readonly has been reset to: punimtag_password'
+\echo 'This should now match your .env file'
+
diff --git a/viewer-frontend/run-setup.sh b/viewer-frontend/run-setup.sh
new file mode 100755
index 0000000..c2a57cc
--- /dev/null
+++ b/viewer-frontend/run-setup.sh
@@ -0,0 +1,114 @@
+#!/bin/bash
+# Comprehensive Setup Script for PunimTag Photo Viewer
+# This script handles the complete setup process including:
+# - Dependency installation
+# - Prisma client generation
+# - Database setup
+# - Admin user creation
+
+set -e # Exit on error
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR" && pwd)"
+
+cd "$PROJECT_DIR"
+
+echo "🚀 PunimTag Photo Viewer - Complete Setup"
+echo "========================================"
+echo ""
+
+# Step 1: Install dependencies
+echo "📦 Step 1: Installing dependencies..."
+if [ -f "scripts/install-dependencies.sh" ]; then
+ ./scripts/install-dependencies.sh
+else
+ echo " Running npm install..."
+ npm install
+ echo " Generating Prisma clients..."
+ npm run prisma:generate:all
+fi
+echo ""
+
+# Step 2: Check for .env file
+echo "📋 Step 2: Checking environment configuration..."
+if [ ! -f ".env" ]; then
+ echo "⚠️ .env file not found!"
+ echo ""
+ echo " Please create a .env file with the following variables:"
+ echo " - DATABASE_URL (required)"
+ echo " - DATABASE_URL_AUTH (optional, for authentication)"
+ echo " - DATABASE_URL_WRITE (optional, for write operations)"
+ echo " - NEXTAUTH_SECRET (required for authentication)"
+ echo " - NEXTAUTH_URL (required for authentication)"
+ echo ""
+ read -p "Continue with database setup anyway? (y/N) " -n 1 -r
+ echo
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ echo "Setup cancelled. Please create .env file and run again."
+ exit 1
+ fi
+else
+ echo "✅ .env file found"
+ # Load environment variables
+ export $(grep -v '^#' .env | xargs)
+fi
+echo ""
+
+# Step 3: Database setup
+echo "🗄️ Step 3: Setting up database..."
+if [ -z "$DATABASE_URL_AUTH" ]; then
+ echo "⚠️ DATABASE_URL_AUTH not set in .env"
+ echo " Skipping auth database setup"
+ echo " (Authentication features will not be available)"
+else
+ echo " Setting up auth database tables and admin user..."
+ npx tsx scripts/setup-database.ts || {
+ echo "⚠️ Database setup encountered an error"
+ echo " This might be normal if tables already exist"
+ echo " Continuing..."
+ }
+fi
+echo ""
+
+# Step 4: Verify setup
+echo "✅ Step 4: Verifying setup..."
+echo " Checking Prisma clients..."
+if [ -d "node_modules/.prisma/client" ]; then
+ echo " ✅ Main Prisma client found"
+else
+ echo " ⚠️ Main Prisma client not found, generating..."
+ npm run prisma:generate
+fi
+
+if [ -d "node_modules/.prisma/client-auth" ]; then
+ echo " ✅ Auth Prisma client found"
+else
+ if [ -n "$DATABASE_URL_AUTH" ]; then
+ echo " ⚠️ Auth Prisma client not found, generating..."
+ npm run prisma:generate:auth
+ fi
+fi
+echo ""
+
+# Step 5: Test database connection (optional)
+if [ -n "$DATABASE_URL" ]; then
+ echo "🔍 Step 5: Testing database connection..."
+ if npm run check:permissions > /dev/null 2>&1; then
+ echo " ✅ Database connection successful"
+ else
+ echo " ⚠️ Database connection test failed"
+ echo " Run 'npm run check:permissions' manually to diagnose"
+ fi
+ echo ""
+fi
+
+echo "========================================"
+echo "✅ Setup complete!"
+echo ""
+echo "Next steps:"
+echo "1. Verify your .env file is properly configured"
+echo "2. Run 'npm run dev' to start the development server"
+echo "3. Visit http://localhost:3001 in your browser"
+echo ""
+echo "For more information, see README.md"
+echo ""
diff --git a/viewer-frontend/scripts/check-admin-user.ts b/viewer-frontend/scripts/check-admin-user.ts
new file mode 100644
index 0000000..0764dbb
--- /dev/null
+++ b/viewer-frontend/scripts/check-admin-user.ts
@@ -0,0 +1,87 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import bcrypt from 'bcryptjs';
+import * as dotenv from 'dotenv';
+
+// Load environment variables
+dotenv.config({ path: '.env' });
+
+const prisma = new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+});
+
+async function checkAdminUser() {
+ try {
+ console.log('Checking admin user...\n');
+ if (process.env.DATABASE_URL_AUTH) {
+ // Mask password in connection string for display
+ const masked = process.env.DATABASE_URL_AUTH.replace(/:([^:@]+)@/, ':****@');
+ console.log('DATABASE_URL_AUTH:', masked);
+
+ // Check if it has placeholder values
+ if (process.env.DATABASE_URL_AUTH.includes('username') || process.env.DATABASE_URL_AUTH.includes('password')) {
+ console.log('\n⚠️ WARNING: DATABASE_URL_AUTH contains placeholder values!');
+ console.log('Please update .env file with actual database credentials.');
+ console.log('Format: DATABASE_URL_AUTH="postgresql://actual_username:actual_password@localhost:5432/punimtag_auth"');
+ return;
+ }
+ } else {
+ console.log('DATABASE_URL_AUTH: NOT SET');
+ console.log('Please add DATABASE_URL_AUTH to your .env file');
+ return;
+ }
+ console.log('');
+
+ const user = await prisma.user.findUnique({
+ where: { email: 'admin@admin.com' },
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ isAdmin: true,
+ passwordHash: true,
+ },
+ });
+
+ if (!user) {
+ console.log('❌ Admin user NOT FOUND');
+ console.log('\nRun: npx tsx scripts/create-admin-user.ts');
+ return;
+ }
+
+ console.log('✅ Admin user found:');
+ console.log(' ID:', user.id);
+ console.log(' Email:', user.email);
+ console.log(' Name:', user.name);
+ console.log(' Is Admin:', user.isAdmin);
+ console.log(' Password Hash:', user.passwordHash.substring(0, 20) + '...');
+
+ // Test password
+ const testPassword = 'admin';
+ const isValid = await bcrypt.compare(testPassword, user.passwordHash);
+ console.log('\nPassword test:');
+ console.log(' Testing password "admin":', isValid ? '✅ VALID' : '❌ INVALID');
+
+ if (!isValid) {
+ console.log('\n⚠️ Password hash does not match. Recreating admin user...');
+ const newHash = await bcrypt.hash('admin', 10);
+ await prisma.user.update({
+ where: { email: 'admin@admin.com' },
+ data: {
+ passwordHash: newHash,
+ isAdmin: true,
+ },
+ });
+ console.log('✅ Admin user password updated');
+ }
+ } catch (error: any) {
+ console.error('Error:', error.message);
+ if (error.message.includes('P1001')) {
+ console.error('\n⚠️ Cannot connect to database. Check DATABASE_URL_AUTH in .env');
+ }
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+checkAdminUser();
+
diff --git a/viewer-frontend/scripts/check-and-create-databases.sh b/viewer-frontend/scripts/check-and-create-databases.sh
new file mode 100755
index 0000000..9e4a16a
--- /dev/null
+++ b/viewer-frontend/scripts/check-and-create-databases.sh
@@ -0,0 +1,197 @@
+#!/bin/bash
+
+# Script to check and create databases based on README requirements
+# This script checks for punimtag and punimtag_auth databases and creates them if needed
+
+set -e
+
+echo "🔍 Checking databases..."
+echo ""
+
+# Load .env file if it exists
+if [ -f .env ]; then
+ export $(grep -v '^#' .env | xargs)
+fi
+
+# Try to extract connection info from DATABASE_URL or use defaults
+# Format: postgresql://user:password@host:port/database
+if [ -n "$DATABASE_URL" ]; then
+ # Extract connection details from DATABASE_URL
+ DB_URL="$DATABASE_URL"
+ # Remove postgresql:// prefix
+ DB_URL="${DB_URL#postgresql://}"
+ # Extract user:password@host:port/database
+ if [[ "$DB_URL" =~ ^([^:]+):([^@]+)@([^:]+):([^/]+)/(.+)$ ]]; then
+ PGUSER="${BASH_REMATCH[1]}"
+ PGPASSWORD="${BASH_REMATCH[2]}"
+ PGHOST="${BASH_REMATCH[3]}"
+ PGPORT="${BASH_REMATCH[4]}"
+ elif [[ "$DB_URL" =~ ^([^@]+)@([^:]+):([^/]+)/(.+)$ ]]; then
+ PGUSER="${BASH_REMATCH[1]}"
+ PGHOST="${BASH_REMATCH[2]}"
+ PGPORT="${BASH_REMATCH[3]}"
+ elif [[ "$DB_URL" =~ ^([^@]+)@([^/]+)/(.+)$ ]]; then
+ PGUSER="${BASH_REMATCH[1]}"
+ PGHOST="${BASH_REMATCH[2]}"
+ PGPORT="5432"
+ fi
+fi
+
+# For database creation, we need a superuser
+# Try to use postgres user, or allow override via POSTGRES_SUPERUSER env var
+SUPERUSER=${POSTGRES_SUPERUSER:-postgres}
+SUPERUSER_PASSWORD=${POSTGRES_SUPERUSER_PASSWORD:-}
+
+# Use defaults if not set
+PGUSER=${PGUSER:-postgres}
+PGHOST=${PGHOST:-localhost}
+PGPORT=${PGPORT:-5432}
+
+# Export password if set
+if [ -n "$PGPASSWORD" ]; then
+ export PGPASSWORD
+fi
+
+# For superuser operations, use separate password if provided
+if [ -n "$SUPERUSER_PASSWORD" ]; then
+ export PGPASSWORD="$SUPERUSER_PASSWORD"
+ ADMIN_USER="$SUPERUSER"
+else
+ # Try to use the same user/password, or prompt
+ ADMIN_USER="$SUPERUSER"
+fi
+
+# Check if punimtag database exists
+echo "Checking punimtag database..."
+if psql -h "$PGHOST" -p "$PGPORT" -U "$ADMIN_USER" -d postgres -lqt 2>/dev/null | cut -d \| -f 1 | grep -qw punimtag; then
+ echo "✅ punimtag database exists"
+else
+ echo "⚠️ punimtag database does not exist"
+ echo " This is the main database with photos - it should already exist."
+ echo " If you need to create it, please do so manually or ensure your PunimTag setup is complete."
+fi
+
+echo ""
+
+# Check if punimtag_auth database exists
+echo "Checking punimtag_auth database..."
+if psql -h "$PGHOST" -p "$PGPORT" -U "$ADMIN_USER" -d postgres -lqt 2>/dev/null | cut -d \| -f 1 | grep -qw punimtag_auth; then
+ echo "✅ punimtag_auth database exists"
+ AUTH_DB_EXISTS=true
+else
+ echo "📦 Creating punimtag_auth database..."
+ echo " (This requires a PostgreSQL superuser - using: $ADMIN_USER)"
+ psql -h "$PGHOST" -p "$PGPORT" -U "$ADMIN_USER" -d postgres -c "CREATE DATABASE punimtag_auth;" 2>&1
+ if [ $? -eq 0 ]; then
+ echo "✅ punimtag_auth database created"
+ AUTH_DB_EXISTS=true
+ else
+ echo "❌ Failed to create punimtag_auth database"
+ echo " You may need to run this as a PostgreSQL superuser:"
+ echo " sudo -u postgres psql -c 'CREATE DATABASE punimtag_auth;'"
+ exit 1
+ fi
+fi
+
+echo ""
+
+# Now check and create tables in punimtag_auth
+if [ "$AUTH_DB_EXISTS" = true ]; then
+ echo "🔍 Checking tables in punimtag_auth..."
+
+ # Check if users table exists
+ if psql -h "$PGHOST" -p "$PGPORT" -U "$ADMIN_USER" -d punimtag_auth -c "\dt users" 2>/dev/null | grep -q "users"; then
+ echo "✅ Tables already exist in punimtag_auth"
+ else
+ echo "📋 Creating tables in punimtag_auth..."
+
+ # Create tables using create_auth_tables.sql
+ if [ -f "create_auth_tables.sql" ]; then
+ echo " Running create_auth_tables.sql..."
+ psql -h "$PGHOST" -p "$PGPORT" -U "$ADMIN_USER" -d punimtag_auth -f create_auth_tables.sql 2>&1
+ else
+ echo " ⚠️ create_auth_tables.sql not found, creating tables manually..."
+ psql -h "$PGHOST" -p "$PGPORT" -U "$ADMIN_USER" -d punimtag_auth </dev/null | grep -q "pending_photos"; then
+ echo "✅ pending_photos table exists"
+ else
+ echo "📋 Creating pending_photos table..."
+ if [ -f "migrations/add-pending-photos-table.sql" ]; then
+ psql -h "$PGHOST" -p "$PGPORT" -U "$ADMIN_USER" -d punimtag_auth -f migrations/add-pending-photos-table.sql 2>&1
+ echo "✅ pending_photos table created"
+ else
+ echo " ⚠️ Migration file not found, skipping..."
+ fi
+ fi
+
+ # Check email verification columns
+ if psql -h "$PGHOST" -p "$PGPORT" -U "$ADMIN_USER" -d punimtag_auth -c "\d users" 2>/dev/null | grep -q "email_verified"; then
+ echo "✅ Email verification columns exist"
+ else
+ echo "📋 Adding email verification columns..."
+ if [ -f "migrations/add-email-verification-columns.sql" ]; then
+ psql -h "$PGHOST" -p "$PGPORT" -U "$ADMIN_USER" -d punimtag_auth -f migrations/add-email-verification-columns.sql 2>&1
+ echo "✅ Email verification columns added"
+ else
+ echo " ⚠️ Migration file not found, skipping..."
+ fi
+ fi
+fi
+
+echo ""
+echo "🎉 Database setup complete!"
+echo ""
+echo "Note: If you encountered permission errors, you may need to run this script"
+echo " with a PostgreSQL superuser. You can set:"
+echo " POSTGRES_SUPERUSER=postgres POSTGRES_SUPERUSER_PASSWORD=yourpassword ./scripts/check-and-create-databases.sh"
+echo ""
+echo "Next steps:"
+echo "1. Ensure your .env file has correct DATABASE_URL and DATABASE_URL_AUTH"
+echo "2. Run: npm run prisma:generate:all"
+echo "3. Create admin user: npx tsx scripts/create-admin-user.ts"
+
diff --git a/viewer-frontend/scripts/check-and-create-databases.ts b/viewer-frontend/scripts/check-and-create-databases.ts
new file mode 100644
index 0000000..f96da87
--- /dev/null
+++ b/viewer-frontend/scripts/check-and-create-databases.ts
@@ -0,0 +1,261 @@
+import { Client } from 'pg';
+import * as dotenv from 'dotenv';
+import { readFileSync } from 'fs';
+import { join } from 'path';
+
+// Load environment variables
+dotenv.config({ path: '.env' });
+
+async function checkAndCreateDatabases() {
+ // Get connection info from DATABASE_URL or use defaults
+ const mainDbUrl = process.env.DATABASE_URL || 'postgresql://postgres@localhost:5432/postgres';
+ const authDbUrl = process.env.DATABASE_URL_AUTH || 'postgresql://postgres@localhost:5432/postgres';
+
+ // Parse connection strings to get connection details
+ const parseUrl = (url: string) => {
+ const match = url.match(/postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/(.+)/) ||
+ url.match(/postgresql:\/\/([^@]+)@([^:]+):(\d+)\/(.+)/) ||
+ url.match(/postgresql:\/\/([^@]+)@([^:]+)\/(.+)/);
+
+ if (match) {
+ if (match.length === 6) {
+ // With password
+ return {
+ user: match[1],
+ password: match[2],
+ host: match[3],
+ port: parseInt(match[4]),
+ database: match[5],
+ };
+ } else if (match.length === 5) {
+ // Without password, with port
+ return {
+ user: match[1],
+ host: match[2],
+ port: parseInt(match[3]),
+ database: match[4],
+ };
+ } else if (match.length === 4) {
+ // Without password, without port
+ return {
+ user: match[1],
+ host: match[2],
+ port: 5432,
+ database: match[3],
+ };
+ }
+ }
+
+ // Fallback to defaults
+ return {
+ user: 'postgres',
+ host: 'localhost',
+ port: 5432,
+ database: 'postgres',
+ };
+ };
+
+ const mainConfig = parseUrl(mainDbUrl);
+ const authConfig = parseUrl(authDbUrl);
+
+ // Connect to postgres database to check/create databases
+ const adminClient = new Client({
+ user: mainConfig.user,
+ password: (mainConfig as any).password,
+ host: mainConfig.host,
+ port: mainConfig.port,
+ database: 'postgres', // Connect to postgres database to create other databases
+ });
+
+ try {
+ console.log('🔍 Checking databases...\n');
+ await adminClient.connect();
+ console.log('✅ Connected to PostgreSQL\n');
+
+ // Check if punimtag database exists
+ const mainDbCheck = await adminClient.query(
+ "SELECT 1 FROM pg_database WHERE datname = 'punimtag'"
+ );
+
+ if (mainDbCheck.rows.length === 0) {
+ console.log('⚠️ punimtag database does not exist');
+ console.log(' This is the main database with photos - it should already exist.');
+ console.log(' If you need to create it, please do so manually or ensure your PunimTag setup is complete.\n');
+ } else {
+ console.log('✅ punimtag database exists\n');
+ }
+
+ // Check if punimtag_auth database exists
+ const authDbCheck = await adminClient.query(
+ "SELECT 1 FROM pg_database WHERE datname = 'punimtag_auth'"
+ );
+
+ if (authDbCheck.rows.length === 0) {
+ console.log('📦 Creating punimtag_auth database...');
+ await adminClient.query('CREATE DATABASE punimtag_auth');
+ console.log('✅ punimtag_auth database created\n');
+ } else {
+ console.log('✅ punimtag_auth database exists\n');
+ }
+
+ await adminClient.end();
+
+ // Now connect to punimtag_auth and create tables
+ const authClient = new Client({
+ user: authConfig.user,
+ password: (authConfig as any).password,
+ host: authConfig.host,
+ port: authConfig.port,
+ database: 'punimtag_auth',
+ });
+
+ try {
+ await authClient.connect();
+ console.log('🔗 Connected to punimtag_auth database\n');
+
+ // Check if users table exists
+ const usersTableCheck = await authClient.query(`
+ SELECT EXISTS (
+ SELECT FROM information_schema.tables
+ WHERE table_schema = 'public'
+ AND table_name = 'users'
+ );
+ `);
+
+ if (!usersTableCheck.rows[0].exists) {
+ console.log('📋 Creating tables in punimtag_auth...');
+
+ // Read and execute setup-auth-tables.sql
+ const setupScript = readFileSync(
+ join(__dirname, '../setup-auth-tables.sql'),
+ 'utf-8'
+ );
+
+ // Split by semicolons and execute each statement
+ const statements = setupScript
+ .split(';')
+ .map(s => s.trim())
+ .filter(s => s.length > 0 && !s.startsWith('--') && !s.startsWith('\\'));
+
+ for (const statement of statements) {
+ if (statement.length > 0) {
+ try {
+ await authClient.query(statement);
+ } catch (error: any) {
+ // Ignore "already exists" errors
+ if (!error.message.includes('already exists')) {
+ console.error(`Error executing: ${statement.substring(0, 50)}...`);
+ throw error;
+ }
+ }
+ }
+ }
+
+ console.log('✅ Tables created\n');
+ } else {
+ console.log('✅ Tables already exist in punimtag_auth\n');
+ }
+
+ // Check for required migrations
+ console.log('🔍 Checking for required migrations...\n');
+
+ // Check pending_photos table
+ const pendingPhotosCheck = await authClient.query(`
+ SELECT EXISTS (
+ SELECT FROM information_schema.tables
+ WHERE table_schema = 'public'
+ AND table_name = 'pending_photos'
+ );
+ `);
+
+ if (!pendingPhotosCheck.rows[0].exists) {
+ console.log('📋 Creating pending_photos table...');
+ const migrationScript = readFileSync(
+ join(__dirname, '../migrations/add-pending-photos-table.sql'),
+ 'utf-8'
+ );
+ const statements = migrationScript
+ .split(';')
+ .map(s => s.trim())
+ .filter(s => s.length > 0 && !s.startsWith('--') && !s.startsWith('\\'));
+
+ for (const statement of statements) {
+ if (statement.length > 0) {
+ try {
+ await authClient.query(statement);
+ } catch (error: any) {
+ if (!error.message.includes('already exists')) {
+ throw error;
+ }
+ }
+ }
+ }
+ console.log('✅ pending_photos table created\n');
+ }
+
+ // Check email verification columns
+ const emailVerificationCheck = await authClient.query(`
+ SELECT column_name
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'users'
+ AND column_name = 'email_verified'
+ `);
+
+ if (emailVerificationCheck.rows.length === 0) {
+ console.log('📋 Adding email verification columns...');
+ const migrationScript = readFileSync(
+ join(__dirname, '../migrations/add-email-verification-columns.sql'),
+ 'utf-8'
+ );
+ const statements = migrationScript
+ .split(';')
+ .map(s => s.trim())
+ .filter(s => s.length > 0 && !s.startsWith('--') && !s.startsWith('\\'));
+
+ for (const statement of statements) {
+ if (statement.length > 0) {
+ try {
+ await authClient.query(statement);
+ } catch (error: any) {
+ if (!error.message.includes('already exists')) {
+ throw error;
+ }
+ }
+ }
+ }
+ console.log('✅ Email verification columns added\n');
+ }
+
+ console.log('🎉 Database setup complete!\n');
+ console.log('Next steps:');
+ console.log('1. Ensure your .env file has correct DATABASE_URL_AUTH');
+ console.log('2. Run: npm run prisma:generate:all');
+ console.log('3. Create admin user: npx tsx scripts/create-admin-user.ts');
+
+ } catch (error: any) {
+ console.error('\n❌ Error setting up tables:', error.message);
+ if (error.message.includes('permission denied')) {
+ console.error('\n⚠️ Permission denied. You may need to run this as a PostgreSQL superuser.');
+ }
+ throw error;
+ } finally {
+ await authClient.end();
+ }
+
+ } catch (error: any) {
+ console.error('\n❌ Error:', error.message);
+ if (error.message.includes('password authentication failed')) {
+ console.error('\n⚠️ Authentication failed. Please check:');
+ console.error(' 1. Database credentials in .env file');
+ console.error(' 2. PostgreSQL is running');
+ console.error(' 3. User has permission to create databases');
+ }
+ process.exit(1);
+ } finally {
+ await adminClient.end();
+ }
+}
+
+checkAndCreateDatabases();
+
diff --git a/viewer-frontend/scripts/check-database-permissions.ts b/viewer-frontend/scripts/check-database-permissions.ts
new file mode 100755
index 0000000..218a639
--- /dev/null
+++ b/viewer-frontend/scripts/check-database-permissions.ts
@@ -0,0 +1,139 @@
+#!/usr/bin/env tsx
+/**
+ * Check database permissions and provide helpful error messages
+ * This script checks if the read-only user has SELECT permissions on required tables
+ */
+
+import { PrismaClient } from '@prisma/client';
+import { prisma } from '../lib/db';
+
+const REQUIRED_TABLES = [
+ 'photos',
+ 'people',
+ 'faces',
+ 'person_encodings',
+ 'tags',
+ 'phototaglinkage',
+ 'photo_favorites',
+];
+
+async function checkPermissions() {
+ console.log('🔍 Checking database permissions...\n');
+
+ // Extract username from DATABASE_URL
+ const dbUrl = process.env.DATABASE_URL;
+ if (!dbUrl) {
+ console.error('❌ DATABASE_URL not found in environment variables');
+ console.log('\nPlease add DATABASE_URL to your .env file:');
+ console.log('DATABASE_URL="postgresql://username:password@localhost:5432/punimtag"');
+ process.exit(1);
+ }
+
+ const match = dbUrl.match(/postgresql:\/\/([^:]+):/);
+ if (!match) {
+ console.error('❌ Could not parse username from DATABASE_URL');
+ process.exit(1);
+ }
+
+ const username = match[1];
+ console.log(`📋 Checking permissions for user: ${username}\n`);
+
+ const errors: string[] = [];
+ const successes: string[] = [];
+
+ // Test each required table
+ for (const table of REQUIRED_TABLES) {
+ try {
+ // Try to query the table
+ let query: any;
+ switch (table) {
+ case 'photos':
+ query = prisma.photo.findFirst();
+ break;
+ case 'people':
+ query = prisma.person.findFirst();
+ break;
+ case 'faces':
+ query = prisma.face.findFirst();
+ break;
+ case 'person_encodings':
+ // Skip person_encodings if not in schema
+ try {
+ query = (prisma as any).personEncoding?.findFirst();
+ if (!query) continue;
+ } catch {
+ continue;
+ }
+ break;
+ case 'tags':
+ query = prisma.tag.findFirst();
+ break;
+ case 'phototaglinkage':
+ query = prisma.photoTagLinkage.findFirst();
+ break;
+ case 'photo_favorites':
+ query = prisma.photoFavorite.findFirst();
+ break;
+ default:
+ continue;
+ }
+
+ if (query) {
+ await query;
+ successes.push(`✅ ${table} - SELECT permission OK`);
+ }
+ } catch (error: any) {
+ if (error.message?.includes('permission denied')) {
+ errors.push(`❌ ${table} - Permission denied`);
+ } else if (error.message?.includes('does not exist')) {
+ errors.push(`⚠️ ${table} - Table does not exist (may be OK if not used)`);
+ } else {
+ errors.push(`❌ ${table} - ${error.message}`);
+ }
+ }
+ }
+
+ console.log('\n📊 Permission Check Results:\n');
+ successes.forEach((msg) => console.log(msg));
+ if (errors.length > 0) {
+ console.log('');
+ errors.forEach((msg) => console.log(msg));
+ }
+
+ if (errors.some((e) => e.includes('Permission denied'))) {
+ console.log('\n❌ Permission errors detected!\n');
+ console.log('To fix this, run the following SQL as a PostgreSQL superuser:\n');
+ console.log('```bash');
+ console.log(`psql -U postgres -d punimtag -f grant_readonly_permissions.sql`);
+ console.log('```\n');
+ console.log('Or manually run:\n');
+ console.log('```sql');
+ console.log(`-- Connect to database`);
+ console.log(`\\c punimtag`);
+ console.log('');
+ console.log(`-- Grant permissions`);
+ REQUIRED_TABLES.forEach((table) => {
+ console.log(`GRANT SELECT ON TABLE ${table} TO ${username};`);
+ });
+ console.log(`GRANT USAGE ON SCHEMA public TO ${username};`);
+ console.log(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${username};`);
+ console.log('```\n');
+ process.exit(1);
+ }
+
+ console.log('\n✅ All required permissions are granted!');
+ process.exit(0);
+}
+
+checkPermissions().catch((error) => {
+ console.error('Error checking permissions:', error);
+ process.exit(1);
+});
+
+
+
+
+
+
+
+
diff --git a/viewer-frontend/scripts/check-user-and-resend.ts b/viewer-frontend/scripts/check-user-and-resend.ts
new file mode 100644
index 0000000..6e6b658
--- /dev/null
+++ b/viewer-frontend/scripts/check-user-and-resend.ts
@@ -0,0 +1,178 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import { Resend } from 'resend';
+import * as dotenv from 'dotenv';
+import crypto from 'crypto';
+
+dotenv.config();
+
+const prismaAuth = new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+});
+
+const resend = new Resend(process.env.RESEND_API_KEY);
+
+async function checkAndResend() {
+ try {
+ console.log('🔍 Checking users in database...\n');
+
+ // Find users that are not verified
+ const unverifiedUsers = await prismaAuth.user.findMany({
+ where: {
+ emailVerified: false,
+ },
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ emailConfirmationToken: true,
+ emailConfirmationTokenExpiry: true,
+ createdAt: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ });
+
+ if (unverifiedUsers.length === 0) {
+ console.log('✅ No unverified users found.');
+ console.log('\n📋 All users:');
+ const allUsers = await prismaAuth.user.findMany({
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ emailVerified: true,
+ createdAt: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ });
+ allUsers.forEach(user => {
+ console.log(` - ${user.email} (${user.name}) - Verified: ${user.emailVerified}`);
+ });
+ return;
+ }
+
+ console.log(`Found ${unverifiedUsers.length} unverified user(s):\n`);
+
+ for (const user of unverifiedUsers) {
+ console.log(`📧 User: ${user.email} (${user.name})`);
+ console.log(` Created: ${user.createdAt}`);
+ console.log(` Has token: ${user.emailConfirmationToken ? 'Yes' : 'No'}`);
+ if (user.emailConfirmationTokenExpiry) {
+ const isExpired = user.emailConfirmationTokenExpiry < new Date();
+ console.log(` Token expires: ${user.emailConfirmationTokenExpiry} ${isExpired ? '(EXPIRED)' : ''}`);
+ }
+ console.log('');
+ }
+
+ // Get the most recent unverified user
+ const latestUser = unverifiedUsers[0];
+ console.log(`\n📤 Attempting to resend confirmation email to: ${latestUser.email}\n`);
+
+ // Generate new token if needed
+ let token = latestUser.emailConfirmationToken;
+ if (!token || (latestUser.emailConfirmationTokenExpiry && latestUser.emailConfirmationTokenExpiry < new Date())) {
+ console.log('🔄 Generating new confirmation token...');
+ token = crypto.randomBytes(32).toString('hex');
+ const tokenExpiry = new Date();
+ tokenExpiry.setHours(tokenExpiry.getHours() + 24);
+
+ await prismaAuth.user.update({
+ where: { id: latestUser.id },
+ data: {
+ emailConfirmationToken: token,
+ emailConfirmationTokenExpiry: tokenExpiry,
+ },
+ });
+ console.log('✅ New token generated');
+ }
+
+ // Send email
+ const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001';
+ const confirmationUrl = `${baseUrl}/api/auth/verify-email?token=${token}`;
+ const fromEmail = process.env.RESEND_FROM_EMAIL?.trim().replace(/^["']|["']$/g, '') || 'onboarding@resend.dev';
+
+ console.log(`📧 Sending email from: ${fromEmail}`);
+ console.log(`📧 Sending email to: ${latestUser.email}`);
+ console.log(`🔗 Confirmation URL: ${confirmationUrl}\n`);
+
+ try {
+ const result = await resend.emails.send({
+ from: fromEmail,
+ to: latestUser.email,
+ subject: 'Confirm your email address',
+ html: `
+
+
+
+
+
+ Confirm your email
+
+
+
+
Confirm your email address
+
Hi ${latestUser.name},
+
You requested a new confirmation email. Please confirm your email address by clicking the button below:
+
+
Or copy and paste this link into your browser:
+
${confirmationUrl}
+
+ This link will expire in 24 hours. If you didn't request this email, you can safely ignore it.
+
+
+
+
+ `,
+ });
+
+ if (result.error) {
+ console.error('❌ Error from Resend API:');
+ console.error(' Status:', result.error.statusCode);
+ console.error(' Message:', result.error.message);
+ console.error(' Name:', result.error.name);
+
+ if (result.error.message?.includes('domain')) {
+ console.error('\n⚠️ IMPORTANT: Domain verification issue!');
+ console.error(' Resend\'s test domain (onboarding@resend.dev) can only send to:');
+ console.error(' - The email address associated with your Resend account');
+ console.error(' - To send to other addresses, you need to verify your own domain');
+ console.error(' - Go to: https://resend.com/domains to verify a domain');
+ }
+ } else {
+ console.log('✅ Email sent successfully!');
+ console.log(' Email ID:', result.data?.id);
+ console.log(`\n📬 Check the inbox for: ${latestUser.email}`);
+ console.log(' (Also check spam/junk folder)');
+ }
+ } catch (error: any) {
+ console.error('❌ Error sending email:');
+ console.error(' Message:', error.message);
+ if (error.response) {
+ console.error(' Response:', JSON.stringify(error.response, null, 2));
+ }
+ }
+
+ } catch (error: any) {
+ console.error('❌ Error:', error.message);
+ if (error.message?.includes('email_verified')) {
+ console.error('\n⚠️ Database migration may not have been run!');
+ console.error(' Run: sudo -u postgres psql -d punimtag_auth -f migrations/add-email-verification-columns.sql');
+ }
+ } finally {
+ await prismaAuth.$disconnect();
+ }
+}
+
+checkAndResend();
+
+
+
+
+
+
+
diff --git a/viewer-frontend/scripts/create-admin-user.ts b/viewer-frontend/scripts/create-admin-user.ts
new file mode 100644
index 0000000..b940e9e
--- /dev/null
+++ b/viewer-frontend/scripts/create-admin-user.ts
@@ -0,0 +1,70 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import bcrypt from 'bcryptjs';
+import * as dotenv from 'dotenv';
+
+// Load environment variables
+dotenv.config({ path: '.env' });
+
+const prisma = new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+});
+
+async function createAdminUser() {
+ try {
+ console.log('Creating admin user...\n');
+
+ // Hash the password
+ const passwordHash = await bcrypt.hash('admin', 10);
+
+ // Check if admin user already exists
+ const existingAdmin = await prisma.user.findUnique({
+ where: { email: 'admin@admin.com' },
+ });
+
+ if (existingAdmin) {
+ console.log('Admin user already exists. Updating password and admin status...');
+ await prisma.user.update({
+ where: { email: 'admin@admin.com' },
+ data: {
+ passwordHash,
+ isAdmin: true,
+ name: 'Admin',
+ hasWriteAccess: true, // Admins should have write access
+ },
+ });
+ console.log('✅ Admin user updated');
+ } else {
+ console.log('Creating new admin user...');
+ await prisma.user.create({
+ data: {
+ email: 'admin@admin.com',
+ name: 'Admin',
+ passwordHash,
+ isAdmin: true,
+ hasWriteAccess: true, // Admins should have write access
+ },
+ });
+ console.log('✅ Admin user created');
+ }
+
+ console.log('\n✅ Admin user setup complete!');
+ console.log('\nAdmin credentials:');
+ console.log(' Email: admin@admin.com');
+ console.log(' Password: admin');
+ console.log(' Role: Admin (can approve identifications)');
+ } catch (error: any) {
+ console.error('Error creating admin user:', error);
+ if (error.message.includes('permission denied')) {
+ console.error('\n⚠️ Permission denied. Make sure:');
+ console.error(' 1. Database tables are created (run setup-auth-complete.sql)');
+ console.error(' 2. Database user has INSERT/UPDATE permissions on users table');
+ console.error(' 3. DATABASE_URL_AUTH is correctly configured in .env');
+ }
+ throw error;
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+createAdminUser();
+
diff --git a/viewer-frontend/scripts/find-corrupted-data.ts b/viewer-frontend/scripts/find-corrupted-data.ts
new file mode 100755
index 0000000..7b38b04
--- /dev/null
+++ b/viewer-frontend/scripts/find-corrupted-data.ts
@@ -0,0 +1,308 @@
+#!/usr/bin/env tsx
+/**
+ * Script to identify corrupted data in the database
+ * Finds records with invalid characters that cause P2023 Prisma conversion errors
+ */
+
+import { PrismaClient } from '@prisma/client';
+import * as dotenv from 'dotenv';
+
+// Load environment variables
+dotenv.config({ path: '.env' });
+
+const prisma = new PrismaClient({
+ log: ['error'],
+});
+
+/**
+ * Check if a string contains invalid UTF-8 characters
+ */
+function hasInvalidChars(str: string | null | undefined): boolean {
+ if (!str) return false;
+ try {
+ // Try to encode/decode the string
+ const encoded = new TextEncoder().encode(str);
+ const decoded = new TextDecoder('utf-8', { fatal: true }).decode(encoded);
+ return decoded !== str;
+ } catch (e) {
+ return true;
+ }
+}
+
+/**
+ * Find problematic characters in a string
+ */
+function findProblematicChars(str: string): string[] {
+ const problematic: string[] = [];
+ for (let i = 0; i < str.length; i++) {
+ const char = str[i];
+ const code = char.charCodeAt(0);
+ // Check for invalid UTF-8 sequences or control characters (except common ones)
+ if (
+ (code >= 0 && code < 32 && code !== 9 && code !== 10 && code !== 13) ||
+ (code >= 127 && code < 160) ||
+ code === 0xfffe ||
+ code === 0xffff
+ ) {
+ problematic.push(`U+${code.toString(16).padStart(4, '0')} (${char})`);
+ }
+ }
+ return problematic;
+}
+
+async function findCorruptedPeople() {
+ console.log('🔍 Checking Person table for corrupted data...\n');
+
+ try {
+ // Try to fetch all people
+ const people = 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,
+ },
+ });
+
+ console.log(`✅ Successfully fetched ${people.length} people\n`);
+ console.log('Checking for invalid characters in text fields...\n');
+
+ let corruptedCount = 0;
+ const corruptedRecords: Array<{
+ id: number;
+ field: string;
+ value: string;
+ problematicChars: string[];
+ }> = [];
+
+ for (const person of people) {
+ const fields = [
+ { name: 'first_name', value: person.first_name },
+ { name: 'last_name', value: person.last_name },
+ { name: 'middle_name', value: person.middle_name },
+ { name: 'maiden_name', value: person.maiden_name },
+ ];
+
+ for (const field of fields) {
+ if (field.value && hasInvalidChars(field.value)) {
+ const problematic = findProblematicChars(field.value);
+ corruptedRecords.push({
+ id: person.id,
+ field: field.name,
+ value: field.value,
+ problematicChars: problematic,
+ });
+ corruptedCount++;
+ }
+ }
+ }
+
+ if (corruptedCount === 0) {
+ console.log('✅ No corrupted text data found in Person table\n');
+ } else {
+ console.log(`⚠️ Found ${corruptedCount} corrupted field(s) in ${corruptedRecords.length} record(s):\n`);
+ for (const record of corruptedRecords) {
+ console.log(` Person ID ${record.id}, Field: ${record.field}`);
+ console.log(` Value: ${JSON.stringify(record.value)}`);
+ console.log(` Problematic characters: ${record.problematicChars.join(', ')}`);
+ console.log('');
+ }
+ }
+
+ return corruptedRecords;
+ } catch (error: any) {
+ if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
+ console.error('❌ Cannot query Person table due to conversion error');
+ console.error(' This means there is definitely corrupted data, but we cannot identify which records\n');
+ console.error(' Error details:', error.message);
+
+ // Try to query with raw SQL to identify problematic records
+ console.log('\n🔍 Attempting to identify corrupted records using raw SQL...\n');
+ try {
+ // First, get all records with text fields only
+ const textFields = await prisma.$queryRaw>`
+ SELECT id, first_name, last_name, middle_name, maiden_name
+ FROM people
+ `;
+
+ console.log(`Found ${textFields.length} records via raw SQL (text fields only)\n`);
+ console.log('Checking text fields for invalid characters...\n');
+
+ let corruptedCount = 0;
+ for (const person of textFields) {
+ const fields = [
+ { name: 'first_name', value: person.first_name },
+ { name: 'last_name', value: person.last_name },
+ { name: 'middle_name', value: person.middle_name },
+ { name: 'maiden_name', value: person.maiden_name },
+ ];
+
+ for (const field of fields) {
+ if (field.value && hasInvalidChars(field.value)) {
+ const problematic = findProblematicChars(field.value);
+ console.log(` Person ID ${person.id}, Field: ${field.name}`);
+ console.log(` Value: ${JSON.stringify(field.value)}`);
+ console.log(` Problematic characters: ${problematic.join(', ')}`);
+ console.log('');
+ corruptedCount++;
+ }
+ }
+ }
+
+ if (corruptedCount === 0) {
+ console.log('✅ No invalid characters found in text fields');
+ }
+
+ // Now check date fields
+ console.log('\n🔍 Checking date fields (date_of_birth, created_date)...\n');
+ try {
+ const dateFields = await prisma.$queryRaw>`
+ SELECT id, date_of_birth, created_date
+ FROM people
+ `;
+
+ console.log(`Checking ${dateFields.length} records for corrupted date fields...\n`);
+
+ let dateCorruptedCount = 0;
+ for (const person of dateFields) {
+ const issues: string[] = [];
+
+ // Check created_date
+ if (person.created_date) {
+ try {
+ const date = new Date(person.created_date);
+ if (isNaN(date.getTime())) {
+ issues.push(`created_date: invalid date value "${person.created_date}"`);
+ }
+ } catch (e) {
+ issues.push(`created_date: cannot parse "${person.created_date}"`);
+ }
+ }
+
+ // Check date_of_birth
+ if (person.date_of_birth) {
+ try {
+ const date = new Date(person.date_of_birth);
+ if (isNaN(date.getTime())) {
+ issues.push(`date_of_birth: invalid date value "${person.date_of_birth}"`);
+ }
+ } catch (e) {
+ issues.push(`date_of_birth: cannot parse "${person.date_of_birth}"`);
+ }
+ }
+
+ if (issues.length > 0) {
+ console.log(` Person ID ${person.id}:`);
+ for (const issue of issues) {
+ console.log(` ❌ ${issue}`);
+ }
+ console.log('');
+ dateCorruptedCount++;
+ }
+ }
+
+ if (dateCorruptedCount === 0) {
+ console.log('✅ No corrupted date fields found\n');
+ } else {
+ console.log(`⚠️ Found ${dateCorruptedCount} record(s) with corrupted date fields\n`);
+ }
+
+ // Try to identify which specific field is causing the issue
+ console.log('🔍 Testing individual field queries...\n');
+
+ // Test querying without date_of_birth
+ try {
+ await prisma.$queryRaw`SELECT id, first_name, last_name, middle_name, maiden_name, created_date FROM people LIMIT 1`;
+ console.log('✅ Query without date_of_birth works');
+ } catch (e: any) {
+ console.log('❌ Query without date_of_birth still fails:', e.message);
+ }
+
+ // Test querying without created_date
+ try {
+ await prisma.$queryRaw`SELECT id, first_name, last_name, middle_name, maiden_name, date_of_birth FROM people LIMIT 1`;
+ console.log('✅ Query without created_date works');
+ } catch (e: any) {
+ console.log('❌ Query without created_date still fails:', e.message);
+ }
+
+ } catch (dateError: any) {
+ console.error('❌ Error checking date fields:', dateError.message);
+ }
+ } catch (rawError: any) {
+ console.error('❌ Raw SQL query also failed:', rawError.message);
+ }
+ } else {
+ throw error;
+ }
+ }
+}
+
+async function findCorruptedTags() {
+ console.log('\n🔍 Checking Tag table for corrupted data...\n');
+
+ try {
+ const tags = await prisma.tag.findMany({
+ select: {
+ id: true,
+ tag_name: true,
+ created_date: true,
+ },
+ });
+
+ console.log(`✅ Successfully fetched ${tags.length} tags\n`);
+
+ let corruptedCount = 0;
+ for (const tag of tags) {
+ if (hasInvalidChars(tag.tag_name)) {
+ const problematic = findProblematicChars(tag.tag_name);
+ console.log(` Tag ID ${tag.id}, Field: tag_name`);
+ console.log(` Value: ${JSON.stringify(tag.tag_name)}`);
+ console.log(` Problematic characters: ${problematic.join(', ')}`);
+ console.log('');
+ corruptedCount++;
+ }
+ }
+
+ if (corruptedCount === 0) {
+ console.log('✅ No corrupted text data found in Tag table\n');
+ } else {
+ console.log(`⚠️ Found ${corruptedCount} corrupted tag(s)\n`);
+ }
+ } catch (error: any) {
+ if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
+ console.error('❌ Cannot query Tag table due to conversion error');
+ console.error(' Error details:', error.message);
+ } else {
+ throw error;
+ }
+ }
+}
+
+async function main() {
+ try {
+ await findCorruptedPeople();
+ await findCorruptedTags();
+ } catch (error: any) {
+ console.error('❌ Unexpected error:', error);
+ process.exit(1);
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+main();
+
diff --git a/viewer-frontend/scripts/fix-admin-user.ts b/viewer-frontend/scripts/fix-admin-user.ts
new file mode 100644
index 0000000..cfbbe62
--- /dev/null
+++ b/viewer-frontend/scripts/fix-admin-user.ts
@@ -0,0 +1,124 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import bcrypt from 'bcryptjs';
+import * as dotenv from 'dotenv';
+
+// Load environment variables
+dotenv.config({ path: '.env' });
+
+const prisma = new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+});
+
+async function fixAdminUser() {
+ try {
+ console.log('Checking and fixing admin user...\n');
+
+ // Check if has_write_access column exists by trying to query it
+ let hasWriteAccessColumn = true;
+ try {
+ await prisma.$queryRaw`SELECT has_write_access FROM users LIMIT 1`;
+ console.log('✅ has_write_access column exists');
+ } catch (error: any) {
+ if (error.message?.includes('has_write_access') || error.message?.includes('column') || error.code === '42703') {
+ console.log('❌ has_write_access column does NOT exist');
+ console.log('\n⚠️ You need to run the database migration first:');
+ console.log(' psql -U postgres -d punimtag -f migrations/add-write-access-column.sql');
+ console.log('\n Or manually add the column:');
+ console.log(' ALTER TABLE users ADD COLUMN has_write_access BOOLEAN NOT NULL DEFAULT false;');
+ hasWriteAccessColumn = false;
+ } else {
+ throw error;
+ }
+ }
+
+ if (!hasWriteAccessColumn) {
+ console.log('\n⚠️ Please run the migration and then run this script again.');
+ return;
+ }
+
+ // Hash the password
+ const passwordHash = await bcrypt.hash('admin', 10);
+
+ // Check if admin user exists
+ const existingAdmin = await prisma.user.findUnique({
+ where: { email: 'admin@admin.com' },
+ });
+
+ if (existingAdmin) {
+ console.log('✅ Admin user found, updating...');
+ await prisma.user.update({
+ where: { email: 'admin@admin.com' },
+ data: {
+ passwordHash,
+ isAdmin: true,
+ name: 'Admin',
+ hasWriteAccess: true,
+ },
+ });
+ console.log('✅ Admin user updated successfully');
+ } else {
+ console.log('Creating new admin user...');
+ await prisma.user.create({
+ data: {
+ email: 'admin@admin.com',
+ name: 'Admin',
+ passwordHash,
+ isAdmin: true,
+ hasWriteAccess: true,
+ },
+ });
+ console.log('✅ Admin user created successfully');
+ }
+
+ // Verify the user
+ const admin = await prisma.user.findUnique({
+ where: { email: 'admin@admin.com' },
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ isAdmin: true,
+ hasWriteAccess: true,
+ },
+ });
+
+ console.log('\n✅ Admin user verified:');
+ console.log(' Email:', admin?.email);
+ console.log(' Name:', admin?.name);
+ console.log(' Is Admin:', admin?.isAdmin);
+ console.log(' Has Write Access:', admin?.hasWriteAccess);
+
+ // Test password
+ const testPassword = 'admin';
+ const fullUser = await prisma.user.findUnique({
+ where: { email: 'admin@admin.com' },
+ select: { passwordHash: true },
+ });
+
+ if (fullUser) {
+ const isValid = await bcrypt.compare(testPassword, fullUser.passwordHash);
+ console.log(' Password test:', isValid ? '✅ VALID' : '❌ INVALID');
+ }
+
+ console.log('\n✅ Setup complete!');
+ console.log('\nYou can now login with:');
+ console.log(' Email: admin@admin.com');
+ console.log(' Password: admin');
+ } catch (error: any) {
+ console.error('\n❌ Error:', error.message);
+ if (error.message.includes('permission denied')) {
+ console.error('\n⚠️ Permission denied. Make sure:');
+ console.error(' 1. Database tables are created');
+ console.error(' 2. Database user has INSERT/UPDATE permissions');
+ console.error(' 3. DATABASE_URL_AUTH is correctly configured in .env');
+ } else if (error.message.includes('relation') || error.message.includes('does not exist')) {
+ console.error('\n⚠️ Database tables may not exist. Run setup-auth-complete.sql first.');
+ }
+ throw error;
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+fixAdminUser();
+
diff --git a/viewer-frontend/scripts/grant-delete-permission.ts b/viewer-frontend/scripts/grant-delete-permission.ts
new file mode 100644
index 0000000..a0ba7a1
--- /dev/null
+++ b/viewer-frontend/scripts/grant-delete-permission.ts
@@ -0,0 +1,65 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import * as dotenv from 'dotenv';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+
+const execAsync = promisify(exec);
+
+// Load environment variables
+dotenv.config({ path: '.env' });
+
+async function grantDeletePermission() {
+ try {
+ console.log('Granting DELETE permission on inappropriate_photo_reports table...\n');
+
+ // Extract username from DATABASE_URL_AUTH
+ const dbUrl = process.env.DATABASE_URL_AUTH;
+ if (!dbUrl) {
+ console.error('❌ DATABASE_URL_AUTH not found in environment variables');
+ return;
+ }
+
+ // Parse the connection string to get username
+ const match = dbUrl.match(/postgresql:\/\/([^:]+):/);
+ if (!match) {
+ console.error('❌ Could not parse username from DATABASE_URL_AUTH');
+ console.log('Please run this SQL command manually:');
+ console.log('GRANT DELETE ON TABLE inappropriate_photo_reports TO your_username;');
+ return;
+ }
+
+ const username = match[1];
+ console.log(`Found database user: ${username}`);
+ console.log('');
+
+ // Try to grant permission using psql
+ const sqlCommand = `GRANT DELETE ON TABLE inappropriate_photo_reports TO ${username};`;
+
+ console.log('Attempting to grant DELETE permission...');
+ console.log(`SQL: ${sqlCommand}\n`);
+
+ try {
+ // Try to run as current user first
+ const { stdout, stderr } = await execAsync(
+ `psql -d punimtag_auth -c "${sqlCommand}"`
+ );
+
+ if (stdout) console.log(stdout);
+ if (stderr && !stderr.includes('WARNING')) console.error(stderr);
+
+ console.log('✅ DELETE permission granted successfully!');
+ } catch (error: any) {
+ console.log('⚠️ Could not grant permission automatically (may need sudo)');
+ console.log('\nPlease run this command manually as PostgreSQL superuser:');
+ console.log(`\nsudo -u postgres psql -d punimtag_auth -c "GRANT DELETE ON TABLE inappropriate_photo_reports TO ${username};"`);
+ console.log('\nOr connect to PostgreSQL and run:');
+ console.log(`\\c punimtag_auth`);
+ console.log(`GRANT DELETE ON TABLE inappropriate_photo_reports TO ${username};`);
+ }
+ } catch (error: any) {
+ console.error('Error:', error.message);
+ }
+}
+
+grantDeletePermission();
+
diff --git a/viewer-frontend/scripts/grant-permissions.ts b/viewer-frontend/scripts/grant-permissions.ts
new file mode 100644
index 0000000..63874d9
--- /dev/null
+++ b/viewer-frontend/scripts/grant-permissions.ts
@@ -0,0 +1,102 @@
+#!/usr/bin/env tsx
+/**
+ * Grant read-only permissions to viewer_readonly user
+ * This script requires PostgreSQL superuser credentials
+ */
+
+import { execSync } from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+
+const SQL_COMMANDS = `
+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;
+`;
+
+async function grantPermissions() {
+ console.log('🔐 Attempting to grant database permissions...\n');
+
+ // Try different connection methods
+ const methods = [
+ // Method 1: Try with PGPASSWORD environment variable
+ () => {
+ if (process.env.PGPASSWORD) {
+ console.log('Trying with PGPASSWORD environment variable...');
+ try {
+ const result = execSync(
+ `psql -h localhost -U postgres -d punimtag -c "${SQL_COMMANDS.replace(/\n/g, ' ')}"`,
+ {
+ env: { ...process.env, PGPASSWORD: process.env.PGPASSWORD },
+ stdio: 'inherit'
+ }
+ );
+ return true;
+ } catch (error) {
+ return false;
+ }
+ }
+ return false;
+ },
+ // Method 2: Try with sudo (if NOPASSWD is configured)
+ () => {
+ console.log('Trying with sudo...');
+ try {
+ execSync(
+ `sudo -u postgres psql -d punimtag -c "${SQL_COMMANDS.replace(/\n/g, ' ')}"`,
+ { stdio: 'inherit' }
+ );
+ return true;
+ } catch (error) {
+ return false;
+ }
+ },
+ ];
+
+ for (const method of methods) {
+ try {
+ if (method()) {
+ console.log('\n✅ Permissions granted successfully!');
+ return;
+ }
+ } catch (error) {
+ // Continue to next method
+ }
+ }
+
+ // If all methods fail, provide manual instructions
+ console.log('\n❌ Could not automatically grant permissions.\n');
+ console.log('Please run the SQL commands manually as PostgreSQL superuser:\n');
+ console.log('Option 1: Using psql with password:');
+ console.log(' PGPASSWORD=your_password psql -U postgres -d punimtag');
+ console.log(' Then paste these commands:');
+ console.log(SQL_COMMANDS);
+ console.log('\nOption 2: Using sudo:');
+ console.log(' sudo -u postgres psql -d punimtag');
+ console.log(' Then paste these commands:');
+ console.log(SQL_COMMANDS);
+ console.log('\nOption 3: Run the SQL file:');
+ console.log(' psql -U postgres -d punimtag -f grant_permissions_now.sql');
+
+ process.exit(1);
+}
+
+grantPermissions().catch((error) => {
+ console.error('Error:', error);
+ process.exit(1);
+});
+
+
+
+
+
+
+
+
diff --git a/viewer-frontend/scripts/install-dependencies.sh b/viewer-frontend/scripts/install-dependencies.sh
new file mode 100755
index 0000000..ab64403
--- /dev/null
+++ b/viewer-frontend/scripts/install-dependencies.sh
@@ -0,0 +1,210 @@
+#!/bin/bash
+# Install Dependencies Script
+# This script installs all required dependencies for the PunimTag Photo Viewer
+# including npm packages, system dependencies, and Prisma clients
+
+set -e # Exit on error
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+cd "$PROJECT_DIR"
+
+echo "🚀 Installing PunimTag Photo Viewer Dependencies"
+echo "================================================"
+echo ""
+
+# Check Node.js version
+echo "📋 Checking Node.js version..."
+NODE_VERSION=$(node --version | cut -d'v' -f2 | cut -d'.' -f1)
+if [ "$NODE_VERSION" -lt 20 ]; then
+ echo "⚠️ Warning: Node.js 20+ is recommended (found v$NODE_VERSION)"
+ echo " Consider upgrading: nvm install 20 && nvm use 20"
+ read -p "Continue anyway? (y/N) " -n 1 -r
+ echo
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ exit 1
+ fi
+else
+ echo "✅ Node.js version: $(node --version)"
+fi
+echo ""
+
+# Check for system dependencies
+echo "📦 Checking system dependencies..."
+MISSING_DEPS=()
+
+# Check for libvips (optional but recommended)
+if ! command -v vips &> /dev/null && ! dpkg -l | grep -q libvips-dev; then
+ echo "⚠️ libvips-dev not found (optional, for image watermarking)"
+ MISSING_DEPS+=("libvips-dev")
+fi
+
+# Check for FFmpeg (optional)
+if ! command -v ffmpeg &> /dev/null; then
+ echo "⚠️ FFmpeg not found (optional, for video thumbnails)"
+ MISSING_DEPS+=("ffmpeg")
+fi
+
+if [ ${#MISSING_DEPS[@]} -gt 0 ]; then
+ echo ""
+ echo "Optional system dependencies not installed:"
+ for dep in "${MISSING_DEPS[@]}"; do
+ echo " - $dep"
+ done
+ echo ""
+ read -p "Install optional dependencies? (y/N) " -n 1 -r
+ echo
+ if [[ $REPLY =~ ^[Yy]$ ]]; then
+ echo "Installing system dependencies..."
+ if command -v apt-get &> /dev/null; then
+ sudo apt-get update
+ for dep in "${MISSING_DEPS[@]}"; do
+ if [ "$dep" = "libvips-dev" ]; then
+ sudo apt-get install -y libvips-dev
+ elif [ "$dep" = "ffmpeg" ]; then
+ sudo apt-get install -y ffmpeg
+ fi
+ done
+ elif command -v brew &> /dev/null; then
+ for dep in "${MISSING_DEPS[@]}"; do
+ if [ "$dep" = "libvips-dev" ]; then
+ brew install vips
+ elif [ "$dep" = "ffmpeg" ]; then
+ brew install ffmpeg
+ fi
+ done
+ else
+ echo "⚠️ Please install dependencies manually for your system"
+ fi
+ fi
+else
+ echo "✅ All system dependencies found"
+fi
+echo ""
+
+# Install npm dependencies
+echo "📦 Installing npm dependencies..."
+npm install
+echo "✅ npm dependencies installed"
+echo ""
+
+# Install build tools for Sharp (if needed)
+if [ -d "node_modules/sharp" ]; then
+ echo "🔧 Setting up Sharp image processing library..."
+
+ # Check if Sharp can load
+ if node -e "try { require('sharp'); console.log('OK'); } catch(e) { console.log('FAIL'); process.exit(1); }" 2>/dev/null; then
+ echo "✅ Sharp is working correctly"
+ else
+ echo "⚠️ Sharp needs additional setup..."
+
+ # Install build dependencies if not present
+ if ! npm list node-gyp &> /dev/null; then
+ echo " Installing node-gyp..."
+ npm install --save-dev node-gyp
+ fi
+
+ if ! npm list node-addon-api &> /dev/null; then
+ echo " Installing node-addon-api..."
+ npm install --save-dev node-addon-api
+ fi
+
+ # Try to rebuild Sharp
+ echo " Attempting to rebuild Sharp..."
+ npm rebuild sharp || echo " ⚠️ Sharp rebuild failed, but wrapper script will handle library path"
+ fi
+ echo ""
+fi
+
+# Verify Sharp wrapper script exists
+if [ ! -f "scripts/with-sharp-libpath.sh" ]; then
+ echo "📝 Creating Sharp library path wrapper script..."
+ cat > scripts/with-sharp-libpath.sh << 'EOF'
+#!/bin/bash
+# Helper script to set LD_LIBRARY_PATH for Sharp before running commands
+# This ensures Sharp can find its bundled libvips library
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+SHARP_LIB_PATH="$PROJECT_DIR/node_modules/sharp/node_modules/@img/sharp-libvips-linux-x64/lib"
+
+if [ -d "$SHARP_LIB_PATH" ]; then
+ export LD_LIBRARY_PATH="$SHARP_LIB_PATH:${LD_LIBRARY_PATH:-}"
+ exec "$@"
+else
+ echo "Warning: Sharp libvips library not found at $SHARP_LIB_PATH"
+ echo "Sharp image processing may not work correctly."
+ exec "$@"
+fi
+EOF
+ chmod +x scripts/with-sharp-libpath.sh
+ echo "✅ Sharp wrapper script created"
+ echo ""
+fi
+
+# Generate Prisma clients
+echo "🔧 Generating Prisma clients..."
+if [ -f "prisma/schema.prisma" ]; then
+ npm run prisma:generate
+ echo "✅ Main Prisma client generated"
+else
+ echo "⚠️ prisma/schema.prisma not found, skipping Prisma generation"
+fi
+
+if [ -f "prisma/schema-auth.prisma" ]; then
+ npm run prisma:generate:auth
+ echo "✅ Auth Prisma client generated"
+fi
+echo ""
+
+# Check for .env file
+if [ ! -f ".env" ]; then
+ echo "⚠️ .env file not found"
+ echo " Please create a .env file with the following variables:"
+ echo " - DATABASE_URL"
+ echo " - DATABASE_URL_AUTH (optional)"
+ echo " - NEXTAUTH_SECRET"
+ echo " - NEXTAUTH_URL"
+ echo ""
+else
+ echo "✅ .env file found"
+fi
+echo ""
+
+# Test Sharp if available
+echo "🧪 Testing Sharp library..."
+if node -e "
+const path = require('path');
+const libPath = path.join(__dirname, 'node_modules/sharp/node_modules/@img/sharp-libvips-linux-x64/lib');
+process.env.LD_LIBRARY_PATH = libPath + ':' + (process.env.LD_LIBRARY_PATH || '');
+try {
+ const sharp = require('sharp');
+ console.log('✅ Sharp loaded successfully');
+ console.log(' Version:', require('sharp/package.json').version);
+ console.log(' libvips:', sharp.versions.vips);
+} catch(e) {
+ console.log('⚠️ Sharp not available:', e.message.split('\n')[0]);
+ console.log(' Image watermarking will be disabled');
+ console.log(' The wrapper script will handle this at runtime');
+}
+" 2>/dev/null; then
+ echo ""
+else
+ echo "⚠️ Sharp test failed, but wrapper script will handle it at runtime"
+ echo ""
+fi
+
+echo "================================================"
+echo "✅ Dependency installation complete!"
+echo ""
+echo "Next steps:"
+echo "1. Configure your .env file with database connection strings"
+echo "2. Run 'npm run dev' to start the development server"
+echo "3. Run 'npm run check:permissions' to verify database access"
+echo ""
+
+
+
+
+
diff --git a/viewer-frontend/scripts/make-name-required-migration.ts b/viewer-frontend/scripts/make-name-required-migration.ts
new file mode 100644
index 0000000..9230620
--- /dev/null
+++ b/viewer-frontend/scripts/make-name-required-migration.ts
@@ -0,0 +1,95 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import * as dotenv from 'dotenv';
+
+// Load environment variables
+dotenv.config({ path: '.env' });
+
+const prisma = new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+});
+
+async function runMigration() {
+ try {
+ console.log('Running migration to make name column required (NOT NULL)...\n');
+
+ // Check current state of the name column
+ const columnInfo = await prisma.$queryRaw>`
+ SELECT column_name, data_type, is_nullable, column_default
+ FROM information_schema.columns
+ WHERE table_name = 'users' AND column_name = 'name';
+ `;
+
+ if (columnInfo.length === 0) {
+ throw new Error('Name column not found in users table');
+ }
+
+ const currentState = columnInfo[0];
+ console.log(`Current state: is_nullable = ${currentState.is_nullable}`);
+
+ if (currentState.is_nullable === 'NO') {
+ console.log('✅ Name column is already NOT NULL');
+ return;
+ }
+
+ // Check if any users have NULL names
+ const nullNameCount = await prisma.$queryRaw>`
+ SELECT COUNT(*) as count FROM users WHERE name IS NULL;
+ `;
+
+ const count = Number(nullNameCount[0].count);
+ if (count > 0) {
+ console.log(`⚠️ Found ${count} user(s) with NULL names. Updating them to use email as name...`);
+ await prisma.$executeRawUnsafe(`
+ UPDATE users
+ SET name = email
+ WHERE name IS NULL;
+ `);
+ console.log('✅ Updated users with NULL names');
+ }
+
+ // Alter the column to be NOT NULL
+ console.log('Altering name column to be NOT NULL...');
+ await prisma.$executeRawUnsafe(`
+ ALTER TABLE users
+ ALTER COLUMN name SET NOT NULL;
+ `);
+ console.log('✅ Column altered successfully');
+
+ // Verify the change
+ const verifyInfo = await prisma.$queryRaw>`
+ SELECT column_name, data_type, is_nullable, column_default
+ FROM information_schema.columns
+ WHERE table_name = 'users' AND column_name = 'name';
+ `;
+
+ const newState = verifyInfo[0];
+ if (newState.is_nullable === 'NO') {
+ console.log('\n✅ Migration completed successfully!');
+ console.log('Name column is now required (NOT NULL).');
+ } else {
+ throw new Error('Migration failed: column is still nullable');
+ }
+ } catch (error: any) {
+ console.error('\n❌ Migration failed:');
+ console.error(error.message);
+ if (error.code) {
+ console.error(`Error code: ${error.code}`);
+ }
+ process.exit(1);
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+runMigration();
+
diff --git a/viewer-frontend/scripts/manually-verify-user.ts b/viewer-frontend/scripts/manually-verify-user.ts
new file mode 100644
index 0000000..e25619f
--- /dev/null
+++ b/viewer-frontend/scripts/manually-verify-user.ts
@@ -0,0 +1,75 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import * as dotenv from 'dotenv';
+
+dotenv.config();
+
+const prismaAuth = new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+});
+
+async function manuallyVerifyUser() {
+ try {
+ console.log('🔍 Finding unverified users...\n');
+
+ const unverifiedUsers = await prismaAuth.user.findMany({
+ where: {
+ emailVerified: false,
+ },
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ createdAt: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ });
+
+ if (unverifiedUsers.length === 0) {
+ console.log('✅ No unverified users found.');
+ return;
+ }
+
+ console.log(`Found ${unverifiedUsers.length} unverified user(s):\n`);
+ unverifiedUsers.forEach((user, index) => {
+ console.log(`${index + 1}. ${user.email} (${user.name}) - Created: ${user.createdAt}`);
+ });
+
+ // Verify all unverified users
+ console.log('\n✅ Verifying all unverified users...\n');
+
+ for (const user of unverifiedUsers) {
+ await prismaAuth.user.update({
+ where: { id: user.id },
+ data: {
+ emailVerified: true,
+ emailConfirmationToken: null,
+ emailConfirmationTokenExpiry: null,
+ },
+ });
+ console.log(`✅ Verified: ${user.email} (${user.name})`);
+ }
+
+ console.log('\n🎉 All users have been verified! They can now log in.');
+
+ } catch (error: any) {
+ console.error('❌ Error:', error.message);
+ if (error.message?.includes('email_verified')) {
+ console.error('\n⚠️ Database migration may not have been run!');
+ console.error(' Run: sudo -u postgres psql -d punimtag_auth -f migrations/add-email-verification-columns.sql');
+ }
+ process.exit(1);
+ } finally {
+ await prismaAuth.$disconnect();
+ }
+}
+
+manuallyVerifyUser();
+
+
+
+
+
+
+
diff --git a/viewer-frontend/scripts/run-email-verification-migration.ts b/viewer-frontend/scripts/run-email-verification-migration.ts
new file mode 100644
index 0000000..476a680
--- /dev/null
+++ b/viewer-frontend/scripts/run-email-verification-migration.ts
@@ -0,0 +1,80 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import * as fs from 'fs';
+import * as path from 'path';
+
+const prismaAuth = new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+});
+
+async function runMigration() {
+ try {
+ console.log('🔄 Running email verification migration...');
+ console.log('📝 Reading migration file...');
+
+ const migrationPath = path.join(__dirname, '../migrations/add-email-verification-columns.sql');
+ const migrationSQL = fs.readFileSync(migrationPath, 'utf-8');
+
+ // Execute each SQL statement individually
+ const statements = [
+ `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;`,
+ ];
+
+ console.log(`📋 Executing ${statements.length} SQL statements...`);
+
+ for (let i = 0; i < statements.length; i++) {
+ const statement = statements[i];
+ try {
+ await prismaAuth.$executeRawUnsafe(statement);
+ const desc = statement.split(' ').slice(0, 4).join(' ').toLowerCase();
+ console.log(`✅ [${i + 1}/${statements.length}] ${desc}...`);
+ } catch (error: any) {
+ // Ignore "already exists" errors
+ if (error.message?.includes('already exists') ||
+ error.message?.includes('duplicate') ||
+ error.message?.includes('IF NOT EXISTS')) {
+ const desc = statement.split(' ').slice(0, 4).join(' ').toLowerCase();
+ console.log(`ℹ️ [${i + 1}/${statements.length}] ${desc}... (already exists)`);
+ } else {
+ console.error(`❌ Error executing statement ${i + 1}:`, error.message);
+ throw error;
+ }
+ }
+ }
+
+ console.log('✅ Migration completed successfully!');
+ console.log('');
+ console.log('📊 Verifying migration...');
+
+ // Verify the columns were added
+ const result = await prismaAuth.$queryRawUnsafe>(`
+ SELECT column_name
+ FROM information_schema.columns
+ WHERE table_name = 'users'
+ AND column_name IN ('email_verified', 'email_confirmation_token', 'email_confirmation_token_expiry')
+ ORDER BY column_name;
+ `);
+
+ console.log('✅ Found columns:', result.map(r => r.column_name).join(', '));
+
+ // Check existing users
+ const userCount = await prismaAuth.user.count();
+ const verifiedCount = await prismaAuth.user.count({
+ where: { emailVerified: true }
+ });
+
+ console.log(`📊 Users: ${userCount} total, ${verifiedCount} verified`);
+
+ } catch (error: any) {
+ console.error('❌ Migration failed:', error.message);
+ process.exit(1);
+ } finally {
+ await prismaAuth.$disconnect();
+ }
+}
+
+runMigration();
+
diff --git a/viewer-frontend/scripts/run-favorites-migration.ts b/viewer-frontend/scripts/run-favorites-migration.ts
new file mode 100644
index 0000000..aecaae3
--- /dev/null
+++ b/viewer-frontend/scripts/run-favorites-migration.ts
@@ -0,0 +1,130 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import * as dotenv from 'dotenv';
+
+// Load environment variables
+dotenv.config({ path: '.env' });
+
+const prisma = new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+});
+
+async function runMigration() {
+ try {
+ console.log('Running migration to ensure photo_favorites table exists...\n');
+
+ // Check if table already exists
+ let tableExists = true;
+ try {
+ await prisma.$queryRaw`SELECT id FROM photo_favorites LIMIT 1`;
+ console.log('✅ Table photo_favorites already exists');
+ } catch (error: any) {
+ if (error.message?.includes('photo_favorites') || error.code === '42P01') {
+ console.log('Table does not exist, creating it...');
+ tableExists = false;
+ } else {
+ throw error;
+ }
+ }
+
+ if (!tableExists) {
+ // Create the table
+ console.log('Creating photo_favorites table...');
+ try {
+ await prisma.$executeRawUnsafe(`
+ CREATE TABLE IF NOT EXISTS photo_favorites (
+ id SERIAL PRIMARY KEY,
+ photo_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ favorited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT fk_photo_favorites_user
+ FOREIGN KEY (user_id)
+ REFERENCES users(id)
+ ON DELETE CASCADE,
+
+ CONSTRAINT uq_photo_user_favorite UNIQUE (photo_id, user_id)
+ );
+ `);
+ console.log('✅ Table created');
+ } catch (error: any) {
+ // If permission denied, try without IF NOT EXISTS (sometimes works)
+ if (error.message?.includes('permission denied') || error.code === '42501') {
+ console.log('Permission denied with IF NOT EXISTS, trying without...');
+ await prisma.$executeRawUnsafe(`
+ CREATE TABLE photo_favorites (
+ id SERIAL PRIMARY KEY,
+ photo_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ favorited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT fk_photo_favorites_user
+ FOREIGN KEY (user_id)
+ REFERENCES users(id)
+ ON DELETE CASCADE,
+
+ CONSTRAINT uq_photo_user_favorite UNIQUE (photo_id, user_id)
+ );
+ `);
+ console.log('✅ Table created');
+ } else {
+ throw error;
+ }
+ }
+ }
+
+ // Create indexes
+ console.log('Creating indexes...');
+ await prisma.$executeRawUnsafe(
+ `CREATE INDEX IF NOT EXISTS idx_photo_favorites_photo_id ON photo_favorites(photo_id);`
+ );
+ await prisma.$executeRawUnsafe(
+ `CREATE INDEX IF NOT EXISTS idx_photo_favorites_user_id ON photo_favorites(user_id);`
+ );
+ await prisma.$executeRawUnsafe(
+ `CREATE INDEX IF NOT EXISTS idx_photo_favorites_favorited_at ON photo_favorites(favorited_at);`
+ );
+ console.log('✅ Indexes created');
+
+ // Add comment (may fail if user doesn't have permission, but that's okay)
+ try {
+ console.log('Adding table comment...');
+ await prisma.$executeRawUnsafe(`
+ COMMENT ON TABLE photo_favorites IS 'Stores user favorites for photos';
+ `);
+ console.log('✅ Comment added');
+ } catch (error: any) {
+ console.log('⚠️ Could not add comment (non-critical)');
+ }
+
+ // Verify
+ const result = await prisma.$queryRaw>`
+ SELECT table_name
+ FROM information_schema.tables
+ WHERE table_schema = 'public' AND table_name = 'photo_favorites';
+ `;
+
+ if (result.length > 0) {
+ console.log('\n✅ Migration completed successfully!');
+ console.log('Table photo_favorites is ready to use.');
+ } else {
+ throw new Error('Table was not created');
+ }
+ } catch (error: any) {
+ console.error('\n❌ Migration failed:');
+ console.error(error.message);
+ if (error.code) {
+ console.error(`Error code: ${error.code}`);
+ }
+ process.exit(1);
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+runMigration();
+
+
+
+
+
+
diff --git a/viewer-frontend/scripts/run-inappropriate-reports-migration.ts b/viewer-frontend/scripts/run-inappropriate-reports-migration.ts
new file mode 100644
index 0000000..e59b947
--- /dev/null
+++ b/viewer-frontend/scripts/run-inappropriate-reports-migration.ts
@@ -0,0 +1,150 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import * as dotenv from 'dotenv';
+
+// Load environment variables
+dotenv.config({ path: '.env' });
+
+const prisma = new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+});
+
+async function runMigration() {
+ try {
+ console.log('Running migration to ensure inappropriate_photo_reports table exists...\n');
+
+ // Check if table already exists
+ let tableExists = true;
+ try {
+ await prisma.$queryRaw`SELECT id FROM inappropriate_photo_reports LIMIT 1`;
+ console.log('✅ Table inappropriate_photo_reports already exists');
+ } catch (error: any) {
+ if (error.message?.includes('inappropriate_photo_reports') || error.code === '42P01') {
+ console.log('Table does not exist, creating it...');
+ tableExists = false;
+ } else {
+ throw error;
+ }
+ }
+
+ if (!tableExists) {
+ // Create the table (using same approach as setup-auth.ts)
+ console.log('Creating inappropriate_photo_reports table...');
+ try {
+ await prisma.$executeRawUnsafe(`
+ CREATE TABLE IF NOT EXISTS inappropriate_photo_reports (
+ id SERIAL PRIMARY KEY,
+ photo_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ status VARCHAR(50) DEFAULT 'pending' CHECK (status IN ('pending', 'reviewed', 'dismissed')),
+ reported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ reviewed_at TIMESTAMP,
+ reviewed_by INTEGER,
+ review_notes TEXT,
+ report_comment TEXT,
+
+ CONSTRAINT fk_inappropriate_photo_reports_user
+ FOREIGN KEY (user_id)
+ REFERENCES users(id)
+ ON DELETE CASCADE,
+
+ CONSTRAINT uq_photo_user_report UNIQUE (photo_id, user_id)
+ );
+ `);
+ console.log('✅ Table created');
+ } catch (error: any) {
+ // If permission denied, try without IF NOT EXISTS (sometimes works)
+ if (error.message?.includes('permission denied') || error.code === '42501') {
+ console.log('Permission denied with IF NOT EXISTS, trying without...');
+ await prisma.$executeRawUnsafe(`
+ CREATE TABLE inappropriate_photo_reports (
+ id SERIAL PRIMARY KEY,
+ photo_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ status VARCHAR(50) DEFAULT 'pending' CHECK (status IN ('pending', 'reviewed', 'dismissed')),
+ reported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ reviewed_at TIMESTAMP,
+ reviewed_by INTEGER,
+ review_notes TEXT,
+ report_comment TEXT,
+
+ CONSTRAINT fk_inappropriate_photo_reports_user
+ FOREIGN KEY (user_id)
+ REFERENCES users(id)
+ ON DELETE CASCADE,
+
+ CONSTRAINT uq_photo_user_report UNIQUE (photo_id, user_id)
+ );
+ `);
+ console.log('✅ Table created');
+ } else {
+ throw error;
+ }
+ }
+ }
+
+ // Ensure the report_comment column exists (for older deployments)
+ try {
+ console.log('Ensuring report_comment column exists...');
+ await prisma.$executeRawUnsafe(`
+ ALTER TABLE inappropriate_photo_reports
+ ADD COLUMN IF NOT EXISTS report_comment TEXT;
+ `);
+ console.log('✅ report_comment column ready');
+ } catch (error: any) {
+ console.log('⚠️ Could not ensure report_comment column (non-critical)', error.message);
+ }
+
+ // Create indexes
+ console.log('Creating indexes...');
+ await prisma.$executeRawUnsafe(
+ `CREATE INDEX IF NOT EXISTS idx_inappropriate_photo_reports_photo_id ON inappropriate_photo_reports(photo_id);`
+ );
+ await prisma.$executeRawUnsafe(
+ `CREATE INDEX IF NOT EXISTS idx_inappropriate_photo_reports_user_id ON inappropriate_photo_reports(user_id);`
+ );
+ await prisma.$executeRawUnsafe(
+ `CREATE INDEX IF NOT EXISTS idx_inappropriate_photo_reports_status ON inappropriate_photo_reports(status);`
+ );
+ await prisma.$executeRawUnsafe(
+ `CREATE INDEX IF NOT EXISTS idx_inappropriate_photo_reports_reported_at ON inappropriate_photo_reports(reported_at);`
+ );
+ console.log('✅ Indexes created');
+
+ // Add comment (may fail if user doesn't have permission, but that's okay)
+ try {
+ console.log('Adding table comment...');
+ await prisma.$executeRawUnsafe(`
+ COMMENT ON TABLE inappropriate_photo_reports IS 'Stores reports of inappropriate photos submitted by users, pending admin review';
+ `);
+ console.log('✅ Comment added');
+ } catch (error: any) {
+ console.log('⚠️ Could not add comment (non-critical)');
+ }
+
+ // Verify
+ const result = await prisma.$queryRaw>`
+ SELECT table_name
+ FROM information_schema.tables
+ WHERE table_schema = 'public' AND table_name = 'inappropriate_photo_reports';
+ `;
+
+ if (result.length > 0) {
+ console.log('\n✅ Migration completed successfully!');
+ console.log('Table inappropriate_photo_reports is ready to use.');
+ } else {
+ throw new Error('Table was not created');
+ }
+ } catch (error: any) {
+ console.error('\n❌ Migration failed:');
+ console.error(error.message);
+ if (error.code) {
+ console.error(`Error code: ${error.code}`);
+ }
+ process.exit(1);
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+runMigration();
+
diff --git a/viewer-frontend/scripts/run-migration.ts b/viewer-frontend/scripts/run-migration.ts
new file mode 100644
index 0000000..dcac97f
--- /dev/null
+++ b/viewer-frontend/scripts/run-migration.ts
@@ -0,0 +1,79 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import * as dotenv from 'dotenv';
+
+// Load environment variables
+dotenv.config({ path: '.env' });
+
+const prisma = new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+});
+
+async function runMigration() {
+ try {
+ console.log('Running migration to add has_write_access column...\n');
+
+ // Check if column already exists
+ try {
+ await prisma.$queryRaw`SELECT has_write_access FROM users LIMIT 1`;
+ console.log('✅ Column has_write_access already exists');
+ return;
+ } catch (error: any) {
+ if (error.message?.includes('has_write_access') || error.code === '42703') {
+ console.log('Column does not exist, adding it...');
+ } else {
+ throw error;
+ }
+ }
+
+ // Add the column
+ console.log('Adding has_write_access column...');
+ await prisma.$executeRawUnsafe(`
+ ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS has_write_access BOOLEAN NOT NULL DEFAULT false;
+ `);
+ console.log('✅ Column added');
+
+ // Create index
+ console.log('Creating index...');
+ await prisma.$executeRawUnsafe(`
+ CREATE INDEX IF NOT EXISTS idx_users_has_write_access ON users(has_write_access);
+ `);
+ console.log('✅ Index created');
+
+ // Update existing users to have write access = false (except we'll update admin separately)
+ console.log('Updating existing users...');
+ await prisma.$executeRawUnsafe(`
+ UPDATE users
+ SET has_write_access = false
+ WHERE has_write_access IS NULL;
+ `);
+ console.log('✅ Existing users updated');
+
+ // Verify
+ const result = await prisma.$queryRaw>`
+ SELECT column_name, data_type, column_default
+ FROM information_schema.columns
+ WHERE table_name = 'users' AND column_name = 'has_write_access';
+ `;
+
+ if (result.length > 0) {
+ console.log('\n✅ Migration completed successfully!');
+ console.log('Column details:', result[0]);
+ } else {
+ console.log('\n⚠️ Column was added but verification query returned no results');
+ }
+ } catch (error: any) {
+ console.error('\n❌ Error running migration:', error.message);
+ if (error.message.includes('permission denied')) {
+ console.error('\n⚠️ Permission denied. You may need to run this as a database superuser.');
+ console.error('Try running the SQL manually:');
+ console.error(' ALTER TABLE users ADD COLUMN IF NOT EXISTS has_write_access BOOLEAN NOT NULL DEFAULT false;');
+ }
+ throw error;
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+runMigration();
+
diff --git a/viewer-frontend/scripts/setup-auth.ts b/viewer-frontend/scripts/setup-auth.ts
new file mode 100644
index 0000000..b248781
--- /dev/null
+++ b/viewer-frontend/scripts/setup-auth.ts
@@ -0,0 +1,132 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import bcrypt from 'bcryptjs';
+import * as dotenv from 'dotenv';
+
+// Load environment variables
+dotenv.config({ path: '.env' });
+
+const prisma = new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+});
+
+async function setupAuth() {
+ try {
+ console.log('Setting up authentication tables and admin user...\n');
+
+ // Create tables using raw SQL (Prisma doesn't support CREATE TABLE in migrations easily)
+ console.log('Creating users table...');
+ await prisma.$executeRawUnsafe(`
+ CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ email VARCHAR(255) UNIQUE NOT NULL,
+ name VARCHAR(255),
+ password_hash VARCHAR(255) NOT NULL,
+ is_admin BOOLEAN DEFAULT FALSE,
+ is_active BOOLEAN DEFAULT TRUE,
+ has_write_access BOOLEAN DEFAULT FALSE,
+ email_verified BOOLEAN DEFAULT FALSE,
+ email_confirmation_token VARCHAR(255) UNIQUE,
+ email_confirmation_token_expiry TIMESTAMP,
+ password_reset_token VARCHAR(255) UNIQUE,
+ password_reset_token_expiry TIMESTAMP,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ `);
+
+ // Add missing columns if table already exists with old schema
+ console.log('Adding missing columns if needed...');
+ await prisma.$executeRawUnsafe(`ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE;`);
+ await prisma.$executeRawUnsafe(`ALTER TABLE users ADD COLUMN IF NOT EXISTS has_write_access BOOLEAN DEFAULT FALSE;`);
+ await prisma.$executeRawUnsafe(`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE;`);
+ await prisma.$executeRawUnsafe(`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_confirmation_token VARCHAR(255);`);
+ await prisma.$executeRawUnsafe(`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_confirmation_token_expiry TIMESTAMP;`);
+ await prisma.$executeRawUnsafe(`ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255);`);
+ await prisma.$executeRawUnsafe(`ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_token_expiry TIMESTAMP;`);
+
+ // Add unique constraints if they don't exist
+ await prisma.$executeRawUnsafe(`CREATE UNIQUE INDEX IF NOT EXISTS users_email_confirmation_token_key ON users(email_confirmation_token) WHERE email_confirmation_token IS NOT NULL;`);
+ await prisma.$executeRawUnsafe(`CREATE UNIQUE INDEX IF NOT EXISTS users_password_reset_token_key ON users(password_reset_token) WHERE password_reset_token IS NOT NULL;`);
+
+ console.log('Creating pending_identifications table...');
+ await prisma.$executeRawUnsafe(`
+ CREATE TABLE IF NOT EXISTS pending_identifications (
+ id SERIAL PRIMARY KEY,
+ face_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ first_name VARCHAR(255) NOT NULL,
+ last_name VARCHAR(255) NOT NULL,
+ middle_name VARCHAR(255),
+ maiden_name VARCHAR(255),
+ date_of_birth DATE,
+ status VARCHAR(50) DEFAULT 'pending',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ -- Note: face_id references faces in punimtag database, but we can't use foreign key across databases
+ );
+ `);
+
+ console.log('Creating indexes...');
+ await prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_pending_identifications_face_id ON pending_identifications(face_id);`);
+ await prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_pending_identifications_user_id ON pending_identifications(user_id);`);
+ await prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_pending_identifications_status ON pending_identifications(status);`);
+
+ // Check if admin user already exists
+ const existingAdmin = await prisma.user.findUnique({
+ where: { email: 'admin@admin.com' },
+ });
+
+ if (existingAdmin) {
+ console.log('Admin user already exists. Updating password...');
+ const passwordHash = await bcrypt.hash('admin', 10);
+ await prisma.user.update({
+ where: { email: 'admin@admin.com' },
+ data: {
+ passwordHash,
+ isAdmin: true,
+ hasWriteAccess: true,
+ emailVerified: true,
+ isActive: true,
+ },
+ });
+ console.log('✅ Admin user password updated (admin@admin.com / admin)');
+ } else {
+ console.log('Creating admin user...');
+ const passwordHash = await bcrypt.hash('admin', 10);
+ await prisma.user.create({
+ data: {
+ email: 'admin@admin.com',
+ name: 'Admin',
+ passwordHash,
+ isAdmin: true,
+ hasWriteAccess: true,
+ emailVerified: true,
+ isActive: true,
+ },
+ });
+ console.log('✅ Admin user created (admin@admin.com / admin)');
+ }
+
+ console.log('\n✅ Setup complete!');
+ console.log('\nAdmin credentials:');
+ console.log(' Email: admin@admin.com');
+ console.log(' Password: admin');
+ console.log('\nNote: Make sure to grant appropriate database permissions:');
+ console.log(' - Regular users: INSERT on pending_identifications');
+ console.log(' - Admin: UPDATE on pending_identifications (for approval)');
+ } catch (error: any) {
+ console.error('Error setting up authentication:', error);
+ if (error.message.includes('permission denied')) {
+ console.error('\n⚠️ Permission denied. You may need to:');
+ console.error(' 1. Run this script with a user that has CREATE TABLE permissions');
+ console.error(' 2. Or manually create the tables using create_auth_tables.sql');
+ }
+ throw error;
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+setupAuth();
+
diff --git a/viewer-frontend/scripts/setup-database.ts b/viewer-frontend/scripts/setup-database.ts
new file mode 100644
index 0000000..35c9883
--- /dev/null
+++ b/viewer-frontend/scripts/setup-database.ts
@@ -0,0 +1,124 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import bcrypt from 'bcryptjs';
+import * as dotenv from 'dotenv';
+
+// Load environment variables
+dotenv.config({ path: '.env' });
+
+// Connect to auth database
+const dbUrl = process.env.DATABASE_URL_AUTH;
+console.log(`Connecting to auth database: ${dbUrl ? dbUrl.replace(/:[^:@]+@/, ':****@') : 'none'}\n`);
+
+const prisma = new PrismaClientAuth({
+ datasourceUrl: dbUrl,
+});
+
+async function setupDatabase() {
+ try {
+ console.log('Setting up database tables and admin user...\n');
+
+ // Test connection first
+ await prisma.$connect();
+ console.log('✅ Connected to auth database\n');
+
+ // Create users table
+ console.log('Creating users table...');
+ await prisma.$executeRawUnsafe(`
+ CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ email VARCHAR(255) UNIQUE NOT NULL,
+ name VARCHAR(255),
+ password_hash VARCHAR(255) NOT NULL,
+ is_admin BOOLEAN DEFAULT FALSE,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ `);
+ console.log('✅ Users table created');
+
+ // Create pending_identifications table
+ console.log('Creating pending_identifications table...');
+ await prisma.$executeRawUnsafe(`
+ CREATE TABLE IF NOT EXISTS pending_identifications (
+ id SERIAL PRIMARY KEY,
+ face_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ first_name VARCHAR(255) NOT NULL,
+ last_name VARCHAR(255) NOT NULL,
+ middle_name VARCHAR(255),
+ maiden_name VARCHAR(255),
+ date_of_birth DATE,
+ status VARCHAR(50) DEFAULT 'pending',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ -- Note: face_id references faces in punimtag database, but we can't use foreign key across databases
+ );
+ `);
+ console.log('✅ Pending identifications table created');
+
+ // Create indexes
+ console.log('Creating indexes...');
+ await prisma.$executeRawUnsafe(`
+ CREATE INDEX IF NOT EXISTS idx_pending_identifications_face_id ON pending_identifications(face_id);
+ CREATE INDEX IF NOT EXISTS idx_pending_identifications_user_id ON pending_identifications(user_id);
+ CREATE INDEX IF NOT EXISTS idx_pending_identifications_status ON pending_identifications(status);
+ `);
+ console.log('✅ Indexes created');
+
+ // Create admin user
+ console.log('\nCreating admin user...');
+ const passwordHash = await bcrypt.hash('admin', 10);
+
+ const existingAdmin = await prisma.user.findUnique({
+ where: { email: 'admin@admin.com' },
+ });
+
+ if (existingAdmin) {
+ await prisma.user.update({
+ where: { email: 'admin@admin.com' },
+ data: {
+ passwordHash,
+ isAdmin: true,
+ name: 'Admin',
+ },
+ });
+ console.log('✅ Admin user updated');
+ } else {
+ await prisma.user.create({
+ data: {
+ email: 'admin@admin.com',
+ name: 'Admin',
+ passwordHash,
+ isAdmin: true,
+ },
+ });
+ console.log('✅ Admin user created');
+ }
+
+ console.log('\n🎉 Database setup complete!');
+ console.log('\nAdmin credentials:');
+ console.log(' Email: admin@admin.com');
+ console.log(' Password: admin');
+ console.log(' Role: Admin');
+ } catch (error: any) {
+ console.error('\n❌ Error setting up database:', error.message);
+ if (error.message.includes('permission denied')) {
+ console.error('\n⚠️ Permission denied. You may need to:');
+ console.error(' 1. Run this script with a user that has CREATE TABLE permissions');
+ console.error(' 2. Or manually create the tables using setup-auth-complete.sql');
+ } else if (error.message.includes('Authentication failed')) {
+ console.error('\n⚠️ Authentication failed. Please check:');
+ console.error(' 1. DATABASE_URL_AUTH in .env file');
+ console.error(' 2. Database credentials are correct');
+ console.error(' 3. Database server is running');
+ }
+ process.exit(1);
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+setupDatabase();
+
diff --git a/viewer-frontend/scripts/setup-with-superuser.sh b/viewer-frontend/scripts/setup-with-superuser.sh
new file mode 100755
index 0000000..c1a4691
--- /dev/null
+++ b/viewer-frontend/scripts/setup-with-superuser.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+# Setup script that uses PostgreSQL superuser to create tables
+# Usage: ./scripts/setup-with-superuser.sh [postgres_user] [postgres_password]
+
+POSTGRES_USER=${1:-postgres}
+POSTGRES_PASSWORD=${2:-}
+DB_NAME="punimtag"
+
+echo "Creating database tables using PostgreSQL superuser..."
+echo ""
+
+if [ -z "$POSTGRES_PASSWORD" ]; then
+ # Try without password (trust authentication)
+ PGPASSWORD="" psql -U "$POSTGRES_USER" -d "$DB_NAME" -f setup-auth-complete.sql
+else
+ # Use password
+ PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d "$DB_NAME" -f setup-auth-complete.sql
+fi
+
+if [ $? -eq 0 ]; then
+ echo ""
+ echo "✅ Tables created! Now creating admin user..."
+ echo ""
+ npx tsx scripts/create-admin-user.ts
+else
+ echo ""
+ echo "❌ Failed to create tables. Please check:"
+ echo " 1. PostgreSQL superuser credentials"
+ echo " 2. Database name is correct: $DB_NAME"
+ echo " 3. You have CREATE TABLE permissions"
+fi
+
+
+
diff --git a/viewer-frontend/scripts/test-admin-check.ts b/viewer-frontend/scripts/test-admin-check.ts
new file mode 100644
index 0000000..81bb2c6
--- /dev/null
+++ b/viewer-frontend/scripts/test-admin-check.ts
@@ -0,0 +1,70 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import * as dotenv from 'dotenv';
+
+// Load environment variables
+dotenv.config({ path: '.env' });
+
+const prisma = new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+});
+
+async function testAdminCheck() {
+ try {
+ console.log('Testing admin user check...\n');
+
+ // Find admin user
+ const admin = await prisma.user.findUnique({
+ where: { email: 'admin@admin.com' },
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ isAdmin: true,
+ hasWriteAccess: true,
+ },
+ });
+
+ if (!admin) {
+ console.log('❌ Admin user not found!');
+ return;
+ }
+
+ console.log('✅ Admin user found:');
+ console.log(' ID:', admin.id);
+ console.log(' Email:', admin.email);
+ console.log(' Is Admin:', admin.isAdmin);
+ console.log(' Has Write Access:', admin.hasWriteAccess);
+
+ // Test querying all users (like the API does)
+ console.log('\nTesting user list query...');
+ const users = await prisma.user.findMany({
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ isAdmin: true,
+ hasWriteAccess: true,
+ createdAt: true,
+ updatedAt: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ });
+
+ console.log(`✅ Successfully queried ${users.length} users`);
+ users.forEach((user, index) => {
+ console.log(` ${index + 1}. ${user.email} (Admin: ${user.isAdmin}, Write: ${user.hasWriteAccess})`);
+ });
+
+ console.log('\n✅ All checks passed!');
+ } catch (error: any) {
+ console.error('\n❌ Error:', error.message);
+ console.error('Stack:', error.stack);
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+testAdminCheck();
+
diff --git a/viewer-frontend/scripts/test-email-sending.ts b/viewer-frontend/scripts/test-email-sending.ts
new file mode 100644
index 0000000..82a93bf
--- /dev/null
+++ b/viewer-frontend/scripts/test-email-sending.ts
@@ -0,0 +1,77 @@
+import { Resend } from 'resend';
+import * as dotenv from 'dotenv';
+
+// Load environment variables
+dotenv.config();
+
+const resend = new Resend(process.env.RESEND_API_KEY);
+
+async function testEmailSending() {
+ console.log('🧪 Testing email sending configuration...\n');
+
+ // Check environment variables
+ console.log('📋 Environment Variables:');
+ console.log(' RESEND_API_KEY:', process.env.RESEND_API_KEY ? `${process.env.RESEND_API_KEY.substring(0, 10)}...` : '❌ NOT SET');
+ console.log(' RESEND_FROM_EMAIL:', process.env.RESEND_FROM_EMAIL || '❌ NOT SET');
+ console.log(' NEXTAUTH_URL:', process.env.NEXTAUTH_URL || '❌ NOT SET');
+ console.log('');
+
+ if (!process.env.RESEND_API_KEY) {
+ console.error('❌ RESEND_API_KEY is not set in .env file');
+ process.exit(1);
+ }
+
+ if (!process.env.RESEND_FROM_EMAIL) {
+ console.error('❌ RESEND_FROM_EMAIL is not set in .env file');
+ process.exit(1);
+ }
+
+ // Clean up the from email (remove quotes and spaces)
+ const fromEmail = process.env.RESEND_FROM_EMAIL.trim().replace(/^["']|["']$/g, '');
+ console.log('📧 Using FROM email:', fromEmail);
+ console.log('');
+
+ // Test email sending
+ console.log('📤 Attempting to send test email...');
+ try {
+ const result = await resend.emails.send({
+ from: fromEmail,
+ to: 'test@example.com', // This will fail but we'll see the error
+ subject: 'Test Email',
+ html: 'This is a test email
',
+ });
+
+ console.log('✅ Email API call successful!');
+ console.log('Response:', JSON.stringify(result, null, 2));
+ } catch (error: any) {
+ console.error('❌ Error sending email:');
+ console.error(' Message:', error.message);
+ if (error.response) {
+ console.error(' Response:', JSON.stringify(error.response, null, 2));
+ }
+
+ // Check for common errors
+ if (error.message?.includes('domain')) {
+ console.error('\n⚠️ Domain verification issue:');
+ console.error(' The email domain needs to be verified in Resend dashboard');
+ console.error(' For testing, use: onboarding@resend.dev');
+ }
+
+ if (error.message?.includes('unauthorized') || error.message?.includes('Invalid API key')) {
+ console.error('\n⚠️ API Key issue:');
+ console.error(' Check that your RESEND_API_KEY is correct');
+ console.error(' Get a new key from: https://resend.com/api-keys');
+ }
+
+ process.exit(1);
+ }
+}
+
+testEmailSending();
+
+
+
+
+
+
+
diff --git a/viewer-frontend/scripts/test-prisma-query.ts b/viewer-frontend/scripts/test-prisma-query.ts
new file mode 100644
index 0000000..6b37630
--- /dev/null
+++ b/viewer-frontend/scripts/test-prisma-query.ts
@@ -0,0 +1,151 @@
+#!/usr/bin/env tsx
+/**
+ * Test script to identify which field is causing Prisma conversion errors
+ */
+
+import { PrismaClient } from '@prisma/client';
+import * as dotenv from 'dotenv';
+
+dotenv.config({ path: '.env' });
+
+const prisma = new PrismaClient({
+ log: ['error', 'warn'],
+});
+
+async function testQueries() {
+ console.log('Testing different Prisma queries to identify the problematic field...\n');
+
+ // Test 1: Query without date_of_birth
+ console.log('Test 1: Query without date_of_birth field...');
+ try {
+ const result1 = await prisma.$queryRaw>`
+ SELECT id, first_name, last_name, created_date
+ FROM people
+ `;
+ console.log('✅ SUCCESS: Query without date_of_birth works');
+ console.log(` Found ${result1.length} record(s)\n`);
+ } catch (e: any) {
+ console.log('❌ FAILED:', e.message);
+ console.log('');
+ }
+
+ // Test 2: Query with date_of_birth but cast it
+ console.log('Test 2: Query with date_of_birth (as text)...');
+ try {
+ const result2 = await prisma.$queryRaw>`
+ SELECT id, first_name, last_name,
+ CAST(date_of_birth AS TEXT) as date_of_birth,
+ created_date
+ FROM people
+ `;
+ console.log('✅ SUCCESS: Query with date_of_birth as TEXT works');
+ console.log(` Found ${result2.length} record(s)`);
+ for (const r of result2) {
+ console.log(` Person ${r.id}: date_of_birth = ${JSON.stringify(r.date_of_birth)}`);
+ }
+ console.log('');
+ } catch (e: any) {
+ console.log('❌ FAILED:', e.message);
+ console.log('');
+ }
+
+ // Test 3: Query with date_of_birth using CASE to handle NULL
+ console.log('Test 3: Query with date_of_birth (using CASE for NULL)...');
+ try {
+ const result3 = await prisma.$queryRaw>`
+ SELECT id, first_name, last_name,
+ CASE
+ WHEN date_of_birth IS NULL THEN NULL
+ ELSE CAST(date_of_birth AS TEXT)
+ END as date_of_birth,
+ created_date
+ FROM people
+ `;
+ console.log('✅ SUCCESS: Query with date_of_birth using CASE works');
+ console.log(` Found ${result3.length} record(s)\n`);
+ } catch (e: any) {
+ console.log('❌ FAILED:', e.message);
+ console.log('');
+ }
+
+ // Test 4: Try using findMany with select excluding date_of_birth
+ console.log('Test 4: Prisma findMany without date_of_birth...');
+ try {
+ const result4 = await prisma.person.findMany({
+ select: {
+ id: true,
+ first_name: true,
+ last_name: true,
+ middle_name: true,
+ maiden_name: true,
+ created_date: true,
+ // Exclude date_of_birth
+ },
+ });
+ console.log('✅ SUCCESS: Prisma findMany without date_of_birth works');
+ console.log(` Found ${result4.length} record(s)\n`);
+ } catch (e: any) {
+ console.log('❌ FAILED:', e.message);
+ console.log('');
+ }
+
+ // Test 5: Try using findMany WITH date_of_birth
+ console.log('Test 5: Prisma findMany WITH date_of_birth...');
+ try {
+ const result5 = await prisma.person.findMany({
+ select: {
+ id: true,
+ first_name: true,
+ last_name: true,
+ middle_name: true,
+ maiden_name: true,
+ date_of_birth: true, // This is the problematic field
+ created_date: true,
+ },
+ });
+ console.log('✅ SUCCESS: Prisma findMany with date_of_birth works');
+ console.log(` Found ${result5.length} record(s)\n`);
+ } catch (e: any) {
+ console.log('❌ FAILED:', e.message);
+ if (e.code === 'P2023') {
+ console.log(' This confirms date_of_birth is the problematic field!\n');
+ } else {
+ console.log('');
+ }
+ }
+}
+
+testQueries()
+ .then(() => {
+ console.log('Tests complete.');
+ process.exit(0);
+ })
+ .catch((e) => {
+ console.error('Unexpected error:', e);
+ process.exit(1);
+ })
+ .finally(() => {
+ prisma.$disconnect();
+ });
+
+
+
+
+
diff --git a/viewer-frontend/scripts/verify-all-users.ts b/viewer-frontend/scripts/verify-all-users.ts
new file mode 100644
index 0000000..a2d27d9
--- /dev/null
+++ b/viewer-frontend/scripts/verify-all-users.ts
@@ -0,0 +1,65 @@
+import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
+import * as dotenv from 'dotenv';
+
+dotenv.config();
+
+const prismaAuth = new PrismaClientAuth({
+ datasourceUrl: process.env.DATABASE_URL_AUTH,
+});
+
+async function verifyAllUsers() {
+ try {
+ console.log('🔍 Finding all users...\n');
+
+ const allUsers = await prismaAuth.user.findMany({
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ emailVerified: true,
+ createdAt: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ });
+
+ if (allUsers.length === 0) {
+ console.log('✅ No users found.');
+ return;
+ }
+
+ console.log(`Found ${allUsers.length} user(s):\n`);
+ allUsers.forEach((user, index) => {
+ const status = user.emailVerified ? '✅ Verified' : '❌ Unverified';
+ console.log(`${index + 1}. ${user.email} (${user.name}) - ${status} - Created: ${user.createdAt}`);
+ });
+
+ // Verify all users
+ console.log('\n✅ Verifying all users (force confirm)...\n');
+
+ const result = await prismaAuth.user.updateMany({
+ data: {
+ emailVerified: true,
+ emailConfirmationToken: null,
+ emailConfirmationTokenExpiry: null,
+ },
+ });
+
+ console.log(`✅ Successfully verified ${result.count} user(s)!`);
+ console.log('\n🎉 All users can now log in without email verification.');
+
+ } catch (error: any) {
+ console.error('❌ Error:', error.message);
+ if (error.message?.includes('email_verified')) {
+ console.error('\n⚠️ Database migration may not have been run!');
+ console.error(' Run: sudo -u postgres psql -d punimtag_auth -f migrations/add-email-verification-columns.sql');
+ }
+ process.exit(1);
+ } finally {
+ await prismaAuth.$disconnect();
+ }
+}
+
+verifyAllUsers();
+
diff --git a/viewer-frontend/scripts/with-sharp-libpath.sh b/viewer-frontend/scripts/with-sharp-libpath.sh
new file mode 100755
index 0000000..6677835
--- /dev/null
+++ b/viewer-frontend/scripts/with-sharp-libpath.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+# Helper script to set LD_LIBRARY_PATH for Sharp before running commands
+# This ensures Sharp can find its bundled libvips library
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+SHARP_LIB_PATH="$PROJECT_DIR/node_modules/sharp/node_modules/@img/sharp-libvips-linux-x64/lib"
+
+# Add node_modules/.bin to PATH if it exists
+if [ -d "$PROJECT_DIR/node_modules/.bin" ]; then
+ export PATH="$PROJECT_DIR/node_modules/.bin:$PATH"
+fi
+
+# Change to project directory to ensure relative paths work
+cd "$PROJECT_DIR" || exit 1
+if [ -d "$SHARP_LIB_PATH" ]; then
+ export LD_LIBRARY_PATH="$SHARP_LIB_PATH:${LD_LIBRARY_PATH:-}"
+ exec "$@"
+else
+ echo "Warning: Sharp libvips library not found at $SHARP_LIB_PATH"
+ echo "Sharp image processing may not work correctly."
+ exec "$@"
+fi
+
+
+
+
+
diff --git a/viewer-frontend/setup-auth-complete.sql b/viewer-frontend/setup-auth-complete.sql
new file mode 100644
index 0000000..39b091d
--- /dev/null
+++ b/viewer-frontend/setup-auth-complete.sql
@@ -0,0 +1,83 @@
+-- Complete setup script for authentication
+-- Run this as PostgreSQL superuser (e.g., postgres user)
+-- Make sure you're connected to the punimtag database
+
+-- Step 1: Create users table
+CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ email VARCHAR(255) UNIQUE NOT NULL,
+ name VARCHAR(255),
+ password_hash VARCHAR(255) NOT NULL,
+ is_admin BOOLEAN DEFAULT FALSE,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Step 2: Create pending_identifications table
+CREATE TABLE IF NOT EXISTS pending_identifications (
+ id SERIAL PRIMARY KEY,
+ face_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ first_name VARCHAR(255) NOT NULL,
+ last_name VARCHAR(255) NOT NULL,
+ middle_name VARCHAR(255),
+ maiden_name VARCHAR(255),
+ date_of_birth DATE,
+ status VARCHAR(50) DEFAULT 'pending',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY (face_id) REFERENCES faces(id)
+);
+
+-- Step 3: Create indexes
+CREATE INDEX IF NOT EXISTS idx_pending_identifications_face_id ON pending_identifications(face_id);
+CREATE INDEX IF NOT EXISTS idx_pending_identifications_user_id ON pending_identifications(user_id);
+CREATE INDEX IF NOT EXISTS idx_pending_identifications_status ON pending_identifications(status);
+
+-- Step 4: Create admin user (password: admin, hashed with bcrypt)
+-- The hash for 'admin' password is: $2a$10$rOzJ8Z8Z8Z8Z8Z8Z8Z8Z8eZ8Z8Z8Z8Z8Z8Z8Z8Z8Z8Z8Z8Z8Z8Z8
+-- We'll use a proper bcrypt hash - this is a placeholder that needs to be generated
+-- For now, we'll insert and you can update the password hash after running the Node script
+
+-- Step 5: Grant permissions
+-- Regular users: INSERT only on pending_identifications
+-- Admin users: UPDATE on pending_identifications (for approval)
+
+-- Grant INSERT to all authenticated users (they can create pending identifications)
+-- Note: In PostgreSQL, we can't easily restrict UPDATE per user without row-level security
+-- For now, we'll grant UPDATE to the write user, and handle admin checks in application code
+
+-- If using viewer_write user:
+DO $$
+BEGIN
+ IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'viewer_write') THEN
+ -- Grant permissions to write user
+ 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;
+ RAISE NOTICE 'Permissions granted to viewer_write';
+ END IF;
+END $$;
+
+-- If using viewer_readonly with write permissions:
+DO $$
+BEGIN
+ IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'viewer_readonly') THEN
+ -- Grant permissions to readonly user (if it has write 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;
+ RAISE NOTICE 'Permissions granted to viewer_readonly';
+ END IF;
+END $$;
+
+-- Display success message
+\echo '✅ Tables created successfully!'
+\echo '⚠️ Next step: Run the Node.js script to create admin user with proper password hash:'
+\echo ' npx tsx scripts/create-admin-user.ts'
+
+
diff --git a/viewer-frontend/setup-auth-database.sql b/viewer-frontend/setup-auth-database.sql
new file mode 100644
index 0000000..0ec86c2
--- /dev/null
+++ b/viewer-frontend/setup-auth-database.sql
@@ -0,0 +1,72 @@
+-- Setup script for separate authentication database (punimtag_auth)
+-- This database stores users and pending_identifications separately from the read-only punimtag database
+-- Run this script as a PostgreSQL superuser (e.g., postgres user)
+
+-- Step 1: Create the database (if it doesn't exist)
+-- Note: This must be run as a superuser
+CREATE DATABASE punimtag_auth;
+
+-- Step 2: Connect to the new database
+\c punimtag_auth
+
+-- Step 3: Create users table
+CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ email VARCHAR(255) UNIQUE NOT NULL,
+ name VARCHAR(255),
+ password_hash VARCHAR(255) NOT NULL,
+ is_admin BOOLEAN DEFAULT FALSE,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Step 4: Create pending_identifications table
+-- Note: face_id references faces in the punimtag database, but we can't use a foreign key
+-- across databases. The application will validate that faces exist.
+CREATE TABLE IF NOT EXISTS pending_identifications (
+ id SERIAL PRIMARY KEY,
+ face_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ first_name VARCHAR(255) NOT NULL,
+ last_name VARCHAR(255) NOT NULL,
+ middle_name VARCHAR(255),
+ maiden_name VARCHAR(255),
+ date_of_birth DATE,
+ status VARCHAR(50) DEFAULT 'pending',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+-- Step 5: Create indexes
+CREATE INDEX IF NOT EXISTS idx_pending_identifications_face_id ON pending_identifications(face_id);
+CREATE INDEX IF NOT EXISTS idx_pending_identifications_user_id ON pending_identifications(user_id);
+CREATE INDEX IF NOT EXISTS idx_pending_identifications_status ON pending_identifications(status);
+
+-- Step 6: Create a database user for the application (optional, if you want a separate user)
+-- Replace 'your_secure_password' with a strong password
+-- CREATE USER punimtag_auth_user WITH PASSWORD 'your_secure_password';
+
+-- Step 7: Grant permissions to the database user
+-- If you created a separate user above, uncomment and adjust:
+-- GRANT CONNECT ON DATABASE punimtag_auth TO punimtag_auth_user;
+-- GRANT USAGE ON SCHEMA public TO punimtag_auth_user;
+-- GRANT SELECT, INSERT, UPDATE ON TABLE users TO punimtag_auth_user;
+-- GRANT SELECT, INSERT, UPDATE ON TABLE pending_identifications TO punimtag_auth_user;
+-- GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO punimtag_auth_user;
+-- GRANT USAGE, SELECT ON SEQUENCE pending_identifications_id_seq TO punimtag_auth_user;
+
+-- Or if you want to use an existing user (e.g., postgres or your current user):
+-- GRANT ALL PRIVILEGES ON DATABASE punimtag_auth TO your_existing_user;
+-- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO your_existing_user;
+-- GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO your_existing_user;
+
+\echo '✅ Auth database setup complete!'
+\echo ''
+\echo 'Next steps:'
+\echo '1. Add DATABASE_URL_AUTH to your .env file:'
+\echo ' DATABASE_URL_AUTH="postgresql://username:password@localhost:5432/punimtag_auth"'
+\echo '2. Generate Prisma client for auth: npx prisma generate --schema=prisma/schema-auth.prisma'
+\echo '3. Create admin user: npx tsx scripts/create-admin-user.ts'
+
diff --git a/viewer-frontend/setup-auth-tables.sql b/viewer-frontend/setup-auth-tables.sql
new file mode 100644
index 0000000..f86a95b
--- /dev/null
+++ b/viewer-frontend/setup-auth-tables.sql
@@ -0,0 +1,57 @@
+-- Create tables in punimtag_auth database
+-- Run this after the database is created: psql -d punimtag_auth -f setup-auth-tables.sql
+
+-- Create users table
+CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ email VARCHAR(255) UNIQUE NOT NULL,
+ name VARCHAR(255),
+ password_hash VARCHAR(255) NOT NULL,
+ is_admin BOOLEAN DEFAULT FALSE,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Create pending_identifications table
+-- Note: face_id references faces in the punimtag database, but we can't use a foreign key
+-- across databases. The application will validate that faces exist.
+CREATE TABLE IF NOT EXISTS pending_identifications (
+ id SERIAL PRIMARY KEY,
+ face_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ first_name VARCHAR(255) NOT NULL,
+ last_name VARCHAR(255) NOT NULL,
+ middle_name VARCHAR(255),
+ maiden_name VARCHAR(255),
+ date_of_birth DATE,
+ status VARCHAR(50) DEFAULT 'pending',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+-- Create indexes
+CREATE INDEX IF NOT EXISTS idx_pending_identifications_face_id ON pending_identifications(face_id);
+CREATE INDEX IF NOT EXISTS idx_pending_identifications_user_id ON pending_identifications(user_id);
+CREATE INDEX IF NOT EXISTS idx_pending_identifications_status ON pending_identifications(status);
+
+-- Create pending_linkages table to track tag submissions
+CREATE TABLE IF NOT EXISTS pending_linkages (
+ id SERIAL PRIMARY KEY,
+ photo_id INTEGER NOT NULL,
+ tag_id INTEGER,
+ tag_name VARCHAR(255),
+ user_id INTEGER NOT NULL,
+ status VARCHAR(50) DEFAULT 'pending',
+ notes TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_pending_linkages_photo_id ON pending_linkages(photo_id);
+CREATE INDEX IF NOT EXISTS idx_pending_linkages_tag_id ON pending_linkages(tag_id);
+CREATE INDEX IF NOT EXISTS idx_pending_linkages_user_id ON pending_linkages(user_id);
+CREATE INDEX IF NOT EXISTS idx_pending_linkages_status ON pending_linkages(status);
+
diff --git a/viewer-frontend/test-image-detection.ts b/viewer-frontend/test-image-detection.ts
new file mode 100644
index 0000000..8c223c4
--- /dev/null
+++ b/viewer-frontend/test-image-detection.ts
@@ -0,0 +1,102 @@
+/**
+ * Test script to verify image source detection logic
+ * Run with: npx tsx test-image-detection.ts
+ */
+
+/**
+ * Determines if a path is a URL (http/https) or a file system path
+ */
+function isUrl(path: string): boolean {
+ return path.startsWith('http://') || path.startsWith('https://');
+}
+
+/**
+ * Gets the appropriate image source URL
+ */
+function getImageSrc(photoId: number, path: string): string {
+ if (isUrl(path)) {
+ return path; // Direct access
+ } else {
+ return `/api/photos/${photoId}/image`; // API proxy
+ }
+}
+
+// Test cases
+const testCases = [
+ {
+ id: 1,
+ path: 'https://picsum.photos/800/600',
+ expected: 'direct',
+ description: 'HTTPS URL (SharePoint, CDN)',
+ },
+ {
+ id: 2,
+ path: 'http://example.com/image.jpg',
+ expected: 'direct',
+ description: 'HTTP URL',
+ },
+ {
+ id: 3,
+ path: '/path/to/photos/image.jpg',
+ expected: 'proxy',
+ description: 'Unix file system path',
+ },
+ {
+ id: 4,
+ path: 'C:\\Photos\\image.jpg',
+ expected: 'proxy',
+ description: 'Windows file system path',
+ },
+ {
+ id: 5,
+ path: 'https://yourcompany.sharepoint.com/sites/Photos/image.jpg',
+ expected: 'direct',
+ description: 'SharePoint Online URL',
+ },
+ {
+ id: 6,
+ path: 'https://sharepoint.company.com/sites/Photos/image.jpg',
+ expected: 'direct',
+ description: 'SharePoint Server URL',
+ },
+];
+
+console.log('🧪 Testing Image Source Detection Logic\n');
+console.log('=' .repeat(60));
+
+let passed = 0;
+let failed = 0;
+
+testCases.forEach((testCase) => {
+ const result = getImageSrc(testCase.id, testCase.path);
+ const isDirect = isUrl(testCase.path);
+ const actual = isDirect ? 'direct' : 'proxy';
+ const success = actual === testCase.expected;
+
+ if (success) {
+ passed++;
+ console.log(`✅ Test ${testCase.id}: PASSED`);
+ } else {
+ failed++;
+ console.log(`❌ Test ${testCase.id}: FAILED`);
+ }
+
+ console.log(` Description: ${testCase.description}`);
+ console.log(` Path: ${testCase.path}`);
+ console.log(` Detected as: ${actual} (expected: ${testCase.expected})`);
+ console.log(` Image src: ${result}`);
+ console.log('');
+});
+
+console.log('=' .repeat(60));
+console.log(`Results: ${passed} passed, ${failed} failed`);
+
+if (failed === 0) {
+ console.log('🎉 All tests passed!');
+ process.exit(0);
+} else {
+ console.log('⚠️ Some tests failed');
+ process.exit(1);
+}
+
+
diff --git a/viewer-frontend/tsconfig.json b/viewer-frontend/tsconfig.json
new file mode 100644
index 0000000..6f82382
--- /dev/null
+++ b/viewer-frontend/tsconfig.json
@@ -0,0 +1,35 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts",
+ "types/**/*.d.ts"
+ ],
+ "exclude": ["node_modules", "scripts"]
+}
diff --git a/viewer-frontend/types/next-auth.d.ts b/viewer-frontend/types/next-auth.d.ts
new file mode 100644
index 0000000..8fa1d80
--- /dev/null
+++ b/viewer-frontend/types/next-auth.d.ts
@@ -0,0 +1,32 @@
+import 'next-auth';
+
+declare module 'next-auth' {
+ interface Session {
+ user: {
+ id: string;
+ email: string;
+ name?: string | null;
+ isAdmin?: boolean;
+ hasWriteAccess?: boolean;
+ };
+ }
+
+ interface User {
+ id: string;
+ email: string;
+ name?: string | null;
+ isAdmin?: boolean;
+ hasWriteAccess?: boolean;
+ }
+}
+
+declare module 'next-auth/jwt' {
+ interface JWT {
+ id: string;
+ email: string;
+ isAdmin?: boolean;
+ hasWriteAccess?: boolean;
+ exp?: number; // Expiration timestamp
+ }
+}
+
diff --git a/viewer-frontend/types/prisma-client-auth.d.ts b/viewer-frontend/types/prisma-client-auth.d.ts
new file mode 100644
index 0000000..3442ccf
--- /dev/null
+++ b/viewer-frontend/types/prisma-client-auth.d.ts
@@ -0,0 +1,6 @@
+// Type declaration for Prisma client-auth
+// This module is generated at build time by Prisma
+declare module '../node_modules/.prisma/client-auth' {
+ export * from '@prisma/client';
+}
+