-
Notifications
You must be signed in to change notification settings - Fork 2
Add multi-select and drag functionality to sidebar #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0368939
5fff7cf
7d69ca3
9bb6068
a02fbb3
232a9c1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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 ( | ||||||
| <AlertDialog open={isOpen} onOpenChange={onClose}> | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrap the The Apply this diff: - <AlertDialog open={isOpen} onOpenChange={onClose}>
+ <AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| <AlertDialogContent> | ||||||
| <AlertDialogHeader> | ||||||
| <AlertDialogTitle>Move {pageCount} pages to trash?</AlertDialogTitle> | ||||||
| <AlertDialogDescription asChild> | ||||||
| <div className="space-y-2"> | ||||||
| <p> | ||||||
| This action will move {pageCount} selected page{pageCount !== 1 ? "s" : ""} to the trash. | ||||||
| You can restore them later. | ||||||
| </p> | ||||||
| {showNames && ( | ||||||
| <ul className="mt-2 list-disc list-inside text-sm text-muted-foreground"> | ||||||
| {pageNames.map((name, index) => ( | ||||||
| <li key={index} className="truncate">{name}</li> | ||||||
| ))} | ||||||
| </ul> | ||||||
| )} | ||||||
| {pageNames && pageNames.length > 5 && ( | ||||||
| <p className="text-sm text-muted-foreground"> | ||||||
| and {pageNames.length - 5} more... | ||||||
| </p> | ||||||
| )} | ||||||
| </div> | ||||||
| </AlertDialogDescription> | ||||||
| </AlertDialogHeader> | ||||||
| <AlertDialogFooter> | ||||||
| <AlertDialogCancel>Cancel</AlertDialogCancel> | ||||||
| <AlertDialogAction onClick={onConfirm}> | ||||||
| Move to Trash | ||||||
| </AlertDialogAction> | ||||||
| </AlertDialogFooter> | ||||||
| </AlertDialogContent> | ||||||
| </AlertDialog> | ||||||
| ); | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix the confusing "and N more..." message when no names are displayed.
When there are more than 5 pages,
showNamesisfalse(line 27), so no page names are rendered in lines 40-46. However, lines 47-51 then display "and {pageNames.length - 5} more..." which implies that some names were already shown. This creates a confusing user experience.Solution 1 (recommended): Display the first 5 names plus "and N more..." when there are more than 5 pages:
{showNames && ( <ul className="mt-2 list-disc list-inside text-sm text-muted-foreground"> - {pageNames.map((name, index) => ( + {displayNames.map((name, index) => ( <li key={index} className="truncate">{name}</li> ))} </ul>Solution 2: Change the message to not imply previous names were shown:
{pageNames && pageNames.length > 5 && ( <p className="text-sm text-muted-foreground"> - and {pageNames.length - 5} more... + {pageNames.length} pages selected </p> )}Also applies to: 40-51
🤖 Prompt for AI Agents