diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 0891bb6..182c7f1 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -39,9 +39,9 @@ export default function Layout() { -
- {/* Left sidebar */} -
+
+ {/* 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' && ( -
-