diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 48cc8323..928e0fee 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -5,6 +5,7 @@ import { docToText, emptyDoc, ApiTokenCreateSchema, + DeleteMemosSchema, LoginSchema, markdownToDoc, MemoCreateSchema, @@ -718,6 +719,28 @@ app.post("/api/v1/memos/batch/move", zValidator("json", MoveMemosSchema), async } }); +app.post("/api/v1/memos/batch/delete", zValidator("json", DeleteMemosSchema), async (c) => { + const denied = requireScopes(c, "write:memos"); + + if (denied) { + return denied; + } + + const input = c.req.valid("json"); + const actor = getAuditActor(c); + + try { + const deleted = await deleteMemosRecord(c.env.DB, c.env.RESOURCES, input.memoIds, Boolean(input.permanent), actor); + return c.json({ ok: true, deleted }); + } catch (error) { + if (error instanceof AppError) { + return apiError(c, error.code, error.message, error.status); + } + + throw error; + } +}); + app.get("/api/v1/memos/:id", async (c) => { const denied = requireScopes(c, "read:memos"); @@ -2460,6 +2483,88 @@ const getMemoDetail = async (db: D1Database, id: string, includeDeleted = false) return row ? mapMemoDetail(row) : null; }; +const deleteMemosRecord = async ( + db: D1Database, + resourcesBucket: R2Bucket, + memoIds: string[], + permanent: boolean, + actor: { actorType: "user" | "agent"; actorId: string | null } +) => { + const uniqueMemoIds = Array.from(new Set(memoIds)); + + if (uniqueMemoIds.length === 0) { + return 0; + } + + const placeholders = uniqueMemoIds.map(() => "?").join(", "); + const expectedDeletedState = permanent ? 1 : 0; + const rows = await db + .prepare( + `SELECT id + FROM memos + WHERE is_deleted = ? AND id IN (${placeholders})` + ) + .bind(expectedDeletedState, ...uniqueMemoIds) + .all<{ id: string }>(); + + if (rows.results.length !== uniqueMemoIds.length) { + throw new AppError( + "missing_memos", + permanent ? "One or more memos cannot be permanently deleted." : "One or more memos cannot be deleted.", + 400 + ); + } + + const now = isoNow(); + const statements: D1PreparedStatement[] = []; + + if (permanent) { + const resourceRows = await db + .prepare( + `SELECT object_key + FROM resources + WHERE memo_id IN (${placeholders})` + ) + .bind(...uniqueMemoIds) + .all<{ object_key: string }>(); + const objectKeys = resourceRows.results.map((resource) => resource.object_key); + + if (objectKeys.length > 0) { + await resourcesBucket.delete(objectKeys); + } + + statements.push( + db.prepare(`DELETE FROM memos_fts WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds), + db.prepare(`DELETE FROM resources WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds), + db.prepare(`DELETE FROM memo_revisions WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds), + db.prepare(`DELETE FROM memo_contents WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds), + db.prepare(`DELETE FROM memos WHERE is_deleted = 1 AND id IN (${placeholders})`).bind(...uniqueMemoIds) + ); + + for (const memoId of uniqueMemoIds) { + statements.push(auditStatement(db, actor.actorType, actor.actorId, "memo.delete_permanent", "memo", memoId, {})); + } + } else { + statements.push( + db + .prepare( + `UPDATE memos + SET is_deleted = 1, deleted_at = ?, updated_at = ? + WHERE is_deleted = 0 AND id IN (${placeholders})` + ) + .bind(now, now, ...uniqueMemoIds), + db.prepare(`DELETE FROM memos_fts WHERE memo_id IN (${placeholders})`).bind(...uniqueMemoIds) + ); + + for (const memoId of uniqueMemoIds) { + statements.push(auditStatement(db, actor.actorType, actor.actorId, "memo.delete", "memo", memoId, {})); + } + } + + await db.batch(statements); + return uniqueMemoIds.length; +}; + const moveMemosToNotebook = async ( db: D1Database, memoIds: string[], diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index a325fdcc..d391cb8e 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -18,6 +18,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Archive, Bold, + ChevronLeft, CheckSquare, ChevronDown, ChevronRight, @@ -70,7 +71,9 @@ import { useState, type DragEvent, type FormEvent, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, + type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; import { api } from "@/lib/api"; @@ -90,9 +93,52 @@ import { Button } from "@/components/ui/button"; type Pane = "notebooks" | "memos" | "editor"; type MemoView = "notebook" | "trash"; +type MemoFilterMode = "all" | "tagged" | "untagged" | "pinned"; +type MemoSortMode = "updated-desc" | "created-desc" | "title-asc"; +type MemoListDensity = "preview" | "compact"; +type MobileBottomNavItem = "home" | "search" | "assets" | "settings"; +type MemoContextMenuState = { memo: MemoSummary; x: number; y: number }; type NotebookDropPosition = "before" | "inside" | "after"; const IMAGE_COMPRESSION_STORAGE_KEY = "edgeever.imageCompressionEnabled"; +const MEMO_LONG_PRESS_DELAY_MS = 520; +const MEMO_LONG_PRESS_MOVE_TOLERANCE_PX = 14; + +const useDismissableLayer = (open: boolean, onClose: () => void) => { + const ref = useRef(null); + + useEffect(() => { + if (!open) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + const node = ref.current; + + if (!node || node.contains(event.target as Node)) { + return; + } + + onClose(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose, open]); + + return ref; +}; export const App = () => { const queryClient = useQueryClient(); @@ -178,12 +224,14 @@ const WorkspaceApp = ({ const [selectedNotebookId, setSelectedNotebookId] = useState(null); const [selectedMemoId, setSelectedMemoId] = useState(null); const [selectedMemoIds, setSelectedMemoIds] = useState>(new Set()); + const [memoSelectionMode, setMemoSelectionMode] = useState(false); const [multiSelectKeyDown, setMultiSelectKeyDown] = useState(false); const [imageCompressionEnabled, setImageCompressionEnabled] = useState(readImageCompressionPreference); const [assetsOpen, setAssetsOpen] = useState(false); const [tagsOpen, setTagsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [mobileNotebookPickerOpen, setMobileNotebookPickerOpen] = useState(false); + const [mobileBottomNavActive, setMobileBottomNavActive] = useState("home"); const [mobileSearchFocusToken, setMobileSearchFocusToken] = useState(0); const [search, setSearch] = useState(""); const [syncSummary, setSyncSummary] = useState(emptySyncQueueSummary); @@ -219,15 +267,33 @@ const WorkspaceApp = ({ }); const notebooks = notebooksQuery.data?.notebooks ?? []; + const defaultMemoNotebookId = + selectedNotebookId ?? notebooks.find((notebook) => notebook.slug === "inbox")?.id ?? notebooks[0]?.id ?? null; + const canCreateMemo = Boolean(defaultMemoNotebookId && memoView !== "trash"); + const memoSelectionModeActive = memoSelectionMode || selectedMemoIds.size > 0; - useEffect(() => { - if (!selectedNotebookId && notebooks.length > 0) { - setSelectedNotebookId(notebooks[0].id); - } - }, [notebooks, selectedNotebookId]); + const clearMemoSelection = useCallback(() => { + setSelectedMemoIds(new Set()); + setMemoSelectionMode(false); + }, []); + + const replaceMemoSelection = useCallback((memoIds: string[]) => { + setSelectedMemoIds(new Set(memoIds)); + setMemoSelectionMode(true); + }, []); + + const enterMemoSelectionMode = useCallback(() => { + setMemoSelectionMode(true); + setActivePane("memos"); + }, []); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + clearMemoSelection(); + return; + } + if (event.ctrlKey || event.metaKey || event.key === "Control" || event.key === "Meta") { setMultiSelectKeyDown(true); } @@ -248,7 +314,7 @@ const WorkspaceApp = ({ window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("blur", handleBlur); }; - }, []); + }, [clearMemoSelection]); useEffect(() => { writeImageCompressionPreference(imageCompressionEnabled); @@ -296,10 +362,13 @@ const WorkspaceApp = ({ q: search, trash: memoView === "trash", }), - enabled: memoView === "trash" || Boolean(selectedNotebookId), }); const memos = memosQuery.data?.memos ?? []; + const selectedMemoIndex = selectedMemoId ? memos.findIndex((memo) => memo.id === selectedMemoId) : -1; + const previousMemoId = selectedMemoIndex > 0 ? memos[selectedMemoIndex - 1]?.id : null; + const nextMemoId = + selectedMemoIndex >= 0 && selectedMemoIndex < memos.length - 1 ? memos[selectedMemoIndex + 1]?.id : null; useEffect(() => { if (memos.length === 0) { @@ -367,7 +436,7 @@ const WorkspaceApp = ({ const mergeMutation = useMutation({ mutationFn: api.mergeMemos, onSuccess: async (data) => { - setSelectedMemoIds(new Set()); + clearMemoSelection(); await queryClient.invalidateQueries({ queryKey: ["memos"] }); queryClient.setQueryData(["memo", data.memo.id], { memo: data.memo }); setSelectedMemoId(data.memo.id); @@ -378,16 +447,41 @@ const WorkspaceApp = ({ const moveMemosMutation = useMutation({ mutationFn: api.moveMemos, onSuccess: async () => { - setSelectedMemoIds(new Set()); + clearMemoSelection(); await queryClient.invalidateQueries({ queryKey: ["memos"] }); await queryClient.invalidateQueries({ queryKey: ["memo"] }); }, }); + const deleteMemosMutation = useMutation({ + mutationFn: api.deleteMemos, + onSuccess: async (_, variables) => { + const deletedMemoIds = new Set(variables.memoIds); + + clearMemoSelection(); + + if (selectedMemoId && deletedMemoIds.has(selectedMemoId)) { + setSelectedMemoId(null); + setActivePane("memos"); + } + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["memos"] }), + queryClient.invalidateQueries({ queryKey: ["memo"] }), + queryClient.invalidateQueries({ queryKey: ["resources"] }), + ]); + }, + }); + const deleteMemoMutation = useMutation({ mutationFn: ({ memoId, permanent }: { memoId: string; permanent?: boolean }) => api.deleteMemo(memoId, { permanent }), - onSuccess: async () => { + onSuccess: async (_data, variables) => { + if (selectedMemoId === variables.memoId) { + setSelectedMemoId(null); + setActivePane("memos"); + } + await queryClient.invalidateQueries({ queryKey: ["memos"] }); }, }); @@ -441,12 +535,13 @@ const WorkspaceApp = ({ }; const handleCreateMemo = () => { - if (!selectedNotebookId || memoView === "trash") { + if (!defaultMemoNotebookId || memoView === "trash") { return; } + setMobileBottomNavActive("home"); createMemoMutation.mutate({ - notebookId: selectedNotebookId, + notebookId: defaultMemoNotebookId, title: DEFAULT_MEMO_TITLE, contentMarkdown: "", tags: [], @@ -488,21 +583,82 @@ const WorkspaceApp = ({ }); }; + const handleMoveMemoFromList = (memoId: string, targetNotebookId: string) => { + if (memoView === "trash") { + return; + } + + moveMemosMutation.mutate({ + memoIds: [memoId], + notebookId: targetNotebookId, + }); + }; + const handleMerge = () => { - if (!selectedNotebookId || selectedMemoIds.size < 2 || memoView === "trash") { + if (selectedMemoIds.size < 2 || memoView === "trash") { return; } mergeMutation.mutate({ memoIds: Array.from(selectedMemoIds), - notebookId: selectedNotebookId, + notebookId: selectedNotebookId ?? undefined, }); }; + const handleDeleteSelectedMemos = () => { + if (selectedMemoIds.size === 0) { + return; + } + + const count = selectedMemoIds.size; + const permanent = memoView === "trash"; + const confirmed = window.confirm( + permanent + ? `永久删除选中的 ${count} 条笔记?这个操作不可恢复。` + : `删除选中的 ${count} 条笔记?删除后可在回收站恢复。` + ); + + if (!confirmed) { + return; + } + + deleteMemosMutation.mutate({ + memoIds: Array.from(selectedMemoIds), + permanent, + }); + }; + + const handleDeleteMemoFromList = (memoId: string) => { + const permanent = memoView === "trash"; + const confirmed = window.confirm( + permanent ? "永久删除这条笔记?这个操作不可恢复。" : "删除这条笔记?删除后可在回收站恢复。" + ); + + if (!confirmed) { + return; + } + + deleteMemoMutation.mutate({ memoId, permanent }); + }; + + const handleRestoreMemoFromList = (memoId: string) => { + restoreMemoMutation.mutate(memoId); + }; + const handleSelectNotebook = (notebookId: string) => { setMemoView("notebook"); setSelectedNotebookId(notebookId); - setSelectedMemoIds(new Set()); + setMobileBottomNavActive("home"); + clearMemoSelection(); + setMobileNotebookPickerOpen(false); + setActivePane("memos"); + }; + + const handleSelectAllMemos = () => { + setMemoView("notebook"); + setSelectedNotebookId(null); + setMobileBottomNavActive("home"); + clearMemoSelection(); setMobileNotebookPickerOpen(false); setActivePane("memos"); }; @@ -512,15 +668,38 @@ const WorkspaceApp = ({ setMemoView("notebook"); } - setSelectedMemoIds(new Set()); + setMobileBottomNavActive("home"); + setSelectedNotebookId(null); + clearMemoSelection(); setActivePane("memos"); }; const handleMobileSearch = () => { + setMobileBottomNavActive("search"); setActivePane("memos"); setMobileSearchFocusToken((value) => value + 1); }; + const handleOpenAssets = () => { + setMobileBottomNavActive("assets"); + setAssetsOpen(true); + }; + + const handleOpenSettings = () => { + setMobileBottomNavActive("settings"); + setSettingsOpen(true); + }; + + const handleCloseAssets = () => { + setAssetsOpen(false); + setMobileBottomNavActive("home"); + }; + + const handleCloseSettings = () => { + setSettingsOpen(false); + setMobileBottomNavActive("home"); + }; + return (
@@ -536,18 +715,30 @@ const WorkspaceApp = ({ user={user} notebooks={notebooks} selectedNotebookId={selectedNotebookId} + view={memoView} isLoading={notebooksQuery.isLoading} + canCreateMemo={canCreateMemo} + isCreatingMemo={createMemoMutation.isPending} onSelect={(notebookId) => { setMemoView("notebook"); setSelectedNotebookId(notebookId); - setSelectedMemoIds(new Set()); + clearMemoSelection(); setActivePane("memos"); }} + onCreateMemo={handleCreateMemo} onCreateNotebook={handleCreateNotebook} onRenameNotebook={handleRenameNotebook} onDeleteNotebook={handleDeleteNotebook} onMoveNotebook={handleMoveNotebook} - onBackToList={() => setActivePane("memos")} + onBackToList={() => { + if (memoView === "trash") { + setMemoView("notebook"); + } + + setSelectedNotebookId(null); + clearMemoSelection(); + setActivePane("memos"); + }} onLogout={onLogout} isLoggingOut={isLoggingOut} imageCompressionEnabled={imageCompressionEnabled} @@ -556,12 +747,14 @@ const WorkspaceApp = ({ isOnline={isOnline} isSyncingQueuedChanges={isSyncingQueuedChanges} onSyncQueuedChanges={() => void runQueuedSync()} - onOpenAssets={() => setAssetsOpen(true)} + onOpenAssets={handleOpenAssets} onOpenTags={() => setTagsOpen(true)} - onOpenSettings={() => setSettingsOpen(true)} + onOpenSettings={handleOpenSettings} onOpenTrash={() => { setMemoView("trash"); - setSelectedMemoIds(new Set()); + setSelectedNotebookId(null); + setMobileBottomNavActive("home"); + clearMemoSelection(); setSelectedMemoId(null); setActivePane("memos"); }} @@ -581,24 +774,58 @@ const WorkspaceApp = ({ memos={memos} selectedMemoId={selectedMemoId} selectedMemoIds={selectedMemoIds} + selectionMode={memoSelectionModeActive} search={search} searchFocusToken={mobileSearchFocusToken} + canCreateMemo={canCreateMemo} isLoading={memosQuery.isLoading} isCreating={createMemoMutation.isPending} isMerging={mergeMutation.isPending} isMoving={moveMemosMutation.isPending} + isDeleting={deleteMemosMutation.isPending || deleteMemoMutation.isPending} multiSelectKeyDown={multiSelectKeyDown} onOpenNotebookPicker={() => setMobileNotebookPickerOpen(true)} onSearch={setSearch} onCreateMemo={handleCreateMemo} + onClearSelection={clearMemoSelection} + onEnterSelectionMode={enterMemoSelectionMode} + onReplaceSelection={replaceMemoSelection} + onOpenAssets={handleOpenAssets} + onOpenTags={() => setTagsOpen(true)} + onOpenSettings={handleOpenSettings} + onOpenTrash={() => { + setMemoView("trash"); + setSelectedNotebookId(null); + setMobileBottomNavActive("home"); + clearMemoSelection(); + setSelectedMemoId(null); + setActivePane("memos"); + }} onOpenMemo={(memoId) => { setSelectedMemoId(memoId); setActivePane("editor"); }} - onToggleMemo={(memoId) => { - setSelectedMemoIds((current) => toggleMemoSelection(current, memoId)); + onToggleMemo={(memoId, rangeMemoIds) => { + setMemoSelectionMode(true); + setSelectedMemoIds((current) => { + if (!rangeMemoIds?.length) { + return toggleMemoSelection(current, memoId); + } + + const next = new Set(current); + + for (const rangeMemoId of rangeMemoIds) { + next.add(rangeMemoId); + } + + return next; + }); }} onMerge={handleMerge} + onDeleteMemo={handleDeleteMemoFromList} + onRestoreMemo={handleRestoreMemoFromList} + onMoveMemo={handleMoveMemoFromList} + onDeleteSelectedMemos={handleDeleteSelectedMemos} onMoveSelectedMemos={handleMoveSelectedMemos} /> @@ -610,7 +837,19 @@ const WorkspaceApp = ({ notebooks={notebooks} isLoading={memoQuery.isLoading} imageCompressionEnabled={imageCompressionEnabled} + hasNextMemo={Boolean(nextMemoId)} + hasPreviousMemo={Boolean(previousMemoId)} onBackToList={() => setActivePane("memos")} + onOpenNextMemo={() => { + if (nextMemoId) { + setSelectedMemoId(nextMemoId); + } + }} + onOpenPreviousMemo={() => { + if (previousMemoId) { + setSelectedMemoId(previousMemoId); + } + }} onSaved={async (memo) => { queryClient.setQueryData(["memo", memo.id], { memo }); await queryClient.invalidateQueries({ queryKey: ["memos"] }); @@ -632,17 +871,18 @@ const WorkspaceApp = ({
- {assetsOpen ? setAssetsOpen(false)} /> : null} + {assetsOpen ? : null} {tagsOpen ? setTagsOpen(false)} /> : null} - {settingsOpen ? setSettingsOpen(false)} /> : null} - {activePane !== "editor" ? ( + {settingsOpen ? : null} + {activePane !== "editor" && !memoSelectionModeActive ? ( setAssetsOpen(true)} - onOpenSettings={() => setSettingsOpen(true)} + onOpenAssets={handleOpenAssets} + onOpenSettings={handleOpenSettings} onSearch={handleMobileSearch} /> ) : null} @@ -651,6 +891,7 @@ const WorkspaceApp = ({ notebooks={notebooks} selectedNotebookId={selectedNotebookId} onClose={() => setMobileNotebookPickerOpen(false)} + onSelectAll={handleSelectAllMemos} onSelect={handleSelectNotebook} /> ) : null} @@ -811,8 +1052,12 @@ const NotebookPane = ({ user, notebooks, selectedNotebookId, + view, isLoading, + canCreateMemo, + isCreatingMemo, onSelect, + onCreateMemo, onCreateNotebook, onRenameNotebook, onDeleteNotebook, @@ -835,8 +1080,12 @@ const NotebookPane = ({ user: AuthUser | null; notebooks: Notebook[]; selectedNotebookId: string | null; + view: MemoView; isLoading: boolean; + canCreateMemo: boolean; + isCreatingMemo: boolean; onSelect: (notebookId: string) => void; + onCreateMemo: () => void; onCreateNotebook: (parentId?: string | null) => void; onRenameNotebook: (notebook: Notebook) => void; onDeleteNotebook: (notebook: Notebook) => void; @@ -863,11 +1112,11 @@ const NotebookPane = ({
笔记本
EdgeEver
-
{user?.username ?? "Cloudflare-native notes"}
+
{user?.username ?? "边缘笔记工作区"}
+ + + +
+ + + 笔记本 + +
{isLoading ? ( @@ -919,25 +1208,12 @@ const NotebookPane = ({ aria-label="粘贴图片时自动压缩" /> -
- - - - - {authRequired ? ( - - ) : null} -
+ ) : null}
); @@ -991,6 +1267,30 @@ const SyncStatusBar = ({ ); }; +const SidebarNavButton = ({ + active = false, + icon, + label, + onClick, +}: { + active?: boolean; + icon: ReactNode; + label: string; + onClick: () => void; +}) => ( + +); + const getSyncStatusLabel = (summary: SyncQueueSummary, isOnline: boolean, isSyncing: boolean) => { if (!isOnline) { return summary.total > 0 ? `离线,${summary.total} 项待同步` : "离线"; @@ -1016,6 +1316,7 @@ const getSyncStatusLabel = (summary: SyncQueueSummary, isOnline: boolean, isSync }; const MobileBottomNav = ({ + activeItem, canCreateMemo, isCreating, onCreateMemo, @@ -1024,6 +1325,7 @@ const MobileBottomNav = ({ onOpenSettings, onSearch, }: { + activeItem: MobileBottomNavItem; canCreateMemo: boolean; isCreating: boolean; onCreateMemo: () => void; @@ -1032,23 +1334,22 @@ const MobileBottomNav = ({ onOpenSettings: () => void; onSearch: () => void; }) => ( -