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:
tanyar09 2025-11-04 12:16:22 -05:00
parent 91ee2ce8ab
commit 0a960a99ce
5 changed files with 1244 additions and 13 deletions

View File

@ -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

View File

@ -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>
)
}

View File

@ -14,13 +14,24 @@ from src.web.schemas.tags import (
TagCreateRequest,
TagResponse,
TagsResponse,
TagUpdateRequest,
TagDeleteRequest,
PhotoTagsListResponse,
PhotoTagItem,
PhotosWithTagsResponse,
PhotoWithTagsItem,
)
from src.web.services.tag_service import (
add_tags_to_photos,
get_or_create_tag,
list_tags,
remove_tags_from_photos,
get_photo_tags,
update_tag,
delete_tags,
get_photos_with_tags,
)
from src.web.db.models import Photo
router = APIRouter(prefix="/tags", tags=["tags"])
@ -59,7 +70,7 @@ def add_tags_to_photos_endpoint(
)
photos_updated, tags_added = add_tags_to_photos(
db, request.photo_ids, request.tag_names
db, request.photo_ids, request.tag_names, request.linkage_type
)
return PhotoTagsResponse(
@ -95,3 +106,84 @@ def remove_tags_from_photos_endpoint(
tags_removed=tags_removed,
)
@router.get("/photos/{photo_id}", response_model=PhotoTagsListResponse)
def get_photo_tags_endpoint(
photo_id: int, db: Session = Depends(get_db)
) -> PhotoTagsListResponse:
"""Get all tags for a specific photo."""
# Validate photo exists
photo = db.query(Photo).filter(Photo.id == photo_id).first()
if not photo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"Photo {photo_id} not found"
)
tags_data = get_photo_tags(db, photo_id)
items = [
PhotoTagItem(tag_id=tag_id, tag_name=tag_name, linkage_type=linkage_type)
for tag_id, tag_name, linkage_type in tags_data
]
return PhotoTagsListResponse(photo_id=photo_id, tags=items, total=len(items))
@router.put("/{tag_id}", response_model=TagResponse)
def update_tag_endpoint(
tag_id: int, request: TagUpdateRequest, db: Session = Depends(get_db)
) -> TagResponse:
"""Update a tag name."""
try:
tag = update_tag(db, tag_id, request.tag_name)
return TagResponse.model_validate(tag)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
)
@router.post("/delete", response_model=dict)
def delete_tags_endpoint(
request: TagDeleteRequest, db: Session = Depends(get_db)
) -> dict:
"""Delete tags and all their linkages."""
if not request.tag_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="tag_ids list cannot be empty",
)
deleted_count = delete_tags(db, request.tag_ids)
return {
"message": f"Deleted {deleted_count} tag(s)",
"deleted_count": deleted_count,
}
@router.get("/photos", response_model=PhotosWithTagsResponse)
def get_photos_with_tags_endpoint(db: Session = Depends(get_db)) -> PhotosWithTagsResponse:
"""Get all photos with tags and face counts, matching desktop tag manager query exactly.
Returns all photos with their tags (comma-separated) and face counts,
ordered by date_taken DESC, filename.
"""
photos_data = get_photos_with_tags(db)
items = [
PhotoWithTagsItem(
id=p['id'],
filename=p['filename'],
path=p['path'],
processed=p['processed'],
date_taken=p['date_taken'],
date_added=p['date_added'],
face_count=p['face_count'],
tags=p['tags'],
)
for p in photos_data
]
return PhotosWithTagsResponse(items=items, total=len(items))

View File

@ -37,6 +37,7 @@ class PhotoTagsRequest(BaseModel):
photo_ids: List[int] = Field(..., description="Photo IDs")
tag_names: List[str] = Field(..., description="Tag names to add/remove")
linkage_type: int = Field(0, ge=0, le=1, description="Linkage type: 0=single, 1=bulk")
class PhotoTagsResponse(BaseModel):
@ -47,3 +48,51 @@ class PhotoTagsResponse(BaseModel):
tags_added: int
tags_removed: int
class TagUpdateRequest(BaseModel):
"""Request to update a tag name."""
tag_name: str = Field(..., description="New tag name")
class TagDeleteRequest(BaseModel):
"""Request to delete tags."""
tag_ids: List[int] = Field(..., description="Tag IDs to delete")
class PhotoTagItem(BaseModel):
"""Tag item for a photo."""
tag_id: int
tag_name: str
linkage_type: int # 0=single, 1=bulk
class PhotoTagsListResponse(BaseModel):
"""Response for listing tags on a photo."""
photo_id: int
tags: List[PhotoTagItem]
total: int
class PhotoWithTagsItem(BaseModel):
"""Photo item with tags for tag manager."""
id: int
filename: str
path: str
processed: bool
date_taken: Optional[str] = None
date_added: Optional[str] = None
face_count: int
tags: str # Comma-separated tags string (matching desktop)
class PhotosWithTagsResponse(BaseModel):
"""Response for listing photos with tags."""
items: List[PhotoWithTagsItem]
total: int

View File

@ -3,10 +3,11 @@
from __future__ import annotations
from typing import List, Optional
from datetime import datetime
from sqlalchemy.orm import Session
from src.web.db.models import Photo, PhotoTagLinkage, Tag
from src.web.db.models import Photo, PhotoTagLinkage, Tag, Face
def list_tags(db: Session) -> List[Tag]:
@ -33,9 +34,14 @@ def get_or_create_tag(db: Session, tag_name: str) -> Tag:
def add_tags_to_photos(
db: Session, photo_ids: List[int], tag_names: List[str]
db: Session, photo_ids: List[int], tag_names: List[str], linkage_type: int = 0
) -> tuple[int, int]:
"""Add tags to photos.
"""Add tags to photos, matching desktop logic exactly.
Desktop logic:
- linkage_type: 0 = single (manually added to individual photo)
- linkage_type: 1 = bulk (applied to all photos in folder)
- Uses INSERT ... ON CONFLICT DO UPDATE to update linkage_type if exists
Returns:
Tuple of (photos_updated, tags_added)
@ -43,7 +49,7 @@ def add_tags_to_photos(
photos_updated = 0
tags_added = 0
# Deduplicate tag names (case-insensitive)
# Deduplicate tag names (case-insensitive) - matching desktop deduplicate_tags
seen_tags = set()
unique_tags = []
for tag_name in tag_names:
@ -55,13 +61,13 @@ def add_tags_to_photos(
if not unique_tags:
return 0, 0
# Get or create tags
# Get or create tags (matching desktop add_tag)
tag_objs = []
for tag_name in unique_tags:
tag = get_or_create_tag(db, tag_name)
tag_objs.append(tag)
# Add tags to photos
# Add tags to photos (matching desktop link_photo_tag with linkage_type)
for photo_id in photo_ids:
photo = db.query(Photo).filter(Photo.id == photo_id).first()
if not photo:
@ -77,8 +83,19 @@ def add_tags_to_photos(
)
.first()
)
if not existing:
linkage = PhotoTagLinkage(photo_id=photo_id, tag_id=tag.id)
if existing:
# Update linkage_type if different (matching desktop ON CONFLICT DO UPDATE)
if existing.linkage_type != linkage_type:
existing.linkage_type = linkage_type
existing.created_date = datetime.utcnow()
tags_added += 1
else:
# Create new linkage with linkage_type
linkage = PhotoTagLinkage(
photo_id=photo_id,
tag_id=tag.id,
linkage_type=linkage_type,
)
db.add(linkage)
tags_added += 1
@ -133,3 +150,135 @@ def remove_tags_from_photos(
db.commit()
return photos_updated, tags_removed
def get_photo_tags(db: Session, photo_id: int) -> List[tuple[int, str, int]]:
"""Get all tags for a photo, matching desktop logic exactly.
Returns:
List of (tag_id, tag_name, linkage_type) tuples
"""
linkages = (
db.query(PhotoTagLinkage, Tag)
.join(Tag, PhotoTagLinkage.tag_id == Tag.id)
.filter(PhotoTagLinkage.photo_id == photo_id)
.order_by(Tag.tag_name)
.all()
)
return [
(linkage.tag_id, tag.tag_name, linkage.linkage_type)
for linkage, tag in linkages
]
def update_tag(db: Session, tag_id: int, new_tag_name: str) -> Tag:
"""Update a tag name, matching desktop logic exactly.
Desktop logic:
- Updates tag_name in tags table
- Case-insensitive check for duplicates
"""
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise ValueError(f"Tag {tag_id} not found")
# Check if new name already exists (case-insensitive)
existing = (
db.query(Tag)
.filter(Tag.tag_name.ilike(new_tag_name.strip()))
.filter(Tag.id != tag_id)
.first()
)
if existing:
raise ValueError(f"Tag name '{new_tag_name}' already exists")
tag.tag_name = new_tag_name.strip()
db.commit()
db.refresh(tag)
return tag
def delete_tags(db: Session, tag_ids: List[int]) -> int:
"""Delete tags and all their linkages, matching desktop logic exactly.
Desktop logic:
- Deletes all phototaglinkage entries for these tags
- Deletes the tags themselves
Returns:
Number of tags deleted
"""
if not tag_ids:
return 0
# Delete all linkages for these tags (matching desktop)
db.query(PhotoTagLinkage).filter(PhotoTagLinkage.tag_id.in_(tag_ids)).delete(
synchronize_session=False
)
# Delete the tags themselves
deleted_count = db.query(Tag).filter(Tag.id.in_(tag_ids)).delete(
synchronize_session=False
)
db.commit()
return deleted_count
def get_photos_with_tags(db: Session) -> List[dict]:
"""Get all photos with tags and face counts, matching desktop query exactly.
Desktop query:
SELECT p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added,
(SELECT COUNT(*) FROM faces f WHERE f.photo_id = p.id) as face_count,
(SELECT GROUP_CONCAT(DISTINCT t.tag_name)
FROM phototaglinkage ptl
JOIN tags t ON t.id = ptl.tag_id
WHERE ptl.photo_id = p.id) as tags
FROM photos p
ORDER BY p.date_taken DESC, p.filename
Returns:
List of dicts with photo info, face_count, and tags (comma-separated string)
"""
from sqlalchemy import func
# Get all photos with face counts and tags
photos = (
db.query(Photo)
.order_by(Photo.date_taken.desc().nullslast(), Photo.filename)
.all()
)
result = []
for photo in photos:
# Get face count
face_count = (
db.query(func.count(Face.id))
.filter(Face.photo_id == photo.id)
.scalar() or 0
)
# Get tags as comma-separated string (matching desktop GROUP_CONCAT)
tags_query = (
db.query(Tag.tag_name)
.join(PhotoTagLinkage, Tag.id == PhotoTagLinkage.tag_id)
.filter(PhotoTagLinkage.photo_id == photo.id)
.order_by(Tag.tag_name)
.all()
)
tags = ", ".join([t[0] for t in tags_query]) if tags_query else ""
result.append({
'id': photo.id,
'filename': photo.filename,
'path': photo.path,
'processed': photo.processed,
'date_taken': photo.date_taken.isoformat() if photo.date_taken else None,
'date_added': photo.date_added.isoformat() if photo.date_added else None,
'face_count': face_count,
'tags': tags,
})
return result