feat: Improve UI components for batch processing and tag management

This commit enhances the user interface of the Process, Scan, Search, and Tags components. The input fields for batch size and button styles have been adjusted for better usability and consistency. The Search component now includes improved logic for handling removable tags, ensuring only single tags can be deleted. Additionally, a new dialog for tagging selected photos has been introduced, allowing users to manage tags more effectively. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-11-10 15:05:00 -05:00
parent 8d668a9658
commit 21c138a339
4 changed files with 427 additions and 49 deletions

View File

@ -235,7 +235,7 @@ export default function Process() {
<div>
<label
htmlFor="batch-size"
className="block text-sm font-medium text-gray-700 mb-2"
className="block text-sm font-medium text-gray-700 mb-1"
>
Batch Size
</label>
@ -271,11 +271,10 @@ export default function Process() {
e.preventDefault()
}
}}
placeholder="All unprocessed photos"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
className="w-32 px-2 py-1 text-sm border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
disabled={isProcessing}
/>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-xs text-gray-500">
Leave empty to process all unprocessed photos
</p>
</div>
@ -342,7 +341,7 @@ export default function Process() {
type="button"
onClick={handleStartProcessing}
disabled={isProcessing}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? 'Processing...' : 'Start Processing'}
</button>

View File

@ -289,9 +289,9 @@ export default function Scan() {
type="button"
onClick={handleScanFolder}
disabled={isImporting || !folderPath.trim()}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? 'Scanning...' : 'Start Scan'}
{isImporting ? 'Scanning...' : 'Start Scanning'}
</button>
</div>
</div>

View File

@ -237,20 +237,36 @@ export default function Search() {
}, [showTagModal, selectedPhotos.size])
// Get all unique tags from selected photos
// Only include tags that are single (linkage_type = 0) in ALL selected photos
// Tags that are bulk (linkage_type = 1) in ANY photo cannot be removed
const allPhotoTags = useMemo(() => {
if (selectedPhotos.size === 0) return []
const photoIds = Array.from(selectedPhotos)
const tagMap = new Map<number, PhotoTagItem>()
// First, collect all tags from all photos
Object.values(photoTags).forEach(tags => {
tags.forEach(tag => {
// Use tag_id as key to avoid duplicates
if (!tagMap.has(tag.tag_id)) {
tagMap.set(tag.tag_id, tag)
}
})
})
return Array.from(tagMap.values())
}, [photoTags])
// Filter to only include tags that are single (linkage_type = 0) in ALL photos
const removableTags = Array.from(tagMap.values()).filter(tag => {
// Check if this tag is single in all selected photos
return photoIds.every(photoId => {
const photoTagList = photoTags[photoId] || []
const photoTag = photoTagList.find(t => t.tag_id === tag.tag_id)
// Tag must exist in photo and be single (linkage_type = 0)
return photoTag && photoTag.linkage_type === 0
})
})
return removableTags
}, [photoTags, selectedPhotos])
const handleAddTag = async () => {
if (selectedPhotos.size === 0) {
@ -311,7 +327,18 @@ export default function Search() {
setShowDeleteConfirm(false)
// Get tag names from selected tag IDs
// Filter to only include tags that are single (linkage_type = 0) in all photos
// This is a defensive check - allPhotoTags should already only contain single tags
const photoIds = Array.from(selectedPhotos)
const tagNamesToDelete = Array.from(selectedTagIds)
.filter(tagId => {
// Check if tag is single in all selected photos
return photoIds.every(photoId => {
const photoTagList = photoTags[photoId] || []
const photoTag = photoTagList.find(t => t.tag_id === tagId)
return photoTag && photoTag.linkage_type === 0
})
})
.map(tagId => {
const tag = allPhotoTags.find(t => t.tag_id === tagId)
return tag ? tag.tag_name : null
@ -319,7 +346,7 @@ export default function Search() {
.filter(Boolean) as string[]
if (tagNamesToDelete.length === 0) {
alert('No valid tags selected for deletion.')
alert('No removable tags selected. Bulk tags cannot be removed from this dialog.')
return
}
@ -804,12 +831,15 @@ export default function Search() {
<div className="mb-4 border-t pt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Existing Tags on Selected Photos:
Removable Tags (single tags present in all selected photos):
</label>
<p className="text-xs text-gray-500 mb-2">
Note: Bulk tags cannot be removed from this dialog
</p>
{loadingTags ? (
<p className="text-sm text-gray-500">Loading tags...</p>
) : allPhotoTags.length === 0 ? (
<p className="text-sm text-gray-500">No tags on selected photos</p>
<p className="text-sm text-gray-500">No removable tags on selected photos</p>
) : (
<div className="space-y-2 max-h-40 overflow-y-auto border rounded p-2">
{allPhotoTags.map(tag => (
@ -830,7 +860,6 @@ export default function Search() {
/>
<span className="flex-1 text-sm">
{tag.tag_name}
{tag.linkage_type === 1 && ' (bulk)'}
</span>
</div>
))}

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react'
import tagsApi, { PhotoWithTagsItem, TagResponse } from '../api/tags'
import { useDeveloperMode } from '../context/DeveloperModeContext'
type ViewMode = 'list' | 'icons' | 'compact'
@ -22,6 +23,7 @@ interface FolderGroup {
}
export default function Tags() {
const { isDeveloperMode } = useDeveloperMode()
const [viewMode, setViewMode] = useState<ViewMode>('list')
const [photos, setPhotos] = useState<PhotoWithTagsItem[]>([])
const [tags, setTags] = useState<TagResponse[]>([])
@ -34,6 +36,8 @@ export default function Tags() {
const [showManageTags, setShowManageTags] = useState(false)
const [showTagDialog, setShowTagDialog] = useState<number | null>(null)
const [showBulkTagDialog, setShowBulkTagDialog] = useState<string | null>(null)
const [selectedPhotoIds, setSelectedPhotoIds] = useState<Set<number>>(new Set())
const [showTagSelectedDialog, setShowTagSelectedDialog] = useState(false)
// Load photos and tags
useEffect(() => {
@ -332,41 +336,50 @@ export default function Tags() {
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-gray-900">Photo Explorer - Tag Management</h1>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">View:</label>
<div className="flex gap-2">
<button
onClick={() => setViewMode('list')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'list'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
List
</button>
<button
onClick={() => setViewMode('icons')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'icons'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Icons
</button>
<button
onClick={() => setViewMode('compact')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'compact'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Compact
</button>
{isDeveloperMode && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">View:</label>
<div className="flex gap-2">
<button
onClick={() => setViewMode('list')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'list'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
List
</button>
<button
onClick={() => setViewMode('icons')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'icons'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Icons
</button>
<button
onClick={() => setViewMode('compact')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'compact'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Compact
</button>
</div>
</div>
</div>
)}
<button
onClick={() => setShowTagSelectedDialog(true)}
disabled={selectedPhotoIds.size === 0}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Tag Selected Photos ({selectedPhotoIds.size})
</button>
<button
onClick={() => setShowManageTags(true)}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
@ -382,6 +395,20 @@ export default function Tags() {
<table className="w-full border-collapse">
<thead>
<tr className="border-b">
<th className="text-left p-2 font-semibold w-12">
<input
type="checkbox"
checked={selectedPhotoIds.size > 0 && selectedPhotoIds.size === photos.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedPhotoIds(new Set(photos.map(p => p.id)))
} else {
setSelectedPhotoIds(new Set())
}
}}
className="w-4 h-4"
/>
</th>
<th className="text-left p-2 font-semibold">ID</th>
<th className="text-left p-2 font-semibold">Filename</th>
<th className="text-left p-2 font-semibold">Path</th>
@ -395,7 +422,7 @@ export default function Tags() {
{folderGroups.map(folder => (
<React.Fragment key={folder.folderPath}>
<tr className="bg-gray-50 border-b">
<td colSpan={7} className="p-2">
<td colSpan={8} className="p-2">
<div className="flex items-center gap-2">
<button
onClick={() => toggleFolder(folder.folderPath)}
@ -417,6 +444,22 @@ export default function Tags() {
</tr>
{folderStates[folder.folderPath] === true && folder.photos.map(photo => (
<tr key={photo.id} className="border-b hover:bg-gray-50">
<td className="p-2">
<input
type="checkbox"
checked={selectedPhotoIds.has(photo.id)}
onChange={(e) => {
const newSet = new Set(selectedPhotoIds)
if (e.target.checked) {
newSet.add(photo.id)
} else {
newSet.delete(photo.id)
}
setSelectedPhotoIds(newSet)
}}
className="w-4 h-4"
/>
</td>
<td className="p-2">{photo.id}</td>
<td className="p-2">
<a
@ -571,6 +614,21 @@ export default function Tags() {
}}
/>
)}
{/* Tag Selected Photos Dialog */}
{showTagSelectedDialog && (
<TagSelectedPhotosDialog
selectedPhotoIds={Array.from(selectedPhotoIds)}
photos={photos.filter(p => selectedPhotoIds.has(p.id))}
tags={tags}
onClose={async () => {
setShowTagSelectedDialog(false)
setSelectedPhotoIds(new Set())
// Reload data when dialog is closed
await loadData()
}}
/>
)}
</div>
)
}
@ -1228,3 +1286,295 @@ function BulkTagDialog({
</>
)
}
// Tag Selected Photos Dialog Component
function TagSelectedPhotosDialog({
selectedPhotoIds,
photos,
tags,
onClose,
}: {
selectedPhotoIds: number[]
photos: PhotoWithTagsItem[]
tags: TagResponse[]
onClose: () => void
}) {
const [selectedTagName, setSelectedTagName] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set())
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [photoTagsData, setPhotoTagsData] = useState<Record<number, any[]>>({})
// Load tag linkage information for all selected photos
useEffect(() => {
const loadPhotoTags = async () => {
const tagsData: Record<number, any[]> = {}
for (const photoId of selectedPhotoIds) {
try {
const response = await tagsApi.getPhotoTags(photoId)
tagsData[photoId] = response.tags || []
} catch (error) {
console.error(`Failed to load tags for photo ${photoId}:`, error)
tagsData[photoId] = []
}
}
setPhotoTagsData(tagsData)
}
if (selectedPhotoIds.length > 0) {
loadPhotoTags()
}
}, [selectedPhotoIds])
const handleAddTag = async () => {
if (!selectedTagName.trim() || 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
}
}
// Make single batch API call for all selected photos
try {
await tagsApi.addToPhotos({
photo_ids: selectedPhotoIds,
tag_names: [selectedTagName.trim()],
linkage_type: 0, // Always use single linkage type
})
setSelectedTagName('')
// Reload photo tags data to update the common tags list
const tagsData: Record<number, any[]> = {}
for (const photoId of selectedPhotoIds) {
try {
const response = await tagsApi.getPhotoTags(photoId)
tagsData[photoId] = response.tags || []
} catch (error) {
console.error(`Failed to load tags for photo ${photoId}:`, error)
tagsData[photoId] = []
}
}
setPhotoTagsData(tagsData)
} catch (error) {
console.error('Failed to save tags:', error)
alert('Failed to save tags')
}
}
const handleRemoveTags = () => {
if (selectedTagIds.size === 0) return
setShowConfirmDialog(true)
}
const confirmRemoveTags = async () => {
setShowConfirmDialog(false)
if (selectedTagIds.size === 0) return
// Filter to only remove tags that are single (linkage_type = 0) in all photos
// This is a defensive check - commonTags should already only contain single tags
const tagIdsToRemove = Array.from(selectedTagIds).filter(tagId => {
// Check if tag is single in all photos
return selectedPhotoIds.every(photoId => {
const photoTags = photoTagsData[photoId] || []
const tag = photoTags.find(t => t.tag_id === tagId)
return tag && tag.linkage_type === 0
})
})
if (tagIdsToRemove.length === 0) {
alert('No removable tags selected. Bulk tags cannot be removed from this dialog.')
return
}
for (const tagId of tagIdsToRemove) {
const tag = tags.find(t => t.id === tagId)
if (!tag) continue
try {
await tagsApi.removeFromPhotos({
photo_ids: selectedPhotoIds,
tag_names: [tag.tag_name],
})
} catch (error) {
console.error('Failed to remove tags:', error)
alert(`Failed to remove tag: ${tag.tag_name}`)
}
}
setSelectedTagIds(new Set())
// Reload photo tags data to update the common tags list
const tagsData: Record<number, any[]> = {}
for (const photoId of selectedPhotoIds) {
try {
const response = await tagsApi.getPhotoTags(photoId)
tagsData[photoId] = response.tags || []
} catch (error) {
console.error(`Failed to load tags for photo ${photoId}:`, error)
tagsData[photoId] = []
}
}
setPhotoTagsData(tagsData)
}
// Get common tags across all selected photos (only single linkage type tags can be removed)
const commonTags = useMemo(() => {
if (photos.length === 0 || selectedPhotoIds.length === 0) return []
// Get tag linkage information for all selected photos
const allPhotoTags: Record<number, any[]> = {}
selectedPhotoIds.forEach(photoId => {
allPhotoTags[photoId] = photoTagsData[photoId] || []
})
// Find tags that exist in all selected photos with single linkage type (linkage_type = 0)
// Only tags that are single in ALL photos can be removed
const tagIdToName = new Map(tags.map(t => [t.id, t.tag_name]))
// Get all unique tag IDs from all photos
const allTagIds = new Set<number>()
Object.values(allPhotoTags).forEach(photoTags => {
photoTags.forEach(tag => allTagIds.add(tag.tag_id))
})
// Find tags that are:
// 1. Present in all selected photos
// 2. Have linkage_type = 0 (single) in all photos
const removableTags = Array.from(allTagIds).filter(tagId => {
// Check if tag exists in all photos
const existsInAll = selectedPhotoIds.every(photoId => {
const photoTags = allPhotoTags[photoId] || []
return photoTags.some(t => t.tag_id === tagId)
})
if (!existsInAll) return false
// Check if tag is single (linkage_type = 0) in all photos
const isSingleInAll = selectedPhotoIds.every(photoId => {
const photoTags = allPhotoTags[photoId] || []
const tag = photoTags.find(t => t.tag_id === tagId)
return tag && tag.linkage_type === 0
})
return isSingleInAll
})
// Convert to tag objects
return removableTags
.map(tagId => {
const tagName = tagIdToName.get(tagId)
if (!tagName) return null
return { tag_id: tagId, tag_name: tagName, linkage_type: 0 }
})
.filter(Boolean) as any[]
}, [photos, tags, selectedPhotoIds, photoTagsData])
// Get selected tag names for confirmation message
const selectedTagNames = useMemo(() => {
return Array.from(selectedTagIds)
.map(id => {
const tag = commonTags.find(t => t.tag_id === id)
return tag ? tag.tag_name : ''
})
.filter(Boolean)
.join(', ')
}, [selectedTagIds, commonTags])
return (
<>
{showConfirmDialog && (
<ConfirmDialog
message={`Remove ${selectedTagIds.size} selected tag(s) from ${selectedPhotoIds.length} photo(s)? (${selectedTagNames})`}
onConfirm={confirmRemoveTags}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[80vh] flex flex-col">
<div className="p-4 border-b">
<h2 className="text-xl font-bold">Tag Selected Photos</h2>
<p className="text-sm text-gray-600">
{selectedPhotoIds.length} photo(s) selected
</p>
</div>
<div className="p-4 flex-1 overflow-auto">
<div className="mb-4 flex gap-2">
<select
value={selectedTagName}
onChange={(e) => setSelectedTagName(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded"
>
<option value="">Select tag...</option>
{tags.map(tag => (
<option key={tag.id} value={tag.tag_name}>
{tag.tag_name}
</option>
))}
</select>
<button
onClick={handleAddTag}
disabled={!selectedTagName.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Add
</button>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-sm text-gray-700 mb-2">
Removable Tags (single tags present in all selected photos):
</h3>
<p className="text-xs text-gray-500 mb-2">
Note: Bulk tags cannot be removed from this dialog
</p>
{commonTags.length === 0 ? (
<p className="text-gray-500 text-sm">No common tags found</p>
) : (
commonTags.map(tag => (
<div key={tag.tag_id} className="flex items-center gap-2 py-1">
<input
type="checkbox"
checked={selectedTagIds.has(tag.tag_id)}
onChange={(e) => {
const newSet = new Set(selectedTagIds)
if (e.target.checked) {
newSet.add(tag.tag_id)
} else {
newSet.delete(tag.tag_id)
}
setSelectedTagIds(newSet)
}}
className="w-4 h-4"
/>
<span className="flex-1">{tag.tag_name}</span>
</div>
))
)}
</div>
</div>
<div className="p-4 border-t flex justify-between">
<button
onClick={handleRemoveTags}
disabled={selectedTagIds.size === 0}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Remove selected tags
</button>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Close
</button>
</div>
</div>
</div>
</>
)
}