From 7a76216e5c971a300e9db32c93509b0376f6f47e Mon Sep 17 00:00:00 2001
From: Mohamed Bassem
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
---
.../components/dashboard/BulkBookmarksAction.tsx | 75 +++++++++++++++++++++-
.../dashboard/bookmarks/BookmarksGrid.tsx | 7 +-
2 files changed, 80 insertions(+), 2 deletions(-)
(limited to 'apps/web/components')
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: ,
+ 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:
,
@@ -298,6 +350,27 @@ export default function BulkBookmarksAction() {
)}
/>
+
+ Are you sure you want to remove {selectedBookmarks.length} bookmarks
+ from this list?
+
+ }
+ actionButton={() => (
+ removeBookmarksFromList()}
+ >
+ {t("actions.remove")}
+
+ )}
+ />
b.id)}
open={manageListsModal}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx
index f726c703..ab7bafb3 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx
@@ -16,6 +16,7 @@ import Masonry from "react-masonry-css";
import resolveConfig from "tailwindcss/resolveConfig";
import type { ZBookmark } from "@karakeep/shared/types/bookmarks";
+import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context";
import BookmarkCard from "./BookmarkCard";
import EditorCard from "./EditorCard";
@@ -64,6 +65,7 @@ export default function BookmarksGrid({
const gridColumns = useGridColumns();
const bulkActionsStore = useBulkActionsStore();
const inBookmarkGrid = useInBookmarkGridStore();
+ const withinListContext = useBookmarkListContext();
const breakpointConfig = useMemo(
() => getBreakpointConfig(gridColumns),
[gridColumns],
@@ -72,10 +74,13 @@ export default function BookmarksGrid({
useEffect(() => {
bulkActionsStore.setVisibleBookmarks(bookmarks);
+ bulkActionsStore.setListContext(withinListContext);
+
return () => {
bulkActionsStore.setVisibleBookmarks([]);
+ bulkActionsStore.setListContext(undefined);
};
- }, [bookmarks]);
+ }, [bookmarks, withinListContext?.id]);
useEffect(() => {
inBookmarkGrid.setInBookmarkGrid(true);
--
cgit v1.2.3-70-g09d2