diff --git a/.hintrc b/.hintrc new file mode 100644 index 000000000..4b404dec7 --- /dev/null +++ b/.hintrc @@ -0,0 +1,8 @@ +{ + "connector": { + "name": "local" + }, + "hints": { + "no-inline-styles": "off" + } +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..6e4b50ada --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" + ], + "unwantedRecommendations": [ + "ms-edgedevtools.vscode-edge-devtools" + ] +} diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index d57fbde75..4efb48fd3 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -16,6 +16,18 @@ "no-warning-comments": [ "error", { "terms": ["todo", "fixme"], "location": "anywhere" } + ], + // Allow style prop for CSS variables, but keep dangerouslySetInnerHTML forbidden + "react/forbid-dom-props": [ + "error", + { + "forbid": [ + { + "propName": "dangerouslySetInnerHTML", + "message": "Use safe alternatives instead of dangerouslySetInnerHTML" + } + ] + } ] } } diff --git a/frontend/.hintrc b/frontend/.hintrc new file mode 100644 index 000000000..4b404dec7 --- /dev/null +++ b/frontend/.hintrc @@ -0,0 +1,8 @@ +{ + "connector": { + "name": "local" + }, + "hints": { + "no-inline-styles": "off" + } +} diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 000000000..3d24979e6 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "css.lint.validProperties": [ + "--sidebar-width", + "--sidebar-width-icon" + ], + "css.validate": false, + "webhint.enable": false, + "edge-devtools-network.enable": false +} diff --git a/frontend/TYPESCRIPT_SETUP.md b/frontend/TYPESCRIPT_SETUP.md new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 47073989b..a925bf665 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "PictoPy", + "name": "pictopy-frontend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "PictoPy", + "name": "pictopy-frontend", "version": "1.0.0", "dependencies": { "@radix-ui/react-aspect-ratio": "^1.1.7", diff --git a/frontend/package.json b/frontend/package.json index b9a7f6ac3..5fa34d462 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "PictoPy", + "name": "pictopy-frontend", "private": true, "version": "1.0.0", "type": "module", @@ -14,7 +14,8 @@ "lint:check": "eslint --max-warnings 0 --config .eslintrc.json .", "lint:fix": "eslint --max-warnings 0 --config .eslintrc.json . --fix", "format:fix": "prettier --write \"**/*.{ts,tsx,json}\"", - "format:check": "prettier --check \"**/*.{ts,tsx,json}\"" + "format:check": "prettier --check \"**/*.{ts,tsx,json}\"", + "type-check": "tsc --noEmit" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ diff --git a/frontend/src/api/api-functions/albums.ts b/frontend/src/api/api-functions/albums.ts new file mode 100644 index 000000000..757b95a95 --- /dev/null +++ b/frontend/src/api/api-functions/albums.ts @@ -0,0 +1,154 @@ +import { APIResponse } from '@/types/API'; +import { albumsEndpoints } from '../apiEndpoints'; +import { apiClient } from '../axiosConfig'; + +export interface Album { + album_id: string; + album_name: string; + description: string; + is_hidden: boolean; +} + +export interface CreateAlbumRequest { + name: string; + description?: string; + is_hidden: boolean; + password?: string; +} + +export interface UpdateAlbumRequest { + name: string; + description?: string; + is_hidden: boolean; + current_password?: string; + password?: string; +} + +export interface GetAlbumImagesRequest { + password?: string; +} + +export interface ImageIdsRequest { + image_ids: string[]; +} + +export interface GetAlbumsResponse extends APIResponse { + albums: Album[]; +} + +export interface CreateAlbumResponse extends APIResponse { + album_id: string; +} + +export interface GetAlbumResponse extends APIResponse { + data: Album; +} + +export interface GetAlbumImagesResponse extends APIResponse { + image_ids: string[]; +} + +// Get all albums +export const fetchAllAlbums = async ( + showHidden = false, +): Promise => { + const response = await apiClient.get( + `${albumsEndpoints.getAllAlbums}?show_hidden=${showHidden}`, + ); + return response.data; +}; + +// Create a new album +export const createAlbum = async ( + albumData: CreateAlbumRequest, +): Promise => { + const response = await apiClient.post( + albumsEndpoints.createAlbum, + albumData, + ); + return response.data; +}; + +// Get specific album details +export const fetchAlbum = async ( + albumId: string, +): Promise => { + const response = await apiClient.get( + albumsEndpoints.getAlbum(albumId), + ); + return response.data; +}; + +// Update album +export const updateAlbum = async ( + albumId: string, + albumData: UpdateAlbumRequest, +): Promise => { + const response = await apiClient.put( + albumsEndpoints.updateAlbum(albumId), + albumData, + ); + return response.data; +}; + +// Delete album +export const deleteAlbum = async (albumId: string): Promise => { + const response = await apiClient.delete( + albumsEndpoints.deleteAlbum(albumId), + ); + return response.data; +}; + +// Get album images +export const fetchAlbumImages = async ( + albumId: string, + password?: string, +): Promise => { + const requestBody: GetAlbumImagesRequest = {}; + if (password) { + requestBody.password = password; + } + + const response = await apiClient.post( + albumsEndpoints.getAlbumImages(albumId), + requestBody, + ); + return response.data; +}; + +// Add images to album +export const addImagesToAlbum = async ( + albumId: string, + imageIds: string[], +): Promise => { + const requestBody: ImageIdsRequest = { image_ids: imageIds }; + const response = await apiClient.post( + albumsEndpoints.addImagesToAlbum(albumId), + requestBody, + ); + return response.data; +}; + +// Remove image from album +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 album +export const removeImagesFromAlbum = async ( + albumId: string, + imageIds: string[], +): Promise => { + const requestBody: ImageIdsRequest = { image_ids: imageIds }; + const response = await apiClient.delete( + albumsEndpoints.removeImagesFromAlbum(albumId), + { data: requestBody }, + ); + return response.data; +}; diff --git a/frontend/src/api/api-functions/index.ts b/frontend/src/api/api-functions/index.ts index 5d6f2fa8c..9d22fae87 100644 --- a/frontend/src/api/api-functions/index.ts +++ b/frontend/src/api/api-functions/index.ts @@ -1,6 +1,7 @@ // Export all API functions +export * from './albums'; export * from './face_clusters'; -export * from './images'; export * from './folders'; -export * from './user_preferences'; export * from './health'; +export * from './images'; +export * from './user_preferences'; diff --git a/frontend/src/api/apiEndpoints.ts b/frontend/src/api/apiEndpoints.ts index 805fc2010..d92e99a8c 100644 --- a/frontend/src/api/apiEndpoints.ts +++ b/frontend/src/api/apiEndpoints.ts @@ -23,6 +23,19 @@ export const userPreferencesEndpoints = { updateUserPreferences: '/user-preferences/', }; +export const albumsEndpoints = { + getAllAlbums: '/albums/', + createAlbum: '/albums/', + getAlbum: (albumId: string) => `/albums/${albumId}`, + updateAlbum: (albumId: string) => `/albums/${albumId}`, + deleteAlbum: (albumId: string) => `/albums/${albumId}`, + getAlbumImages: (albumId: string) => `/albums/${albumId}/images/get`, + addImagesToAlbum: (albumId: string) => `/albums/${albumId}/images`, + removeImageFromAlbum: (albumId: string, imageId: string) => + `/albums/${albumId}/images/${imageId}`, + removeImagesFromAlbum: (albumId: string) => `/albums/${albumId}/images`, +}; + export const healthEndpoints = { healthCheck: '/health', }; diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts index 7252274a6..2e44652fe 100644 --- a/frontend/src/app/store.ts +++ b/frontend/src/app/store.ts @@ -1,11 +1,12 @@ -import { configureStore } from '@reduxjs/toolkit'; +import albumReducer from '@/features/albumSlice'; +import faceClustersReducer from '@/features/faceClustersSlice'; +import folderReducer from '@/features/folderSlice'; +import imageReducer from '@/features/imageSlice'; +import infoDialogReducer from '@/features/infoDialogSlice'; import loaderReducer from '@/features/loaderSlice'; import onboardingReducer from '@/features/onboardingSlice'; import searchReducer from '@/features/searchSlice'; -import imageReducer from '@/features/imageSlice'; -import faceClustersReducer from '@/features/faceClustersSlice'; -import infoDialogReducer from '@/features/infoDialogSlice'; -import folderReducer from '@/features/folderSlice'; +import { configureStore } from '@reduxjs/toolkit'; export const store = configureStore({ reducer: { @@ -16,6 +17,7 @@ export const store = configureStore({ infoDialog: infoDialogReducer, folders: folderReducer, search: searchReducer, + albums: albumReducer, }, }); // Infer the `RootState` and `AppDispatch` types from the store itself diff --git a/frontend/src/components/Album/AddToAlbumDialog.tsx b/frontend/src/components/Album/AddToAlbumDialog.tsx new file mode 100644 index 000000000..c1dad8dea --- /dev/null +++ b/frontend/src/components/Album/AddToAlbumDialog.tsx @@ -0,0 +1,253 @@ +import { addImagesToAlbum, fetchAllAlbums } from '@/api/api-functions/albums'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { showInfoDialog } from '@/features/infoDialogSlice'; +import { useMutationFeedback } from '@/hooks/useMutationFeedback'; +import { usePictoMutation, usePictoQuery } from '@/hooks/useQueryExtension'; +import { Eye, EyeOff, Lock, Search } from 'lucide-react'; +import { ChangeEvent, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; + +interface AddToAlbumDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedImageIds: string[]; + onImagesAdded?: () => void; +} + +export function AddToAlbumDialog({ + open, + onOpenChange, + selectedImageIds, + onImagesAdded, +}: AddToAlbumDialogProps) { + const dispatch = useDispatch(); + const [localAlbums, setLocalAlbums] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedAlbumId, setSelectedAlbumId] = useState(null); + const [showHidden, setShowHidden] = useState(false); + + // Fetch albums + const { + data: albumsData, + isLoading, + isSuccess, + } = usePictoQuery({ + queryKey: ['albums', showHidden], + queryFn: () => fetchAllAlbums(showHidden), + enabled: open, + }); + + // Add images to album mutation + const addToAlbumMutation = usePictoMutation({ + mutationFn: ({ + albumId, + imageIds, + }: { + albumId: string; + imageIds: string[]; + }) => addImagesToAlbum(albumId, imageIds), + onSuccess: (data: any) => { + if (data.success) { + // Resolve album name before any state mutations + const selectedAlbum = localAlbums.find( + (album: any) => album.album_id === selectedAlbumId, + ); + const albumName = selectedAlbum?.album_name ?? 'selected album'; + + dispatch( + showInfoDialog({ + title: 'Success', + message: `Added ${selectedImageIds.length} photo${ + selectedImageIds.length > 1 ? 's' : '' + } to "${albumName}"`, + variant: 'info', + }), + ); + + onImagesAdded?.(); + handleClose(); + } + }, + onError: () => { + dispatch( + showInfoDialog({ + title: 'Error', + message: 'Failed to add photos to album. Please try again.', + variant: 'error', + }), + ); + }, + }); + + // Use mutation feedback + useMutationFeedback(addToAlbumMutation, { + loadingMessage: 'Adding photos to album...', + showSuccess: false, // We handle success manually + showError: false, // We handle errors manually + errorTitle: 'Failed to Add Photos', + }); + + // Update local state when albums are fetched + useEffect(() => { + if (isSuccess && albumsData?.success) { + setLocalAlbums(albumsData.albums); + } + }, [isSuccess, albumsData]); + + // Filter albums based on search term + const filteredAlbums = localAlbums.filter( + (album: any) => + album.album_name.toLowerCase().includes(searchTerm.toLowerCase()) || + album.description.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + const handleClose = () => { + setSelectedAlbumId(null); + setSearchTerm(''); + onOpenChange(false); + }; + + const handleAddToAlbum = () => { + if (!selectedAlbumId) return; + + addToAlbumMutation.mutate({ + albumId: selectedAlbumId, + imageIds: selectedImageIds, + }); + }; + + const handleToggleShowHidden = () => { + setShowHidden(!showHidden); + }; + + return ( + + + + Add to Album + + Add {selectedImageIds.length} selected photo + {selectedImageIds.length > 1 ? 's' : ''} to an existing album. + + + +
+ {/* Search and filters */} +
+
+ + ) => + setSearchTerm(e.target.value) + } + className="pl-9" + /> +
+ +
+ +
+
+ + {/* Albums list */} +
+ + +
+ {isLoading ? ( +
+

Loading albums...

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

+ {searchTerm + ? 'No albums found matching your search.' + : 'No albums available.'} +

+
+ ) : ( + filteredAlbums.map((album: any) => ( + setSelectedAlbumId(album.album_id)} + > + +
+
+
+

+ {album.album_name} +

+ {album.is_hidden && ( + + )} +
+ {album.description && ( +

+ {album.description} +

+ )} +
+ {album.is_hidden && ( + + Hidden + + )} +
+
+
+ )) + )} +
+
+
+
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/Album/AlbumDetail.tsx b/frontend/src/components/Album/AlbumDetail.tsx new file mode 100644 index 000000000..78cc58d59 --- /dev/null +++ b/frontend/src/components/Album/AlbumDetail.tsx @@ -0,0 +1,515 @@ +import { DeleteAlbumDialog } from '@/components/Album/DeleteAlbumDialog'; +import { EditAlbumDialog } from '@/components/Album/EditAlbumDialog'; +import { ImageCard } from '@/components/Media/ImageCard'; +import { MediaView } from '@/components/Media/MediaView'; +import { SelectionToolbar } from '@/components/Media/SelectionToolbar'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { usePictoMutation, usePictoQuery } from '@/hooks/useQueryExtension'; +import { ChangeEvent, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { + Album as AlbumType, + fetchAlbum, + fetchAlbumImages, + removeImagesFromAlbum, +} from '@/api/api-functions/albums'; +import { fetchAllImages } from '@/api/api-functions/images'; +import { + selectIsSelectionMode, + selectSelectedImageIds, +} from '@/features/albumSelectors'; +import { + clearSelectedImages, + enableSelectionMode, + setSelectedAlbum, +} from '@/features/albumSlice'; +import { selectIsImageViewOpen } from '@/features/imageSelectors'; +import { setImages } from '@/features/imageSlice'; +import { showInfoDialog } from '@/features/infoDialogSlice'; +import { hideLoader, showLoader } from '@/features/loaderSlice'; +import { useMutationFeedback } from '@/hooks/useMutationFeedback'; +import { Image } from '@/types/Media'; +import { + ArrowLeft, + CheckSquare, + Edit3, + Lock, + MoreHorizontal, + Plus, + Trash2, +} from 'lucide-react'; + +export function AlbumDetail() { + const { albumId } = useParams<{ albumId: string }>(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const [album, setAlbum] = useState(null); + const [albumImages, setAlbumImages] = useState([]); + const [password, setPassword] = useState(''); + const [showPasswordDialog, setShowPasswordDialog] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const isSelectionMode = useSelector(selectIsSelectionMode); + const selectedImageIds = useSelector(selectSelectedImageIds); + const isImageViewOpen = useSelector(selectIsImageViewOpen); + + // Mutation for removing images from album + const removeImagesMutation = usePictoMutation({ + mutationFn: ({ + albumId, + imageIds, + }: { + albumId: string; + imageIds: string[]; + }) => removeImagesFromAlbum(albumId, imageIds), + onSuccess: (data: any) => { + if (data.success) { + dispatch( + showInfoDialog({ + title: 'Success', + message: `Removed ${selectedImageIds.length} photo${ + selectedImageIds.length > 1 ? 's' : '' + } from album`, + variant: 'info', + }), + ); + + // Update local state by filtering out removed images + const updatedImages = albumImages.filter( + (img) => !selectedImageIds.includes(img.id), + ); + setAlbumImages(updatedImages); + dispatch(setImages(updatedImages)); + + // Clear selection + dispatch(clearSelectedImages()); + } + }, + onError: () => { + dispatch( + showInfoDialog({ + title: 'Error', + message: 'Failed to remove photos from album. Please try again.', + variant: 'error', + }), + ); + }, + }); + + // Use mutation feedback for loading states + useMutationFeedback(removeImagesMutation, { + loadingMessage: 'Removing photos from album...', + showSuccess: false, // We handle success manually + errorTitle: 'Failed to Remove Photos', + }); + + // Fetch album details + const { + data: albumData, + isLoading: isAlbumLoading, + isSuccess: isAlbumSuccess, + isError: isAlbumError, + } = usePictoQuery({ + queryKey: ['album', albumId], + queryFn: () => + albumId ? fetchAlbum(albumId) : Promise.reject('No album ID'), + enabled: !!albumId, + }); + + // Fetch album images + const { + data: imageIdsData, + isLoading: isImagesLoading, + isSuccess: isImagesSuccess, + isError: isImagesError, + } = usePictoQuery({ + queryKey: ['album-images', albumId, password], + queryFn: () => + albumId + ? fetchAlbumImages(albumId, password || undefined) + : Promise.reject('No album ID'), + enabled: !!albumId && !!album && (!album.is_hidden || Boolean(password)), + }); + + // Fetch all images to get full image details + const { data: allImagesData } = usePictoQuery({ + queryKey: ['images'], + queryFn: fetchAllImages, + }); + + // Handle loading states + useEffect(() => { + if (isAlbumLoading || isImagesLoading) { + dispatch(showLoader('Loading album...')); + } else { + dispatch(hideLoader()); + } + }, [isAlbumLoading, isImagesLoading, dispatch]); + + // Handle album data + useEffect(() => { + if (isAlbumSuccess && albumData?.success) { + setAlbum(albumData.data); + dispatch(setSelectedAlbum(albumData.data)); + + // Check if album is hidden and we don't have password + if (albumData.data.is_hidden && !password) { + setShowPasswordDialog(true); + } + } else if (isAlbumError) { + dispatch( + showInfoDialog({ + title: 'Error', + message: 'Failed to load album. Please try again.', + variant: 'error', + }), + ); + } + }, [isAlbumSuccess, isAlbumError, albumData, password, dispatch]); + + // Handle image IDs data + useEffect(() => { + if (isImagesSuccess && imageIdsData?.success && allImagesData?.data) { + const imageIds = imageIdsData.image_ids; + const allImagesArray = allImagesData.data as Image[]; + + // Filter images that are in this album + const filteredImages = allImagesArray.filter((image) => + imageIds.includes(image.id), + ); + + setAlbumImages(filteredImages); + dispatch(setImages(filteredImages)); + } + }, [isImagesSuccess, imageIdsData, allImagesData, dispatch]); + + // Handle failed password attempts for hidden albums + useEffect(() => { + // Check if query failed or returned unsuccessful response + if ( + isImagesError || + (isImagesSuccess && imageIdsData && !imageIdsData.success) + ) { + // Only handle this for hidden albums with a password attempt + if (album?.is_hidden && password) { + // Clear the incorrect password + setPassword(''); + + // Clear any existing images from UI + setAlbumImages([]); + dispatch(setImages([])); + + // Show password dialog again for retry + setShowPasswordDialog(true); + + // Show error notification + dispatch( + showInfoDialog({ + title: 'Invalid Password', + message: 'The password you entered is incorrect. Please try again.', + variant: 'error', + }), + ); + } + } + }, [isImagesError, isImagesSuccess, imageIdsData, album, password, dispatch]); + + const handleBack = () => { + navigate('/albums'); + }; + + const handleEnterSelectionMode = () => { + dispatch(enableSelectionMode()); + }; + + const handleEditAlbum = () => { + setShowEditDialog(true); + }; + + const handleDeleteAlbum = () => { + setShowDeleteDialog(true); + }; + + const handleAlbumUpdated = () => { + // Album will be refetched via React Query + console.log('Album updated successfully'); + }; + + const handleAlbumDeleted = () => { + // Navigate back to albums list after deletion + navigate('/albums'); + }; + + const handleRemoveFromAlbum = () => { + if (!albumId || selectedImageIds.length === 0) return; + + removeImagesMutation.mutate({ + albumId, + imageIds: selectedImageIds, + }); + }; + + const handlePasswordSubmit = (submittedPassword: string) => { + setPassword(submittedPassword); + setShowPasswordDialog(false); + }; + + const handleCloseMediaView = () => { + // MediaView handles closing via Redux + }; + + if (!album) { + return ( +
+
+

Loading...

+

+ Please wait while we load the album. +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+ +
+
+
+

{album.album_name}

+ {album.is_hidden && ( + + )} +
+ {album.is_hidden && Hidden} + + {albumImages.length} photo + {albumImages.length !== 1 ? 's' : ''} + +
+
+ {album.description && ( +

{album.description}

+ )} +
+ +
+ {!isSelectionMode && albumImages.length > 0 && ( + + )} + + + + + + + + + Edit Album + + + + Delete Album + + + +
+
+
+ + {/* Empty state */} + {albumImages.length === 0 ? ( +
+
+

+ No photos in this album +

+

+ Add photos to this album from your photo collection. +

+ +
+
+ ) : ( + /* Image Grid */ +
+ {albumImages.map((image: any, index: number) => ( + + ))} +
+ )} + + {/* Selection Toolbar */} + {isSelectionMode && ( + console.log('Download:', selectedImageIds)} + onShare={() => console.log('Share:', selectedImageIds)} + onRemoveFromAlbum={handleRemoveFromAlbum} + showAlbumActions={false} + showRemoveFromAlbum={true} + /> + )} + + {/* Media Viewer Modal */} + {isImageViewOpen && ( + + )} + + {/* Password Dialog for Hidden Albums */} + + + {/* Edit Album Dialog */} + {albumId && ( + + )} + + {/* Delete Album Dialog */} + {albumId && album && ( + + )} +
+ ); +} + +// Password Dialog Component +interface PasswordDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onPasswordSubmit: (password: string) => void; + albumName: string; +} + +function PasswordDialog({ + open, + onOpenChange, + onPasswordSubmit, + albumName, +}: PasswordDialogProps) { + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = (e: any) => { + e.preventDefault(); + if (!password.trim()) { + setError('Password is required'); + return; + } + onPasswordSubmit(password); + setPassword(''); + setError(''); + }; + + const handleCancel = () => { + setPassword(''); + setError(''); + onOpenChange(false); + }; + + return ( + + + + Enter Album Password + + This album is password protected. Please enter the password to view + "{albumName}". + + +
+
+ + ) => + setPassword(e.target.value) + } + className={error ? 'border-red-500' : ''} + /> + {error &&

{error}

} +
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/components/Album/AlbumList.tsx b/frontend/src/components/Album/AlbumList.tsx new file mode 100644 index 000000000..051ed2925 --- /dev/null +++ b/frontend/src/components/Album/AlbumList.tsx @@ -0,0 +1,217 @@ +import { fetchAllAlbums } from '@/api/api-functions/albums'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { selectAlbums } from '@/features/albumSelectors'; +import { setAlbums } from '@/features/albumSlice'; +import { showInfoDialog } from '@/features/infoDialogSlice'; +import { hideLoader, showLoader } from '@/features/loaderSlice'; +import { usePictoQuery } from '@/hooks/useQueryExtension'; +import { + Edit3, + Eye, + EyeOff, + FolderOpen, + Lock, + MoreHorizontal, + Plus, + Trash2, +} from 'lucide-react'; +import * as React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; + +interface AlbumListProps { + onCreateAlbum?: () => void; + onEditAlbum?: (albumId: string) => void; + onDeleteAlbum?: (albumId: string, albumName: string) => void; +} + +export function AlbumList({ + onCreateAlbum, + onEditAlbum, + onDeleteAlbum, +}: AlbumListProps) { + const dispatch = useDispatch(); + const albums = useSelector(selectAlbums); + const [showHidden, setShowHidden] = React.useState(false); + + // Fetch albums + const { data, isLoading, isSuccess, isError } = usePictoQuery({ + queryKey: ['albums', showHidden], + queryFn: () => fetchAllAlbums(showHidden), + }); + + // Handle loading states + React.useEffect(() => { + if (isLoading) { + dispatch(showLoader('Loading albums...')); + } else if (isError) { + dispatch(hideLoader()); + dispatch( + showInfoDialog({ + title: 'Error', + message: 'Failed to load albums. Please try again later.', + variant: 'error', + }), + ); + } else if (isSuccess && data?.success) { + dispatch(setAlbums(data.albums)); + dispatch(hideLoader()); + } + }, [data, isSuccess, isError, isLoading, dispatch]); + + const handleToggleShowHidden = () => { + setShowHidden(!showHidden); + }; + + if (albums.length === 0 && !isLoading) { + return ( +
+ +

No albums yet

+

+ Create your first album to organize your photos into collections. +

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Albums

+

+ {albums.length} album{albums.length !== 1 ? 's' : ''} +

+
+
+ + +
+
+ + {/* Album Grid */} +
+ {albums.map((album: any) => ( + + +
+
+
+ + {album.album_name} + + {album.is_hidden && ( + + )} +
+ {album.description && ( + + {album.description} + + )} +
+ + + + + + + onEditAlbum?.(album.album_id)} + > + + Edit Album + + + onDeleteAlbum?.(album.album_id, album.album_name) + } + className="text-red-600" + > + + Delete Album + + + +
+
+ + + {/* Album preview placeholder */} +
+ +
+ + {/* Album badges */} +
+ {album.is_hidden && ( + + Hidden + + )} + {/* Photo count badge */} + + 0 photos + +
+
+ + + + + + +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/Album/CreateAlbumDialog.tsx b/frontend/src/components/Album/CreateAlbumDialog.tsx new file mode 100644 index 000000000..c710c03c3 --- /dev/null +++ b/frontend/src/components/Album/CreateAlbumDialog.tsx @@ -0,0 +1,235 @@ +import { createAlbum, CreateAlbumRequest } from '@/api/api-functions/albums'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { addAlbum } from '@/features/albumSlice'; +import { showInfoDialog } from '@/features/infoDialogSlice'; +import { useMutationFeedback } from '@/hooks/useMutationFeedback'; +import { usePictoMutation } from '@/hooks/useQueryExtension'; +import React from 'react'; +import { useDispatch } from 'react-redux'; + +interface CreateAlbumDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedImageIds?: string[]; + onAlbumCreated?: (albumId: string) => void; +} + +export function CreateAlbumDialog({ + open, + onOpenChange, + selectedImageIds = [], + onAlbumCreated, +}: CreateAlbumDialogProps) { + const dispatch = useDispatch(); + const [albumName, setAlbumName] = React.useState(''); + const [description, setDescription] = React.useState(''); + const [isHidden, setIsHidden] = React.useState(false); + const [password, setPassword] = React.useState(''); + const [errors, setErrors] = React.useState>({}); + + const createAlbumMutation = usePictoMutation({ + mutationFn: (data: CreateAlbumRequest) => createAlbum(data), + onSuccess: (data: any) => { + if (data.success) { + // Add album to Redux store + dispatch( + addAlbum({ + album_id: data.album_id, + album_name: albumName, + description: description, + is_hidden: isHidden, + }), + ); + + // Show success message + dispatch( + showInfoDialog({ + title: 'Success', + message: `Album "${albumName}" created successfully!`, + variant: 'info', + }), + ); + + // Call callback with album ID + onAlbumCreated?.(data.album_id); + + // Reset form and close dialog + resetForm(); + onOpenChange(false); + } + }, + onError: (_error: any) => { + dispatch( + showInfoDialog({ + title: 'Error', + message: 'Failed to create album. Please try again.', + variant: 'error', + }), + ); + }, + }); + + // Use mutation feedback hook + useMutationFeedback(createAlbumMutation, { + loadingMessage: 'Creating album...', + showSuccess: false, // We handle success manually above + showError: false, // We handle errors manually above + errorTitle: 'Failed to Create Album', + }); + + const resetForm = () => { + setAlbumName(''); + setDescription(''); + setIsHidden(false); + setPassword(''); + setErrors({}); + }; + + const validateForm = (): boolean => { + const newErrors: Record = {}; + + if (!albumName.trim()) { + newErrors.albumName = 'Album name is required'; + } + + if (isHidden && !password.trim()) { + newErrors.password = 'Password is required for hidden albums'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = (e: any) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + const albumData: CreateAlbumRequest = { + name: albumName.trim(), + description: description.trim(), + is_hidden: isHidden, + password: isHidden ? password : undefined, + }; + + createAlbumMutation.mutate(albumData); + }; + + const handleCancel = () => { + resetForm(); + onOpenChange(false); + }; + + return ( + + + + Create New Album + + {selectedImageIds.length > 0 + ? `Create a new album with ${selectedImageIds.length} selected photo${ + selectedImageIds.length > 1 ? 's' : '' + }.` + : 'Create a new album to organize your photos.'} + + + +
+ {/* Album Name */} +
+ + ) => + setAlbumName(e.target.value) + } + className={errors.albumName ? 'border-red-500' : ''} + /> + {errors.albumName && ( +

{errors.albumName}

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