mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 09:16:22 +02:00
feat: add folder management features including creation, deletion, and organization of documents within folders
This commit is contained in:
parent
95bb522220
commit
685ad0c02d
41 changed files with 7475 additions and 4330 deletions
94
surfsense_web/components/documents/CreateFolderDialog.tsx
Normal file
94
surfsense_web/components/documents/CreateFolderDialog.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
import { FolderPlus } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface CreateFolderDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
parentFolderName?: string | null;
|
||||
onConfirm: (name: string) => void;
|
||||
}
|
||||
|
||||
export function CreateFolderDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
parentFolderName,
|
||||
onConfirm,
|
||||
}: CreateFolderDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
onConfirm(trimmed);
|
||||
onOpenChange(false);
|
||||
},
|
||||
[name, onConfirm, onOpenChange],
|
||||
);
|
||||
|
||||
const isSubfolder = !!parentFolderName;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FolderPlus className="size-5 text-muted-foreground" />
|
||||
{isSubfolder ? "New subfolder" : "New folder"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isSubfolder
|
||||
? `Create a new folder inside "${parentFolderName}".`
|
||||
: "Create a new folder at the root level."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="folder-name">Folder name</Label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="folder-name"
|
||||
placeholder="e.g. Research, Notes, Archive…"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={255}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
192
surfsense_web/components/documents/DocumentNode.tsx
Normal file
192
surfsense_web/components/documents/DocumentNode.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
Move,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import { useDrag } from "react-dnd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getDocumentTypeIcon } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DND_TYPES } from "./FolderNode";
|
||||
|
||||
export interface DocumentNodeDoc {
|
||||
id: number;
|
||||
title: string;
|
||||
document_type: string;
|
||||
folderId: number | null;
|
||||
status?: { state: string; reason?: string | null };
|
||||
}
|
||||
|
||||
interface DocumentNodeProps {
|
||||
doc: DocumentNodeDoc;
|
||||
depth: number;
|
||||
isMentioned: boolean;
|
||||
onToggleChatMention: (doc: DocumentNodeDoc, isMentioned: boolean) => void;
|
||||
onPreview: (doc: DocumentNodeDoc) => void;
|
||||
onEdit: (doc: DocumentNodeDoc) => void;
|
||||
onDelete: (doc: DocumentNodeDoc) => void;
|
||||
onMove: (doc: DocumentNodeDoc) => void;
|
||||
}
|
||||
|
||||
export const DocumentNode = React.memo(function DocumentNode({
|
||||
doc,
|
||||
depth,
|
||||
isMentioned,
|
||||
onToggleChatMention,
|
||||
onPreview,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMove,
|
||||
}: DocumentNodeProps) {
|
||||
const statusState = doc.status?.state ?? "ready";
|
||||
const isSelectable = statusState !== "pending" && statusState !== "processing";
|
||||
const isEditable =
|
||||
doc.document_type === "NOTE" &&
|
||||
statusState !== "pending" &&
|
||||
statusState !== "processing";
|
||||
|
||||
const handleCheckChange = useCallback(() => {
|
||||
if (isSelectable) {
|
||||
onToggleChatMention(doc, isMentioned);
|
||||
}
|
||||
}, [doc, isMentioned, isSelectable, onToggleChatMention]);
|
||||
|
||||
const [{ isDragging }, drag] = useDrag(
|
||||
() => ({
|
||||
type: DND_TYPES.DOCUMENT,
|
||||
item: { id: doc.id },
|
||||
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
|
||||
}),
|
||||
[doc.id],
|
||||
);
|
||||
|
||||
const isProcessing = statusState === "pending" || statusState === "processing";
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
ref={drag}
|
||||
className={cn(
|
||||
"group flex h-8 items-center gap-1.5 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none",
|
||||
isMentioned && "bg-accent/30",
|
||||
isDragging && "opacity-40",
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
||||
onClick={handleCheckChange}
|
||||
>
|
||||
{isSelectable ? (
|
||||
<Checkbox
|
||||
checked={isMentioned}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
statusState === "processing" && "animate-pulse bg-amber-500",
|
||||
statusState === "pending" && "bg-muted-foreground/40",
|
||||
statusState === "failed" && "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="flex-1 min-w-0 truncate">{doc.title}</span>
|
||||
|
||||
<span className="shrink-0">
|
||||
{getDocumentTypeIcon(doc.document_type as DocumentTypeEnum, "h-3.5 w-3.5 text-muted-foreground")}
|
||||
</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onClick={() => onPreview(doc)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Preview
|
||||
</DropdownMenuItem>
|
||||
{isEditable && (
|
||||
<DropdownMenuItem onClick={() => onEdit(doc)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isProcessing}
|
||||
onClick={() => onDelete(doc)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent className="w-44">
|
||||
<ContextMenuItem onClick={() => onPreview(doc)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Preview
|
||||
</ContextMenuItem>
|
||||
{isEditable && (
|
||||
<ContextMenuItem onClick={() => onEdit(doc)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isProcessing}
|
||||
onClick={() => onDelete(doc)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
});
|
||||
333
surfsense_web/components/documents/FolderNode.tsx
Normal file
333
surfsense_web/components/documents/FolderNode.tsx
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
MoreHorizontal,
|
||||
Move,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const DND_TYPES = {
|
||||
FOLDER: "FOLDER",
|
||||
DOCUMENT: "DOCUMENT",
|
||||
} as const;
|
||||
|
||||
type DropZone = "top" | "middle" | "bottom";
|
||||
|
||||
export interface FolderDisplay {
|
||||
id: number;
|
||||
name: string;
|
||||
position: string;
|
||||
parentId: number | null;
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
interface FolderNodeProps {
|
||||
folder: FolderDisplay;
|
||||
depth: number;
|
||||
isExpanded: boolean;
|
||||
isRenaming: boolean;
|
||||
childCount: number;
|
||||
onToggleExpand: (folderId: number) => void;
|
||||
onRename: (folder: FolderDisplay, newName: string) => void;
|
||||
onStartRename: (folderId: number) => void;
|
||||
onCancelRename: () => void;
|
||||
onDelete: (folder: FolderDisplay) => void;
|
||||
onMove: (folder: FolderDisplay) => void;
|
||||
onCreateSubfolder: (parentId: number) => void;
|
||||
onDropIntoFolder?: (itemType: "folder" | "document", itemId: number, targetFolderId: number) => void;
|
||||
onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void;
|
||||
siblingPositions?: { before: string | null; after: string | null };
|
||||
disabledDropIds?: Set<number>;
|
||||
}
|
||||
|
||||
function getDropZone(monitor: { getClientOffset: () => { y: number } | null }, element: HTMLElement): DropZone {
|
||||
const offset = monitor.getClientOffset();
|
||||
if (!offset) return "middle";
|
||||
const rect = element.getBoundingClientRect();
|
||||
const y = offset.y - rect.top;
|
||||
const pct = y / rect.height;
|
||||
if (pct < 0.25) return "top";
|
||||
if (pct > 0.75) return "bottom";
|
||||
return "middle";
|
||||
}
|
||||
|
||||
export const FolderNode = React.memo(function FolderNode({
|
||||
folder,
|
||||
depth,
|
||||
isExpanded,
|
||||
isRenaming,
|
||||
childCount,
|
||||
onToggleExpand,
|
||||
onRename,
|
||||
onStartRename,
|
||||
onCancelRename,
|
||||
onDelete,
|
||||
onMove,
|
||||
onCreateSubfolder,
|
||||
onDropIntoFolder,
|
||||
onReorderFolder,
|
||||
siblingPositions,
|
||||
disabledDropIds,
|
||||
}: FolderNodeProps) {
|
||||
const [renameValue, setRenameValue] = useState(folder.name);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const [dropZone, setDropZone] = useState<DropZone | null>(null);
|
||||
|
||||
const [{ isDragging }, drag] = useDrag(
|
||||
() => ({
|
||||
type: DND_TYPES.FOLDER,
|
||||
item: { id: folder.id, position: folder.position, parentId: folder.parentId },
|
||||
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
|
||||
}),
|
||||
[folder.id, folder.position, folder.parentId],
|
||||
);
|
||||
|
||||
const [{ isOver, canDrop }, drop] = useDrop(
|
||||
() => ({
|
||||
accept: [DND_TYPES.FOLDER, DND_TYPES.DOCUMENT],
|
||||
canDrop: (item: { id: number }) => {
|
||||
if (item.id === folder.id) return false;
|
||||
if (disabledDropIds?.has(item.id)) return false;
|
||||
return true;
|
||||
},
|
||||
hover: (_item, monitor) => {
|
||||
if (!rowRef.current || !monitor.isOver({ shallow: true })) {
|
||||
setDropZone(null);
|
||||
return;
|
||||
}
|
||||
setDropZone(getDropZone(monitor, rowRef.current));
|
||||
},
|
||||
drop: (item: { id: number }, monitor) => {
|
||||
if (!rowRef.current) return;
|
||||
const zone = getDropZone(monitor, rowRef.current);
|
||||
const type = monitor.getItemType();
|
||||
|
||||
if (zone === "middle") {
|
||||
if (type === DND_TYPES.FOLDER) {
|
||||
onDropIntoFolder?.("folder", item.id, folder.id);
|
||||
} else {
|
||||
onDropIntoFolder?.("document", item.id, folder.id);
|
||||
}
|
||||
} else if (type === DND_TYPES.FOLDER && onReorderFolder && siblingPositions) {
|
||||
if (zone === "top") {
|
||||
onReorderFolder(item.id, siblingPositions.before, folder.position);
|
||||
} else {
|
||||
onReorderFolder(item.id, folder.position, siblingPositions.after);
|
||||
}
|
||||
}
|
||||
setDropZone(null);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver({ shallow: true }),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
}),
|
||||
[folder.id, folder.position, disabledDropIds, onDropIntoFolder, onReorderFolder, siblingPositions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOver) setDropZone(null);
|
||||
}, [isOver]);
|
||||
|
||||
const attachRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
rowRef.current = node;
|
||||
drag(drop(node));
|
||||
},
|
||||
[drag, drop],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isRenaming]);
|
||||
|
||||
const handleRenameSubmit = useCallback(() => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed && trimmed !== folder.name) {
|
||||
onRename(folder, trimmed);
|
||||
}
|
||||
onCancelRename();
|
||||
}, [renameValue, folder, onRename, onCancelRename]);
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRenameSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setRenameValue(folder.name);
|
||||
onCancelRename();
|
||||
}
|
||||
},
|
||||
[handleRenameSubmit, folder.name, onCancelRename],
|
||||
);
|
||||
|
||||
const startRename = useCallback(() => {
|
||||
setRenameValue(folder.name);
|
||||
onStartRename(folder.id);
|
||||
}, [folder, onStartRename]);
|
||||
|
||||
const FolderIcon = isExpanded ? FolderOpen : Folder;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild disabled={isRenaming}>
|
||||
<div
|
||||
ref={attachRef}
|
||||
className={cn(
|
||||
"group relative flex h-8 items-center gap-1 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none",
|
||||
isExpanded && "font-medium",
|
||||
isDragging && "opacity-40",
|
||||
isOver && canDrop && dropZone === "middle" && "bg-accent ring-1 ring-primary/40",
|
||||
isOver && canDrop && dropZone === "top" && "border-t-2 border-primary",
|
||||
isOver && canDrop && dropZone === "bottom" && "border-b-2 border-primary",
|
||||
isOver && !canDrop && "cursor-not-allowed",
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
||||
onClick={() => onToggleExpand(folder.id)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename();
|
||||
}}
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={handleRenameSubmit}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 min-w-0 rounded border border-primary bg-background px-1 py-0.5 text-sm outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 min-w-0 truncate">{folder.name}</span>
|
||||
)}
|
||||
|
||||
{!isRenaming && childCount > 0 && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
|
||||
{childCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isRenaming && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCreateSubfolder(folder.id);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" />
|
||||
New subfolder
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename();
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMove(folder);
|
||||
}}
|
||||
>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(folder);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
{!isRenaming && (
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem onClick={() => onCreateSubfolder(folder.id)}>
|
||||
<FolderPlus className="mr-2 h-4 w-4" />
|
||||
New subfolder
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => startRename()}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onMove(folder)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem className="text-destructive focus:text-destructive" onClick={() => onDelete(folder)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
});
|
||||
157
surfsense_web/components/documents/FolderPickerDialog.tsx
Normal file
157
surfsense_web/components/documents/FolderPickerDialog.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, Home } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FolderDisplay } from "./FolderNode";
|
||||
|
||||
interface FolderPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
folders: FolderDisplay[];
|
||||
title: string;
|
||||
description?: string;
|
||||
disabledFolderIds?: Set<number>;
|
||||
onSelect: (folderId: number | null) => void;
|
||||
}
|
||||
|
||||
export function FolderPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
folders,
|
||||
title,
|
||||
description,
|
||||
disabledFolderIds,
|
||||
onSelect,
|
||||
}: FolderPickerDialogProps) {
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedId(null);
|
||||
setExpandedIds(new Set());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const foldersByParent = useMemo(() => {
|
||||
const map: Record<string, FolderDisplay[]> = {};
|
||||
for (const f of folders) {
|
||||
const key = f.parentId ?? "root";
|
||||
(map[key] ??= []).push(f);
|
||||
}
|
||||
return map;
|
||||
}, [folders]);
|
||||
|
||||
const toggleExpand = useCallback((id: number) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
onSelect(selectedId);
|
||||
onOpenChange(false);
|
||||
}, [selectedId, onSelect, onOpenChange]);
|
||||
|
||||
function renderPickerLevel(parentId: number | null, depth: number): React.ReactNode[] {
|
||||
const key = parentId ?? "root";
|
||||
const children = (foldersByParent[key] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.position.localeCompare(b.position));
|
||||
|
||||
return children.flatMap((f) => {
|
||||
const isDisabled = disabledFolderIds?.has(f.id) ?? false;
|
||||
const isExpanded = expandedIds.has(f.id);
|
||||
const hasChildren = (foldersByParent[f.id] ?? []).length > 0;
|
||||
const isSelected = selectedId === f.id;
|
||||
const FolderIcon = isExpanded ? FolderOpen : Folder;
|
||||
|
||||
return [
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isSelected && "bg-accent text-accent-foreground",
|
||||
!isSelected && !isDisabled && "hover:bg-accent/50",
|
||||
isDisabled && "cursor-not-allowed opacity-40",
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => {
|
||||
if (!isDisabled) setSelectedId(f.id);
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<span
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(f.id);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{f.name}</span>
|
||||
</button>,
|
||||
...(isExpanded ? renderPickerLevel(f.id, depth + 1) : []),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-md border p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
selectedId === null && "bg-accent text-accent-foreground",
|
||||
selectedId !== null && "hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => setSelectedId(null)}
|
||||
>
|
||||
<span className="h-4 w-4 shrink-0" />
|
||||
<Home className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span>Root</span>
|
||||
</button>
|
||||
{renderPickerLevel(null, 1)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>Move here</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
216
surfsense_web/components/documents/FolderTreeView.tsx
Normal file
216
surfsense_web/components/documents/FolderTreeView.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { TreePine } from "lucide-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { renamingFolderIdAtom } from "@/atoms/documents/folder.atoms";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { DocumentNode, type DocumentNodeDoc } from "./DocumentNode";
|
||||
import { FolderNode, type FolderDisplay } from "./FolderNode";
|
||||
|
||||
interface FolderTreeViewProps {
|
||||
folders: FolderDisplay[];
|
||||
documents: DocumentNodeDoc[];
|
||||
expandedIds: Set<number>;
|
||||
onToggleExpand: (folderId: number) => void;
|
||||
mentionedDocIds: Set<number>;
|
||||
onToggleChatMention: (doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => void;
|
||||
onRenameFolder: (folder: FolderDisplay, newName: string) => void;
|
||||
onDeleteFolder: (folder: FolderDisplay) => void;
|
||||
onMoveFolder: (folder: FolderDisplay) => void;
|
||||
onCreateFolder: (parentId: number | null) => void;
|
||||
onPreviewDocument: (doc: DocumentNodeDoc) => void;
|
||||
onEditDocument: (doc: DocumentNodeDoc) => void;
|
||||
onDeleteDocument: (doc: DocumentNodeDoc) => void;
|
||||
onMoveDocument: (doc: DocumentNodeDoc) => void;
|
||||
activeTypes: DocumentTypeEnum[];
|
||||
onDropIntoFolder?: (itemType: "folder" | "document", itemId: number, targetFolderId: number | null) => void;
|
||||
onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void;
|
||||
}
|
||||
|
||||
function groupBy<T>(items: T[], keyFn: (item: T) => string | number): Record<string | number, T[]> {
|
||||
const result: Record<string | number, T[]> = {};
|
||||
for (const item of items) {
|
||||
const key = keyFn(item);
|
||||
(result[key] ??= []).push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function FolderTreeView({
|
||||
folders,
|
||||
documents,
|
||||
expandedIds,
|
||||
onToggleExpand,
|
||||
mentionedDocIds,
|
||||
onToggleChatMention,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onMoveFolder,
|
||||
onCreateFolder,
|
||||
onPreviewDocument,
|
||||
onEditDocument,
|
||||
onDeleteDocument,
|
||||
onMoveDocument,
|
||||
activeTypes,
|
||||
onDropIntoFolder,
|
||||
onReorderFolder,
|
||||
}: FolderTreeViewProps) {
|
||||
const foldersByParent = useMemo(
|
||||
() => groupBy(folders, (f) => f.parentId ?? "root"),
|
||||
[folders],
|
||||
);
|
||||
|
||||
const docsByFolder = useMemo(
|
||||
() => groupBy(documents, (d) => d.folderId ?? "root"),
|
||||
[documents],
|
||||
);
|
||||
|
||||
const folderChildCounts = useMemo(() => {
|
||||
const counts: Record<number, number> = {};
|
||||
for (const f of folders) {
|
||||
const children = foldersByParent[f.id] ?? [];
|
||||
const docs = docsByFolder[f.id] ?? [];
|
||||
counts[f.id] = children.length + docs.length;
|
||||
}
|
||||
return counts;
|
||||
}, [folders, foldersByParent, docsByFolder]);
|
||||
|
||||
// Single subscription for rename state — derived boolean passed to each FolderNode
|
||||
const [renamingFolderId, setRenamingFolderId] = useAtom(renamingFolderIdAtom);
|
||||
const handleStartRename = useCallback(
|
||||
(folderId: number) => setRenamingFolderId(folderId),
|
||||
[setRenamingFolderId],
|
||||
);
|
||||
const handleCancelRename = useCallback(
|
||||
() => setRenamingFolderId(null),
|
||||
[setRenamingFolderId],
|
||||
);
|
||||
|
||||
const hasDescendantMatch = useMemo(() => {
|
||||
if (activeTypes.length === 0) return null;
|
||||
const match: Record<number, boolean> = {};
|
||||
|
||||
function check(folderId: number): boolean {
|
||||
if (match[folderId] !== undefined) return match[folderId];
|
||||
const childDocs = (docsByFolder[folderId] ?? []).some((d) =>
|
||||
activeTypes.includes(d.document_type as DocumentTypeEnum),
|
||||
);
|
||||
if (childDocs) {
|
||||
match[folderId] = true;
|
||||
return true;
|
||||
}
|
||||
const childFolders = foldersByParent[folderId] ?? [];
|
||||
for (const cf of childFolders) {
|
||||
if (check(cf.id)) {
|
||||
match[folderId] = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
match[folderId] = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const f of folders) {
|
||||
check(f.id);
|
||||
}
|
||||
return match;
|
||||
}, [folders, docsByFolder, foldersByParent, activeTypes]);
|
||||
|
||||
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
|
||||
const key = parentId ?? "root";
|
||||
const childFolders = (foldersByParent[key] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.position.localeCompare(b.position));
|
||||
const visibleFolders = hasDescendantMatch
|
||||
? childFolders.filter((f) => hasDescendantMatch[f.id])
|
||||
: childFolders;
|
||||
const childDocs = (docsByFolder[key] ?? [])
|
||||
.filter(
|
||||
(d) => activeTypes.length === 0 || activeTypes.includes(d.document_type as DocumentTypeEnum),
|
||||
);
|
||||
|
||||
const nodes: React.ReactNode[] = [];
|
||||
|
||||
for (let i = 0; i < visibleFolders.length; i++) {
|
||||
const f = visibleFolders[i];
|
||||
const siblingPositions = {
|
||||
before: i > 0 ? visibleFolders[i - 1].position : null,
|
||||
after: i < visibleFolders.length - 1 ? visibleFolders[i + 1].position : null,
|
||||
};
|
||||
|
||||
nodes.push(
|
||||
<FolderNode
|
||||
key={`folder-${f.id}`}
|
||||
folder={f}
|
||||
depth={depth}
|
||||
isExpanded={expandedIds.has(f.id)}
|
||||
isRenaming={renamingFolderId === f.id}
|
||||
childCount={folderChildCounts[f.id] ?? 0}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onRename={onRenameFolder}
|
||||
onStartRename={handleStartRename}
|
||||
onCancelRename={handleCancelRename}
|
||||
onDelete={onDeleteFolder}
|
||||
onMove={onMoveFolder}
|
||||
onCreateSubfolder={onCreateFolder}
|
||||
onDropIntoFolder={onDropIntoFolder}
|
||||
onReorderFolder={onReorderFolder}
|
||||
siblingPositions={siblingPositions}
|
||||
/>,
|
||||
);
|
||||
|
||||
if (expandedIds.has(f.id)) {
|
||||
nodes.push(...renderLevel(f.id, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
for (const d of childDocs) {
|
||||
nodes.push(
|
||||
<DocumentNode
|
||||
key={`doc-${d.id}`}
|
||||
doc={d}
|
||||
depth={depth}
|
||||
isMentioned={mentionedDocIds.has(d.id)}
|
||||
onToggleChatMention={onToggleChatMention}
|
||||
onPreview={onPreviewDocument}
|
||||
onEdit={onEditDocument}
|
||||
onDelete={onDeleteDocument}
|
||||
onMove={onMoveDocument}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const treeNodes = renderLevel(null, 0);
|
||||
|
||||
if (treeNodes.length === 0 && folders.length === 0 && documents.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
|
||||
<TreePine className="h-10 w-10" />
|
||||
<p className="text-sm">No documents yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (treeNodes.length === 0 && activeTypes.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
|
||||
<TreePine className="h-10 w-10" />
|
||||
<p className="text-sm">No matching documents</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-1">
|
||||
{treeNodes}
|
||||
</div>
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue