Refactor: 사이드바 리팩토링

This commit is contained in:
정현조 2024-10-03 17:25:19 +09:00
parent f628a84477
commit 6b4946b273
7 changed files with 934 additions and 107 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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];

View File

@ -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}
/>
))}
</div>
<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>

View 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,
};
}

View File

@ -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
View 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;
}