feat: Enhance Modify and Tags components with improved state management and confirmation dialogs

This commit refines the Modify and Tags components by implementing better state management for person selection and tag handling. In the Modify component, the logic for checking if a selected person still exists has been optimized to prevent unnecessary state updates. The Tags component has been updated to support immediate saving of tags and includes confirmation dialogs for tag removals, enhancing user experience. Additionally, error handling for tag creation and deletion has been improved, ensuring a more robust interaction with the API. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-11-10 11:41:45 -05:00
parent 3e78e90061
commit 8d11ac415e
2 changed files with 486 additions and 127 deletions

View File

@ -326,21 +326,28 @@ export default function Modify() {
// Reload people list first to update face counts and check if person still exists
const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
// Check if selected person still exists BEFORE updating state
// This prevents useEffect from triggering unnecessary requests
const currentSelectedId = selectedPersonId
const personStillExists = currentSelectedId
? peopleRes.items.some(p => p.id === currentSelectedId)
: false
// Clear selection immediately if person was deleted (before state update)
if (currentSelectedId && !personStillExists) {
setSelectedPersonId(null)
setSelectedPersonName('')
setFaces([])
setError(null)
}
// Update people list
setPeople(peopleRes.items)
// Check if selected person still exists and handle accordingly
if (selectedPersonId) {
const personStillExists = peopleRes.items.some(p => p.id === selectedPersonId)
if (personStillExists) {
// Person still exists, reload their faces
await loadPersonFaces(selectedPersonId)
} else {
// Person was deleted, clear selection and any errors
setSelectedPersonId(null)
setSelectedPersonName('')
setFaces([])
setError(null) // Clear any error that might have been set
}
// Reload faces only if person still exists
if (currentSelectedId && personStillExists) {
await loadPersonFaces(currentSelectedId)
}
setSuccess(`Successfully unlinked ${faceIds.length} face(s)`)

View File

@ -122,8 +122,8 @@ export default function Tags() {
}))
}
// Add tag to photo
const addTagToPhoto = async (photoId: number, tagName: string, linkageType: number = 0) => {
// Add tag to photo (with optional immediate save)
const addTagToPhoto = async (photoId: number, tagName: string, linkageType: number = 0, saveImmediately: boolean = false) => {
const tag = tags.find(t => t.tag_name.toLowerCase() === tagName.toLowerCase().trim())
let tagId: number
@ -142,46 +142,80 @@ export default function Tags() {
}
}
// Add to pending changes
setPendingTagChanges(prev => ({
...prev,
[photoId]: [...(prev[photoId] || []), tagId],
}))
setPendingLinkageTypes(prev => ({
...prev,
[photoId]: {
...(prev[photoId] || {}),
[tagId]: linkageType,
},
}))
}
// Remove tag from photo
const removeTagFromPhoto = (photoId: number, tagId: number, linkageType: number) => {
// If it's a pending addition, just remove it
if (pendingTagChanges[photoId]?.includes(tagId)) {
if (saveImmediately) {
// Save immediately to database
try {
await tagsApi.addToPhotos({
photo_ids: [photoId],
tag_names: [tagName.trim()],
linkage_type: linkageType,
})
// Don't reload here - will reload when dialog closes
} catch (error) {
console.error('Failed to save tag:', error)
alert('Failed to save tag')
return
}
} else {
// Add to pending changes
setPendingTagChanges(prev => ({
...prev,
[photoId]: (prev[photoId] || []).filter(id => id !== tagId),
[photoId]: [...(prev[photoId] || []), tagId],
}))
setPendingLinkageTypes(prev => ({
...prev,
[photoId]: {
...(prev[photoId] || {}),
[tagId]: linkageType,
},
}))
setPendingLinkageTypes(prev => {
const newTypes = { ...prev }
if (newTypes[photoId]) {
delete newTypes[photoId][tagId]
if (Object.keys(newTypes[photoId]).length === 0) {
delete newTypes[photoId]
}
}
return newTypes
})
return
}
}
// Otherwise, add to pending removals
setPendingTagRemovals(prev => ({
...prev,
[photoId]: [...(prev[photoId] || []), tagId],
}))
// Remove tag from photo (with optional immediate save)
const removeTagFromPhoto = async (photoId: number, tagId: number, linkageType: number, saveImmediately: boolean = false) => {
const tag = tags.find(t => t.id === tagId)
if (!tag) return
if (saveImmediately) {
// Save immediately to database
try {
await tagsApi.removeFromPhotos({
photo_ids: [photoId],
tag_names: [tag.tag_name],
})
// Don't reload here - will reload when dialog closes
} catch (error) {
console.error('Failed to remove tag:', error)
alert('Failed to remove tag')
return
}
} else {
// If it's a pending addition, just remove it
if (pendingTagChanges[photoId]?.includes(tagId)) {
setPendingTagChanges(prev => ({
...prev,
[photoId]: (prev[photoId] || []).filter(id => id !== tagId),
}))
setPendingLinkageTypes(prev => {
const newTypes = { ...prev }
if (newTypes[photoId]) {
delete newTypes[photoId][tagId]
if (Object.keys(newTypes[photoId]).length === 0) {
delete newTypes[photoId]
}
}
return newTypes
})
return
}
// Otherwise, add to pending removals
setPendingTagRemovals(prev => ({
...prev,
[photoId]: [...(prev[photoId] || []), tagId],
}))
}
}
// Save pending changes
@ -367,7 +401,7 @@ export default function Tags() {
onClick={() => toggleFolder(folder.folderPath)}
className="px-2 py-1 text-sm"
>
{folderStates[folder.folderPath] !== false ? '▼' : '▶'}
{folderStates[folder.folderPath] === true ? '▼' : '▶'}
</button>
<span className="font-semibold">
📁 {folder.folderName} ({folder.photoCount} photos)
@ -381,7 +415,7 @@ export default function Tags() {
</div>
</td>
</tr>
{folderStates[folder.folderPath] !== false && folder.photos.map(photo => (
{folderStates[folder.folderPath] === true && folder.photos.map(photo => (
<tr key={photo.id} className="border-b hover:bg-gray-50">
<td className="p-2">{photo.id}</td>
<td className="p-2">
@ -449,7 +483,11 @@ export default function Tags() {
{showManageTags && (
<ManageTagsDialog
tags={tags}
onClose={() => setShowManageTags(false)}
onClose={async () => {
setShowManageTags(false)
// Refresh photos when exiting the dialog
await loadData()
}}
onTagsChange={setTags}
/>
)}
@ -462,9 +500,13 @@ export default function Tags() {
tags={tags}
pendingTagChanges={pendingTagChanges[showTagDialog] || []}
pendingTagRemovals={pendingTagRemovals[showTagDialog] || []}
onClose={() => setShowTagDialog(null)}
onAddTag={(tagName, linkageType) => addTagToPhoto(showTagDialog, tagName, linkageType)}
onRemoveTag={(tagId, linkageType) => removeTagFromPhoto(showTagDialog, tagId, linkageType)}
onClose={async () => {
setShowTagDialog(null)
// Reload data when dialog is closed
await loadData()
}}
onAddTag={(tagName, linkageType) => addTagToPhoto(showTagDialog, tagName, linkageType, true)}
onRemoveTag={(tagId, linkageType) => removeTagFromPhoto(showTagDialog, tagId, linkageType, true)}
getPhotoTags={async (photoId) => {
return await tagsApi.getPhotoTags(photoId)
}}
@ -477,14 +519,69 @@ export default function Tags() {
folderPath={showBulkTagDialog}
folder={folderGroups.find(f => f.folderPath === showBulkTagDialog)}
tags={tags}
onClose={() => setShowBulkTagDialog(null)}
onAddTag={(tagName) => {
pendingTagChanges={pendingTagChanges}
pendingTagRemovals={pendingTagRemovals}
onClose={async () => {
setShowBulkTagDialog(null)
// Reload data when dialog is closed
await loadData()
}}
onAddTag={async (tagName) => {
const folder = folderGroups.find(f => f.folderPath === showBulkTagDialog)
if (folder) {
folder.photos.forEach(photo => {
addTagToPhoto(photo.id, tagName, 1) // linkage_type = 1 (bulk)
})
if (!folder || folder.photos.length === 0) return
// Check if tag exists, create if not
let tag = tags.find(t => t.tag_name.toLowerCase() === tagName.toLowerCase().trim())
if (!tag) {
try {
tag = await tagsApi.create(tagName.trim())
setTags(prev => [...prev, tag!].sort((a, b) => a.tag_name.localeCompare(b.tag_name)))
} catch (error) {
console.error('Failed to create tag:', error)
alert('Failed to create tag')
return
}
}
// Collect all photo IDs
const photoIds = folder.photos.map(photo => photo.id)
// Make single batch API call for all photos
try {
await tagsApi.addToPhotos({
photo_ids: photoIds,
tag_names: [tagName.trim()],
linkage_type: 1, // bulk
})
} catch (error) {
console.error('Failed to save tags:', error)
alert('Failed to save tags')
}
}}
onRemoveTag={async (tagId) => {
const folder = folderGroups.find(f => f.folderPath === showBulkTagDialog)
if (!folder || folder.photos.length === 0) return
// Find tag name
const tag = tags.find(t => t.id === tagId)
if (!tag) return
// Collect all photo IDs
const photoIds = folder.photos.map(photo => photo.id)
// Make single batch API call for all photos
try {
await tagsApi.removeFromPhotos({
photo_ids: photoIds,
tag_names: [tag.tag_name],
})
} catch (error) {
console.error('Failed to remove tags:', error)
alert('Failed to remove tags')
}
}}
getPhotoTags={async (photoId) => {
return await tagsApi.getPhotoTags(photoId)
}}
/>
)}
@ -492,6 +589,40 @@ export default function Tags() {
)
}
// Confirmation Dialog Component
function ConfirmDialog({
message,
onConfirm,
onCancel,
}: {
message: string
onConfirm: () => void
onCancel: () => void
}) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60]">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h3 className="text-lg font-bold mb-4">Confirm Delete</h3>
<p className="text-gray-700 mb-6">{message}</p>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)
}
// Manage Tags Dialog Component
function ManageTagsDialog({
tags,
@ -506,14 +637,31 @@ function ManageTagsDialog({
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set())
const [editingTagId, setEditingTagId] = useState<number | null>(null)
const [editTagName, setEditTagName] = useState('')
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [tagError, setTagError] = useState<string>('')
// Check if tag name already exists (case-insensitive)
const isTagDuplicate = (tagName: string): boolean => {
if (!tagName.trim()) return false
const trimmedName = tagName.trim().toLowerCase()
return tags.some(tag => tag.tag_name.toLowerCase() === trimmedName)
}
const handleCreateTag = async () => {
if (!newTagName.trim()) return
// Check for duplicate (case-insensitive)
if (isTagDuplicate(newTagName)) {
setTagError('A tag with this name already exists')
return
}
setTagError('')
try {
const newTag = await tagsApi.create(newTagName.trim())
onTagsChange([...tags, newTag].sort((a, b) => a.tag_name.localeCompare(b.tag_name)))
setNewTagName('')
setTagError('')
} catch (error) {
console.error('Failed to create tag:', error)
alert('Failed to create tag')
@ -534,19 +682,18 @@ function ManageTagsDialog({
}
}
const handleDeleteTags = async () => {
const handleDeleteTags = () => {
if (selectedTagIds.size === 0) return
setShowConfirmDialog(true)
}
if (!confirm(`Delete ${selectedTagIds.size} selected tag(s)? This will unlink them from photos.`)) {
return
}
const confirmDeleteTags = async () => {
setShowConfirmDialog(false)
try {
await tagsApi.delete(Array.from(selectedTagIds))
onTagsChange(tags.filter(t => !selectedTagIds.has(t.id)))
setSelectedTagIds(new Set())
// Reload photos to reflect tag deletions
window.location.reload()
} catch (error) {
console.error('Failed to delete tags:', error)
alert('Failed to delete tags')
@ -554,27 +701,53 @@ function ManageTagsDialog({
}
return (
<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-2xl max-h-[80vh] flex flex-col">
<>
{showConfirmDialog && (
<ConfirmDialog
message={`Delete ${selectedTagIds.size} selected tag(s)? This will unlink them from photos.`}
onConfirm={confirmDeleteTags}
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-2xl max-h-[80vh] flex flex-col">
<div className="p-4 border-b">
<h2 className="text-xl font-bold">Manage Tags</h2>
</div>
<div className="p-4 flex-1 overflow-auto">
<div className="mb-4 flex gap-2">
<input
type="text"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreateTag()}
placeholder="Enter new tag name"
className="flex-1 px-3 py-2 border border-gray-300 rounded"
/>
<button
onClick={handleCreateTag}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Add tag
</button>
<div className="mb-4">
<div className="flex gap-2">
<div className="flex-1">
<input
type="text"
value={newTagName}
onChange={(e) => {
setNewTagName(e.target.value)
// Check for duplicate as user types
if (e.target.value.trim() && isTagDuplicate(e.target.value)) {
setTagError('A tag with this name already exists')
} else {
setTagError('')
}
}}
onKeyPress={(e) => e.key === 'Enter' && handleCreateTag()}
placeholder="Enter new tag name"
className={`w-full px-3 py-2 border rounded ${
tagError ? 'border-red-500' : 'border-gray-300'
}`}
/>
{tagError && (
<p className="mt-1 text-sm text-red-600">{tagError}</p>
)}
</div>
<button
onClick={handleCreateTag}
disabled={!!tagError || !newTagName.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 tag
</button>
</div>
</div>
<div className="border-b mb-2 pb-2 flex">
@ -660,6 +833,7 @@ function ManageTagsDialog({
</div>
</div>
</div>
</>
)
}
@ -681,13 +855,14 @@ function PhotoTagDialog({
pendingTagChanges: number[]
pendingTagRemovals: number[]
onClose: () => void
onAddTag: (tagName: string, linkageType: number) => void
onRemoveTag: (tagId: number, linkageType: number) => void
onAddTag: (tagName: string, linkageType: number) => Promise<void>
onRemoveTag: (tagId: number, linkageType: number) => Promise<void>
getPhotoTags: (photoId: number) => Promise<any>
}) {
const [selectedTagName, setSelectedTagName] = useState('')
const [photoTags, setPhotoTags] = useState<any[]>([])
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set())
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
useEffect(() => {
loadPhotoTags()
@ -702,22 +877,28 @@ function PhotoTagDialog({
}
}
const handleAddTag = () => {
const handleAddTag = async () => {
if (!selectedTagName.trim()) return
onAddTag(selectedTagName.trim(), 0) // linkage_type = 0 (single)
await onAddTag(selectedTagName.trim(), 0) // linkage_type = 0 (single)
setSelectedTagName('')
loadPhotoTags()
await loadPhotoTags()
}
const handleRemoveTags = () => {
if (selectedTagIds.size === 0) return
selectedTagIds.forEach(tagId => {
setShowConfirmDialog(true)
}
const confirmRemoveTags = async () => {
setShowConfirmDialog(false)
if (selectedTagIds.size === 0) return
for (const tagId of selectedTagIds) {
const tag = photoTags.find(t => t.tag_id === tagId)
const linkageType = tag?.linkage_type || 0
onRemoveTag(tagId, linkageType)
})
await onRemoveTag(tagId, linkageType)
}
setSelectedTagIds(new Set())
loadPhotoTags()
await loadPhotoTags()
}
// Combine saved and pending tags
@ -733,8 +914,27 @@ function PhotoTagDialog({
return [...savedTags, ...pendingTagObjs]
}, [photoTags, pendingTagChanges, pendingTagRemovals, tags])
// Get selected tag names for confirmation message
const selectedTagNames = useMemo(() => {
return Array.from(selectedTagIds)
.map(id => {
const tag = allTags.find(t => t.tag_id === id)
return tag ? tag.tag_name : ''
})
.filter(Boolean)
.join(', ')
}, [selectedTagIds, allTags])
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<>
{showConfirmDialog && (
<ConfirmDialog
message={`Remove ${selectedTagIds.size} selected tag(s) from this photo? (${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">Manage Photo Tags</h2>
@ -815,6 +1015,7 @@ function PhotoTagDialog({
</div>
</div>
</div>
</>
)
}
@ -823,25 +1024,134 @@ function BulkTagDialog({
folderPath,
folder,
tags,
pendingTagChanges,
pendingTagRemovals,
onClose,
onAddTag,
onRemoveTag,
getPhotoTags,
}: {
folderPath: string
folder: FolderGroup | undefined
tags: TagResponse[]
pendingTagChanges: Record<number, number[]>
pendingTagRemovals: Record<number, number[]>
onClose: () => void
onAddTag: (tagName: string) => void
onAddTag: (tagName: string) => Promise<void>
onRemoveTag: (tagId: number) => Promise<void>
getPhotoTags: (photoId: number) => Promise<any>
}) {
const [selectedTagName, setSelectedTagName] = useState('')
const [folderTags, setFolderTags] = useState<any[]>([])
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set())
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const handleAddTag = () => {
if (!selectedTagName.trim()) return
onAddTag(selectedTagName.trim())
setSelectedTagName('')
useEffect(() => {
loadFolderTags()
}, [folder])
const loadFolderTags = async () => {
if (!folder || folder.photos.length === 0) {
setFolderTags([])
return
}
try {
// Get tags from the first photo in the folder
// Bulk tags should be the same for all photos in the folder
const response = await getPhotoTags(folder.photos[0].id)
// Filter for bulk tags (linkage_type = 1)
const bulkTags = (response.tags || []).filter((tag: any) => tag.linkage_type === 1)
setFolderTags(bulkTags)
} catch (error) {
console.error('Failed to load folder tags:', error)
setFolderTags([])
}
}
const handleAddTag = async () => {
if (!selectedTagName.trim()) return
const tagName = selectedTagName.trim()
await onAddTag(tagName)
setSelectedTagName('')
// Update local state immediately to reflect the change
const tag = tags.find(t => t.tag_name.toLowerCase() === tagName.toLowerCase())
if (tag) {
const newTag = { tag_id: tag.id, tag_name: tag.tag_name, linkage_type: 1 }
setFolderTags(prev => {
// Check if tag already exists
if (prev.some(t => t.tag_id === tag.id)) {
return prev
}
return [...prev, newTag]
})
}
}
const handleRemoveTags = () => {
if (selectedTagIds.size === 0) return
setShowConfirmDialog(true)
}
const confirmRemoveTags = async () => {
setShowConfirmDialog(false)
if (selectedTagIds.size === 0) return
const tagIdsToRemove = Array.from(selectedTagIds)
for (const tagId of tagIdsToRemove) {
await onRemoveTag(tagId)
}
setSelectedTagIds(new Set())
// Update local state immediately to reflect the removal
setFolderTags(prev => prev.filter(t => !tagIdsToRemove.includes(t.tag_id)))
}
// Combine saved and pending tags
const allTags = useMemo(() => {
if (!folder || folder.photos.length === 0) return []
// Get pending changes and removals for the first photo (they should be the same for all)
const firstPhotoId = folder.photos[0].id
const pendingTagIds = pendingTagChanges[firstPhotoId] || []
const pendingRemovalIds = pendingTagRemovals[firstPhotoId] || []
// Filter out pending removals from saved tags
const savedTags = folderTags.filter(t => !pendingRemovalIds.includes(t.tag_id))
// Add pending tags that are bulk (linkage_type = 1)
const pendingTagObjs = pendingTagIds
.filter(id => !pendingRemovalIds.includes(id))
.map(id => {
const tag = tags.find(t => t.id === id)
return tag ? { tag_id: id, tag_name: tag.tag_name, linkage_type: 1 } : null
})
.filter(Boolean) as any[]
return [...savedTags, ...pendingTagObjs]
}, [folderTags, pendingTagChanges, pendingTagRemovals, tags, folder])
// Get selected tag names for confirmation message
const selectedTagNames = useMemo(() => {
return Array.from(selectedTagIds)
.map(id => {
const tag = allTags.find(t => t.tag_id === id)
return tag ? tag.tag_name : ''
})
.filter(Boolean)
.join(', ')
}, [selectedTagIds, allTags])
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<>
{showConfirmDialog && (
<ConfirmDialog
message={`Remove ${selectedTagIds.size} selected bulk tag(s) from all photos in this folder? (${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">Bulk Link Tags to Folder</h2>
@ -852,33 +1162,74 @@ function BulkTagDialog({
)}
</div>
<div className="p-4 flex-1 overflow-auto">
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Add tag to all photos:
</label>
<div className="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}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Add
</button>
</div>
<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">
{allTags.length === 0 ? (
<p className="text-gray-500 text-sm">No bulk tags linked to this folder</p>
) : (
allTags.map(tag => {
if (!folder || folder.photos.length === 0) return null
const firstPhotoId = folder.photos[0].id
const isPending = (pendingTagChanges[firstPhotoId] || []).includes(tag.tag_id)
const linkageType = tag.linkage_type || 1
const canSelect = isPending || linkageType === 1
return (
<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)
}}
disabled={!canSelect}
className="w-4 h-4"
/>
<span className={`flex-1 ${!canSelect ? 'text-gray-400' : ''}`}>
{tag.tag_name}
{isPending ? ' (pending)' : ' (saved bulk)'}
</span>
</div>
)
})
)}
</div>
</div>
<div className="p-4 border-t flex justify-end">
<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"
@ -888,5 +1239,6 @@ function BulkTagDialog({
</div>
</div>
</div>
</>
)
}