= ({
+ {selection && selection.isSelectionMode && !readonly && (
+ selection.toggleMemoSelection(memo.name)}
+ onClick={(event) => event.stopPropagation()}
+ aria-label={t("common.select")}
+ />
+ )}
{currentUser && !isArchived && (
= ({
)}
-
+ selection.enterSelectionMode(memo.name) : undefined} />
);
diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx
index 518635094..bda7ab415 100644
--- a/web/src/components/PagedMemoList/PagedMemoList.tsx
+++ b/web/src/components/PagedMemoList/PagedMemoList.tsx
@@ -1,13 +1,17 @@
import { useQueryClient } from "@tanstack/react-query";
-import { ArrowUpIcon } from "lucide-react";
+import toast from "react-hot-toast";
+import { ArchiveIcon, ArrowUpIcon, BookmarkPlusIcon, TrashIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { matchPath } from "react-router-dom";
import { Button } from "@/components/ui/button";
+import ConfirmDialog from "@/components/ConfirmDialog";
import { userServiceClient } from "@/connect";
+import { MemoSelectionContext, useMemoSelection } from "@/contexts/MemoSelectionContext";
import { useView } from "@/contexts/ViewContext";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
-import { useInfiniteMemos } from "@/hooks/useMemoQueries";
+import { useDeleteMemo, useInfiniteMemos, useUpdateMemo } from "@/hooks/useMemoQueries";
import { userKeys } from "@/hooks/useUserQueries";
+import { handleError } from "@/lib/error";
import { Routes } from "@/router";
import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
@@ -85,6 +89,8 @@ const PagedMemoList = (props: Props) => {
const t = useTranslate();
const { layout } = useView();
const queryClient = useQueryClient();
+ const [isSelectionMode, setIsSelectionMode] = useState(false);
+ const [selectedMemoNames, setSelectedMemoNames] = useState>(() => new Set());
// Show memo editor only on the root route
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
@@ -105,6 +111,42 @@ const PagedMemoList = (props: Props) => {
// Apply custom sorting if provided, otherwise use memos directly
const sortedMemoList = useMemo(() => (props.listSort ? props.listSort(memos) : memos), [memos, props.listSort]);
+ const selectionContextValue = useMemo(() => {
+ const selectedCount = selectedMemoNames.size;
+ return {
+ isSelectionMode,
+ selectedMemoNames,
+ selectedCount,
+ isSelected: (name: string) => selectedMemoNames.has(name),
+ toggleMemoSelection: (name: string) => {
+ setSelectedMemoNames((prev) => {
+ const next = new Set(prev);
+ if (next.has(name)) {
+ next.delete(name);
+ } else {
+ next.add(name);
+ }
+ return next;
+ });
+ },
+ enterSelectionMode: (name?: string) => {
+ setIsSelectionMode(true);
+ if (name) {
+ setSelectedMemoNames((prev) => {
+ if (prev.has(name)) return prev;
+ const next = new Set(prev);
+ next.add(name);
+ return next;
+ });
+ }
+ },
+ exitSelectionMode: () => {
+ setIsSelectionMode(false);
+ setSelectedMemoNames(new Set());
+ },
+ };
+ }, [isSelectionMode, selectedMemoNames]);
+
// Prefetch creators when new data arrives to improve performance
useEffect(() => {
if (!data?.pages || !props.showCreator) return;
@@ -133,6 +175,23 @@ const PagedMemoList = (props: Props) => {
onFetchNext: fetchNextPage,
});
+ useEffect(() => {
+ if (!isSelectionMode || selectedMemoNames.size === 0) return;
+ const memoNameSet = new Set(sortedMemoList.map((memo) => memo.name));
+ setSelectedMemoNames((prev) => {
+ let changed = false;
+ const next = new Set();
+ for (const name of prev) {
+ if (memoNameSet.has(name)) {
+ next.add(name);
+ } else {
+ changed = true;
+ }
+ }
+ return changed ? next : prev;
+ });
+ }, [isSelectionMode, selectedMemoNames, sortedMemoList]);
+
// Infinite scroll: fetch more when user scrolls near bottom
useEffect(() => {
if (!hasNextPage) return;
@@ -160,6 +219,7 @@ const PagedMemoList = (props: Props) => {
renderer={props.renderer}
prefixElement={
<>
+
{showMemoEditor ? (
) : undefined}
@@ -192,7 +252,7 @@ const PagedMemoList = (props: Props) => {
);
- return children;
+ return {children};
};
const BackToTop = () => {
@@ -230,3 +290,115 @@ const BackToTop = () => {
};
export default PagedMemoList;
+
+const MemoSelectionBar = ({ memoList }: { memoList: Memo[] }) => {
+ const t = useTranslate();
+ const selection = useMemoSelection();
+ const { mutateAsync: updateMemo } = useUpdateMemo();
+ const { mutateAsync: deleteMemo } = useDeleteMemo();
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+
+ if (!selection || !selection.isSelectionMode) {
+ return null;
+ }
+
+ const selectedMemos = memoList.filter((memo) => selection.selectedMemoNames.has(memo.name));
+ const selectedCount = selection.selectedCount;
+
+ const handleBulkPin = async () => {
+ if (selectedCount === 0) return;
+ const targets = selectedMemos.filter((memo) => !memo.pinned);
+ if (targets.length === 0) return;
+ try {
+ await Promise.all(
+ targets.map((memo) => updateMemo({ update: { name: memo.name, pinned: true }, updateMask: ["pinned"] })),
+ );
+ toast.success(t("message.pinned-selected-memos"));
+ } catch (error: unknown) {
+ handleError(error, toast.error, {
+ context: "Bulk pin memos",
+ fallbackMessage: "Failed to pin selected memos",
+ });
+ }
+ };
+
+ const handleBulkArchive = async () => {
+ if (selectedCount === 0) return;
+ const targets = selectedMemos.filter((memo) => memo.state !== State.ARCHIVED);
+ if (targets.length === 0) return;
+ try {
+ await Promise.all(
+ targets.map((memo) => updateMemo({ update: { name: memo.name, state: State.ARCHIVED }, updateMask: ["state"] })),
+ );
+ toast.success(t("message.archived-selected-memos"));
+ } catch (error: unknown) {
+ handleError(error, toast.error, {
+ context: "Bulk archive memos",
+ fallbackMessage: "Failed to archive selected memos",
+ });
+ }
+ };
+
+ const confirmBulkDelete = async () => {
+ if (selectedCount === 0) return;
+ try {
+ await Promise.all(selectedMemos.map((memo) => deleteMemo(memo.name)));
+ toast.success(t("message.deleted-selected-memos"));
+ selection.exitSelectionMode();
+ } catch (error: unknown) {
+ handleError(error, toast.error, {
+ context: "Bulk delete memos",
+ fallbackMessage: "Failed to delete selected memos",
+ });
+ }
+ };
+
+ return (
+
+
{t("memo.selected-count", { count: selectedCount })}
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/web/src/contexts/MemoSelectionContext.tsx b/web/src/contexts/MemoSelectionContext.tsx
new file mode 100644
index 000000000..fa0f620ea
--- /dev/null
+++ b/web/src/contexts/MemoSelectionContext.tsx
@@ -0,0 +1,15 @@
+import { createContext, useContext } from "react";
+
+export interface MemoSelectionContextValue {
+ isSelectionMode: boolean;
+ selectedMemoNames: Set;
+ selectedCount: number;
+ isSelected: (name: string) => boolean;
+ toggleMemoSelection: (name: string) => void;
+ enterSelectionMode: (name?: string) => void;
+ exitSelectionMode: () => void;
+}
+
+export const MemoSelectionContext = createContext(null);
+
+export const useMemoSelection = () => useContext(MemoSelectionContext);
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index d4598eea1..5ed5df352 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -158,6 +158,8 @@
"count-memos-in-date": "{{count}} {{memos}} in {{date}}",
"delete-confirm": "Are you sure you want to delete this memo?",
"delete-confirm-description": "This action is irreversible. Attachments, links, and references will also be removed.",
+ "delete-selected-confirm": "Are you sure you want to delete the selected memos?",
+ "delete-selected-confirm-description": "This action is irreversible. Attachments, links, and references will also be removed.",
"direction": "Direction",
"direction-asc": "Ascending",
"direction-desc": "Descending",
@@ -171,6 +173,7 @@
"remove-completed-task-list-items": "Remove done",
"remove-completed-task-list-items-confirm": "Are you sure you want to remove all completed to-dos? THIS ACTION IS IRREVERSIBLE",
"search-placeholder": "Search memos...",
+ "selected-count": "{{count}} selected",
"show-less": "Show less",
"show-more": "Show more",
"to-do": "To-do",
@@ -186,8 +189,10 @@
},
"message": {
"archived-successfully": "Archived successfully",
+ "archived-selected-memos": "Archived selected memos",
"change-memo-created-time": "Change memo created time",
"copied": "Copied",
+ "deleted-selected-memos": "Deleted selected memos",
"deleted-successfully": "Memo deleted successfully",
"description-is-required": "Description is required",
"failed-to-embed-memo": "Failed to embed memo",
@@ -199,6 +204,7 @@
"no-data": "No data found.",
"password-changed": "Password Changed",
"password-not-match": "Passwords do not match.",
+ "pinned-selected-memos": "Pinned selected memos",
"remove-completed-task-list-items-successfully": "The removal was successful",
"restored-successfully": "Restored successfully",
"succeed-copy-content": "Content copied successfully.",