+ {/* Left sidebar - fixed position */}
+
- {/* Main content */}
-
+ {/* Main content - with left margin to account for fixed sidebar */}
+
diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx
index f73b4af..e1a13d1 100644
--- a/frontend/src/pages/Search.tsx
+++ b/frontend/src/pages/Search.tsx
@@ -1,6 +1,6 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useState, useMemo } from 'react'
import { photosApi, PhotoSearchResult } from '../api/photos'
-import tagsApi, { TagResponse } from '../api/tags'
+import tagsApi, { TagResponse, PhotoTagItem } from '../api/tags'
import { apiClient } from '../api/client'
type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags'
@@ -19,11 +19,12 @@ type SortDir = 'asc' | 'desc'
export default function Search() {
const [searchType, setSearchType] = useState
('name')
const [filtersExpanded, setFiltersExpanded] = useState(false)
+ const [tagsExpanded, setTagsExpanded] = useState(true) // Default to expanded
const [folderPath, setFolderPath] = useState('')
// Search inputs
const [personName, setPersonName] = useState('')
- const [tagNames, setTagNames] = useState('')
+ const [selectedTags, setSelectedTags] = useState([])
const [matchAll, setMatchAll] = useState(false)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
@@ -44,11 +45,15 @@ export default function Search() {
// Tags
const [availableTags, setAvailableTags] = useState([])
- const [showTagHelp, setShowTagHelp] = useState(false)
// Tag modal
const [showTagModal, setShowTagModal] = useState(false)
- const [tagInput, setTagInput] = useState('')
+ const [selectedTagName, setSelectedTagName] = useState('')
+ const [newTagName, setNewTagName] = useState('')
+ const [photoTags, setPhotoTags] = useState>({})
+ const [selectedTagIds, setSelectedTagIds] = useState>(new Set())
+ const [loadingTags, setLoadingTags] = useState(false)
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const loadTags = async () => {
try {
@@ -89,12 +94,12 @@ export default function Search() {
params.date_from = dateFrom || undefined
params.date_to = dateTo || undefined
} else if (searchType === 'tags') {
- if (!tagNames.trim()) {
- alert('Please enter tags to search for.')
+ if (selectedTags.length === 0) {
+ alert('Please select at least one tag to search for.')
setLoading(false)
return
}
- params.tag_names = tagNames.trim()
+ params.tag_names = selectedTags.join(', ')
params.match_all = matchAll
}
@@ -103,13 +108,7 @@ export default function Search() {
setTotal(res.total)
// Auto-run search for no_faces and no_tags
- if (searchType === 'no_faces' || searchType === 'no_tags') {
- if (res.items.length === 0) {
- const actualFolderPath = folderPathOverride || folderPath
- const folderMsg = actualFolderPath ? ` in folder '${actualFolderPath}'` : ''
- alert(`No photos found${folderMsg}.`)
- }
- }
+ // No alert shown when no photos found
} catch (error) {
console.error('Error searching photos:', error)
alert('Error searching photos. Please try again.')
@@ -128,6 +127,10 @@ export default function Search() {
if (searchType === 'no_faces' || searchType === 'no_tags') {
handleSearch()
}
+ // Clear selected tags when switching away from tag search
+ if (searchType !== 'tags') {
+ setSelectedTags([])
+ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchType])
@@ -195,39 +198,157 @@ export default function Search() {
setSelectedPhotos(new Set())
}
- const handleTagSelected = async () => {
+ const loadPhotoTags = async () => {
+ if (selectedPhotos.size === 0) return
+
+ setLoadingTags(true)
+ try {
+ const tagsMap: Record = {}
+ const photoIds = Array.from(selectedPhotos)
+
+ // Load tags for each selected photo
+ for (const photoId of photoIds) {
+ try {
+ const response = await tagsApi.getPhotoTags(photoId)
+ tagsMap[photoId] = response.tags || []
+ } catch (error) {
+ console.error(`Error loading tags for photo ${photoId}:`, error)
+ tagsMap[photoId] = []
+ }
+ }
+
+ setPhotoTags(tagsMap)
+ } catch (error) {
+ console.error('Error loading photo tags:', error)
+ } finally {
+ setLoadingTags(false)
+ }
+ }
+
+ // Load tags for selected photos when dialog opens
+ useEffect(() => {
+ if (showTagModal && selectedPhotos.size > 0) {
+ loadPhotoTags()
+ } else {
+ setPhotoTags({})
+ setSelectedTagIds(new Set())
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [showTagModal, selectedPhotos.size])
+
+ // Get all unique tags from selected photos
+ const allPhotoTags = useMemo(() => {
+ const tagMap = new Map()
+
+ Object.values(photoTags).forEach(tags => {
+ tags.forEach(tag => {
+ // Use tag_id as key to avoid duplicates
+ if (!tagMap.has(tag.tag_id)) {
+ tagMap.set(tag.tag_id, tag)
+ }
+ })
+ })
+
+ return Array.from(tagMap.values())
+ }, [photoTags])
+
+ const handleAddTag = async () => {
if (selectedPhotos.size === 0) {
alert('Please select photos to tag.')
return
}
- if (!tagInput.trim()) {
- alert('Please enter tags to add.')
- return
- }
+ // Determine which tag to use: new tag input or selected from dropdown
+ const tagToAdd = newTagName.trim() || selectedTagName.trim()
- const tags = tagInput.split(',').map(t => t.trim()).filter(t => t)
- if (tags.length === 0) {
- alert('Please enter valid tags.')
+ if (!tagToAdd) {
+ alert('Please select a tag or enter a new tag name.')
return
}
try {
+ // If it's a new tag (from input field), create it first
+ if (newTagName.trim()) {
+ await tagsApi.create(newTagName.trim())
+ // Refresh available tags list
+ await loadTags()
+ // Clear the new tag input
+ setNewTagName('')
+ }
+
+ // Add tag to photos (always as single/linkage_type = 0)
await tagsApi.addToPhotos({
photo_ids: Array.from(selectedPhotos),
- tag_names: tags,
+ tag_names: [tagToAdd],
+ linkage_type: 0, // Always single
})
- alert(`Added tags to ${selectedPhotos.size} photos.`)
- setShowTagModal(false)
- setTagInput('')
- // Refresh search results
- performSearch(page)
+
+ setSelectedTagName('')
+ // Reload tags for selected photos
+ await loadPhotoTags()
} catch (error) {
console.error('Error tagging photos:', error)
alert('Error tagging photos. Please try again.')
}
}
+ const handleDeleteTags = () => {
+ if (selectedTagIds.size === 0) {
+ alert('Please select tags to delete.')
+ return
+ }
+
+ if (selectedPhotos.size === 0) {
+ alert('No photos selected.')
+ return
+ }
+
+ // Show confirmation dialog
+ setShowDeleteConfirm(true)
+ }
+
+ const confirmDeleteTags = async () => {
+ setShowDeleteConfirm(false)
+
+ // Get tag names from selected tag IDs
+ const tagNamesToDelete = Array.from(selectedTagIds)
+ .map(tagId => {
+ const tag = allPhotoTags.find(t => t.tag_id === tagId)
+ return tag ? tag.tag_name : null
+ })
+ .filter(Boolean) as string[]
+
+ if (tagNamesToDelete.length === 0) {
+ alert('No valid tags selected for deletion.')
+ return
+ }
+
+ try {
+ await tagsApi.removeFromPhotos({
+ photo_ids: Array.from(selectedPhotos),
+ tag_names: tagNamesToDelete,
+ })
+
+ setSelectedTagIds(new Set())
+ // Reload tags for selected photos
+ await loadPhotoTags()
+ } catch (error) {
+ console.error('Error removing tags:', error)
+ alert('Error removing tags. Please try again.')
+ }
+ }
+
+ // Get selected tag names for confirmation message
+ const selectedTagNames = useMemo(() => {
+ return Array.from(selectedTagIds)
+ .map(tagId => {
+ const tag = allPhotoTags.find(t => t.tag_id === tagId)
+ return tag ? tag.tag_name : ''
+ })
+ .filter(Boolean)
+ .join(', ')
+ }, [selectedTagIds, allPhotoTags])
+
const openPhoto = (photoId: number) => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
window.open(photoUrl, '_blank')
@@ -251,34 +372,37 @@ export default function Search() {
🔎 Search Photos
{/* Search Type Selector */}
-
-
-
+
+
+
+
+
{/* Filters */}
-
-
+
+
Filters
{filtersExpanded && (
-
+
{/* Search Inputs */}
-
- {searchType === 'name' && (
-
-