Merge branch 'fe/feat/75-workspace-sidebar' into 'fe/develop'
Feat: 워크스페이스 사이드바 컴포넌트 추가 - S11P21S002-75 See merge request s11-s-project/S11P21S002!11
This commit is contained in:
commit
6014358a90
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@ -20,6 +20,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
|
"react-resizable-panels": "^2.1.1",
|
||||||
"react-router-dom": "^6.26.1",
|
"react-router-dom": "^6.26.1",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@ -10264,6 +10265,16 @@
|
|||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/react-resizable-panels": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-+cUV/yZBYfiBj+WJtpWDJ3NtR4zgDZfHt3+xtaETKE+FCvp+RK/NJxacDQKxMHgRUTSkfA6AnGljQ5QZNsCQoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.14.0 || ^17.0.0 || ^18.0.0",
|
||||||
|
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "6.26.1",
|
"version": "6.26.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.1.tgz",
|
||||||
|
@ -26,6 +26,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
|
"react-resizable-panels": "^2.1.1",
|
||||||
"react-router-dom": "^6.26.1",
|
"react-router-dom": "^6.26.1",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
@ -0,0 +1,58 @@
|
|||||||
|
import { DirectoryItem } 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,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
item: DirectoryItem;
|
||||||
|
depth?: number;
|
||||||
|
}) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const paddingLeft = depth * 12;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn('flex cursor-pointer items-center gap-2 rounded-md py-0.5 pr-1 hover:bg-gray-200', className)}
|
||||||
|
style={{
|
||||||
|
paddingLeft,
|
||||||
|
}}
|
||||||
|
onClick={() => setIsExpanded((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<button className="flex items-center">
|
||||||
|
<ChevronRight
|
||||||
|
size={16}
|
||||||
|
className={`stroke-gray-500 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{item.name}</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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
43
frontend/src/components/WorkspaceSidebar/ProjectFileItem.tsx
Normal file
43
frontend/src/components/WorkspaceSidebar/ProjectFileItem.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { FileItem } from '@/types';
|
||||||
|
import { Check, Image, Minus } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ProjectFileItem({
|
||||||
|
className = '',
|
||||||
|
item,
|
||||||
|
depth = 1,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
item: FileItem;
|
||||||
|
depth?: number;
|
||||||
|
}) {
|
||||||
|
const paddingLeft = depth * 12;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn('flex w-full gap-2 rounded-md py-0.5 pr-1 hover:bg-gray-200', className)}
|
||||||
|
style={{
|
||||||
|
paddingLeft,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Image
|
||||||
|
size={16}
|
||||||
|
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="stroke-gray-400"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Check
|
||||||
|
size={16}
|
||||||
|
className="stroke-green-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,60 @@
|
|||||||
|
import { Project } from '@/types';
|
||||||
|
import { ChevronRight, SquarePenIcon, Upload } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import ProjectFileItem from './ProjectFileItem';
|
||||||
|
import ProjectDirectoryItem from './ProjectDirectoryItem';
|
||||||
|
|
||||||
|
export default function ProjectStructure({ project }: { project: Project }) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<h3 className="caption overflow-hidden text-ellipsis whitespace-nowrap">{project.type}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex gap-1"
|
||||||
|
onClick={() => console.log('edit project')}
|
||||||
|
>
|
||||||
|
<SquarePenIcon size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex gap-1"
|
||||||
|
onClick={() => console.log('upload image')}
|
||||||
|
>
|
||||||
|
<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'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
82
frontend/src/components/WorkspaceSidebar/index.stories.tsx
Normal file
82
frontend/src/components/WorkspaceSidebar/index.stories.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import '@/index.css';
|
||||||
|
import WorkspaceSidebar from '.';
|
||||||
|
import { ResizablePanel, ResizablePanelGroup } from '../ui/resizable';
|
||||||
|
import { Project } from '@/types';
|
||||||
|
import { Meta } from '@storybook/react';
|
||||||
|
import { Component } from 'react';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Component> = {
|
||||||
|
title: 'Workspace/WorkspaceSidebar',
|
||||||
|
component: WorkspaceSidebar,
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
const projects: Project[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'project-111',
|
||||||
|
type: 'Segmentation',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
type: 'directory',
|
||||||
|
name: 'directory-1',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 123,
|
||||||
|
type: 'directory',
|
||||||
|
name: 'directory-2',
|
||||||
|
children: [
|
||||||
|
{ id: 1, url: '', type: 'image', name: 'image-1.jpg', status: 'done' },
|
||||||
|
{ id: 1, url: '', type: 'image', name: 'image-2.jpg', status: 'idle' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 1, url: '', type: 'image', name: 'image-1.jpg', status: 'idle' },
|
||||||
|
{ id: 1, url: '', type: 'image', name: 'image-2.jpg', status: 'done' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 1, url: '', type: 'image', name: 'image-1.jpg', status: 'done' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'very-extremely-long-long-project-name-222',
|
||||||
|
type: 'Classification',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 23,
|
||||||
|
type: 'directory',
|
||||||
|
name: 'this-is-my-very-very-long-directory-name-that-will-be-overflow',
|
||||||
|
children: [
|
||||||
|
{ id: 1, url: '', type: 'image', name: 'image-1.jpg', status: 'done' },
|
||||||
|
{ id: 1, url: '', type: 'image', name: 'image-2.jpg', status: 'done' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
url: '',
|
||||||
|
type: 'image',
|
||||||
|
name: 'wow-this-is-my-very-very-long-image-name-so-this-will-be-overflow-too.jpg',
|
||||||
|
status: 'idle',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Default = () => (
|
||||||
|
<div className="h-screen">
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
<WorkspaceSidebar
|
||||||
|
workspaceName="Workspace-name-1"
|
||||||
|
projects={projects}
|
||||||
|
/>
|
||||||
|
<ResizablePanel className="flex w-full items-center justify-center">
|
||||||
|
<div>Content</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
45
frontend/src/components/WorkspaceSidebar/index.tsx
Normal file
45
frontend/src/components/WorkspaceSidebar/index.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { SquarePen } from 'lucide-react';
|
||||||
|
import { ResizableHandle, ResizablePanel } from '../ui/resizable';
|
||||||
|
import ProjectStructure from './ProjectStructure';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Project } from '@/types';
|
||||||
|
|
||||||
|
export default function WorkspaceSidebar({ workspaceName, projects }: { workspaceName: string; projects: Project[] }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ResizablePanel
|
||||||
|
minSize={10}
|
||||||
|
maxSize={35}
|
||||||
|
defaultSize={20}
|
||||||
|
className="flex h-full flex-col bg-gray-100"
|
||||||
|
onResize={(size) => {
|
||||||
|
console.log(size);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header className="body flex w-full items-center gap-2 p-2">
|
||||||
|
<h1 className="w-full overflow-hidden text-ellipsis whitespace-nowrap">{workspaceName}</h1>
|
||||||
|
<button>
|
||||||
|
<SquarePen size={16} />
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="caption border-gray-800 bg-gray-100"
|
||||||
|
onClick={() => console.log('New project')}
|
||||||
|
>
|
||||||
|
새 프로젝트
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectStructure
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle className="bg-gray-300" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -24,6 +24,7 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-10 px-4 py-2',
|
default: 'h-10 px-4 py-2',
|
||||||
|
xs: 'rounded-md px-2 py-1',
|
||||||
sm: 'h-9 rounded-md px-3',
|
sm: 'h-9 rounded-md px-3',
|
||||||
lg: 'h-11 rounded-md px-8',
|
lg: 'h-11 rounded-md px-8',
|
||||||
icon: 'h-10 w-10',
|
icon: 'h-10 w-10',
|
||||||
|
43
frontend/src/components/ui/resizable.tsx
Normal file
43
frontend/src/components/ui/resizable.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { GripVertical } from "lucide-react"
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ResizablePanelGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ResizablePanel = ResizablePrimitive.Panel
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-px items-center justify-center bg-gray-200 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-950 focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90 dark:bg-gray-800 dark:focus-visible:ring-gray-300",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border border-gray-200 bg-gray-200 dark:border-gray-800 dark:bg-gray-800">
|
||||||
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
)
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
21
frontend/src/types/index.ts
Normal file
21
frontend/src/types/index.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
export type FileItem = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
type: 'image' | 'json';
|
||||||
|
status: 'idle' | 'done';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DirectoryItem = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'directory';
|
||||||
|
children: Array<DirectoryItem | FileItem>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Project = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'Classification' | 'Detection' | 'Segmentation';
|
||||||
|
children: Array<DirectoryItem | FileItem>;
|
||||||
|
};
|
@ -33,6 +33,10 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
|
padding: {
|
||||||
|
0.5: '0.125rem',
|
||||||
|
1.5: '0.375rem',
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
transparent: 'transparent',
|
transparent: 'transparent',
|
||||||
current: 'currentColor',
|
current: 'currentColor',
|
||||||
|
Loading…
Reference in New Issue
Block a user