diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md index a21bc720..959801bb 100644 --- a/.claude/ralph-loop.local.md +++ b/.claude/ralph-loop.local.md @@ -1,6 +1,6 @@ --- active: true -iteration: 3 +iteration: 4 max_iterations: 40 completion_promise: "PR_READY" started_at: "2026-02-03T00:48:37Z" diff --git a/apps/web/src/app/api/pages/bulk-copy/route.ts b/apps/web/src/app/api/pages/bulk-copy/route.ts new file mode 100644 index 00000000..d8d977f8 --- /dev/null +++ b/apps/web/src/app/api/pages/bulk-copy/route.ts @@ -0,0 +1,229 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod/v4'; +import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket'; +import { loggers, pageTreeCache } from '@pagespace/lib/server'; +import { pages, drives, driveMembers, db, and, eq, inArray, desc, isNull } from '@pagespace/db'; +import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { canUserViewPage } from '@pagespace/lib/server'; +import { createId } from '@paralleldrive/cuid2'; + +const AUTH_OPTIONS = { allow: ['session', 'mcp'] as const, requireCSRF: true }; + +const requestSchema = z.object({ + pageIds: z.array(z.string()).min(1, 'At least one page ID is required'), + targetDriveId: z.string().min(1, 'Target drive ID is required'), + targetParentId: z.string().nullable(), + includeChildren: z.boolean().default(true), +}); + +export async function POST(request: Request) { + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS); + if (isAuthError(auth)) { + return auth.error; + } + const userId = auth.userId; + + try { + const body = await request.json(); + + const parseResult = requestSchema.safeParse(body); + if (!parseResult.success) { + return NextResponse.json( + { error: parseResult.error.issues.map(i => i.message).join('. ') }, + { status: 400 } + ); + } + + const { pageIds, targetDriveId, targetParentId, includeChildren } = parseResult.data; + + // Verify target drive exists + const targetDrive = await db.query.drives.findFirst({ + where: eq(drives.id, targetDriveId), + }); + + if (!targetDrive) { + return NextResponse.json({ error: 'Target drive not found' }, { status: 404 }); + } + + // Check user has edit access to target drive + const isOwner = targetDrive.ownerId === userId; + let canEditDrive = isOwner; + + if (!isOwner) { + const membership = await db.query.driveMembers.findFirst({ + where: and( + eq(driveMembers.driveId, targetDriveId), + eq(driveMembers.userId, userId) + ), + }); + canEditDrive = membership?.role === 'OWNER' || membership?.role === 'ADMIN'; + } + + if (!canEditDrive) { + return NextResponse.json( + { error: 'You do not have permission to copy pages to this drive' }, + { status: 403 } + ); + } + + // Verify target parent exists if specified + if (targetParentId) { + const targetParent = await db.query.pages.findFirst({ + where: and( + eq(pages.id, targetParentId), + eq(pages.driveId, targetDriveId), + eq(pages.isTrashed, false) + ), + }); + if (!targetParent) { + return NextResponse.json({ error: 'Target folder not found' }, { status: 404 }); + } + } + + // Fetch source pages + const sourcePages = await db.query.pages.findMany({ + where: inArray(pages.id, pageIds), + }); + + if (sourcePages.length !== pageIds.length) { + return NextResponse.json({ error: 'Some pages not found' }, { status: 404 }); + } + + // Verify view permissions for all pages + for (const page of sourcePages) { + const canView = await canUserViewPage(userId, page.id); + if (!canView) { + return NextResponse.json( + { error: `You do not have permission to copy page: ${page.title}` }, + { status: 403 } + ); + } + } + + // Get the max position in target parent + const lastPage = await db.query.pages.findFirst({ + where: and( + eq(pages.driveId, targetDriveId), + targetParentId ? eq(pages.parentId, targetParentId) : isNull(pages.parentId), + eq(pages.isTrashed, false) + ), + orderBy: [desc(pages.position)], + }); + + let nextPosition = (lastPage?.position || 0) + 1; + let copiedCount = 0; + + // Copy pages in transaction + await db.transaction(async (tx) => { + for (const page of sourcePages) { + const newPageId = createId(); + + // Copy the page + await tx.insert(pages).values({ + id: newPageId, + title: page.title ? `${page.title} (Copy)` : 'Untitled (Copy)', + type: page.type, + content: page.content, + driveId: targetDriveId, + parentId: targetParentId, + position: nextPosition, + createdAt: new Date(), + updatedAt: new Date(), + revision: 0, + stateHash: null, + isTrashed: false, + aiProvider: page.aiProvider, + aiModel: page.aiModel, + systemPrompt: page.systemPrompt, + enabledTools: page.enabledTools, + isPaginated: page.isPaginated, + }); + + copiedCount += 1; + nextPosition += 1; + + // Recursively copy children if requested + if (includeChildren) { + const childCount = await copyChildrenRecursively( + tx, + page.id, + newPageId, + targetDriveId + ); + copiedCount += childCount; + } + } + }); + + // Invalidate cache and broadcast event + await pageTreeCache.invalidateDriveTree(targetDriveId); + await broadcastPageEvent( + createPageEventPayload(targetDriveId, '', 'created') + ); + + return NextResponse.json({ + success: true, + copiedCount, + }); + } catch (error) { + loggers.api.error('Error bulk copying pages:', error as Error); + return NextResponse.json( + { error: 'Failed to copy pages' }, + { status: 500 } + ); + } +} + +// Recursively copy children +async function copyChildrenRecursively( + tx: Parameters[0]>[0], + sourceParentId: string, + newParentId: string, + targetDriveId: string +): Promise { + const children = await tx.query.pages.findMany({ + where: and( + eq(pages.parentId, sourceParentId), + eq(pages.isTrashed, false) + ), + }); + + let copiedCount = 0; + + for (const child of children) { + const newChildId = createId(); + + await tx.insert(pages).values({ + id: newChildId, + title: child.title, + type: child.type, + content: child.content, + driveId: targetDriveId, + parentId: newParentId, + position: child.position, + createdAt: new Date(), + updatedAt: new Date(), + revision: 0, + stateHash: null, + isTrashed: false, + aiProvider: child.aiProvider, + aiModel: child.aiModel, + systemPrompt: child.systemPrompt, + enabledTools: child.enabledTools, + isPaginated: child.isPaginated, + }); + + copiedCount += 1; + + // Recursively copy grandchildren + const grandchildCount = await copyChildrenRecursively( + tx, + child.id, + newChildId, + targetDriveId + ); + copiedCount += grandchildCount; + } + + return copiedCount; +} diff --git a/apps/web/src/app/api/pages/bulk-delete/route.ts b/apps/web/src/app/api/pages/bulk-delete/route.ts new file mode 100644 index 00000000..75b638e5 --- /dev/null +++ b/apps/web/src/app/api/pages/bulk-delete/route.ts @@ -0,0 +1,153 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod/v4'; +import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket'; +import { loggers, pageTreeCache, agentAwarenessCache } from '@pagespace/lib/server'; +import { pages, db, eq, inArray } from '@pagespace/db'; +import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { canUserDeletePage } from '@pagespace/lib/server'; + +const AUTH_OPTIONS = { allow: ['session', 'mcp'] as const, requireCSRF: true }; + +const requestSchema = z.object({ + pageIds: z.array(z.string()).min(1, 'At least one page ID is required'), + trashChildren: z.boolean().default(true), +}); + +export async function DELETE(request: Request) { + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS); + if (isAuthError(auth)) { + return auth.error; + } + const userId = auth.userId; + + try { + const body = await request.json(); + + const parseResult = requestSchema.safeParse(body); + if (!parseResult.success) { + return NextResponse.json( + { error: parseResult.error.issues.map(i => i.message).join('. ') }, + { status: 400 } + ); + } + + const { pageIds, trashChildren } = parseResult.data; + + // Fetch source pages + const sourcePages = await db.query.pages.findMany({ + where: inArray(pages.id, pageIds), + }); + + if (sourcePages.length !== pageIds.length) { + return NextResponse.json({ error: 'Some pages not found' }, { status: 404 }); + } + + // Verify delete permissions for all pages + for (const page of sourcePages) { + const canDelete = await canUserDeletePage(userId, page.id); + if (!canDelete) { + return NextResponse.json( + { error: `You do not have permission to delete page: ${page.title}` }, + { status: 403 } + ); + } + } + + // Track affected drives for cache invalidation + const affectedDriveIds = new Set(); + let hasAIChatPages = sourcePages.some(p => p.type === 'AI_CHAT'); + + // Trash pages in transaction + await db.transaction(async (tx) => { + const now = new Date(); + + for (const page of sourcePages) { + affectedDriveIds.add(page.driveId); + + // Trash the page + await tx.update(pages) + .set({ + isTrashed: true, + trashedAt: now, + updatedAt: now, + }) + .where(eq(pages.id, page.id)); + + // Recursively trash children if requested + if (trashChildren) { + const trashedAIChat = await trashChildrenRecursively(tx, page.id, now); + if (trashedAIChat) { + hasAIChatPages = true; + } + } else { + // Move children to parent's parent + await tx.update(pages) + .set({ + parentId: page.parentId, + updatedAt: now, + }) + .where(eq(pages.parentId, page.id)); + } + } + }); + + // Invalidate caches and broadcast events + for (const driveId of affectedDriveIds) { + await pageTreeCache.invalidateDriveTree(driveId); + + if (hasAIChatPages) { + await agentAwarenessCache.invalidateDriveAgents(driveId); + } + + await broadcastPageEvent( + createPageEventPayload(driveId, '', 'trashed') + ); + } + + return NextResponse.json({ + success: true, + trashedCount: pageIds.length, + }); + } catch (error) { + loggers.api.error('Error bulk deleting pages:', error as Error); + return NextResponse.json( + { error: 'Failed to delete pages' }, + { status: 500 } + ); + } +} + +// Recursively trash children and return whether any AI_CHAT pages were trashed +async function trashChildrenRecursively( + tx: Parameters[0]>[0], + parentId: string, + trashedAt: Date +): Promise { + const children = await tx.query.pages.findMany({ + where: eq(pages.parentId, parentId), + }); + + let hasAIChatChild = false; + + for (const child of children) { + if (child.type === 'AI_CHAT') { + hasAIChatChild = true; + } + + await tx.update(pages) + .set({ + isTrashed: true, + trashedAt: trashedAt, + updatedAt: trashedAt, + }) + .where(eq(pages.id, child.id)); + + // Recursively trash grandchildren + const grandchildHasAIChat = await trashChildrenRecursively(tx, child.id, trashedAt); + if (grandchildHasAIChat) { + hasAIChatChild = true; + } + } + + return hasAIChatChild; +} diff --git a/apps/web/src/app/api/pages/bulk-move/route.ts b/apps/web/src/app/api/pages/bulk-move/route.ts new file mode 100644 index 00000000..e0d4685f --- /dev/null +++ b/apps/web/src/app/api/pages/bulk-move/route.ts @@ -0,0 +1,192 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod/v4'; +import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket'; +import { loggers, pageTreeCache } from '@pagespace/lib/server'; +import { pages, drives, driveMembers, db, and, eq, inArray, desc, isNull } from '@pagespace/db'; +import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { canUserEditPage } from '@pagespace/lib/server'; +import { validatePageMove } from '@pagespace/lib/pages/circular-reference-guard'; + +const AUTH_OPTIONS = { allow: ['session', 'mcp'] as const, requireCSRF: true }; + +const requestSchema = z.object({ + pageIds: z.array(z.string()).min(1, 'At least one page ID is required'), + targetDriveId: z.string().min(1, 'Target drive ID is required'), + targetParentId: z.string().nullable(), +}); + +export async function POST(request: Request) { + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS); + if (isAuthError(auth)) { + return auth.error; + } + const userId = auth.userId; + + try { + const body = await request.json(); + + const parseResult = requestSchema.safeParse(body); + if (!parseResult.success) { + return NextResponse.json( + { error: parseResult.error.issues.map(i => i.message).join('. ') }, + { status: 400 } + ); + } + + const { pageIds, targetDriveId, targetParentId } = parseResult.data; + + // Verify target drive exists + const targetDrive = await db.query.drives.findFirst({ + where: eq(drives.id, targetDriveId), + }); + + if (!targetDrive) { + return NextResponse.json({ error: 'Target drive not found' }, { status: 404 }); + } + + // Check user has edit access to target drive + const isOwner = targetDrive.ownerId === userId; + let canEditDrive = isOwner; + + if (!isOwner) { + const membership = await db.query.driveMembers.findFirst({ + where: and( + eq(driveMembers.driveId, targetDriveId), + eq(driveMembers.userId, userId) + ), + }); + canEditDrive = membership?.role === 'OWNER' || membership?.role === 'ADMIN'; + } + + if (!canEditDrive) { + return NextResponse.json( + { error: 'You do not have permission to move pages to this drive' }, + { status: 403 } + ); + } + + // Verify target parent exists if specified + if (targetParentId) { + const targetParent = await db.query.pages.findFirst({ + where: and( + eq(pages.id, targetParentId), + eq(pages.driveId, targetDriveId), + eq(pages.isTrashed, false) + ), + }); + if (!targetParent) { + return NextResponse.json({ error: 'Target folder not found' }, { status: 404 }); + } + } + + // Check user has edit access to all source pages + const sourcePages = await db.query.pages.findMany({ + where: inArray(pages.id, pageIds), + }); + + if (sourcePages.length !== pageIds.length) { + return NextResponse.json({ error: 'Some pages not found' }, { status: 404 }); + } + + // Verify edit permissions for all pages + for (const page of sourcePages) { + const canEdit = await canUserEditPage(userId, page.id); + if (!canEdit) { + return NextResponse.json( + { error: `You do not have permission to move page: ${page.title}` }, + { status: 403 } + ); + } + } + + // Validate move doesn't create circular references + for (const pageId of pageIds) { + if (targetParentId) { + const validation = await validatePageMove(pageId, targetParentId); + if (!validation.valid) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + } + } + + // Get the max position in target parent + const lastPage = await db.query.pages.findFirst({ + where: and( + eq(pages.driveId, targetDriveId), + targetParentId ? eq(pages.parentId, targetParentId) : isNull(pages.parentId), + eq(pages.isTrashed, false) + ), + orderBy: [desc(pages.position)], + }); + + let nextPosition = (lastPage?.position || 0) + 1; + + // Track affected drives for cache invalidation + const affectedDriveIds = new Set(); + affectedDriveIds.add(targetDriveId); + + // Move pages in transaction + await db.transaction(async (tx) => { + for (const page of sourcePages) { + const sourceDriveId = page.driveId; + affectedDriveIds.add(sourceDriveId); + + // Update page with new drive, parent, and position + await tx.update(pages) + .set({ + driveId: targetDriveId, + parentId: targetParentId, + position: nextPosition, + updatedAt: new Date(), + }) + .where(eq(pages.id, page.id)); + + // If moving to a different drive, recursively update all children's driveId + if (page.driveId !== targetDriveId) { + await updateChildrenDriveId(tx, page.id, targetDriveId); + } + + nextPosition += 1; + } + }); + + // Invalidate caches and broadcast events + for (const driveId of affectedDriveIds) { + await pageTreeCache.invalidateDriveTree(driveId); + await broadcastPageEvent( + createPageEventPayload(driveId, '', 'moved') + ); + } + + return NextResponse.json({ + success: true, + movedCount: pageIds.length, + }); + } catch (error) { + loggers.api.error('Error bulk moving pages:', error as Error); + return NextResponse.json( + { error: 'Failed to move pages' }, + { status: 500 } + ); + } +} + +// Recursively update driveId for all children +async function updateChildrenDriveId( + tx: Parameters[0]>[0], + parentId: string, + newDriveId: string +) { + const children = await tx.query.pages.findMany({ + where: eq(pages.parentId, parentId), + }); + + for (const child of children) { + await tx.update(pages) + .set({ driveId: newDriveId }) + .where(eq(pages.id, child.id)); + + // Recursively update grandchildren + await updateChildrenDriveId(tx, child.id, newDriveId); + } +} diff --git a/apps/web/src/app/api/pages/tree/route.ts b/apps/web/src/app/api/pages/tree/route.ts new file mode 100644 index 00000000..520864b4 --- /dev/null +++ b/apps/web/src/app/api/pages/tree/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod/v4'; +import { buildTree } from '@pagespace/lib/server'; +import { pages, drives, driveMembers, db, and, eq, asc } from '@pagespace/db'; +import { loggers } from '@pagespace/lib/server'; +import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; + +const AUTH_OPTIONS = { allow: ['session', 'mcp'] as const, requireCSRF: true }; + +const requestSchema = z.object({ + driveId: z.string().min(1, 'Drive ID is required'), +}); + +export async function POST(request: Request) { + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS); + if (isAuthError(auth)) { + return auth.error; + } + const userId = auth.userId; + + try { + const body = await request.json(); + + const parseResult = requestSchema.safeParse(body); + if (!parseResult.success) { + return NextResponse.json( + { error: parseResult.error.issues.map(i => i.message).join('. ') }, + { status: 400 } + ); + } + + const { driveId } = parseResult.data; + + // Find drive and check access + const drive = await db.query.drives.findFirst({ + where: eq(drives.id, driveId), + }); + + if (!drive) { + return NextResponse.json({ error: 'Drive not found' }, { status: 404 }); + } + + // Check authorization + const isOwner = drive.ownerId === userId; + let hasAccess = isOwner; + + if (!isOwner) { + const membership = await db.query.driveMembers.findFirst({ + where: and( + eq(driveMembers.driveId, driveId), + eq(driveMembers.userId, userId) + ), + }); + hasAccess = !!membership; + } + + if (!hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Fetch all non-trashed pages for the drive + const pageResults = await db.query.pages.findMany({ + where: and( + eq(pages.driveId, driveId), + eq(pages.isTrashed, false) + ), + orderBy: [asc(pages.position)], + }); + + const pageTree = buildTree(pageResults); + return NextResponse.json({ tree: pageTree }); + } catch (error) { + loggers.api.error('Error fetching page tree:', error as Error); + return NextResponse.json( + { error: 'Failed to fetch page tree' }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/components/dialogs/CopyPageDialog.tsx b/apps/web/src/components/dialogs/CopyPageDialog.tsx new file mode 100644 index 00000000..2a6e6cfe --- /dev/null +++ b/apps/web/src/components/dialogs/CopyPageDialog.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Loader2, FolderInput, ChevronRight, Copy } from "lucide-react"; +import { toast } from "sonner"; +import { useDriveStore, Drive } from "@/hooks/useDrive"; +import { fetchWithAuth } from "@/lib/auth/auth-fetch"; +import { SelectedPageInfo } from "@/stores/useMultiSelectStore"; +import { cn } from "@/lib/utils"; + +interface TreeNode { + id: string; + title: string; + type: string; + children?: TreeNode[]; +} + +interface CopyPageDialogProps { + isOpen: boolean; + onClose: () => void; + pages: SelectedPageInfo[]; + onSuccess?: () => void; +} + +export function CopyPageDialog({ + isOpen, + onClose, + pages, + onSuccess, +}: CopyPageDialogProps) { + const { drives, fetchDrives, isLoading: drivesLoading } = useDriveStore(); + const [selectedDriveId, setSelectedDriveId] = useState(""); + const [selectedParentId, setSelectedParentId] = useState(null); + const [includeChildren, setIncludeChildren] = useState(true); + const [pageTree, setPageTree] = useState([]); + const [treeLoading, setTreeLoading] = useState(false); + const [isCopying, setIsCopying] = useState(false); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + + // Check if any page has children (for showing the include children option) + const anyPageHasChildren = pages.some((p) => p.type === "FOLDER"); + + // Fetch drives on mount + useEffect(() => { + if (isOpen) { + fetchDrives(); + } + }, [isOpen, fetchDrives]); + + // Filter drives to only show ones where user has edit access + const availableDrives = drives.filter( + (d: Drive) => d.isOwned || d.role === "OWNER" || d.role === "ADMIN" + ); + + // Fetch page tree when drive changes + useEffect(() => { + if (!selectedDriveId) { + setPageTree([]); + return; + } + + const fetchTree = async () => { + setTreeLoading(true); + try { + const response = await fetchWithAuth("/api/pages/tree", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ driveId: selectedDriveId }), + }); + if (response.ok) { + const data = await response.json(); + setPageTree(data.tree || []); + } + } catch (error) { + console.error("Failed to fetch page tree:", error); + } finally { + setTreeLoading(false); + } + }; + + fetchTree(); + }, [selectedDriveId]); + + // Reset state when dialog opens/closes + useEffect(() => { + if (isOpen) { + // Set default drive to first available + if (availableDrives.length > 0) { + setSelectedDriveId(availableDrives[0].id); + } + setSelectedParentId(null); + setIncludeChildren(true); + setExpandedNodes(new Set()); + } + }, [isOpen, availableDrives]); + + const toggleExpanded = useCallback((nodeId: string) => { + setExpandedNodes((prev) => { + const next = new Set(prev); + if (next.has(nodeId)) { + next.delete(nodeId); + } else { + next.add(nodeId); + } + return next; + }); + }, []); + + const handleCopy = async () => { + if (!selectedDriveId) { + toast.error("Please select a destination drive"); + return; + } + + setIsCopying(true); + const toastId = toast.loading( + `Copying ${pages.length} ${pages.length === 1 ? "page" : "pages"}...` + ); + + try { + const response = await fetchWithAuth("/api/pages/bulk-copy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + pageIds: pages.map((p) => p.id), + targetDriveId: selectedDriveId, + targetParentId: selectedParentId, + includeChildren, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to copy pages"); + } + + const result = await response.json(); + toast.success( + `Successfully copied ${result.copiedCount || pages.length} ${(result.copiedCount || pages.length) === 1 ? "page" : "pages"}`, + { id: toastId } + ); + onSuccess?.(); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to copy pages", + { id: toastId } + ); + } finally { + setIsCopying(false); + } + }; + + // Recursive tree renderer + const renderTreeNode = (node: TreeNode, depth: number = 0): React.ReactNode => { + // Only show folders + if (node.type !== "FOLDER") { + return null; + } + + const hasChildren = node.children && node.children.length > 0; + const isExpanded = expandedNodes.has(node.id); + const isSelected = selectedParentId === node.id; + + // Filter children recursively + const filteredChildren = node.children?.filter( + (child) => child.type === "FOLDER" + ); + + return ( +
+
setSelectedParentId(node.id)} + > + {hasChildren && filteredChildren && filteredChildren.length > 0 ? ( + + ) : ( + + )} + + {node.title} +
+ {isExpanded && filteredChildren && filteredChildren.length > 0 && ( +
+ {filteredChildren.map((child) => renderTreeNode(child, depth + 1))} +
+ )} +
+ ); + }; + + const pageNames = pages.map((p) => p.title).join(", "); + const displayPageNames = + pageNames.length > 50 ? pageNames.slice(0, 50) + "..." : pageNames; + + return ( + + + + + + Copy {pages.length === 1 ? "Page" : "Pages"} + + + Copying: {displayPageNames} + + + +
+
+ + +
+ +
+ +
+ {treeLoading ? ( +
+ +
+ ) : ( + <> +
setSelectedParentId(null)} + > + + Root (Top Level) +
+ {pageTree.map((node) => renderTreeNode(node))} + + )} +
+
+ + {anyPageHasChildren && ( +
+ setIncludeChildren(checked === true)} + /> + +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/web/src/components/dialogs/MovePageDialog.tsx b/apps/web/src/components/dialogs/MovePageDialog.tsx new file mode 100644 index 00000000..9fff59f3 --- /dev/null +++ b/apps/web/src/components/dialogs/MovePageDialog.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Loader2, FolderInput, ChevronRight } from "lucide-react"; +import { toast } from "sonner"; +import { useDriveStore, Drive } from "@/hooks/useDrive"; +import { fetchWithAuth } from "@/lib/auth/auth-fetch"; +import { SelectedPageInfo } from "@/stores/useMultiSelectStore"; +import { cn } from "@/lib/utils"; + +interface TreeNode { + id: string; + title: string; + type: string; + children?: TreeNode[]; +} + +interface MovePageDialogProps { + isOpen: boolean; + onClose: () => void; + pages: SelectedPageInfo[]; + onSuccess?: () => void; +} + +export function MovePageDialog({ + isOpen, + onClose, + pages, + onSuccess, +}: MovePageDialogProps) { + const { drives, fetchDrives, isLoading: drivesLoading } = useDriveStore(); + const [selectedDriveId, setSelectedDriveId] = useState(""); + const [selectedParentId, setSelectedParentId] = useState(null); + const [pageTree, setPageTree] = useState([]); + const [treeLoading, setTreeLoading] = useState(false); + const [isMoving, setIsMoving] = useState(false); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + + // Filter out the pages being moved from tree display + const movingPageIds = new Set(pages.map((p) => p.id)); + + // Fetch drives on mount + useEffect(() => { + if (isOpen) { + fetchDrives(); + } + }, [isOpen, fetchDrives]); + + // Filter drives to only show ones where user has edit access + const availableDrives = drives.filter( + (d: Drive) => d.isOwned || d.role === "OWNER" || d.role === "ADMIN" + ); + + // Fetch page tree when drive changes + useEffect(() => { + if (!selectedDriveId) { + setPageTree([]); + return; + } + + const fetchTree = async () => { + setTreeLoading(true); + try { + const response = await fetchWithAuth("/api/pages/tree", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ driveId: selectedDriveId }), + }); + if (response.ok) { + const data = await response.json(); + setPageTree(data.tree || []); + } + } catch (error) { + console.error("Failed to fetch page tree:", error); + } finally { + setTreeLoading(false); + } + }; + + fetchTree(); + }, [selectedDriveId]); + + // Reset state when dialog opens/closes + useEffect(() => { + if (isOpen) { + // Set default drive to the first page's current drive, or first available + const currentDriveId = pages[0]?.driveId; + if (currentDriveId && availableDrives.some((d: Drive) => d.id === currentDriveId)) { + setSelectedDriveId(currentDriveId); + } else if (availableDrives.length > 0) { + setSelectedDriveId(availableDrives[0].id); + } + setSelectedParentId(null); + setExpandedNodes(new Set()); + } + }, [isOpen, pages, availableDrives]); + + const toggleExpanded = useCallback((nodeId: string) => { + setExpandedNodes((prev) => { + const next = new Set(prev); + if (next.has(nodeId)) { + next.delete(nodeId); + } else { + next.add(nodeId); + } + return next; + }); + }, []); + + const handleMove = async () => { + if (!selectedDriveId) { + toast.error("Please select a destination drive"); + return; + } + + setIsMoving(true); + const toastId = toast.loading( + `Moving ${pages.length} ${pages.length === 1 ? "page" : "pages"}...` + ); + + try { + const response = await fetchWithAuth("/api/pages/bulk-move", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + pageIds: pages.map((p) => p.id), + targetDriveId: selectedDriveId, + targetParentId: selectedParentId, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to move pages"); + } + + toast.success( + `Successfully moved ${pages.length} ${pages.length === 1 ? "page" : "pages"}`, + { id: toastId } + ); + onSuccess?.(); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to move pages", + { id: toastId } + ); + } finally { + setIsMoving(false); + } + }; + + // Recursive tree renderer + const renderTreeNode = (node: TreeNode, depth: number = 0): React.ReactNode => { + // Skip pages that are being moved + if (movingPageIds.has(node.id)) { + return null; + } + + // Only show folders + if (node.type !== "FOLDER") { + return null; + } + + const hasChildren = node.children && node.children.length > 0; + const isExpanded = expandedNodes.has(node.id); + const isSelected = selectedParentId === node.id; + + // Filter children recursively + const filteredChildren = node.children?.filter( + (child) => !movingPageIds.has(child.id) && child.type === "FOLDER" + ); + + return ( +
+
setSelectedParentId(node.id)} + > + {hasChildren && filteredChildren && filteredChildren.length > 0 ? ( + + ) : ( + + )} + + {node.title} +
+ {isExpanded && filteredChildren && filteredChildren.length > 0 && ( +
+ {filteredChildren.map((child) => renderTreeNode(child, depth + 1))} +
+ )} +
+ ); + }; + + const pageNames = pages.map((p) => p.title).join(", "); + const displayPageNames = + pageNames.length > 50 ? pageNames.slice(0, 50) + "..." : pageNames; + + return ( + + + + Move {pages.length === 1 ? "Page" : "Pages"} + + Moving: {displayPageNames} + + + +
+
+ + +
+ +
+ +
+ {treeLoading ? ( +
+ +
+ ) : ( + <> +
setSelectedParentId(null)} + > + + Root (Top Level) +
+ {pageTree.map((node) => renderTreeNode(node))} + + )} +
+
+
+ + + + + +
+
+ ); +} diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/MultiSelectToolbar.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/MultiSelectToolbar.tsx new file mode 100644 index 00000000..4a2305b3 --- /dev/null +++ b/apps/web/src/components/layout/left-sidebar/page-tree/MultiSelectToolbar.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { X, Trash2, FolderInput, Copy, CheckSquare } from "lucide-react"; +import { toast } from "sonner"; +import { useMultiSelectStore } from "@/stores/useMultiSelectStore"; +import { MovePageDialog } from "@/components/dialogs/MovePageDialog"; +import { CopyPageDialog } from "@/components/dialogs/CopyPageDialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { fetchWithAuth } from "@/lib/auth/auth-fetch"; + +interface MultiSelectToolbarProps { + driveId: string; + onMutate: () => void; +} + +export function MultiSelectToolbar({ driveId, onMutate }: MultiSelectToolbarProps) { + const { + isMultiSelectMode, + activeDriveId, + getSelectedPages, + getSelectedCount, + exitMultiSelectMode, + } = useMultiSelectStore(); + + const [isMoveOpen, setMoveOpen] = useState(false); + const [isCopyOpen, setCopyOpen] = useState(false); + const [isDeleteOpen, setDeleteOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const selectedPages = getSelectedPages(); + const selectedCount = getSelectedCount(); + + // Must define all hooks before any conditional returns + const handleSuccess = useCallback(() => { + exitMultiSelectMode(); + onMutate(); + }, [exitMultiSelectMode, onMutate]); + + const handleCancel = useCallback(() => { + exitMultiSelectMode(); + }, [exitMultiSelectMode]); + + const handleBulkDelete = useCallback(async () => { + setIsDeleting(true); + const count = selectedCount; + const pageIds = selectedPages.map((p) => p.id); + const toastId = toast.loading( + `Moving ${count} ${count === 1 ? "page" : "pages"} to trash...` + ); + + try { + const response = await fetchWithAuth("/api/pages/bulk-delete", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + pageIds, + trashChildren: true, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to delete pages"); + } + + toast.success( + `Successfully moved ${count} ${count === 1 ? "page" : "pages"} to trash`, + { id: toastId } + ); + exitMultiSelectMode(); + onMutate(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete pages", + { id: toastId } + ); + } finally { + setIsDeleting(false); + setDeleteOpen(false); + } + }, [selectedCount, selectedPages, exitMultiSelectMode, onMutate]); + + // Only show toolbar for the active drive + if (!isMultiSelectMode || activeDriveId !== driveId) { + return null; + } + + return ( + <> +
+
+
+ + + {selectedCount} selected + +
+ +
+ + + + + Move to... + + + + + + + Copy to... + + + + + + + Move to trash + + + + + + + Cancel selection + +
+
+
+ + setMoveOpen(false)} + pages={selectedPages} + onSuccess={handleSuccess} + /> + + setCopyOpen(false)} + pages={selectedPages} + onSuccess={handleSuccess} + /> + + + + + Move to Trash? + + This will move {selectedCount} {selectedCount === 1 ? "page" : "pages"} and all their + children to the trash. You can restore them later. + + + + Cancel + + {isDeleting ? "Moving..." : "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 0e8c1d7e..7c15baf2 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 @@ -16,6 +16,7 @@ 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 { MultiSelectToolbar } from "./MultiSelectToolbar"; import { KeyedMutator } from "swr"; interface PageTreeProps { @@ -293,6 +294,7 @@ export default function PageTree({ return ( +
state.createTab); const isTouchDevice = useTouchDevice(); const { isNative } = useCapacitor(); const hasChildren = item.children && item.children.length > 0; + const driveId = params.driveId as string; + + // Multi-select state + const { + isMultiSelectMode, + activeDriveId, + enterMultiSelectMode, + togglePageSelection, + isSelected, + } = useMultiSelectStore(); + + const isInMultiSelectMode = isMultiSelectMode && activeDriveId === driveId; + const isPageSelected = isSelected(item.id); + + // Memoize page info for selection to prevent unnecessary re-renders + const pageInfo: SelectedPageInfo = useMemo(() => ({ + id: item.id, + title: item.title, + type: item.type, + driveId: driveId, + parentId: item.parentId ?? null, + }), [item.id, item.title, item.type, item.parentId, driveId]); + + const handleEnterMultiSelect = useCallback(() => { + enterMultiSelectMode(driveId); + togglePageSelection(pageInfo); + }, [enterMultiSelectMode, driveId, togglePageSelection, pageInfo]); + + const handleCheckboxClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + togglePageSelection(pageInfo); + }, [togglePageSelection, pageInfo]); const linkHref = `/dashboard/${params.driveId}/${item.id}`; @@ -231,10 +274,26 @@ export function PageTreeItem({ "bg-primary/10 dark:bg-primary/20 ring-2 ring-primary ring-inset", !isActive && !showDropIndicator && "hover:bg-gray-200 dark:hover:bg-gray-700", - params.pageId === item.id && "bg-gray-200 dark:bg-gray-700" + params.pageId === item.id && "bg-gray-200 dark:bg-gray-700", + isInMultiSelectMode && isPageSelected && "bg-primary/10 dark:bg-primary/20 ring-1 ring-primary/50" )} style={{ paddingLeft: `${depth * 8 + 4}px` }} > + {/* Multi-select Checkbox */} + {isInMultiSelectMode && ( +
e.stopPropagation()} + > + +
+ )} + {/* Expand/Collapse Chevron */} {hasChildren && (