Welcome to PunimTag!
diff --git a/frontend/src/pages/Help.tsx b/frontend/src/pages/Help.tsx
new file mode 100644
index 0000000..d80bc27
--- /dev/null
+++ b/frontend/src/pages/Help.tsx
@@ -0,0 +1,520 @@
+import { useState } from 'react'
+
+type PageId = 'overview' | 'scan' | 'process' | 'identify' | 'auto-match' | 'search' | 'modify' | 'tags' | 'faces-maintenance'
+
+export default function Help() {
+ const [currentPage, setCurrentPage] = useState('overview')
+
+ const renderPageContent = () => {
+ switch (currentPage) {
+ case 'overview':
+ return
+ case 'scan':
+ return setCurrentPage('overview')} />
+ case 'process':
+ return setCurrentPage('overview')} />
+ case 'identify':
+ return setCurrentPage('overview')} />
+ case 'auto-match':
+ return setCurrentPage('overview')} />
+ case 'search':
+ return setCurrentPage('overview')} />
+ case 'modify':
+ return setCurrentPage('overview')} />
+ case 'tags':
+ return setCurrentPage('overview')} />
+ case 'faces-maintenance':
+ return setCurrentPage('overview')} />
+ default:
+ return
+ }
+ }
+
+ return (
+
+
📚 Help
+ {renderPageContent()}
+
+ )
+}
+
+function NavigationOverview({ onPageClick }: { onPageClick: (page: PageId) => void }) {
+ const navItems = [
+ { id: 'scan' as PageId, icon: '🗂️', label: 'Scan', description: 'Import photos from folders or upload files' },
+ { id: 'process' as PageId, icon: '⚙️', label: 'Process', description: 'Detect and process faces in photos' },
+ { id: 'identify' as PageId, icon: '👤', label: 'Identify', description: 'Manually identify people in faces' },
+ { id: 'auto-match' as PageId, icon: '🤖', label: 'Auto-Match', description: 'Automatically match similar faces to previously identified faces' },
+ { id: 'search' as PageId, icon: '🔍', label: 'Search', description: 'Search and filter photos' },
+ { id: 'modify' as PageId, icon: '✏️', label: 'Modify', description: 'Edit person information' },
+ { id: 'tags' as PageId, icon: '🏷️', label: 'Tags', description: 'Tag photos and manage photo tags' },
+ { id: 'faces-maintenance' as PageId, icon: '🔧', label: 'Faces Maintenance', description: 'Manage face data' },
+ ]
+
+ return (
+
+
Navigation Overview
+
+ The application uses a left sidebar navigation with the following pages. Click on any page to learn more about it:
+
+
+ {navItems.map((item) => (
+
+ ))}
+
+
+ )
+}
+
+function PageHelpLayout({ title, onBack, children }: { title: string; onBack: () => void; children: React.ReactNode }) {
+ return (
+
+
+
+
{title}
+
+ {children}
+
+ )
+}
+
+function ScanPageHelp({ onBack }: { onBack: () => void }) {
+ return (
+
+
+
+
Purpose
+
Import photos into your collection from folders or upload files
+
+
+
Features
+
+ - Folder Selection: Browse and select folders containing photos
+ - Recursive Scanning: Option to scan subfolders recursively (enabled by default)
+ - Duplicate Detection: Automatically detects and skips duplicate photos
+ - Real-time Progress: Live progress tracking during import
+ - EXIF Extraction: Automatically extracts date taken and metadata
+
+
+
+
How to Use
+
Folder Scan:
+
+ - Click "Browse Folder" button
+ - Select a folder containing photos
+ - Toggle "Recursive" 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)
+
+
+
+
What Happens
+
+ - Photos are copied to
data/uploads directory
+ - EXIF metadata is extracted (date taken, orientation, etc.)
+ - Duplicate detection by checksum
+ - Photos are added to database
+ - Faces are NOT detected yet (use Process page for that)
+
+
+
+
Tips
+
+ - Large folders may take time - be patient!
+
+
+
+
+ )
+}
+
+function ProcessPageHelp({ onBack }: { onBack: () => void }) {
+ return (
+
+
+
+
Purpose
+
Detect faces in imported photos and generate face encodings
+
+
+
Features
+
+ - Face detection method used -
retinaface - Best accuracy, medium speed
+ - Face recognition model used -
ArcFace - Best accuracy, medium speed
+ - Batch Size: Configure how many photos to process at once
+ - Real-time Progress: Live progress tracking
+ - Job Cancellation: Stop processing if needed
+
+
+
+
How to Use
+
+ - Optionally set Batch Size (leave empty for default)
+ - Click "Start Processing" button
+ - Monitor progress:
+
+ - Progress bar shows overall completion
+ - Photo count shows photos processed
+ - Face count shows faces detected and stored
+
+
+ - Wait for completion or click "Stop Processing" to cancel
+
+
+
+
What Happens
+
+ - DeepFace analyzes each unprocessed photo
+ - Faces are detected and located
+ - 512-dimensional face encodings are generated
+ - Face metadata is stored (confidence, quality, location)
+ - Photos are marked as processed
+
+
+
+
Tips
+
+ - You can cancel and resume later
+
+
+
+
+ )
+}
+
+function IdentifyPageHelp({ onBack }: { onBack: () => void }) {
+ return (
+
+
+
+
Purpose
+
Manually identify people in detected faces
+
+
+
Features
+
+ - Face Navigation: Browse through unidentified faces
+ - Person Creation: Create new person records
+ - Similar Faces Panel: View similar faces for comparison
+ - Confidence Display: See match confidence percentages
+ - Date Filtering: Filter faces by date taken or processed
+ - Unique Faces Filter: Hide duplicate faces of same person
+ - Face Information: View face metadata (confidence, quality, detector/model)
+
+
+
+
How to Use
+
+
Basic Identification:
+
+ - Navigate to Identify page
+ - View the current face on the left panel
+ - Select existing person from the drop down list or create new person below
+ - Enter person information:
+
+ - First Name (required)
+ - Last Name (required)
+ - Middle Name (optional)
+ - Maiden Name (optional)
+ - Date of Birth (required)
+
+
+ - Click "Identify" button to identify the face
+
+
+
+
Using Similar Faces:
+
+ - Toggle "Compare" checkbox to show similar faces
+ - View similar faces in the right panel
+ - See confidence percentages (color-coded)
+ - Select similar faces to bulk identify with the current left face
+ - Click "Identify" button to bulk identify left and all selected on the right faces to that person.
+
+
+
+
+
Confidence Colors
+
+ - 🟢 80%+ = Very High (Almost Certain)
+ - 🟡 70%+ = High (Likely Match)
+ - 🟠 60%+ = Medium (Possible Match)
+ - 🔴 50%+ = Low (Questionable)
+ - ⚫ <50% = Very Low (Unlikely)
+
+
+
+
Tips
+
+ - Use similar faces to identify groups of photos
+ - Date filtering helps focus on specific time periods
+ - Unique faces filter reduces clutter
+ - Confidence scores help prioritize identification
+
+
+
+
+ )
+}
+
+function AutoMatchPageHelp({ onBack }: { onBack: () => void }) {
+ return (
+
+
+
+
Purpose
+
Automatically match unidentified faces to identified people
+
+
+
Features
+
+ - Person-Centric View: Shows identified person on left, matches on right
+ - Checkbox Selection: Select which faces to identify
+ - Confidence Display: Color-coded match confidence
+ - Batch Identification: Identify multiple faces at once
+ - Navigation: Move between different people
+
+
+
+
How to Use
+
+
Automatic Match Workflow:
+
+ - Navigate to Auto-Match page
+ - Click Run Auto-Match button
+ - All unidentified faces will be matched to identified faces based on the following criteria:
+
+ - Similarity higher than 70%
+ - Picture quality higher than 50%
+ - Profile faces are excluded for better accuracy
+
+
+
+
+
+
Manual Match Workflow:
+
+ - Navigate to Auto-Match page
+ - Faces load automatically on page load
+ - View identified person on the left panel
+ - View matching unidentified faces on the right panel
+ - Check boxes next to faces you want to identify
+ - Click "Save changes for [Person Name]" button
+ - Use "Next" and "Back" buttons to navigate between people
+
+
+
+
+
Tips
+
+ - Review matches carefully before saving
+ - Use confidence scores to guide decisions
+ - You can correct mistakes by going back and unchecking
+ - High confidence matches (>70%) are usually accurate
+
+
+
+
+ )
+}
+
+function SearchPageHelp({ onBack }: { onBack: () => void }) {
+ return (
+
+
+
+
Purpose
+
Search and filter photos by various criteria
+
+
+
Features
+
+ - People Filter: Filter by identified people
+ - Date Filter: Filter by date taken or date added
+ - Tag Filter: Filter by photo tags
+ - Folder Filter: Filter by source folder
+ - Photo Grid: Virtualized grid of matching photos
+ - Pagination: Navigate through search results
+ - Select All: Select all photos in current results
+
+
+
+
How to Use
+
Basic Search:
+
+ - Navigate to Search page
+ - Use filter dropdowns to set criteria
+ - Click "Search" or filters apply automatically
+ - View matching photos in the grid
+ - Click on photos to view details
+ - Use "Select All" to select all photos in results
+ - Use "Tag selected photos" to tag multiple photos at once
+
+
+
+
Tips
+
+ - Combine filters for precise searches
+ - Use date ranges to find photos from specific periods
+ - Tag filtering helps find themed photos
+ - People filter is most useful after identification
+
+
+
+
+ )
+}
+
+function ModifyPageHelp({ onBack }: { onBack: () => void }) {
+ return (
+
+
+
+
Purpose
+
Edit person information and manage person records
+
+
+
Features
+
+ - Person Selection: Choose person to edit
+ - Information Editing: Update names and date of birth
+ - Face Management: View and manage person's faces
+ - Person Deletion: Remove person records (with confirmation)
+
+
+
+
How to Use
+
Editing Person Information:
+
+ - Navigate to Modify page
+ - Select person from dropdown
+ - Edit information fields (First Name, Last Name, Middle Name, Maiden Name, Date of Birth)
+ - Click "Save Changes" button
+
+
+
+
Tips
+
+ - Use this to correct mistakes in identification
+ - Update names if you learn more information
+ - Be careful with deletion - it's permanent
+
+
+
+
+ )
+}
+
+function TagsPageHelp({ onBack }: { onBack: () => void }) {
+ return (
+
+
+
+
Purpose
+
Manage photo tags and tag-photo relationships
+
+
+
Features
+
+ - Tag List: View all existing tags
+ - Tag Creation: Create new tags
+ - Tag Editing: Edit tag names
+ - Tag Deletion: Remove tags
+ - Photo-Tag Linkage: Assign tags to photos
+
+
+
+
How to Use
+
Creating Tags:
+
+ - Navigate to Tags page
+ - Click "Manage Tags" button
+ - Enter new tag name
+ - Click "Add tag" button
+
+
Assigning Tags to Photos:
+
+ - Select photos from Tags page (or from Search page)
+ - Tags can be assigned to multiple photos at once by selecting multiple photos and clicking "Tag Selected Photos" button
+ - Tags can be assigned to all photos in a specific folder at once by clicking the linkage icon next to the folder name
+ - Tags can be assigned to a single photo by clicking the linkage icon on the right of each photo
+
+
+
+
Tips
+
+ - Use descriptive tag names
+ - Create tags for events, locations, themes
+ - Tags help organize and find photos later
+ - Note: Tags are case insensitive!
+
+
+
+
+ )
+}
+
+function FacesMaintenancePageHelp({ onBack }: { onBack: () => void }) {
+ return (
+
+
+
+
Purpose
+
Remove unwanted faces - mainly due to low quality face detections
+
+
+
Features
+
+ - Face List: View all faces in database
+ - Face Filtering: Filter quality
+ - Face Deletion: Remove unwanted faces
+ - Bulk Operations: Perform actions on multiple faces
+
+
+
+
How to Use
+
Viewing Faces:
+
+ - Navigate to Faces Maintenance page
+ - View list of all faces
+ - See face thumbnails and metadata
+ - Filter faces by quality (delete faces with low quality)
+
+
Deleting Faces:
+
+ - Select faces to delete
+ - Click "Delete Selected" button
+ - Confirm deletion
+ - ⚠️ Warning: Deletion is permanent
+
+
+
+
Tips
+
+ - Remove low-quality face detections
+ - Regular maintenance keeps database clean
+
+
+
+
+ )
+}
+
diff --git a/frontend/src/pages/Identify.tsx b/frontend/src/pages/Identify.tsx
index f88c868..6483e2d 100644
--- a/frontend/src/pages/Identify.tsx
+++ b/frontend/src/pages/Identify.tsx
@@ -31,6 +31,8 @@ export default function Identify() {
// SessionStorage key for persisting settings (clears when tab/window closes)
const SETTINGS_KEY = 'identify_settings'
+ // SessionStorage key for persisting page state (faces, current index, etc.)
+ const STATE_KEY = 'identify_state'
const [people, setPeople] = useState([])
const [personId, setPersonId] = useState(undefined)
@@ -63,15 +65,24 @@ export default function Identify() {
const initialLoadRef = useRef(false)
// Track if settings have been loaded from localStorage
const [settingsLoaded, setSettingsLoaded] = useState(false)
+ // Track if state has been restored from sessionStorage
+ const [stateRestored, setStateRestored] = useState(false)
+ // Track if initial restoration is complete (prevents reload effects from firing during restoration)
+ const restorationCompleteRef = useRef(false)
const canIdentify = useMemo(() => {
return Boolean((personId && currentFace) || (firstName && lastName && dob && currentFace))
}, [personId, firstName, lastName, dob, currentFace])
- const loadFaces = async () => {
+ const loadFaces = async (clearState: boolean = false) => {
setLoadingFaces(true)
try {
+ // Clear saved state if explicitly requested (Refresh button)
+ if (clearState) {
+ sessionStorage.removeItem(STATE_KEY)
+ }
+
const res = await facesApi.getUnidentified({
page: 1,
page_size: pageSize,
@@ -96,6 +107,12 @@ export default function Identify() {
setTotal(res.total)
}
setCurrentIdx(0)
+ // Clear form data when refreshing
+ if (clearState) {
+ setFaceFormData({})
+ setSimilar([])
+ setSelectedSimilar({})
+ }
} finally {
setLoadingFaces(false)
}
@@ -266,6 +283,95 @@ export default function Identify() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
+ // Load state from sessionStorage on mount (faces, current index, similar, form data)
+ useEffect(() => {
+ try {
+ const saved = sessionStorage.getItem(STATE_KEY)
+ if (saved) {
+ const state = JSON.parse(saved)
+ if (state.faces && Array.isArray(state.faces) && state.faces.length > 0) {
+ setFaces(state.faces)
+ if (state.currentIdx !== undefined) {
+ setCurrentIdx(Math.min(state.currentIdx, state.faces.length - 1))
+ }
+ if (state.similar && Array.isArray(state.similar)) {
+ setSimilar(state.similar)
+ }
+ if (state.faceFormData && typeof state.faceFormData === 'object') {
+ setFaceFormData(state.faceFormData)
+ }
+ if (state.selectedSimilar && typeof state.selectedSimilar === 'object') {
+ setSelectedSimilar(state.selectedSimilar)
+ }
+ // Mark that we restored state, so we don't reload
+ initialLoadRef.current = true
+ // Mark restoration as complete after state is restored
+ // Use a small delay to ensure all state updates have been processed
+ setTimeout(() => {
+ restorationCompleteRef.current = true
+ }, 50)
+ }
+ }
+ } catch (error) {
+ console.error('Error loading state from sessionStorage:', error)
+ } finally {
+ setStateRestored(true)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ // Save state to sessionStorage whenever it changes (but only after initial restore)
+ useEffect(() => {
+ if (!stateRestored) return // Don't save during initial restore
+
+ try {
+ const state = {
+ faces,
+ currentIdx,
+ similar,
+ faceFormData,
+ selectedSimilar,
+ }
+ sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
+ } catch (error) {
+ console.error('Error saving state to sessionStorage:', error)
+ }
+ }, [faces, currentIdx, similar, faceFormData, selectedSimilar, stateRestored])
+
+ // Save state on unmount (when navigating away) - use refs to capture latest values
+ const facesRef = useRef(faces)
+ const currentIdxRef = useRef(currentIdx)
+ const similarRef = useRef(similar)
+ const faceFormDataRef = useRef(faceFormData)
+ const selectedSimilarRef = useRef(selectedSimilar)
+
+ // Update refs whenever state changes
+ useEffect(() => {
+ facesRef.current = faces
+ currentIdxRef.current = currentIdx
+ similarRef.current = similar
+ faceFormDataRef.current = faceFormData
+ selectedSimilarRef.current = selectedSimilar
+ }, [faces, currentIdx, similar, faceFormData, selectedSimilar])
+
+ // Save state on unmount (when navigating away)
+ useEffect(() => {
+ return () => {
+ try {
+ const state = {
+ faces: facesRef.current,
+ currentIdx: currentIdxRef.current,
+ similar: similarRef.current,
+ faceFormData: faceFormDataRef.current,
+ selectedSimilar: selectedSimilarRef.current,
+ }
+ sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
+ } catch (error) {
+ console.error('Error saving state on unmount:', error)
+ }
+ }
+ }, [])
+
// Save settings to sessionStorage whenever they change (but only after initial load)
useEffect(() => {
if (!settingsLoaded) return // Don't save during initial load
@@ -289,35 +395,50 @@ export default function Identify() {
}
}, [pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly, compareEnabled, selectedTags, settingsLoaded])
- // Initial load on mount (after settings are loaded)
+ // Initial load on mount (after settings and state are loaded)
useEffect(() => {
- if (!initialLoadRef.current && settingsLoaded) {
+ if (!initialLoadRef.current && settingsLoaded && stateRestored) {
initialLoadRef.current = true
- loadFaces()
+ // Only load if we didn't restore state (no faces means we need to load)
+ if (faces.length === 0) {
+ loadFaces()
+ // If we're loading fresh, mark restoration as complete immediately
+ restorationCompleteRef.current = true
+ } else {
+ // If state was restored, restorationCompleteRef is already set in the state restoration effect
+ // But ensure it's set in case state restoration didn't happen
+ if (!restorationCompleteRef.current) {
+ setTimeout(() => {
+ restorationCompleteRef.current = true
+ }, 50)
+ }
+ }
loadPeople()
loadTags()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [settingsLoaded])
+ }, [settingsLoaded, stateRestored])
// Reload when uniqueFacesOnly changes (immediate reload)
+ // But only if restoration is complete (prevents reload during initial restoration)
useEffect(() => {
- if (initialLoadRef.current) {
+ if (initialLoadRef.current && restorationCompleteRef.current) {
loadFaces()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uniqueFacesOnly])
// Reload when pageSize changes (immediate reload)
+ // But only if restoration is complete (prevents reload during initial restoration)
useEffect(() => {
- if (initialLoadRef.current) {
+ if (initialLoadRef.current && restorationCompleteRef.current) {
loadFaces()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize])
useEffect(() => {
- if (currentFace) {
+ if (currentFace && restorationCompleteRef.current) {
setImageLoading(true) // Show loading indicator when face changes
loadSimilar(currentFace.id)
}
@@ -416,15 +537,6 @@ export default function Identify() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentFace?.id]) // Only restore when face ID changes
- useEffect(() => {
- const onKey = (e: KeyboardEvent) => {
- if (e.key === 'Enter' && canIdentify) {
- handleIdentify()
- }
- }
- window.addEventListener('keydown', onKey)
- return () => window.removeEventListener('keydown', onKey)
- }, [canIdentify])
const handleIdentify = async () => {
if (!currentFace) return
@@ -614,13 +726,23 @@ export default function Identify() {
)}