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:
parent
8d668a9658
commit
21c138a339
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user