From afceceadc45d3f6a8e003493e2532958eea9afc2 Mon Sep 17 00:00:00 2001 From: SiddharthJiyani Date: Mon, 3 Nov 2025 18:57:23 +0530 Subject: [PATCH 01/11] feat: implement albums feature with create, edit, delete, and image management UI --- backend/app/database/albums.py | 76 ++- backend/app/routes/albums.py | 108 ++++- backend/app/schemas/album.py | 20 +- frontend/public/placeholder-album-light.svg | 21 + frontend/public/placeholder-album.svg | 21 + frontend/src/api/api-functions/albums.ts | 151 ++++++ frontend/src/api/api-functions/index.ts | 1 + frontend/src/api/apiEndpoints.ts | 15 + frontend/src/app/store.ts | 2 + .../Albums/AddImagesToAlbumDialog.tsx | 210 +++++++++ frontend/src/components/Albums/AlbumCard.tsx | 107 +++++ .../components/Albums/AlbumPasswordDialog.tsx | 98 ++++ .../components/Albums/CreateAlbumDialog.tsx | 228 +++++++++ .../components/Albums/DeleteConfirmDialog.tsx | 51 ++ .../src/components/Albums/EditAlbumDialog.tsx | 301 ++++++++++++ .../EmptyStates/EmptyAlbumsState.tsx | 28 ++ frontend/src/constants/routes.ts | 1 + frontend/src/features/albumSelectors.ts | 26 ++ frontend/src/features/albumsSlice.ts | 97 ++++ frontend/src/pages/Album/Album.tsx | 269 ++++++++++- frontend/src/pages/Album/AlbumDetail.tsx | 439 ++++++++++++++++++ frontend/src/routes/AppRoutes.tsx | 5 +- frontend/src/types/Album.ts | 124 ++--- 23 files changed, 2285 insertions(+), 114 deletions(-) create mode 100644 frontend/public/placeholder-album-light.svg create mode 100644 frontend/public/placeholder-album.svg create mode 100644 frontend/src/api/api-functions/albums.ts create mode 100644 frontend/src/components/Albums/AddImagesToAlbumDialog.tsx create mode 100644 frontend/src/components/Albums/AlbumCard.tsx create mode 100644 frontend/src/components/Albums/AlbumPasswordDialog.tsx create mode 100644 frontend/src/components/Albums/CreateAlbumDialog.tsx create mode 100644 frontend/src/components/Albums/DeleteConfirmDialog.tsx create mode 100644 frontend/src/components/Albums/EditAlbumDialog.tsx create mode 100644 frontend/src/components/EmptyStates/EmptyAlbumsState.tsx create mode 100644 frontend/src/features/albumSelectors.ts create mode 100644 frontend/src/features/albumsSlice.ts create mode 100644 frontend/src/pages/Album/AlbumDetail.tsx diff --git a/backend/app/database/albums.py b/backend/app/database/albums.py index 74a81f6dc..0681ce2c3 100644 --- a/backend/app/database/albums.py +++ b/backend/app/database/albums.py @@ -50,8 +50,9 @@ def db_create_albums_table() -> None: album_id TEXT PRIMARY KEY, album_name TEXT UNIQUE, description TEXT, - is_hidden BOOLEAN DEFAULT 0, - password_hash TEXT + is_locked BOOLEAN DEFAULT 0, + password_hash TEXT, + cover_image_path TEXT ) """ ) @@ -61,6 +62,26 @@ def db_create_albums_table() -> None: conn.close() +def db_migrate_add_cover_image_column() -> None: + """Add cover_image_path column to existing albums table if it doesn't exist""" + conn = None + try: + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + # Check if column exists + cursor.execute("PRAGMA table_info(albums)") + columns = [column[1] for column in cursor.fetchall()] + + if "cover_image_path" not in columns: + cursor.execute("ALTER TABLE albums ADD COLUMN cover_image_path TEXT") + conn.commit() + print("Added cover_image_path column to albums table") + finally: + if conn is not None: + conn.close() + + def db_create_album_images_table() -> None: conn = None try: @@ -83,14 +104,15 @@ def db_create_album_images_table() -> None: conn.close() -def db_get_all_albums(show_hidden: bool = False): +def db_get_all_albums(show_locked: bool = True): + """Get all albums. By default, returns all albums including locked ones.""" conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() try: - if show_hidden: - cursor.execute("SELECT * FROM albums") - else: - cursor.execute("SELECT * FROM albums WHERE is_hidden = 0") + # Always show all albums (locked and unlocked) + cursor.execute( + "SELECT album_id, album_name, description, is_locked, password_hash, cover_image_path FROM albums" + ) albums = cursor.fetchall() return albums finally: @@ -101,7 +123,10 @@ def db_get_album_by_name(name: str): conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() try: - cursor.execute("SELECT * FROM albums WHERE album_name = ?", (name,)) + cursor.execute( + "SELECT album_id, album_name, description, is_locked, password_hash, cover_image_path FROM albums WHERE album_name = ?", + (name,), + ) album = cursor.fetchone() return album if album else None finally: @@ -112,7 +137,10 @@ def db_get_album(album_id: str): conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() try: - cursor.execute("SELECT * FROM albums WHERE album_id = ?", (album_id,)) + cursor.execute( + "SELECT album_id, album_name, description, is_locked, password_hash, cover_image_path FROM albums WHERE album_id = ?", + (album_id,), + ) album = cursor.fetchone() return album if album else None finally: @@ -123,7 +151,7 @@ def db_insert_album( album_id: str, album_name: str, description: str = "", - is_hidden: bool = False, + is_locked: bool = False, password: str = None, ): conn = sqlite3.connect(DATABASE_PATH) @@ -136,10 +164,10 @@ def db_insert_album( ).decode("utf-8") cursor.execute( """ - INSERT INTO albums (album_id, album_name, description, is_hidden, password_hash) + INSERT INTO albums (album_id, album_name, description, is_locked, password_hash) VALUES (?, ?, ?, ?, ?) """, - (album_id, album_name, description, int(is_hidden), password_hash), + (album_id, album_name, description, int(is_locked), password_hash), ) conn.commit() finally: @@ -150,7 +178,7 @@ def db_update_album( album_id: str, album_name: str, description: str, - is_hidden: bool, + is_locked: bool, password: str = None, ): conn = sqlite3.connect(DATABASE_PATH) @@ -164,20 +192,20 @@ def db_update_album( cursor.execute( """ UPDATE albums - SET album_name = ?, description = ?, is_hidden = ?, password_hash = ? + SET album_name = ?, description = ?, is_locked = ?, password_hash = ? WHERE album_id = ? """, - (album_name, description, int(is_hidden), password_hash, album_id), + (album_name, description, int(is_locked), password_hash, album_id), ) else: # Update without changing password cursor.execute( """ UPDATE albums - SET album_name = ?, description = ?, is_hidden = ? + SET album_name = ?, description = ?, is_locked = ? WHERE album_id = ? """, - (album_name, description, int(is_hidden), album_id), + (album_name, description, int(is_locked), album_id), ) conn.commit() finally: @@ -190,6 +218,20 @@ def db_delete_album(album_id: str): cursor.execute("DELETE FROM albums WHERE album_id = ?", (album_id,)) +def db_update_album_cover_image(album_id: str, cover_image_path: str): + """Update the cover image path for an album""" + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + try: + cursor.execute( + "UPDATE albums SET cover_image_path = ? WHERE album_id = ?", + (cover_image_path, album_id), + ) + conn.commit() + finally: + conn.close() + + def db_get_album_images(album_id: str): conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() diff --git a/backend/app/routes/albums.py b/backend/app/routes/albums.py index ae0408613..0f95b569f 100644 --- a/backend/app/routes/albums.py +++ b/backend/app/routes/albums.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException, status, Query, Body, Path +from fastapi import APIRouter, HTTPException, status, Body, Path import uuid from app.schemas.album import ( GetAlbumsResponse, @@ -11,6 +11,7 @@ SuccessResponse, ErrorResponse, ImageIdsRequest, + SetCoverImageRequest, Album, ) from app.database.albums import ( @@ -24,24 +25,32 @@ db_add_images_to_album, db_remove_image_from_album, db_remove_images_from_album, + db_update_album_cover_image, verify_album_password, ) router = APIRouter() -# GET /albums/ - Get all albums +# GET /albums/ - Get all albums (including locked ones) @router.get("/", response_model=GetAlbumsResponse) -def get_albums(show_hidden: bool = Query(False)): - albums = db_get_all_albums(show_hidden) +def get_albums(): + """Get all albums. Always returns both locked and unlocked albums.""" + albums = db_get_all_albums() album_list = [] for album in albums: + # Get image count for each album + image_ids = db_get_album_images(album[0]) + image_count = len(image_ids) + album_list.append( Album( album_id=album[0], album_name=album[1], description=album[2] or "", - is_hidden=bool(album[3]), + is_locked=bool(album[3]), + cover_image_path=album[5] if len(album) > 5 else None, + image_count=image_count, ) ) return GetAlbumsResponse(success=True, albums=album_list) @@ -64,7 +73,7 @@ def create_album(body: CreateAlbumRequest): album_id = str(uuid.uuid4()) try: db_insert_album( - album_id, body.name, body.description, body.is_hidden, body.password + album_id, body.name, body.description, body.is_locked, body.password ) return CreateAlbumResponse(success=True, album_id=album_id) except Exception as e: @@ -91,11 +100,17 @@ def get_album(album_id: str = Path(...)): ) try: + # Get image count for the album + image_ids = db_get_album_images(album_id) + image_count = len(image_ids) + album_obj = Album( album_id=album[0], album_name=album[1], description=album[2] or "", - is_hidden=bool(album[3]), + is_locked=bool(album[3]), + cover_image_path=album[5] if len(album) > 5 else None, + image_count=image_count, ) return GetAlbumResponse(success=True, data=album_obj) except Exception as e: @@ -109,6 +124,8 @@ def get_album(album_id: str = Path(...)): ) +# PUT /albums/{album_id} - Update Album +@router.put("/{album_id}", response_model=SuccessResponse) # PUT /albums/{album_id} - Update Album @router.put("/{album_id}", response_model=SuccessResponse) def update_album(album_id: str = Path(...), body: UpdateAlbumRequest = Body(...)): @@ -127,11 +144,11 @@ def update_album(album_id: str = Path(...), body: UpdateAlbumRequest = Body(...) "album_id": album[0], "album_name": album[1], "description": album[2], - "is_hidden": bool(album[3]), + "is_locked": bool(album[3]), "password_hash": album[4], } - if album_dict["password_hash"]: + if album_dict["is_locked"]: if not body.current_password: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -154,7 +171,7 @@ def update_album(album_id: str = Path(...), body: UpdateAlbumRequest = Body(...) try: db_update_album( - album_id, body.name, body.description, body.is_hidden, body.password + album_id, body.name, body.description, body.is_locked, body.password ) return SuccessResponse(success=True, msg="Album updated successfully") except Exception as e: @@ -215,18 +232,18 @@ def get_album_images( "album_id": album[0], "album_name": album[1], "description": album[2], - "is_hidden": bool(album[3]), + "is_locked": bool(album[3]), "password_hash": album[4], } - if album_dict["is_hidden"]: + if album_dict["is_locked"]: if not body.password: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorResponse( success=False, error="Password Required", - message="Password is required to access this hidden album.", + message="Password is required to access this locked album.", ).model_dump(), ) if not verify_album_password(album_id, body.password): @@ -355,3 +372,68 @@ def remove_images_from_album( success=False, error="Failed to Remove Images", message=str(e) ).model_dump(), ) + + +# PUT /albums/{album_id}/cover - Set album cover image +@router.put("/{album_id}/cover", response_model=SuccessResponse) +def set_album_cover_image( + album_id: str = Path(...), body: SetCoverImageRequest = Body(...) +): + """Set or update the cover image for an album""" + album = db_get_album(album_id) + if not album: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ErrorResponse( + success=False, + error="Album Not Found", + message="No album exists with the provided ID.", + ).model_dump(), + ) + + # Verify the image exists in the album + album_image_ids = db_get_album_images(album_id) + if body.image_id not in album_image_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ErrorResponse( + success=False, + error="Image Not In Album", + message="The specified image is not in this album.", + ).model_dump(), + ) + + try: + # Get the image path from the database + import sqlite3 + from app.config.settings import DATABASE_PATH + + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + cursor.execute("SELECT path FROM images WHERE id = ?", (body.image_id,)) + result = cursor.fetchone() + conn.close() + + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ErrorResponse( + success=False, + error="Image Not Found", + message="The specified image does not exist.", + ).model_dump(), + ) + + image_path = result[0] + db_update_album_cover_image(album_id, image_path) + + return SuccessResponse( + success=True, msg="Album cover image updated successfully" + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, error="Failed to Set Cover Image", message=str(e) + ).model_dump(), + ) diff --git a/backend/app/schemas/album.py b/backend/app/schemas/album.py index cae98e650..96db62aa3 100644 --- a/backend/app/schemas/album.py +++ b/backend/app/schemas/album.py @@ -7,7 +7,9 @@ class Album(BaseModel): album_id: str album_name: str description: str - is_hidden: bool + is_locked: bool + cover_image_path: Optional[str] = None + image_count: int = 0 # ############################## @@ -18,27 +20,27 @@ class Album(BaseModel): class CreateAlbumRequest(BaseModel): name: str = Field(..., min_length=1) description: Optional[str] = "" - is_hidden: bool = False + is_locked: bool = False password: Optional[str] = None @field_validator("password") def check_password(cls, value, info: ValidationInfo): - if info.data.get("is_hidden") and not value: - raise ValueError("Password is required for hidden albums") + if info.data.get("is_locked") and not value: + raise ValueError("Password is required for locked albums") return value class UpdateAlbumRequest(BaseModel): name: str description: Optional[str] = "" - is_hidden: bool + is_locked: bool current_password: Optional[str] = None password: Optional[str] = None @field_validator("password") def check_password(cls, value, info: ValidationInfo): - if info.data.get("is_hidden") and not value: - raise ValueError("Password is required for hidden albums") + if info.data.get("is_locked") and not value: + raise ValueError("Password is required for locked albums") return value @@ -50,6 +52,10 @@ class ImageIdsRequest(BaseModel): image_ids: List[str] +class SetCoverImageRequest(BaseModel): + image_id: str = Field(..., min_length=1) + + # ############################## # Response Handler # ############################## diff --git a/frontend/public/placeholder-album-light.svg b/frontend/public/placeholder-album-light.svg new file mode 100644 index 000000000..0337857a6 --- /dev/null +++ b/frontend/public/placeholder-album-light.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + Album Image + + diff --git a/frontend/public/placeholder-album.svg b/frontend/public/placeholder-album.svg new file mode 100644 index 000000000..11d2a14ee --- /dev/null +++ b/frontend/public/placeholder-album.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + Album Image + + diff --git a/frontend/src/api/api-functions/albums.ts b/frontend/src/api/api-functions/albums.ts new file mode 100644 index 000000000..166bf241f --- /dev/null +++ b/frontend/src/api/api-functions/albums.ts @@ -0,0 +1,151 @@ +import { albumsEndpoints } from '../apiEndpoints'; +import { apiClient } from '../axiosConfig'; +import { APIResponse } from '@/types/API'; +import { + CreateAlbumRequest, + UpdateAlbumRequest, + AddImagesToAlbumRequest, + GetAlbumImagesRequest, + RemoveImagesFromAlbumRequest, +} from '@/types/Album'; + +/** + * Get all albums + */ +export const getAllAlbums = async (): Promise => { + const response = await apiClient.get( + albumsEndpoints.getAllAlbums, + ); + return response.data; +}; + +/** + * Get album by ID + * @param albumId - Album UUID + */ +export const getAlbumById = async (albumId: string): Promise => { + const response = await apiClient.get( + albumsEndpoints.getAlbumById(albumId), + ); + return response.data; +}; + +/** + * Create a new album + * @param data - Album creation data + */ +export const createAlbum = async ( + data: CreateAlbumRequest, +): Promise => { + const response = await apiClient.post( + albumsEndpoints.createAlbum, + data, + ); + return response.data; +}; + +/** + * Update an existing album + * @param albumId - Album UUID + * @param data - Album update data + */ +export const updateAlbum = async ( + albumId: string, + data: UpdateAlbumRequest, +): Promise => { + const response = await apiClient.put( + albumsEndpoints.updateAlbum(albumId), + data, + ); + return response.data; +}; + +/** + * Delete an album + * @param albumId - Album UUID + */ +export const deleteAlbum = async (albumId: string): Promise => { + const response = await apiClient.delete( + albumsEndpoints.deleteAlbum(albumId), + ); + return response.data; +}; + +/** + * Add images to an album + * @param albumId - Album UUID + * @param data - Image IDs to add + */ +export const addImagesToAlbum = async ( + albumId: string, + data: AddImagesToAlbumRequest, +): Promise => { + const response = await apiClient.post( + albumsEndpoints.addImagesToAlbum(albumId), + data, + ); + return response.data; +}; + +/** + * Get all images in an album + * @param albumId - Album UUID + * @param data - Optional password for locked albums + */ +export const getAlbumImages = async ( + albumId: string, + data?: GetAlbumImagesRequest, +): Promise => { + const response = await apiClient.post( + albumsEndpoints.getAlbumImages(albumId), + data || {}, + ); + return response.data; +}; + +/** + * Remove a single image from an album + * @param albumId - Album UUID + * @param imageId - Image UUID + */ +export const removeImageFromAlbum = async ( + albumId: string, + imageId: string, +): Promise => { + const response = await apiClient.delete( + albumsEndpoints.removeImageFromAlbum(albumId, imageId), + ); + return response.data; +}; + +/** + * Remove multiple images from an album + * @param albumId - Album UUID + * @param data - Image IDs to remove + */ +export const removeMultipleImagesFromAlbum = async ( + albumId: string, + data: RemoveImagesFromAlbumRequest, +): Promise => { + const response = await apiClient.delete( + albumsEndpoints.removeMultipleImagesFromAlbum(albumId), + { data }, + ); + return response.data; +}; + +/** + * Set or update the cover image for an album + * @param albumId - Album UUID + * @param imageId - Image UUID to set as cover + */ +export const setAlbumCoverImage = async ( + albumId: string, + imageId: string, +): Promise => { + const response = await apiClient.put( + albumsEndpoints.setAlbumCoverImage(albumId), + { image_id: imageId }, + ); + return response.data; +}; diff --git a/frontend/src/api/api-functions/index.ts b/frontend/src/api/api-functions/index.ts index 5d6f2fa8c..f984bb61b 100644 --- a/frontend/src/api/api-functions/index.ts +++ b/frontend/src/api/api-functions/index.ts @@ -4,3 +4,4 @@ export * from './images'; export * from './folders'; export * from './user_preferences'; export * from './health'; +export * from './albums'; diff --git a/frontend/src/api/apiEndpoints.ts b/frontend/src/api/apiEndpoints.ts index e4cfd816e..1697b165a 100644 --- a/frontend/src/api/apiEndpoints.ts +++ b/frontend/src/api/apiEndpoints.ts @@ -29,3 +29,18 @@ export const userPreferencesEndpoints = { export const healthEndpoints = { healthCheck: '/health', }; + +export const albumsEndpoints = { + getAllAlbums: '/albums/', + getAlbumById: (albumId: string) => `/albums/${albumId}`, + createAlbum: '/albums/', + updateAlbum: (albumId: string) => `/albums/${albumId}`, + deleteAlbum: (albumId: string) => `/albums/${albumId}`, + addImagesToAlbum: (albumId: string) => `/albums/${albumId}/images`, + getAlbumImages: (albumId: string) => `/albums/${albumId}/images/get`, + removeImageFromAlbum: (albumId: string, imageId: string) => + `/albums/${albumId}/images/${imageId}`, + removeMultipleImagesFromAlbum: (albumId: string) => + `/albums/${albumId}/images`, + setAlbumCoverImage: (albumId: string) => `/albums/${albumId}/cover`, +}; diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts index 7252274a6..7976ba20b 100644 --- a/frontend/src/app/store.ts +++ b/frontend/src/app/store.ts @@ -6,6 +6,7 @@ import imageReducer from '@/features/imageSlice'; import faceClustersReducer from '@/features/faceClustersSlice'; import infoDialogReducer from '@/features/infoDialogSlice'; import folderReducer from '@/features/folderSlice'; +import albumsReducer from '@/features/albumsSlice'; export const store = configureStore({ reducer: { @@ -16,6 +17,7 @@ export const store = configureStore({ infoDialog: infoDialogReducer, folders: folderReducer, search: searchReducer, + albums: albumsReducer, }, }); // Infer the `RootState` and `AppDispatch` types from the store itself diff --git a/frontend/src/components/Albums/AddImagesToAlbumDialog.tsx b/frontend/src/components/Albums/AddImagesToAlbumDialog.tsx new file mode 100644 index 000000000..0fee106a8 --- /dev/null +++ b/frontend/src/components/Albums/AddImagesToAlbumDialog.tsx @@ -0,0 +1,210 @@ +import React, { useState, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { AddImagesToAlbumDialogProps } from '@/types/Album'; +import { usePictoMutation, usePictoQuery } from '@/hooks/useQueryExtension'; +import { addImagesToAlbum, fetchAllImages } from '@/api/api-functions'; +import { showInfoDialog } from '@/features/infoDialogSlice'; +import { hideLoader, showLoader } from '@/features/loaderSlice'; +import { Image } from '@/types/Media'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { Search, Check } from 'lucide-react'; + +export const AddImagesToAlbumDialog: React.FC = ({ + isOpen, + onClose, + albumId, + albumName, +}) => { + const dispatch = useDispatch(); + const [selectedImages, setSelectedImages] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(''); + const [allImages, setAllImages] = useState([]); + + const { data: imagesData, isLoading } = usePictoQuery({ + queryKey: ['images'], + queryFn: () => fetchAllImages(), + enabled: isOpen, + }); + + useEffect(() => { + if (imagesData?.data) { + setAllImages(imagesData.data as Image[]); + } + }, [imagesData]); + + const { mutate: addImagesMutate, isPending } = usePictoMutation({ + mutationFn: (data: { image_ids: string[] }) => + addImagesToAlbum(albumId, data), + onSuccess: () => { + dispatch(hideLoader()); + dispatch( + showInfoDialog({ + title: 'Success', + message: `Added ${selectedImages.size} image${selectedImages.size > 1 ? 's' : ''} to album!`, + variant: 'info', + }), + ); + handleClose(); + }, + onError: (error: any) => { + dispatch(hideLoader()); + dispatch( + showInfoDialog({ + title: 'Error', + message: error?.message || 'Failed to add images. Please try again.', + variant: 'error', + }), + ); + }, + }); + + const handleImageToggle = (imageId: string) => { + const newSelected = new Set(selectedImages); + if (newSelected.has(imageId)) { + newSelected.delete(imageId); + } else { + newSelected.add(imageId); + } + setSelectedImages(newSelected); + }; + + const handleSubmit = () => { + if (selectedImages.size === 0) { + dispatch( + showInfoDialog({ + title: 'No Images Selected', + message: 'Please select at least one image to add to the album.', + variant: 'info', + }), + ); + return; + } + + dispatch(showLoader('Adding images to album...')); + addImagesMutate({ image_ids: Array.from(selectedImages) }); + }; + + const handleClose = () => { + setSelectedImages(new Set()); + setSearchQuery(''); + onClose(); + }; + + const filteredImages = allImages.filter((image) => { + const searchLower = searchQuery.toLowerCase(); + const fileName = image.path.split('/').pop()?.toLowerCase() || ''; + return fileName.includes(searchLower); + }); + + return ( + + + + Add Images to "{albumName}" + + Select images to add to this album. You can select multiple images. + + +
+ {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ {/* Image Grid */} + + {isLoading ? ( +
+

Loading images...

+
+ ) : filteredImages.length === 0 ? ( +
+

+ {searchQuery + ? 'No images found matching your search' + : 'No images available'} +

+
+ ) : ( +
+ {filteredImages.map((image) => ( +
handleImageToggle(image.id)} + > + { + const img = e.target as HTMLImageElement; + img.onerror = null; + img.src = '/placeholder.svg'; + }} + /> + {selectedImages.has(image.id) && ( +
+
+ +
+
+ )} +
+ ))} +
+ )} +
+
+ {/* Selected Count */} + {selectedImages.size > 0 && ( +
+

+ {selectedImages.size} image{selectedImages.size > 1 ? 's' : ''}{' '} + selected +

+
+ )} + + + + +
+
+ ); +}; diff --git a/frontend/src/components/Albums/AlbumCard.tsx b/frontend/src/components/Albums/AlbumCard.tsx new file mode 100644 index 000000000..7dfa336cd --- /dev/null +++ b/frontend/src/components/Albums/AlbumCard.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { MoreVertical, Lock, Pencil, Trash2 } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import { AlbumCardProps } from '@/types/Album'; +import { useTheme } from '@/contexts/ThemeContext'; +import { convertFileSrc } from '@tauri-apps/api/core'; + +export const AlbumCard: React.FC = ({ + album, + onClick, + onEdit, + onDelete, +}) => { + const handleMenuAction = (e: React.MouseEvent, action: () => void): void => { + e.stopPropagation(); + action(); + }; + + const { theme } = useTheme(); + + const isLightMode = theme === 'light'; + + const placeholderSrc = isLightMode + ? '/placeholder-album-light.svg' + : '/placeholder-album.svg'; + + const coverImageSrc = album.cover_image_path + ? convertFileSrc(album.cover_image_path) + : placeholderSrc; + + return ( + + + {/* Album Cover Image */} +
+ {album.name} { + const img = e.target as HTMLImageElement; + img.onerror = null; + img.src = placeholderSrc; + }} + /> + {/* Lock Icon for Locked Albums */} + {album.is_locked && ( +
+ +
+ )} + {/* Actions Menu */} +
+ + + + + + handleMenuAction(e, onEdit)}> + + Edit Album + + handleMenuAction(e, onDelete)} + className="text-destructive focus:text-destructive" + > + + Delete Album + + + +
+
+ {/* Album Info */} +
+

{album.name}

+ {album.description && ( +

+ {album.description} +

+ )} + +

+ {album.image_count} {album.image_count === 1 ? 'photo' : 'photos'} +

+
+
+
+ ); +}; diff --git a/frontend/src/components/Albums/AlbumPasswordDialog.tsx b/frontend/src/components/Albums/AlbumPasswordDialog.tsx new file mode 100644 index 000000000..ca3b111e7 --- /dev/null +++ b/frontend/src/components/Albums/AlbumPasswordDialog.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { PasswordPromptDialogProps } from '@/types/Album'; +import { Lock, Eye, EyeOff } from 'lucide-react'; + +export const AlbumPasswordDialog: React.FC = ({ + isOpen, + onClose, + onSubmit, + albumName, +}) => { + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [showPassword, setShowPassword] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!password.trim()) { + setError('Password is required'); + return; + } + onSubmit(password); + handleClose(); + }; + + const handleClose = () => { + setPassword(''); + setError(''); + onClose(); + }; + + return ( + + +
+ +
+ +
+ + This Album is Protected + + + Enter the password to access "{albumName}" + +
+
+
+ +
+ { + setPassword(e.target.value); + setError(''); + }} + className={error ? 'border-destructive' : ''} + autoFocus + /> + +
+ {error &&

{error}

} +
+
+ + + + +
+
+
+ ); +}; diff --git a/frontend/src/components/Albums/CreateAlbumDialog.tsx b/frontend/src/components/Albums/CreateAlbumDialog.tsx new file mode 100644 index 000000000..feb473a15 --- /dev/null +++ b/frontend/src/components/Albums/CreateAlbumDialog.tsx @@ -0,0 +1,228 @@ +import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { CreateAlbumDialogProps } from '@/types/Album'; +import { usePictoMutation } from '@/hooks/useQueryExtension'; +import { createAlbum } from '@/api/api-functions'; +import { showInfoDialog } from '@/features/infoDialogSlice'; +import { hideLoader, showLoader } from '@/features/loaderSlice'; + +export const CreateAlbumDialog: React.FC = ({ + isOpen, + onClose, + onSuccess, +}) => { + const dispatch = useDispatch(); + const [formData, setFormData] = useState({ + name: '', + description: '', + is_locked: false, + password: '', + }); + const [errors, setErrors] = useState>({}); + + const { mutate: createAlbumMutate, isPending } = usePictoMutation({ + mutationFn: createAlbum, + onSuccess: () => { + dispatch(hideLoader()); + dispatch( + showInfoDialog({ + title: 'Success', + message: 'Album created successfully!', + variant: 'info', + }), + ); + + if (onSuccess) { + onSuccess(); + } + + handleClose(); + }, + onError: (error: any) => { + dispatch(hideLoader()); + + let errorMessage = 'Failed to create album. Please try again.'; + + if (error?.response?.data?.detail?.message) { + errorMessage = error.response.data.detail.message; + } else if (error?.message) { + errorMessage = error.message; + } + + dispatch( + showInfoDialog({ + title: 'Error', + message: errorMessage, + variant: 'error', + }), + ); + }, + }); + + const validateForm = (): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = 'Album name is required'; + } + + if (formData.is_locked && !formData.password.trim()) { + newErrors.password = 'Password is required for locked albums'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + dispatch(showLoader('Creating album...')); + + const requestData = { + name: formData.name.trim(), + ...(formData.description.trim() && { + description: formData.description.trim(), + }), + is_locked: formData.is_locked ? 1 : 0, + ...(formData.is_locked && { password: formData.password }), + }; + + createAlbumMutate(requestData); + }; + + const handleClose = () => { + setFormData({ + name: '', + description: '', + is_locked: false, + password: '', + }); + setErrors({}); + onClose(); + }; + + return ( + + +
+ + Create New Album + + Create a new album to organize your photos and videos. + + + +
+ {/* Album Name */} +
+ + + setFormData({ ...formData, name: e.target.value }) + } + className={errors.name ? 'border-destructive' : ''} + /> + {errors.name && ( +

{errors.name}

+ )} +
+ + {/* Album Description */} +
+ +