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-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@ -10264,6 +10265,16 @@
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"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": {
|
||||
"version": "6.26.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.1.tgz",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"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: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
xs: 'rounded-md px-2 py-1',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
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: {
|
||||
padding: {
|
||||
0.5: '0.125rem',
|
||||
1.5: '0.375rem',
|
||||
},
|
||||
colors: {
|
||||
transparent: 'transparent',
|
||||
current: 'currentColor',
|
||||
|
Loading…
Reference in New Issue
Block a user