Feat: 컨텍스트 메뉴 구현, 수정중

This commit is contained in:
정현조 2024-10-07 01:34:22 +09:00
parent c0fcfb8bff
commit 22dffdc7a8
11 changed files with 295 additions and 60 deletions

View File

@ -31,6 +31,7 @@
"konva": "^9.3.14", "konva": "^9.3.14",
"lucide-react": "^0.436.0", "lucide-react": "^0.436.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-contexify": "^6.0.0",
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@ -12804,6 +12805,26 @@
"react": "^16.3.0 || ^17.0.1 || ^18.0.0" "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": { "node_modules/react-dnd": {
"version": "16.0.1", "version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",

View File

@ -37,6 +37,7 @@
"konva": "^9.3.14", "konva": "^9.3.14",
"lucide-react": "^0.436.0", "lucide-react": "^0.436.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-contexify": "^6.0.0",
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@ -4,13 +4,8 @@ import { FolderRequest, FolderResponse } from '@/types';
export async function getFolder(projectId: string, folderId: number) { export async function getFolder(projectId: string, folderId: number) {
return api.get<FolderResponse>(`/projects/${projectId}/folders/${folderId}`).then(({ data }) => data); return api.get<FolderResponse>(`/projects/${projectId}/folders/${folderId}`).then(({ data }) => data);
} }
export async function updateFolder(projectId: number, folderId: number, folderData: FolderRequest) {
export async function updateFolder(projectId: number, folderId: number, memberId: number, folderData: FolderRequest) { return api.put(`/projects/${projectId}/folders/${folderId}`, folderData).then(({ data }) => data);
return api
.put(`/projects/${projectId}/folders/${folderId}`, folderData, {
params: { memberId },
})
.then(({ data }) => data);
} }
export async function deleteFolder(projectId: number, folderId: number, memberId: number) { 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); .then(({ data }) => data);
} }
export async function createFolder(projectId: number, memberId: number, folderData: FolderRequest) { export async function createFolder(projectId: number, folderData: FolderRequest) {
return api return api.post(`/projects/${projectId}/folders`, folderData).then(({ data }) => data);
.post(`/projects/${projectId}/folders`, folderData, {
params: { memberId },
})
.then(({ data }) => data);
} }
export async function getFolderReviewList(projectId: number, folderId: number, memberId: number) { export async function getFolderReviewList(projectId: number, folderId: number, memberId: number) {
return api return api
.get(`/projects/${projectId}/folders/${folderId}/review`, { .get(`/projects/${projectId}/folders/${folderId}/review`, {

View File

@ -10,10 +10,8 @@ export async function moveImage(projectId: number, folderId: number, imageId: nu
return api.put(`/projects/${projectId}/folders/${folderId}/images/${imageId}`, moveRequest); return api.put(`/projects/${projectId}/folders/${folderId}/images/${imageId}`, moveRequest);
} }
export async function deleteImage(imageId: number, memberId: number) { export async function deleteImage(projectId: number, folderId: number, imageId: number) {
return api.delete(`/images/${imageId}`, { return api.delete(`/projects/${projectId}/folders/${folderId}/images/${imageId}`);
params: { memberId },
});
} }
export async function changeImageStatus( export async function changeImageStatus(

View File

@ -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<boolean>(false);
const [uploadType, setUploadType] = React.useState<'file' | 'folder' | 'zip'>('file');
const [fileCount, setFileCount] = React.useState<number>(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 (
<>
<div onContextMenu={handleContextMenu}></div>
<Menu id={MENU_ID}>
{node?.type === 'folder' && (
<>
<Item
id="rename"
onClick={handleItemClick}
>
</Item>
<Item
id="delete"
onClick={handleItemClick}
>
</Item>
<Item
id="createFolder"
onClick={handleItemClick}
>
</Item>
<Item
id="uploadFile"
onClick={handleItemClick}
>
</Item>
<Item
id="uploadFolder"
onClick={handleItemClick}
>
</Item>
<Item
id="uploadZip"
onClick={handleItemClick}
>
ZIP
</Item>
</>
)}
{node?.type === 'image' && (
<Item
id="delete"
onClick={handleItemClick}
>
</Item>
)}
</Menu>
<Dialog
open={isOpenUpload}
onOpenChange={setIsOpenUpload}
>
<DialogTrigger asChild></DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader title={fileCount > 0 ? `업로드 (${fileCount})` : '업로드'} />
<ImagePreSignedForm
onClose={() => setIsOpenUpload(false)}
onRefetch={onRefetch}
onFileCount={handleFileCount}
projectId={projectId}
folderId={node?.id ?? folderId}
uploadType={uploadType}
/>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -16,6 +16,10 @@ import { HTML5Backend } from 'react-dnd-html5-backend';
import useFolderQuery from '@/queries/folders/useFolderQuery'; import useFolderQuery from '@/queries/folders/useFolderQuery';
import MemoFileStatusIcon from './FileStatusIcon'; import MemoFileStatusIcon from './FileStatusIcon';
import moveNodeInTree from '@/utils/moveNodeInTree'; import moveNodeInTree from '@/utils/moveNodeInTree';
import ProjectContextMenu from './ProjectContextMenu';
import { useContextMenu } from 'react-contexify';
import 'react-contexify/ReactContexify.css';
interface FlatNode extends TreeNode { interface FlatNode extends TreeNode {
depth: number; depth: number;
isLeaf: boolean; isLeaf: boolean;
@ -27,6 +31,8 @@ const ItemTypes = {
NODE: 'node', NODE: 'node',
}; };
const MENU_ID = 'project-menu';
export default function ProjectStructure({ project }: { project: Project }) { export default function ProjectStructure({ project }: { project: Project }) {
const { setProject, setCategories, setFolderId } = useProjectStore(); const { setProject, setCategories, setFolderId } = useProjectStore();
const { image: selectedImage, setImage } = useCanvasStore(); const { image: selectedImage, setImage } = useCanvasStore();
@ -39,6 +45,9 @@ export default function ProjectStructure({ project }: { project: Project }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number>(400); const [containerHeight, setContainerHeight] = useState<number>(400);
const { show } = useContextMenu({ id: MENU_ID });
const [contextNode, setContextNode] = useState<FlatNode | null>(null);
useEffect(() => { useEffect(() => {
if (categories) { if (categories) {
setCategories(categories); setCategories(categories);
@ -78,7 +87,7 @@ export default function ProjectStructure({ project }: { project: Project }) {
(image: ImageResponse, parent?: FlatNode) => { (image: ImageResponse, parent?: FlatNode) => {
setImage(image); setImage(image);
if (parent) { if (parent) {
setFolderId(Number(parent.id)); // 클릭된 이미지의 상위 폴더 ID 설정 setFolderId(Number(parent.id));
} }
}, },
[setImage, setFolderId] [setImage, setFolderId]
@ -130,6 +139,12 @@ export default function ProjectStructure({ project }: { project: Project }) {
[treeData, setTreeData, moveImageMutation, project.id] [treeData, setTreeData, moveImageMutation, project.id]
); );
const handleContextMenu = (event: React.MouseEvent, node: FlatNode) => {
event.preventDefault();
setContextNode(node);
show({ event });
};
const Row = ({ index, style, data }: ListChildComponentProps<FlatNode[]>) => { const Row = ({ index, style, data }: ListChildComponentProps<FlatNode[]>) => {
const node = data[index]; const node = data[index];
const ref = useRef<HTMLButtonElement>(null); const ref = useRef<HTMLButtonElement>(null);
@ -160,7 +175,9 @@ export default function ProjectStructure({ project }: { project: Project }) {
...style, ...style,
paddingLeft: `${node.depth * 12}px`, 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={() => { onClick={() => {
if (node.imageData) { if (node.imageData) {
handleImageClick(node.imageData as ImageResponse, node.parent); handleImageClick(node.imageData as ImageResponse, node.parent);
@ -168,6 +185,7 @@ export default function ProjectStructure({ project }: { project: Project }) {
onToggle(node, !node.toggled); onToggle(node, !node.toggled);
} }
}} }}
onContextMenu={(event) => handleContextMenu(event, node)}
> >
<div className="flex items-center"> <div className="flex items-center">
{!node.imageData ? ( {!node.imageData ? (
@ -232,6 +250,19 @@ export default function ProjectStructure({ project }: { project: Project }) {
<AutoLabelButton projectId={project.id} /> <AutoLabelButton projectId={project.id} />
</div> </div>
</div> </div>
<ProjectContextMenu
projectId={project.id}
folderId={contextNode?.parent?.id ? Number(contextNode.parent.id) : 0}
node={
contextNode && contextNode.imageData
? { id: Number(contextNode.id), type: 'image', name: contextNode.name }
: contextNode
? { id: Number(contextNode.id), type: 'folder', name: contextNode.name }
: undefined
}
onRefetch={refetch}
/>
</DndProvider> </DndProvider>
); );
} }

View File

@ -23,7 +23,6 @@ export default function useTreeData(projectId: string) {
currentFolderId || 0, currentFolderId || 0,
currentFolderId !== null currentFolderId !== null
); );
const updateTreeData = useCallback((folder: FolderResponse, isRoot: boolean = false) => { const updateTreeData = useCallback((folder: FolderResponse, isRoot: boolean = false) => {
if (!folder) return; if (!folder) return;
@ -42,7 +41,7 @@ export default function useTreeData(projectId: string) {
const updateNode = (currentNode: TreeNode): TreeNode => { const updateNode = (currentNode: TreeNode): TreeNode => {
if (currentNode.id !== folder.id.toString()) { if (currentNode.id !== folder.id.toString()) {
return currentNode.children return currentNode.children
? { ...currentNode, children: currentNode.children.map(updateNode) } ? { ...currentNode, children: currentNode.children.map(updateNode).filter(Boolean) }
: currentNode; : currentNode;
} }

View File

@ -1,23 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { createFolder } from '@/api/folderApi'; import { createFolder } from '@/api/folderApi';
import { FolderRequest } from '@/types'; import { FolderRequest } from '@/types';
interface CreateFolderMutationVariables { interface CreateFolderMutationVariables {
projectId: number; projectId: number;
memberId: number;
folderData: FolderRequest; folderData: FolderRequest;
} }
export default function useCreateFolderQuery() { export default function useCreateFolderQuery() {
const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ projectId, memberId, folderData }: CreateFolderMutationVariables) => mutationFn: ({ projectId, folderData }: CreateFolderMutationVariables) => createFolder(projectId, folderData),
createFolder(projectId, memberId, folderData),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ['folderList', variables.projectId, variables.memberId],
});
},
}); });
} }

View File

@ -1,4 +1,4 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { deleteFolder } from '@/api/folderApi'; import { deleteFolder } from '@/api/folderApi';
interface DeleteFolderMutationVariables { interface DeleteFolderMutationVariables {
@ -8,15 +8,8 @@ interface DeleteFolderMutationVariables {
} }
export default function useDeleteFolderQuery() { export default function useDeleteFolderQuery() {
const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ projectId, folderId, memberId }: DeleteFolderMutationVariables) => mutationFn: ({ projectId, folderId, memberId }: DeleteFolderMutationVariables) =>
deleteFolder(projectId, folderId, memberId), deleteFolder(projectId, folderId, memberId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ['folderList', variables.projectId, variables.folderId],
});
},
}); });
} }

View File

@ -1,24 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { updateFolder } from '@/api/folderApi'; import { updateFolder } from '@/api/folderApi';
import { FolderRequest } from '@/types'; import { FolderRequest } from '@/types';
interface UpdateFolderMutationVariables { interface UpdateFolderMutationVariables {
projectId: number; projectId: number;
folderId: number; folderId: number;
memberId: number;
folderData: FolderRequest; folderData: FolderRequest;
} }
export default function useUpdateFolderQuery() { export default function useUpdateFolderQuery() {
const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ projectId, folderId, memberId, folderData }: UpdateFolderMutationVariables) => mutationFn: ({ projectId, folderId, folderData }: UpdateFolderMutationVariables) =>
updateFolder(projectId, folderId, memberId, folderData), updateFolder(projectId, folderId, folderData),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ['folderList', variables.projectId, variables.folderId],
});
},
}); });
} }

View File

@ -1,18 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { deleteImage } from '@/api/imageApi'; import { deleteImage } from '@/api/imageApi';
interface DeleteImageMutationVariables { interface DeleteImageMutationVariables {
projectId: number;
folderId: number;
imageId: number; imageId: number;
memberId: number;
} }
export default function useDeleteImageQuery() { export default function useDeleteImageQuery() {
const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ imageId, memberId }: DeleteImageMutationVariables) => deleteImage(imageId, memberId), mutationFn: ({ projectId, folderId, imageId }: DeleteImageMutationVariables) =>
onSuccess: (_, variables) => { deleteImage(projectId, folderId, imageId),
queryClient.invalidateQueries({ queryKey: ['image', variables.imageId] });
},
}); });
} }