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

View File

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

View File

@ -4,13 +4,8 @@ import { FolderRequest, FolderResponse } from '@/types';
export async function getFolder(projectId: string, folderId: number) {
return api.get<FolderResponse>(`/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`, {

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);
}
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(

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 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<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number>(400);
const { show } = useContextMenu({ id: MENU_ID });
const [contextNode, setContextNode] = useState<FlatNode | null>(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<FlatNode[]>) => {
const node = data[index];
const ref = useRef<HTMLButtonElement>(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)}
>
<div className="flex items-center">
{!node.imageData ? (
@ -232,6 +250,19 @@ export default function ProjectStructure({ project }: { project: Project }) {
<AutoLabelButton projectId={project.id} />
</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>
);
}

View File

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

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 { 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),
});
}

View File

@ -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],
});
},
});
}

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 { 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),
});
}

View File

@ -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),
});
}