Merge branch 'fe/feat/137-home-page' into 'fe/develop'

Feat: 홈페이지 구현- S11P21S002-137

See merge request s11-s-project/S11P21S002!53
This commit is contained in:
홍창기 2024-09-10 15:51:55 +09:00
commit 2a3d5e2672
16 changed files with 392 additions and 146 deletions

View File

@ -1,14 +0,0 @@
import type { Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@ -0,0 +1,30 @@
import React from 'react';
import type { Preview } from '@storybook/react';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import '../src/index.css';
const queryClient = new QueryClient();
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export const decorators = [
(Story) => (
<MemoryRouter initialEntries={['/']}>
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
</MemoryRouter>
),
];
export default preview;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -1,7 +1,6 @@
import '@/index.css';
import { Meta, StoryObj } from '@storybook/react';
import AdminLayout from './index';
import { BrowserRouter as Router } from 'react-router-dom';
import { Workspace } from '@/types';
const meta: Meta<typeof AdminLayout> = {
@ -36,9 +35,5 @@ const workspace: Workspace = {
};
export const Default: Story = {
render: () => (
<Router>
<AdminLayout workspace={workspace} />
</Router>
),
render: () => <AdminLayout workspace={workspace} />,
};

View File

@ -1,4 +1,5 @@
import { Outlet } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import Header from '../Header';
import { ResizablePanelGroup, ResizablePanel } from '../ui/resizable';
import AdminProjectSidebar from '../AdminProjectSidebar';
@ -6,18 +7,59 @@ import AdminMenuSidebar from '../AdminMenuSidebar';
import { Workspace } from '@/types';
interface AdminLayoutProps {
workspace: Workspace;
workspace?: Workspace;
}
export default function AdminLayout({ workspace }: AdminLayoutProps) {
const { workspaceId } = useParams<{ workspaceId: string }>();
const numericWorkspaceId = workspaceId ? parseInt(workspaceId, 10) : 0;
const effectiveWorkspace: Workspace = workspace || {
id: numericWorkspaceId,
name: workspaceId ? `workspace-${workspaceId}` : 'default-workspace',
projects: [
{
id: 1,
name: 'project1',
type: 'Detection',
children: [],
},
{
id: 2,
name: 'project2',
type: 'Detection',
children: [],
},
{
id: 3,
name: 'project3',
type: 'Detection',
children: [],
},
{
id: 4,
name: 'project4',
type: 'Detection',
children: [],
},
{
id: 5,
name: 'project5',
type: 'Detection',
children: [],
},
],
};
return (
<>
<Header className="fixed left-0 top-0" />
<div className="mt-16 h-[calc(100vh-64px)] w-screen">
<ResizablePanelGroup direction="horizontal">
<AdminProjectSidebar
workspaceName={workspace.name}
projects={workspace.projects}
workspaceName={effectiveWorkspace.name}
projects={effectiveWorkspace.projects}
/>
<ResizablePanel className="flex w-full items-center">
<main className="h-full grow">

View File

@ -16,18 +16,24 @@ interface Project {
export default function AdminMemberManage({
title = '멤버 관리',
projects,
onProjectChange,
onSubmit,
members,
onMemberInvite,
projects = [
{ id: 'project-1', name: '프로젝트 A' },
{ id: 'project-2', name: '프로젝트 B' },
],
onProjectChange = (projectId: string) => console.log('Selected Project:', projectId),
onSubmit = (data: MemberManageFormValues) => console.log('Submitted:', data),
members = [
{ email: 'admin1@example.com', role: 'admin' },
{ email: 'viewer2@example.com', role: 'viewer' },
],
onMemberInvite = () => console.log('Invite member'),
}: {
title?: string;
projects: Project[];
onProjectChange: (projectId: string) => void;
onSubmit: (data: MemberManageFormValues) => void;
members: Member[];
onMemberInvite: () => void;
projects?: Project[];
onProjectChange?: (projectId: string) => void;
onSubmit?: (data: MemberManageFormValues) => void;
members?: Member[];
onMemberInvite?: () => void;
}) {
return (
<div className="flex w-full flex-col gap-6 border-b-[0.67px] border-[#dcdcde] bg-[#fbfafd] p-6">

View File

@ -1,13 +1,13 @@
import { useNavigate } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { cn } from '@/lib/utils';
export default function AdminMenuSidebar() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const menuItems = [
{ label: '작업', path: '/admin/tasks' },
{ label: '리뷰', path: '/admin/reviews' },
{ label: '멤버 관리', path: '/admin/members' },
{ label: '리뷰', path: `/admin/${id}/review` },
{ label: '멤버 관리', path: `/admin/${id}/members` },
];
return (

View File

@ -1,10 +1,15 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Bell, User } from 'lucide-react';
import { useLocation, Link } from 'react-router-dom';
export interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
export default function Header({ className, ...props }: HeaderProps) {
const location = useLocation();
const isHomePage = location.pathname === '/';
return (
<header
className={cn(
@ -14,25 +19,44 @@ export default function Header({ className, ...props }: HeaderProps) {
{...props}
>
<div className="flex items-center gap-4 md:gap-10">
<div
<Link
to="/"
className={cn('text-[20px] font-normal tracking-[-1.60px] text-black sm:text-[24px] md:text-[32px]')}
style={{ fontFamily: "'Offside-Regular', Helvetica" }}
>
WorLabel
</div>
</Link>
{!isHomePage && (
<nav className="hidden items-center gap-5 md:flex">
<div className={cn('text-color-text-default-default', 'font-body-strong', 'text-sm sm:text-base md:text-lg')}>
<Link
to="/browse"
className={cn('text-color-text-default-default', 'font-body-strong', 'text-sm sm:text-base md:text-lg')}
>
workspace
</div>
<div className={cn('text-color-text-default-default', 'font-body', 'text-sm sm:text-base md:text-lg')}>
</Link>
<Link
to="/workspace"
className={cn('text-color-text-default-default', 'font-body', 'text-sm sm:text-base md:text-lg')}
>
labeling
</div>
</Link>
<Link
to="/admin/1"
className={cn('text-color-text-default-default', 'font-body', 'text-sm sm:text-base md:text-lg')}
>
admin
</Link>
</nav>
)}
</div>
{!isHomePage && (
<div className="flex items-center gap-4 md:gap-5">
<Bell className="h-4 w-4 text-black sm:h-5 sm:w-5" />
<User className="h-4 w-4 text-black sm:h-5 sm:w-5" />
</div>
)}
</header>
);
}

View File

@ -0,0 +1,35 @@
import '@/index.css';
import { Meta, StoryObj } from '@storybook/react';
import Home from '.';
import { useState } from 'react';
const HomeWrapper = ({ initialLoggedIn }: { initialLoggedIn: boolean }) => {
const [isLoggedIn, setIsLoggedIn] = useState(initialLoggedIn);
return (
<Home
isLoggedIn={isLoggedIn}
setIsLoggedIn={setIsLoggedIn}
/>
);
};
const meta: Meta<typeof Home> = {
title: 'Components/Home',
component: Home,
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof Home>;
export const GoogleLogin: Story = {
render: () => <HomeWrapper initialLoggedIn={false} />,
};
export const SelectWorkspace: Story = {
render: () => <HomeWrapper initialLoggedIn={true} />,
};

View File

@ -0,0 +1,91 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue, SelectGroup } from '../ui/select';
import GoogleLogo from '@/assets/icons/web_neutral_rd_ctn@1x.png';
interface HomeProps {
isLoggedIn?: boolean;
setIsLoggedIn?: (loggedIn: boolean) => void;
}
export default function Home({ isLoggedIn = false, setIsLoggedIn }: HomeProps) {
const [, setSelectedWorkspace] = useState('');
const [loggedIn, setLoggedIn] = useState(isLoggedIn);
const navigate = useNavigate();
const workspaces = [
{ id: 1, name: 'Workspace 1' },
{ id: 2, name: 'Workspace 2' },
{ id: 3, name: 'Workspace 3' },
];
const handleGoogleSignIn = () => {
console.log('구글로 계속하기');
setLoggedIn(true);
if (setIsLoggedIn) {
setIsLoggedIn(true);
}
};
const handleWorkspaceSelect = (value: string) => {
const selected = workspaces.find((workspace) => workspace.name === value);
if (selected) {
navigate(`/browse/${selected.id}`);
}
setSelectedWorkspace(value);
};
return (
<div className="flex h-full flex-col items-center justify-center bg-gray-50 p-8">
<div className="mb-6 max-w-xl rounded-lg bg-white p-6 shadow-lg">
<h2 className="mb-4 text-2xl font-bold text-gray-900"> </h2>
<p className="mb-4 text-base text-gray-700">
(AI)
. .
</p>
<p className="mb-4 text-base text-gray-700">
.
Auto Labeler를 , .
</p>
<p className="mb-4 text-base text-gray-700">
Auto Labeler는 , .
, .
</p>
<p className="text-base text-gray-700">
.
</p>
</div>
{!loggedIn ? (
<button
onClick={handleGoogleSignIn}
className="mb-4 transition hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-gray-300 active:opacity-80"
>
<img
src={GoogleLogo}
alt="Sign in with Google"
className="h-auto w-full"
/>
</button>
) : (
<Select onValueChange={handleWorkspaceSelect}>
<SelectTrigger className="mb-4 w-72">
<SelectValue placeholder="워크스페이스를 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{workspaces.map((workspace) => (
<SelectItem
key={workspace.id}
value={workspace.name}
>
{workspace.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</div>
);
}

View File

@ -0,0 +1,19 @@
import '@/index.css';
import { Meta, StoryObj } from '@storybook/react';
import PageLayout from '.';
const meta: Meta<typeof PageLayout> = {
title: 'Layout/PageLayout',
component: PageLayout,
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof PageLayout>;
export const Default: Story = {
render: () => <PageLayout></PageLayout>,
};

View File

@ -3,11 +3,11 @@ import ReviewItem from './ReviewItem';
import ReviewSearchInput from './ReviewSearchInput';
interface ReviewListProps {
acceptedCount: number;
rejectedCount: number;
pendingCount: number;
totalCount: number;
items: {
acceptedCount?: number;
rejectedCount?: number;
pendingCount?: number;
totalCount?: number;
items?: {
title: string;
createdTime: string;
creatorName: string;
@ -24,18 +24,53 @@ const typeColors: Record<'Classification' | 'Detection' | 'Polygon' | 'Polyline'
Polyline: '#c5f9d4',
};
const defaultItems: ReviewListProps['items'] = [
{
title: '리뷰 항목 1',
createdTime: '2024-09-09T10:00:00Z',
creatorName: '사용자 1',
project: '프로젝트 A',
type: 'Classification',
status: 'needs_review',
},
{
title: '리뷰 항목 2',
createdTime: '2024-09-08T14:30:00Z',
creatorName: '사용자 2',
project: '프로젝트 B',
type: 'Detection',
status: 'completed',
},
{
title: '리뷰 항목 3',
createdTime: '2024-09-07T08:45:00Z',
creatorName: '사용자 3',
project: '프로젝트 C',
type: 'Polygon',
status: 'in_progress',
},
{
title: '리뷰 항목 4',
createdTime: '2024-09-06T10:20:00Z',
creatorName: '사용자 4',
project: '프로젝트 D',
type: 'Polyline',
status: 'pending',
},
];
export default function ReviewList({
acceptedCount,
rejectedCount,
pendingCount,
totalCount,
items,
acceptedCount = 1,
rejectedCount = 1,
pendingCount = 1,
totalCount = 3,
items = defaultItems,
}: ReviewListProps): JSX.Element {
const [activeTab, setActiveTab] = useState('pending');
const [searchQuery, setSearchQuery] = useState('');
const [sortValue, setSortValue] = useState('latest');
const filteredItems = items
const filteredItems = (items ?? [])
.filter((item) => {
if (activeTab === 'pending') return item.status.toLowerCase() === 'needs_review';
if (activeTab === 'accepted') return item.status.toLowerCase() === 'completed';

View File

@ -2,17 +2,9 @@ import { useParams } from 'react-router-dom';
import ProjectCard from '@/components/ProjectCard';
import { Button } from '@/components/ui/button';
import { Workspace } from '@/types';
import { Plus, Smile, Users } from 'lucide-react';
import { Plus, Smile } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom';
import WorkSpaceCreateForm from '../WorkSpaceCreateModal/WorkSpaceCreateForm';
import MemberManageForm from '../MemberManageModal/MemberManageForm';
type Role = 'admin' | 'editor' | 'viewer';
interface Member {
email: string;
role: Role;
}
export default function WorkspaceBrowseDetail() {
const { workspaceId } = useParams<{ workspaceId: string }>();
@ -27,78 +19,20 @@ export default function WorkspaceBrowseDetail() {
id: numericWorkspaceId,
name: `workspace-${workspaceId}`,
projects: [
{
id: 1,
name: 'project1',
type: 'Detection',
children: [],
},
{
id: 2,
name: 'project2',
type: 'Detection',
children: [],
},
{
id: 3,
name: 'project3',
type: 'Detection',
children: [],
},
{
id: 4,
name: 'project4',
type: 'Detection',
children: [],
},
{
id: 5,
name: 'project5',
type: 'Detection',
children: [],
},
{ id: 1, name: 'project1', type: 'Detection', children: [] },
{ id: 2, name: 'project2', type: 'Detection', children: [] },
{ id: 3, name: 'project3', type: 'Detection', children: [] },
{ id: 4, name: 'project4', type: 'Detection', children: [] },
{ id: 5, name: 'project5', type: 'Detection', children: [] },
],
};
const members: Array<Member> = [
{ email: 'admin1@example.com', role: 'admin' },
{ email: 'admin2@example.com', role: 'admin' },
{ email: 'viewer3@example.com', role: 'viewer' },
{ email: 'editor1@example.com', role: 'editor' },
{ email: 'editor2@example.com', role: 'editor' },
{ email: 'editor3@example.com', role: 'editor' },
{ email: 'editor4@example.com', role: 'editor' },
];
return (
<div className="flex h-full w-full flex-col gap-8 px-6 py-4">
<div className="flex items-center justify-center">
<h1 className="small-title flex grow">{workspaceId ? workspace.name : ''}</h1>
<div className="flex flex-col">
<div className="flex gap-3">
{workspaceId ? (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<div className="body flex items-center gap-2">
<Users size={16} />
<span> </span>
</div>
</Button>
</DialogTrigger>
<DialogContent className="lg:max-w-xl">
<DialogHeader title="멤버 관리" />
<MemberManageForm
onSubmit={(data) => {
console.log(data);
}}
members={members}
/>
</DialogContent>
</Dialog>
) : (
<></>
)}
<Dialog>
<DialogTrigger asChild>
<Button

View File

@ -0,0 +1,23 @@
import '@/index.css';
import { Meta, StoryObj } from '@storybook/react';
import WorkspaceBrowseLayout from '.';
const meta: Meta<typeof WorkspaceBrowseLayout> = {
title: 'Layout/WorkspaceBrowseLayout',
component: WorkspaceBrowseLayout,
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof WorkspaceBrowseLayout>;
export const Default: Story = {
render: () => <WorkspaceBrowseLayout />,
};
export const Empty: Story = {
render: () => <WorkspaceBrowseLayout />,
};

View File

@ -1,7 +1,6 @@
import { Suspense } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import Header from '../Header';
import Footer from '../Footer';
import { Workspace } from '@/types';
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom';
import { Plus } from 'lucide-react';
@ -60,7 +59,8 @@ export default function WorkspaceBrowseLayout() {
</DialogContent>
</Dialog>
</div>
{workspaces.map((workspace) => (
{workspaces.length > 0 ? (
workspaces.map((workspace) => (
<NavLink
to={`/browse/${workspace.id}`}
key={workspace.id}
@ -68,7 +68,10 @@ export default function WorkspaceBrowseLayout() {
>
{workspace.name}
</NavLink>
))}
))
) : (
<p className="text-gray-500"> .</p>
)}
</div>
<div className="flex w-[calc(100%-280px)] flex-col gap-24">
<Suspense fallback={<div></div>}>
@ -76,7 +79,6 @@ export default function WorkspaceBrowseLayout() {
<Outlet />
</main>
</Suspense>
<Footer className="mt-0" />
</div>
</div>
</div>

View File

@ -1,13 +1,19 @@
import PageLayout from '@/components/PageLayout';
import Home from '@/components/Home';
import WorkspaceBrowseDetail from '@/components/WorkspaceBrowseDetail';
import WorkspaceBrowseLayout from '@/components/WorkspaceBrowseLayout';
import WorkspaceLayout from '@/components/WorkspaceLayout';
import AdminLayout from '@/components/AdminLayout';
import ReviewList from '@/components/ReviewList';
import AdminMemberManage from '@/components/AdminMemberManage';
import { createBrowserRouter } from 'react-router-dom';
import { Navigate } from 'react-router-dom';
export const webPath = {
home: () => '/',
browse: () => '/browse',
workspace: () => '/workspace',
admin: (id: string) => `/admin/${id}`,
};
const router = createBrowserRouter([
@ -17,7 +23,7 @@ const router = createBrowserRouter([
children: [
{
index: true,
element: <div>home</div>,
element: <Home />,
},
],
},
@ -45,6 +51,24 @@ const router = createBrowserRouter([
},
],
},
{
path: webPath.admin(':id'),
element: <AdminLayout />,
children: [
{
index: true,
element: <Navigate to="review" />,
},
{
path: 'review',
element: <ReviewList />,
},
{
path: 'members',
element: <AdminMemberManage />,
},
],
},
]);
export default router;