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.
This commit is contained in:
parent
8d11ac415e
commit
ea3d06a3d5
@ -39,9 +39,9 @@ export default function Layout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
{/* Left sidebar */}
|
||||
<div className="w-64 bg-white border-r border-gray-200 min-h-[calc(100vh-4rem)]">
|
||||
<div className="flex relative">
|
||||
{/* Left sidebar - fixed position */}
|
||||
<div className="fixed left-0 top-16 w-64 bg-white border-r border-gray-200 h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<nav className="p-4 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path
|
||||
@ -63,8 +63,8 @@ export default function Layout() {
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 p-6">
|
||||
{/* Main content - with left margin to account for fixed sidebar */}
|
||||
<div className="flex-1 ml-64 p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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<SearchType>('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<string[]>([])
|
||||
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<TagResponse[]>([])
|
||||
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<Record<number, PhotoTagItem[]>>({})
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(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<number, PhotoTagItem[]> = {}
|
||||
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<number, PhotoTagItem>()
|
||||
|
||||
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() {
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">🔎 Search Photos</h1>
|
||||
|
||||
{/* Search Type Selector */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Search type:
|
||||
</label>
|
||||
<select
|
||||
value={searchType}
|
||||
onChange={(e) => setSearchType(e.target.value as SearchType)}
|
||||
className="block w-full border rounded px-3 py-2"
|
||||
>
|
||||
{SEARCH_TYPES.map(type => (
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="bg-white rounded-lg shadow py-2 px-4 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Search type:
|
||||
</label>
|
||||
<select
|
||||
value={searchType}
|
||||
onChange={(e) => setSearchType(e.target.value as SearchType)}
|
||||
className="flex-1 border rounded px-3 py-2"
|
||||
>
|
||||
{SEARCH_TYPES.map(type => (
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="bg-white rounded-lg shadow py-2 px-4 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-medium text-gray-700">Filters</h2>
|
||||
<button
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
className="text-sm text-gray-600 hover:text-gray-800"
|
||||
className="text-lg text-gray-600 hover:text-gray-800"
|
||||
title={filtersExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{filtersExpanded ? '−' : '+'}
|
||||
{filtersExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
{filtersExpanded && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Folder location:
|
||||
@ -310,17 +434,18 @@ export default function Search() {
|
||||
</div>
|
||||
|
||||
{/* Search Inputs */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
{searchType === 'name' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{(searchType !== 'no_faces' && searchType !== 'no_tags') && (
|
||||
<div className="bg-white rounded-lg shadow py-2 px-4 mb-2">
|
||||
{searchType === 'name' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Person name:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={personName}
|
||||
onChange={(e) => setPersonName(e.target.value)}
|
||||
className="w-full border rounded px-3 py-2"
|
||||
className="flex-1 border rounded px-3 py-2"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Enter person name"
|
||||
/>
|
||||
@ -328,9 +453,9 @@ export default function Search() {
|
||||
)}
|
||||
|
||||
{searchType === 'date' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
From date:
|
||||
</label>
|
||||
<input
|
||||
@ -342,7 +467,7 @@ export default function Search() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
To date:
|
||||
</label>
|
||||
<input
|
||||
@ -357,60 +482,62 @@ export default function Search() {
|
||||
)}
|
||||
|
||||
{searchType === 'tags' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tags:
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagNames}
|
||||
onChange={(e) => setTagNames(e.target.value)}
|
||||
className="flex-1 border rounded px-3 py-2"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Comma-separated tags"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowTagHelp(!showTagHelp)}
|
||||
className="text-lg"
|
||||
title="Show available tags"
|
||||
>
|
||||
❓
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">(comma-separated)</span>
|
||||
</div>
|
||||
{showTagHelp && (
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded border max-h-40 overflow-y-auto">
|
||||
<div className="text-sm font-medium mb-2">Available tags:</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
{availableTags.map(tag => (
|
||||
<div key={tag.id}>{tag.tag_name}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Match mode:
|
||||
</label>
|
||||
<select
|
||||
value={matchAll ? 'ALL' : 'ANY'}
|
||||
onChange={(e) => setMatchAll(e.target.value === 'ALL')}
|
||||
className="border rounded px-3 py-2"
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-medium text-gray-700">Tags</h2>
|
||||
<button
|
||||
onClick={() => setTagsExpanded(!tagsExpanded)}
|
||||
className="text-lg text-gray-600 hover:text-gray-800"
|
||||
title={tagsExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<option value="ANY">ANY (photos with any tag)</option>
|
||||
<option value="ALL">ALL (photos with all tags)</option>
|
||||
</select>
|
||||
{tagsExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
{tagsExpanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tags:
|
||||
</label>
|
||||
<select
|
||||
multiple
|
||||
value={selectedTags}
|
||||
onChange={(e) => {
|
||||
const selected = Array.from(e.target.selectedOptions, option => option.value)
|
||||
setSelectedTags(selected)
|
||||
}}
|
||||
className="w-full border rounded px-3 py-2 min-h-[120px]"
|
||||
size={Math.min(availableTags.length, 8)}
|
||||
>
|
||||
{availableTags.map(tag => (
|
||||
<option key={tag.id} value={tag.tag_name}>
|
||||
{tag.tag_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Hold Ctrl/Cmd to select multiple tags
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Match mode:
|
||||
</label>
|
||||
<select
|
||||
value={matchAll ? 'ALL' : 'ANY'}
|
||||
onChange={(e) => setMatchAll(e.target.value === 'ALL')}
|
||||
className="border rounded px-3 py-2"
|
||||
>
|
||||
<option value="ANY">ANY (photos with any tag)</option>
|
||||
<option value="ALL">ALL (photos with all tags)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(searchType === 'no_faces' || searchType === 'no_tags') && (
|
||||
<p className="text-sm text-gray-600">No input needed for this search type.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Button */}
|
||||
<div className="mb-4">
|
||||
@ -426,26 +553,28 @@ export default function Search() {
|
||||
{/* Results */}
|
||||
{results.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">Results:</span>
|
||||
<span className="text-sm text-gray-500 ml-2">({total} items)</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowTagModal(true)}
|
||||
disabled={selectedPhotos.size === 0}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
Tag selected photos
|
||||
</button>
|
||||
<button
|
||||
onClick={clearAllSelected}
|
||||
disabled={selectedPhotos.size === 0}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
Clear all selected
|
||||
</button>
|
||||
<div className="sticky top-0 z-10 bg-white pb-4 mb-4 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">Results:</span>
|
||||
<span className="text-sm text-gray-500 ml-2">({total} items)</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowTagModal(true)}
|
||||
disabled={selectedPhotos.size === 0}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
Tag selected photos
|
||||
</button>
|
||||
<button
|
||||
onClick={clearAllSelected}
|
||||
disabled={selectedPhotos.size === 0}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
Clear all selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -586,51 +715,166 @@ export default function Search() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag Modal */}
|
||||
{showTagModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<h2 className="text-xl font-bold mb-4">Tag Selected Photos</h2>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tags (comma-separated):
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
className="w-full border rounded px-3 py-2"
|
||||
placeholder="Enter tags"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleTagSelected()
|
||||
if (e.key === 'Escape') setShowTagModal(false)
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{selectedPhotos.size} photo(s) selected
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60]">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 className="text-lg font-bold mb-4">Confirm Delete</h3>
|
||||
<p className="text-gray-700 mb-6">
|
||||
Remove {selectedTagIds.size} tag(s) ({selectedTagNames}) from {selectedPhotos.size} photo(s)?
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTagModal(false)
|
||||
setTagInput('')
|
||||
}}
|
||||
className="px-4 py-2 border rounded hover:bg-gray-50"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleTagSelected}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
|
||||
onClick={confirmDeleteTags}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
Add Tags
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag Modal */}
|
||||
{showTagModal && (
|
||||
<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">Tag Selected Photos</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{selectedPhotos.size} photo(s) selected
|
||||
</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">
|
||||
Select Existing Tag:
|
||||
</label>
|
||||
<select
|
||||
value={selectedTagName}
|
||||
onChange={(e) => {
|
||||
setSelectedTagName(e.target.value)
|
||||
// Clear new tag input when selecting from dropdown
|
||||
if (e.target.value) {
|
||||
setNewTagName('')
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded"
|
||||
>
|
||||
<option value="">Select tag...</option>
|
||||
{availableTags.map(tag => (
|
||||
<option key={tag.id} value={tag.tag_name}>
|
||||
{tag.tag_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Or Enter New Tag Name:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTagName}
|
||||
onChange={(e) => {
|
||||
setNewTagName(e.target.value)
|
||||
// Clear selected tag when typing new tag
|
||||
if (e.target.value) {
|
||||
setSelectedTagName('')
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded"
|
||||
placeholder="Type new tag name..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
New tags will be created in the database automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 border-t pt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Existing Tags on Selected Photos:
|
||||
</label>
|
||||
{loadingTags ? (
|
||||
<p className="text-sm text-gray-500">Loading tags...</p>
|
||||
) : allPhotoTags.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No tags on selected photos</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto border rounded p-2">
|
||||
{allPhotoTags.map(tag => (
|
||||
<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)
|
||||
}}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="flex-1 text-sm">
|
||||
{tag.tag_name}
|
||||
{tag.linkage_type === 1 && ' (bulk)'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t flex justify-between">
|
||||
<div className="flex gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTagModal(false)
|
||||
setSelectedTagName('')
|
||||
setNewTagName('')
|
||||
setSelectedTagIds(new Set())
|
||||
setSelectedPhotos(new Set()) // Unselect all photos
|
||||
// Reload search results when closing dialog
|
||||
performSearch(page)
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddTag}
|
||||
disabled={!selectedTagName.trim() && !newTagName.trim()}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
Add Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -465,20 +465,6 @@ export default function Tags() {
|
||||
)}
|
||||
</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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user