Merge branch 'fe/feat/193-canvas-sidebar-state' into 'fe/develop'

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

See merge request s11-s-project/S11P21S002!96
This commit is contained in:
정현조 2024-09-19 16:51:54 +09:00
commit 4b48718cbe
12 changed files with 111 additions and 198 deletions

View File

@ -1,12 +1,8 @@
import api from '@/api/axiosConfig'; import api from '@/api/axiosConfig';
import { FolderRequest, FolderResponse } from '@/types'; import { FolderRequest, FolderResponse } from '@/types';
export async function getFolder(projectId: number, folderId: number, memberId: number) { export async function getFolder(projectId: string, folderId: number) {
return api return api.get<FolderResponse>(`/projects/${projectId}/folders/${folderId}`).then(({ data }) => data);
.get<FolderResponse>(`/projects/${projectId}/folders/${folderId}`, {
params: { memberId },
})
.then(({ data }) => data);
} }
export async function updateFolder(projectId: number, folderId: number, memberId: number, folderData: FolderRequest) { 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 stageRef = useRef<Konva.Stage>(null);
const dragLayerRef = useRef<Konva.Layer>(null); const dragLayerRef = useRef<Konva.Layer>(null);
const scale = useRef<number>(0); const scale = useRef<number>(0);
const imageUrl = '/sample.jpg'; const imageUrl = useCanvasStore((state) => state.image);
const labels = useCanvasStore((state) => state.labels) ?? []; const labels = useCanvasStore((state) => state.labels) ?? [];
const [image, imageStatus] = useImage(imageUrl); const [image] = useImage(imageUrl);
const [rectPoints, setRectPoints] = useState<[number, number][]>([]); const [rectPoints, setRectPoints] = useState<[number, number][]>([]);
const [polygonPoints, setPolygonPoints] = useState<[number, number][]>([]); const [polygonPoints, setPolygonPoints] = useState<[number, number][]>([]);
const drawState = useCanvasStore((state) => state.drawState); const drawState = useCanvasStore((state) => state.drawState);
@ -183,6 +183,17 @@ export default function ImageCanvas() {
return { x: scale.current, y: scale.current }; 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(() => { useEffect(() => {
if (!stageRef.current) return; if (!stageRef.current) return;
stageRef.current.container().style.cursor = drawState === 'pointer' ? 'default' : 'crosshair'; stageRef.current.container().style.cursor = drawState === 'pointer' ? 'default' : 'crosshair';
@ -190,9 +201,9 @@ export default function ImageCanvas() {
if (drawState !== 'pointer') { if (drawState !== 'pointer') {
setSelectedLabelId(null); setSelectedLabelId(null);
} }
}, [drawState]); }, [drawState, setSelectedLabelId]);
return imageStatus === 'loaded' ? ( return image ? (
<div> <div>
<Stage <Stage
ref={stageRef} ref={stageRef}

View File

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

View File

@ -1,58 +1,16 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, Outlet } from 'react-router-dom'; import { useParams, Outlet } from 'react-router-dom';
import Header from '../Header'; import Header from '../Header';
import { Label, Project } from '@/types'; import { Project } from '@/types';
import { ResizablePanelGroup } from '../ui/resizable'; import { ResizablePanelGroup } from '../ui/resizable';
// import { ResizablePanel } from '../ui/resizable';
import WorkspaceSidebar from '../WorkspaceSidebar'; import WorkspaceSidebar from '../WorkspaceSidebar';
import useAuthStore from '@/stores/useAuthStore'; import useAuthStore from '@/stores/useAuthStore';
import useCanvasStore from '@/stores/useCanvasStore';
import useFolderQuery from '@/queries/folders/useFolderQuery';
import useWorkspaceQuery from '@/queries/workspaces/useWorkspaceQuery'; import useWorkspaceQuery from '@/queries/workspaces/useWorkspaceQuery';
import useProjectListQuery from '@/queries/projects/useProjectListQuery'; 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() { export default function WorkspaceLayout() {
const setLabels = useCanvasStore((state) => state.setLabels);
const params = useParams<{ workspaceId: string; projectId: string }>(); const params = useParams<{ workspaceId: string; projectId: string }>();
const workspaceId = Number(params.workspaceId); const workspaceId = Number(params.workspaceId);
const projectId = Number(params.projectId);
const [workspace, setWorkspace] = useState<{ name: string; projects: Project[] }>({ const [workspace, setWorkspace] = useState<{ name: string; projects: Project[] }>({
name: '', name: '',
projects: [], projects: [],
@ -61,7 +19,6 @@ export default function WorkspaceLayout() {
const memberId = profile?.id || 0; const memberId = profile?.id || 0;
const { data: workspaceData } = useWorkspaceQuery(workspaceId, memberId); const { data: workspaceData } = useWorkspaceQuery(workspaceId, memberId);
const { data: projectListData } = useProjectListQuery(workspaceId, memberId); const { data: projectListData } = useProjectListQuery(workspaceId, memberId);
const { data: folderData } = useFolderQuery(projectId, 0, memberId);
useEffect(() => { useEffect(() => {
if (!workspaceData) return; if (!workspaceData) return;
@ -73,7 +30,6 @@ export default function WorkspaceLayout() {
useEffect(() => { useEffect(() => {
if (!projectListData) return; if (!projectListData) return;
console.log(folderData);
const projects = projectListData.workspaceResponses.map( const projects = projectListData.workspaceResponses.map(
(project): Project => ({ (project): Project => ({
id: project.id, id: project.id,
@ -89,11 +45,7 @@ export default function WorkspaceLayout() {
...prev, ...prev,
projects, projects,
})); }));
}, [folderData, projectListData]); }, [projectListData]);
useEffect(() => {
setLabels(mockLabels);
}, [setLabels]);
return ( return (
<> <>

View File

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

View File

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

View File

@ -1,30 +1,20 @@
import { Project } from '@/types'; import { Project } from '@/types';
import { ChevronRight, SquarePenIcon, Upload } from 'lucide-react'; import { SquarePenIcon, Upload } from 'lucide-react';
import { useState } from 'react';
import ProjectFileItem from './ProjectFileItem'; import ProjectFileItem from './ProjectFileItem';
import ProjectDirectoryItem from './ProjectDirectoryItem'; import ProjectDirectoryItem from './ProjectDirectoryItem';
import useFolderQuery from '@/queries/folders/useFolderQuery';
import useCanvasStore from '@/stores/useCanvasStore';
export default function ProjectStructure({ project }: { project: Project }) { 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 ( return (
<div className="flex select-none flex-col px-1 pb-2"> <div className="flex h-full flex-col overflow-y-auto px-1 pb-2">
<header className="flex w-full items-center gap-2 rounded px-1 hover:bg-gray-200"> <header className="flex w-full items-center gap-2 rounded p-1">
<div <div className="flex w-full items-center gap-1 overflow-hidden pr-1">
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>
<h3 className="caption overflow-hidden text-ellipsis whitespace-nowrap">{project.type}</h3> <h3 className="caption overflow-hidden text-ellipsis whitespace-nowrap">{project.type}</h3>
</div> </div>
</div>
<button <button
className="flex gap-1" className="flex gap-1"
onClick={() => console.log('edit project')} onClick={() => console.log('edit project')}
@ -38,23 +28,28 @@ export default function ProjectStructure({ project }: { project: Project }) {
<Upload size={16} /> <Upload size={16} />
</button> </button>
</header> </header>
<div className={`caption flex flex-col ${isExpanded ? '' : 'hidden'}`}> {folderData.children.length === 0 && folderData.images.length === 0 ? (
{project.children.map((item) => <div className="body-small flex h-full select-none items-center justify-center text-gray-400">
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'}
/>
)
)}
</div> </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> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import { getFolder } from '@/api/folderApi'; import { getFolder } from '@/api/folderApi';
import { useSuspenseQuery } from '@tanstack/react-query'; 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({ return useSuspenseQuery({
queryKey: ['folder', projectId, folderId, memberId], queryKey: ['folder', projectId, folderId],
queryFn: () => getFolder(projectId, folderId, memberId), queryFn: () => getFolder(projectId, folderId),
}); });
} }