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:
tanyar09
2025-11-13 12:58:17 -05:00
parent cd72913cd5
commit 5ca130f8bd
9 changed files with 507 additions and 8 deletions
+32 -1
View File
@@ -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 {
+45 -2
View File
@@ -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}
+94 -2
View File
@@ -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) => {