From ea3d06a3d547626c5b31590e4425a6ab091a92d9 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Mon, 10 Nov 2025 12:43:44 -0500 Subject: [PATCH] feat: Refactor Layout and Search components for improved UI and tag management This commit enhances the Layout component by fixing the sidebar position for better usability and adjusting the main content layout accordingly. The Search component has been updated to improve tag selection and management, including the addition of new state variables for handling selected tags and loading photo tags. A confirmation dialog for tag deletion has been introduced, along with improved error handling and user feedback. The overall user interface has been refined for a more cohesive experience. Documentation has been updated to reflect these changes. --- frontend/src/components/Layout.tsx | 10 +- frontend/src/pages/Search.tsx | 562 +++++++++++++++++++++-------- frontend/src/pages/Tags.tsx | 14 - 3 files changed, 408 insertions(+), 178 deletions(-) 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' && ( -
-