igor_nadein 6def6d2d43 feat: Improve folders UI with breadcrumbs and document counts
Backend improvements:
- Add document_count annotation to FolderViewSet queryset
- Add document_count field to FolderSerializer

Frontend improvements:
- Add folder_path display in document cards with folder icon
- Add document count badges in folder tree
- Add breadcrumbs navigation showing current folder path
- Improve folder selector button to show selected folder name
- Add clear filter button (X) to folder selector
- Add highlighted style for selected folder in selector
- Update Document interface to include folder and folder_path fields

UI enhancements:
- Folder path shown in sky-blue color with FolderOpen icon in cards
- Document count displayed in gray badge next to folder names
- Breadcrumbs allow quick navigation back to root
- Selected folder highlighted with sky-blue background
- Visual feedback for folder selection state
2026-02-28 13:15:06 +03:00

209 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect, useCallback } from "react";
import { ChevronRight, ChevronDown, Folder, FolderOpen, Plus, Edit2, Trash2 } from "lucide-react";
export interface FolderNode {
id: number;
name: string;
parent_id: number | null;
path: string;
document_count?: number;
children?: FolderNode[];
}
interface FolderTreeProps {
folders: FolderNode[];
selectedFolderId: number | null;
onSelectFolder: (folderId: number | null) => void;
onCreateFolder?: (parentId: number | null) => void;
onEditFolder?: (folderId: number) => void;
onDeleteFolder?: (folderId: number) => void;
}
export function FolderTree({
folders,
selectedFolderId,
onSelectFolder,
onCreateFolder,
onEditFolder,
onDeleteFolder,
}: FolderTreeProps) {
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [hoveredId, setHoveredId] = useState<number | null>(null);
// Построение дерева из плоского списка
const buildTree = useCallback((flatFolders: FolderNode[]): FolderNode[] => {
const map = new Map<number | null, FolderNode[]>();
flatFolders.forEach((folder) => {
const parentId = folder.parent_id;
if (!map.has(parentId)) {
map.set(parentId, []);
}
map.get(parentId)!.push({ ...folder });
});
const attachChildren = (node: FolderNode) => {
const children = map.get(node.id) || [];
if (children.length > 0) {
node.children = children;
children.forEach(attachChildren);
}
};
const rootNodes = map.get(null) || [];
rootNodes.forEach(attachChildren);
return rootNodes;
}, []);
const tree = buildTree(folders);
const toggleExpand = (folderId: number) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
};
const renderNode = (node: FolderNode, level: number = 0) => {
const isExpanded = expandedIds.has(node.id);
const isSelected = selectedFolderId === node.id;
const hasChildren = node.children && node.children.length > 0;
const isHovered = hoveredId === node.id;
return (
<div key={node.id}>
<div
className={`group flex items-center gap-1 rounded-lg px-2 py-1.5 transition ${
isSelected
? "bg-sky-100 text-sky-900"
: "text-gray-700 hover:bg-gray-100"
}`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onMouseEnter={() => setHoveredId(node.id)}
onMouseLeave={() => setHoveredId(null)}
>
{/* Expand/Collapse */}
{hasChildren ? (
<button
onClick={() => toggleExpand(node.id)}
className="shrink-0 rounded p-0.5 hover:bg-gray-200"
>
{isExpanded ? (
<ChevronDown size={14} className="text-gray-500" />
) : (
<ChevronRight size={14} className="text-gray-500" />
)}
</button>
) : (
<span className="w-[22px]" />
)}
{/* Folder Icon */}
<button
onClick={() => onSelectFolder(node.id)}
className="flex min-w-0 flex-1 items-center gap-2"
>
{isExpanded || isSelected ? (
<FolderOpen size={16} className="shrink-0 text-sky-600" />
) : (
<Folder size={16} className="shrink-0 text-gray-500" />
)}
<span className="truncate text-sm font-medium">{node.name}</span>
{node.document_count !== undefined && node.document_count > 0 && (
<span className="ml-auto shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{node.document_count}
</span>
)}
</button>
{/* Actions */}
{isHovered && (
<div className="ml-auto flex shrink-0 gap-0.5 opacity-0 group-hover:opacity-100">
{onCreateFolder && (
<button
onClick={() => onCreateFolder(node.id)}
className="rounded p-1 hover:bg-gray-200"
title="Создать подпапку"
>
<Plus size={12} className="text-gray-600" />
</button>
)}
{onEditFolder && (
<button
onClick={() => onEditFolder(node.id)}
className="rounded p-1 hover:bg-gray-200"
title="Переименовать"
>
<Edit2 size={12} className="text-gray-600" />
</button>
)}
{onDeleteFolder && (
<button
onClick={() => onDeleteFolder(node.id)}
className="rounded p-1 hover:bg-red-100"
title="Удалить"
>
<Trash2 size={12} className="text-red-600" />
</button>
)}
</div>
)}
</div>
{/* Children */}
{hasChildren && isExpanded && (
<div>{node.children!.map((child) => renderNode(child, level + 1))}</div>
)}
</div>
);
};
return (
<div className="space-y-1">
{/* Root folder */}
<div
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 transition ${
selectedFolderId === null
? "bg-sky-100 text-sky-900"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<button
onClick={() => onSelectFolder(null)}
className="flex min-w-0 flex-1 items-center gap-2"
>
<Folder size={16} className="shrink-0 text-gray-500" />
<span className="text-sm font-medium">Все документы</span>
</button>
{onCreateFolder && (
<button
onClick={() => onCreateFolder(null)}
className="shrink-0 rounded p-1 hover:bg-gray-200"
title="Создать папку"
>
<Plus size={12} className="text-gray-600" />
</button>
)}
</div>
{/* Tree nodes */}
{tree.map((node) => renderNode(node))}
{/* Empty state */}
{folders.length === 0 && (
<div className="py-4 text-center text-sm text-gray-500">
Папок нет
</div>
)}
</div>
);
}