diff --git a/eslint.config.ts b/eslint.config.ts index 03a898c..af5eecc 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -52,5 +52,6 @@ export default tseslint.config(sheriff(sheriffOptions), { '@typescript-eslint/explicit-module-boundary-types': 'off', 'fsecond/prefer-destructured-optionals': 'off', '@typescript-eslint/no-misused-spread': 'off', + '@typescript-eslint/prefer-nullish-coalescing': 'off', }, }); diff --git a/src/components/mydashboard/create-newboard-modal.tsx b/src/components/mydashboard/create-newboard-modal.tsx index 5699013..7ed8141 100644 --- a/src/components/mydashboard/create-newboard-modal.tsx +++ b/src/components/mydashboard/create-newboard-modal.tsx @@ -2,12 +2,14 @@ import { type ReactNode, useState } from 'react'; import CreateNewboardForm from '@/components/mydashboard/create-newboard-form'; import type { CreateNewboardFormData } from '@/components/mydashboard/type'; import ButtonModal from '@/components/ui/modal/modal-button'; +import { useMutate } from '@/hooks/useAsync'; import { useModalKeyHandler } from '@/hooks/useModal'; +import { createDashBoard } from '@/lib/dashboards/api'; interface CreateNewboardModalProps { isOpen: boolean; onClose: () => void; - onSubmit: (formData: CreateNewboardFormData) => void; + onSubmit?: (formData: CreateNewboardFormData) => void; } export default function CreateNewboardModal({ @@ -19,7 +21,9 @@ export default function CreateNewboardModal({ title: '', color: 'green', // 기본 색상 설정 }); - + const { mutate } = useMutate({ + asyncFunction: () => createDashBoard(formData), + }); const handleClose = () => { setFormData({ title: '', color: 'green' }); onClose(); @@ -27,11 +31,15 @@ export default function CreateNewboardModal({ useModalKeyHandler(isOpen, handleClose); + // const handleSubmit = () => { + // onSubmit(formData); + // handleClose(); + // }; + const handleSubmit = () => { - onSubmit(formData); - handleClose(); + mutate(); + onClose(); }; - const isSubmitDisabled = !formData.title.trim(); return ( diff --git a/src/components/ui/dashboard-header/header-dropdown.tsx b/src/components/ui/dashboard-header/header-profile-dropdown.tsx similarity index 78% rename from src/components/ui/dashboard-header/header-dropdown.tsx rename to src/components/ui/dashboard-header/header-profile-dropdown.tsx index cbd1727..eaaed4b 100644 --- a/src/components/ui/dashboard-header/header-dropdown.tsx +++ b/src/components/ui/dashboard-header/header-profile-dropdown.tsx @@ -1,20 +1,15 @@ import { useRouter } from 'next/router'; import { type ReactNode, useCallback, useEffect } from 'react'; -import ChipProfile, { - type ChipProfileProps, -} from '@/components/ui/chip/chip-profile'; +import ChipProfile 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 { +export default function HeaderProfileDropdwon({ + myNickname, +}: { + myNickname: string; +}): ReactNode { + const profileColor = 'yellow'; + const profileLabel = myNickname.slice(0, 1); const router = useRouter(); const handleMyPageButton = useCallback(() => { @@ -46,11 +41,11 @@ export default function HeaderDropdown({ - {nickname} + {myNickname} getMyInfo(), + }); + const { data: dashboardData, refetch } = useFetch({ + asyncFunction: () => getDashBoard(Number(dashboardId)), + deps: [dashboardId], + immediate: false, + }); + const isMyDashboard = dashboardId && dashboardData?.createdByMe; + + useEffect(() => { + if (dashboardId) { + refetch(); + } + }, [dashboardId, refetch]); const handleOpenModal = () => { setIsModalOpen(true); @@ -24,17 +47,21 @@ export default function DashboardHeader(): ReactNode { handleCloseModal(); }; + const title = pathnameToTitle(router.pathname); + return ( - + - 내 대시보드 - + {title ?? dashboardData?.title} + {isMyDashboard && ( + + )} @@ -56,18 +83,18 @@ export default function DashboardHeader(): ReactNode { - - + {dashboardId && myInfo && ( + + )} + {myInfo && } - + + + ); @@ -99,3 +126,18 @@ function AddBoxIcon() { ); } + +const pathnameToTitle = (pathname: string) => { + switch (pathname) { + case '/mydashboard': { + return '나의 대시보드'; + } + case '/mypage': { + return '계정관리'; + } + default: { + return null; + break; + } + } +}; diff --git a/src/components/ui/dashboard-header/profile-list.tsx b/src/components/ui/dashboard-header/profile-list.tsx index 9a07729..8d2d593 100644 --- a/src/components/ui/dashboard-header/profile-list.tsx +++ b/src/components/ui/dashboard-header/profile-list.tsx @@ -1,21 +1,54 @@ import type { ReactNode } from 'react'; import ChipProfile from '@/components/ui/chip/chip-profile'; +import { useFetch } from '@/hooks/useAsync'; +import useIsBreakPoint from '@/hooks/useIsBreakPoint'; +import { getMemberList } from '@/lib/members/api'; + +export default function ProfileList({ + dashboardId, + myId, +}: { + dashboardId: string; + myId: number; +}): ReactNode { + const { data, loading, error } = useFetch({ + asyncFunction: () => getMemberList({ dashboardId: Number(dashboardId) }), + deps: [dashboardId], + }); + const isTablet = useIsBreakPoint(80); + + if (!data || error) { + return null; + } + const maxDisplayLength = isTablet ? 2 : 4; + const excessNumber = data.totalCount - maxDisplayLength; -export default function ProfileList(): ReactNode { return ( - {Array(2) - .fill('0') - .map((num: number) => { - return ( - - - - ); - })} - - - + {data.members.slice(0, maxDisplayLength).map((member) => { + if (member.userId === myId) { + return; + } + + return ( + + + + ); + })} + {excessNumber > 0 && ( + + + + )} ); } diff --git a/src/components/ui/modal/modal-portal.tsx b/src/components/ui/modal/modal-portal.tsx new file mode 100644 index 0000000..ac3f3c0 --- /dev/null +++ b/src/components/ui/modal/modal-portal.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +export default function Portal({ children }: { children: ReactNode }) { + const element = + typeof window !== 'undefined' && document.querySelector('#modal-portal'); + + return element && children ? createPortal(children, element) : null; +} diff --git a/src/components/ui/side-menu/dashboard-list.tsx b/src/components/ui/side-menu/dashboard-list.tsx index 2360cb5..047a003 100644 --- a/src/components/ui/side-menu/dashboard-list.tsx +++ b/src/components/ui/side-menu/dashboard-list.tsx @@ -1,47 +1,72 @@ import Image from 'next/image'; +import { useRouter } from 'next/router'; import type { ReactNode } from 'react'; +import Dot from '@/components/ui/side-menu/dot'; +import type { DashboardType } from '@/lib/dashboards/type'; import { cn } from '@/utils/cn'; +import type { dashboardColorType } from '@/utils/dashboard-color'; +import { getStringFromQuery } from '@/utils/getContextQuery'; -export default function DashboardList(): ReactNode { - const selectedId = 0; +interface DashboardListProps { + dashboards: DashboardType[]; +} +export default function DashboardList({ + dashboards, +}: DashboardListProps): ReactNode { + const router = useRouter(); + const dashboardId = getStringFromQuery(router.query, 'dashboardId'); + const handleClickItem = (id: number) => { + router.push(`/dashboard/${String(id)}`); + }; return ( - {Array(10) - .fill('') - .map((num, i) => { - return ( - - { - console.log('todo'); - }} + {dashboards.map((dashboard) => { + return ( + + { + handleClickItem(dashboard.id); + }} + > + - - - 코드잇 + + + + + {dashboard.title.length > 20 + ? `${dashboard.title.slice(0, 20)}...` + : dashboard.title} + + {dashboard.createdByMe && ( - - - - ); - })} + )} + + + + ); + })} ); } +const hexToClassName = { + '#7AC555': '*:fill-green', + '#760DDE': '*:fill-purple', + '#FFA500': '*:fill-orange', + '#76A5EA': '*:fill-blue', + '#E876EA': '*:fill-pink', +}; diff --git a/src/components/ui/side-menu/dot.tsx b/src/components/ui/side-menu/dot.tsx new file mode 100644 index 0000000..2a725b0 --- /dev/null +++ b/src/components/ui/side-menu/dot.tsx @@ -0,0 +1,13 @@ +export default function Dot() { + return ( + + + + ); +} diff --git a/src/components/ui/side-menu/index.tsx b/src/components/ui/side-menu/index.tsx index c336819..bba294b 100644 --- a/src/components/ui/side-menu/index.tsx +++ b/src/components/ui/side-menu/index.tsx @@ -1,15 +1,57 @@ import Image from 'next/image'; import Link from 'next/link'; -import type { ReactNode } from 'react'; +import { type ReactNode, useState } from 'react'; +import CreateNewboardModal from '@/components/mydashboard/create-newboard-modal'; import ButtonPagination from '@/components/ui/button/button-pagination'; +import ModalPortal from '@/components/ui/modal/modal-portal'; import DashboardList from '@/components/ui/side-menu/dashboard-list'; +import { useFetch } from '@/hooks/useAsync'; +import { getDashBoardList } from '@/lib/dashboards/api'; export default function SideMenu(): ReactNode { + const [isModalOpen, setIsModalOpen] = useState(false); + const [page, setPage] = useState(1); + const pageSize = 12; + const { + data: dashboardListData, + loading, + error, + } = useFetch({ + asyncFunction: () => { + return getDashBoardList({ + navigationMethod: 'pagination', + cursorId: 0, + page, + size: pageSize, + }); + }, + deps: [page], + }); + + if (!dashboardListData || error) { + return null; + } + const pageCount = Math.ceil(dashboardListData.totalCount / pageSize); + const isPrevButtonDisabled = page <= 1; + const isNextButtonDisabled = pageCount === page; const handleClickPrev = () => { - console.log('click'); + if (isPrevButtonDisabled) { + return; + } + setPage((prev) => prev - 1); }; const handleClickNext = () => { - console.log('click'); + if (isNextButtonDisabled) { + return; + } + setPage((prev) => prev + 1); + }; + const handleOpenModal = () => { + setIsModalOpen(true); + }; + + const handleCloseModal = () => { + setIsModalOpen(false); }; return ( @@ -40,7 +82,7 @@ export default function SideMenu(): ReactNode { DashBoards - + - + + + + ); } diff --git a/src/hooks/useAsync.ts b/src/hooks/useAsync.ts new file mode 100644 index 0000000..0f9ebf1 --- /dev/null +++ b/src/hooks/useAsync.ts @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from 'react'; + +interface useFetchParams { + asyncFunction: () => Promise; + deps?: unknown[]; + immediate?: boolean; +} +interface fetchStateInterface { + loading: boolean; + data: T | null; + error: Error | null; +} +export default function useAsync({ + asyncFunction, + deps = [], + immediate = false, +}: useFetchParams) { + const [state, setState] = useState>({ + data: null, + loading: false, + error: null, + }); + + const asyncFnRef = useRef(asyncFunction); + + useEffect(() => { + asyncFnRef.current = asyncFunction; + }, [asyncFunction]); + + const refetchRef = useRef<() => Promise | null>(null); + + if (refetchRef.current === null) { + refetchRef.current = async () => { + setState((prev) => ({ ...prev, error: null, loading: true })); + try { + const response = await asyncFnRef.current(); + + setState((prev) => ({ ...prev, loading: false, data: response })); + } catch (error) { + if (error instanceof Error) { + setState({ data: null, loading: false, error }); + } + } + }; + } + + useEffect(() => { + if (refetchRef.current === null) { + return; + } + if (immediate) { + refetchRef.current(); + } + }, [immediate, ...deps]); + + return { ...state, refetch: refetchRef.current }; +} + +export function useFetch({ + asyncFunction, + deps = [], + immediate = true, +}: useFetchParams) { + return useAsync({ + asyncFunction, + immediate, + deps, + }); +} + +export function useMutate({ asyncFunction, deps = [] }: useFetchParams) { + const { + data, + loading, + error, + refetch: mutate, + } = useAsync({ + asyncFunction, + immediate: false, + deps, + }); + + return { data, loading, error, mutate }; +} diff --git a/src/hooks/useIsBreakPoint.ts b/src/hooks/useIsBreakPoint.ts new file mode 100644 index 0000000..3744206 --- /dev/null +++ b/src/hooks/useIsBreakPoint.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; +/** + * 화면 사이즈가 지정한 breakpoint(rem 기준) 아래로 내려가면, isBreakPoint 상태가 변화하는 hook + * + * // @custom-variant tablet (@media (max-width: 80rem)); + * // @custom-variant mobile (@media (max-width: 48rem)); + */ +const useIsBreakPoint = (breakpoint = 48) => { + const [isBreakPoint, setIsBreakPoint] = useState(false); + + useEffect(() => { + const media = window.matchMedia(`(max-width: ${String(breakpoint)}rem)`); + + setIsBreakPoint(media.matches); + + const handleMediaChange = () => { + setIsBreakPoint(media.matches); + }; + + media.addEventListener('change', handleMediaChange); + + return () => { + media.removeEventListener('change', handleMediaChange); + }; + }, [breakpoint]); + + return isBreakPoint; +}; + +export default useIsBreakPoint; diff --git a/src/lib/cards/type.ts b/src/lib/cards/type.ts index ed98509..7317b51 100644 --- a/src/lib/cards/type.ts +++ b/src/lib/cards/type.ts @@ -7,7 +7,7 @@ export const cardSchema = z.object({ tags: z.array(z.string()), dueDate: z.string(), assignee: z.object({ - profileImageUrl: z.string(), + profileImageUrl: z.union([z.string(), z.null()]), nickname: z.string(), id: z.number(), }), @@ -18,7 +18,7 @@ export const cardSchema = z.object({ updatedAt: z.string(), }); export const cardListSchema = z.object({ - cursorId: z.number(), + cursorId: z.union([z.number(), z.null()]), totalCount: z.number(), cards: z.array(cardSchema), }); diff --git a/src/lib/comments/type.ts b/src/lib/comments/type.ts index 97d49aa..900d040 100644 --- a/src/lib/comments/type.ts +++ b/src/lib/comments/type.ts @@ -7,13 +7,13 @@ export const commentSchema = z.object({ updatedAt: z.string(), cardId: z.number(), author: z.object({ - profileImageUrl: z.string(), + profileImageUrl: z.union([z.string(), z.null()]), nickname: z.string(), id: z.number(), }), }); export const commentListSchema = z.object({ - cursorId: z.number(), + cursorId: z.union([z.number(), z.null()]), comments: z.array(commentSchema), }); export const deleteSchema = z.object(); diff --git a/src/lib/dashboards/type.ts b/src/lib/dashboards/type.ts index 3342fcd..19e9ba5 100644 --- a/src/lib/dashboards/type.ts +++ b/src/lib/dashboards/type.ts @@ -8,7 +8,7 @@ export const dashboardSchema = z.object({ createdByMe: z.boolean(), }); export const dashboardListSchema = z.object({ - cursorId: z.number(), + cursorId: z.union([z.number(), z.null()]), totalCount: z.number(), dashboards: z.array(dashboardSchema), }); diff --git a/src/lib/invitations/type.ts b/src/lib/invitations/type.ts index c858e25..2431242 100644 --- a/src/lib/invitations/type.ts +++ b/src/lib/invitations/type.ts @@ -23,7 +23,7 @@ export const invitationSchema = z.object({ }); export const invitationListSchema = z.object({ - cursorId: z.number(), + cursorId: z.union([z.number(), z.null()]), invitations: z.array(invitationSchema), }); export type InvitationType = z.infer; diff --git a/src/lib/members/type.ts b/src/lib/members/type.ts index 0f0748e..2fb9793 100644 --- a/src/lib/members/type.ts +++ b/src/lib/members/type.ts @@ -7,7 +7,7 @@ export const memberListSchema = z.object({ userId: z.number(), email: z.string(), nickname: z.string(), - profileImageUrl: z.string(), + profileImageUrl: z.union([z.string(), z.null()]), createdAt: z.string(), updatedAt: z.string(), isOwner: z.boolean(), diff --git a/src/lib/users/type.ts b/src/lib/users/type.ts index ae43c5d..b15e2d9 100644 --- a/src/lib/users/type.ts +++ b/src/lib/users/type.ts @@ -28,7 +28,7 @@ export const updateMyInfoSchema = z.object({ }); export const uploadProfileImageSchema = z.object({ - profileImageUrl: z.string(), + profileImageUrl: z.union([z.string(), z.null()]), }); export type UserType = z.infer; diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx index 3c15be4..81c4e6a 100644 --- a/src/pages/_document.tsx +++ b/src/pages/_document.tsx @@ -7,6 +7,7 @@ export default function Document(): ReactNode { + diff --git a/src/pages/mydashboard/index.tsx b/src/pages/mydashboard/index.tsx index f8524be..bfe6c55 100644 --- a/src/pages/mydashboard/index.tsx +++ b/src/pages/mydashboard/index.tsx @@ -6,6 +6,7 @@ import { type ReactNode, useState } from 'react'; import DashboardLayout from '@/components/layout/dashboard-layout'; import CreateNewboardModal from '@/components/mydashboard/create-newboard-modal'; import type { CreateNewboardFormData } from '@/components/mydashboard/type'; +import ModalPortal from '@/components/ui/modal/modal-portal'; import { createDashBoard } from '@/lib/dashboards/api'; import type { InvitationType } from '@/lib/dashboards/type'; import type { InvitationListType } from '@/lib/invitations/type'; @@ -306,11 +307,13 @@ export default function Mydashboard({ - + + + > ); } diff --git a/src/utils/dashboard-color.ts b/src/utils/dashboard-color.ts new file mode 100644 index 0000000..690e23f --- /dev/null +++ b/src/utils/dashboard-color.ts @@ -0,0 +1,20 @@ +export type dashboardColorType = + | '#7AC555' + | '#760DDE' + | '#FFA500' + | '#76A5EA' + | '#E876EA'; +export const colorToHex = { + green: '#7AC555', + purple: '#760DDE', + orange: '#FFA500', + blue: '#76A5EA', + pink: '#E876EA', +}; +export const hexToColor = { + '#7AC555': 'green', + '#760DDE': 'purple', + '#FFA500': 'orange', + '#76A5EA': 'blue', + '#E876EA': 'pink', +};