diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 87e1004..750759d 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -117,11 +117,9 @@ The application uses a **left sidebar navigation** with the following pages: **Features**: - **Folder Selection**: Browse and select folders containing photos -- **File Upload**: Drag-and-drop or click to upload individual files - **Recursive Scanning**: Option to scan subfolders recursively - **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**: @@ -457,7 +455,7 @@ Note: Tags are case insensitive!********* **Purpose**: Configure application settings and preferences --- -## Workflow Examples + ### Complete Workflow: Import and Identify Photos @@ -490,15 +488,6 @@ Note: Tags are case insensitive!********* - Find specific photos - Assign tags for organization -### Quick Workflow: Just Process New Photos - -1. **Scan** → Import new photos -2. **Process** → Detect faces -3. **Identify** → Manually identify any remaining faces -4. **Auto-Match** → Automatically match to existing people -5. **Tag Photos** → Tag photos for easy future search - - --- **Last Updated**: October 2025 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1155b2e..1f9d3f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import Modify from './pages/Modify' import Tags from './pages/Tags' import FacesMaintenance from './pages/FacesMaintenance' import Settings from './pages/Settings' +import Help from './pages/Help' import Layout from './components/Layout' function PrivateRoute({ children }: { children: React.ReactNode }) { @@ -44,6 +45,7 @@ function AppRoutes() { } /> } /> } /> + } /> ) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f8aa0d7..5cdfb0f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -6,26 +6,29 @@ export default function Layout() { const { username, logout } = useAuth() const navItems = [ - { path: '/', label: 'Dashboard', icon: '📊' }, { path: '/scan', label: 'Scan', icon: '🗂️' }, { path: '/process', label: 'Process', icon: '⚙️' }, { path: '/search', label: 'Search', icon: '🔍' }, { path: '/identify', label: 'Identify', icon: '👤' }, { path: '/auto-match', label: 'Auto-Match', icon: '🤖' }, { path: '/modify', label: 'Modify', icon: '✏️' }, - { path: '/tags', label: 'Tags', icon: '🏷️' }, + { path: '/tags', label: 'Tag', icon: '🏷️' }, { path: '/faces-maintenance', label: 'Faces Maintenance', icon: '🔧' }, { path: '/settings', label: 'Settings', icon: '⚙️' }, + { path: '/help', label: 'Help', icon: '📚' }, ] return (
{/* Top bar */}
-
+
-
-

PunimTag

+
+ + 🏠 +

PunimTag

+
{username} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 623d85e..6428ab1 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,7 +1,6 @@ export default function Dashboard() { return (
-

Dashboard

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:

+
    +
  1. Click "Browse Folder" button
  2. +
  3. Select a folder containing photos
  4. +
  5. Toggle "Recursive" if you want to include subfolders (enabled by default)
  6. +
  7. Click "Start Scan" button
  8. +
  9. Monitor progress in the progress bar
  10. +
  11. View results (photos added, existing photos skipped)
  12. +
+
+
+

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

+
    +
  1. Optionally set Batch Size (leave empty for default)
  2. +
  3. Click "Start Processing" button
  4. +
  5. Monitor progress: +
      +
    • Progress bar shows overall completion
    • +
    • Photo count shows photos processed
    • +
    • Face count shows faces detected and stored
    • +
    +
  6. +
  7. Wait for completion or click "Stop Processing" to cancel
  8. +
+
+
+

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:

+
    +
  1. Navigate to Identify page
  2. +
  3. View the current face on the left panel
  4. +
  5. Select existing person from the drop down list or create new person below
  6. +
  7. Enter person information: +
      +
    • First Name (required)
    • +
    • Last Name (required)
    • +
    • Middle Name (optional)
    • +
    • Maiden Name (optional)
    • +
    • Date of Birth (required)
    • +
    +
  8. +
  9. Click "Identify" button to identify the face
  10. +
+
+
+

Using Similar Faces:

+
    +
  1. Toggle "Compare" checkbox to show similar faces
  2. +
  3. View similar faces in the right panel
  4. +
  5. See confidence percentages (color-coded)
  6. +
  7. Select similar faces to bulk identify with the current left face
  8. +
  9. Click "Identify" button to bulk identify left and all selected on the right faces to that person.
  10. +
+
+
+
+

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:

+
    +
  1. Navigate to Auto-Match page
  2. +
  3. Click Run Auto-Match button
  4. +
  5. 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
    • +
    +
  6. +
+
+
+

Manual Match Workflow:

+
    +
  1. Navigate to Auto-Match page
  2. +
  3. Faces load automatically on page load
  4. +
  5. View identified person on the left panel
  6. +
  7. View matching unidentified faces on the right panel
  8. +
  9. Check boxes next to faces you want to identify
  10. +
  11. Click "Save changes for [Person Name]" button
  12. +
  13. Use "Next" and "Back" buttons to navigate between people
  14. +
+
+
+
+

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:

+
    +
  1. Navigate to Search page
  2. +
  3. Use filter dropdowns to set criteria
  4. +
  5. Click "Search" or filters apply automatically
  6. +
  7. View matching photos in the grid
  8. +
  9. Click on photos to view details
  10. +
  11. Use "Select All" to select all photos in results
  12. +
  13. Use "Tag selected photos" to tag multiple photos at once
  14. +
+
+
+

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:

+
    +
  1. Navigate to Modify page
  2. +
  3. Select person from dropdown
  4. +
  5. Edit information fields (First Name, Last Name, Middle Name, Maiden Name, Date of Birth)
  6. +
  7. Click "Save Changes" button
  8. +
+
+
+

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:

+
    +
  1. Navigate to Tags page
  2. +
  3. Click "Manage Tags" button
  4. +
  5. Enter new tag name
  6. +
  7. Click "Add tag" button
  8. +
+

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:

+
    +
  1. Navigate to Faces Maintenance page
  2. +
  3. View list of all faces
  4. +
  5. See face thumbnails and metadata
  6. +
  7. Filter faces by quality (delete faces with low quality)
  8. +
+

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() { )}
- +
+ + +
)} @@ -632,6 +754,14 @@ export default function Identify() {
+
{!currentFace ? ( @@ -769,7 +899,7 @@ export default function Identify() { diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index 9143997..3fd1d64 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -200,6 +200,11 @@ export default function Search() { setSelectedPhotos(new Set()) } + const selectAll = () => { + const allPhotoIds = new Set(results.map(photo => photo.id)) + setSelectedPhotos(allPhotoIds) + } + const loadPhotoTags = async () => { if (selectedPhotos.size === 0) return @@ -593,6 +598,13 @@ export default function Search() { > Tag selected photos +