diff options
| author | Md Saban <45597394+mdsaban@users.noreply.github.com> | 2024-07-02 03:51:23 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-07-01 23:21:23 +0100 |
| commit | d193d9bf89e8a88bf70b673ea5e438d73cf40c0c (patch) | |
| tree | 56d260c0624b74bda054f8865c3bd2e166f49e8d /apps | |
| parent | bf92fa3386be331871963f99ec5c813186a388b3 (diff) | |
| download | karakeep-d193d9bf89e8a88bf70b673ea5e438d73cf40c0c.tar.zst | |
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 <me@mbassem.com>
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/app/dashboard/archive/page.tsx | 4 | ||||
| -rw-r--r-- | apps/web/app/dashboard/bookmarks/page.tsx | 6 | ||||
| -rw-r--r-- | apps/web/app/dashboard/favourites/page.tsx | 4 | ||||
| -rw-r--r-- | apps/web/app/dashboard/search/page.tsx | 4 | ||||
| -rw-r--r-- | apps/web/components/dashboard/BulkBookmarksAction.tsx | 171 | ||||
| -rw-r--r-- | apps/web/components/dashboard/ChangeLayout.tsx | 12 | ||||
| -rw-r--r-- | apps/web/components/dashboard/GlobalActions.tsx | 13 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx | 2 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx | 62 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/icons.tsx | 12 | ||||
| -rw-r--r-- | apps/web/components/dashboard/lists/ListHeader.tsx | 6 | ||||
| -rw-r--r-- | apps/web/components/ui/action-button.tsx | 2 | ||||
| -rw-r--r-- | apps/web/components/ui/action-confirming-dialog.tsx | 4 | ||||
| -rw-r--r-- | apps/web/lib/bulkActions.ts | 39 |
14 files changed, 314 insertions, 27 deletions
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() { </InfoTooltip> </div> <div> - <ChangeLayout /> + <GlobalActions /> </div> </div> ); 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() { <div> <div className="flex gap-2"> <SearchInput /> - <ChangeLayout /> + <GlobalActions /> </div> - <div className="my-4 flex-1"> + <div className="my-4"> <Bookmarks query={{ archived: false }} showEditorCard={true} /> </div> </div> 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={ <div className="flex items-center justify-between"> <p className="text-2xl">⭐️ Favourites</p> - <ChangeLayout /> + <GlobalActions /> </div> } 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() { <div className="flex flex-col gap-3"> <div className="flex gap-2"> <SearchInput ref={inputRef} autoFocus={true} /> - <ChangeLayout /> + <GlobalActions /> </div> {data ? ( <BookmarksGrid bookmarks={data.bookmarks} /> 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: <FavouritedActionIcon favourited={!!alreadyFavourited} size={18} />, + action: () => updateBookmarks({ favourited: !alreadyFavourited }), + isPending: updateBookmarkMutator.isPending, + hidden: !isBulkEditEnabled, + }, + { + name: alreadyArchived ? "Un-arhcive" : "Archive", + icon: <ArchivedActionIcon size={18} archived={!!alreadyArchived} />, + action: () => updateBookmarks({ archived: !alreadyArchived }), + isPending: updateBookmarkMutator.isPending, + hidden: !isBulkEditEnabled, + }, + { + name: "Delete", + icon: <Trash2 size={18} color="red" />, + action: () => setIsDeleteDialogOpen(true), + hidden: !isBulkEditEnabled, + }, + { + name: "Close bulk edit", + icon: <X size={18} />, + action: () => setIsBulkEditEnabled(false), + alwaysEnable: true, + hidden: !isBulkEditEnabled, + }, + { + name: "Bulk Edit", + icon: <Pencil size={18} />, + action: () => setIsBulkEditEnabled(true), + alwaysEnable: true, + hidden: isBulkEditEnabled, + }, + ]; + + return ( + <div> + <ActionConfirmingDialog + open={isDeleteDialogOpen} + setOpen={setIsDeleteDialogOpen} + title={"Delete Bookmarks"} + description={<p>Are you sure you want to delete these bookmarks?</p>} + actionButton={() => ( + <ActionButton + type="button" + variant="destructive" + loading={deleteBookmarkMutator.isPending} + onClick={() => deleteBookmarks()} + > + Delete + </ActionButton> + )} + /> + <div className="flex"> + {actionList.map( + ({ name, icon: Icon, action, isPending, hidden, alwaysEnable }) => ( + <ActionButtonWithTooltip + className={hidden ? "hidden" : "block"} + tooltip={name} + disabled={!selectedBookmarks.length && !alwaysEnable} + delayDuration={100} + loading={!!isPending} + variant="ghost" + key={name} + onClick={action} + > + {Icon} + </ActionButtonWithTooltip> + ), + )} + </div> + </div> + ); +} 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 ( <DropdownMenu> <DropdownMenuTrigger asChild> - <Button variant="outline"> + <ButtonWithTooltip + tooltip="Change layout" + delayDuration={100} + variant="ghost" + > {React.createElement(iconMap[layout], { size: 18 })} - </Button> + </ButtonWithTooltip> </DropdownMenuTrigger> <DropdownMenuContent className="w-fit"> {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 ( + <div className="flex min-w-max flex-wrap overflow-hidden rounded-md border bg-background"> + <ChangeLayout /> + <BulkBookmarksAction /> + </div> + ); +} 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")} > - <Maximize2 size="20" /> + <Maximize2 size={16} /> </Link> <BookmarkOptions bookmark={bookmark} /> </div> 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 ( + <button + className={cn( + "absolute left-0 top-0 z-50 h-full w-full bg-opacity-0", + { + "bg-opacity-10": isSelected, + }, + theme === "dark" ? "bg-white" : "bg-black", + )} + onClick={() => toggleBookmark(bookmark)} + > + <button className="absolute right-2 top-2 z-50 opacity-100"> + <div + className={cn( + "flex h-4 w-4 items-center justify-center rounded-full border border-gray-600", + getIconBackgroundColor(), + )} + > + <Check size={12} color={getIconColor()} /> + </div> + </button> + </button> + ); +} + function ListView({ bookmark, image, @@ -56,10 +110,11 @@ function ListView({ return ( <div className={cn( - "flex max-h-96 gap-4 overflow-hidden rounded-lg p-2 shadow-md", + "relative flex max-h-96 gap-4 overflow-hidden rounded-lg p-2 shadow-md", className, )} > + <MultiBookmarkSelector bookmark={bookmark} /> <div className="flex size-32 items-center justify-center overflow-hidden"> {image("list", "object-cover rounded-lg size-32")} </div> @@ -100,11 +155,12 @@ function GridView({ return ( <div className={cn( - "flex flex-col overflow-hidden rounded-lg shadow-md", + "relative flex flex-col overflow-hidden rounded-lg shadow-md", className, fitHeight && layout != "grid" ? "max-h-96" : "h-96", )} > + <MultiBookmarkSelector bookmark={bookmark} /> {img && <div className="h-56 w-full shrink-0 overflow-hidden">{img}</div>} <div className="flex h-full flex-col justify-between gap-2 overflow-hidden p-2"> <div className="grow-1 flex flex-col gap-2 overflow-hidden"> 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 ? ( - <Star className={className} color="#ebb434" fill="#ebb434" /> + <Star size={size} className={className} color="#ebb434" fill="#ebb434" /> ) : ( - <Star className={className} /> + <Star size={size} className={className} /> ); } export function ArchivedActionIcon({ archived, className, + size, }: { archived: boolean; className?: string; + size?: number; }) { return archived ? ( - <ArchiveRestore className={className} /> + <ArchiveRestore size={size} className={className} /> ) : ( - <Archive className={className} /> + <Archive size={size} className={className} /> ); } 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({ <span className="text-2xl"> {list.icon} {list.name} </span> - <div> + <div className="flex"> <ListOptions list={list}> <Button variant="ghost"> <MoreHorizontal /> </Button> </ListOptions> - <ChangeLayout /> + <GlobalActions /> </div> </div> ); 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 ( <Tooltip delayDuration={delayDuration}> - <TooltipTrigger> + <TooltipTrigger asChild> <ActionButton ref={ref} {...props} /> </TooltipTrigger> <TooltipPortal> 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 ( <Dialog open={isDialogOpen} onOpenChange={setDialogOpen}> - <DialogTrigger asChild>{children}</DialogTrigger> + {children && <DialogTrigger asChild>{children}</DialogTrigger>} <DialogContent> <DialogHeader> <DialogTitle>{title}</DialogTitle> 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<BookmarkState>((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; |
