feat: Implement favorites functionality for photos with API and UI updates
This commit introduces a favorites feature for photos, allowing users to mark and manage their favorite images. The API has been updated with new endpoints for toggling favorite status, checking if a photo is a favorite, and bulk adding/removing favorites. The frontend has been enhanced to include favorite management in the PhotoViewer and Search components, with UI elements for adding/removing favorites and displaying favorite status. Documentation has been updated to reflect these changes.
This commit is contained in:
@@ -73,7 +73,7 @@ export const photosApi = {
|
||||
},
|
||||
|
||||
searchPhotos: async (params: {
|
||||
search_type: 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed'
|
||||
search_type: 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' | 'favorites'
|
||||
person_name?: string
|
||||
tag_names?: string
|
||||
match_all?: boolean
|
||||
@@ -89,6 +89,36 @@ export const photosApi = {
|
||||
return data
|
||||
},
|
||||
|
||||
toggleFavorite: async (photoId: number): Promise<{ photo_id: number; is_favorite: boolean; message: string }> => {
|
||||
const { data } = await apiClient.post(
|
||||
`/api/v1/photos/${photoId}/toggle-favorite`
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
checkFavorite: async (photoId: number): Promise<{ photo_id: number; is_favorite: boolean }> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/api/v1/photos/${photoId}/is-favorite`
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
bulkAddFavorites: async (photoIds: number[]): Promise<{ message: string; added_count: number; already_favorite_count: number; total_requested: number }> => {
|
||||
const { data } = await apiClient.post(
|
||||
'/api/v1/photos/bulk-add-favorites',
|
||||
{ photo_ids: photoIds }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
bulkRemoveFavorites: async (photoIds: number[]): Promise<{ message: string; removed_count: number; not_favorite_count: number; total_requested: number }> => {
|
||||
const { data } = await apiClient.post(
|
||||
'/api/v1/photos/bulk-remove-favorites',
|
||||
{ photo_ids: photoIds }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
openFolder: async (photoId: number): Promise<{ message: string; folder: string }> => {
|
||||
const { data } = await apiClient.post<{ message: string; folder: string }>(
|
||||
`/api/v1/photos/${photoId}/open-folder`
|
||||
@@ -115,6 +145,7 @@ export interface PhotoSearchResult {
|
||||
tags: string[]
|
||||
has_faces: boolean
|
||||
face_count: number
|
||||
is_favorite?: boolean
|
||||
}
|
||||
|
||||
export interface SearchPhotosResponse {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { PhotoSearchResult } from '../api/photos'
|
||||
import { PhotoSearchResult, photosApi } from '../api/photos'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
interface PhotoViewerProps {
|
||||
@@ -37,6 +37,10 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [slideshowInterval, setSlideshowInterval] = useState(3) // seconds
|
||||
const slideshowTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Favorite state
|
||||
const [isFavorite, setIsFavorite] = useState(false)
|
||||
const [loadingFavorite, setLoadingFavorite] = useState(false)
|
||||
|
||||
const currentPhoto = photos[currentIndex]
|
||||
const canGoPrev = currentIndex > 0
|
||||
@@ -180,10 +184,34 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
|
||||
// Load favorite status when photo changes
|
||||
photosApi.checkFavorite(currentPhoto.id)
|
||||
.then(result => setIsFavorite(result.is_favorite))
|
||||
.catch(err => {
|
||||
console.error('Error checking favorite:', err)
|
||||
setIsFavorite(false)
|
||||
})
|
||||
|
||||
// Preload adjacent images when current photo changes
|
||||
preloadAdjacent(currentIndex)
|
||||
}, [currentIndex, currentPhoto, photos.length])
|
||||
|
||||
// Toggle favorite
|
||||
const toggleFavorite = async () => {
|
||||
if (loadingFavorite || !currentPhoto) return
|
||||
|
||||
setLoadingFavorite(true)
|
||||
try {
|
||||
const result = await photosApi.toggleFavorite(currentPhoto.id)
|
||||
setIsFavorite(result.is_favorite)
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error)
|
||||
alert('Error updating favorite status')
|
||||
} finally {
|
||||
setLoadingFavorite(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -255,9 +283,24 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Right Play Button */}
|
||||
{/* Top Right Controls */}
|
||||
<div className="absolute top-0 right-0 z-10 bg-black bg-opacity-70 text-white p-2 rounded-bl-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Favorite button */}
|
||||
<button
|
||||
onClick={toggleFavorite}
|
||||
disabled={loadingFavorite}
|
||||
className={`px-3 py-1 rounded text-xs ${
|
||||
isFavorite
|
||||
? 'bg-yellow-600 hover:bg-yellow-700'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
} disabled:opacity-50`}
|
||||
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{isFavorite ? '⭐' : '☆'}
|
||||
</button>
|
||||
|
||||
{/* Slideshow controls */}
|
||||
{isPlaying && (
|
||||
<select
|
||||
value={slideshowInterval}
|
||||
|
||||
@@ -4,7 +4,7 @@ import tagsApi, { TagResponse, PhotoTagItem } from '../api/tags'
|
||||
import { apiClient } from '../api/client'
|
||||
import PhotoViewer from '../components/PhotoViewer'
|
||||
|
||||
type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed'
|
||||
type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' | 'favorites'
|
||||
|
||||
const SEARCH_TYPES: { value: SearchType; label: string }[] = [
|
||||
{ value: 'name', label: 'Search photos by name' },
|
||||
@@ -14,6 +14,7 @@ const SEARCH_TYPES: { value: SearchType; label: string }[] = [
|
||||
{ value: 'no_tags', label: 'Photos without tags' },
|
||||
{ value: 'processed', label: 'Search processed photos' },
|
||||
{ value: 'unprocessed', label: 'Search un-processed photos' },
|
||||
{ value: 'favorites', label: '⭐ Favorite photos' },
|
||||
]
|
||||
|
||||
type SortColumn = 'person' | 'tags' | 'processed' | 'path' | 'date_taken'
|
||||
@@ -57,6 +58,7 @@ export default function Search() {
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set())
|
||||
const [loadingTags, setLoadingTags] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [loadingFavorites, setLoadingFavorites] = useState(false)
|
||||
|
||||
// Photo viewer
|
||||
const [showPhotoViewer, setShowPhotoViewer] = useState(false)
|
||||
@@ -133,7 +135,13 @@ export default function Search() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (searchType === 'no_faces' || searchType === 'no_tags' || searchType === 'processed' || searchType === 'unprocessed') {
|
||||
// Clear results from previous search when search type changes
|
||||
setResults([])
|
||||
setTotal(0)
|
||||
setPage(1)
|
||||
setSelectedPhotos(new Set())
|
||||
|
||||
if (searchType === 'no_faces' || searchType === 'no_tags' || searchType === 'processed' || searchType === 'unprocessed' || searchType === 'favorites') {
|
||||
handleSearch()
|
||||
}
|
||||
// Clear selected tags when switching away from tag search
|
||||
@@ -408,6 +416,66 @@ export default function Search() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddToFavorites = async () => {
|
||||
if (selectedPhotos.size === 0) {
|
||||
alert('Please select photos to add to favorites.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingFavorites(true)
|
||||
try {
|
||||
const photoIds = Array.from(selectedPhotos)
|
||||
|
||||
// Use bulk endpoint for better performance
|
||||
await photosApi.bulkAddFavorites(photoIds)
|
||||
|
||||
// Refresh search results to update favorite status
|
||||
await performSearch(page)
|
||||
|
||||
// Clear selection after successful operation
|
||||
setSelectedPhotos(new Set())
|
||||
} catch (error) {
|
||||
console.error('Error adding photos to favorites:', error)
|
||||
} finally {
|
||||
setLoadingFavorites(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFromFavorites = async () => {
|
||||
if (selectedPhotos.size === 0) {
|
||||
alert('Please select photos to remove from favorites.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingFavorites(true)
|
||||
try {
|
||||
const photoIds = Array.from(selectedPhotos)
|
||||
|
||||
// Use bulk endpoint for better performance
|
||||
await photosApi.bulkRemoveFavorites(photoIds)
|
||||
|
||||
// Refresh search results to update favorite status
|
||||
await performSearch(page)
|
||||
|
||||
// Clear selection after successful operation
|
||||
setSelectedPhotos(new Set())
|
||||
} catch (error) {
|
||||
console.error('Error removing photos from favorites:', error)
|
||||
} finally {
|
||||
setLoadingFavorites(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleFavorite = async (photoId: number) => {
|
||||
try {
|
||||
await photosApi.toggleFavorite(photoId)
|
||||
// Refresh search results to update favorite status
|
||||
await performSearch(page)
|
||||
} catch (error) {
|
||||
console.error(`Error toggling favorite for photo ${photoId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Load all photos for photo viewer (all pages)
|
||||
const loadAllPhotos = async () => {
|
||||
if (total === 0) {
|
||||
@@ -676,6 +744,14 @@ export default function Search() {
|
||||
>
|
||||
{loadingAllPhotos ? 'Loading...' : '▶ Play photos'}
|
||||
</button>
|
||||
<button
|
||||
onClick={searchType === 'favorites' ? handleRemoveFromFavorites : handleAddToFavorites}
|
||||
disabled={selectedPhotos.size === 0 || loadingFavorites}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
title={searchType === 'favorites' ? 'Remove selected photos from favorites' : 'Add selected photos to favorites'}
|
||||
>
|
||||
{loadingFavorites ? '...' : '⭐'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTagModal(true)}
|
||||
disabled={selectedPhotos.size === 0}
|
||||
@@ -729,6 +805,7 @@ export default function Search() {
|
||||
Processed {sortColumn === 'processed' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
)}
|
||||
<th className="text-left p-2 w-12">⭐</th>
|
||||
<th className="text-left p-2 w-12">📁</th>
|
||||
{searchType !== 'no_faces' && (
|
||||
<th className="text-left p-2 w-12">👤</th>
|
||||
@@ -767,6 +844,21 @@ export default function Search() {
|
||||
{searchType !== 'name' && (
|
||||
<td className="p-2 text-center">{photo.processed ? 'Yes' : 'No'}</td>
|
||||
)}
|
||||
<td className="p-2 text-center">
|
||||
{photo.is_favorite && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleToggleFavorite(photo.id)
|
||||
}}
|
||||
className="cursor-pointer hover:text-yellow-600 text-lg"
|
||||
title="Remove from favorites"
|
||||
>
|
||||
⭐
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
||||
Reference in New Issue
Block a user