From 7a76216e5c971a300e9db32c93509b0376f6f47e Mon Sep 17 00:00:00 2001
From: Mohamed Bassem <me@mbassem.com>
Date: Tue, 30 Dec 2025 13:30:35 +0200
Subject: feat: Add bulk remove from list (#2279)

* feat: Add bulk remove from list action in list context

- Add "Remove from List" button in bulk actions menu
- Only visible when in a manual list context with editor/owner role
- Includes confirmation dialog before removal
- Uses same concurrency pattern as bulk add (50 concurrent operations)
- Displays success count in toast notification
- Add translation key "actions.remove" for consistency

This complements the existing bulk add to list functionality and allows
users to efficiently remove multiple bookmarks from a list at once.

* fmt

* fix list context

* add remove from list

---------

Co-authored-by: Claude <noreply@anthropic.com>
---
 .../components/dashboard/BulkBookmarksAction.tsx   | 75 +++++++++++++++++++++-
 1 file changed, 74 insertions(+), 1 deletion(-)

(limited to 'apps/web/components/dashboard/BulkBookmarksAction.tsx')

diff --git a/apps/web/components/dashboard/BulkBookmarksAction.tsx b/apps/web/components/dashboard/BulkBookmarksAction.tsx
index 9e248c03..bad76ff9 100644
--- a/apps/web/components/dashboard/BulkBookmarksAction.tsx
+++ b/apps/web/components/dashboard/BulkBookmarksAction.tsx
@@ -16,6 +16,7 @@ import {
   Hash,
   Link,
   List,
+  ListMinus,
   Pencil,
   RotateCw,
   Trash2,
@@ -27,6 +28,7 @@ import {
   useRecrawlBookmark,
   useUpdateBookmark,
 } from "@karakeep/shared-react/hooks/bookmarks";
+import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks/lists";
 import { limitConcurrency } from "@karakeep/shared/concurrency";
 import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
 
@@ -38,7 +40,11 @@ const MAX_CONCURRENT_BULK_ACTIONS = 50;
 
 export default function BulkBookmarksAction() {
   const { t } = useTranslation();
-  const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore();
+  const {
+    selectedBookmarks,
+    isBulkEditEnabled,
+    listContext: withinListContext,
+  } = useBulkActionsStore();
   const setIsBulkEditEnabled = useBulkActionsStore(
     (state) => state.setIsBulkEditEnabled,
   );
@@ -50,6 +56,8 @@ export default function BulkBookmarksAction() {
     (state) => state.isEverythingSelected,
   );
   const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+  const [isRemoveFromListDialogOpen, setIsRemoveFromListDialogOpen] =
+    useState(false);
   const [manageListsModal, setManageListsModalOpen] = useState(false);
   const [bulkTagModal, setBulkTagModalOpen] = useState(false);
   const pathname = usePathname();
@@ -92,6 +100,13 @@ export default function BulkBookmarksAction() {
     onError,
   });
 
+  const removeBookmarkFromListMutator = useRemoveBookmarkFromList({
+    onSuccess: () => {
+      setIsBulkEditEnabled(false);
+    },
+    onError,
+  });
+
   interface UpdateBookmarkProps {
     favourited?: boolean;
     archived?: boolean;
@@ -184,6 +199,31 @@ export default function BulkBookmarksAction() {
     setIsDeleteDialogOpen(false);
   };
 
+  const removeBookmarksFromList = async () => {
+    if (!withinListContext) return;
+
+    const results = await Promise.allSettled(
+      limitConcurrency(
+        selectedBookmarks.map(
+          (item) => () =>
+            removeBookmarkFromListMutator.mutateAsync({
+              bookmarkId: item.id,
+              listId: withinListContext.id,
+            }),
+        ),
+        MAX_CONCURRENT_BULK_ACTIONS,
+      ),
+    );
+
+    const successes = results.filter((r) => r.status === "fulfilled").length;
+    if (successes > 0) {
+      toast({
+        description: `${successes} bookmarks have been removed from the list!`,
+      });
+    }
+    setIsRemoveFromListDialogOpen(false);
+  };
+
   const alreadyFavourited =
     selectedBookmarks.length &&
     selectedBookmarks.every((item) => item.favourited === true);
@@ -202,6 +242,18 @@ export default function BulkBookmarksAction() {
       isPending: false,
       hidden: !isBulkEditEnabled,
     },
+    {
+      name: t("actions.remove_from_list"),
+      icon: <ListMinus size={18} />,
+      action: () => setIsRemoveFromListDialogOpen(true),
+      isPending: removeBookmarkFromListMutator.isPending,
+      hidden:
+        !isBulkEditEnabled ||
+        !withinListContext ||
+        withinListContext.type !== "manual" ||
+        (withinListContext.userRole !== "editor" &&
+          withinListContext.userRole !== "owner"),
+    },
     {
       name: t("actions.add_to_list"),
       icon: <List size={18} />,
@@ -298,6 +350,27 @@ export default function BulkBookmarksAction() {
           </ActionButton>
         )}
       />
+      <ActionConfirmingDialog
+        open={isRemoveFromListDialogOpen}
+        setOpen={setIsRemoveFromListDialogOpen}
+        title={"Remove Bookmarks from List"}
+        description={
+          <p>
+            Are you sure you want to remove {selectedBookmarks.length} bookmarks
+            from this list?
+          </p>
+        }
+        actionButton={() => (
+          <ActionButton
+            type="button"
+            variant="destructive"
+            loading={removeBookmarkFromListMutator.isPending}
+            onClick={() => removeBookmarksFromList()}
+          >
+            {t("actions.remove")}
+          </ActionButton>
+        )}
+      />
       <BulkManageListsModal
         bookmarkIds={selectedBookmarks.map((b) => b.id)}
         open={manageListsModal}
-- 
cgit v1.2.3-70-g09d2

