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

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 {

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}

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) => {

View File

@ -7,11 +7,13 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import JSONResponse, FileResponse
from typing import Annotated
from rq import Queue
from redis import Redis
from sqlalchemy.orm import Session
from src.web.db.session import get_db
from src.web.api.auth import get_current_user
# Redis connection for RQ
redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
@ -20,6 +22,10 @@ from src.web.schemas.photos import (
PhotoImportRequest,
PhotoImportResponse,
PhotoResponse,
BulkAddFavoritesRequest,
BulkAddFavoritesResponse,
BulkRemoveFavoritesRequest,
BulkRemoveFavoritesResponse,
)
from src.web.schemas.search import (
PhotoSearchResult,
@ -30,6 +36,7 @@ from src.web.services.photo_service import (
import_photo_from_path,
)
from src.web.services.search_service import (
get_favorite_photos,
get_photo_face_count,
get_photo_person,
get_photo_tags,
@ -48,7 +55,8 @@ router = APIRouter(prefix="/photos", tags=["photos"])
@router.get("", response_model=SearchPhotosResponse)
def search_photos(
search_type: str = Query("name", description="Search type: name, date, tags, no_faces, no_tags, processed, unprocessed"),
current_user: Annotated[dict, Depends(get_current_user)],
search_type: str = Query("name", description="Search type: name, date, tags, no_faces, no_tags, processed, unprocessed, favorites"),
person_name: Optional[str] = Query(None, description="Person name for name search"),
tag_names: Optional[str] = Query(None, description="Comma-separated tag names for tag search"),
match_all: bool = Query(False, description="Match all tags (for tag search)"),
@ -69,9 +77,23 @@ def search_photos(
- Search no tags: returns photos without tags
- Search processed: returns photos that have been processed for face detection
- Search unprocessed: returns photos that have not been processed for face detection
- Search favorites: returns photos favorited by current user
"""
from src.web.db.models import PhotoFavorite
items: List[PhotoSearchResult] = []
total = 0
username = current_user["username"] if current_user else None
# Helper function to check if photo is favorite
def check_is_favorite(photo_id: int) -> bool:
if not username:
return False
favorite = db.query(PhotoFavorite).filter(
PhotoFavorite.username == username,
PhotoFavorite.photo_id == photo_id
).first()
return favorite is not None
if search_type == "name":
if not person_name:
@ -99,6 +121,7 @@ def search_photos(
tags=tags,
has_faces=face_count > 0,
face_count=face_count,
is_favorite=check_is_favorite(photo.id),
)
)
elif search_type == "date":
@ -128,6 +151,7 @@ def search_photos(
tags=tags,
has_faces=face_count > 0,
face_count=face_count,
is_favorite=check_is_favorite(photo.id),
)
)
elif search_type == "tags":
@ -163,6 +187,7 @@ def search_photos(
tags=tags,
has_faces=face_count > 0,
face_count=face_count,
is_favorite=check_is_favorite(photo.id),
)
)
elif search_type == "no_faces":
@ -183,6 +208,7 @@ def search_photos(
tags=tags,
has_faces=False,
face_count=0,
is_favorite=check_is_favorite(photo.id),
)
)
elif search_type == "no_tags":
@ -204,6 +230,7 @@ def search_photos(
tags=[],
has_faces=face_count > 0,
face_count=face_count,
is_favorite=check_is_favorite(photo.id),
)
)
elif search_type == "processed":
@ -226,6 +253,7 @@ def search_photos(
tags=tags,
has_faces=face_count > 0,
face_count=face_count,
is_favorite=check_is_favorite(photo.id),
)
)
elif search_type == "unprocessed":
@ -248,6 +276,35 @@ def search_photos(
tags=tags,
has_faces=face_count > 0,
face_count=face_count,
is_favorite=check_is_favorite(photo.id),
)
)
elif search_type == "favorites":
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required for favorites search",
)
results, total = get_favorite_photos(db, username, folder_path, page, page_size)
for photo in results:
tags = get_photo_tags(db, photo.id)
face_count = get_photo_face_count(db, photo.id)
person_name_val = get_photo_person(db, photo.id)
# Convert datetime to date for date_added
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
items.append(
PhotoSearchResult(
id=photo.id,
path=photo.path,
filename=photo.filename,
date_taken=photo.date_taken,
date_added=date_added,
processed=photo.processed,
person_name=person_name_val,
tags=tags,
has_faces=face_count > 0,
face_count=face_count,
is_favorite=True, # All results are favorites
)
)
else:
@ -481,6 +538,196 @@ def get_photo_image(photo_id: int, db: Session = Depends(get_db)) -> FileRespons
return response
@router.post("/{photo_id}/toggle-favorite")
def toggle_favorite(
photo_id: int,
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> dict:
"""Toggle favorite status of a photo for current user."""
from src.web.db.models import Photo, PhotoFavorite
username = current_user["username"]
# Verify photo exists
photo = db.query(Photo).filter(Photo.id == photo_id).first()
if not photo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Photo {photo_id} not found",
)
# Check if already favorited
existing = db.query(PhotoFavorite).filter(
PhotoFavorite.username == username,
PhotoFavorite.photo_id == photo_id
).first()
if existing:
# Remove favorite
db.delete(existing)
is_favorite = False
else:
# Add favorite
favorite = PhotoFavorite(
username=username,
photo_id=photo_id
)
db.add(favorite)
is_favorite = True
db.commit()
return {
"photo_id": photo_id,
"is_favorite": is_favorite,
"message": "Favorite status updated"
}
@router.get("/{photo_id}/is-favorite")
def check_favorite(
photo_id: int,
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> dict:
"""Check if photo is favorited by current user."""
from src.web.db.models import PhotoFavorite
username = current_user["username"]
favorite = db.query(PhotoFavorite).filter(
PhotoFavorite.username == username,
PhotoFavorite.photo_id == photo_id
).first()
return {
"photo_id": photo_id,
"is_favorite": favorite is not None
}
@router.post("/bulk-add-favorites", response_model=BulkAddFavoritesResponse)
def bulk_add_favorites(
request: BulkAddFavoritesRequest,
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> BulkAddFavoritesResponse:
"""Add multiple photos to favorites for current user.
Only adds favorites for photos that aren't already favorites.
Uses a single database transaction for better performance.
"""
from src.web.db.models import Photo, PhotoFavorite
photo_ids = request.photo_ids
if not photo_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="photo_ids list cannot be empty",
)
username = current_user["username"]
# Verify all photos exist
photos = db.query(Photo).filter(Photo.id.in_(photo_ids)).all()
found_ids = {photo.id for photo in photos}
missing_ids = set(photo_ids) - found_ids
if missing_ids:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Photos not found: {sorted(missing_ids)}",
)
# Get existing favorites in a single query
existing_favorites = db.query(PhotoFavorite).filter(
PhotoFavorite.username == username,
PhotoFavorite.photo_id.in_(photo_ids)
).all()
existing_ids = {fav.photo_id for fav in existing_favorites}
# Only add favorites for photos that aren't already favorites
photos_to_add = [photo_id for photo_id in photo_ids if photo_id not in existing_ids]
added_count = 0
for photo_id in photos_to_add:
favorite = PhotoFavorite(
username=username,
photo_id=photo_id
)
db.add(favorite)
added_count += 1
db.commit()
return BulkAddFavoritesResponse(
message=f"Added {added_count} photo(s) to favorites",
added_count=added_count,
already_favorite_count=len(existing_ids),
total_requested=len(photo_ids),
)
@router.post("/bulk-remove-favorites", response_model=BulkRemoveFavoritesResponse)
def bulk_remove_favorites(
request: BulkRemoveFavoritesRequest,
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> BulkRemoveFavoritesResponse:
"""Remove multiple photos from favorites for current user.
Only removes favorites for photos that are currently favorites.
Uses a single database transaction for better performance.
"""
from src.web.db.models import Photo, PhotoFavorite
photo_ids = request.photo_ids
if not photo_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="photo_ids list cannot be empty",
)
username = current_user["username"]
# Verify all photos exist
photos = db.query(Photo).filter(Photo.id.in_(photo_ids)).all()
found_ids = {photo.id for photo in photos}
missing_ids = set(photo_ids) - found_ids
if missing_ids:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Photos not found: {sorted(missing_ids)}",
)
# Get existing favorites in a single query
existing_favorites = db.query(PhotoFavorite).filter(
PhotoFavorite.username == username,
PhotoFavorite.photo_id.in_(photo_ids)
).all()
existing_ids = {fav.photo_id for fav in existing_favorites}
# Only remove favorites for photos that are currently favorites
photos_to_remove = [photo_id for photo_id in photo_ids if photo_id in existing_ids]
removed_count = 0
for favorite in existing_favorites:
if favorite.photo_id in photos_to_remove:
db.delete(favorite)
removed_count += 1
db.commit()
return BulkRemoveFavoritesResponse(
message=f"Removed {removed_count} photo(s) from favorites",
removed_count=removed_count,
not_favorite_count=len(photo_ids) - len(existing_ids),
total_requested=len(photo_ids),
)
@router.post("/{photo_id}/open-folder")
def open_photo_folder(photo_id: int, db: Session = Depends(get_db)) -> dict:
"""Open the folder containing the photo in the system file manager and select the file.

View File

@ -103,7 +103,7 @@ async def lifespan(app: FastAPI):
existing_tables = set(inspector.get_table_names())
# Check if required application tables exist (not just alembic_version)
required_tables = {"photos", "people", "faces", "tags", "phototaglinkage", "person_encodings"}
required_tables = {"photos", "people", "faces", "tags", "phototaglinkage", "person_encodings", "photo_favorites"}
missing_tables = required_tables - existing_tables
if missing_tables:

View File

@ -43,6 +43,7 @@ class Photo(Base):
photo_tags = relationship(
"PhotoTagLinkage", back_populates="photo", cascade="all, delete-orphan"
)
favorites = relationship("PhotoFavorite", back_populates="photo", cascade="all, delete-orphan")
__table_args__ = (
Index("idx_photos_processed", "processed"),
@ -175,3 +176,22 @@ class PhotoTagLinkage(Base):
Index("idx_photo_tags_photo", "photo_id"),
)
class PhotoFavorite(Base):
"""Photo favorites model - user-specific favorites."""
__tablename__ = "photo_favorites"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(Text, nullable=False, index=True)
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
photo = relationship("Photo", back_populates="favorites")
__table_args__ = (
UniqueConstraint("username", "photo_id", name="uq_user_photo_favorite"),
Index("idx_favorites_username", "username"),
Index("idx_favorites_photo", "photo_id"),
)

View File

@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from typing import List, Optional
from pydantic import BaseModel, Field
@ -44,3 +44,33 @@ class PhotoImportResponse(BaseModel):
folder_path: Optional[str] = None
estimated_photos: Optional[int] = None
class BulkAddFavoritesRequest(BaseModel):
"""Request to add multiple photos to favorites."""
photo_ids: List[int] = Field(..., description="List of photo IDs to add to favorites")
class BulkAddFavoritesResponse(BaseModel):
"""Response for bulk add favorites operation."""
message: str
added_count: int
already_favorite_count: int
total_requested: int
class BulkRemoveFavoritesRequest(BaseModel):
"""Request to remove multiple photos from favorites."""
photo_ids: List[int] = Field(..., description="List of photo IDs to remove from favorites")
class BulkRemoveFavoritesResponse(BaseModel):
"""Response for bulk remove favorites operation."""
message: str
removed_count: int
not_favorite_count: int
total_requested: int

View File

@ -36,6 +36,7 @@ class PhotoSearchResult(BaseModel):
tags: List[str] = Field(default_factory=list) # All tags for the photo
has_faces: bool = False
face_count: int = 0
is_favorite: bool = False # Whether photo is favorited by current user
class SearchPhotosResponse(BaseModel):

View File

@ -291,6 +291,41 @@ def get_processed_photos(
return results, total
def get_favorite_photos(
db: Session,
username: str,
folder_path: Optional[str] = None,
page: int = 1,
page_size: int = 50,
) -> Tuple[List[Photo], int]:
"""Get all favorite photos for a user with pagination."""
from src.web.db.models import PhotoFavorite
# Join favorites with photos
query = (
db.query(Photo)
.join(PhotoFavorite, Photo.id == PhotoFavorite.photo_id)
.filter(PhotoFavorite.username == username)
)
if folder_path:
query = query.filter(Photo.path.like(f"{folder_path}%"))
total = query.count()
# Order by favorite date (most recent first), then date_taken
results = (
query.order_by(PhotoFavorite.created_date.desc())
.order_by(Photo.date_taken.desc().nulls_last())
.order_by(Photo.date_added.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return results, total
def get_unprocessed_photos(
db: Session,
folder_path: Optional[str] = None,