From 4dfbc163b56a795b8fab687b773015ce30ca57d3 Mon Sep 17 00:00:00 2001 From: Local Admin Date: Mon, 26 Jan 2026 23:59:33 +0000 Subject: [PATCH] feat(ui): add bulk memo selection --- .../MemoActionMenu/MemoActionMenu.tsx | 6 + web/src/components/MemoActionMenu/types.ts | 1 + web/src/components/MemoView/MemoView.tsx | 14 +- .../MemoView/components/MemoHeader.tsx | 14 +- .../PagedMemoList/PagedMemoList.tsx | 178 +++++++++++++++++- web/src/contexts/MemoSelectionContext.tsx | 15 ++ web/src/locales/en.json | 6 + 7 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 web/src/contexts/MemoSelectionContext.tsx diff --git a/web/src/components/MemoActionMenu/MemoActionMenu.tsx b/web/src/components/MemoActionMenu/MemoActionMenu.tsx index afe2c0ce2..a19382a91 100644 --- a/web/src/components/MemoActionMenu/MemoActionMenu.tsx +++ b/web/src/components/MemoActionMenu/MemoActionMenu.tsx @@ -81,6 +81,12 @@ const MemoActionMenu = (props: MemoActionMenuProps) => { {t("common.edit")} + {props.onSelect && ( + + + {t("common.select")} + + )} )} diff --git a/web/src/components/MemoActionMenu/types.ts b/web/src/components/MemoActionMenu/types.ts index 9133f95ea..b5723a392 100644 --- a/web/src/components/MemoActionMenu/types.ts +++ b/web/src/components/MemoActionMenu/types.ts @@ -5,6 +5,7 @@ export interface MemoActionMenuProps { readonly?: boolean; className?: string; onEdit?: () => void; + onSelect?: () => void; } export interface UseMemoActionHandlersReturn { diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx index 9d617b7f8..d8e459754 100644 --- a/web/src/components/MemoView/MemoView.tsx +++ b/web/src/components/MemoView/MemoView.tsx @@ -4,6 +4,7 @@ import { useUser } from "@/hooks/useUserQueries"; import { cn } from "@/lib/utils"; import { State } from "@/types/proto/api/v1/common_pb"; import { isSuperUser } from "@/utils/user"; +import { useMemoSelection } from "@/contexts/MemoSelectionContext"; import MemoEditor from "../MemoEditor"; import PreviewImageDialog from "../PreviewImageDialog"; import { MemoBody, MemoHeader } from "./components"; @@ -26,6 +27,8 @@ const MemoView: React.FC = (props: MemoViewProps) => { const { nsfw, showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent); const { previewState, openPreview, setPreviewOpen } = useImagePreview(); const { unpinMemo } = useMemoActions(memoData, isArchived); + const selection = useMemoSelection(); + const isSelected = selection?.isSelected(memoData.name) ?? false; const handleEditorConfirm = () => setShowEditor(false); const handleEditorCancel = () => setShowEditor(false); @@ -68,7 +71,16 @@ const MemoView: React.FC = (props: MemoViewProps) => { return ( -
+
= ({ const { memo, creator, currentUser, parentPage, isArchived, readonly, showNSFWContent, nsfw } = useMemoViewContext(); const { isInMemoDetailPage, commentAmount, relativeTimeFormat } = useMemoViewDerived(); + const selection = useMemoSelection(); + const isSelected = selection?.isSelected(memo.name) ?? false; const displayTime = isArchived ? ( (memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language) @@ -52,6 +56,14 @@ const MemoHeader: React.FC = ({
+ {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.",