From d193d9bf89e8a88bf70b673ea5e438d73cf40c0c Mon Sep 17 00:00:00 2001 From: Md Saban <45597394+mdsaban@users.noreply.github.com> Date: Tue, 2 Jul 2024 03:51:23 +0530 Subject: feat: Add bulk edit option for bookmarks. Fixes #84 (#259) * feat: add bulk edit option for bookmarks * fix: resolve comments * fix: resolve comments * fix: resolve comments * fix: resolve comments * rename bulk action store, simplify the bulk action toolbar --------- Co-authored-by: MohamedBassem --- apps/web/app/dashboard/archive/page.tsx | 4 +- apps/web/app/dashboard/bookmarks/page.tsx | 6 +- apps/web/app/dashboard/favourites/page.tsx | 4 +- apps/web/app/dashboard/search/page.tsx | 4 +- .../components/dashboard/BulkBookmarksAction.tsx | 171 +++++++++++++++++++++ apps/web/components/dashboard/ChangeLayout.tsx | 12 +- apps/web/components/dashboard/GlobalActions.tsx | 13 ++ .../dashboard/bookmarks/BookmarkActionBar.tsx | 2 +- .../bookmarks/BookmarkLayoutAdaptingCard.tsx | 62 +++++++- apps/web/components/dashboard/bookmarks/icons.tsx | 12 +- apps/web/components/dashboard/lists/ListHeader.tsx | 6 +- apps/web/components/ui/action-button.tsx | 2 +- .../web/components/ui/action-confirming-dialog.tsx | 4 +- apps/web/lib/bulkActions.ts | 39 +++++ 14 files changed, 314 insertions(+), 27 deletions(-) create mode 100644 apps/web/components/dashboard/BulkBookmarksAction.tsx create mode 100644 apps/web/components/dashboard/GlobalActions.tsx create mode 100644 apps/web/lib/bulkActions.ts (limited to 'apps') diff --git a/apps/web/app/dashboard/archive/page.tsx b/apps/web/app/dashboard/archive/page.tsx index a5326205..5c25d8cc 100644 --- a/apps/web/app/dashboard/archive/page.tsx +++ b/apps/web/app/dashboard/archive/page.tsx @@ -1,5 +1,5 @@ import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks"; -import ChangeLayout from "@/components/dashboard/ChangeLayout"; +import GlobalActions from "@/components/dashboard/GlobalActions"; import InfoTooltip from "@/components/ui/info-tooltip"; function header() { @@ -12,7 +12,7 @@ function header() {
- +
); diff --git a/apps/web/app/dashboard/bookmarks/page.tsx b/apps/web/app/dashboard/bookmarks/page.tsx index 47392ad5..c02e6b85 100644 --- a/apps/web/app/dashboard/bookmarks/page.tsx +++ b/apps/web/app/dashboard/bookmarks/page.tsx @@ -1,6 +1,6 @@ import React from "react"; import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks"; -import ChangeLayout from "@/components/dashboard/ChangeLayout"; +import GlobalActions from "@/components/dashboard/GlobalActions"; import { SearchInput } from "@/components/dashboard/search/SearchInput"; export default async function BookmarksPage() { @@ -8,9 +8,9 @@ export default async function BookmarksPage() {
- +
-
+
diff --git a/apps/web/app/dashboard/favourites/page.tsx b/apps/web/app/dashboard/favourites/page.tsx index fd39b90a..e5959af3 100644 --- a/apps/web/app/dashboard/favourites/page.tsx +++ b/apps/web/app/dashboard/favourites/page.tsx @@ -1,5 +1,5 @@ import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks"; -import ChangeLayout from "@/components/dashboard/ChangeLayout"; +import GlobalActions from "@/components/dashboard/GlobalActions"; export default async function FavouritesBookmarkPage() { return ( @@ -7,7 +7,7 @@ export default async function FavouritesBookmarkPage() { header={

⭐️ Favourites

- +
} query={{ favourited: true }} diff --git a/apps/web/app/dashboard/search/page.tsx b/apps/web/app/dashboard/search/page.tsx index 11febca6..e7405c85 100644 --- a/apps/web/app/dashboard/search/page.tsx +++ b/apps/web/app/dashboard/search/page.tsx @@ -2,7 +2,7 @@ import { Suspense, useRef } from "react"; import BookmarksGrid from "@/components/dashboard/bookmarks/BookmarksGrid"; -import ChangeLayout from "@/components/dashboard/ChangeLayout"; +import GlobalActions from "@/components/dashboard/GlobalActions"; import { SearchInput } from "@/components/dashboard/search/SearchInput"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { useBookmarkSearch } from "@/lib/hooks/bookmark-search"; @@ -17,7 +17,7 @@ function SearchComp() {
- +
{data ? ( diff --git a/apps/web/components/dashboard/BulkBookmarksAction.tsx b/apps/web/components/dashboard/BulkBookmarksAction.tsx new file mode 100644 index 00000000..b78071ee --- /dev/null +++ b/apps/web/components/dashboard/BulkBookmarksAction.tsx @@ -0,0 +1,171 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { + ActionButton, + ActionButtonWithTooltip, +} from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { useToast } from "@/components/ui/use-toast"; +import useBulkActionsStore from "@/lib/bulkActions"; +import { Pencil, Trash2, X } from "lucide-react"; + +import { + useDeleteBookmark, + useUpdateBookmark, +} from "@hoarder/shared-react/hooks/bookmarks"; + +import { ArchivedActionIcon, FavouritedActionIcon } from "./bookmarks/icons"; + +export default function BulkBookmarksAction() { + const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore(); + const setIsBulkEditEnabled = useBulkActionsStore( + (state) => state.setIsBulkEditEnabled, + ); + const { toast } = useToast(); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + useEffect(() => { + setIsBulkEditEnabled(false); // turn off toggle + clear selected bookmarks on mount + }, []); + + const onError = () => { + toast({ + variant: "destructive", + title: "Something went wrong", + description: "There was a problem with your request.", + }); + }; + + const deleteBookmarkMutator = useDeleteBookmark({ + onSuccess: () => { + setIsBulkEditEnabled(false); + }, + onError, + }); + + const updateBookmarkMutator = useUpdateBookmark({ + onSuccess: () => { + setIsBulkEditEnabled(false); + }, + onError, + }); + + interface UpdateBookmarkProps { + favourited?: boolean; + archived?: boolean; + } + + const updateBookmarks = async ({ + favourited, + archived, + }: UpdateBookmarkProps) => { + await Promise.all( + selectedBookmarks.map((item) => + updateBookmarkMutator.mutateAsync({ + bookmarkId: item.id, + favourited, + archived, + }), + ), + ); + toast({ + description: `${selectedBookmarks.length} bookmarks have been updated!`, + }); + }; + + const deleteBookmarks = async () => { + await Promise.all( + selectedBookmarks.map((item) => + deleteBookmarkMutator.mutateAsync({ bookmarkId: item.id }), + ), + ); + toast({ + description: `${selectedBookmarks.length} bookmarks have been deleted!`, + }); + }; + + const alreadyFavourited = + selectedBookmarks.length && + selectedBookmarks.every((item) => item.favourited === true); + + const alreadyArchived = + selectedBookmarks.length && + selectedBookmarks.every((item) => item.archived === true); + + const actionList = [ + { + name: alreadyFavourited ? "Unfavourite" : "Favourite", + icon: , + action: () => updateBookmarks({ favourited: !alreadyFavourited }), + isPending: updateBookmarkMutator.isPending, + hidden: !isBulkEditEnabled, + }, + { + name: alreadyArchived ? "Un-arhcive" : "Archive", + icon: , + action: () => updateBookmarks({ archived: !alreadyArchived }), + isPending: updateBookmarkMutator.isPending, + hidden: !isBulkEditEnabled, + }, + { + name: "Delete", + icon: , + action: () => setIsDeleteDialogOpen(true), + hidden: !isBulkEditEnabled, + }, + { + name: "Close bulk edit", + icon: , + action: () => setIsBulkEditEnabled(false), + alwaysEnable: true, + hidden: !isBulkEditEnabled, + }, + { + name: "Bulk Edit", + icon: , + action: () => setIsBulkEditEnabled(true), + alwaysEnable: true, + hidden: isBulkEditEnabled, + }, + ]; + + return ( +
+ Are you sure you want to delete these bookmarks?

} + actionButton={() => ( + deleteBookmarks()} + > + Delete + + )} + /> +
+ {actionList.map( + ({ name, icon: Icon, action, isPending, hidden, alwaysEnable }) => ( + + {Icon} + + ), + )} +
+
+ ); +} diff --git a/apps/web/components/dashboard/ChangeLayout.tsx b/apps/web/components/dashboard/ChangeLayout.tsx index 59acb6bd..7449bd2d 100644 --- a/apps/web/components/dashboard/ChangeLayout.tsx +++ b/apps/web/components/dashboard/ChangeLayout.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { Button } from "@/components/ui/button"; +import { ButtonWithTooltip } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -20,15 +20,19 @@ const iconMap = { list: LayoutList, }; -export default function SidebarProfileOptions() { +export default function ChangeLayout() { const layout = useBookmarkLayout(); return ( - + {Object.keys(iconMap).map((key) => ( diff --git a/apps/web/components/dashboard/GlobalActions.tsx b/apps/web/components/dashboard/GlobalActions.tsx new file mode 100644 index 00000000..e09f92a2 --- /dev/null +++ b/apps/web/components/dashboard/GlobalActions.tsx @@ -0,0 +1,13 @@ +"use client"; + +import BulkBookmarksAction from "@/components/dashboard/BulkBookmarksAction"; +import ChangeLayout from "@/components/dashboard/ChangeLayout"; + +export default function GlobalActions() { + return ( +
+ + +
+ ); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx b/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx index 6cc8e44e..299f47eb 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx @@ -22,7 +22,7 @@ export default function BookmarkActionBar({ href={`/dashboard/preview/${bookmark.id}`} className={cn(buttonVariants({ variant: "ghost" }), "px-2")} > - +
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx index e1cc1f7c..33b65108 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -1,12 +1,15 @@ import type { BookmarksLayoutTypes } from "@/lib/userLocalSettings/types"; -import React from "react"; +import React, { useEffect, useState } from "react"; import Link from "next/link"; +import useBulkActionsStore from "@/lib/bulkActions"; import { bookmarkLayoutSwitch, useBookmarkLayout, } from "@/lib/userLocalSettings/bookmarksLayout"; import { cn } from "@/lib/utils"; import dayjs from "dayjs"; +import { Check } from "lucide-react"; +import { useTheme } from "next-themes"; import type { ZBookmark } from "@hoarder/shared/types/bookmarks"; import { isBookmarkStillTagging } from "@hoarder/shared-react/utils/bookmarkUtils"; @@ -45,6 +48,57 @@ function BottomRow({ ); } +function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) { + const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore(); + const toggleBookmark = useBulkActionsStore((state) => state.toggleBookmark); + const [isSelected, setIsSelected] = useState(false); + const { theme } = useTheme(); + + useEffect(() => { + setIsSelected(selectedBookmarks.some((item) => item.id === bookmark.id)); + }, [selectedBookmarks]); + + if (!isBulkEditEnabled) return null; + + const getIconColor = () => { + if (theme === "dark") { + return isSelected ? "black" : "white"; + } + return isSelected ? "white" : "black"; + }; + + const getIconBackgroundColor = () => { + if (theme === "dark") { + return isSelected ? "bg-white" : "bg-white bg-opacity-10"; + } + return isSelected ? "bg-black" : "bg-white bg-opacity-40"; + }; + + return ( + + + ); +} + function ListView({ bookmark, image, @@ -56,10 +110,11 @@ function ListView({ return (
+
{image("list", "object-cover rounded-lg size-32")}
@@ -100,11 +155,12 @@ function GridView({ return (
+ {img &&
{img}
}
diff --git a/apps/web/components/dashboard/bookmarks/icons.tsx b/apps/web/components/dashboard/bookmarks/icons.tsx index d899f19d..04e3ff32 100644 --- a/apps/web/components/dashboard/bookmarks/icons.tsx +++ b/apps/web/components/dashboard/bookmarks/icons.tsx @@ -3,27 +3,31 @@ import { Archive, ArchiveRestore, Star } from "lucide-react"; export function FavouritedActionIcon({ favourited, className, + size, }: { favourited: boolean; className?: string; + size?: number; }) { return favourited ? ( - + ) : ( - + ); } export function ArchivedActionIcon({ archived, className, + size, }: { archived: boolean; className?: string; + size?: number; }) { return archived ? ( - + ) : ( - + ); } diff --git a/apps/web/components/dashboard/lists/ListHeader.tsx b/apps/web/components/dashboard/lists/ListHeader.tsx index 2f69203e..1655a80b 100644 --- a/apps/web/components/dashboard/lists/ListHeader.tsx +++ b/apps/web/components/dashboard/lists/ListHeader.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import ChangeLayout from "@/components/dashboard/ChangeLayout"; +import GlobalActions from "@/components/dashboard/GlobalActions"; import { Button } from "@/components/ui/button"; import { MoreHorizontal } from "lucide-react"; @@ -37,13 +37,13 @@ export default function ListHeader({ {list.icon} {list.name} -
+
- +
); diff --git a/apps/web/components/ui/action-button.tsx b/apps/web/components/ui/action-button.tsx index b3984d97..b7cd9b3d 100644 --- a/apps/web/components/ui/action-button.tsx +++ b/apps/web/components/ui/action-button.tsx @@ -46,7 +46,7 @@ const ActionButtonWithTooltip = React.forwardRef< >(({ tooltip, delayDuration, ...props }, ref) => { return ( - + diff --git a/apps/web/components/ui/action-confirming-dialog.tsx b/apps/web/components/ui/action-confirming-dialog.tsx index 37895ee7..cfd38fc3 100644 --- a/apps/web/components/ui/action-confirming-dialog.tsx +++ b/apps/web/components/ui/action-confirming-dialog.tsx @@ -24,7 +24,7 @@ export default function ActionConfirmingDialog({ title: React.ReactNode; description: React.ReactNode; actionButton: (setDialogOpen: (open: boolean) => void) => React.ReactNode; - children: React.ReactNode; + children?: React.ReactNode; }) { const [customIsOpen, setCustomIsOpen] = useState(false); const [isDialogOpen, setDialogOpen] = [ @@ -33,7 +33,7 @@ export default function ActionConfirmingDialog({ ]; return ( - {children} + {children && {children}} {title} diff --git a/apps/web/lib/bulkActions.ts b/apps/web/lib/bulkActions.ts new file mode 100644 index 00000000..1e9dbbd7 --- /dev/null +++ b/apps/web/lib/bulkActions.ts @@ -0,0 +1,39 @@ +// reference article https://refine.dev/blog/zustand-react-state/#build-a-to-do-app-using-zustand +import { create } from "zustand"; + +import type { ZBookmark } from "@hoarder/shared/types/bookmarks"; + +interface BookmarkState { + selectedBookmarks: ZBookmark[]; + isBulkEditEnabled: boolean; + setIsBulkEditEnabled: (isEnabled: boolean) => void; + toggleBookmark: (bookmark: ZBookmark) => void; +} + +const useBulkActionsStore = create((set, get) => ({ + selectedBookmarks: [], + isBulkEditEnabled: false, + + toggleBookmark: (bookmark: ZBookmark) => { + const selectedBookmarks = get().selectedBookmarks; + const isBookmarkAlreadySelected = selectedBookmarks.some( + (b) => b.id === bookmark.id, + ); + if (isBookmarkAlreadySelected) { + set({ + selectedBookmarks: selectedBookmarks.filter( + (b) => b.id !== bookmark.id, + ), + }); + } else { + set({ selectedBookmarks: [...selectedBookmarks, bookmark] }); + } + }, + + setIsBulkEditEnabled: (isEnabled) => { + set({ isBulkEditEnabled: isEnabled }); + set({ selectedBookmarks: [] }); + }, +})); + +export default useBulkActionsStore; -- cgit v1.2.3-70-g09d2