From 0368939de5727f2df6f18dee64d8289f28d4e5d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Dec 2025 02:33:35 +0000 Subject: [PATCH 1/6] feat(sidebar): add multi-select and drag support with context menu - Add selection state to useUIStore with Ctrl/Cmd+click toggle and Shift+click range selection - Extract PageTreeItemContent as presentational component following dnd-kit patterns - Update SortableTree with DragOverlay for multi-select drag visualization - Add context-menu.tsx component (Radix UI based, shadcn/ui pattern) - Implement right-click context menu with batch actions for multi-select - Add batch-trash and batch-move API endpoints with permission checks - Update PageEventPayload to support batch operation events - Wire up selection interactions in PageTree and PageTreeItem --- apps/web/package.json | 1 + .../web/src/app/api/pages/batch-move/route.ts | 65 +++ .../src/app/api/pages/batch-trash/route.ts | 66 ++++ .../left-sidebar/page-tree/PageTree.tsx | 84 +++- .../left-sidebar/page-tree/PageTreeItem.tsx | 369 +++++++++--------- .../page-tree/PageTreeItemContent.tsx | 318 +++++++++++++++ apps/web/src/components/ui/context-menu.tsx | 255 ++++++++++++ .../ui/sortable-tree/SortableTree.tsx | 92 ++++- apps/web/src/hooks/useUI.ts | 62 ++- apps/web/src/lib/websocket/socket-utils.ts | 8 +- apps/web/src/services/api/page-service.ts | 209 ++++++++++ apps/web/src/stores/useUIStore.ts | 106 ++++- pnpm-lock.yaml | 31 ++ 13 files changed, 1453 insertions(+), 213 deletions(-) create mode 100644 apps/web/src/app/api/pages/batch-move/route.ts create mode 100644 apps/web/src/app/api/pages/batch-trash/route.ts create mode 100644 apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx create mode 100644 apps/web/src/components/ui/context-menu.tsx diff --git a/apps/web/package.json b/apps/web/package.json index ab668db70..d42308a9d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", diff --git a/apps/web/src/app/api/pages/batch-move/route.ts b/apps/web/src/app/api/pages/batch-move/route.ts new file mode 100644 index 000000000..7d391b57b --- /dev/null +++ b/apps/web/src/app/api/pages/batch-move/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod/v4'; +import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket'; +import { loggers, pageTreeCache } from '@pagespace/lib/server'; +import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { pageService } from '@/services/api'; + +const AUTH_OPTIONS = { allow: ['jwt', 'mcp'] as const, requireCSRF: true }; + +const batchMoveSchema = z.object({ + pageIds: z.array(z.string()).min(1, 'At least one page ID is required'), + newParentId: z.string().nullable(), + insertionIndex: z.number().int().optional(), +}); + +export async function POST(request: Request) { + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS); + if (isAuthError(auth)) { + return auth.error; + } + + try { + const body = await request.json(); + const { pageIds, newParentId } = batchMoveSchema.parse(body); + + const result = await pageService.batchMovePages(pageIds, newParentId, auth.userId); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + + // Broadcast events and invalidate caches for all affected drives + for (const driveId of result.driveIds) { + // Broadcast a batch move event + await broadcastPageEvent( + createPageEventPayload(driveId, pageIds[0], 'batch-moved', { + pageIds, + count: result.movedCount, + parentId: result.targetParentId ?? undefined, + }) + ); + + // Invalidate page tree cache + await pageTreeCache.invalidateDriveTree(driveId); + } + + return NextResponse.json({ + message: `${result.movedCount} pages moved successfully.`, + movedCount: result.movedCount, + targetParentId: result.targetParentId, + }); + } catch (error) { + loggers.api.error('Error batch moving pages:', error as Error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.issues[0]?.message || 'Validation failed' }, + { status: 400 } + ); + } + + const errorMessage = error instanceof Error ? error.message : 'Failed to batch move pages'; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/pages/batch-trash/route.ts b/apps/web/src/app/api/pages/batch-trash/route.ts new file mode 100644 index 000000000..ac4e87a5d --- /dev/null +++ b/apps/web/src/app/api/pages/batch-trash/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod/v4'; +import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket'; +import { loggers, agentAwarenessCache, pageTreeCache } from '@pagespace/lib/server'; +import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { pageService } from '@/services/api'; + +const AUTH_OPTIONS = { allow: ['jwt', 'mcp'] as const, requireCSRF: true }; + +const batchTrashSchema = z.object({ + pageIds: z.array(z.string()).min(1, 'At least one page ID is required'), +}); + +export async function POST(request: Request) { + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS); + if (isAuthError(auth)) { + return auth.error; + } + + try { + const body = await request.json(); + const { pageIds } = batchTrashSchema.parse(body); + + const result = await pageService.batchTrashPages(pageIds, auth.userId); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + + // Broadcast events and invalidate caches for all affected drives + for (const driveId of result.driveIds) { + // Broadcast a batch trash event + await broadcastPageEvent( + createPageEventPayload(driveId, pageIds[0], 'batch-trashed', { + pageIds, + count: result.trashedCount, + }) + ); + + // Invalidate page tree cache + await pageTreeCache.invalidateDriveTree(driveId); + + // Invalidate agent awareness cache if AI_CHAT pages were trashed + if (result.hasAIChatPages) { + await agentAwarenessCache.invalidateDriveAgents(driveId); + } + } + + return NextResponse.json({ + message: `${result.trashedCount} pages moved to trash successfully.`, + trashedCount: result.trashedCount, + }); + } catch (error) { + loggers.api.error('Error batch trashing pages:', error as Error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.issues[0]?.message || 'Validation failed' }, + { status: 400 } + ); + } + + const errorMessage = error instanceof Error ? error.message : 'Failed to batch trash pages'; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/PageTree.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/PageTree.tsx index 8a732f5e2..2edf9073e 100644 --- a/apps/web/src/components/layout/left-sidebar/page-tree/PageTree.tsx +++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTree.tsx @@ -1,20 +1,22 @@ "use client"; -import { useState, useMemo, useCallback } from "react"; +import { useState, useMemo, useCallback, useEffect } from "react"; import { FileIcon } from "lucide-react"; -import { patch } from "@/lib/auth/auth-fetch"; +import { patch, post } from "@/lib/auth/auth-fetch"; import { TreePage } from "@/hooks/usePageTree"; import { usePageTreeSocket } from "@/hooks/usePageTreeSocket"; import { findNodeAndParent } from "@/lib/tree/tree-utils"; import { Skeleton } from "@/components/ui/skeleton"; import CreatePageDialog from "../CreatePageDialog"; -import { useTreeState } from "@/hooks/useUI"; +import { useTreeState, usePageSelection } from "@/hooks/useUI"; import { useFileDrop } from "@/hooks/useFileDrop"; import { cn } from "@/lib/utils"; import { SortableTree } from "@/components/ui/sortable-tree"; import { Projection, TreeItem } from "@/lib/tree/sortable-tree"; import { PageTreeItem, DropPosition } from "./PageTreeItem"; +import { DragOverlayContent } from "./PageTreeItemContent"; import { KeyedMutator } from "swr"; +import { toast } from "sonner"; interface PageTreeProps { driveId: string; @@ -38,6 +40,11 @@ export default function PageTree({ const tree = initialTree ?? fetchedTree; const mutate = externalMutate ?? internalMutate; const { expanded: expandedNodes, toggleExpanded } = useTreeState(); + const { + selectedPageIds, + setSelection, + clearSelection, + } = usePageSelection(); const [createPageInfo, setCreatePageInfo] = useState<{ isOpen: boolean; @@ -63,6 +70,23 @@ export default function PageTree({ onUploadComplete: () => mutate(), }); + // Clear selection when clicking outside the tree + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + // If click is outside the sidebar tree, clear selection + if (!target.closest('[data-tree-node-id]') && !target.closest('[data-slot="context-menu"]')) { + // Only clear if not right-clicking (context menu) + if (e.button === 0) { + clearSelection(); + } + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [clearSelection]); + // Filter tree by search query const filterTree = useCallback((nodes: TreePage[], query: string): TreePage[] => { if (!query) return nodes; @@ -103,7 +127,7 @@ export default function PageTree({ return allIds; }, [displayedTree, expandedNodes]); - // Handle move from SortableTree + // Handle single move from SortableTree const handleMove = useCallback( async (activeId: string, _overId: string, projection: Projection) => { const activeInfo = findNodeAndParent(tree, activeId); @@ -116,7 +140,7 @@ export default function PageTree({ const siblings = newParent ? newParent.children : tree; // Filter out the active item from siblings (it's being moved) - const siblingsWithoutActive = siblings.filter(s => s.id !== activeId); + const siblingsWithoutActive = siblings.filter((s: TreePage) => s.id !== activeId); // Use projection.insertionIndex for correct position calculation const insertAt = projection.insertionIndex; @@ -159,6 +183,41 @@ export default function PageTree({ [tree, mutate, expandedNodes, toggleExpanded] ); + // Handle multi-move from SortableTree + const handleMultiMove = useCallback( + async (activeIds: string[], _overId: string, projection: Projection) => { + const newParentId = projection.parentId; + + // Auto-expand parent if dropping inside + if (newParentId && !expandedNodes.has(newParentId)) { + toggleExpanded(newParentId); + } + + const toastId = toast.loading(`Moving ${activeIds.length} pages...`); + try { + await post("/api/pages/batch-move", { + pageIds: activeIds, + newParentId: newParentId, + insertionIndex: projection.insertionIndex, + }); + await mutate(); + toast.success(`${activeIds.length} pages moved.`, { id: toastId }); + } catch (error) { + console.error("Failed to batch move pages:", error); + toast.error("Failed to move pages.", { id: toastId }); + } + }, + [expandedNodes, toggleExpanded, mutate] + ); + + // Handle selection change from SortableTree (when dragging unselected item) + const handleSelectionChange = useCallback( + (ids: string[]) => { + setSelection(ids); + }, + [setSelection] + ); + const handleToggleExpand = useCallback( (id: string) => { toggleExpanded(id); @@ -274,6 +333,14 @@ export default function PageTree({ [isDraggingFiles, fileDragState, tree, handleFileDrop] ); + // Render drag overlay for multi-select + const renderDragOverlay = useCallback( + ({ items, totalCount }: { items: TreePage[]; totalCount: number }) => { + return ; + }, + [] + ); + if (isLoading) { return (
@@ -297,8 +364,12 @@ export default function PageTree({ items={displayedTree as SortableTreePage[]} collapsedIds={collapsedIds} indentationWidth={24} + selectedIds={selectedPageIds} onMove={handleMove} - renderItem={({ item, depth, isActive, isOver, dropPosition, projectedDepth, projected, handleProps, wrapperProps }) => ( + onMultiMove={handleMultiMove} + onSelectionChange={handleSelectionChange} + renderDragOverlay={renderDragOverlay} + renderItem={({ item, depth, isActive, isOver, dropPosition, projectedDepth, projected, flattenedIds, handleProps, wrapperProps }) => ( )} /> diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx index b867b8bca..beaa00a39 100644 --- a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx +++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx @@ -1,35 +1,32 @@ "use client"; -import { useState, CSSProperties } from "react"; -import Link from "next/link"; +import { useState, CSSProperties, MouseEvent, useCallback } from "react"; import { useParams } from "next/navigation"; import { - ChevronRight, - Plus, - MoreHorizontal, Trash2, Pencil, Star, Undo2, + FolderInput, } from "lucide-react"; import { TreePage } from "@/hooks/usePageTree"; -import { PageTypeIcon } from "@/components/common/PageTypeIcon"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { useFavorites } from "@/hooks/useFavorites"; import { toast } from "sonner"; import { DeletePageDialog } from "@/components/dialogs/DeletePageDialog"; import { RenameDialog } from "@/components/dialogs/RenameDialog"; import { patch, del, post } from "@/lib/auth/auth-fetch"; import { Projection } from "@/lib/tree/sortable-tree"; -import { cn } from "@/lib/utils"; import { useSortable } from "@dnd-kit/sortable"; - -export type DropPosition = "before" | "after" | "inside" | null; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { PageTreeItemContent, DropPosition } from "./PageTreeItemContent"; +import { usePageSelection } from "@/hooks/useUI"; +import { SelectionMode } from "@/stores/useUIStore"; interface HandleProps { ref: (element: HTMLElement | null) => void; @@ -62,6 +59,8 @@ export interface PageTreeItemProps { overId: string | null; dropPosition: DropPosition; }; + // For range selection + flattenedIds?: string[]; } export function PageTreeItem({ @@ -80,6 +79,7 @@ export function PageTreeItem({ mutate, isTrashView = false, fileDragState, + flattenedIds = [], }: PageTreeItemProps) { // Silence unused var - projected is passed but only needed for future use void projected; @@ -92,22 +92,29 @@ export function PageTreeItem({ const [isRenameOpen, setRenameOpen] = useState(false); const params = useParams(); const { addFavorite, removeFavorite, isFavorite } = useFavorites(); - const hasChildren = item.children && item.children.length > 0; + const { + isSelected, + selectPage, + clearSelection, + getSelectedIds, + isMultiSelect, + } = usePageSelection(); - const linkHref = `/dashboard/${params.driveId}/${item.id}`; + const hasChildren = item.children && item.children.length > 0; + const itemIsSelected = isSelected(item.id); + const multiSelectActive = isMultiSelect(); // Combine file drops AND internal dnd-kit drags for drop indicators const isFileDragOver = fileDragState?.overId === item.id; const isInternalDragOver = isOver && !isActive; const showDropIndicator = isFileDragOver || isInternalDragOver; - // Determine drop position: - // - For file drags: use fileDragState.dropPosition - // - For internal drags: use dropPosition from SortableTree + // Determine drop position const dropPosition: DropPosition = isFileDragOver ? fileDragState?.dropPosition ?? null : internalDropPosition ?? null; + // Action handlers const handleRename = async (newName: string) => { const toastId = toast.loading("Renaming page..."); try { @@ -134,6 +141,21 @@ export function PageTreeItem({ } }; + const handleBatchDelete = async () => { + const selectedIds = getSelectedIds(); + if (selectedIds.length === 0) return; + + const toastId = toast.loading(`Moving ${selectedIds.length} pages to trash...`); + try { + await post("/api/pages/batch-trash", { pageIds: selectedIds }); + clearSelection(); + await mutate(); + toast.success(`${selectedIds.length} pages moved to trash.`, { id: toastId }); + } catch { + toast.error("Error moving pages to trash.", { id: toastId }); + } + }; + const handleRestore = async () => { const toastId = toast.loading("Restoring page..."); try { @@ -169,174 +191,158 @@ export function PageTreeItem({ } }; - return ( - <> -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - {/* Drop indicator - BEFORE */} - {showDropIndicator && dropPosition === "before" && ( -
-
-
-
- )} + // Selection click handler + const handleSelectionClick = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); -
- {/* Expand/Collapse Chevron */} - {hasChildren && ( - - )} + // Determine selection mode based on modifier keys + let mode: SelectionMode = "toggle"; + if (e.shiftKey) { + mode = "range"; + } else if (e.ctrlKey || e.metaKey) { + mode = "toggle"; + } - {/* Icon - Drag Handle */} -
- -
+ selectPage(item.id, mode, flattenedIds); + }, + [item.id, selectPage, flattenedIds] + ); - {/* Title - Click to Navigate */} - - {item.title} - + // Row click handler + const handleRowClick = useCallback( + (e: MouseEvent) => { + // If clicking on selection checkbox area, handled separately + if ((e.target as HTMLElement).closest("[data-selection-checkbox]")) { + return; + } - {/* Action Buttons */} -
- - - - - - e.stopPropagation()} - className="w-48" - > - {isTrashView ? ( - <> - - - Restore - - - - Delete Permanently - - - ) : ( - <> - setRenameOpen(true)}> - - Rename - - - - {isFavorite(item.id) ? "Unfavorite" : "Favorite"} - - setConfirmTrashOpen(true)} - className="text-red-500 focus:text-red-500" - > - - Trash - - - )} - - -
+ // Handle selection with modifier keys + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + selectPage(item.id, "toggle", flattenedIds); + } else if (e.shiftKey) { + e.preventDefault(); + selectPage(item.id, "range", flattenedIds); + } + // Normal click navigates (handled by Link) + }, + [item.id, selectPage, flattenedIds] + ); - {/* Visual hint for inside drop */} - {showDropIndicator && dropPosition === "inside" && ( -
-
-
- )} -
+ // Context menu handler + const handleContextMenu = useCallback( + (e: MouseEvent) => { + // If this item is not selected, select it first (single selection) + if (!itemIsSelected) { + clearSelection(); + selectPage(item.id, "single"); + } + // Context menu will show batch options if multiple selected + }, + [item.id, itemIsSelected, clearSelection, selectPage] + ); - {/* Drop indicator - AFTER */} - {showDropIndicator && dropPosition === "after" && ( -
-
-
+ + +
+ setRenameOpen(true)} + onTrash={() => setConfirmTrashOpen(true)} + onRestore={handleRestore} + onPermanentDelete={handlePermanentDelete} + onFavoriteToggle={handleFavoriteToggle} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={handleRowClick} + onContextMenu={handleContextMenu} + onSelectionClick={handleSelectionClick} + dragHandleProps={{ + listeners: handleProps.listeners, + attributes: handleProps.attributes, + }} />
- )} -
+ + + + {isTrashView ? ( + <> + + + Restore + + + + Delete Permanently + + + ) : multiSelectActive && itemIsSelected ? ( + // Multi-select context menu + <> + + + Move {getSelectedIds().length} pages... + + + + + Trash {getSelectedIds().length} pages + + + ) : ( + // Single item context menu + <> + setRenameOpen(true)}> + + Rename + + + + {isFavorite(item.id) ? "Unfavorite" : "Favorite"} + + + setConfirmTrashOpen(true)} + variant="destructive" + > + + Trash + + + )} + + ); } + +// Re-export DropPosition for backwards compatibility +export type { DropPosition }; diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx new file mode 100644 index 000000000..ddcb5bc2a --- /dev/null +++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { CSSProperties, forwardRef, MouseEvent } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { + ChevronRight, + Plus, + MoreHorizontal, + Trash2, + Pencil, + Star, + Undo2, + GripVertical, +} from "lucide-react"; +import { TreePage } from "@/hooks/usePageTree"; +import { PageTypeIcon } from "@/components/common/PageTypeIcon"; +import { cn } from "@/lib/utils"; + +export type DropPosition = "before" | "after" | "inside" | null; + +export interface PageTreeItemContentProps { + item: TreePage; + depth: number; + isSelected?: boolean; + isActive?: boolean; // Currently being dragged + isDragOverlay?: boolean; // Rendered inside DragOverlay + showDropIndicator?: boolean; + dropPosition?: DropPosition; + indicatorDepth?: number; + isExpanded?: boolean; + hasChildren?: boolean; + isFavorite?: boolean; + isHovered?: boolean; + isTrashView?: boolean; + style?: CSSProperties; + // Event handlers - optional for presentational use + onToggleExpand?: (id: string) => void; + onOpenCreateDialog?: (parentId: string | null) => void; + onRename?: () => void; + onTrash?: () => void; + onRestore?: () => void; + onPermanentDelete?: () => void; + onFavoriteToggle?: () => void; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + onClick?: (e: MouseEvent) => void; + onContextMenu?: (e: MouseEvent) => void; + // For selection indicator click + onSelectionClick?: (e: MouseEvent) => void; + // Drag handle props (optional - only when draggable) + dragHandleProps?: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + listeners?: Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + attributes?: Record; + }; +} + +export const PageTreeItemContent = forwardRef( + function PageTreeItemContent( + { + item, + depth, + isSelected = false, + isActive = false, + isDragOverlay = false, + showDropIndicator = false, + dropPosition = null, + indicatorDepth, + isExpanded = false, + hasChildren = false, + isFavorite = false, + isHovered = false, + isTrashView = false, + style, + onToggleExpand, + onOpenCreateDialog, + onRename, + onTrash, + onRestore, + onPermanentDelete, + onFavoriteToggle, + onMouseEnter, + onMouseLeave, + onClick, + onContextMenu, + onSelectionClick, + dragHandleProps, + }, + ref + ) { + const params = useParams(); + const linkHref = `/dashboard/${params.driveId}/${item.id}`; + const effectiveIndicatorDepth = indicatorDepth ?? depth; + + // Compute if children exist from item + const itemHasChildren = hasChildren || (item.children && item.children.length > 0); + + return ( +
+ {/* Drop indicator - BEFORE */} + {showDropIndicator && dropPosition === "before" && ( +
+
+
+
+ )} + +
+ {/* Selection indicator / checkbox area */} +
+
+ {isSelected && ( + + + + )} +
+
+ + {/* Expand/Collapse Chevron */} + {itemHasChildren ? ( + + ) : ( +
// Spacer for alignment + )} + + {/* Icon / Drag Handle */} +
+ +
+ + {/* Title - Click to Navigate */} + e.stopPropagation()} + > + {item.title} + + + {/* Action Buttons - visible on hover */} +
+ {!isTrashView && ( + + )} + {/* 3-dot menu trigger - we keep this for single-item actions */} + +
+ + {/* Visual hint for inside drop */} + {showDropIndicator && dropPosition === "inside" && ( +
+
+
+ )} +
+ + {/* Drop indicator - AFTER */} + {showDropIndicator && dropPosition === "after" && ( +
+
+
+
+ )} +
+ ); + } +); + +// Compact version for DragOverlay with multiple items +export interface DragOverlayContentProps { + items: TreePage[]; + totalCount: number; +} + +export function DragOverlayContent({ items, totalCount }: DragOverlayContentProps) { + const firstItem = items[0]; + if (!firstItem) return null; + + if (totalCount === 1) { + // Single item drag - show the full item + return ( +
+
+ + + {firstItem.title} +
+
+ ); + } + + // Multi-item drag - show stacked preview with count + return ( +
+ {/* Background cards for stacked effect */} +
+
+ + {/* Main card */} +
+
+ + + {firstItem.title} + + {totalCount} + +
+
+
+ ); +} diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx new file mode 100644 index 000000000..d3473de96 --- /dev/null +++ b/apps/web/src/components/ui/context-menu.tsx @@ -0,0 +1,255 @@ +"use client" + +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils/index" + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/apps/web/src/components/ui/sortable-tree/SortableTree.tsx b/apps/web/src/components/ui/sortable-tree/SortableTree.tsx index 6e0a9ad19..9f2159dd3 100644 --- a/apps/web/src/components/ui/sortable-tree/SortableTree.tsx +++ b/apps/web/src/components/ui/sortable-tree/SortableTree.tsx @@ -7,6 +7,7 @@ import { DragMoveEvent, DragOverEvent, DragStartEvent, + DragOverlay, PointerSensor, KeyboardSensor, useSensor, @@ -43,7 +44,10 @@ export interface SortableTreeProps { items: T[]; collapsedIds?: Set; indentationWidth?: number; + selectedIds?: Set; onMove?: (activeId: string, overId: string, projection: Projection) => void; + onMultiMove?: (activeIds: string[], overId: string, projection: Projection) => void; + onSelectionChange?: (ids: string[]) => void; renderItem: (props: { item: T; depth: number; @@ -52,6 +56,7 @@ export interface SortableTreeProps { dropPosition: DropPosition; projectedDepth: number | null; projected: Projection | null; + flattenedIds: string[]; handleProps: { ref: (element: HTMLElement | null) => void; listeners: SortableReturnType["listeners"]; @@ -62,14 +67,22 @@ export interface SortableTreeProps { style: React.CSSProperties; }; }) => ReactNode; + renderDragOverlay?: (props: { + items: T[]; + totalCount: number; + }) => ReactNode; } export function SortableTree({ items, collapsedIds = new Set(), indentationWidth = 24, + selectedIds = new Set(), onMove, + onMultiMove, + onSelectionChange, renderItem, + renderDragOverlay, }: SortableTreeProps) { const [activeId, setActiveId] = useState(null); const [overId, setOverId] = useState(null); @@ -92,11 +105,26 @@ export function SortableTree({ [items, collapsedIds] ); + // Flattened IDs for selection operations + const flattenedIds = useMemo( + () => flattenedItems.map(({ item }) => item.id), + [flattenedItems] + ); + // During drag, remove children of the active item from sortable list + // Also remove children of all selected items if doing multi-select drag const sortableItems = useMemo(() => { if (!activeId) return flattenedItems; - return removeChildrenOf(flattenedItems, [activeId]); - }, [flattenedItems, activeId]); + + // If the active item is selected and there are multiple selections, + // remove children of all selected items + const isMultiDrag = selectedIds.has(String(activeId)) && selectedIds.size > 1; + const idsToRemoveChildrenFrom = isMultiDrag + ? Array.from(selectedIds) + : [activeId]; + + return removeChildrenOf(flattenedItems, idsToRemoveChildrenFrom); + }, [flattenedItems, activeId, selectedIds]); const sortableIds = useMemo( () => sortableItems.map(({ item }) => item.id), @@ -109,12 +137,37 @@ export function SortableTree({ return getProjection(sortableItems, activeId, overId, offsetLeft, indentationWidth); }, [sortableItems, activeId, overId, offsetLeft, indentationWidth]); - const handleDragStart = useCallback(({ active }: DragStartEvent) => { - setActiveId(active.id); - setOverId(active.id); + // Get active item for DragOverlay + const activeItem = useMemo(() => { + if (!activeId) return null; + return flattenedItems.find(({ item }) => item.id === activeId)?.item ?? null; + }, [activeId, flattenedItems]); - document.body.style.cursor = "grabbing"; - }, []); + // Get all selected items for multi-drag overlay + const selectedItems = useMemo(() => { + if (!activeId || selectedIds.size <= 1) return activeItem ? [activeItem] : []; + if (!selectedIds.has(String(activeId))) return activeItem ? [activeItem] : []; + + // Return selected items in their tree order + return flattenedItems + .filter(({ item }) => selectedIds.has(item.id)) + .map(({ item }) => item); + }, [activeId, selectedIds, flattenedItems, activeItem]); + + const handleDragStart = useCallback( + ({ active }: DragStartEvent) => { + setActiveId(active.id); + setOverId(active.id); + + // If dragging an item that's not selected, clear selection and select just this item + if (!selectedIds.has(String(active.id))) { + onSelectionChange?.([String(active.id)]); + } + + document.body.style.cursor = "grabbing"; + }, + [selectedIds, onSelectionChange] + ); const handleDragMove = useCallback(({ delta }: DragMoveEvent) => { setOffsetLeft(delta.x); @@ -140,11 +193,20 @@ export function SortableTree({ setOverId(null); setOffsetLeft(0); - if (over && active.id !== over.id && projected && onMove) { - onMove(String(active.id), String(over.id), projected); + if (over && active.id !== over.id && projected) { + const isMultiDrag = selectedIds.has(String(active.id)) && selectedIds.size > 1; + + if (isMultiDrag && onMultiMove) { + // Multi-drag: move all selected items + const selectedIdsArray = Array.from(selectedIds); + onMultiMove(selectedIdsArray, String(over.id), projected); + } else if (onMove) { + // Single drag + onMove(String(active.id), String(over.id), projected); + } } }, - [projected, onMove] + [projected, onMove, onMultiMove, selectedIds] ); const handleDragCancel = useCallback(() => { @@ -192,6 +254,7 @@ export function SortableTree({ // Pass projectedDepth to over item for indicator positioning projectedDepth: overId === flatItem.item.id && projected ? projected.depth : null, projected: activeId === flatItem.item.id ? projected : null, + flattenedIds, handleProps, wrapperProps, }) @@ -199,6 +262,15 @@ export function SortableTree({ ))} + + + {activeId && renderDragOverlay ? ( + renderDragOverlay({ + items: selectedItems as T[], + totalCount: selectedItems.length, + }) + ) : null} + ); } diff --git a/apps/web/src/hooks/useUI.ts b/apps/web/src/hooks/useUI.ts index 61497152b..ffc22d06c 100644 --- a/apps/web/src/hooks/useUI.ts +++ b/apps/web/src/hooks/useUI.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { useUIStore } from '@/stores/useUIStore'; +import { useUIStore, SelectionMode } from '@/stores/useUIStore'; // Selector hooks for UI state - only re-render when specific values change export const useLeftSidebar = () => { @@ -63,21 +63,75 @@ export const useTreeState = () => { export const useResponsiveLayout = () => { const leftSidebar = useLeftSidebar(); const rightSidebar = useRightSidebar(); - + const closeAllSidebars = useCallback(() => { leftSidebar.setOpen(false); rightSidebar.setOpen(false); }, [leftSidebar, rightSidebar]); - + const openAllSidebars = useCallback(() => { leftSidebar.setOpen(true); rightSidebar.setOpen(true); }, [leftSidebar, rightSidebar]); - + return { leftSidebar, rightSidebar, closeAllSidebars, openAllSidebars, }; +}; + +// Hook for multi-select in page tree +export const usePageSelection = () => { + const selectedPageIds = useUIStore((state) => state.selectedPageIds); + const lastSelectedPageId = useUIStore((state) => state.lastSelectedPageId); + const selectPageStore = useUIStore((state) => state.selectPage); + const clearSelection = useUIStore((state) => state.clearSelection); + const setSelection = useUIStore((state) => state.setSelection); + + const isSelected = useCallback( + (id: string) => selectedPageIds.has(id), + [selectedPageIds] + ); + + const selectPage = useCallback( + (id: string, mode: SelectionMode, flattenedIds?: string[]) => { + selectPageStore(id, mode, flattenedIds); + }, + [selectPageStore] + ); + + const getSelectedCount = useCallback( + () => selectedPageIds.size, + [selectedPageIds] + ); + + const getSelectedIds = useCallback( + () => Array.from(selectedPageIds), + [selectedPageIds] + ); + + const hasSelection = useCallback( + () => selectedPageIds.size > 0, + [selectedPageIds] + ); + + const isMultiSelect = useCallback( + () => selectedPageIds.size > 1, + [selectedPageIds] + ); + + return { + selectedPageIds, + lastSelectedPageId, + selectPage, + clearSelection, + setSelection, + isSelected, + getSelectedCount, + getSelectedIds, + hasSelection, + isMultiSelect, + }; }; \ No newline at end of file diff --git a/apps/web/src/lib/websocket/socket-utils.ts b/apps/web/src/lib/websocket/socket-utils.ts index 8b04c180f..72baf5a0c 100644 --- a/apps/web/src/lib/websocket/socket-utils.ts +++ b/apps/web/src/lib/websocket/socket-utils.ts @@ -11,7 +11,7 @@ import { maskIdentifier } from '@/lib/logging/mask'; // This prevents Node.js-specific API errors in browser contexts const loggers = browserLoggers; -export type PageOperation = 'created' | 'updated' | 'moved' | 'deleted' | 'restored' | 'trashed' | 'content-updated'; +export type PageOperation = 'created' | 'updated' | 'moved' | 'deleted' | 'restored' | 'trashed' | 'content-updated' | 'batch-trashed' | 'batch-moved'; export type DriveOperation = 'created' | 'updated' | 'deleted'; export type DriveMemberOperation = 'member_added' | 'member_role_changed' | 'member_removed'; export type TaskOperation = 'task_list_created' | 'task_added' | 'task_updated' | 'task_completed' | 'task_deleted' | 'tasks_reordered'; @@ -25,6 +25,9 @@ export interface PageEventPayload { title?: string; type?: string; socketId?: string; // Socket ID of the user who triggered this event (to prevent self-refetch) + // Batch operation fields + pageIds?: string[]; + count?: number; } export interface DriveEventPayload { @@ -220,6 +223,9 @@ export function createPageEventPayload( title?: string; type?: string; socketId?: string; + // Batch operation fields + pageIds?: string[]; + count?: number; } = {} ): PageEventPayload { return { diff --git a/apps/web/src/services/api/page-service.ts b/apps/web/src/services/api/page-service.ts index 641e9bff7..965418d97 100644 --- a/apps/web/src/services/api/page-service.ts +++ b/apps/web/src/services/api/page-service.ts @@ -631,6 +631,215 @@ export const pageService = { isAIChatPage: isAIChatPage(params.type as PageTypeEnum), }; }, + + /** + * Batch trash multiple pages (soft delete) + */ + async batchTrashPages(pageIds: string[], userId: string): Promise { + if (!pageIds.length) { + return { success: false, error: 'No page IDs provided', status: 400 }; + } + + // Check authorization for all pages + const authResults = await Promise.all( + pageIds.map(async (pageId) => ({ + pageId, + canDelete: await canUserDeletePage(userId, pageId), + })) + ); + + const unauthorized = authResults.filter((r) => !r.canDelete); + if (unauthorized.length > 0) { + return { + success: false, + error: `No permission to delete ${unauthorized.length} page(s)`, + status: 403, + }; + } + + // Get page info for all pages + const pagesInfo = await db.query.pages.findMany({ + where: inArray(pages.id, pageIds), + with: { + drive: { columns: { id: true } }, + }, + }); + + if (pagesInfo.length !== pageIds.length) { + return { success: false, error: 'Some pages not found', status: 404 }; + } + + // Get unique drive IDs + const driveIds = [...new Set(pagesInfo.map((p) => p.drive?.id).filter(Boolean))] as string[]; + + // Trash all pages in a transaction + await db.transaction(async (tx) => { + const now = new Date(); + await tx.update(pages) + .set({ isTrashed: true, trashedAt: now }) + .where(inArray(pages.id, pageIds)); + }); + + // Check if any AI_CHAT pages were trashed + const hasAIChatPages = pagesInfo.some((p) => p.type === 'AI_CHAT'); + + return { + success: true, + driveIds, + trashedCount: pageIds.length, + hasAIChatPages, + }; + }, + + /** + * Batch move multiple pages to a new parent + */ + async batchMovePages( + pageIds: string[], + newParentId: string | null, + userId: string + ): Promise { + if (!pageIds.length) { + return { success: false, error: 'No page IDs provided', status: 400 }; + } + + // Check authorization for all pages + const authResults = await Promise.all( + pageIds.map(async (pageId) => ({ + pageId, + canEdit: await canUserEditPage(userId, pageId), + })) + ); + + const unauthorized = authResults.filter((r) => !r.canEdit); + if (unauthorized.length > 0) { + return { + success: false, + error: `No permission to move ${unauthorized.length} page(s)`, + status: 403, + }; + } + + // Get page info for all pages + const pagesInfo = await db.query.pages.findMany({ + where: inArray(pages.id, pageIds), + with: { + drive: { columns: { id: true } }, + }, + }); + + if (pagesInfo.length !== pageIds.length) { + return { success: false, error: 'Some pages not found', status: 404 }; + } + + // Validate moves (check for circular references) + for (const pageId of pageIds) { + const validation = await validatePageMove(pageId, newParentId); + if (!validation.valid) { + return { success: false, error: validation.error || 'Invalid move', status: 400 }; + } + } + + // Get the target parent's driveId (if moving to a parent) + let targetDriveId: string; + if (newParentId) { + const parentInfo = await db.query.pages.findFirst({ + where: eq(pages.id, newParentId), + columns: { driveId: true }, + }); + if (!parentInfo) { + return { success: false, error: 'Parent page not found', status: 404 }; + } + targetDriveId = parentInfo.driveId; + } else { + // Moving to root - use the first page's drive + targetDriveId = pagesInfo[0]?.drive?.id || ''; + } + + // Calculate new positions for all pages + const existingSiblings = await db.select({ id: pages.id, position: pages.position }) + .from(pages) + .where( + and( + newParentId ? eq(pages.parentId, newParentId) : isNull(pages.parentId), + eq(pages.driveId, targetDriveId), + eq(pages.isTrashed, false) + ) + ) + .orderBy(pages.position); + + // Filter out pages being moved + const siblingsWithoutMoving = existingSiblings.filter( + (s) => !pageIds.includes(s.id) + ); + + // Calculate starting position (at the end) + let lastPosition = siblingsWithoutMoving.length > 0 + ? (siblingsWithoutMoving[siblingsWithoutMoving.length - 1]?.position ?? 0) + 1 + : 1; + + // Move all pages in a transaction + await db.transaction(async (tx) => { + for (const pageId of pageIds) { + await tx.update(pages) + .set({ + parentId: newParentId, + position: lastPosition, + updatedAt: new Date(), + }) + .where(eq(pages.id, pageId)); + lastPosition += 1; + } + }); + + // Get unique drive IDs (source drives) + const sourceDriveIds = [...new Set(pagesInfo.map((p) => p.drive?.id).filter(Boolean))] as string[]; + // Add target drive if different + const allDriveIds = [...new Set([...sourceDriveIds, targetDriveId])]; + + return { + success: true, + driveIds: allDriveIds, + movedCount: pageIds.length, + targetParentId: newParentId, + }; + }, }; +/** + * Batch trash result types + */ +export interface BatchTrashSuccess { + success: true; + driveIds: string[]; + trashedCount: number; + hasAIChatPages: boolean; +} + +export interface BatchTrashError { + success: false; + error: string; + status: number; +} + +export type BatchTrashResult = BatchTrashSuccess | BatchTrashError; + +/** + * Batch move result types + */ +export interface BatchMoveSuccess { + success: true; + driveIds: string[]; + movedCount: number; + targetParentId: string | null; +} + +export interface BatchMoveError { + success: false; + error: string; + status: number; +} + +export type BatchMoveResult = BatchMoveSuccess | BatchMoveError; + export type PageService = typeof pageService; diff --git a/apps/web/src/stores/useUIStore.ts b/apps/web/src/stores/useUIStore.ts index cd62f75f4..6cefb158c 100644 --- a/apps/web/src/stores/useUIStore.ts +++ b/apps/web/src/stores/useUIStore.ts @@ -1,21 +1,27 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +export type SelectionMode = 'single' | 'toggle' | 'range'; + export interface UIState { // Sidebar state leftSidebarOpen: boolean; rightSidebarOpen: boolean; - + // Tree state treeExpanded: Set; treeScrollPosition: number; - + + // Selection state for multi-select in sidebar + selectedPageIds: Set; + lastSelectedPageId: string | null; + // Current view type centerViewType: 'document' | 'folder' | 'channel' | 'ai' | 'settings'; - + // Loading states isNavigating: boolean; - + // Actions toggleLeftSidebar: () => void; toggleRightSidebar: () => void; @@ -25,6 +31,13 @@ export interface UIState { setNavigating: (navigating: boolean) => void; setTreeExpanded: (nodeId: string, expanded: boolean) => void; setTreeScrollPosition: (position: number) => void; + + // Selection actions + selectPage: (id: string, mode: SelectionMode, flattenedIds?: string[]) => void; + clearSelection: () => void; + setSelection: (ids: string[]) => void; + isPageSelected: (id: string) => boolean; + getSelectedPageIds: () => string[]; } export const useUIStore = create()( @@ -35,34 +48,36 @@ export const useUIStore = create()( rightSidebarOpen: true, treeExpanded: new Set(), treeScrollPosition: 0, + selectedPageIds: new Set(), + lastSelectedPageId: null, centerViewType: 'document', isNavigating: false, - + // Actions toggleLeftSidebar: () => { set((state) => ({ leftSidebarOpen: !state.leftSidebarOpen })); }, - + toggleRightSidebar: () => { set((state) => ({ rightSidebarOpen: !state.rightSidebarOpen })); }, - + setLeftSidebar: (open: boolean) => { set({ leftSidebarOpen: open }); }, - + setRightSidebar: (open: boolean) => { set({ rightSidebarOpen: open }); }, - + setCenterViewType: (viewType: UIState['centerViewType']) => { set({ centerViewType: viewType }); }, - + setNavigating: (navigating: boolean) => { set({ isNavigating: navigating }); }, - + setTreeExpanded: (nodeId: string, expanded: boolean) => { const newExpanded = new Set(get().treeExpanded); if (expanded) { @@ -72,10 +87,72 @@ export const useUIStore = create()( } set({ treeExpanded: newExpanded }); }, - + setTreeScrollPosition: (position: number) => { set({ treeScrollPosition: position }); }, + + // Selection actions + selectPage: (id: string, mode: SelectionMode, flattenedIds?: string[]) => { + const state = get(); + const newSelected = new Set(state.selectedPageIds); + + switch (mode) { + case 'single': + // Clear and select only this item + newSelected.clear(); + newSelected.add(id); + set({ selectedPageIds: newSelected, lastSelectedPageId: id }); + break; + + case 'toggle': + // Toggle this item in selection (Ctrl/Cmd+click) + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + set({ selectedPageIds: newSelected, lastSelectedPageId: id }); + break; + + case 'range': + // Select range from lastSelectedPageId to id (Shift+click) + if (flattenedIds && state.lastSelectedPageId) { + const startIdx = flattenedIds.indexOf(state.lastSelectedPageId); + const endIdx = flattenedIds.indexOf(id); + if (startIdx !== -1 && endIdx !== -1) { + const [from, to] = startIdx < endIdx ? [startIdx, endIdx] : [endIdx, startIdx]; + for (let i = from; i <= to; i++) { + newSelected.add(flattenedIds[i]); + } + } + } else { + // No previous selection, just select this one + newSelected.add(id); + } + set({ selectedPageIds: newSelected, lastSelectedPageId: id }); + break; + } + }, + + clearSelection: () => { + set({ selectedPageIds: new Set(), lastSelectedPageId: null }); + }, + + setSelection: (ids: string[]) => { + set({ + selectedPageIds: new Set(ids), + lastSelectedPageId: ids.length > 0 ? ids[ids.length - 1] : null, + }); + }, + + isPageSelected: (id: string) => { + return get().selectedPageIds.has(id); + }, + + getSelectedPageIds: () => { + return Array.from(get().selectedPageIds); + }, }), { name: 'ui-store', @@ -91,6 +168,11 @@ export const useUIStore = create()( const expanded = state.treeExpanded; state.treeExpanded = new Set(Array.isArray(expanded) ? expanded : Array.from(expanded as Set)); } + // Initialize selection state (not persisted) + if (state) { + state.selectedPageIds = new Set(); + state.lastSelectedPageId = null; + } }, } ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0049d4e1..a8eaf3eb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,9 @@ importers: '@radix-ui/react-collapsible': specifier: ^1.1.12 version: 1.1.12(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-context-menu': + specifier: ^2.2.16 + version: 2.2.16(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -2881,6 +2884,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': ^19.1.13 + '@types/react-dom': ^19.1.9 + react: ^19.1.2 + react-dom: ^19.1.2 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-context@1.1.2': resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} peerDependencies: @@ -8032,6 +8048,7 @@ packages: next@15.3.6: resolution: {integrity: sha512-oI6D1zbbsh6JzzZFDCSHnnx6Qpvd1fSkVJu/5d8uluqnxzuoqtodVZjYvNovooznUq8udSAiKp7MbwlfZ8Gm6w==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -12177,6 +12194,20 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.13)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.13)(react@19.2.1) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-context@1.1.2(@types/react@19.1.13)(react@19.2.1)': dependencies: react: 19.2.1 From 5fff7cfa0e9bb00f6c1702d3f45a88d281828d96 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Dec 2025 03:30:45 +0000 Subject: [PATCH 2/6] fix(sidebar): remove unused variables to pass linting - Remove unused useParams import and params variable from PageTreeItem - Remove unused event parameter from handleContextMenu callback - Remove unused lucide-react imports (Trash2, Pencil, Star, Undo2) from PageTreeItemContent - Add void statements for interface props that are passed but handled by context menu --- .../left-sidebar/page-tree/PageTreeItem.tsx | 4 +-- .../page-tree/PageTreeItemContent.tsx | 25 +++++++++++-------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx index beaa00a39..96f2ae14f 100644 --- a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx +++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, CSSProperties, MouseEvent, useCallback } from "react"; -import { useParams } from "next/navigation"; import { Trash2, Pencil, @@ -90,7 +89,6 @@ export function PageTreeItem({ const [isHovered, setIsHovered] = useState(false); const [isConfirmTrashOpen, setConfirmTrashOpen] = useState(false); const [isRenameOpen, setRenameOpen] = useState(false); - const params = useParams(); const { addFavorite, removeFavorite, isFavorite } = useFavorites(); const { isSelected, @@ -233,7 +231,7 @@ export function PageTreeItem({ // Context menu handler const handleContextMenu = useCallback( - (e: MouseEvent) => { + () => { // If this item is not selected, select it first (single selection) if (!itemIsSelected) { clearSelection(); diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx index ddcb5bc2a..322469c11 100644 --- a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx +++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx @@ -7,10 +7,6 @@ import { ChevronRight, Plus, MoreHorizontal, - Trash2, - Pencil, - Star, - Undo2, GripVertical, } from "lucide-react"; import { TreePage } from "@/hooks/usePageTree"; @@ -70,17 +66,17 @@ export const PageTreeItemContent = forwardRef 0); From 7d69ca315c9be8c7687de1dc852e99897a8698c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Dec 2025 03:51:02 +0000 Subject: [PATCH 3/6] feat(sidebar): add confirmation dialog for batch delete - Create BatchDeleteDialog component with page count display - Show confirmation before batch trashing multiple selected pages - Dialog displays count and optional list of page names (up to 5) - User can confirm or cancel the batch delete action --- .../components/dialogs/BatchDeleteDialog.tsx | 64 +++++++++++++++++++ .../left-sidebar/page-tree/PageTreeItem.tsx | 30 +++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/dialogs/BatchDeleteDialog.tsx diff --git a/apps/web/src/components/dialogs/BatchDeleteDialog.tsx b/apps/web/src/components/dialogs/BatchDeleteDialog.tsx new file mode 100644 index 000000000..b75101576 --- /dev/null +++ b/apps/web/src/components/dialogs/BatchDeleteDialog.tsx @@ -0,0 +1,64 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +interface BatchDeleteDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + pageCount: number; + pageNames?: string[]; +} + +export function BatchDeleteDialog({ + isOpen, + onClose, + onConfirm, + pageCount, + pageNames, +}: BatchDeleteDialogProps) { + const showNames = pageNames && pageNames.length > 0 && pageNames.length <= 5; + + return ( + + + + Move {pageCount} pages to trash? + +
+

+ This action will move {pageCount} selected page{pageCount !== 1 ? "s" : ""} to the trash. + You can restore them later. +

+ {showNames && ( +
    + {pageNames.map((name, index) => ( +
  • {name}
  • + ))} +
+ )} + {pageNames && pageNames.length > 5 && ( +

+ and {pageNames.length - 5} more... +

+ )} +
+
+
+ + Cancel + + Move to Trash + + +
+
+ ); +} diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx index 96f2ae14f..1645e1a6d 100644 --- a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx +++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx @@ -12,6 +12,7 @@ import { TreePage } from "@/hooks/usePageTree"; import { useFavorites } from "@/hooks/useFavorites"; import { toast } from "sonner"; import { DeletePageDialog } from "@/components/dialogs/DeletePageDialog"; +import { BatchDeleteDialog } from "@/components/dialogs/BatchDeleteDialog"; import { RenameDialog } from "@/components/dialogs/RenameDialog"; import { patch, del, post } from "@/lib/auth/auth-fetch"; import { Projection } from "@/lib/tree/sortable-tree"; @@ -88,6 +89,8 @@ export function PageTreeItem({ const [isHovered, setIsHovered] = useState(false); const [isConfirmTrashOpen, setConfirmTrashOpen] = useState(false); + const [isBatchDeleteOpen, setIsBatchDeleteOpen] = useState(false); + const [batchDeleteInfo, setBatchDeleteInfo] = useState<{ ids: string[]; count: number }>({ ids: [], count: 0 }); const [isRenameOpen, setRenameOpen] = useState(false); const { addFavorite, removeFavorite, isFavorite } = useFavorites(); const { @@ -139,18 +142,30 @@ export function PageTreeItem({ } }; - const handleBatchDelete = async () => { + const handleBatchDelete = () => { const selectedIds = getSelectedIds(); if (selectedIds.length === 0) return; - const toastId = toast.loading(`Moving ${selectedIds.length} pages to trash...`); + // Open confirmation dialog with selected page info + setBatchDeleteInfo({ ids: selectedIds, count: selectedIds.length }); + setIsBatchDeleteOpen(true); + }; + + const confirmBatchDelete = async () => { + const { ids, count } = batchDeleteInfo; + if (ids.length === 0) return; + + setIsBatchDeleteOpen(false); + const toastId = toast.loading(`Moving ${count} pages to trash...`); try { - await post("/api/pages/batch-trash", { pageIds: selectedIds }); + await post("/api/pages/batch-trash", { pageIds: ids }); clearSelection(); await mutate(); - toast.success(`${selectedIds.length} pages moved to trash.`, { id: toastId }); + toast.success(`${count} pages moved to trash.`, { id: toastId }); } catch { toast.error("Error moving pages to trash.", { id: toastId }); + } finally { + setBatchDeleteInfo({ ids: [], count: 0 }); } }; @@ -349,6 +364,13 @@ export function PageTreeItem({ hasChildren={hasChildren} /> + setIsBatchDeleteOpen(false)} + onConfirm={confirmBatchDelete} + pageCount={batchDeleteInfo.count} + /> + setRenameOpen(false)} From 9bb606875aeef6c7e7fdee19a9b2abde1ea046ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Dec 2025 04:09:17 +0000 Subject: [PATCH 4/6] fix(sidebar): make selection checkbox keyboard-accessible - Convert selection indicator from div to semantic button element - Add aria-pressed to indicate selection state - Add aria-label describing the action (Select/Deselect page title) - Add onKeyDown handler for Enter/Space key support - Add focus-visible ring styles for keyboard navigation - Reset native button styles to preserve visual appearance - Update type signatures to accept MouseEvent | KeyboardEvent --- .../left-sidebar/page-tree/PageTreeItem.tsx | 4 ++-- .../page-tree/PageTreeItemContent.tsx | 22 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx index 1645e1a6d..bfa934c3a 100644 --- a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx +++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, CSSProperties, MouseEvent, useCallback } from "react"; +import { useState, CSSProperties, MouseEvent, KeyboardEvent, useCallback } from "react"; import { Trash2, Pencil, @@ -206,7 +206,7 @@ export function PageTreeItem({ // Selection click handler const handleSelectionClick = useCallback( - (e: MouseEvent) => { + (e: MouseEvent | KeyboardEvent) => { e.stopPropagation(); e.preventDefault(); diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx index 322469c11..e920d4d75 100644 --- a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx +++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { CSSProperties, forwardRef, MouseEvent } from "react"; +import { CSSProperties, forwardRef, MouseEvent, KeyboardEvent } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { @@ -42,8 +42,8 @@ export interface PageTreeItemContentProps { onMouseLeave?: () => void; onClick?: (e: MouseEvent) => void; onContextMenu?: (e: MouseEvent) => void; - // For selection indicator click - onSelectionClick?: (e: MouseEvent) => void; + // For selection indicator click/keypress + onSelectionClick?: (e: MouseEvent | KeyboardEvent) => void; // Drag handle props (optional - only when draggable) dragHandleProps?: { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -141,12 +141,24 @@ export const PageTreeItemContent = forwardRef {/* Selection indicator / checkbox area */} -
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelectionClick?.(e); + } + }} + aria-pressed={isSelected} + aria-label={isSelected ? `Deselect ${item.title}` : `Select ${item.title}`} >
)}
-
+ {/* Expand/Collapse Chevron */} {itemHasChildren ? ( From a02fbb356cdaa45468be9ec5755c20de3f948d7e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Dec 2025 04:53:50 +0000 Subject: [PATCH 5/6] refactor(sidebar): address code review feedback - Cache getSelectedIds() call to avoid multiple iterations during render - Use cached selectedCount instead of calling getSelectedIds().length - Remove unused isMultiSelect from hook destructuring - Remove empty "More options" button (context menu serves same purpose) - Remove unused MoreHorizontal import --- .../left-sidebar/page-tree/PageTreeItem.tsx | 14 +++++++------- .../page-tree/PageTreeItemContent.tsx | 15 +-------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx index bfa934c3a..3073e20e9 100644 --- a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx +++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx @@ -98,12 +98,13 @@ export function PageTreeItem({ selectPage, clearSelection, getSelectedIds, - isMultiSelect, } = usePageSelection(); const hasChildren = item.children && item.children.length > 0; const itemIsSelected = isSelected(item.id); - const multiSelectActive = isMultiSelect(); + const selectedIds = getSelectedIds(); + const selectedCount = selectedIds.length; + const multiSelectActive = selectedCount > 1; // Combine file drops AND internal dnd-kit drags for drop indicators const isFileDragOver = fileDragState?.overId === item.id; @@ -143,11 +144,10 @@ export function PageTreeItem({ }; const handleBatchDelete = () => { - const selectedIds = getSelectedIds(); - if (selectedIds.length === 0) return; + if (selectedCount === 0) return; // Open confirmation dialog with selected page info - setBatchDeleteInfo({ ids: selectedIds, count: selectedIds.length }); + setBatchDeleteInfo({ ids: selectedIds, count: selectedCount }); setIsBatchDeleteOpen(true); }; @@ -316,7 +316,7 @@ export function PageTreeItem({ <> - Move {getSelectedIds().length} pages... + Move {selectedCount} pages... - Trash {getSelectedIds().length} pages + Trash {selectedCount} pages ) : ( diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx index e920d4d75..3d324f071 100644 --- a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx +++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx @@ -6,7 +6,6 @@ import { useParams } from "next/navigation"; import { ChevronRight, Plus, - MoreHorizontal, GripVertical, } from "lucide-react"; import { TreePage } from "@/hooks/usePageTree"; @@ -90,8 +89,7 @@ export const PageTreeItemContent = forwardRef )} - {/* 3-dot menu trigger - we keep this for single-item actions */} -
{/* Visual hint for inside drop */} From 232a9c1765e32d828cf00ee233b5bb1147e0f372 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Dec 2025 05:08:58 +0000 Subject: [PATCH 6/6] refactor(sidebar): use proper @dnd-kit types for dragHandleProps - Import DraggableAttributes and DraggableSyntheticListeners from @dnd-kit/core - Replace Record with proper typed interfaces - Remove eslint-disable comments for @typescript-eslint/no-explicit-any --- .../layout/left-sidebar/page-tree/PageTreeItemContent.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx index 3d324f071..c62e0fa77 100644 --- a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx +++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx @@ -3,6 +3,7 @@ import { CSSProperties, forwardRef, MouseEvent, KeyboardEvent } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; +import type { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core"; import { ChevronRight, Plus, @@ -45,10 +46,8 @@ export interface PageTreeItemContentProps { onSelectionClick?: (e: MouseEvent | KeyboardEvent) => void; // Drag handle props (optional - only when draggable) dragHandleProps?: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - listeners?: Record; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - attributes?: Record; + listeners?: DraggableSyntheticListeners; + attributes?: DraggableAttributes; }; }