+ |
+ {
+ 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"
+ />
+ |
{photo.id} |
)}
+
+ {/* Tag Selected Photos Dialog */}
+ {showTagSelectedDialog && (
+ selectedPhotoIds.has(p.id))}
+ tags={tags}
+ onClose={async () => {
+ setShowTagSelectedDialog(false)
+ setSelectedPhotoIds(new Set())
+ // Reload data when dialog is closed
+ await loadData()
+ }}
+ />
+ )}
)
}
@@ -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>(new Set())
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false)
+ const [photoTagsData, setPhotoTagsData] = useState>({})
+
+ // Load tag linkage information for all selected photos
+ useEffect(() => {
+ const loadPhotoTags = async () => {
+ const tagsData: Record = {}
+ 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 = {}
+ 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 = {}
+ 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 = {}
+ 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()
+ 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 && (
+ setShowConfirmDialog(false)}
+ />
+ )}
+
+
+
+ Tag Selected Photos
+
+ {selectedPhotoIds.length} photo(s) selected
+
+
+
+
+
+
+
+
+
+
+ Removable Tags (single tags present in all selected photos):
+
+
+ Note: Bulk tags cannot be removed from this dialog
+
+ {commonTags.length === 0 ? (
+ No common tags found
+ ) : (
+ commonTags.map(tag => (
+
+ {
+ 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"
+ />
+ {tag.tag_name}
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+ >
+ )
+}
|