"use client"; import { DndContext, KeyboardSensor, MouseSensor, TouchSensor, closestCenter, useDroppable, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { ChevronDownIcon, ChevronRightIcon, FolderIcon, FolderPlusIcon, LayersIcon, MoreHorizontalIcon, PencilIcon, PlusIcon, Trash2Icon, } from "lucide-react"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; import { toast } from "sonner"; import { OptionCombobox } from "@/components/option-combobox"; import { ProjectDialog } from "@/components/project-dialog"; import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Field, FieldContent, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Textarea } from "@/components/ui/textarea"; import { createKnowledgeDirectory, deleteKnowledgeDirectory, fetchKnowledgeDirectories, updateKnowledgeDirectory, updateKnowledgeDirectorySort, type KnowledgeDirectory, } from "@/lib/api/admin"; import { useI18n } from "@/i18n/provider"; import { cn } from "@/lib/utils"; import { findDirectoryParentId, moveDirectoryToParent, moveDirectoryWithinParent, } from "./knowledge-directory-sort"; type KnowledgeDirectoryPanelProps = { knowledgeBaseId: number; selectedDirectoryId: number | null; onSelectDirectory: (directoryId: number | null) => void; onChanged?: () => void; }; type DirectoryDialogState = { open: boolean; id: number | null; parentId: number; name: string; remark: string; }; type DirectoryOption = { value: string; label: string }; const DIRECTORY_PANEL_WIDTH_STORAGE_KEY = "knowledge-directory-panel-width"; const DIRECTORY_PANEL_MIN_WIDTH = 180; const DIRECTORY_PANEL_MAX_WIDTH = 360; const DIRECTORY_PANEL_DEFAULT_WIDTH = 224; const ROOT_DIRECTORY_DROP_ID = "knowledge-directory-root"; function rootDirectoryOptions(items: KnowledgeDirectory[]): DirectoryOption[] { return items.map((item) => ({ value: String(item.id), label: item.name })); } function collectExpandedIds(items: KnowledgeDirectory[]) { const ids = new Set(); const walk = (nodes: KnowledgeDirectory[]) => { for (const node of nodes) { ids.add(node.id); walk(node.children || []); } }; walk(items); return ids; } export function KnowledgeDirectoryPanel({ knowledgeBaseId, selectedDirectoryId, onSelectDirectory, onChanged, }: KnowledgeDirectoryPanelProps) { const t = useI18n(); const directoryNameInputRef = useRef(null); const directoryNameFocusTimerRef = useRef(null); const [panelWidth, setPanelWidth] = useState(() => { if (typeof window === "undefined") { return DIRECTORY_PANEL_DEFAULT_WIDTH; } const saved = Number(localStorage.getItem(DIRECTORY_PANEL_WIDTH_STORAGE_KEY)); if (!Number.isFinite(saved)) { return DIRECTORY_PANEL_DEFAULT_WIDTH; } return Math.min(DIRECTORY_PANEL_MAX_WIDTH, Math.max(DIRECTORY_PANEL_MIN_WIDTH, saved)); }); const [directories, setDirectories] = useState([]); const [expandedIds, setExpandedIds] = useState>(new Set()); const [contextMenuDirectoryId, setContextMenuDirectoryId] = useState(null); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [sorting, setSorting] = useState(false); const [dialog, setDialog] = useState({ open: false, id: null, parentId: 0, name: "", remark: "", }); const parentOptions = useMemo( () => [ { value: "0", label: t("knowledge.rootDirectory") }, ...rootDirectoryOptions(directories).filter((item) => item.value !== String(dialog.id ?? "")), ], [directories, dialog.id, t], ); const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8 }, }), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 8 }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const loadDirectories = useCallback(async () => { setLoading(true); try { const data = await fetchKnowledgeDirectories(knowledgeBaseId); setDirectories(data); setExpandedIds(collectExpandedIds(data)); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.loadDirectoriesFailed")); } finally { setLoading(false); } }, [knowledgeBaseId, t]); useEffect(() => { void loadDirectories(); }, [loadDirectories]); useEffect(() => { localStorage.setItem(DIRECTORY_PANEL_WIDTH_STORAGE_KEY, String(panelWidth)); }, [panelWidth]); useEffect(() => { if (!dialog.open) { return; } const frame = requestAnimationFrame(() => { directoryNameFocusTimerRef.current = window.setTimeout(() => { const input = directoryNameInputRef.current ?? document.querySelector("[data-knowledge-directory-name-input='true']"); input?.focus({ preventScroll: true }); input?.select(); }, 80); }); return () => { cancelAnimationFrame(frame); if (directoryNameFocusTimerRef.current !== null) { window.clearTimeout(directoryNameFocusTimerRef.current); directoryNameFocusTimerRef.current = null; } }; }, [dialog.open]); function handleResizePointerDown(event: PointerEvent) { event.preventDefault(); const startX = event.clientX; const startWidth = panelWidth; function handlePointerMove(moveEvent: globalThis.PointerEvent) { const nextWidth = startWidth + moveEvent.clientX - startX; setPanelWidth( Math.min(DIRECTORY_PANEL_MAX_WIDTH, Math.max(DIRECTORY_PANEL_MIN_WIDTH, nextWidth)), ); } function handlePointerUp() { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); document.body.style.cursor = ""; document.body.style.userSelect = ""; } document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); } function toggleDirectory(id: number) { setExpandedIds((current) => { const next = new Set(current); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); } function openCreate(parentId = 0) { setDialog({ open: true, id: null, parentId, name: "", remark: "" }); } function openEdit(item: KnowledgeDirectory) { setDialog({ open: true, id: item.id, parentId: item.parentId, name: item.name, remark: item.remark || "", }); } async function handleSubmit() { const name = dialog.name.trim(); if (!name) { toast.error(t("knowledge.directoryNameRequired")); return; } setSaving(true); try { if (dialog.id) { await updateKnowledgeDirectory({ id: dialog.id, knowledgeBaseId, parentId: dialog.parentId, name, remark: dialog.remark.trim(), }); toast.success(t("knowledge.directoryUpdated", { name })); } else { await createKnowledgeDirectory({ knowledgeBaseId, parentId: dialog.parentId, name, remark: dialog.remark.trim(), }); toast.success(t("knowledge.directoryCreated", { name })); } setDialog((current) => ({ ...current, open: false })); await loadDirectories(); onChanged?.(); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.directorySaveFailed")); } finally { setSaving(false); } } async function handleDelete(item: KnowledgeDirectory) { setSaving(true); try { await deleteKnowledgeDirectory(item.id); if (selectedDirectoryId === item.id) { onSelectDirectory(null); } toast.success(t("knowledge.directoryDeleted", { name: item.name })); await loadDirectories(); onChanged?.(); } catch (error) { toast.error(error instanceof Error ? error.message : t("knowledge.directoryDeleteFailed")); } finally { setSaving(false); } } async function handleDirectoryDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id || sorting) { return; } const activeId = Number(active.id); if (!Number.isFinite(activeId)) { return; } if (over.id === ROOT_DIRECTORY_DROP_ID) { await handleDirectoryMoveToParent(activeId, 0); return; } const overId = Number(over.id); if (!Number.isFinite(overId)) { return; } const activeParentId = findDirectoryParentId(directories, activeId); const overParentId = findDirectoryParentId(directories, overId); if (activeParentId === null || overParentId === null) { return; } if (activeParentId !== overParentId) { await handleDirectoryMoveToParent(activeId, overId); return; } if (activeParentId === 0 && overParentId === 0 && event.delta.x > 24) { await handleDirectoryMoveToParent(activeId, overId); return; } const previousDirectories = directories; const moved = moveDirectoryWithinParent(previousDirectories, activeParentId, activeId, overId); if (!moved.changed) { return; } setDirectories(moved.items); setSorting(true); try { await updateKnowledgeDirectorySort({ knowledgeBaseId, parentId: moved.parentId, ids: moved.orderedIds, }); toast.success(t("knowledge.directorySortUpdated")); await loadDirectories(); onChanged?.(); } catch (error) { setDirectories(previousDirectories); toast.error(error instanceof Error ? error.message : t("knowledge.directorySortUpdateFailed")); } finally { setSorting(false); } } async function handleDirectoryMoveToParent(activeId: number, targetParentId: number) { const previousDirectories = directories; const moved = moveDirectoryToParent(previousDirectories, activeId, targetParentId); if (!moved.changed || !moved.item) { return; } setDirectories(moved.items); if (moved.parentId > 0) { setExpandedIds((current) => new Set(current).add(moved.parentId)); } setSorting(true); try { await updateKnowledgeDirectory({ id: moved.item.id, knowledgeBaseId, parentId: moved.parentId, name: moved.item.name, remark: moved.item.remark || "", }); await updateKnowledgeDirectorySort({ knowledgeBaseId, parentId: moved.parentId, ids: moved.orderedIds, }); toast.success(t("knowledge.directoryMoved")); await loadDirectories(); onChanged?.(); } catch (error) { setDirectories(previousDirectories); toast.error(error instanceof Error ? error.message : t("knowledge.directoryMoveFailed")); } finally { setSorting(false); } } return ( <>
{t("knowledge.directory")}
} label={t("knowledge.allContent")} selected={selectedDirectoryId === null} onClick={() => onSelectDirectory(null)} /> void handleDirectoryDragEnd(event)} > onSelectDirectory(0)} t={t} /> item.id)} strategy={verticalListSortingStrategy} > {directories.map((item) => ( setContextMenuDirectoryId(open ? directoryId : null) } onCreate={openCreate} onEdit={openEdit} onDelete={(directory) => void handleDelete(directory)} t={t} /> ))}
setDialog((current) => ({ ...current, open }))} title={dialog.id ? t("knowledge.editDirectory") : t("knowledge.createDirectory")} size="sm" footer={ <> } >
{t("knowledge.parentDirectory")} setDialog((current) => ({ ...current, parentId: Number(value ?? 0) })) } options={parentOptions} placeholder={t("knowledge.selectDirectory")} searchPlaceholder={t("knowledge.searchDirectory")} emptyText={t("knowledge.emptyDirectory")} /> {t("knowledge.directoryName")} setDialog((current) => ({ ...current, name: event.target.value })) } placeholder={t("knowledge.directoryNamePlaceholder")} /> {t("knowledge.remark")}