feat: Add Help page and enhance user navigation in PunimTag application

This commit introduces a new Help page to the PunimTag application, providing users with detailed guidance on various features and workflows. The navigation has been updated to include the Help page, improving accessibility to support resources. Additionally, the user guide has been refined to remove outdated workflow examples, ensuring clarity and relevance. The Dashboard page has also been streamlined for a cleaner interface. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-11-12 12:13:19 -05:00
parent 842f588f19
commit 89a63cbf57
8 changed files with 749 additions and 46 deletions

View File

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

View File

@ -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() {
<Route path="tags" element={<Tags />} />
<Route path="faces-maintenance" element={<FacesMaintenance />} />
<Route path="settings" element={<Settings />} />
<Route path="help" element={<Help />} />
</Route>
</Routes>
)

View File

@ -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 (
<div className="min-h-screen bg-gray-50">
{/* Top bar */}
<div className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto px-2 sm:px-4 lg:px-6">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<h1 className="text-xl font-bold text-gray-900">PunimTag</h1>
<div className="flex items-center gap-2">
<Link to="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
<span className="text-2xl">🏠</span>
<h1 className="text-xl font-bold text-gray-900">PunimTag</h1>
</Link>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{username}</span>

View File

@ -1,7 +1,6 @@
export default function Dashboard() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Dashboard</h1>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-gray-600">Welcome to PunimTag!</p>
<p className="text-gray-600 mt-2">

520
frontend/src/pages/Help.tsx Normal file
View File

@ -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<PageId>('overview')
const renderPageContent = () => {
switch (currentPage) {
case 'overview':
return <NavigationOverview onPageClick={setCurrentPage} />
case 'scan':
return <ScanPageHelp onBack={() => setCurrentPage('overview')} />
case 'process':
return <ProcessPageHelp onBack={() => setCurrentPage('overview')} />
case 'identify':
return <IdentifyPageHelp onBack={() => setCurrentPage('overview')} />
case 'auto-match':
return <AutoMatchPageHelp onBack={() => setCurrentPage('overview')} />
case 'search':
return <SearchPageHelp onBack={() => setCurrentPage('overview')} />
case 'modify':
return <ModifyPageHelp onBack={() => setCurrentPage('overview')} />
case 'tags':
return <TagsPageHelp onBack={() => setCurrentPage('overview')} />
case 'faces-maintenance':
return <FacesMaintenancePageHelp onBack={() => setCurrentPage('overview')} />
default:
return <NavigationOverview onPageClick={setCurrentPage} />
}
}
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-6">📚 Help</h1>
{renderPageContent()}
</div>
)
}
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 (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Navigation Overview</h2>
<p className="text-gray-700 mb-6">
The application uses a <strong>left sidebar navigation</strong> with the following pages. Click on any page to learn more about it:
</p>
<div className="grid grid-cols-1 gap-3">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => onPageClick(item.id)}
className="flex items-center gap-4 p-4 hover:bg-gray-50 rounded-lg border border-gray-200 text-left transition-colors"
>
<span className="text-2xl">{item.icon}</span>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900">{item.label}</span>
</div>
<p className="text-sm text-gray-600 mt-1">{item.description}</p>
</div>
<span className="text-gray-400"></span>
</button>
))}
</div>
</div>
)
}
function PageHelpLayout({ title, onBack, children }: { title: string; onBack: () => void; children: React.ReactNode }) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-4 mb-6">
<button
onClick={onBack}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 font-medium"
>
Back
</button>
<h2 className="text-2xl font-bold text-gray-900">{title}</h2>
</div>
{children}
</div>
)
}
function ScanPageHelp({ onBack }: { onBack: () => void }) {
return (
<PageHelpLayout title="Scan Page" onBack={onBack}>
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Purpose</h3>
<p className="text-gray-700">Import photos into your collection from folders or upload files</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Features</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li><strong>Folder Selection:</strong> Browse and select folders containing photos</li>
<li><strong>Recursive Scanning:</strong> Option to scan subfolders recursively (enabled by default)</li>
<li><strong>Duplicate Detection:</strong> Automatically detects and skips duplicate photos</li>
<li><strong>Real-time Progress:</strong> Live progress tracking during import</li>
<li><strong>EXIF Extraction:</strong> Automatically extracts date taken and metadata</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">How to Use</h3>
<p className="text-gray-700 font-medium mb-2">Folder Scan:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Click "Browse Folder" button</li>
<li>Select a folder containing photos</li>
<li>Toggle "Recursive" if you want to include subfolders (enabled by default)</li>
<li>Click "Start Scan" button</li>
<li>Monitor progress in the progress bar</li>
<li>View results (photos added, existing photos skipped)</li>
</ol>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">What Happens</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Photos are copied to <code className="bg-gray-100 px-1 rounded">data/uploads</code> directory</li>
<li>EXIF metadata is extracted (date taken, orientation, etc.)</li>
<li>Duplicate detection by checksum</li>
<li>Photos are added to database</li>
<li>Faces are NOT detected yet (use Process page for that)</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Tips</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Large folders may take time - be patient!</li>
</ul>
</div>
</div>
</PageHelpLayout>
)
}
function ProcessPageHelp({ onBack }: { onBack: () => void }) {
return (
<PageHelpLayout title="Process Page" onBack={onBack}>
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Purpose</h3>
<p className="text-gray-700">Detect faces in imported photos and generate face encodings</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Features</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li><strong>Face detection method used</strong> - <code className="bg-gray-100 px-1 rounded">retinaface</code> - Best accuracy, medium speed</li>
<li><strong>Face recognition model used</strong> - <code className="bg-gray-100 px-1 rounded">ArcFace</code> - Best accuracy, medium speed</li>
<li><strong>Batch Size:</strong> Configure how many photos to process at once</li>
<li><strong>Real-time Progress:</strong> Live progress tracking</li>
<li><strong>Job Cancellation:</strong> Stop processing if needed</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">How to Use</h3>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Optionally set <strong>Batch Size</strong> (leave empty for default)</li>
<li>Click "Start Processing" button</li>
<li>Monitor progress:
<ul className="list-disc list-inside ml-4 mt-1">
<li>Progress bar shows overall completion</li>
<li>Photo count shows photos processed</li>
<li>Face count shows faces detected and stored</li>
</ul>
</li>
<li>Wait for completion or click "Stop Processing" to cancel</li>
</ol>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">What Happens</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>DeepFace analyzes each unprocessed photo</li>
<li>Faces are detected and located</li>
<li>512-dimensional face encodings are generated</li>
<li>Face metadata is stored (confidence, quality, location)</li>
<li>Photos are marked as processed</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Tips</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>You can cancel and resume later</li>
</ul>
</div>
</div>
</PageHelpLayout>
)
}
function IdentifyPageHelp({ onBack }: { onBack: () => void }) {
return (
<PageHelpLayout title="Identify Page" onBack={onBack}>
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Purpose</h3>
<p className="text-gray-700">Manually identify people in detected faces</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Features</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li><strong>Face Navigation:</strong> Browse through unidentified faces</li>
<li><strong>Person Creation:</strong> Create new person records</li>
<li><strong>Similar Faces Panel:</strong> View similar faces for comparison</li>
<li><strong>Confidence Display:</strong> See match confidence percentages</li>
<li><strong>Date Filtering:</strong> Filter faces by date taken or processed</li>
<li><strong>Unique Faces Filter:</strong> Hide duplicate faces of same person</li>
<li><strong>Face Information:</strong> View face metadata (confidence, quality, detector/model)</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">How to Use</h3>
<div className="mb-3">
<p className="text-gray-700 font-medium mb-2">Basic Identification:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Navigate to Identify page</li>
<li>View the current face on the left panel</li>
<li>Select existing person from the drop down list or create new person below</li>
<li>Enter person information:
<ul className="list-disc list-inside ml-4 mt-1">
<li>First Name (required)</li>
<li>Last Name (required)</li>
<li>Middle Name (optional)</li>
<li>Maiden Name (optional)</li>
<li>Date of Birth (required)</li>
</ul>
</li>
<li>Click "Identify" button to identify the face</li>
</ol>
</div>
<div className="mb-3">
<p className="text-gray-700 font-medium mb-2">Using Similar Faces:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Toggle "Compare" checkbox to show similar faces</li>
<li>View similar faces in the right panel</li>
<li>See confidence percentages (color-coded)</li>
<li>Select similar faces to bulk identify with the current left face</li>
<li>Click "Identify" button to bulk identify left and all selected on the right faces to that person.</li>
</ol>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Confidence Colors</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>🟢 <strong>80%+</strong> = Very High (Almost Certain)</li>
<li>🟡 <strong>70%+</strong> = High (Likely Match)</li>
<li>🟠 <strong>60%+</strong> = Medium (Possible Match)</li>
<li>🔴 <strong>50%+</strong> = Low (Questionable)</li>
<li> <strong>&lt;50%</strong> = Very Low (Unlikely)</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Tips</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Use similar faces to identify groups of photos</li>
<li>Date filtering helps focus on specific time periods</li>
<li>Unique faces filter reduces clutter</li>
<li>Confidence scores help prioritize identification</li>
</ul>
</div>
</div>
</PageHelpLayout>
)
}
function AutoMatchPageHelp({ onBack }: { onBack: () => void }) {
return (
<PageHelpLayout title="Auto-Match Page" onBack={onBack}>
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Purpose</h3>
<p className="text-gray-700">Automatically match unidentified faces to identified people</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Features</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li><strong>Person-Centric View:</strong> Shows identified person on left, matches on right</li>
<li><strong>Checkbox Selection:</strong> Select which faces to identify</li>
<li><strong>Confidence Display:</strong> Color-coded match confidence</li>
<li><strong>Batch Identification:</strong> Identify multiple faces at once</li>
<li><strong>Navigation:</strong> Move between different people</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">How to Use</h3>
<div className="mb-3">
<p className="text-gray-700 font-medium mb-2">Automatic Match Workflow:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Navigate to Auto-Match page</li>
<li>Click Run Auto-Match button</li>
<li>All unidentified faces will be matched to identified faces based on the following criteria:
<ul className="list-disc list-inside ml-4 mt-1">
<li>Similarity higher than 70%</li>
<li>Picture quality higher than 50%</li>
<li>Profile faces are excluded for better accuracy</li>
</ul>
</li>
</ol>
</div>
<div className="mb-3">
<p className="text-gray-700 font-medium mb-2">Manual Match Workflow:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Navigate to Auto-Match page</li>
<li>Faces load automatically on page load</li>
<li>View identified person on the left panel</li>
<li>View matching unidentified faces on the right panel</li>
<li>Check boxes next to faces you want to identify</li>
<li>Click "Save changes for [Person Name]" button</li>
<li>Use "Next" and "Back" buttons to navigate between people</li>
</ol>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Tips</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Review matches carefully before saving</li>
<li>Use confidence scores to guide decisions</li>
<li>You can correct mistakes by going back and unchecking</li>
<li>High confidence matches (&gt;70%) are usually accurate</li>
</ul>
</div>
</div>
</PageHelpLayout>
)
}
function SearchPageHelp({ onBack }: { onBack: () => void }) {
return (
<PageHelpLayout title="Search Page" onBack={onBack}>
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Purpose</h3>
<p className="text-gray-700">Search and filter photos by various criteria</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Features</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li><strong>People Filter:</strong> Filter by identified people</li>
<li><strong>Date Filter:</strong> Filter by date taken or date added</li>
<li><strong>Tag Filter:</strong> Filter by photo tags</li>
<li><strong>Folder Filter:</strong> Filter by source folder</li>
<li><strong>Photo Grid:</strong> Virtualized grid of matching photos</li>
<li><strong>Pagination:</strong> Navigate through search results</li>
<li><strong>Select All:</strong> Select all photos in current results</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">How to Use</h3>
<p className="text-gray-700 font-medium mb-2">Basic Search:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Navigate to Search page</li>
<li>Use filter dropdowns to set criteria</li>
<li>Click "Search" or filters apply automatically</li>
<li>View matching photos in the grid</li>
<li>Click on photos to view details</li>
<li>Use "Select All" to select all photos in results</li>
<li>Use "Tag selected photos" to tag multiple photos at once</li>
</ol>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Tips</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Combine filters for precise searches</li>
<li>Use date ranges to find photos from specific periods</li>
<li>Tag filtering helps find themed photos</li>
<li>People filter is most useful after identification</li>
</ul>
</div>
</div>
</PageHelpLayout>
)
}
function ModifyPageHelp({ onBack }: { onBack: () => void }) {
return (
<PageHelpLayout title="Modify Page" onBack={onBack}>
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Purpose</h3>
<p className="text-gray-700">Edit person information and manage person records</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Features</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li><strong>Person Selection:</strong> Choose person to edit</li>
<li><strong>Information Editing:</strong> Update names and date of birth</li>
<li><strong>Face Management:</strong> View and manage person's faces</li>
<li><strong>Person Deletion:</strong> Remove person records (with confirmation)</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">How to Use</h3>
<p className="text-gray-700 font-medium mb-2">Editing Person Information:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Navigate to Modify page</li>
<li>Select person from dropdown</li>
<li>Edit information fields (First Name, Last Name, Middle Name, Maiden Name, Date of Birth)</li>
<li>Click "Save Changes" button</li>
</ol>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Tips</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Use this to correct mistakes in identification</li>
<li>Update names if you learn more information</li>
<li>Be careful with deletion - it's permanent</li>
</ul>
</div>
</div>
</PageHelpLayout>
)
}
function TagsPageHelp({ onBack }: { onBack: () => void }) {
return (
<PageHelpLayout title="Tags Page" onBack={onBack}>
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Purpose</h3>
<p className="text-gray-700">Manage photo tags and tag-photo relationships</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Features</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li><strong>Tag List:</strong> View all existing tags</li>
<li><strong>Tag Creation:</strong> Create new tags</li>
<li><strong>Tag Editing:</strong> Edit tag names</li>
<li><strong>Tag Deletion:</strong> Remove tags</li>
<li><strong>Photo-Tag Linkage:</strong> Assign tags to photos</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">How to Use</h3>
<p className="text-gray-700 font-medium mb-2">Creating Tags:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Navigate to Tags page</li>
<li>Click "Manage Tags" button</li>
<li>Enter new tag name</li>
<li>Click "Add tag" button</li>
</ol>
<p className="text-gray-700 font-medium mb-2 mt-4">Assigning Tags to Photos:</p>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Select photos from Tags page (or from Search page)</li>
<li>Tags can be assigned to multiple photos at once by selecting multiple photos and clicking "Tag Selected Photos" button</li>
<li>Tags can be assigned to all photos in a specific folder at once by clicking the linkage icon next to the folder name</li>
<li>Tags can be assigned to a single photo by clicking the linkage icon on the right of each photo</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Tips</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Use descriptive tag names</li>
<li>Create tags for events, locations, themes</li>
<li>Tags help organize and find photos later</li>
<li><strong>Note:</strong> Tags are case insensitive!</li>
</ul>
</div>
</div>
</PageHelpLayout>
)
}
function FacesMaintenancePageHelp({ onBack }: { onBack: () => void }) {
return (
<PageHelpLayout title="Faces Maintenance Page" onBack={onBack}>
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Purpose</h3>
<p className="text-gray-700">Remove unwanted faces - mainly due to low quality face detections</p>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Features</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li><strong>Face List:</strong> View all faces in database</li>
<li><strong>Face Filtering:</strong> Filter quality</li>
<li><strong>Face Deletion:</strong> Remove unwanted faces</li>
<li><strong>Bulk Operations:</strong> Perform actions on multiple faces</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">How to Use</h3>
<p className="text-gray-700 font-medium mb-2">Viewing Faces:</p>
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
<li>Navigate to Faces Maintenance page</li>
<li>View list of all faces</li>
<li>See face thumbnails and metadata</li>
<li>Filter faces by quality (delete faces with low quality)</li>
</ol>
<p className="text-gray-700 font-medium mb-2 mt-4">Deleting Faces:</p>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Select faces to delete</li>
<li>Click "Delete Selected" button</li>
<li>Confirm deletion</li>
<li> <strong>Warning:</strong> Deletion is permanent</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Tips</h3>
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
<li>Remove low-quality face detections</li>
<li>Regular maintenance keeps database clean</li>
</ul>
</div>
</div>
</PageHelpLayout>
)
}

View File

@ -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<Person[]>([])
const [personId, setPersonId] = useState<number | undefined>(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() {
)}
</div>
<div className="mt-4 pt-3 border-t">
<button
onClick={loadFaces}
disabled={loadingFaces}
className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
>
{loadingFaces ? 'Loading...' : 'Apply Filters'}
</button>
<div className="flex gap-2">
<button
onClick={() => loadFaces(false)}
disabled={loadingFaces}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
>
{loadingFaces ? 'Loading...' : 'Apply Filters'}
</button>
<button
onClick={() => loadFaces(true)}
disabled={loadingFaces}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
title="Refresh and start from beginning"
>
{loadingFaces ? 'Refreshing...' : '🔄 Refresh'}
</button>
</div>
</div>
</div>
)}
@ -632,6 +754,14 @@ export default function Identify() {
<div className="space-x-2">
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))}>Prev</button>
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))}>Next</button>
<button
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
onClick={() => loadFaces(true)}
disabled={loadingFaces}
title="Refresh and start from beginning"
>
{loadingFaces ? 'Refreshing...' : '🔄 Refresh'}
</button>
</div>
</div>
{!currentFace ? (
@ -769,7 +899,7 @@ export default function Identify() {
<button disabled={!canIdentify || busy}
onClick={handleIdentify}
className={`px-3 py-2 rounded text-white ${canIdentify && !busy ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-gray-400 cursor-not-allowed'}`}>
{busy ? 'Identifying...' : 'Identify (Enter)'}
{busy ? 'Identifying...' : 'Identify'}
</button>
<button onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))} className="px-3 py-2 rounded border">Back</button>
<button onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))} className="px-3 py-2 rounded border">Next</button>

View File

@ -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
</button>
<button
onClick={selectAll}
disabled={results.length === 0}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
>
Select all
</button>
<button
onClick={clearAllSelected}
disabled={selectedPhotos.size === 0}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect, useMemo, useRef } from 'react'
import tagsApi, { PhotoWithTagsItem, TagResponse } from '../api/tags'
import { useDeveloperMode } from '../context/DeveloperModeContext'
@ -22,12 +22,32 @@ interface FolderGroup {
photoCount: number
}
// SessionStorage key for persisting folder states
const FOLDER_STATES_KEY = 'tags_folder_states'
// Helper function to load folder states from sessionStorage synchronously
const loadFolderStatesFromStorage = (): Record<string, boolean> => {
try {
const saved = sessionStorage.getItem(FOLDER_STATES_KEY)
if (saved) {
const states = JSON.parse(saved)
if (states && typeof states === 'object') {
return states
}
}
} catch (error) {
console.error('Error loading folder states from sessionStorage:', error)
}
return {}
}
export default function Tags() {
const { isDeveloperMode } = useDeveloperMode()
const [viewMode, setViewMode] = useState<ViewMode>('list')
const [photos, setPhotos] = useState<PhotoWithTagsItem[]>([])
const [tags, setTags] = useState<TagResponse[]>([])
const [folderStates, setFolderStates] = useState<Record<string, boolean>>({})
// Initialize folder states from sessionStorage synchronously
const [folderStates, setFolderStates] = useState<Record<string, boolean>>(() => loadFolderStatesFromStorage())
const [pendingTagChanges, setPendingTagChanges] = useState<Record<number, number[]>>({})
const [pendingTagRemovals, setPendingTagRemovals] = useState<Record<number, number[]>>({})
const [pendingLinkageTypes, setPendingLinkageTypes] = useState<Record<number, Record<number, number>>>({})
@ -38,6 +58,34 @@ export default function Tags() {
const [showBulkTagDialog, setShowBulkTagDialog] = useState<string | null>(null)
const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<number>>(new Set())
const [showTagSelectedDialog, setShowTagSelectedDialog] = useState(false)
// Refs to capture latest folder states for unmount save
const folderStatesRef = useRef(folderStates)
// Update ref whenever folder states change
useEffect(() => {
folderStatesRef.current = folderStates
}, [folderStates])
// Save folder states to sessionStorage whenever they change
useEffect(() => {
try {
sessionStorage.setItem(FOLDER_STATES_KEY, JSON.stringify(folderStates))
} catch (error) {
console.error('Error saving folder states to sessionStorage:', error)
}
}, [folderStates])
// Save folder states on unmount (when navigating away)
useEffect(() => {
return () => {
try {
sessionStorage.setItem(FOLDER_STATES_KEY, JSON.stringify(folderStatesRef.current))
} catch (error) {
console.error('Error saving folder states on unmount:', error)
}
}
}, [])
// Load photos and tags
useEffect(() => {
@ -334,7 +382,7 @@ export default function Tags() {
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-gray-900">Photo Explorer - Tag Management</h1>
<h1 className="text-2xl font-bold text-gray-900">Photos tagging interface</h1>
<div className="flex items-center gap-4">
{isDeveloperMode && (
<div className="flex items-center gap-2">