mirror of https://github.com/usememos/memos.git
feat(ui): add bulk memo selection
This commit is contained in:
parent
a8dbc1fd5e
commit
4dfbc163b5
|
|
@ -81,6 +81,12 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
|
|||
<Edit3Icon className="w-4 h-auto" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
{props.onSelect && (
|
||||
<DropdownMenuItem onClick={props.onSelect}>
|
||||
<SquareCheckIcon className="w-4 h-auto" />
|
||||
{t("common.select")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface MemoActionMenuProps {
|
|||
readonly?: boolean;
|
||||
className?: string;
|
||||
onEdit?: () => void;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
export interface UseMemoActionHandlersReturn {
|
||||
|
|
|
|||
|
|
@ -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<MemoViewProps> = (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<MemoViewProps> = (props: MemoViewProps) => {
|
|||
|
||||
return (
|
||||
<MemoViewContext.Provider value={contextValue}>
|
||||
<article className={cn(MEMO_CARD_BASE_CLASSES, className)} ref={cardRef} tabIndex={readonly ? -1 : 0}>
|
||||
<article
|
||||
className={cn(
|
||||
MEMO_CARD_BASE_CLASSES,
|
||||
className,
|
||||
selection?.isSelectionMode && "ring-1 ring-border/60",
|
||||
isSelected && "ring-2 ring-primary/50 bg-accent/20",
|
||||
)}
|
||||
ref={cardRef}
|
||||
tabIndex={readonly ? -1 : 0}
|
||||
>
|
||||
<MemoHeader
|
||||
showCreator={props.showCreator}
|
||||
showVisibility={props.showVisibility}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|||
import { BookmarkIcon, EyeOffIcon, MessageCircleMoreIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMemoSelection } from "@/contexts/MemoSelectionContext";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import i18n from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
|
@ -30,6 +32,8 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({
|
|||
|
||||
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<MemoHeaderProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
|
||||
{selection && selection.isSelectionMode && !readonly && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => selection.toggleMemoSelection(memo.name)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label={t("common.select")}
|
||||
/>
|
||||
)}
|
||||
{currentUser && !isArchived && (
|
||||
<ReactionSelector
|
||||
className={cn("border-none w-auto h-auto", reactionSelectorOpen && "block!", "block sm:hidden sm:group-hover:block")}
|
||||
|
|
@ -106,7 +118,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({
|
|||
</span>
|
||||
)}
|
||||
|
||||
<MemoActionMenu memo={memo} readonly={readonly} onEdit={onEdit} />
|
||||
<MemoActionMenu memo={memo} readonly={readonly} onEdit={onEdit} onSelect={selection ? () => selection.enterSelectionMode(memo.name) : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<Set<string>>(() => 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<string>();
|
||||
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={
|
||||
<>
|
||||
<MemoSelectionBar memoList={sortedMemoList} />
|
||||
{showMemoEditor ? (
|
||||
<MemoEditor className="mb-2" cacheKey="home-memo-editor" placeholder={t("editor.any-thoughts")} />
|
||||
) : undefined}
|
||||
|
|
@ -192,7 +252,7 @@ const PagedMemoList = (props: Props) => {
|
|||
</div>
|
||||
);
|
||||
|
||||
return children;
|
||||
return <MemoSelectionContext.Provider value={selectionContextValue}>{children}</MemoSelectionContext.Provider>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="w-full mb-2 flex flex-row justify-between items-center gap-3 rounded-lg border border-border/60 bg-accent/30 px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">{t("memo.selected-count", { count: selectedCount })}</span>
|
||||
<div className="flex flex-row justify-end items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={selectedCount === 0}
|
||||
onClick={handleBulkPin}
|
||||
aria-label={t("common.pin")}
|
||||
>
|
||||
<BookmarkPlusIcon className="w-4 h-auto" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={selectedCount === 0}
|
||||
onClick={handleBulkArchive}
|
||||
aria-label={t("common.archive")}
|
||||
>
|
||||
<ArchiveIcon className="w-4 h-auto" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={selectedCount === 0}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
aria-label={t("common.delete")}
|
||||
>
|
||||
<TrashIcon className="w-4 h-auto" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={selection.exitSelectionMode} aria-label={t("common.cancel")}>
|
||||
<XIcon className="w-4 h-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title={t("memo.delete-selected-confirm")}
|
||||
confirmLabel={t("common.delete")}
|
||||
description={t("memo.delete-selected-confirm-description")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmBulkDelete}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { createContext, useContext } from "react";
|
||||
|
||||
export interface MemoSelectionContextValue {
|
||||
isSelectionMode: boolean;
|
||||
selectedMemoNames: Set<string>;
|
||||
selectedCount: number;
|
||||
isSelected: (name: string) => boolean;
|
||||
toggleMemoSelection: (name: string) => void;
|
||||
enterSelectionMode: (name?: string) => void;
|
||||
exitSelectionMode: () => void;
|
||||
}
|
||||
|
||||
export const MemoSelectionContext = createContext<MemoSelectionContextValue | null>(null);
|
||||
|
||||
export const useMemoSelection = () => useContext(MemoSelectionContext);
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Reference in New Issue