首次发布git
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { Page, useEditorStore } from "@/lib/store";
|
||||
import { ChevronRight, FileText, Folder, FolderOpen, MoreHorizontal, File, Plus, Trash2, FilePlus, FolderPlus, Download } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { exportPageAsMarkdown, exportFolderAsZip } from "@/lib/export";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useImport } from "@/components/import-context";
|
||||
import { Upload } from "lucide-react";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { useSortable, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
interface TreeViewProps {
|
||||
pages: Page[];
|
||||
parentId: string | null;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
function TreeNode({ node, pages, level, expanded, toggleExpand }: {
|
||||
node: Page,
|
||||
pages: Page[],
|
||||
level: number,
|
||||
expanded: Record<string, boolean>,
|
||||
toggleExpand: (id: string, e: React.MouseEvent) => void
|
||||
}) {
|
||||
const { activePageId, setActivePageId, addPage, deletePage } = useEditorStore();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { triggerImport } = useImport();
|
||||
|
||||
const isFolder = node.type === 'folder';
|
||||
const hasChildren = pages.some(p => p.parentId === node.id);
|
||||
const isExpanded = expanded[node.id];
|
||||
|
||||
// DnD Hooks - Sorting
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: node.id,
|
||||
data: { type: node.type, title: node.title, parentId: node.parentId }
|
||||
});
|
||||
|
||||
const style = {
|
||||
paddingLeft: `${level * 12 + 8}px`,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={node.id} style={style} ref={setNodeRef} {...attributes} {...listeners}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center justify-between px-2 py-1 text-sm rounded-md transition-colors cursor-pointer select-none border border-transparent",
|
||||
activePageId === node.id
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
// Use simple hover effect for "drop over" visual or rely on drag overlay
|
||||
)}
|
||||
onClick={() => {
|
||||
setActivePageId(node.id);
|
||||
if (pathname !== '/') router.push('/');
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<button
|
||||
onPointerDown={(e) => e.stopPropagation()} // Prevent drag start on expand button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(node.id, e);
|
||||
}}
|
||||
className={cn(
|
||||
"p-0.5 rounded-sm hover:bg-muted-foreground/20 transition-colors",
|
||||
!hasChildren && !isFolder && "invisible"
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className={cn("transition-transform shrink-0 text-muted-foreground/50", isExpanded && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
{node.icon ? (
|
||||
<span className="text-[16px] leading-none shrink-0 w-4 h-4 flex items-center justify-center grayscale-[0.2]">{node.icon}</span>
|
||||
) : isFolder ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen size={16} className="text-indigo-500 fill-indigo-500/20 shrink-0" />
|
||||
) : (
|
||||
<Folder size={16} className="text-indigo-500 fill-indigo-500/20 shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<FileText size={16} className="text-muted-foreground/70 shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-[14px] leading-none mb-0.5">{node.title}</span>
|
||||
</div>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
onPointerDown={(e) => e.stopPropagation()} // Prevent drag
|
||||
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 p-1 hover:bg-muted-foreground/20 rounded text-muted-foreground transition-opacity"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="min-w-[120px] bg-popover text-popover-foreground rounded-md border shadow-md p-1 z-50 animate-in fade-in zoom-in-95" align="start">
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const targetParentId = node.type === 'folder' ? node.id : node.parentId;
|
||||
// If creating sibling (node.type !== 'folder'), insert after current node
|
||||
// Note: Order might be float/int. We just increment for now.
|
||||
// Ideally we'd find the mid-point, but simpler logic: just +1 and let sort handle basic "after"
|
||||
const order = node.type === 'folder' ? undefined : (node.order || 0) + 1;
|
||||
addPage(targetParentId, 'file', undefined, order);
|
||||
if (node.type === 'folder') toggleExpand(node.id, e);
|
||||
}}
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
<span>新建文件</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const targetParentId = node.type === 'folder' ? node.id : node.parentId;
|
||||
const order = node.type === 'folder' ? undefined : (node.order || 0) + 1;
|
||||
addPage(targetParentId, 'folder', undefined, order);
|
||||
if (node.type === 'folder') toggleExpand(node.id, e);
|
||||
}}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
<span>新建文件夹</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const targetParentId = node.type === 'folder' ? node.id : node.parentId;
|
||||
triggerImport(targetParentId);
|
||||
if (node.type === 'folder') toggleExpand(node.id, e);
|
||||
}}
|
||||
>
|
||||
<Upload size={14} />
|
||||
<span>导入文档...</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer outline-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (node.type === 'folder') {
|
||||
exportFolderAsZip(node.id, pages, node.title);
|
||||
} else {
|
||||
exportPageAsMarkdown(node);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span>导出</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator className="h-px bg-muted my-1" />
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-red-50 text-red-600 cursor-pointer outline-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm("确定要删除吗?此操作无法撤销。")) {
|
||||
deletePage(node.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>删除</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
{(isExpanded || hasChildren) && isExpanded && (
|
||||
<TreeView pages={pages} parentId={node.id} level={level + 1} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreeView({ pages, parentId, level = 0 }: TreeViewProps) {
|
||||
// Sort nodes by order field
|
||||
const nodes = pages
|
||||
.filter(p => p.parentId === parentId)
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
// Simple state for expansion
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleExpand = (id: string, e: React.MouseEvent) => {
|
||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
if (nodes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SortableContext
|
||||
items={nodes.map(n => n.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{nodes.map(node => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
pages={pages}
|
||||
level={level}
|
||||
expanded={expanded}
|
||||
toggleExpand={toggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user