aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/app/dashboard/archive/page.tsx4
-rw-r--r--apps/web/app/dashboard/bookmarks/page.tsx6
-rw-r--r--apps/web/app/dashboard/favourites/page.tsx4
-rw-r--r--apps/web/app/dashboard/search/page.tsx4
-rw-r--r--apps/web/components/dashboard/BulkBookmarksAction.tsx171
-rw-r--r--apps/web/components/dashboard/ChangeLayout.tsx12
-rw-r--r--apps/web/components/dashboard/GlobalActions.tsx13
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx62
-rw-r--r--apps/web/components/dashboard/bookmarks/icons.tsx12
-rw-r--r--apps/web/components/dashboard/lists/ListHeader.tsx6
-rw-r--r--apps/web/components/ui/action-button.tsx2
-rw-r--r--apps/web/components/ui/action-confirming-dialog.tsx4
-rw-r--r--apps/web/lib/bulkActions.ts39
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;