feat: Implement photo search functionality with filtering and tagging support

This commit introduces a comprehensive photo search feature in the application, allowing users to search photos by various criteria including name, date, and tags. The API has been updated to support these search parameters, returning results that match the specified filters. Additionally, new endpoints for managing tags have been added, enabling users to add and remove tags from multiple photos. The frontend has been enhanced with a user-friendly interface for search inputs and results display, improving overall usability. Documentation and tests have been updated to reflect these new features and ensure reliability.
This commit is contained in:
tanyar09
2025-11-03 14:27:27 -05:00
parent 59dc01118e
commit c0f9d19368
13 changed files with 1533 additions and 42 deletions
-1
View File
@@ -1,5 +1,4 @@
import apiClient from './client'
import { JobResponse } from './jobs'
export interface ProcessFacesRequest {
batch_size?: number
+37 -1
View File
@@ -1,5 +1,4 @@
import apiClient from './client'
import { JobResponse } from './jobs'
export interface PhotoImportRequest {
folder_path: string
@@ -72,5 +71,42 @@ export const photosApi = {
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}`)
},
searchPhotos: async (params: {
search_type: 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags'
person_name?: string
tag_names?: string
match_all?: boolean
date_from?: string
date_to?: string
folder_path?: string
page?: number
page_size?: number
}): Promise<SearchPhotosResponse> => {
const { data } = await apiClient.get<SearchPhotosResponse>('/api/v1/photos', {
params,
})
return data
},
}
export interface PhotoSearchResult {
id: number
path: string
filename: string
date_taken?: string
date_added: string
processed: boolean
person_name?: string
tags: string[]
has_faces: boolean
face_count: number
}
export interface SearchPhotosResponse {
items: PhotoSearchResult[]
page: number
page_size: number
total: number
}
+57
View File
@@ -0,0 +1,57 @@
import apiClient from './client'
export interface TagResponse {
id: number
tag_name: string
created_date: string
}
export interface TagsResponse {
items: TagResponse[]
total: number
}
export interface PhotoTagsRequest {
photo_ids: number[]
tag_names: string[]
}
export interface PhotoTagsResponse {
message: string
photos_updated: number
tags_added: number
tags_removed: number
}
export const tagsApi = {
list: async (): Promise<TagsResponse> => {
const { data } = await apiClient.get<TagsResponse>('/api/v1/tags')
return data
},
create: async (tagName: string): Promise<TagResponse> => {
const { data } = await apiClient.post<TagResponse>('/api/v1/tags', {
tag_name: tagName,
})
return data
},
addToPhotos: async (request: PhotoTagsRequest): Promise<PhotoTagsResponse> => {
const { data } = await apiClient.post<PhotoTagsResponse>(
'/api/v1/tags/photos/add',
request
)
return data
},
removeFromPhotos: async (request: PhotoTagsRequest): Promise<PhotoTagsResponse> => {
const { data } = await apiClient.post<PhotoTagsResponse>(
'/api/v1/tags/photos/remove',
request
)
return data
},
}
export default tagsApi
+55 -14
View File
@@ -8,8 +8,7 @@ type SortDir = 'asc' | 'desc'
export default function Identify() {
const [faces, setFaces] = useState<FaceItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [, setTotal] = useState(0)
const [pageSize, setPageSize] = useState(50)
const [minQuality, setMinQuality] = useState(0.0)
const [sortBy, setSortBy] = useState<SortBy>('quality')
@@ -33,6 +32,7 @@ export default function Identify() {
const [maidenName, setMaidenName] = useState('')
const [dob, setDob] = useState('')
const [busy, setBusy] = useState(false)
const [imageLoading, setImageLoading] = useState(false)
// Store form data per face ID (matching desktop behavior)
const [faceFormData, setFaceFormData] = useState<Record<number, {
@@ -53,7 +53,7 @@ export default function Identify() {
const loadFaces = async () => {
const res = await facesApi.getUnidentified({
page,
page: 1,
page_size: pageSize,
min_quality: minQuality,
date_from: dateFrom || undefined,
@@ -99,7 +99,7 @@ export default function Identify() {
}
}
} catch (error) {
console.error(`Error checking similar faces for face ${face.id}:`, error)
// Silently skip faces with errors
}
similarityMap.set(face.id, similarSet)
@@ -167,14 +167,9 @@ export default function Identify() {
}
try {
const res = await facesApi.getSimilar(faceId)
console.log('Similar faces response:', res)
console.log('Similar faces items:', res.items)
console.log('Similar faces count:', res.items?.length || 0)
setSimilar(res.items || [])
setSelectedSimilar({})
} catch (error) {
console.error('Error loading similar faces:', error)
console.error('Error details:', error instanceof Error ? error.message : String(error))
setSimilar([])
}
}
@@ -183,13 +178,49 @@ export default function Identify() {
loadFaces()
loadPeople()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly])
}, [pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly])
useEffect(() => {
if (currentFace) loadSimilar(currentFace.id)
if (currentFace) {
setImageLoading(true) // Show loading indicator when face changes
loadSimilar(currentFace.id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentFace?.id, compareEnabled])
// Preload images for next/previous faces
useEffect(() => {
if (!currentFace || faces.length === 0) return
const preloadImages = () => {
const preloadUrls: string[] = []
const baseUrl = apiClient.defaults.baseURL || 'http://127.0.0.1:8000'
// Preload next face
if (currentIdx + 1 < faces.length) {
const nextFace = faces[currentIdx + 1]
preloadUrls.push(`${baseUrl}/api/v1/faces/${nextFace.id}/crop`)
}
// Preload previous face
if (currentIdx > 0) {
const prevFace = faces[currentIdx - 1]
preloadUrls.push(`${baseUrl}/api/v1/faces/${prevFace.id}/crop`)
}
// Preload images in background
preloadUrls.forEach(url => {
const img = new Image()
img.src = url
})
}
// Small delay to avoid blocking current image load
const timer = setTimeout(preloadImages, 100)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentIdx, faces])
// Save form data whenever fields change (for current face)
useEffect(() => {
if (!currentFace) return
@@ -392,22 +423,31 @@ export default function Identify() {
) : (
<div>
<div
className="aspect-video bg-gray-100 rounded mb-3 overflow-hidden flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
className="aspect-video bg-gray-100 rounded mb-3 overflow-hidden flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity relative"
onClick={() => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${currentFace.photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
{imageLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100 z-10">
<div className="text-gray-400 text-sm">Loading...</div>
</div>
)}
<img
key={currentFace.id}
src={`${apiClient.defaults.baseURL}/api/v1/faces/${currentFace.id}/crop?t=${Date.now()}`}
src={`${apiClient.defaults.baseURL}/api/v1/faces/${currentFace.id}/crop`}
alt={`Face ${currentFace.id}`}
className="max-w-full max-h-full object-contain pointer-events-none"
crossOrigin="anonymous"
loading="eager"
onLoad={() => setImageLoading(false)}
onLoadStart={() => setImageLoading(true)}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
setImageLoading(false)
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
@@ -589,10 +629,11 @@ export default function Identify() {
title="Click to open full photo"
>
<img
src={`${apiClient.defaults.baseURL}/api/v1/faces/${s.id}/crop?t=${Date.now()}`}
src={`${apiClient.defaults.baseURL}/api/v1/faces/${s.id}/crop`}
alt={`Face ${s.id}`}
className="max-w-full max-h-full object-contain pointer-events-none"
crossOrigin="anonymous"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
+1 -1
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { useState, useRef, useEffect } from 'react'
import { facesApi, ProcessFacesRequest } from '../api/faces'
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
+610 -4
View File
@@ -1,11 +1,617 @@
import { useEffect, useState } from 'react'
import { photosApi, PhotoSearchResult } from '../api/photos'
import tagsApi, { TagResponse } from '../api/tags'
import { apiClient } from '../api/client'
type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags'
const SEARCH_TYPES: { value: SearchType; label: string }[] = [
{ value: 'name', label: 'Search photos by name' },
{ value: 'date', label: 'Search photos by date' },
{ value: 'tags', label: 'Search photos by tags' },
{ value: 'no_faces', label: 'Photos without faces' },
{ value: 'no_tags', label: 'Photos without tags' },
]
type SortColumn = 'person' | 'tags' | 'processed' | 'path' | 'date_taken'
type SortDir = 'asc' | 'desc'
export default function Search() {
const [searchType, setSearchType] = useState<SearchType>('name')
const [filtersExpanded, setFiltersExpanded] = useState(false)
const [folderPath, setFolderPath] = useState('')
// Search inputs
const [personName, setPersonName] = useState('')
const [tagNames, setTagNames] = useState('')
const [matchAll, setMatchAll] = useState(false)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
// Results
const [results, setResults] = useState<PhotoSearchResult[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(50)
const [loading, setLoading] = useState(false)
// Selection
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set())
// Sorting
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
const [sortDir, setSortDir] = useState<SortDir>('asc')
// Tags
const [availableTags, setAvailableTags] = useState<TagResponse[]>([])
const [showTagHelp, setShowTagHelp] = useState(false)
// Tag modal
const [showTagModal, setShowTagModal] = useState(false)
const [tagInput, setTagInput] = useState('')
const loadTags = async () => {
try {
const res = await tagsApi.list()
setAvailableTags(res.items)
} catch (error) {
console.error('Error loading tags:', error)
}
}
useEffect(() => {
loadTags()
}, [])
const performSearch = async (pageNum: number = page) => {
setLoading(true)
try {
const params: any = {
search_type: searchType,
folder_path: folderPath || undefined,
page: pageNum,
page_size: pageSize,
}
if (searchType === 'name') {
if (!personName.trim()) {
alert('Please enter a name to search.')
setLoading(false)
return
}
params.person_name = personName.trim()
} else if (searchType === 'date') {
if (!dateFrom && !dateTo) {
alert('Please enter at least one date (from date or to date).')
setLoading(false)
return
}
params.date_from = dateFrom || undefined
params.date_to = dateTo || undefined
} else if (searchType === 'tags') {
if (!tagNames.trim()) {
alert('Please enter tags to search for.')
setLoading(false)
return
}
params.tag_names = tagNames.trim()
params.match_all = matchAll
}
const res = await photosApi.searchPhotos(params)
setResults(res.items)
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 folderMsg = folderPath ? ` in folder '${folderPath}'` : ''
alert(`No photos found${folderMsg}.`)
}
}
} catch (error) {
console.error('Error searching photos:', error)
alert('Error searching photos. Please try again.')
} finally {
setLoading(false)
}
}
const handleSearch = () => {
setPage(1)
setSelectedPhotos(new Set())
performSearch(1)
}
useEffect(() => {
if (searchType === 'no_faces' || searchType === 'no_tags') {
handleSearch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchType])
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDir('asc')
}
}
const sortedResults = [...results].sort((a, b) => {
if (!sortColumn) return 0
let aVal: any
let bVal: any
switch (sortColumn) {
case 'person':
aVal = a.person_name || ''
bVal = b.person_name || ''
break
case 'tags':
aVal = a.tags.join(', ')
bVal = b.tags.join(', ')
break
case 'processed':
aVal = a.processed ? 'Yes' : 'No'
bVal = b.processed ? 'Yes' : 'No'
break
case 'path':
aVal = a.path
bVal = b.path
break
case 'date_taken':
aVal = a.date_taken || '9999-12-31'
bVal = b.date_taken || '9999-12-31'
break
}
if (typeof aVal === 'string') {
aVal = aVal.toLowerCase()
bVal = bVal.toLowerCase()
}
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1
return 0
})
const toggleSelection = (photoId: number) => {
setSelectedPhotos(prev => {
const newSet = new Set(prev)
if (newSet.has(photoId)) {
newSet.delete(photoId)
} else {
newSet.add(photoId)
}
return newSet
})
}
const clearAllSelected = () => {
setSelectedPhotos(new Set())
}
const handleTagSelected = async () => {
if (selectedPhotos.size === 0) {
alert('Please select photos to tag.')
return
}
if (!tagInput.trim()) {
alert('Please enter tags to add.')
return
}
const tags = tagInput.split(',').map(t => t.trim()).filter(t => t)
if (tags.length === 0) {
alert('Please enter valid tags.')
return
}
try {
await tagsApi.addToPhotos({
photo_ids: Array.from(selectedPhotos),
tag_names: tags,
})
alert(`Added tags to ${selectedPhotos.size} photos.`)
setShowTagModal(false)
setTagInput('')
// Refresh search results
performSearch(page)
} catch (error) {
console.error('Error tagging photos:', error)
alert('Error tagging photos. Please try again.')
}
}
const openPhoto = (photoId: number) => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
window.open(photoUrl, '_blank')
}
const openFolder = (path: string) => {
// Extract folder path from file path
const folder = path.substring(0, path.lastIndexOf('/'))
alert(`Open folder: ${folder}\n\nNote: Folder opening not implemented in web version.`)
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Search</h1>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-gray-600">Search functionality coming in Phase 3.</p>
<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>
{/* Filters */}
<div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="flex items-center justify-between mb-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"
>
{filtersExpanded ? '' : '+'}
</button>
</div>
{filtersExpanded && (
<div className="mt-4 space-y-3">
<div>
<label className="block text-sm text-gray-700 mb-1">Folder location:</label>
<div className="flex gap-2">
<input
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="(optional - filter by folder path)"
className="flex-1 border rounded px-3 py-2"
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<button
onClick={() => setFolderPath('')}
className="px-3 py-2 border rounded hover:bg-gray-50"
>
Clear
</button>
</div>
</div>
</div>
)}
</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">
Person name:
</label>
<input
type="text"
value={personName}
onChange={(e) => setPersonName(e.target.value)}
className="w-full border rounded px-3 py-2"
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Enter person name"
/>
</div>
)}
{searchType === 'date' && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
From date:
</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full border rounded px-3 py-2"
placeholder="YYYY-MM-DD"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
To date:
</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full border rounded px-3 py-2"
placeholder="YYYY-MM-DD (optional)"
/>
</div>
</div>
)}
{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"
>
<option value="ANY">ANY (photos with any tag)</option>
<option value="ALL">ALL (photos with all tags)</option>
</select>
</div>
</div>
)}
{(searchType === 'no_faces' || searchType === 'no_tags') && (
<p className="text-sm text-gray-600">No input needed for this search type.</p>
)}
</div>
{/* Search Button */}
<div className="mb-4">
<button
onClick={handleSearch}
disabled={loading}
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400"
>
{loading ? 'Searching...' : 'Search'}
</button>
</div>
{/* 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>
</div>
{/* Results Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left p-2 w-12"></th>
{searchType === 'name' && (
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('person')}
>
Person {sortColumn === 'person' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
)}
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('tags')}
>
Tags {sortColumn === 'tags' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
{searchType !== 'name' && (
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('processed')}
>
Processed {sortColumn === 'processed' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
)}
<th className="text-left p-2 w-12">📁</th>
{searchType !== 'no_faces' && (
<th className="text-left p-2 w-12">👤</th>
)}
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('path')}
>
Photo path {sortColumn === 'path' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('date_taken')}
>
Date Taken {sortColumn === 'date_taken' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
</tr>
</thead>
<tbody>
{sortedResults.map(photo => (
<tr key={photo.id} className="border-b hover:bg-gray-50">
<td className="p-2">
<input
type="checkbox"
checked={selectedPhotos.has(photo.id)}
onChange={() => toggleSelection(photo.id)}
className="cursor-pointer"
/>
</td>
{searchType === 'name' && (
<td className="p-2">{photo.person_name || ''}</td>
)}
<td className="p-2" title={photo.tags.join(', ')}>
{photo.tags.length > 0 ? photo.tags.join(', ') : 'No tags'}
</td>
{searchType !== 'name' && (
<td className="p-2 text-center">{photo.processed ? 'Yes' : 'No'}</td>
)}
<td className="p-2">
<button
onClick={() => openFolder(photo.path)}
className="cursor-pointer hover:text-blue-600"
title="Open file location"
>
📁
</button>
</td>
{searchType !== 'no_faces' && (
<td className="p-2">
{photo.has_faces && (
<span title={`${photo.face_count} face(s)`}>👤</span>
)}
</td>
)}
<td className="p-2">
<button
onClick={() => openPhoto(photo.id)}
className="text-blue-600 hover:underline cursor-pointer"
title="Open photo"
>
{photo.path}
</button>
</td>
<td className="p-2">{photo.date_taken || 'No date'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{total > pageSize && (
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-600">
Page {page} of {Math.ceil(total / pageSize)}
</div>
<div className="flex gap-2">
<button
onClick={() => {
const newPage = Math.max(1, page - 1)
setPage(newPage)
performSearch(newPage)
}}
disabled={page === 1}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
>
Previous
</button>
<button
onClick={() => {
const newPage = Math.min(Math.ceil(total / pageSize), page + 1)
setPage(newPage)
performSearch(newPage)
}}
disabled={page >= Math.ceil(total / pageSize)}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
>
Next
</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 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">
<button
onClick={() => {
setShowTagModal(false)
setTagInput('')
}}
className="px-4 py-2 border rounded hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleTagSelected}
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Add Tags
</button>
</div>
</div>
</div>
)}
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}