diff --git a/bun.lockb b/bun.lockb index 2ad086a..3d7d613 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index ced96fc..d6f629f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@google/generative-ai": "^0.21.0", "@radix-ui/react-accordion": "^1.2.1", + "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.1", @@ -60,6 +61,7 @@ "react-day-picker": "9.0.8", "react-dom": "^18", "react-grid-gallery": "^1.0.1", + "react-hot-toast": "^2.5.1", "react-leaflet": "^4.2.1", "react-mentions": "^4.4.10", "react-query": "^3.39.3", diff --git a/src/components/layouts/RootLayout.tsx b/src/components/layouts/RootLayout.tsx index 3eab240..8f56340 100644 --- a/src/components/layouts/RootLayout.tsx +++ b/src/components/layouts/RootLayout.tsx @@ -1,6 +1,5 @@ import { IUser } from "@/types/user"; import Sidebar from "../shared/Sidebar"; -import { Toaster } from "../ui/toaster"; import { ReactNode, useEffect, useState } from "react"; import { getMe } from "@/handlers/api/user.handler"; import UserContext from "@/contexts/CurrentUserContext"; @@ -12,6 +11,7 @@ import { LoginForm } from "../auth/LoginForm"; import { useConfig } from "@/contexts/ConfigContext"; import { queryClient } from "@/config/rQuery"; import { QueryClientProvider } from "react-query"; +import { Toaster } from "react-hot-toast"; type RootLayoutProps = { children: ReactNode; diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..d898f67 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,207 @@ +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' +import * as React from 'react' + +import { ButtonProps, buttonVariants } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { useImperativeHandle, useState } from 'react' + +const AlertDialogRoot = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = 'AlertDialogHeader' + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = 'AlertDialogFooter' + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + variant?: ButtonProps['variant'] + } +>(({ className, variant, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + + +export interface INewChangelogButtonActions { + open: () => void +} +interface AlertDialogProps extends AlertDialogPrimitive.AlertDialogProps { + children: React.ReactNode + title: string + description: string + onConfirm: () => void | Promise + onCancel: () => void | Promise + variant?: ButtonProps['variant'] + asChild?: boolean +} + +const AlertDialog = React.forwardRef>(({ + children, + title, + description, + onConfirm, + onCancel, + variant, + asChild, + ...props +}, ref) => { + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + + useImperativeHandle(ref, () => ({ + open: () => setOpen(true) + })) + + const handleConfirm = async () => { + setLoading(true) + if (onConfirm) { + await onConfirm() + } + setLoading(false) + setOpen(false) + } + + return ( + + {children} + + + {title} + {description} + + + Cancel + + Confirm + + + + + ) +}) + +AlertDialog.displayName = 'AlertDialog' + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger +} diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx deleted file mode 100644 index a2209ba..0000000 --- a/src/components/ui/toaster.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { - Toast, - ToastClose, - ToastDescription, - ToastProvider, - ToastTitle, - ToastViewport, -} from "@/components/ui/toast" -import { useToast } from "@/components/ui/use-toast" - -export function Toaster() { - const { toasts } = useToast() - - return ( - - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - -
- {title && {title}} - {description && ( - {description} - )} -
- {action} - -
- ) - })} - -
- ) -} diff --git a/src/config/routes.ts b/src/config/routes.ts index 4c417a2..ca1db88 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -26,7 +26,7 @@ export const ALBUM_ASSETS_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" export const CREATE_ALBUM_PATH = BASE_PROXY_ENDPOINT + "/albums"; export const ADD_ASSETS_ALBUMS_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/albums/" + id + "/assets"; export const SHARE_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/share"; - +export const DELETE_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/delete"; // Assets export const LIST_MISSING_LOCATION_DATES_PATH = BASE_API_ENDPOINT + "/assets/missing-location-dates"; export const LIST_MISSING_LOCATION_ALBUMS_PATH = BASE_API_ENDPOINT + "/assets/missing-location-albums"; diff --git a/src/handlers/api/album.handler.ts b/src/handlers/api/album.handler.ts index 4554601..6cb08d7 100644 --- a/src/handlers/api/album.handler.ts +++ b/src/handlers/api/album.handler.ts @@ -1,4 +1,4 @@ -import { ADD_ASSETS_ALBUMS_PATH, ALBUM_ASSETS_PATH, ALBUM_INFO_PATH, ALBUM_PEOPLE_PATH, CREATE_ALBUM_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH, SHARE_ALBUMS_PATH } from "@/config/routes"; +import { ADD_ASSETS_ALBUMS_PATH, ALBUM_ASSETS_PATH, ALBUM_INFO_PATH, ALBUM_PEOPLE_PATH, CREATE_ALBUM_PATH, DELETE_ALBUMS_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH, SHARE_ALBUMS_PATH } from "@/config/routes"; import { cleanUpAsset } from "@/helpers/asset.helper"; import API from "@/lib/api"; import { IAlbumCreate } from "@/types/album"; @@ -48,4 +48,8 @@ export const createAlbum = async (albumData: IAlbumCreate) => { export const shareAlbums = async (albums: { albumId: string, allowDownload: boolean, allowUpload: boolean, showMetadata: boolean }[]) => { return API.post(SHARE_ALBUMS_PATH, { albums }); +} + +export const deleteAlbums = async (albumIds: string[]) => { + return API.delete(DELETE_ALBUMS_PATH, { albumIds }); } \ No newline at end of file diff --git a/src/pages/albums/index.tsx b/src/pages/albums/index.tsx index 182d2ff..1cc340b 100644 --- a/src/pages/albums/index.tsx +++ b/src/pages/albums/index.tsx @@ -2,16 +2,18 @@ import PageLayout from '@/components/layouts/PageLayout' import Header from '@/components/shared/Header' import Loader from '@/components/ui/loader' import { useConfig } from '@/contexts/ConfigContext' -import { listAlbums } from '@/handlers/api/album.handler' +import { deleteAlbums, listAlbums } from '@/handlers/api/album.handler' import { IAlbum } from '@/types/album' import React, { useEffect, useMemo, useRef, useState } from 'react' import AlbumThumbnail from '@/components/albums/list/AlbumThumbnail' import { Button } from '@/components/ui/button' import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select' import { useRouter } from 'next/router' -import { Share, SortAsc, SortDesc } from 'lucide-react' +import { Share, SortAsc, SortDesc, Trash } from 'lucide-react' import { Input } from '@/components/ui/input' import AlbumShareDialog, { IAlbumShareDialogRef } from '@/components/albums/share/AlbumShareDialog' +import { AlertDialog } from '@/components/ui/alert-dialog' +import toast from 'react-hot-toast' const SORT_BY_OPTIONS = [ { value: 'lastPhotoDate', label: 'Last Photo Date' }, @@ -33,10 +35,12 @@ export default function AlbumListPage() { const [errorMessage, setErrorMessage] = useState('') const [selectedAlbumsIds, setSelectedAlbumsIds] = useState([]) const albumShareDialogRef = useRef(null); + const [deleting, setDeleting] = useState(false) const selectedSortBy = useMemo(() => SORT_BY_OPTIONS.find((option) => option.value === sortBy), [sortBy]) - const searchedAlbums = useMemo(() => albums.filter((album) => album.albumName.toLowerCase().includes(search.toLowerCase())), [albums, search]) + const searchedAlbums = useMemo(() => albums.filter((album) => album.albumName.toLowerCase().includes(search.toLowerCase())), [albums, search]) + const fetchAlbums = async () => { setLoading(true) listAlbums({ @@ -67,6 +71,18 @@ export default function AlbumListPage() { } } + const handleDeleteAlbums = async () => { + setDeleting(true) + return deleteAlbums(selectedAlbumsIds).then(() => { + setSelectedAlbumsIds([]) + setAlbums(albums.filter((album) => !selectedAlbumsIds.includes(album.id))) + toast.success(`Deleted ${selectedAlbumsIds.length} albums`) + }).catch((error) => { + toast.error(error.message) + }).finally(() => { + setDeleting(false) + }) + } const renderContent = () => { if (loading) { @@ -78,11 +94,11 @@ export default function AlbumListPage() { return (
{searchedAlbums.map((album) => ( - handleSelect(checked, album.id)} + onSelect={(checked) => handleSelect(checked, album.id)} /> ))}
@@ -95,26 +111,44 @@ export default function AlbumListPage() { rightComponent={
{!!selectedAlbumsIds.length && ( - + <> + + + + + + )} setSearch(e.target.value)} />