From 22dffdc7a88e958bd014f7e4fa4fdd70474fa3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=ED=98=84=EC=A1=B0?= Date: Mon, 7 Oct 2024 01:34:22 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EA=B5=AC=ED=98=84,=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 21 ++ frontend/package.json | 1 + frontend/src/api/folderApi.ts | 18 +- frontend/src/api/imageApi.ts | 6 +- .../WorkspaceSidebar/ProjectContextMenu.tsx | 222 ++++++++++++++++++ .../WorkspaceSidebar/ProjectStructure.tsx | 35 ++- frontend/src/hooks/useTreeData.ts | 3 +- .../queries/folders/useCreateFolderQuery.ts | 13 +- .../queries/folders/useDeleteFolderQuery.ts | 9 +- .../queries/folders/useUpdateFolderQuery.ts | 14 +- .../src/queries/images/useDeleteImageQuery.ts | 13 +- 11 files changed, 295 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/WorkspaceSidebar/ProjectContextMenu.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 178ea83..597c546 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,6 +31,7 @@ "konva": "^9.3.14", "lucide-react": "^0.436.0", "react": "^18.3.1", + "react-contexify": "^6.0.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.3.1", @@ -12804,6 +12805,26 @@ "react": "^16.3.0 || ^17.0.1 || ^18.0.0" } }, + "node_modules/react-contexify": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/react-contexify/-/react-contexify-6.0.0.tgz", + "integrity": "sha512-jMhz6yZI81Jv3UDj7TXqCkhdkCFEEmvwGCPXsQuA2ZUC8EbCuVQ6Cy8FzKMXa0y454XTDClBN2YFvvmoFlrFkg==", + "dependencies": { + "clsx": "^1.2.1" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-contexify/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/react-dnd": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index ec65f03..6fe8bfa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,6 +37,7 @@ "konva": "^9.3.14", "lucide-react": "^0.436.0", "react": "^18.3.1", + "react-contexify": "^6.0.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.3.1", diff --git a/frontend/src/api/folderApi.ts b/frontend/src/api/folderApi.ts index 630240a..a5cfe78 100644 --- a/frontend/src/api/folderApi.ts +++ b/frontend/src/api/folderApi.ts @@ -4,13 +4,8 @@ import { FolderRequest, FolderResponse } from '@/types'; export async function getFolder(projectId: string, folderId: number) { return api.get(`/projects/${projectId}/folders/${folderId}`).then(({ data }) => data); } - -export async function updateFolder(projectId: number, folderId: number, memberId: number, folderData: FolderRequest) { - return api - .put(`/projects/${projectId}/folders/${folderId}`, folderData, { - params: { memberId }, - }) - .then(({ data }) => data); +export async function updateFolder(projectId: number, folderId: number, folderData: FolderRequest) { + return api.put(`/projects/${projectId}/folders/${folderId}`, folderData).then(({ data }) => data); } export async function deleteFolder(projectId: number, folderId: number, memberId: number) { @@ -21,14 +16,9 @@ export async function deleteFolder(projectId: number, folderId: number, memberId .then(({ data }) => data); } -export async function createFolder(projectId: number, memberId: number, folderData: FolderRequest) { - return api - .post(`/projects/${projectId}/folders`, folderData, { - params: { memberId }, - }) - .then(({ data }) => data); +export async function createFolder(projectId: number, folderData: FolderRequest) { + return api.post(`/projects/${projectId}/folders`, folderData).then(({ data }) => data); } - export async function getFolderReviewList(projectId: number, folderId: number, memberId: number) { return api .get(`/projects/${projectId}/folders/${folderId}/review`, { diff --git a/frontend/src/api/imageApi.ts b/frontend/src/api/imageApi.ts index e09d1cf..74b279b 100644 --- a/frontend/src/api/imageApi.ts +++ b/frontend/src/api/imageApi.ts @@ -10,10 +10,8 @@ export async function moveImage(projectId: number, folderId: number, imageId: nu return api.put(`/projects/${projectId}/folders/${folderId}/images/${imageId}`, moveRequest); } -export async function deleteImage(imageId: number, memberId: number) { - return api.delete(`/images/${imageId}`, { - params: { memberId }, - }); +export async function deleteImage(projectId: number, folderId: number, imageId: number) { + return api.delete(`/projects/${projectId}/folders/${folderId}/images/${imageId}`); } export async function changeImageStatus( diff --git a/frontend/src/components/WorkspaceSidebar/ProjectContextMenu.tsx b/frontend/src/components/WorkspaceSidebar/ProjectContextMenu.tsx new file mode 100644 index 0000000..6293c03 --- /dev/null +++ b/frontend/src/components/WorkspaceSidebar/ProjectContextMenu.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { Menu, Item, useContextMenu, ItemParams } from 'react-contexify'; +import 'react-contexify/ReactContexify.css'; +import ImagePreSignedForm from '../ImagePreSignedForm'; +import useDeleteImageQuery from '@/queries/images/useDeleteImageQuery'; +import useDeleteFolderQuery from '@/queries/folders/useDeleteFolderQuery'; +import useUpdateFolderQuery from '@/queries/folders/useUpdateFolderQuery'; +import useCreateFolderQuery from '@/queries/folders/useCreateFolderQuery'; +import { FolderRequest } from '@/types'; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom'; +import useAuthStore from '@/stores/useAuthStore'; +import { useQueryClient } from '@tanstack/react-query'; + +interface ProjectContextMenuProps { + projectId: number; + folderId: number; + node?: { id: number; type: 'folder' | 'image'; name?: string }; + onRefetch: () => void; +} + +const MENU_ID = 'project-menu'; + +export default function ProjectContextMenu({ projectId, folderId, node, onRefetch }: ProjectContextMenuProps) { + const [isOpenUpload, setIsOpenUpload] = React.useState(false); + const [uploadType, setUploadType] = React.useState<'file' | 'folder' | 'zip'>('file'); + const [fileCount, setFileCount] = React.useState(0); + + const { profile } = useAuthStore(); + const memberId = profile?.id ?? 0; + + const deleteImageMutation = useDeleteImageQuery(); + const deleteFolderMutation = useDeleteFolderQuery(); + const updateFolderMutation = useUpdateFolderQuery(); + const createFolderMutation = useCreateFolderQuery(); + const queryClient = useQueryClient(); + + const { show } = useContextMenu({ id: MENU_ID }); + + const handleContextMenu = (event: React.MouseEvent) => { + show({ event }); + }; + + const handleItemClick = ({ id }: ItemParams) => { + switch (id) { + case 'rename': + handleRename(); + break; + case 'delete': + handleDelete(); + break; + case 'createFolder': + handleCreateFolder(); + break; + case 'uploadFile': + setUploadType('file'); + setIsOpenUpload(true); + break; + case 'uploadFolder': + setUploadType('folder'); + setIsOpenUpload(true); + break; + case 'uploadZip': + setUploadType('zip'); + setIsOpenUpload(true); + break; + default: + break; + } + }; + + const handleFileCount = (count: number) => { + setFileCount(count); + }; + + const handleRename = () => { + if (node?.type === 'folder') { + const newName = prompt('폴더의 새 이름을 입력하세요:', node.name); + if (newName && node.id) { + updateFolderMutation.mutate( + { + projectId, + folderId: node.id, + folderData: { + title: newName, + parentId: folderId, + }, + }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folder', projectId, folderId] }); + queryClient.invalidateQueries({ queryKey: ['folder', projectId, node.id] }); + onRefetch(); + }, + } + ); + } + } + }; + + const handleDelete = () => { + if (node?.type === 'folder') { + deleteFolderMutation.mutate( + { projectId, folderId: node.id, memberId }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folder', projectId, folderId] }); + queryClient.invalidateQueries({ queryKey: ['project', projectId] }); + onRefetch(); + }, + } + ); + } else if (node?.type === 'image') { + deleteImageMutation.mutate( + { projectId, folderId, imageId: node.id }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folder', projectId, folderId] }); + queryClient.invalidateQueries({ queryKey: ['image', node.id] }); + queryClient.invalidateQueries({ queryKey: ['project', projectId] }); + onRefetch(); + }, + } + ); + } + }; + + const handleCreateFolder = () => { + const newFolderName = prompt('새 폴더의 이름을 입력하세요:'); + if (newFolderName) { + createFolderMutation.mutate( + { + projectId, + folderData: { title: newFolderName, parentId: node?.id || folderId } as FolderRequest, + }, + { + onSuccess: () => { + console.log(folderId, node?.id); + queryClient.invalidateQueries({ queryKey: ['folder', projectId, folderId] }); + queryClient.invalidateQueries({ queryKey: ['folder', projectId, node?.id] }); + + onRefetch(); + }, + } + ); + } + }; + + return ( + <> +
+ + + {node?.type === 'folder' && ( + <> + + 폴더 이름 수정 + + + 폴더 삭제 + + + 새 폴더 생성 + + + 파일 업로드 + + + 폴더 업로드 + + + ZIP 파일 업로드 + + + )} + {node?.type === 'image' && ( + + 이미지 삭제 + + )} + + + + + + 0 ? `업로드 (${fileCount})` : '업로드'} /> + setIsOpenUpload(false)} + onRefetch={onRefetch} + onFileCount={handleFileCount} + projectId={projectId} + folderId={node?.id ?? folderId} + uploadType={uploadType} + /> + + + + ); +} diff --git a/frontend/src/components/WorkspaceSidebar/ProjectStructure.tsx b/frontend/src/components/WorkspaceSidebar/ProjectStructure.tsx index 18d3f8d..a494fd4 100644 --- a/frontend/src/components/WorkspaceSidebar/ProjectStructure.tsx +++ b/frontend/src/components/WorkspaceSidebar/ProjectStructure.tsx @@ -16,6 +16,10 @@ import { HTML5Backend } from 'react-dnd-html5-backend'; import useFolderQuery from '@/queries/folders/useFolderQuery'; import MemoFileStatusIcon from './FileStatusIcon'; import moveNodeInTree from '@/utils/moveNodeInTree'; +import ProjectContextMenu from './ProjectContextMenu'; +import { useContextMenu } from 'react-contexify'; +import 'react-contexify/ReactContexify.css'; + interface FlatNode extends TreeNode { depth: number; isLeaf: boolean; @@ -27,6 +31,8 @@ const ItemTypes = { NODE: 'node', }; +const MENU_ID = 'project-menu'; + export default function ProjectStructure({ project }: { project: Project }) { const { setProject, setCategories, setFolderId } = useProjectStore(); const { image: selectedImage, setImage } = useCanvasStore(); @@ -39,6 +45,9 @@ export default function ProjectStructure({ project }: { project: Project }) { const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(400); + const { show } = useContextMenu({ id: MENU_ID }); + const [contextNode, setContextNode] = useState(null); + useEffect(() => { if (categories) { setCategories(categories); @@ -78,7 +87,7 @@ export default function ProjectStructure({ project }: { project: Project }) { (image: ImageResponse, parent?: FlatNode) => { setImage(image); if (parent) { - setFolderId(Number(parent.id)); // 클릭된 이미지의 상위 폴더 ID 설정 + setFolderId(Number(parent.id)); } }, [setImage, setFolderId] @@ -130,6 +139,12 @@ export default function ProjectStructure({ project }: { project: Project }) { [treeData, setTreeData, moveImageMutation, project.id] ); + const handleContextMenu = (event: React.MouseEvent, node: FlatNode) => { + event.preventDefault(); + setContextNode(node); + show({ event }); + }; + const Row = ({ index, style, data }: ListChildComponentProps) => { const node = data[index]; const ref = useRef(null); @@ -160,7 +175,9 @@ export default function ProjectStructure({ project }: { project: Project }) { ...style, paddingLeft: `${node.depth * 12}px`, }} - className={`caption } flex w-full items-center gap-2 rounded-md py-0.5 pr-1 ${node.imageData && selectedImage?.id === node.imageData?.id ? 'bg-gray-200' : 'hover:bg-gray-100'} ${isDragging ? 'opacity-50' : ''}`} + className={`caption flex w-full items-center gap-2 rounded-md py-0.5 pr-1 ${ + node.imageData && selectedImage?.id === node.imageData?.id ? 'bg-gray-200' : 'hover:bg-gray-100' + } ${isDragging ? 'opacity-50' : ''}`} onClick={() => { if (node.imageData) { handleImageClick(node.imageData as ImageResponse, node.parent); @@ -168,6 +185,7 @@ export default function ProjectStructure({ project }: { project: Project }) { onToggle(node, !node.toggled); } }} + onContextMenu={(event) => handleContextMenu(event, node)} >
{!node.imageData ? ( @@ -232,6 +250,19 @@ export default function ProjectStructure({ project }: { project: Project }) {
+ + ); } diff --git a/frontend/src/hooks/useTreeData.ts b/frontend/src/hooks/useTreeData.ts index 7118737..1196b2a 100644 --- a/frontend/src/hooks/useTreeData.ts +++ b/frontend/src/hooks/useTreeData.ts @@ -23,7 +23,6 @@ export default function useTreeData(projectId: string) { currentFolderId || 0, currentFolderId !== null ); - const updateTreeData = useCallback((folder: FolderResponse, isRoot: boolean = false) => { if (!folder) return; @@ -42,7 +41,7 @@ export default function useTreeData(projectId: string) { const updateNode = (currentNode: TreeNode): TreeNode => { if (currentNode.id !== folder.id.toString()) { return currentNode.children - ? { ...currentNode, children: currentNode.children.map(updateNode) } + ? { ...currentNode, children: currentNode.children.map(updateNode).filter(Boolean) } : currentNode; } diff --git a/frontend/src/queries/folders/useCreateFolderQuery.ts b/frontend/src/queries/folders/useCreateFolderQuery.ts index edc62b3..d810a40 100644 --- a/frontend/src/queries/folders/useCreateFolderQuery.ts +++ b/frontend/src/queries/folders/useCreateFolderQuery.ts @@ -1,23 +1,14 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { createFolder } from '@/api/folderApi'; import { FolderRequest } from '@/types'; interface CreateFolderMutationVariables { projectId: number; - memberId: number; folderData: FolderRequest; } export default function useCreateFolderQuery() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ projectId, memberId, folderData }: CreateFolderMutationVariables) => - createFolder(projectId, memberId, folderData), - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ - queryKey: ['folderList', variables.projectId, variables.memberId], - }); - }, + mutationFn: ({ projectId, folderData }: CreateFolderMutationVariables) => createFolder(projectId, folderData), }); } diff --git a/frontend/src/queries/folders/useDeleteFolderQuery.ts b/frontend/src/queries/folders/useDeleteFolderQuery.ts index d3e84b0..6b39539 100644 --- a/frontend/src/queries/folders/useDeleteFolderQuery.ts +++ b/frontend/src/queries/folders/useDeleteFolderQuery.ts @@ -1,4 +1,4 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { deleteFolder } from '@/api/folderApi'; interface DeleteFolderMutationVariables { @@ -8,15 +8,8 @@ interface DeleteFolderMutationVariables { } export default function useDeleteFolderQuery() { - const queryClient = useQueryClient(); - return useMutation({ mutationFn: ({ projectId, folderId, memberId }: DeleteFolderMutationVariables) => deleteFolder(projectId, folderId, memberId), - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ - queryKey: ['folderList', variables.projectId, variables.folderId], - }); - }, }); } diff --git a/frontend/src/queries/folders/useUpdateFolderQuery.ts b/frontend/src/queries/folders/useUpdateFolderQuery.ts index 36525e8..6d6194e 100644 --- a/frontend/src/queries/folders/useUpdateFolderQuery.ts +++ b/frontend/src/queries/folders/useUpdateFolderQuery.ts @@ -1,24 +1,16 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { updateFolder } from '@/api/folderApi'; import { FolderRequest } from '@/types'; interface UpdateFolderMutationVariables { projectId: number; folderId: number; - memberId: number; folderData: FolderRequest; } export default function useUpdateFolderQuery() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ projectId, folderId, memberId, folderData }: UpdateFolderMutationVariables) => - updateFolder(projectId, folderId, memberId, folderData), - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ - queryKey: ['folderList', variables.projectId, variables.folderId], - }); - }, + mutationFn: ({ projectId, folderId, folderData }: UpdateFolderMutationVariables) => + updateFolder(projectId, folderId, folderData), }); } diff --git a/frontend/src/queries/images/useDeleteImageQuery.ts b/frontend/src/queries/images/useDeleteImageQuery.ts index 30dce4d..7197f55 100644 --- a/frontend/src/queries/images/useDeleteImageQuery.ts +++ b/frontend/src/queries/images/useDeleteImageQuery.ts @@ -1,18 +1,15 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { deleteImage } from '@/api/imageApi'; interface DeleteImageMutationVariables { + projectId: number; + folderId: number; imageId: number; - memberId: number; } export default function useDeleteImageQuery() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ imageId, memberId }: DeleteImageMutationVariables) => deleteImage(imageId, memberId), - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ queryKey: ['image', variables.imageId] }); - }, + mutationFn: ({ projectId, folderId, imageId }: DeleteImageMutationVariables) => + deleteImage(projectId, folderId, imageId), }); }