diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 36d6cece7..081633e86 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -26,6 +26,7 @@ const preview: Preview = { loaders: [mswLoader], decorators: [ (Story) => { + localStorage.setItem('clubId', '1'); return ( diff --git a/frontend/src/components/dashboard/DashboardSidebar/DashboardSidebar.stories.tsx b/frontend/src/components/dashboard/DashboardSidebar/DashboardSidebar.stories.tsx index 53b06edf3..4941486c9 100644 --- a/frontend/src/components/dashboard/DashboardSidebar/DashboardSidebar.stories.tsx +++ b/frontend/src/components/dashboard/DashboardSidebar/DashboardSidebar.stories.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import { reactRouterParameters, withRouter } from 'storybook-addon-remix-react-router'; import type { Meta, StoryObj } from '@storybook/react'; import DashboardSidebar from '.'; @@ -23,47 +25,89 @@ const meta: Meta = { options: [ { text: '첫번째 옵션', - isSelected: true, + isSelected: false, dashboardId: '1', applyFormId: '10', + status: { + isClosed: true, + isPending: false, + isOngoing: false, + status: 'Closed', + }, }, { text: '두번째 옵션', isSelected: false, dashboardId: '2', applyFormId: '11', + status: { + isClosed: true, + isPending: false, + isOngoing: false, + status: 'Closed', + }, + }, + { + text: '세번째 옵션', + isSelected: true, + dashboardId: '2', + applyFormId: '12', + status: { + isClosed: false, + isPending: false, + isOngoing: true, + status: 'Ongoing', + }, + }, + { + text: '네번째 옵션', + isSelected: false, + dashboardId: '2', + applyFormId: '13', + status: { + isClosed: false, + isPending: true, + isOngoing: false, + status: 'Pending', + }, }, ], }, tags: ['autodocs'], decorators: [ withRouter, - (Child) => ( -
- -
- ), + (Child, context) => { + const [isOpen, setIsOpen] = useState(true); + + const handleToggle = () => { + if (isOpen) setIsOpen(false); + if (!isOpen) setIsOpen(true); + }; + + return ( +
+ +
+ ); + }, ], }; export default meta; type Story = StoryObj; -export const Default: Story = { - args: { - options: [ - { text: '우아한테크코스 6기 프론트엔드', isSelected: true, dashboardId: '1', applyFormId: '10' }, - { text: '우아한테크코스 6기 백엔드', isSelected: false, dashboardId: '2', applyFormId: '11' }, - { text: '우아한테크코스 6기 안드로이드', isSelected: false, dashboardId: '3', applyFormId: '12' }, - ], - }, -}; +export const Default: Story = {}; diff --git a/frontend/src/components/dashboard/DashboardSidebar/index.tsx b/frontend/src/components/dashboard/DashboardSidebar/index.tsx index 9dec33be3..94330f0b5 100644 --- a/frontend/src/components/dashboard/DashboardSidebar/index.tsx +++ b/frontend/src/components/dashboard/DashboardSidebar/index.tsx @@ -1,8 +1,20 @@ +import { useMemo } from 'react'; + import Logo from '@assets/images/logo.svg'; -import Accordion from '@components/_common/molecules/Accordion'; import { routes } from '@router/path'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; + +import type { RecruitmentStatusObject } from '@utils/compareTime'; + +import { Fragment } from 'react/jsx-runtime'; +import { HiChevronDoubleLeft, HiOutlineHome } from 'react-icons/hi2'; +import { HiOutlineMenu } from 'react-icons/hi'; +import { GrDocumentLocked, GrDocumentTime, GrDocumentUser } from 'react-icons/gr'; +import type { IconType } from 'react-icons'; + +import IconButton from '@components/_common/atoms/IconButton'; import LogoutButton from './LogoutButton'; + import S from './style'; interface Option { @@ -10,36 +22,126 @@ interface Option { isSelected: boolean; applyFormId: string; dashboardId: string; + status: RecruitmentStatusObject; +} + +interface SidebarStyle { + isSidebarOpen: boolean; + onClickSidebarToggle: () => void; } interface DashboardSidebarProps { - options: Option[]; + sidebarStyle: SidebarStyle; + options?: Option[]; } -export default function DashboardSidebar({ options }: DashboardSidebarProps) { +export default function DashboardSidebar({ sidebarStyle, options }: DashboardSidebarProps) { + const pendingPosts = useMemo(() => options?.filter(({ status }) => status.isPending), [options]); + const onGoingPosts = useMemo(() => options?.filter(({ status }) => status.isOngoing), [options]); + const closedPosts = useMemo(() => options?.filter(({ status }) => status.isClosed), [options]); + + const sidebarContentList = [ + { title: '모집 예정 공고 목록', posts: pendingPosts }, + { title: '진행 중 공고 목록', posts: onGoingPosts }, + { title: '마감 된 공고 목록', posts: closedPosts }, + ]; + + const location = useLocation(); + + const IconObj: Record = { + Pending: GrDocumentTime, + Ongoing: GrDocumentUser, + Closed: GrDocumentLocked, + }; + return ( - - - - - - - 공고}> - {options.map(({ text, isSelected, applyFormId, dashboardId }, index) => ( - // eslint-disable-next-line react/no-array-index-key - - - {text} - - - ))} - - - - + + + {sidebarStyle.isSidebarOpen && ( + + + + )} + + + + {sidebarStyle.isSidebarOpen ? ( + + ) : ( + + )} + + + + + + + {sidebarStyle.isSidebarOpen && } ); } diff --git a/frontend/src/components/dashboard/DashboardSidebar/style.ts b/frontend/src/components/dashboard/DashboardSidebar/style.ts index 01b9605f0..66cea31f7 100644 --- a/frontend/src/components/dashboard/DashboardSidebar/style.ts +++ b/frontend/src/components/dashboard/DashboardSidebar/style.ts @@ -1,58 +1,129 @@ import styled from '@emotion/styled'; -const Container = styled.div` +const Container = styled.div<{ isSidebarOpen: boolean }>` position: relative; - - width: 20%; - min-width: 25rem; - max-width: 30rem; + width: ${({ isSidebarOpen }) => (isSidebarOpen ? '276px' : '56px')}; + height: 100%; border-right: 1px solid ${({ theme }) => theme.baseColors.grayscale[400]}; padding: 3.6rem 1.6rem; background-color: ${({ theme }) => theme.baseColors.grayscale[50]}; - border-radius: 1.6rem 0 0 1.6rem; display: flex; flex-direction: column; - gap: 3.2rem; + gap: 4rem; +`; + +const SidebarHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + height: 2.4rem; `; const Logo = styled.img` height: 2.4rem; `; -const Contents = styled.nav` +const SidebarToggleIcon = styled.div` + color: ${({ theme }) => theme.baseColors.grayscale[500]}; +`; + +const Contents = styled.ul` display: flex; flex-direction: column; - gap: 1.4rem; + gap: 2rem; `; -const LinkContainer = styled.div<{ isSelected: boolean }>` - ${({ theme }) => theme.typography.common.block} - color: ${({ theme, isSelected }) => (isSelected ? theme.colors.brand.primary : theme.colors.text.default)}; - margin-bottom: 0; +const SidebarItem = styled.li` + display: flex; + align-items: center; + + list-style: none; + height: 2.4rem; +`; +const SidebarItemLink = styled.div<{ isSelected: boolean; isSidebarOpen?: boolean }>` display: flex; - align-items: flex-start; + align-items: center; + justify-content: ${({ isSidebarOpen }) => (isSidebarOpen ? 'left' : 'center')}; + + gap: 1rem; + color: ${({ theme }) => theme.baseColors.grayscale[900]}; + opacity: ${({ isSelected }) => (isSelected ? 0.99 : 0.4)}; - & > button > a { - text-align: start; + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.99; } +`; + +const SidebarItemTextHeader = styled.p` + width: 21rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + ${({ theme }) => theme.typography.common.largeAccent} +`; + +const SidebarItemText = styled.p` + width: 21rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + ${({ theme }) => theme.typography.common.largeBlock} +`; + +const Divider = styled.div` + border-bottom: 0.15rem solid ${({ theme }) => theme.baseColors.grayscale[400]}; +`; + +const ContentSubTitle = styled.div` + height: 2rem; + color: ${({ theme }) => theme.baseColors.grayscale[500]}; + ${({ theme }) => theme.typography.common.smallBlock} + + margin-bottom: -0.6rem; +`; + +const IconContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + + width: 24px; + aspect-ratio: 1/1; +`; + +const Circle = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; &::before { content: '•'; - display: block; - width: 1rem; - aspect-ratio: 1/1; - margin: 0 0.8rem; + scale: 1; } `; const S = { Container, + SidebarHeader, Logo, + SidebarToggleIcon, Contents, - LinkContainer, + SidebarItem, + SidebarItemLink, + SidebarItemTextHeader, + SidebarItemText, + Divider, + ContentSubTitle, + IconContainer, + Circle, }; export default S; diff --git a/frontend/src/hooks/useElementRect/index.tsx b/frontend/src/hooks/useElementRect/index.tsx new file mode 100644 index 000000000..c225662f3 --- /dev/null +++ b/frontend/src/hooks/useElementRect/index.tsx @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export default function useElementRect() { + const ref = useRef(null); + const [rect, setRect] = useState(null); + + const updateRect = useCallback(() => { + if (ref.current) { + setRect(ref.current.getBoundingClientRect()); + } + }, []); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + updateRect(); + + const resizeObserver = new ResizeObserver(() => { + updateRect(); + }); + + resizeObserver.observe(element); + + return () => { + resizeObserver.disconnect(); + }; + }, [updateRect]); + + return [ref, rect] as const; +} diff --git a/frontend/src/pages/DashBoardList/DashboardList.stories.tsx b/frontend/src/pages/DashBoardList/DashboardList.stories.tsx index 29f2f46c0..cef162acf 100644 --- a/frontend/src/pages/DashBoardList/DashboardList.stories.tsx +++ b/frontend/src/pages/DashBoardList/DashboardList.stories.tsx @@ -17,7 +17,7 @@ const meta: Meta = { location: { pathParams: { dashboardId: '1' }, }, - routing: { path: '/dashboardId/:dashboardId' }, + routing: { path: '/dashboardId' }, }), }, tags: ['autodocs'], diff --git a/frontend/src/pages/DashBoardList/style.ts b/frontend/src/pages/DashBoardList/style.ts index 57c75e0ef..448c60bba 100644 --- a/frontend/src/pages/DashBoardList/style.ts +++ b/frontend/src/pages/DashBoardList/style.ts @@ -4,7 +4,7 @@ import { hideScrollBar } from '@styles/utils'; const Container = styled.div` display: flex; flex-direction: column; - padding: 3.8rem 2.4rem; + padding: 3.2rem 1.6rem; gap: 2.4rem; height: 100%; diff --git a/frontend/src/pages/Dashboard/style.ts b/frontend/src/pages/Dashboard/style.ts index 9d813a814..137d1d94c 100644 --- a/frontend/src/pages/Dashboard/style.ts +++ b/frontend/src/pages/Dashboard/style.ts @@ -2,8 +2,8 @@ import styled from '@emotion/styled'; import { hiddenStyles, hideScrollBar, visibleStyles } from '@styles/utils'; const AppContainer = styled.div` - padding: 3.6rem 2rem; - height: 100vh; + padding: 3.2rem 1.6rem; + height: 100%; `; const Header = styled.div` diff --git a/frontend/src/pages/DashboardLayout/DashboardLayout.stories.tsx b/frontend/src/pages/DashboardLayout/DashboardLayout.stories.tsx new file mode 100644 index 000000000..c8b0d9568 --- /dev/null +++ b/frontend/src/pages/DashboardLayout/DashboardLayout.stories.tsx @@ -0,0 +1,34 @@ +import { reactRouterOutlet, reactRouterParameters, withRouter } from 'storybook-addon-remix-react-router'; +import { Meta, StoryObj } from '@storybook/react'; +import DashboardList from '@pages/DashBoardList'; +import Dashboard from '@pages/Dashboard'; + +import DashboardLayout from '.'; + +const meta: Meta = { + title: 'Pages/DashboardLayout', + component: DashboardLayout, + decorators: [withRouter], + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +export const PostListStory: Story = { + parameters: { + reactRouter: reactRouterParameters({ + routing: reactRouterOutlet(), + }), + }, +}; + +export const DashboardStory: Story = { + parameters: { + reactRouter: reactRouterParameters({ + routing: reactRouterOutlet(), + }), + }, +}; diff --git a/frontend/src/pages/DashboardLayout/index.tsx b/frontend/src/pages/DashboardLayout/index.tsx index 0bdc94be5..84c38a1ea 100644 --- a/frontend/src/pages/DashboardLayout/index.tsx +++ b/frontend/src/pages/DashboardLayout/index.tsx @@ -1,31 +1,47 @@ +import { useState } from 'react'; + import DashboardSidebar from '@components/dashboard/DashboardSidebar'; import useGetDashboards from '@hooks/useGetDashboards'; +import useElementRect from '@hooks/useElementRect'; + import { Outlet, useParams } from 'react-router-dom'; +import { getTimeStatus } from '@utils/compareTime'; import S from './style'; export default function DashboardLayout() { const { applyFormId: currentPostId } = useParams(); - const { data, isLoading } = useGetDashboards(); + const { data } = useGetDashboards(); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [ref, rect] = useElementRect(); - if (isLoading) return
Loading...
; - if (!data) return
something wrong
; - - const titleList = data.dashboards.map(({ title, dashboardId, applyFormId }) => ({ + const applyFormList = data?.dashboards.map(({ title, dashboardId, applyFormId, startDate, endDate }) => ({ text: title, isSelected: !!currentPostId && currentPostId === applyFormId, + status: getTimeStatus({ startDate, endDate }), applyFormId, dashboardId, })); + const handleToggleSidebar = () => { + if (isSidebarOpen) setIsSidebarOpen(false); + if (!isSidebarOpen) setIsSidebarOpen(true); + }; + return ( - - - + + + + - - - - - + + + + ); } diff --git a/frontend/src/pages/DashboardLayout/style.ts b/frontend/src/pages/DashboardLayout/style.ts index b3d7b0812..3bdf1b7a5 100644 --- a/frontend/src/pages/DashboardLayout/style.ts +++ b/frontend/src/pages/DashboardLayout/style.ts @@ -1,34 +1,48 @@ import styled from '@emotion/styled'; -const LayoutBg = styled.div` - min-height: 100vh; - min-width: 100vw; - - padding: 2.4rem; - background-color: ${({ theme }) => theme.baseColors.redscale[50]}; +interface SidebarStyleProps { + isSidebarOpen: boolean; + sidebarWidth?: number; +} +const Layout = styled.div` + height: 100vh; + width: 100vw; display: flex; + background-color: ${({ theme }) => theme.baseColors.grayscale[50]}; + + overflow-y: hidden; `; -const Layout = styled.div` +const SidebarContainer = styled.div` display: flex; - flex: 1; - border-radius: 1.6rem; - overflow: hidden; + height: 100%; + width: fit-content; +`; - background-color: ${({ theme }) => theme.baseColors.grayscale[50]}; +const Sidebar = styled.div``; + +const SidebarController = styled.div` + width: ${({ isSidebarOpen }) => (isSidebarOpen ? '0px' : 'fit-content')}; + transform: ${({ isSidebarOpen }) => (isSidebarOpen ? 'translateX(-6rem)' : 'none')}; + z-index: 1; `; -const MainContainer = styled.div` - width: 80%; - height: 100%; - // INFO: 4.8rem = 바깥 컨테이너의 padding-top + padding-bottom - max-height: calc(100vh - 4.8rem); +const ToggleButton = styled.div` + padding: 3.3rem 0rem 0rem 1.6rem; +`; + +const MainContainer = styled.div` + width: ${({ sidebarWidth }) => `calc(100% - ${sidebarWidth}px)`}; + padding-left: ${({ isSidebarOpen }) => isSidebarOpen && '1rem'}; `; const S = { - LayoutBg, Layout, + SidebarContainer, + Sidebar, + SidebarController, + ToggleButton, MainContainer, }; diff --git a/frontend/src/utils/compareTime.ts b/frontend/src/utils/compareTime.ts index a14ac8e3e..675cfad1c 100644 --- a/frontend/src/utils/compareTime.ts +++ b/frontend/src/utils/compareTime.ts @@ -6,7 +6,7 @@ interface GetRecruitmentStatusProps { endDate: string; } -interface RecruitmentStatusObject { +export interface RecruitmentStatusObject { status: RecruitmentStatusType; isPending: boolean; isOngoing: boolean;