feat: option to bulk delete albums

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj
2025-01-05 15:48:28 +05:30
parent 9bd314f626
commit fdda358ab1
9 changed files with 321 additions and 57 deletions
BIN
View File
Binary file not shown.
+2
View File
@@ -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",
+1 -1
View File
@@ -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;
+207
View File
@@ -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<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-gray10 bg-white dark:bg-black p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] dark:border-gray7 sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = 'AlertDialogHeader'
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = 'AlertDialogFooter'
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-slate-500 dark:text-slate-400', className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> & {
variant?: ButtonProps['variant']
}
>(({ className, variant, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants({ variant }), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className
)}
{...props}
/>
))
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<void>
onCancel: () => void | Promise<void>
variant?: ButtonProps['variant']
asChild?: boolean
}
const AlertDialog = React.forwardRef<INewChangelogButtonActions, Partial<AlertDialogProps>>(({
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 (
<AlertDialogRoot {...props} open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild={asChild}>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={loading}
onClick={handleConfirm}
variant={variant}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogRoot>
)
})
AlertDialog.displayName = 'AlertDialog'
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger
}
-33
View File
@@ -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 (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
+1 -1
View File
@@ -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";
+5 -1
View File
@@ -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 });
}
+55 -21
View File
@@ -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<string[]>([])
const albumShareDialogRef = useRef<IAlbumShareDialogRef>(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 (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 p-4">
{searchedAlbums.map((album) => (
<AlbumThumbnail
<AlbumThumbnail
album={album}
key={album.id}
key={album.id}
selected={selectedAlbumsIds.includes(album.id)}
onSelect={(checked) => handleSelect(checked, album.id)}
onSelect={(checked) => handleSelect(checked, album.id)}
/>
))}
</div>
@@ -95,26 +111,44 @@ export default function AlbumListPage() {
rightComponent={
<div className="flex items-center gap-2">
{!!selectedAlbumsIds.length && (
<Button
variant={"secondary"}
className="flex items-center gap-2"
onClick={() => {
albumShareDialogRef.current?.open(selectedAlbums)
}}>
<Share size={16} /> Share {selectedAlbumsIds.length} albums
</Button>
<>
<Button
variant={"secondary"}
size={"sm"}
className="flex items-center gap-2"
onClick={() => {
albumShareDialogRef.current?.open(selectedAlbums)
}}>
<Share size={16} /> Share {selectedAlbumsIds.length} albums
</Button>
<AlertDialog
title='Delete Albums'
description='Are you sure you want to delete these albums?'
onConfirm={handleDeleteAlbums}
>
<Button
variant={"destructive"}
size={"sm"}
className="flex items-center gap-2"
disabled={deleting}
>
<Trash size={16} /> {deleting ? "Deleting..." : `Delete ${selectedAlbumsIds.length} albums`}
</Button>
</AlertDialog>
</>
)}
<Input type="text" placeholder="Search" className="w-48" onChange={(e) => setSearch(e.target.value)} />
<Select
defaultValue={selectedSortBy?.value}
onValueChange={(value) => router.push({
pathname,
query: {
...query,
sortBy: value,
}
})}>
query: {
...query,
sortBy: value,
}
})}>
<SelectTrigger className="px-2">
<SelectValue placeholder="Sort by">{selectedSortBy?.label || 'Sort by'}</SelectValue>
</SelectTrigger>
@@ -137,7 +171,7 @@ export default function AlbumListPage() {
}
/>
{renderContent()}
<AlbumShareDialog
<AlbumShareDialog
ref={albumShareDialogRef}
/>
</PageLayout>
+50
View File
@@ -0,0 +1,50 @@
import { ENV } from "@/config/environment";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiResponse } from "next";
import { NextApiRequest } from "next";
interface IAlbumShare {
albumId: string;
allowDownload: boolean;
allowUpload: boolean;
showMetadata: boolean;
}
const deleteSingleAlbum = async (albumId: string, token: string) => {
const url = ENV.IMMICH_URL + "/api/albums/" + albumId;
return fetch(url, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
}).then(async (response) => {
if (response.status >= 400) {
return {
error: "Failed to fetch assets",
errorData: await response.json()
};
}
return {
success: true
};
});
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const currentUser = await getCurrentUser(req);
if (!currentUser) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { albumIds } = req.body as { albumIds: string[] };
if (!albumIds) {
return res.status(400).json({ error: 'albumIds is required' });
}
const deleteResults = await Promise.all(albumIds.map(async (albumId) => {
return deleteSingleAlbum(albumId, currentUser.accessToken);
}));
return res.status(200).json(deleteResults);
}