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:
parent
3e78e90061
commit
8d11ac415e
@ -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)`)
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user