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