diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc new file mode 100644 index 000000000..3d152c6d6 Binary files /dev/null and b/__pycache__/app.cpython-313.pyc differ diff --git a/backend/app/database/images.py b/backend/app/database/images.py index 855e63fbd..ec9541a56 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -63,6 +63,7 @@ def db_create_images_table() -> None: thumbnailPath TEXT UNIQUE, metadata TEXT, isTagged BOOLEAN DEFAULT 0, + isFavourite BOOLEAN DEFAULT 0, FOREIGN KEY (folder_id) REFERENCES folders(folder_id) ON DELETE CASCADE ) """ @@ -143,6 +144,7 @@ def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]: i.thumbnailPath, i.metadata, i.isTagged, + i.isFavourite, m.name as tag_name FROM images i LEFT JOIN image_classes ic ON i.id = ic.image_id @@ -169,6 +171,7 @@ def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]: thumbnail_path, metadata, is_tagged, + is_favourite, tag_name, ) in results: if image_id not in images_dict: @@ -184,6 +187,7 @@ def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]: "thumbnailPath": thumbnail_path, "metadata": metadata_dict, "isTagged": bool(is_tagged), + "isFavourite": bool(is_favourite), "tags": [], } @@ -390,3 +394,28 @@ def db_delete_images_by_ids(image_ids: List[ImageId]) -> bool: return False finally: conn.close() + + +def db_toggle_image_favourite_status(image_id: str) -> bool: + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + try: + cursor.execute("SELECT id FROM images WHERE id = ?", (image_id,)) + if not cursor.fetchone(): + return False + cursor.execute( + """ + UPDATE images + SET isFavourite = CASE WHEN isFavourite = 1 THEN 0 ELSE 1 END + WHERE id = ? + """, + (image_id,), + ) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Database error: {e}") + conn.rollback() + return False + finally: + conn.close() diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index 8ac41cad5..2e40cd825 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -4,7 +4,11 @@ from app.schemas.images import ErrorResponse from app.utils.images import image_util_parse_metadata from pydantic import BaseModel +from app.database.images import db_toggle_image_favourite_status +from app.logging.setup_logging import get_logger +# Initialize logger +logger = get_logger(__name__) router = APIRouter() @@ -29,6 +33,7 @@ class ImageData(BaseModel): thumbnailPath: str metadata: MetadataModel isTagged: bool + isFavourite: bool tags: Optional[List[str]] = None @@ -60,6 +65,7 @@ def get_all_images( thumbnailPath=image["thumbnailPath"], metadata=image_util_parse_metadata(image["metadata"]), isTagged=image["isTagged"], + isFavourite=image.get("isFavourite", False), tags=image["tags"], ) for image in images @@ -80,3 +86,45 @@ def get_all_images( message=f"Unable to retrieve images: {str(e)}", ).model_dump(), ) + + +# adding add to favourite and remove from favourite routes + + +class ToggleFavouriteRequest(BaseModel): + image_id: str + + +@router.post("/toggle-favourite") +def toggle_favourite(req: ToggleFavouriteRequest): + image_id = req.image_id + try: + success = db_toggle_image_favourite_status(image_id) + if not success: + raise HTTPException( + status_code=404, detail="Image not found or failed to toggle" + ) + # Fetch updated status to return + image = next( + (img for img in db_get_all_images() if img["id"] == image_id), None + ) + return { + "success": True, + "image_id": image_id, + "isFavourite": image.get("isFavourite", False), + } + + except Exception as e: + logger.error(f"error in /toggle-favourite route: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {e}") + + +class ImageInfoResponse(BaseModel): + id: str + path: str + folder_id: str + thumbnailPath: str + metadata: MetadataModel + isTagged: bool + isFavourite: bool + tags: Optional[List[str]] = None diff --git a/backend/run.bat b/backend/run.bat deleted file mode 100644 index 5c6d5ce51..000000000 --- a/backend/run.bat +++ /dev/null @@ -1,11 +0,0 @@ -@echo off - -if "%1"=="--test" ( - hypercorn main:app --bind localhost:8000 --log-level debug --reload -) else ( - REM print the value of the WORKERS environment variable - echo WORKERS: %WORKERS% - hypercorn main:app --bind localhost:8000 --log-level debug --reload - - -) \ No newline at end of file diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index bb7a0adfa..a29e7c4f1 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -887,6 +887,45 @@ } } }, + "/images/toggle-favourite": { + "post": { + "tags": [ + "Images" + ], + "summary": "Toggle Favourite", + "operationId": "toggle_favourite_images_toggle_favourite_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToggleFavouriteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/face-clusters/{cluster_id}": { "put": { "tags": [ @@ -2094,6 +2133,10 @@ "type": "boolean", "title": "Istagged" }, + "isFavourite": { + "type": "boolean", + "title": "Isfavourite" + }, "tags": { "anyOf": [ { @@ -2116,7 +2159,8 @@ "folder_id", "thumbnailPath", "metadata", - "isTagged" + "isTagged", + "isFavourite" ], "title": "ImageData" }, @@ -2506,6 +2550,19 @@ ], "title": "SyncFolderResponse" }, + "ToggleFavouriteRequest": { + "properties": { + "image_id": { + "type": "string", + "title": "Image Id" + } + }, + "type": "object", + "required": [ + "image_id" + ], + "title": "ToggleFavouriteRequest" + }, "UpdateAITaggingData": { "properties": { "updated_count": { diff --git a/frontend/scripts/setup_env.sh b/frontend/scripts/setup_env.sh old mode 100755 new mode 100644 index 3885ed58d..51b03d951 --- a/frontend/scripts/setup_env.sh +++ b/frontend/scripts/setup_env.sh @@ -115,4 +115,4 @@ sudo apt-get clean sudo rm -rf /var/lib/apt/lists/* echo -e "${GREEN}All required tools and libraries are installed!${NC}" -echo -e "${YELLOW}Note: You may need to restart your terminal or run 'source ~/.bashrc' to use the installed tools.${NC}" \ No newline at end of file +echo -e "${YELLOW}Note: You may need to restart your terminal or run 'source ~/.bashrc' to use the installed tools.${NC}" diff --git a/frontend/scripts/setup_win.ps1 b/frontend/scripts/setup_win.ps1 index e753b3b81..e5eafe3e7 100644 --- a/frontend/scripts/setup_win.ps1 +++ b/frontend/scripts/setup_win.ps1 @@ -70,4 +70,4 @@ if (-not (Test-Command cmake)) { $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") Write-Host "All required tools and libraries are installed!" -ForegroundColor Green -Write-Host "Please restart your computer to ensure all changes take effect." -ForegroundColor Yellow \ No newline at end of file +Write-Host "Please restart your computer to ensure all changes take effect." -ForegroundColor Yellow diff --git a/frontend/src/api/api-functions/togglefav.ts b/frontend/src/api/api-functions/togglefav.ts new file mode 100644 index 000000000..5a1efe0c4 --- /dev/null +++ b/frontend/src/api/api-functions/togglefav.ts @@ -0,0 +1,11 @@ +import { imagesEndpoints } from '../apiEndpoints'; +import { apiClient } from '../axiosConfig'; +import { APIResponse } from '@/types/API'; + +export const togglefav = async (image_id: string): Promise => { + const response = await apiClient.post( + imagesEndpoints.setFavourite, + { image_id }, + ); + return response.data; +}; diff --git a/frontend/src/api/apiEndpoints.ts b/frontend/src/api/apiEndpoints.ts index e4cfd816e..69a7e570d 100644 --- a/frontend/src/api/apiEndpoints.ts +++ b/frontend/src/api/apiEndpoints.ts @@ -1,5 +1,6 @@ export const imagesEndpoints = { getAllImages: '/images/', + setFavourite: '/images/toggle-favourite', }; export const faceClustersEndpoints = { diff --git a/frontend/src/components/Media/ImageCard.tsx b/frontend/src/components/Media/ImageCard.tsx index 0d57a0819..47763f022 100644 --- a/frontend/src/components/Media/ImageCard.tsx +++ b/frontend/src/components/Media/ImageCard.tsx @@ -2,10 +2,11 @@ import { AspectRatio } from '@/components/ui/aspect-ratio'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { Check, Heart, Share2 } from 'lucide-react'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { Image } from '@/types/Media'; import { ImageTags } from './ImageTags'; import { convertFileSrc } from '@tauri-apps/api/core'; +import { useToggleFav } from '@/hooks/useToggleFav'; interface ImageCardViewProps { image: Image; @@ -23,12 +24,16 @@ export function ImageCard({ showTags = true, onClick, }: ImageCardViewProps) { - const [isFavorite, setIsFavorite] = useState(false); const [isImageHovered, setIsImageHovered] = useState(false); - // Default to empty array if no tags are provided const tags = image.tags || []; + const { toggleFavourite } = useToggleFav(); + const handleToggleFavourite = useCallback(() => { + if (image?.id) { + toggleFavourite(image.id); + } + }, [image, toggleFavourite]); return (
{ + console.log(image); e.stopPropagation(); - setIsFavorite(!isFavorite); + handleToggleFavourite(); }} - title={isFavorite ? 'Remove from favorites' : 'Add to favorites'} - aria-label="Add to Favorites" > - - Favorite + {image.isFavourite ? ( + + ) : ( + + )} + Favourite + {type === 'image' && ( diff --git a/frontend/src/components/Navigation/Sidebar/AppSidebar.tsx b/frontend/src/components/Navigation/Sidebar/AppSidebar.tsx index a80fd2620..32cd506c0 100644 --- a/frontend/src/components/Navigation/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Navigation/Sidebar/AppSidebar.tsx @@ -12,6 +12,7 @@ import { Bolt, Home, Sparkles, + Heart, Video, BookImage, ClockFading, @@ -38,6 +39,7 @@ export function AppSidebar() { const menuItems = [ { name: 'Home', path: `/${ROUTES.HOME}`, icon: Home }, { name: 'AI Tagging', path: `/${ROUTES.AI}`, icon: Sparkles }, + { name: 'Favourites', path: `/${ROUTES.FAVOURITES}`, icon: Heart }, { name: 'Videos', path: `/${ROUTES.VIDEOS}`, icon: Video }, { name: 'Albums', path: `/${ROUTES.ALBUMS}`, icon: BookImage }, { name: 'Memories', path: `/${ROUTES.MEMORIES}`, icon: ClockFading }, diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 0bedf571f..7a8da5bb5 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -1,5 +1,6 @@ export const ROUTES = { AI: 'ai-tagging', + FAVOURITES: 'favourites', HOME: 'home', DASHBOARD: 'dashboard', PHOTOS: 'photos', diff --git a/frontend/src/hooks/useFavorites.ts b/frontend/src/hooks/useFavorites.ts deleted file mode 100644 index 2c9746c50..000000000 --- a/frontend/src/hooks/useFavorites.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useState, useCallback } from 'react'; - -export const useFavorites = () => { - const [favorites, setFavorites] = useState([]); - - const toggleFavorite = useCallback((imagePath: string) => { - setFavorites((prev) => { - const isFavorite = prev.includes(imagePath); - if (isFavorite) { - return prev.filter((f) => f !== imagePath); - } else { - return [...prev, imagePath]; - } - }); - }, []); - - const isFavorite = useCallback( - (imagePath: string) => { - return favorites.includes(imagePath); - }, - [favorites], - ); - - return { - favorites, - toggleFavorite, - isFavorite, - }; -}; diff --git a/frontend/src/hooks/useToggleFav.ts b/frontend/src/hooks/useToggleFav.ts new file mode 100644 index 000000000..8ce0bb94d --- /dev/null +++ b/frontend/src/hooks/useToggleFav.ts @@ -0,0 +1,18 @@ +import { usePictoMutation } from '@/hooks/useQueryExtension'; +import { useMutationFeedback } from '@/hooks/useMutationFeedback'; +import { togglefav } from '@/api/api-functions/togglefav'; + +export const useToggleFav = () => { + const toggleFavouriteMutation = usePictoMutation({ + mutationFn: async (image_id: string) => togglefav(image_id), + autoInvalidateTags: ['images'], + }); + useMutationFeedback(toggleFavouriteMutation, { + showLoading: false, + showSuccess: false, + }); + return { + toggleFavourite: (id: any) => toggleFavouriteMutation.mutate(id), + toggleFavouritePending: toggleFavouriteMutation.isPending, + }; +}; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 8a1ae3f87..83c9e5c83 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -7,13 +7,12 @@ import { import TimelineScrollbar from '@/components/Timeline/TimelineScrollbar'; import { Image } from '@/types/Media'; import { setImages } from '@/features/imageSlice'; -import { showLoader, hideLoader } from '@/features/loaderSlice'; import { selectImages } from '@/features/imageSelectors'; import { usePictoQuery } from '@/hooks/useQueryExtension'; import { fetchAllImages } from '@/api/api-functions'; import { RootState } from '@/app/store'; -import { showInfoDialog } from '@/features/infoDialogSlice'; import { EmptyGalleryState } from '@/components/EmptyStates/EmptyGalleryState'; +import { useMutationFeedback } from '@/hooks/useMutationFeedback'; export const Home = () => { const dispatch = useDispatch(); @@ -23,33 +22,28 @@ export const Home = () => { const searchState = useSelector((state: RootState) => state.search); const isSearchActive = searchState.active; - const { data, isLoading, isSuccess, isError } = usePictoQuery({ + const { data, isLoading, isSuccess, isError, error } = usePictoQuery({ queryKey: ['images'], queryFn: () => fetchAllImages(), enabled: !isSearchActive, }); - // Handle fetching lifecycle + useMutationFeedback( + { isPending: isLoading, isSuccess, isError, error }, + { + loadingMessage: 'Loading images', + showSuccess: false, + errorTitle: 'Error', + errorMessage: 'Failed to load images. Please try again later.', + }, + ); + useEffect(() => { - if (!isSearchActive) { - if (isLoading) { - dispatch(showLoader('Loading images')); - } else if (isError) { - dispatch(hideLoader()); - dispatch( - showInfoDialog({ - title: 'Error', - message: 'Failed to load images. Please try again later.', - variant: 'error', - }), - ); - } else if (isSuccess) { - const images = data?.data as Image[]; - dispatch(setImages(images)); - dispatch(hideLoader()); - } + if (!isSearchActive && isSuccess) { + const images = data?.data as Image[]; + dispatch(setImages(images)); } - }, [data, isSuccess, isError, isLoading, dispatch, isSearchActive]); + }, [data, isSuccess, dispatch, isSearchActive]); const title = isSearchActive && images.length > 0 diff --git a/frontend/src/pages/Home/MyFav.tsx b/frontend/src/pages/Home/MyFav.tsx new file mode 100644 index 000000000..2afcb70e8 --- /dev/null +++ b/frontend/src/pages/Home/MyFav.tsx @@ -0,0 +1,115 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + ChronologicalGallery, + MonthMarker, +} from '@/components/Media/ChronologicalGallery'; +import TimelineScrollbar from '@/components/Timeline/TimelineScrollbar'; +import { Image } from '@/types/Media'; +import { setImages } from '@/features/imageSlice'; +import { selectImages } from '@/features/imageSelectors'; +import { usePictoQuery } from '@/hooks/useQueryExtension'; +import { fetchAllImages } from '@/api/api-functions'; +import { RootState } from '@/app/store'; +import { EmptyGalleryState } from '@/components/EmptyStates/EmptyGalleryState'; +import { Heart } from 'lucide-react'; +import { useMutationFeedback } from '@/hooks/useMutationFeedback'; + +export const MyFav = () => { + const dispatch = useDispatch(); + const images = useSelector(selectImages); + const scrollableRef = useRef(null); + const [monthMarkers, setMonthMarkers] = useState([]); + const searchState = useSelector((state: RootState) => state.search); + const isSearchActive = searchState.active; + + const { data, isLoading, isSuccess, isError, error } = usePictoQuery({ + queryKey: ['images'], + queryFn: () => fetchAllImages(), + enabled: !isSearchActive, + }); + + useMutationFeedback( + { isPending: isLoading, isSuccess, isError, error }, + { + loadingMessage: 'Loading images', + showSuccess: false, + errorTitle: 'Error', + errorMessage: 'Failed to load images. Please try again later.', + }, + ); + + // Handle fetching lifecycle + useEffect(() => { + if (!isSearchActive && isSuccess) { + const images = data?.data as Image[]; + dispatch(setImages(images)); + } + }, [data, isSuccess, dispatch, isSearchActive]); + + const favouriteImages = useMemo( + () => images.filter((image) => image.isFavourite === true), + [images], + ); + + const title = + isSearchActive && images.length > 0 + ? `Face Search Results (${images.length} found)` + : 'Favourite Image Gallery'; + + if (favouriteImages.length === 0) { + return ( +
+

{title}

+
+ {/* Heart Icon/Sticker */} +
+ +
+ + {/* Text Content */} +

+ No Favourite Images Yet +

+

+ Start building your collection by marking images as favourites. + Click the heart icon on any image to add it here. +

+
+
+ ); + } + + return ( +
+ {/* Gallery Section */} +
+ {favouriteImages.length > 0 ? ( + + ) : ( + + )} +
+ + {/* Timeline Scrollbar */} + {monthMarkers.length > 0 && ( + + )} + + {/* Media viewer modal */} +
+ ); +}; diff --git a/frontend/src/routes/AppRoutes.tsx b/frontend/src/routes/AppRoutes.tsx index e79a9cf90..22153edbb 100644 --- a/frontend/src/routes/AppRoutes.tsx +++ b/frontend/src/routes/AppRoutes.tsx @@ -5,6 +5,7 @@ import Layout from '@/layout/layout'; import { InitialSteps } from '@/pages/InitialSteps/InitialSteps'; import Settings from '@/pages/SettingsPage/Settings'; import { Home } from '@/pages/Home/Home'; +import { MyFav } from '@/pages/Home/MyFav'; import { AITagging } from '@/pages/AITagging/AITagging'; import { PersonImages } from '@/pages/PersonImages/PersonImages'; import { ComingSoon } from '@/pages/ComingSoon/ComingSoon'; @@ -16,6 +17,7 @@ export const AppRoutes: React.FC = () => { }> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/types/Media.ts b/frontend/src/types/Media.ts index b432f8391..d7e0712fc 100644 --- a/frontend/src/types/Media.ts +++ b/frontend/src/types/Media.ts @@ -17,6 +17,7 @@ export interface Image { folder_id: string; isTagged: boolean; metadata?: ImageMetadata; + isFavourite?: boolean; tags?: string[]; bboxes?: { x: number; y: number; width: number; height: number }[]; } diff --git a/utils/__pycache__/cache.cpython-313.pyc b/utils/__pycache__/cache.cpython-313.pyc new file mode 100644 index 000000000..da649d54f Binary files /dev/null and b/utils/__pycache__/cache.cpython-313.pyc differ