Refactor: 사이드바 리팩토링
This commit is contained in:
parent
f628a84477
commit
6b4946b273
575
frontend/package-lock.json
generated
575
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -36,12 +36,14 @@
|
||||
"konva": "^9.3.14",
|
||||
"lucide-react": "^0.436.0",
|
||||
"react": "^18.3.1",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-konva": "^18.2.10",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"react-slick": "^0.30.2",
|
||||
"react-treebeard": "^3.2.4",
|
||||
"react-virtualized-auto-sizer": "^1.0.24",
|
||||
"react-window": "^1.8.10",
|
||||
"recharts": "^2.12.7",
|
||||
@ -66,6 +68,7 @@
|
||||
"@storybook/test": "^8.2.9",
|
||||
"@types/node": "^22.5.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
|
@ -8,10 +8,8 @@ export async function getImage(imageId: number, memberId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function moveImage(imageId: number, memberId: number, moveRequest: ImageMoveRequest) {
|
||||
return api.put(`/images/${imageId}`, moveRequest, {
|
||||
params: { memberId },
|
||||
});
|
||||
export async function moveImage(projectId: number, folderId: number, imageId: number, moveRequest: ImageMoveRequest) {
|
||||
return api.put(`/projects/${projectId}/folders/${folderId}/images/${imageId}`, moveRequest);
|
||||
}
|
||||
|
||||
export async function deleteImage(imageId: number, memberId: number) {
|
||||
@ -23,7 +21,7 @@ export async function deleteImage(imageId: number, memberId: number) {
|
||||
export async function changeImageStatus(
|
||||
imageId: number,
|
||||
memberId: number,
|
||||
statusChangeRequest: ImageStatusChangeRequest,
|
||||
statusChangeRequest: ImageStatusChangeRequest
|
||||
) {
|
||||
return api
|
||||
.put(`/images/${imageId}/status`, statusChangeRequest, {
|
||||
@ -37,7 +35,7 @@ export async function uploadImageFile(
|
||||
projectId: number,
|
||||
folderId: number,
|
||||
files: File[],
|
||||
processCallback: (progress: number) => void,
|
||||
processCallback: (progress: number) => void
|
||||
) {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
@ -62,7 +60,7 @@ export async function uploadImageFolderFile(
|
||||
projectId: number,
|
||||
folderId: number,
|
||||
files: File[],
|
||||
processCallback: (progress: number) => void,
|
||||
processCallback: (progress: number) => void
|
||||
) {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
@ -87,7 +85,7 @@ export async function uploadImageFolder(
|
||||
projectId: number,
|
||||
folderId: number,
|
||||
files: File[],
|
||||
processCallback: (progress: number) => void,
|
||||
processCallback: (progress: number) => void
|
||||
) {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
@ -112,7 +110,7 @@ export async function uploadImageZip(
|
||||
projectId: number,
|
||||
folderId: number,
|
||||
file: File,
|
||||
processCallback: (progress: number) => void,
|
||||
processCallback: (progress: number) => void
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('folderZip', file);
|
||||
@ -135,7 +133,7 @@ export async function uploadImagePresigned(
|
||||
projectId: number,
|
||||
folderId: number,
|
||||
files: File[],
|
||||
processCallback: (index: number) => void,
|
||||
processCallback: (index: number) => void
|
||||
) {
|
||||
// 업로드 시작 시간 기록
|
||||
const startTime = new Date().getTime();
|
||||
@ -152,11 +150,10 @@ export async function uploadImagePresigned(
|
||||
imageMetaList,
|
||||
{
|
||||
params: { memberId },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// 각 파일을 presigned URL에 맞춰서 업로드 (axios 직접 사용)
|
||||
// 각 파일을 presigned URL에 맞춰서 업로드 (axios 직접 사용)
|
||||
for (const presignedUrlInfo of presignedUrlList) {
|
||||
const file = files[presignedUrlInfo.id];
|
||||
|
||||
|
@ -1,66 +1,263 @@
|
||||
import { Project } from '@/types';
|
||||
import ProjectFileItem from './ProjectFileItem';
|
||||
import ProjectDirectoryItem from './ProjectDirectoryItem';
|
||||
import useFolderQuery from '@/queries/folders/useFolderQuery';
|
||||
import useCanvasStore from '@/stores/useCanvasStore';
|
||||
import { useEffect } from 'react';
|
||||
import WorkspaceDropdownMenu from '../WorkspaceDropdownMenu';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { TreeNode } from 'react-treebeard';
|
||||
import useProjectStore from '@/stores/useProjectStore';
|
||||
import useProjectCategoriesQuery from '@/queries/category/useProjectCategoriesQuery';
|
||||
import useCanvasStore from '@/stores/useCanvasStore';
|
||||
import useTreeData from '@/hooks/useTreeData';
|
||||
import { Project, ImageResponse, ImageStatus } from '@/types';
|
||||
import WorkspaceDropdownMenu from '../WorkspaceDropdownMenu';
|
||||
import AutoLabelButton from './AutoLabelButton';
|
||||
import useMoveImageQuery from '@/queries/images/useMoveImageQuery';
|
||||
import { Folder, Image as ImageIcon, Minus, Loader, ArrowDownToLine, Send, CircleSlash, Check } from 'lucide-react';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
export default function ProjectStructure({ project }: { project: Project }) {
|
||||
const { setProject, setCategories } = useProjectStore();
|
||||
const { data: categories } = useProjectCategoriesQuery(project.id);
|
||||
const image = useCanvasStore((state) => state.image);
|
||||
const { data: folderData, refetch } = useFolderQuery(project.id.toString(), 0);
|
||||
|
||||
useEffect(() => {
|
||||
setCategories(categories);
|
||||
}, [categories, setCategories]);
|
||||
const { setProject } = useProjectStore();
|
||||
const { setImage } = useCanvasStore();
|
||||
const { treeData, fetchNodeData, initializeTree, setTreeData, isLoading } = useTreeData(project.id.toString(), 0);
|
||||
const [cursor, setCursor] = useState<TreeNode | null>(null);
|
||||
const moveImageMutation = useMoveImageQuery();
|
||||
|
||||
useEffect(() => {
|
||||
setProject(project);
|
||||
}, [project, setProject]);
|
||||
initializeTree();
|
||||
}, [project, setProject, initializeTree]);
|
||||
|
||||
const onToggle = useCallback(
|
||||
async (node: TreeNode, toggled: boolean) => {
|
||||
if (cursor) {
|
||||
cursor.active = false;
|
||||
}
|
||||
node.active = true;
|
||||
setCursor(node);
|
||||
|
||||
if (node.imageData) {
|
||||
setImage(node.imageData as ImageResponse);
|
||||
} else {
|
||||
if (toggled && (!node.children || node.children.length === 0)) {
|
||||
await fetchNodeData(node);
|
||||
}
|
||||
node.toggled = toggled;
|
||||
}
|
||||
|
||||
setTreeData((prevData) => ({ ...prevData! }));
|
||||
},
|
||||
[cursor, fetchNodeData, setImage, setTreeData]
|
||||
);
|
||||
|
||||
const renderStatusIcon = (status: ImageStatus) => {
|
||||
switch (status) {
|
||||
case 'PENDING':
|
||||
return (
|
||||
<Minus
|
||||
size={12}
|
||||
className="shrink-0 stroke-gray-400"
|
||||
/>
|
||||
);
|
||||
case 'IN_PROGRESS':
|
||||
return (
|
||||
<Loader
|
||||
size={12}
|
||||
className="shrink-0 animate-spin stroke-yellow-400"
|
||||
/>
|
||||
);
|
||||
case 'SAVE':
|
||||
return (
|
||||
<ArrowDownToLine
|
||||
size={12}
|
||||
className="shrink-0 stroke-gray-400"
|
||||
/>
|
||||
);
|
||||
case 'REVIEW_REQUEST':
|
||||
return (
|
||||
<Send
|
||||
size={12}
|
||||
className="shrink-0 stroke-blue-400"
|
||||
/>
|
||||
);
|
||||
case 'REVIEW_REJECT':
|
||||
return (
|
||||
<CircleSlash
|
||||
size={12}
|
||||
className="shrink-0 stroke-red-400"
|
||||
/>
|
||||
);
|
||||
case 'COMPLETED':
|
||||
default:
|
||||
return (
|
||||
<Check
|
||||
size={12}
|
||||
className="shrink-0 stroke-green-400"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTree = useCallback(
|
||||
(nodes: TreeNode[], _parentId: string, level: number = 0) => {
|
||||
return nodes.map((node, index) => (
|
||||
<Draggable
|
||||
draggableId={node.id!}
|
||||
index={index}
|
||||
key={node.id}
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={{
|
||||
...provided.draggableProps.style,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: `${level * 20}px`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => onToggle(node, !node.toggled)}
|
||||
>
|
||||
<div style={{ marginRight: '5px' }}>
|
||||
{!node.imageData ? (
|
||||
<Folder
|
||||
size={16}
|
||||
className="stroke-gray-500"
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon
|
||||
size={16}
|
||||
className="stroke-gray-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ color: '#4a4a4a', flexGrow: 1 }}>{node.name}</span>
|
||||
{node.imageData && <div style={{ marginLeft: '10px' }}>{renderStatusIcon(node.imageData.status)}</div>}
|
||||
</div>
|
||||
{node.toggled && node.children && node.children.length > 0 && (
|
||||
<Droppable
|
||||
droppableId={node.id!}
|
||||
type="TREE"
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
style={{ paddingLeft: `${level * 20}px` }}
|
||||
>
|
||||
{renderTree(node.children!, node.id!, level + 1)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
));
|
||||
},
|
||||
[onToggle]
|
||||
);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
if (!result.destination || !treeData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceDroppableId = result.source.droppableId;
|
||||
const destinationDroppableId = result.destination.droppableId;
|
||||
const sourceIndex = result.source.index;
|
||||
const destinationIndex = result.destination.index;
|
||||
|
||||
const findNodeById = (nodes: TreeNode[], id: string): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeById(node.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sourceParentNode = sourceDroppableId === 'root' ? treeData : findNodeById([treeData], sourceDroppableId);
|
||||
const destinationParentNode =
|
||||
destinationDroppableId === 'root' ? treeData : findNodeById([treeData], destinationDroppableId);
|
||||
|
||||
if (!sourceParentNode || !destinationParentNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [movedItem] = sourceParentNode.children!.splice(sourceIndex, 1);
|
||||
|
||||
destinationParentNode.children!.splice(destinationIndex, 0, movedItem);
|
||||
|
||||
setTreeData({ ...treeData });
|
||||
|
||||
if (movedItem && movedItem.imageData) {
|
||||
const moveFolderId = Number(destinationParentNode.id) || 0;
|
||||
const folderId = Number(sourceParentNode.id) || 0;
|
||||
const projectId = Number(project.id);
|
||||
|
||||
moveImageMutation.mutate({
|
||||
projectId,
|
||||
folderId,
|
||||
imageId: movedItem.imageData.id,
|
||||
moveRequest: {
|
||||
moveFolderId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[treeData, setTreeData, moveImageMutation, project.id]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 grow-0 flex-col">
|
||||
<div className="flex h-full flex-col overflow-hidden px-1 pb-2">
|
||||
<header className="flex w-full items-center gap-2 rounded p-1">
|
||||
<div className="box-border flex h-full min-h-0 flex-col bg-gray-50 p-2">
|
||||
<div className="flex h-full flex-col gap-2 overflow-hidden px-1 pb-2">
|
||||
<header className="flex w-full items-center gap-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div className="flex w-full min-w-0 items-center gap-1 pr-1">
|
||||
<h2 className="caption overflow-hidden text-ellipsis whitespace-nowrap text-gray-500">{project.type}</h2>
|
||||
<h2 className="caption overflow-hidden text-ellipsis whitespace-nowrap text-gray-600">{project.type}</h2>
|
||||
</div>
|
||||
<WorkspaceDropdownMenu
|
||||
projectId={project.id}
|
||||
folderId={0}
|
||||
onRefetch={refetch}
|
||||
onRefetch={() => {}}
|
||||
/>
|
||||
</header>
|
||||
{folderData.children.length === 0 && folderData.images.length === 0 ? (
|
||||
<div className="body-small flex h-full select-none items-center justify-center text-gray-400">
|
||||
빈 프로젝트입니다.
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : !treeData ? (
|
||||
<div className="body-small flex h-full select-none items-center justify-center text-gray-400">Loading...</div>
|
||||
) : (
|
||||
<div className="caption flex flex-col overflow-y-auto">
|
||||
{folderData.children.map((item) => (
|
||||
<ProjectDirectoryItem
|
||||
key={`${project.id}-${item.title}`}
|
||||
projectId={project.id}
|
||||
item={item}
|
||||
initialExpanded={true}
|
||||
/>
|
||||
))}
|
||||
{folderData.images.map((item) => (
|
||||
<ProjectFileItem
|
||||
key={`${project.id}-${item.imageTitle}`}
|
||||
item={item}
|
||||
selected={image?.id === item.id}
|
||||
/>
|
||||
))}
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable
|
||||
droppableId="root"
|
||||
type="TREE"
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className="flex-1 overflow-auto"
|
||||
style={{ overflowX: 'hidden' }}
|
||||
>
|
||||
{renderTree(treeData.children!, 'root')}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
<AutoLabelButton projectId={project.id} />
|
||||
</div>
|
||||
|
84
frontend/src/hooks/useTreeData.ts
Normal file
84
frontend/src/hooks/useTreeData.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { TreeNode } from 'react-treebeard';
|
||||
import { getFolder } from '@/api/folderApi';
|
||||
import { ImageResponse, ChildFolder } from '@/types';
|
||||
|
||||
export default function useTreeData(projectId: string, initialFolderId: number) {
|
||||
const [treeData, setTreeData] = useState<TreeNode | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const fetchNodeData = useCallback(
|
||||
async (node: TreeNode) => {
|
||||
node.loading = true;
|
||||
|
||||
try {
|
||||
const folder = await getFolder(projectId, Number(node.id));
|
||||
const childFolders: TreeNode[] =
|
||||
folder.children?.map((child: ChildFolder) => ({
|
||||
id: child.id.toString(),
|
||||
name: child.title,
|
||||
children: [],
|
||||
})) || [];
|
||||
|
||||
const images: TreeNode[] =
|
||||
folder.images?.map((image: ImageResponse) => ({
|
||||
id: image.id.toString(),
|
||||
name: image.imageTitle,
|
||||
imageData: image,
|
||||
children: [],
|
||||
})) || [];
|
||||
|
||||
node.children = [...childFolders, ...images];
|
||||
node.loading = false;
|
||||
node.toggled = true;
|
||||
setTreeData((prevData) => ({ ...prevData! }));
|
||||
} catch (error) {
|
||||
node.loading = false;
|
||||
console.error(`Error fetching data for node ${node.id}:`, error);
|
||||
}
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const initializeTree = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const rootFolder = await getFolder(projectId, initialFolderId);
|
||||
const childFolders: TreeNode[] =
|
||||
rootFolder.children?.map((child: ChildFolder) => ({
|
||||
id: child.id.toString(),
|
||||
name: child.title,
|
||||
children: [],
|
||||
})) || [];
|
||||
|
||||
const images: TreeNode[] =
|
||||
rootFolder.images?.map((image: ImageResponse) => ({
|
||||
id: image.id.toString(),
|
||||
name: image.imageTitle,
|
||||
imageData: image,
|
||||
children: [],
|
||||
})) || [];
|
||||
|
||||
const rootNode: TreeNode = {
|
||||
id: rootFolder.id.toString(),
|
||||
name: rootFolder.title,
|
||||
children: [...childFolders, ...images],
|
||||
toggled: true,
|
||||
};
|
||||
|
||||
setTreeData(rootNode);
|
||||
} catch (error) {
|
||||
console.error('Error initializing tree data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId, initialFolderId]);
|
||||
|
||||
return {
|
||||
treeData,
|
||||
fetchNodeData,
|
||||
initializeTree,
|
||||
setTreeData,
|
||||
isLoading,
|
||||
};
|
||||
}
|
@ -3,8 +3,9 @@ import { moveImage } from '@/api/imageApi';
|
||||
import { ImageMoveRequest } from '@/types';
|
||||
|
||||
interface MoveImageMutationVariables {
|
||||
projectId: number;
|
||||
folderId: number;
|
||||
imageId: number;
|
||||
memberId: number;
|
||||
moveRequest: ImageMoveRequest;
|
||||
}
|
||||
|
||||
@ -12,10 +13,12 @@ export default function useMoveImageQuery() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ imageId, memberId, moveRequest }: MoveImageMutationVariables) =>
|
||||
moveImage(imageId, memberId, moveRequest),
|
||||
mutationFn: ({ projectId, folderId, imageId, moveRequest }: MoveImageMutationVariables) =>
|
||||
moveImage(projectId, folderId, imageId, moveRequest),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['image', variables.imageId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['project', variables.projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['folder', variables.folderId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
64
frontend/src/types/react-treebeard.d.ts
vendored
Normal file
64
frontend/src/types/react-treebeard.d.ts
vendored
Normal file
@ -0,0 +1,64 @@
|
||||
declare module 'react-treebeard' {
|
||||
import React from 'react';
|
||||
type TreeDecoratorTypes = 'Loading' | 'Toggle' | 'Header' | 'Container';
|
||||
type CSS = React.CSSProperties;
|
||||
|
||||
export type TreeAnimations = object;
|
||||
export interface TreeTheme {
|
||||
tree: {
|
||||
base: CSS;
|
||||
node: {
|
||||
base: CSS;
|
||||
link: CSS;
|
||||
activeLink: CSS;
|
||||
toggle: {
|
||||
base: CSS;
|
||||
wrapper: CSS;
|
||||
height: number;
|
||||
width: number;
|
||||
arrow: CSS;
|
||||
};
|
||||
header: {
|
||||
base: CSS;
|
||||
connector: CSS;
|
||||
title: CSS;
|
||||
};
|
||||
subtree: CSS;
|
||||
loading: CSS;
|
||||
};
|
||||
};
|
||||
}
|
||||
export type TreeDecorators = { [T in TreeDecoratorTypes]: React.ElementType };
|
||||
export interface TreeNode {
|
||||
imageData?: ImageResponse;
|
||||
/** The component key. If not defined, an auto - generated index is used. */
|
||||
id?: string;
|
||||
/** The name prop passed into the Header component. */
|
||||
name: string;
|
||||
/** The children attached to the node. This value populates the subtree at the specific node.Each child is built from the same basic data structure.
|
||||
*
|
||||
* Tip: Make this an empty array, if you want to asynchronously load a potential parent. */
|
||||
children?: Array<TreeNode>;
|
||||
/** Toggled flag. Sets the visibility of a node's children. It also sets the state for the toggle decorator. */
|
||||
toggled?: boolean;
|
||||
/** Active flag. If active, the node will be highlighted.The highlight is derived from the node.activeLink style object in the theme. */
|
||||
active?: boolean;
|
||||
/** Loading flag. It will populate the treeview with the loading component.Useful when asynchronously pulling the data into the treeview. */
|
||||
loading?: boolean;
|
||||
/** Attach specific decorators to a node. Provides the low level functionality to create visuals on a node-by-node basis. */
|
||||
decorators?: TreeDecorators;
|
||||
/** Attach specific animations to a node. Provides the low level functionality to create visuals on a node-by-node basis. */
|
||||
animations?: TreeAnimations;
|
||||
}
|
||||
type TreebeardProps = {
|
||||
data: TreeNode | Array<TreeNode>;
|
||||
onToggle?: (node: TreeNode, toggled: boolean) => void;
|
||||
style?: TreeTheme;
|
||||
animations?: TreeAnimations | boolean;
|
||||
decorators?: TreeDecorators;
|
||||
};
|
||||
export const Treebeard: React.ElementType<TreebeardProps>;
|
||||
export const decorators: TreeDecorators;
|
||||
export const animations: TreeAnimations;
|
||||
export const theme: TreeTheme;
|
||||
}
|
Loading…
Reference in New Issue
Block a user