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/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/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..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
@@ -1,35 +1,32 @@
"use client";
-import { useState, CSSProperties } from "react";
-import Link from "next/link";
-import { useParams } from "next/navigation";
+import { useState, CSSProperties, MouseEvent, KeyboardEvent, useCallback } from "react";
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 { 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";
-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;
@@ -89,25 +89,34 @@ 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 params = useParams();
const { addFavorite, removeFavorite, isFavorite } = useFavorites();
- const hasChildren = item.children && item.children.length > 0;
+ const {
+ isSelected,
+ selectPage,
+ clearSelection,
+ getSelectedIds,
+ } = usePageSelection();
- const linkHref = `/dashboard/${params.driveId}/${item.id}`;
+ const hasChildren = item.children && item.children.length > 0;
+ const itemIsSelected = isSelected(item.id);
+ 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;
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 +143,32 @@ export function PageTreeItem({
}
};
+ const handleBatchDelete = () => {
+ if (selectedCount === 0) return;
+
+ // Open confirmation dialog with selected page info
+ setBatchDeleteInfo({ ids: selectedIds, count: selectedCount });
+ 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: ids });
+ clearSelection();
+ await mutate();
+ toast.success(`${count} pages moved to trash.`, { id: toastId });
+ } catch {
+ toast.error("Error moving pages to trash.", { id: toastId });
+ } finally {
+ setBatchDeleteInfo({ ids: [], count: 0 });
+ }
+ };
+
const handleRestore = async () => {
const toastId = toast.loading("Restoring page...");
try {
@@ -169,174 +204,158 @@ export function PageTreeItem({
}
};
- return (
- <>
-
setIsHovered(true)}
- onMouseLeave={() => setIsHovered(false)}
- >
- {/* Drop indicator - BEFORE */}
- {showDropIndicator && dropPosition === "before" && (
-
- )}
+ // Selection click handler
+ const handleSelectionClick = useCallback(
+ (e: MouseEvent | KeyboardEvent) => {
+ 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(
+ () => {
+ // 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 {selectedCount} pages...
+
+
+
+
+ Trash {selectedCount} pages
+
+ >
+ ) : (
+ // Single item context menu
+ <>
+ setRenameOpen(true)}>
+
+ Rename
+
+
+
+ {isFavorite(item.id) ? "Unfavorite" : "Favorite"}
+
+
+ setConfirmTrashOpen(true)}
+ variant="destructive"
+ >
+
+ Trash
+
+ >
+ )}
+
+
+
setIsBatchDeleteOpen(false)}
+ onConfirm={confirmBatchDelete}
+ pageCount={batchDeleteInfo.count}
+ />
+
setRenameOpen(false)}
@@ -356,3 +382,6 @@ export function PageTreeItem({
>
);
}
+
+// 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..c62e0fa77
--- /dev/null
+++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItemContent.tsx
@@ -0,0 +1,321 @@
+"use client";
+
+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,
+ 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/keypress
+ onSelectionClick?: (e: MouseEvent | KeyboardEvent) => void;
+ // Drag handle props (optional - only when draggable)
+ dragHandleProps?: {
+ listeners?: DraggableSyntheticListeners;
+ attributes?: DraggableAttributes;
+ };
+}
+
+export const PageTreeItemContent = forwardRef(
+ function PageTreeItemContent(
+ {
+ item,
+ depth,
+ isSelected = false,
+ isActive = false,
+ isDragOverlay = false,
+ showDropIndicator = false,
+ dropPosition = null,
+ indicatorDepth,
+ isExpanded = false,
+ hasChildren = false,
+ isFavorite: _isFavorite = false,
+ isHovered = false,
+ isTrashView = false,
+ style,
+ onToggleExpand,
+ onOpenCreateDialog,
+ onRename: _onRename,
+ onTrash: _onTrash,
+ onRestore: _onRestore,
+ onPermanentDelete: _onPermanentDelete,
+ onFavoriteToggle: _onFavoriteToggle,
+ onMouseEnter,
+ onMouseLeave,
+ onClick,
+ onContextMenu,
+ onSelectionClick,
+ dragHandleProps,
+ },
+ ref
+ ) {
+ const params = useParams();
+ const linkHref = `/dashboard/${params.driveId}/${item.id}`;
+ const effectiveIndicatorDepth = indicatorDepth ?? depth;
+
+ // Silence unused vars - passed for interface completeness, handled by context menu
+ void _isFavorite;
+ void _onRename;
+ void _onTrash;
+ void _onRestore;
+ void _onPermanentDelete;
+ void _onFavoriteToggle;
+
+ // 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 */}
+
+
+ {/* Expand/Collapse Chevron */}
+ {itemHasChildren ? (
+
+ ) : (
+
// Spacer for alignment
+ )}
+
+ {/* Icon / Drag Handle */}
+
+
+ {/* Title - Click to Navigate */}
+
e.stopPropagation()}
+ >
+ {item.title}
+
+
+ {/* Action Buttons - visible on hover */}
+
+ {!isTrashView && (
+
+ )}
+
+
+ {/* 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