feat: Enhance tag management with new API endpoints and frontend components
This commit introduces several new features for tag management, including the ability to retrieve tags for specific photos, update tag names, and delete tags through new API endpoints. The frontend has been updated to support these functionalities, allowing users to manage tags more effectively with a user-friendly interface. Additionally, new components for managing photo tags and bulk tagging have been added, improving overall usability. Documentation and tests have been updated to reflect these changes, ensuring reliability and user satisfaction.
This commit is contained in:
@@ -14,6 +14,7 @@ export interface TagsResponse {
|
||||
export interface PhotoTagsRequest {
|
||||
photo_ids: number[]
|
||||
tag_names: string[]
|
||||
linkage_type?: number // 0=single, 1=bulk
|
||||
}
|
||||
|
||||
export interface PhotoTagsResponse {
|
||||
@@ -23,6 +24,42 @@ export interface PhotoTagsResponse {
|
||||
tags_removed: number
|
||||
}
|
||||
|
||||
export interface PhotoTagItem {
|
||||
tag_id: number
|
||||
tag_name: string
|
||||
linkage_type: number // 0=single, 1=bulk
|
||||
}
|
||||
|
||||
export interface PhotoTagsListResponse {
|
||||
photo_id: number
|
||||
tags: PhotoTagItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface TagUpdateRequest {
|
||||
tag_name: string
|
||||
}
|
||||
|
||||
export interface TagDeleteRequest {
|
||||
tag_ids: number[]
|
||||
}
|
||||
|
||||
export interface PhotoWithTagsItem {
|
||||
id: number
|
||||
filename: string
|
||||
path: string
|
||||
processed: boolean
|
||||
date_taken?: string | null
|
||||
date_added?: string | null
|
||||
face_count: number
|
||||
tags: string // Comma-separated tags string
|
||||
}
|
||||
|
||||
export interface PhotosWithTagsResponse {
|
||||
items: PhotoWithTagsItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const tagsApi = {
|
||||
list: async (): Promise<TagsResponse> => {
|
||||
const { data } = await apiClient.get<TagsResponse>('/api/v1/tags')
|
||||
@@ -51,6 +88,29 @@ export const tagsApi = {
|
||||
)
|
||||
return data
|
||||
},
|
||||
getPhotoTags: async (photoId: number): Promise<PhotoTagsListResponse> => {
|
||||
const { data } = await apiClient.get<PhotoTagsListResponse>(
|
||||
`/api/v1/tags/photos/${photoId}`
|
||||
)
|
||||
return data
|
||||
},
|
||||
update: async (tagId: number, tagName: string): Promise<TagResponse> => {
|
||||
const { data } = await apiClient.put<TagResponse>(`/api/v1/tags/${tagId}`, {
|
||||
tag_name: tagName,
|
||||
})
|
||||
return data
|
||||
},
|
||||
delete: async (tagIds: number[]): Promise<{ message: string; deleted_count: number }> => {
|
||||
const { data } = await apiClient.post<{ message: string; deleted_count: number }>(
|
||||
'/api/v1/tags/delete',
|
||||
{ tag_ids: tagIds }
|
||||
)
|
||||
return data
|
||||
},
|
||||
getPhotosWithTags: async (): Promise<PhotosWithTagsResponse> => {
|
||||
const { data } = await apiClient.get<PhotosWithTagsResponse>('/api/v1/tags/photos')
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export default tagsApi
|
||||
|
||||
+885
-4
@@ -1,11 +1,892 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import tagsApi, { PhotoWithTagsItem, TagResponse } from '../api/tags'
|
||||
|
||||
type ViewMode = 'list' | 'icons' | 'compact'
|
||||
|
||||
interface PendingTagChange {
|
||||
photoId: number
|
||||
tagIds: number[]
|
||||
linkageType: number // 0=single, 1=bulk
|
||||
}
|
||||
|
||||
interface PendingTagRemoval {
|
||||
photoId: number
|
||||
tagIds: number[]
|
||||
}
|
||||
|
||||
interface FolderGroup {
|
||||
folderPath: string
|
||||
folderName: string
|
||||
photos: PhotoWithTagsItem[]
|
||||
photoCount: number
|
||||
}
|
||||
|
||||
export default function Tags() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list')
|
||||
const [photos, setPhotos] = useState<PhotoWithTagsItem[]>([])
|
||||
const [tags, setTags] = useState<TagResponse[]>([])
|
||||
const [folderStates, setFolderStates] = useState<Record<string, boolean>>({})
|
||||
const [pendingTagChanges, setPendingTagChanges] = useState<Record<number, number[]>>({})
|
||||
const [pendingTagRemovals, setPendingTagRemovals] = useState<Record<number, number[]>>({})
|
||||
const [pendingLinkageTypes, setPendingLinkageTypes] = useState<Record<number, Record<number, number>>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showManageTags, setShowManageTags] = useState(false)
|
||||
const [showTagDialog, setShowTagDialog] = useState<number | null>(null)
|
||||
const [showBulkTagDialog, setShowBulkTagDialog] = useState<string | null>(null)
|
||||
|
||||
// Load photos and tags
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [photosRes, tagsRes] = await Promise.all([
|
||||
tagsApi.getPhotosWithTags(),
|
||||
tagsApi.list(),
|
||||
])
|
||||
setPhotos(photosRes.items)
|
||||
setTags(tagsRes.items)
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error)
|
||||
alert('Failed to load photos and tags')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Group photos by folder
|
||||
const folderGroups = useMemo(() => {
|
||||
const groups: Record<string, PhotoWithTagsItem[]> = {}
|
||||
photos.forEach(photo => {
|
||||
const folderPath = photo.path.substring(0, photo.path.lastIndexOf('/') || photo.path.length)
|
||||
if (!groups[folderPath]) {
|
||||
groups[folderPath] = []
|
||||
}
|
||||
groups[folderPath].push(photo)
|
||||
})
|
||||
|
||||
const sortedFolders: FolderGroup[] = []
|
||||
Object.keys(groups).sort().forEach(folderPath => {
|
||||
const folderName = folderPath.substring(folderPath.lastIndexOf('/') + 1) || 'Root'
|
||||
const photosInFolder = groups[folderPath].sort((a, b) => {
|
||||
const dateA = a.date_taken || ''
|
||||
const dateB = b.date_taken || ''
|
||||
return dateB.localeCompare(dateA)
|
||||
})
|
||||
sortedFolders.push({
|
||||
folderPath,
|
||||
folderName,
|
||||
photos: photosInFolder,
|
||||
photoCount: photosInFolder.length,
|
||||
})
|
||||
})
|
||||
|
||||
return sortedFolders
|
||||
}, [photos])
|
||||
|
||||
// Get tags for a photo (including pending changes)
|
||||
const getPhotoTags = (photoId: number): string => {
|
||||
const photo = photos.find(p => p.id === photoId)
|
||||
if (!photo) return ''
|
||||
|
||||
const existingTags = photo.tags ? photo.tags.split(',').map(t => t.trim()).filter(t => t) : []
|
||||
const pendingTagIds = pendingTagChanges[photoId] || []
|
||||
const pendingRemovalIds = pendingTagRemovals[photoId] || []
|
||||
|
||||
// Get tag names for pending additions
|
||||
const pendingTagNames = pendingTagIds
|
||||
.filter(tagId => !pendingRemovalIds.includes(tagId))
|
||||
.map(tagId => {
|
||||
const tag = tags.find(t => t.id === tagId)
|
||||
return tag ? tag.tag_name : `Unknown ${tagId}`
|
||||
})
|
||||
|
||||
// Combine existing and pending tags, remove pending removals
|
||||
const allTags = [...existingTags, ...pendingTagNames]
|
||||
const removalTagIds = pendingRemovalIds
|
||||
const tagIdToName = new Map(tags.map(t => [t.id, t.tag_name]))
|
||||
const removalTagNames = removalTagIds.map(id => tagIdToName.get(id) || '').filter(Boolean)
|
||||
|
||||
const finalTags = allTags.filter(tag => !removalTagNames.includes(tag))
|
||||
return finalTags.join(', ') || 'None'
|
||||
}
|
||||
|
||||
// Toggle folder expand/collapse
|
||||
const toggleFolder = (folderPath: string) => {
|
||||
setFolderStates(prev => ({
|
||||
...prev,
|
||||
[folderPath]: !prev[folderPath],
|
||||
}))
|
||||
}
|
||||
|
||||
// Add tag to photo
|
||||
const addTagToPhoto = async (photoId: number, tagName: string, linkageType: number = 0) => {
|
||||
const tag = tags.find(t => t.tag_name.toLowerCase() === tagName.toLowerCase().trim())
|
||||
let tagId: number
|
||||
|
||||
if (tag) {
|
||||
tagId = tag.id
|
||||
} else {
|
||||
// Create new tag
|
||||
try {
|
||||
const newTag = await tagsApi.create(tagName.trim())
|
||||
tagId = newTag.id
|
||||
setTags(prev => [...prev, newTag].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
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
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
|
||||
const saveChanges = async () => {
|
||||
const pendingPhotoIds = new Set([
|
||||
...Object.keys(pendingTagChanges).map(Number),
|
||||
...Object.keys(pendingTagRemovals).map(Number),
|
||||
])
|
||||
|
||||
if (pendingPhotoIds.size === 0) {
|
||||
alert('No tag changes to save.')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
// Process additions
|
||||
for (const photoId of Object.keys(pendingTagChanges).map(Number)) {
|
||||
const tagIds = pendingTagChanges[photoId] || []
|
||||
if (tagIds.length === 0) continue
|
||||
|
||||
const tagNames = tagIds.map(id => {
|
||||
const tag = tags.find(t => t.id === id)
|
||||
return tag ? tag.tag_name : ''
|
||||
}).filter(Boolean)
|
||||
|
||||
// Group by linkage type
|
||||
const linkageTypes = pendingLinkageTypes[photoId] || {}
|
||||
const singleTagIds = tagIds.filter(id => (linkageTypes[id] || 0) === 0)
|
||||
const bulkTagIds = tagIds.filter(id => linkageTypes[id] === 1)
|
||||
|
||||
if (singleTagIds.length > 0) {
|
||||
const singleTagNames = singleTagIds.map(id => {
|
||||
const tag = tags.find(t => t.id === id)
|
||||
return tag ? tag.tag_name : ''
|
||||
}).filter(Boolean)
|
||||
await tagsApi.addToPhotos({
|
||||
photo_ids: [photoId],
|
||||
tag_names: singleTagNames,
|
||||
linkage_type: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if (bulkTagIds.length > 0) {
|
||||
const bulkTagNames = bulkTagIds.map(id => {
|
||||
const tag = tags.find(t => t.id === id)
|
||||
return tag ? tag.tag_name : ''
|
||||
}).filter(Boolean)
|
||||
await tagsApi.addToPhotos({
|
||||
photo_ids: [photoId],
|
||||
tag_names: bulkTagNames,
|
||||
linkage_type: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Process removals
|
||||
for (const photoId of Object.keys(pendingTagRemovals).map(Number)) {
|
||||
const tagIds = pendingTagRemovals[photoId] || []
|
||||
if (tagIds.length === 0) continue
|
||||
|
||||
const tagNames = tagIds.map(id => {
|
||||
const tag = tags.find(t => t.id === id)
|
||||
return tag ? tag.tag_name : ''
|
||||
}).filter(Boolean)
|
||||
|
||||
await tagsApi.removeFromPhotos({
|
||||
photo_ids: [photoId],
|
||||
tag_names: tagNames,
|
||||
})
|
||||
}
|
||||
|
||||
// Clear pending changes
|
||||
setPendingTagChanges({})
|
||||
setPendingTagRemovals({})
|
||||
setPendingLinkageTypes({})
|
||||
|
||||
// Reload data
|
||||
await loadData()
|
||||
|
||||
const additionsCount = Object.keys(pendingTagChanges).length
|
||||
const removalsCount = Object.keys(pendingTagRemovals).length
|
||||
let message = `Saved ${additionsCount} tag addition(s)`
|
||||
if (removalsCount > 0) {
|
||||
message += ` and ${removalsCount} tag removal(s)`
|
||||
}
|
||||
message += '.'
|
||||
alert(message)
|
||||
} catch (error) {
|
||||
console.error('Failed to save changes:', error)
|
||||
alert('Failed to save tag changes')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Get pending changes count
|
||||
const pendingChangesCount = useMemo(() => {
|
||||
const additions = Object.values(pendingTagChanges).reduce((sum, ids) => sum + ids.length, 0)
|
||||
const removals = Object.values(pendingTagRemovals).reduce((sum, ids) => sum + ids.length, 0)
|
||||
return additions + removals
|
||||
}, [pendingTagChanges, pendingTagRemovals])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-gray-600">Loading photos and tags...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Tags</h1>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-gray-600">Tag management coming in Phase 3.</p>
|
||||
<div className="flex flex-col h-full">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowManageTags(true)}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Manage Tags
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto bg-white rounded-lg shadow">
|
||||
{viewMode === 'list' && (
|
||||
<div className="p-4">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<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>
|
||||
<th className="text-left p-2 font-semibold">Processed</th>
|
||||
<th className="text-left p-2 font-semibold">Date Taken</th>
|
||||
<th className="text-left p-2 font-semibold">Faces</th>
|
||||
<th className="text-left p-2 font-semibold">Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{folderGroups.map(folder => (
|
||||
<React.Fragment key={folder.folderPath}>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<td colSpan={7} className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleFolder(folder.folderPath)}
|
||||
className="px-2 py-1 text-sm"
|
||||
>
|
||||
{folderStates[folder.folderPath] !== false ? '▼' : '▶'}
|
||||
</button>
|
||||
<span className="font-semibold">
|
||||
📁 {folder.folderName} ({folder.photoCount} photos)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowBulkTagDialog(folder.folderPath)}
|
||||
className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{folderStates[folder.folderPath] !== false && 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">
|
||||
<a
|
||||
href={`/api/v1/photos/${photo.id}/image`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{photo.filename}
|
||||
</a>
|
||||
</td>
|
||||
<td className="p-2 text-sm text-gray-600">{photo.path}</td>
|
||||
<td className="p-2">{photo.processed ? 'Yes' : 'No'}</td>
|
||||
<td className="p-2">{photo.date_taken || 'Unknown'}</td>
|
||||
<td className="p-2">{photo.face_count}</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{getPhotoTags(photo.id)}</span>
|
||||
<button
|
||||
onClick={() => setShowTagDialog(photo.id)}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'icons' && (
|
||||
<div className="p-4">
|
||||
<p className="text-gray-600">Icons view coming soon...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'compact' && (
|
||||
<div className="p-4">
|
||||
<p className="text-gray-600">Compact view coming soon...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
onClick={saveChanges}
|
||||
disabled={saving || pendingChangesCount === 0}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving
|
||||
? 'Saving...'
|
||||
: pendingChangesCount > 0
|
||||
? `Save Tagging (${pendingChangesCount} pending)`
|
||||
: 'Save Tagging'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Manage Tags Dialog */}
|
||||
{showManageTags && (
|
||||
<ManageTagsDialog
|
||||
tags={tags}
|
||||
onClose={() => setShowManageTags(false)}
|
||||
onTagsChange={setTags}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tag Dialog for Individual Photo */}
|
||||
{showTagDialog !== null && (
|
||||
<PhotoTagDialog
|
||||
photoId={showTagDialog}
|
||||
photo={photos.find(p => p.id === showTagDialog)}
|
||||
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)}
|
||||
getPhotoTags={async (photoId) => {
|
||||
return await tagsApi.getPhotoTags(photoId)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk Tag Dialog for Folder */}
|
||||
{showBulkTagDialog !== null && (
|
||||
<BulkTagDialog
|
||||
folderPath={showBulkTagDialog}
|
||||
folder={folderGroups.find(f => f.folderPath === showBulkTagDialog)}
|
||||
tags={tags}
|
||||
onClose={() => setShowBulkTagDialog(null)}
|
||||
onAddTag={(tagName) => {
|
||||
const folder = folderGroups.find(f => f.folderPath === showBulkTagDialog)
|
||||
if (folder) {
|
||||
folder.photos.forEach(photo => {
|
||||
addTagToPhoto(photo.id, tagName, 1) // linkage_type = 1 (bulk)
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Manage Tags Dialog Component
|
||||
function ManageTagsDialog({
|
||||
tags,
|
||||
onClose,
|
||||
onTagsChange,
|
||||
}: {
|
||||
tags: TagResponse[]
|
||||
onClose: () => void
|
||||
onTagsChange: (tags: TagResponse[]) => void
|
||||
}) {
|
||||
const [newTagName, setNewTagName] = useState('')
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set())
|
||||
const [editingTagId, setEditingTagId] = useState<number | null>(null)
|
||||
const [editTagName, setEditTagName] = useState('')
|
||||
|
||||
const handleCreateTag = async () => {
|
||||
if (!newTagName.trim()) return
|
||||
|
||||
try {
|
||||
const newTag = await tagsApi.create(newTagName.trim())
|
||||
onTagsChange([...tags, newTag].sort((a, b) => a.tag_name.localeCompare(b.tag_name)))
|
||||
setNewTagName('')
|
||||
} catch (error) {
|
||||
console.error('Failed to create tag:', error)
|
||||
alert('Failed to create tag')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditTag = async (tagId: number) => {
|
||||
if (!editTagName.trim()) return
|
||||
|
||||
try {
|
||||
const updatedTag = await tagsApi.update(tagId, editTagName.trim())
|
||||
onTagsChange(tags.map(t => (t.id === tagId ? updatedTag : t)).sort((a, b) => a.tag_name.localeCompare(b.tag_name)))
|
||||
setEditingTagId(null)
|
||||
setEditTagName('')
|
||||
} catch (error) {
|
||||
console.error('Failed to update tag:', error)
|
||||
alert('Failed to update tag')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTags = async () => {
|
||||
if (selectedTagIds.size === 0) return
|
||||
|
||||
if (!confirm(`Delete ${selectedTagIds.size} selected tag(s)? This will unlink them from photos.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
<div className="border-b mb-2 pb-2 flex">
|
||||
<span className="w-12 font-semibold">Delete</span>
|
||||
<span className="flex-1 font-semibold">Tag name</span>
|
||||
<span className="w-20 font-semibold">Edit</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{tags.map(tag => (
|
||||
<div key={tag.id} className="flex items-center gap-2 py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTagIds.has(tag.id)}
|
||||
onChange={(e) => {
|
||||
const newSet = new Set(selectedTagIds)
|
||||
if (e.target.checked) {
|
||||
newSet.add(tag.id)
|
||||
} else {
|
||||
newSet.delete(tag.id)
|
||||
}
|
||||
setSelectedTagIds(newSet)
|
||||
}}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
{editingTagId === tag.id ? (
|
||||
<div className="flex-1 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editTagName}
|
||||
onChange={(e) => setEditTagName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleEditTag(tag.id)}
|
||||
className="flex-1 px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleEditTag(tag.id)}
|
||||
className="px-2 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingTagId(null)
|
||||
setEditTagName('')
|
||||
}}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1">{tag.tag_name}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingTagId(tag.id)
|
||||
setEditTagName(tag.tag_name)
|
||||
}}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t flex justify-between">
|
||||
<button
|
||||
onClick={handleDeleteTags}
|
||||
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"
|
||||
>
|
||||
Delete 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>
|
||||
)
|
||||
}
|
||||
|
||||
// Photo Tag Dialog Component
|
||||
function PhotoTagDialog({
|
||||
photoId,
|
||||
photo,
|
||||
tags,
|
||||
pendingTagChanges,
|
||||
pendingTagRemovals,
|
||||
onClose,
|
||||
onAddTag,
|
||||
onRemoveTag,
|
||||
getPhotoTags,
|
||||
}: {
|
||||
photoId: number
|
||||
photo: PhotoWithTagsItem | undefined
|
||||
tags: TagResponse[]
|
||||
pendingTagChanges: number[]
|
||||
pendingTagRemovals: number[]
|
||||
onClose: () => void
|
||||
onAddTag: (tagName: string, linkageType: number) => void
|
||||
onRemoveTag: (tagId: number, linkageType: number) => void
|
||||
getPhotoTags: (photoId: number) => Promise<any>
|
||||
}) {
|
||||
const [selectedTagName, setSelectedTagName] = useState('')
|
||||
const [photoTags, setPhotoTags] = useState<any[]>([])
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
loadPhotoTags()
|
||||
}, [photoId])
|
||||
|
||||
const loadPhotoTags = async () => {
|
||||
try {
|
||||
const response = await getPhotoTags(photoId)
|
||||
setPhotoTags(response.tags || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load photo tags:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (!selectedTagName.trim()) return
|
||||
onAddTag(selectedTagName.trim(), 0) // linkage_type = 0 (single)
|
||||
setSelectedTagName('')
|
||||
loadPhotoTags()
|
||||
}
|
||||
|
||||
const handleRemoveTags = () => {
|
||||
if (selectedTagIds.size === 0) return
|
||||
selectedTagIds.forEach(tagId => {
|
||||
const tag = photoTags.find(t => t.tag_id === tagId)
|
||||
const linkageType = tag?.linkage_type || 0
|
||||
onRemoveTag(tagId, linkageType)
|
||||
})
|
||||
setSelectedTagIds(new Set())
|
||||
loadPhotoTags()
|
||||
}
|
||||
|
||||
// Combine saved and pending tags
|
||||
const allTags = useMemo(() => {
|
||||
const savedTags = photoTags.filter(t => !pendingTagRemovals.includes(t.tag_id))
|
||||
const pendingTagObjs = pendingTagChanges
|
||||
.filter(id => !pendingTagRemovals.includes(id))
|
||||
.map(id => {
|
||||
const tag = tags.find(t => t.id === id)
|
||||
return tag ? { tag_id: id, tag_name: tag.tag_name, linkage_type: 0 } : null
|
||||
})
|
||||
.filter(Boolean) as any[]
|
||||
return [...savedTags, ...pendingTagObjs]
|
||||
}, [photoTags, pendingTagChanges, pendingTagRemovals, tags])
|
||||
|
||||
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-lg max-h-[80vh] flex flex-col">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-xl font-bold">Manage Photo Tags</h2>
|
||||
{photo && <p className="text-sm text-gray-600">{photo.filename}</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}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{allTags.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No tags linked to this photo</p>
|
||||
) : (
|
||||
allTags.map(tag => {
|
||||
const isPending = pendingTagChanges.includes(tag.tag_id)
|
||||
const linkageType = tag.linkage_type || 0
|
||||
const canSelect = isPending || linkageType === 0
|
||||
|
||||
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 ${linkageType === 0 ? 'single' : 'bulk'})`}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// Bulk Tag Dialog Component
|
||||
function BulkTagDialog({
|
||||
folderPath,
|
||||
folder,
|
||||
tags,
|
||||
onClose,
|
||||
onAddTag,
|
||||
}: {
|
||||
folderPath: string
|
||||
folder: FolderGroup | undefined
|
||||
tags: TagResponse[]
|
||||
onClose: () => void
|
||||
onAddTag: (tagName: string) => void
|
||||
}) {
|
||||
const [selectedTagName, setSelectedTagName] = useState('')
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (!selectedTagName.trim()) return
|
||||
onAddTag(selectedTagName.trim())
|
||||
setSelectedTagName('')
|
||||
}
|
||||
|
||||
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-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>
|
||||
{folder && (
|
||||
<p className="text-sm text-gray-600">
|
||||
Folder: {folder.folderName} ({folder.photoCount} photos)
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
<div className="p-4 border-t flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user