Feat: 프로젝트 캔버스 사이드바 상태 연결 - S11P21S002-193

This commit is contained in:
jhynsoo 2024-09-19 16:50:00 +09:00
parent 9e3f7764c7
commit 584788c260
12 changed files with 111 additions and 198 deletions

View File

@ -1,12 +1,8 @@
import api from '@/api/axiosConfig';
import { FolderRequest, FolderResponse } from '@/types';
export async function getFolder(projectId: number, folderId: number, memberId: number) {
return api
.get<FolderResponse>(`/projects/${projectId}/folders/${folderId}`, {
params: { memberId },
})
.then(({ data }) => data);
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) {

View File

@ -17,9 +17,9 @@ export default function ImageCanvas() {
const stageRef = useRef<Konva.Stage>(null);
const dragLayerRef = useRef<Konva.Layer>(null);
const scale = useRef<number>(0);
const imageUrl = '/sample.jpg';
const imageUrl = useCanvasStore((state) => state.image);
const labels = useCanvasStore((state) => state.labels) ?? [];
const [image, imageStatus] = useImage(imageUrl);
const [image] = useImage(imageUrl);
const [rectPoints, setRectPoints] = useState<[number, number][]>([]);
const [polygonPoints, setPolygonPoints] = useState<[number, number][]>([]);
const drawState = useCanvasStore((state) => state.drawState);
@ -183,6 +183,17 @@ export default function ImageCanvas() {
return { x: scale.current, y: scale.current };
};
useEffect(() => {
if (!image) {
scale.current = 0;
return;
}
const widthRatio = stageWidth / image!.width;
const heightRatio = stageHeight / image!.height;
scale.current = Math.min(widthRatio, heightRatio);
}, [image, stageHeight, stageWidth]);
useEffect(() => {
if (!stageRef.current) return;
stageRef.current.container().style.cursor = drawState === 'pointer' ? 'default' : 'crosshair';
@ -190,9 +201,9 @@ export default function ImageCanvas() {
if (drawState !== 'pointer') {
setSelectedLabelId(null);
}
}, [drawState]);
}, [drawState, setSelectedLabelId]);
return imageStatus === 'loaded' ? (
return image ? (
<div>
<Stage
ref={stageRef}

View File

@ -2,7 +2,6 @@ import * as React from 'react';
import ProjectCreateForm, { ProjectCreateFormValues } from './ProjectCreateForm';
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom';
import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-react';
import { ProjectRequest } from '@/types';
interface ProjectCreateModalProps {
@ -16,19 +15,6 @@ export default function ProjectCreateModal({ onSubmit, buttonClass = '' }: Proje
const handleOpen = () => setIsOpen(true);
const handleClose = () => setIsOpen(false);
const formatLabelType = (
labelType: 'Classification' | 'Detection' | 'Segmentation'
): ProjectRequest['projectType'] => {
switch (labelType) {
case 'Classification':
return 'classification';
case 'Detection':
return 'detection';
case 'Segmentation':
return 'segmentation';
}
};
return (
<Dialog
open={isOpen}
@ -38,10 +24,10 @@ export default function ProjectCreateModal({ onSubmit, buttonClass = '' }: Proje
<Button
variant="outlinePrimary"
className={`${buttonClass}`}
size={'xs'}
onClick={handleOpen}
>
<Plus size={16} />
<span> </span>
<span> </span>
</Button>
</DialogTrigger>
<DialogContent>
@ -50,7 +36,8 @@ export default function ProjectCreateModal({ onSubmit, buttonClass = '' }: Proje
onSubmit={(data: ProjectCreateFormValues) => {
const formattedData: ProjectRequest = {
title: data.projectName,
projectType: formatLabelType(data.labelType),
projectType: (data.labelType.charAt(0).toUpperCase() +
data.labelType.slice(1)) as ProjectRequest['projectType'],
};
onSubmit(formattedData);
handleClose();

View File

@ -1,58 +1,16 @@
import { useEffect, useState } from 'react';
import { useParams, Outlet } from 'react-router-dom';
import Header from '../Header';
import { Label, Project } from '@/types';
import { Project } from '@/types';
import { ResizablePanelGroup } from '../ui/resizable';
// import { ResizablePanel } from '../ui/resizable';
import WorkspaceSidebar from '../WorkspaceSidebar';
import useAuthStore from '@/stores/useAuthStore';
import useCanvasStore from '@/stores/useCanvasStore';
import useFolderQuery from '@/queries/folders/useFolderQuery';
import useWorkspaceQuery from '@/queries/workspaces/useWorkspaceQuery';
import useProjectListQuery from '@/queries/projects/useProjectListQuery';
const mockLabels: Label[] = [
{
id: 1,
name: 'Label 1',
color: '#FFaa33',
coordinates: [
[700, 100],
[1200, 800],
],
type: 'rect',
},
{
id: 2,
name: 'Label 2',
color: '#aaFF55',
coordinates: [
[200, 200],
[400, 200],
[500, 500],
[400, 800],
[200, 800],
[100, 500],
],
type: 'polygon',
},
{
id: 3,
name: 'Label 3',
color: '#77aaFF',
coordinates: [
[1000, 1000],
[1800, 1800],
],
type: 'rect',
},
];
export default function WorkspaceLayout() {
const setLabels = useCanvasStore((state) => state.setLabels);
const params = useParams<{ workspaceId: string; projectId: string }>();
const workspaceId = Number(params.workspaceId);
const projectId = Number(params.projectId);
const [workspace, setWorkspace] = useState<{ name: string; projects: Project[] }>({
name: '',
projects: [],
@ -61,7 +19,6 @@ export default function WorkspaceLayout() {
const memberId = profile?.id || 0;
const { data: workspaceData } = useWorkspaceQuery(workspaceId, memberId);
const { data: projectListData } = useProjectListQuery(workspaceId, memberId);
const { data: folderData } = useFolderQuery(projectId, 0, memberId);
useEffect(() => {
if (!workspaceData) return;
@ -73,7 +30,6 @@ export default function WorkspaceLayout() {
useEffect(() => {
if (!projectListData) return;
console.log(folderData);
const projects = projectListData.workspaceResponses.map(
(project): Project => ({
id: project.id,
@ -89,11 +45,7 @@ export default function WorkspaceLayout() {
...prev,
projects,
}));
}, [folderData, projectListData]);
useEffect(() => {
setLabels(mockLabels);
}, [setLabels]);
}, [projectListData]);
return (
<>

View File

@ -1,19 +1,20 @@
import { DirectoryItem } from '@/types';
import { ChildFolder } from '@/types';
import { ChevronRight } from 'lucide-react';
import { useState } from 'react';
import ProjectFileItem from './ProjectFileItem';
import { cn } from '@/lib/utils';
export default function ProjectDirectoryItem({
className = '',
item,
depth = 1,
depth = 0,
initialExpanded = false,
}: {
className?: string;
item: DirectoryItem;
item: ChildFolder;
depth?: number;
initialExpanded?: boolean;
}) {
const [isExpanded, setIsExpanded] = useState(true);
const [isExpanded, setIsExpanded] = useState(initialExpanded);
const paddingLeft = depth * 12;
return (
@ -31,28 +32,9 @@ export default function ProjectDirectoryItem({
className={`stroke-gray-500 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{item.name}</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{item.title}</span>
</div>
{item.children?.map((child) => {
const childProps = {
className: isExpanded ? '' : 'hidden',
depth: depth + 1,
};
return child.type === 'directory' ? (
<ProjectDirectoryItem
key={`${item.name}-${child.name}`}
item={child}
{...childProps}
/>
) : (
<ProjectFileItem
key={`${item.name}-${child.name}`}
item={child}
{...childProps}
/>
);
})}
{/* TODO: nested 폴더 구조 적용 */}
</>
);
}

View File

@ -1,5 +1,5 @@
import { cn } from '@/lib/utils';
import { FileItem } from '@/types';
import { ImageResponse } from '@/types';
import { Check, Image, Minus } from 'lucide-react';
import useCanvasStore from '@/stores/useCanvasStore';
@ -7,29 +7,38 @@ export default function ProjectFileItem({
className = '',
item,
depth = 1,
selected,
}: {
className?: string;
item: FileItem;
item: ImageResponse;
depth?: number;
selected: boolean;
}) {
const paddingLeft = depth * 12;
const changeImage = useCanvasStore((state) => state.changeImage);
const handleClick = () => {
changeImage(item.url, [
// TODO: fetch image
changeImage(item.imageUrl, [
{
id: item.id,
name: item.name,
name: item.imageTitle,
type: 'rect',
color: '#FF0000',
coordinates: [],
coordinates: [
[0, 0],
[100, 100],
],
},
]);
};
return (
<button
className={cn('flex w-full gap-2 rounded-md py-0.5 pr-1 hover:bg-gray-200', className)}
className={cn(
`flex w-full gap-2 rounded-md py-0.5 pr-1 ${selected ? 'bg-gray-200' : 'hover:bg-gray-100'}`,
className
)}
style={{
paddingLeft,
}}
@ -41,17 +50,17 @@ export default function ProjectFileItem({
className="stroke-gray-500"
/>
</div>
<span className="grow overflow-hidden text-ellipsis whitespace-nowrap text-left">{item.name}</span>
{item.status === 'idle' ? (
<Minus
size={16}
className="shrink-0 stroke-gray-400"
/>
) : (
<span className="grow overflow-hidden text-ellipsis whitespace-nowrap text-left">{item.imageTitle}</span>
{item.status === 'COMPLETED' ? (
<Check
size={16}
className="shrink-0 stroke-green-500"
/>
) : (
<Minus
size={16}
className="shrink-0 stroke-gray-400"
/>
)}
</button>
);

View File

@ -1,30 +1,20 @@
import { Project } from '@/types';
import { ChevronRight, SquarePenIcon, Upload } from 'lucide-react';
import { useState } from 'react';
import { SquarePenIcon, Upload } from 'lucide-react';
import ProjectFileItem from './ProjectFileItem';
import ProjectDirectoryItem from './ProjectDirectoryItem';
import useFolderQuery from '@/queries/folders/useFolderQuery';
import useCanvasStore from '@/stores/useCanvasStore';
export default function ProjectStructure({ project }: { project: Project }) {
const [isExpanded, setIsExpanded] = useState(true);
const image = useCanvasStore((state) => state.image);
const { data: folderData } = useFolderQuery(project.id.toString(), 0);
return (
<div className="flex select-none flex-col px-1 pb-2">
<header className="flex w-full items-center gap-2 rounded px-1 hover:bg-gray-200">
<div
className="flex w-full cursor-pointer items-center gap-1 overflow-hidden pr-1"
onClick={() => setIsExpanded((prev) => !prev)}
>
<button>
<ChevronRight
size={16}
className={`stroke-gray-500 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
<div className="flex flex-col overflow-hidden">
<h2 className="body-small-strong overflow-hidden text-ellipsis whitespace-nowrap">{project.name}</h2>
<div className="flex h-full flex-col overflow-y-auto px-1 pb-2">
<header className="flex w-full items-center gap-2 rounded p-1">
<div className="flex w-full items-center gap-1 overflow-hidden pr-1">
<h3 className="caption overflow-hidden text-ellipsis whitespace-nowrap">{project.type}</h3>
</div>
</div>
<button
className="flex gap-1"
onClick={() => console.log('edit project')}
@ -38,23 +28,28 @@ export default function ProjectStructure({ project }: { project: Project }) {
<Upload size={16} />
</button>
</header>
<div className={`caption flex flex-col ${isExpanded ? '' : 'hidden'}`}>
{project.children.map((item) =>
item.type === 'directory' ? (
<ProjectDirectoryItem
key={`${project.id}-${item.name}`}
item={item}
className={isExpanded ? '' : 'hidden'}
/>
) : (
<ProjectFileItem
key={`${project.id}-${item.name}`}
item={item}
className={isExpanded ? '' : 'hidden'}
/>
)
)}
{folderData.children.length === 0 && folderData.images.length === 0 ? (
<div className="body-small flex h-full select-none items-center justify-center text-gray-400">
.
</div>
) : (
<div className="caption flex flex-col">
{folderData.children.map((item) => (
<ProjectDirectoryItem
key={`${project.id}-${item.title}`}
item={item}
initialExpanded={true}
/>
))}
{folderData.images.map((item) => (
<ProjectFileItem
key={`${project.id}-${item.imageTitle}`}
item={item}
selected={image === item.imageUrl}
/>
))}
</div>
)}
</div>
);
}

View File

@ -1,4 +1,3 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { SquarePen } from 'lucide-react';
import { ResizableHandle, ResizablePanel } from '../ui/resizable';
@ -7,15 +6,18 @@ import { Project } from '@/types';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '../ui/select';
import ProjectCreateModal from '../ProjectCreateModal';
import useCanvasStore from '@/stores/useCanvasStore';
import { webPath } from '@/router';
export default function WorkspaceSidebar({ workspaceName, projects }: { workspaceName: string; projects: Project[] }) {
const { projectId: selectedProjectId } = useParams<{ projectId: string }>();
const selectedProject = projects.find((project) => project.id.toString() === selectedProjectId);
const setSidebarSize = useCanvasStore((state) => state.setSidebarSize);
const navigate = useNavigate();
const { workspaceId } = useParams<{ workspaceId: string }>();
const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>();
// const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>();
const handleSelectProject = (projectId: string) => {
setSelectedProjectId(projectId);
navigate(`/workspace/${workspaceId}/project/${projectId}`);
// setSelectedProjectId(projectId);
navigate(`${webPath.workspace()}/${workspaceId}/project/${projectId}`);
};
return (
@ -24,7 +26,7 @@ export default function WorkspaceSidebar({ workspaceName, projects }: { workspac
minSize={10}
maxSize={35}
defaultSize={20}
className="flex h-full flex-col bg-gray-100"
className="flex h-full flex-col bg-gray-50"
onResize={(size) => setSidebarSize(size)}
>
<header className="body flex w-full items-center gap-2 p-2">
@ -34,11 +36,14 @@ export default function WorkspaceSidebar({ workspaceName, projects }: { workspac
</button>
<ProjectCreateModal
onSubmit={(data) => console.log('프로젝트 생성:', data)}
buttonClass="caption border-gray-800 bg-gray-100 flex items-center gap-2"
buttonClass="caption border-primary bg-gray-50"
/>
</header>
<div className="p-2">
<Select onValueChange={handleSelectProject}>
<Select
onValueChange={handleSelectProject}
defaultValue={selectedProjectId}
>
<SelectTrigger>
<SelectValue placeholder="프로젝트를 선택해주세요" />
</SelectTrigger>
@ -54,16 +59,7 @@ export default function WorkspaceSidebar({ workspaceName, projects }: { workspac
</SelectContent>
</Select>
</div>
<div>
{projects
.filter((project) => project.id.toString() === selectedProjectId)
.map((project) => (
<ProjectStructure
key={project.id}
project={project}
/>
))}
</div>
{selectedProject && <ProjectStructure project={selectedProject} />}
</ResizablePanel>
<ResizableHandle className="bg-gray-300" />
</>

View File

@ -1,30 +1,3 @@
// import React from 'react';
// import ReactDOM from 'react-dom/client';
// import App from './App.tsx';
// import './index.css';
// async function enableMocking() {
// if (!import.meta.env.DEV) {
// return;
// }
// try {
// const { worker } = await import('./mocks/browser.ts');
// await worker.start();
// console.log('[MSW] Mocking enabled. Service Worker is running.');
// } catch (error) {
// console.error('[MSW] Failed to start the Service Worker:', error);
// }
// }
// enableMocking().then(() => {
// ReactDOM.createRoot(document.getElementById('root')!).render(
// <React.StrictMode>
// <App />
// </React.StrictMode>
// );
// });
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';

View File

@ -1,14 +1,26 @@
import ImageCanvas from '@/components/ImageCanvas';
import { ResizablePanel } from '@/components/ui/resizable';
import WorkspaceLabelBar from '@/components/WorkspaceLabelBar';
import useCanvasStore from '@/stores/useCanvasStore';
import { Suspense } from 'react';
export default function LabelCanvas() {
const imageUrl = useCanvasStore((state) => state.image);
return (
<ResizablePanel className="flex w-full items-center">
<Suspense fallback={<div></div>}>
<main className="h-full grow">
{imageUrl ? (
<ImageCanvas />
) : (
<div className="body flex h-full w-full select-none items-center justify-center bg-gray-200 text-gray-400">
.
</div>
)}
</main>
<WorkspaceLabelBar />
</Suspense>
</ResizablePanel>
);
}

View File

@ -63,7 +63,7 @@ function HeaderSection({
<div className="flex flex-col">
<div className="flex gap-3">
<ProjectCreateModal
buttonClass="mt-4 flex items-center gap-2"
buttonClass="mt-4 flex items-center gap-2 body-small-strong"
onSubmit={onCreateProject}
/>
</div>

View File

@ -1,9 +1,9 @@
import { getFolder } from '@/api/folderApi';
import { useSuspenseQuery } from '@tanstack/react-query';
export default function useFolderQuery(projectId: number, folderId: number, memberId: number) {
export default function useFolderQuery(projectId: string, folderId: number) {
return useSuspenseQuery({
queryKey: ['folder', projectId, folderId, memberId],
queryFn: () => getFolder(projectId, folderId, memberId),
queryKey: ['folder', projectId, folderId],
queryFn: () => getFolder(projectId, folderId),
});
}