diff --git a/eslint.config.ts b/eslint.config.ts index 02fed17..03a898c 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -35,6 +35,7 @@ export default tseslint.config(sheriff(sheriffOptions), { patterns: ['.*'], }, ], + '@typescript-eslint/no-unused-vars': 'warn', '@typescript-eslint/no-floating-promises': 'off', 'react-refresh/only-export-components': 'off', 'react/jsx-no-useless-fragment': 'off', @@ -46,5 +47,10 @@ export default tseslint.config(sheriff(sheriffOptions), { 'react/no-multi-comp': 'off', '@typescript-eslint/no-use-before-define': 'off', '@typescript-eslint/require-await': 'off', + 'tsdoc/syntax': 'off', + 'fsecond/valid-event-listener': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + 'fsecond/prefer-destructured-optionals': 'off', + '@typescript-eslint/no-misused-spread': 'off', }, }); diff --git a/src/components/ui/chip/chip-profile.tsx b/src/components/ui/chip/chip-profile.tsx index de95c60..4c688a5 100644 --- a/src/components/ui/chip/chip-profile.tsx +++ b/src/components/ui/chip/chip-profile.tsx @@ -26,7 +26,7 @@ export const ChipVariants = cva( }, } ); -interface ChipProps +export interface ChipProfileProps extends HTMLAttributes, VariantProps { children?: ReactNode; @@ -47,7 +47,7 @@ export default function ChipProfile({ size = 'md', color = 'green', ...props -}: ChipProps): ReactNode { +}: ChipProfileProps): ReactNode { return ( <>
diff --git a/src/components/ui/dashboard-header/header-dropdown.tsx b/src/components/ui/dashboard-header/header-dropdown.tsx new file mode 100644 index 0000000..3ad790b --- /dev/null +++ b/src/components/ui/dashboard-header/header-dropdown.tsx @@ -0,0 +1,64 @@ +import { useRouter } from 'next/router'; +import { type ReactNode, useCallback, useEffect } from 'react'; +import ChipProfile, { + type ChipProfileProps, +} from '@/components/ui/chip/chip-profile'; +import Dropdown from '@/components/ui/dropdown'; + +interface HeaderDropdownProps { + nickname: string; + profileLabel: string; + profileColor: ChipProfileProps['color']; +} +export default function HeaderDropdown({ + nickname, + profileLabel, + profileColor, +}: HeaderDropdownProps): ReactNode { + const router = useRouter(); + + const handleMyPageButton = useCallback(() => { + router.push('/mypage'); + }, [router]); + + const handleMyDashboardButton = useCallback(() => { + router.push('/mydashboard'); + }, [router]); + + const handleLogoutButton = useCallback(async () => { + try { + await fetch('/api/logout', { method: 'POST' }); + } catch { + /** + * @todo + */ + } + router.push('/'); + }, [router]); + + useEffect(() => { + router.prefetch('/mypage'); + router.prefetch('/mydashboard'); + }, [router]); + + return ( + + +
+ + {nickname} +
+
+ + 로그아웃 + 내 정보 + + 내 대시보드 + + +
+ ); +} diff --git a/src/components/ui/dashboard-header/index.tsx b/src/components/ui/dashboard-header/index.tsx index 3d22d40..c2d00e1 100644 --- a/src/components/ui/dashboard-header/index.tsx +++ b/src/components/ui/dashboard-header/index.tsx @@ -1,15 +1,9 @@ import Image from 'next/image'; import Link from 'next/link'; -import { useRouter } from 'next/router'; -import { - type ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; -import InviteMemberModal from '@/components/mydashboard/invite-member-modal'; -import ChipProfile from '@/components/ui/chip/chip-profile'; + +import { type ReactNode, useState } from 'react'; +import HeaderDropdown from '@/components/ui/dashboard-header/header-dropdown'; +import InviteMemberModal from '@/components/ui/dashboard-header/invite-member-modal'; import ProfileList from '@/components/ui/dashboard-header/profile-list'; const buttonClass = @@ -17,10 +11,8 @@ const buttonClass = export default function DashboardHeader(): ReactNode { const [isModalOpen, setIsModalOpen] = useState(false); - const router = useRouter(); - const [open, setOpen] = useState(false); - const menuRef = useRef(null); const dashboardId = 1; + const handleOpenModal = () => { setIsModalOpen(true); }; @@ -33,53 +25,6 @@ export default function DashboardHeader(): ReactNode { handleCloseModal(); }; - const toggle = useCallback(() => { - setOpen((v) => !v); - }, []); - const close = useCallback(() => { - setOpen(false); - }, []); - - useEffect(() => { - const handleDocClick = (e: MouseEvent) => { - if (!menuRef.current) { - return; - } - if (!menuRef.current.contains(e.target as Node)) { - setOpen(false); - } - }; - - document.addEventListener('mousedown', handleDocClick); - - return () => { - document.removeEventListener('mousedown', handleDocClick); - }; - }, []); - - // 이재준 작성 - 내 정보 페이지로 이동하는 기능 추가 - const goMyPage = useCallback(() => { - close(); - router.push('/mypage'); - }, [close, router]); - - // 이재준 작성 - 내 대시보드 페이지로 이동하는 기능 추가 - const goMyDashboard = useCallback(() => { - close(); - router.push('/mydashboard'); - }, [close, router]); - - // 이재준 작성 - 로그아웃 기능 추가 - const doLogout = useCallback(async () => { - try { - await fetch('/api/logout', { method: 'POST' }); - } catch { - // 로그아웃 실패 시 무시 - } - close(); - router.push('/'); - }, [close, router]); - return (
@@ -113,14 +58,11 @@ export default function DashboardHeader(): ReactNode {
- - - 권수형 - +
>; +} + +export const DropdownContext = createContext(null); + +export default function Dropdown({ + children, +}: { + children: ReactNode; +}): ReactNode { + const [isOpen, setIsOpen] = useState(false); + const handleClose = () => { + setIsOpen(false); + }; + const dropdownRef = useExitWhenClickOutSide(handleClose); + + return ( + +
+ {children} +
+
+ ); +} + +Dropdown.Toggle = Toggle; +Dropdown.List = List; +Dropdown.Item = Item; diff --git a/src/components/ui/dropdown/item.tsx b/src/components/ui/dropdown/item.tsx new file mode 100644 index 0000000..0ac93ff --- /dev/null +++ b/src/components/ui/dropdown/item.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; + +interface DropdownItemProps { + children: ReactNode; + onClick: () => void; +} +export default function Item({ + children, + onClick, +}: DropdownItemProps): ReactNode { + return ( +
  • + +
  • + ); +} diff --git a/src/components/ui/dropdown/list.tsx b/src/components/ui/dropdown/list.tsx new file mode 100644 index 0000000..85b033e --- /dev/null +++ b/src/components/ui/dropdown/list.tsx @@ -0,0 +1,36 @@ +import { type ReactNode, useContext } from 'react'; +import { DropdownContext } from '@/components/ui/dropdown'; +import { cn } from '@/utils/cn'; + +interface DropdownListProps { + children: ReactNode; + positionClassName?: string; + ariaLabel?: string; +} +export default function List({ + children, + positionClassName, + ariaLabel = '메뉴', +}: DropdownListProps): ReactNode { + const context = useContext(DropdownContext); + + if (!context) { + throw new Error('DropdownContext 내부에서 호출하세요.'); + } + const { isOpen } = context; + + return ( + isOpen && ( +
      + {children} +
    + ) + ); +} diff --git a/src/components/ui/dropdown/toggle.tsx b/src/components/ui/dropdown/toggle.tsx new file mode 100644 index 0000000..1eb2164 --- /dev/null +++ b/src/components/ui/dropdown/toggle.tsx @@ -0,0 +1,32 @@ +import { type ReactNode, useContext } from 'react'; +import { DropdownContext } from '@/components/ui/dropdown'; + +export default function Toggle({ + children, +}: { + children: ReactNode; +}): ReactNode { + const context = useContext(DropdownContext); + + if (!context) { + throw new Error('Dropdown Context 내부에서 호출해야 합니다'); + } + const { isOpen, setIsOpen } = context; + const handleToggle = () => { + setIsOpen((prev) => !prev); + }; + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
    + {children} +
    + ); +} diff --git a/src/hooks/useExitWhenClickOutSide.ts b/src/hooks/useExitWhenClickOutSide.ts new file mode 100644 index 0000000..68408df --- /dev/null +++ b/src/hooks/useExitWhenClickOutSide.ts @@ -0,0 +1,26 @@ +import { type Ref, useEffect, useRef } from 'react'; + +export default function useExitWhenClickOutSide( + callback: () => void +): Ref { + const ref = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (!ref.current) { + return; + } + if (!ref.current.contains(e.target as Node)) { + callback(); + } + }; + + document.addEventListener('click', handleClickOutside); + + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, [callback]); + + return ref; +} diff --git a/src/pages/dashboard/[dashboardId].tsx b/src/pages/dashboard/[dashboardId].tsx deleted file mode 100644 index 2362937..0000000 --- a/src/pages/dashboard/[dashboardId].tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { GetServerSideProps } from 'next'; - -/** - * 이재준 작성 - 인증되지 않은 사용자가 대시보드 페이지에 접근할 때 로그인 페이지로 리다이렉트하기 위해 추가 - */ -export const getServerSideProps: GetServerSideProps = async (context) => { - const { req } = context; - - const accessToken = req.cookies.access_token; - - if (!accessToken) { - return { - redirect: { - destination: `/login`, - permanent: false, - }, - }; - } - - return { props: {} }; -}; - -export default function DashboardPage(): null { - return null; -} diff --git a/src/pages/dashboard/[dashboardId]/edit.tsx b/src/pages/dashboard/[dashboardId]/edit.tsx index d731b83..6fdff5f 100644 --- a/src/pages/dashboard/[dashboardId]/edit.tsx +++ b/src/pages/dashboard/[dashboardId]/edit.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import { type ReactNode, useState } from 'react'; import DashboardLayout from '@/components/layout/dashboard-layout'; -import InviteMemberModal from '@/components/mydashboard/invite-member-modal'; +import InviteMemberModal from '@/components/ui/dashboard-header/invite-member-modal'; export default function MydashboardEdit(): ReactNode { const router = useRouter(); diff --git a/src/stories/Dropdown.stories.tsx b/src/stories/Dropdown.stories.tsx new file mode 100644 index 0000000..1faab6a --- /dev/null +++ b/src/stories/Dropdown.stories.tsx @@ -0,0 +1,72 @@ +import type { ComponentType } from 'react'; +import { fn } from 'storybook/test'; +import { INITIAL_VIEWPORTS, MINIMAL_VIEWPORTS } from 'storybook/viewport'; +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import Dropdown from '@/components/ui/dropdown'; + +const meta = { + title: 'Dropdown', + component: Dropdown, + subcomponents: { + Toggle: Dropdown.Toggle as ComponentType, + List: Dropdown.List as ComponentType, + Item: Dropdown.Item as ComponentType, + }, + decorators: [ + (Story) => { + return ( +
    + +
    + ); + }, + ], + parameters: { + viewport: { ...MINIMAL_VIEWPORTS, ...INITIAL_VIEWPORTS }, + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: {}, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { children: <> }, + parameters: { + docs: { + canvas: { + sourceState: 'shown', + }, + }, + }, + render: () => { + function DropdownStory() { + /** + * 코드 예시: return 괄호 내부 + */ + return ( + + +
    + 내 정보 +
    +
    + + 0}>로그아웃 + 0}>내 정보 + 0}>내 대시보드 + +
    + ); + /* 끝 */ + } + + return ; + }, +}; diff --git a/src/styles/globals.css b/src/styles/globals.css index 9853ef1..5d05acd 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -75,15 +75,6 @@ --color-background: var(--background); --color-foreground: var(--foreground); } - -html, -body { - height: 100%; - margin: 0; - padding: 0; - overflow-x: hidden; -} - @utility flex-center { display: flex; align-items: center; @@ -100,7 +91,9 @@ body { padding: 0; overflow-x: hidden; } - +button { + cursor: pointer; +} .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; diff --git a/src/utils/getContextQuery.ts b/src/utils/getContextQuery.ts new file mode 100644 index 0000000..8abb085 --- /dev/null +++ b/src/utils/getContextQuery.ts @@ -0,0 +1,29 @@ +import type { ParsedUrlQuery } from 'node:querystring'; +/** + * 쿼리 파라미터에서 숫자 ID를 안정적인 타입으로 추출합니다 + */ +export function getNumberFromQuery( + query: ParsedUrlQuery, + key: string +): number | null { + const value = query[key]; + + if (!value || Array.isArray(value)) { + return null; + } + const num = parseInt(value, 10); + + return Number.isNaN(num) ? null : num; +} + +/** + * 쿼리 파라미터에서 문자열을 안정적인 타입으로 추출합니다 + */ +export function getStringFromQuery( + query: ParsedUrlQuery, + key: string +): string | null { + const value = query[key]; + + return typeof value === 'string' ? value : null; +}