From 85cdf20349b4d49db57f9ff1c33cf53f3072b2f9 Mon Sep 17 00:00:00 2001 From: Doeunnkimm Date: Tue, 26 Mar 2024 20:33:59 +0900 Subject: [PATCH 01/21] =?UTF-8?q?chore:=20DATE=5FSAPARATOR=EB=A5=BC=20cons?= =?UTF-8?q?tants=ED=99=94=20=ED=95=B4=EC=84=9C=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/date.ts | 1 + src/constants/index.ts | 1 + src/utils/date.ts | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 src/constants/date.ts diff --git a/src/constants/date.ts b/src/constants/date.ts new file mode 100644 index 00000000..62a4ab4f --- /dev/null +++ b/src/constants/date.ts @@ -0,0 +1 @@ +export const DATE_SEPARATOR = '.'; diff --git a/src/constants/index.ts b/src/constants/index.ts index 925bbd97..af3ca649 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,4 +1,5 @@ export { blueDataURL } from './blurDataURL'; +export * from './date'; export * from './input'; export { META } from './metadata'; export * from './ogImage.size'; diff --git a/src/utils/date.ts b/src/utils/date.ts index f61cd790..94cc4e97 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -1,6 +1,6 @@ -import { HOURS_PER_DAY, MINUTES_PER_HOUR, SECONDS_PER_MINUTE } from '@/constants'; +import { DATE_SEPARATOR, HOURS_PER_DAY, MINUTES_PER_HOUR, SECONDS_PER_MINUTE } from '@/constants'; -export const formatDate = (splitedDate: string[], separator: string) => { +export const formatDate = (splitedDate: string[], separator = DATE_SEPARATOR) => { return splitedDate.join(separator); }; From eb57da4e7ce7a66666ffa311d7dddd69a0e54088 Mon Sep 17 00:00:00 2001 From: Doeunnkimm Date: Tue, 26 Mar 2024 20:35:51 +0900 Subject: [PATCH 02/21] =?UTF-8?q?refactor:=20=EB=B3=80=EA=B2=BD=EB=90=9C?= =?UTF-8?q?=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/goal/components/new/DateForm.tsx | 6 +- .../goal/components/new/DateInput.tsx | 73 ++++++++----------- src/features/goal/utils/date.ts | 19 ++++- src/hooks/index.ts | 1 + src/hooks/useDateInput.ts | 55 ++++++++++++++ 5 files changed, 107 insertions(+), 47 deletions(-) create mode 100644 src/hooks/useDateInput.ts diff --git a/src/features/goal/components/new/DateForm.tsx b/src/features/goal/components/new/DateForm.tsx index 5f87d786..18f06468 100644 --- a/src/features/goal/components/new/DateForm.tsx +++ b/src/features/goal/components/new/DateForm.tsx @@ -4,10 +4,10 @@ import { useController, useFormContext } from 'react-hook-form'; import Link from 'next/link'; import { Button, Span, Typography } from '@/components'; -import { MAX_DATE_LENGTH_UNTIL_MONTH } from '@/constants'; import type { GoalFormValues } from '@/features/goal/types'; import { NEW_GOAL_FORM_ORDERS } from '../../constants'; +import { isValidDateFormat } from '../../utils/date'; import { DateInput } from './DateInput'; import FormHeader from './FormHeader'; @@ -29,12 +29,12 @@ export const DateForm = () => { } body={
- +
} footer={ - + } /> diff --git a/src/features/goal/components/new/DateInput.tsx b/src/features/goal/components/new/DateInput.tsx index 4d9f2515..5f2d1701 100644 --- a/src/features/goal/components/new/DateInput.tsx +++ b/src/features/goal/components/new/DateInput.tsx @@ -1,63 +1,50 @@ 'use client'; -import type { ChangeEvent } from 'react'; -import { useState } from 'react'; +import { useEffect } from 'react'; import { Input, Typography } from '@/components'; -import { MAX_DATE_LENGTH_UNTIL_MONTH } from '@/constants'; - -import { formatDate, isValidDate } from '../../utils/date'; +import { useDateInput } from '@/hooks'; +import { type DateProps, type DateValueProps, typeToMaxLength } from '@/hooks/useDateInput'; +import { formatDate } from '@/utils/date'; interface DateInputProps { labelName?: string; - intitalValue?: string; - maxLength?: number; + initialValue?: DateValueProps; + requireDateType?: DateProps[]; onChange?: (value: string) => void; } -export const DateInput = ({ labelName = '', intitalValue = '', maxLength, onChange }: DateInputProps) => { - const [formattedValue, setFormattedValue] = useState(intitalValue); - const placeholder = maxLength === MAX_DATE_LENGTH_UNTIL_MONTH ? 'YYYY.MM' : 'YYYY.MM.DD'; - - const handleInputChange = (event: ChangeEvent) => { - const inputValue = event.target.value.replace(/\D/g, ''); - let formatted = inputValue; - let year, month, day; - - if (inputValue.length > 4 && inputValue.length <= 6) { - year = inputValue.slice(0, 4); - month = inputValue.slice(4, 6); - month = +month > 12 ? '12' : +month === 0 ? '0' : month; - formatted = formatDate([year, month], '.'); - } else if (inputValue.length > 6) { - year = inputValue.slice(0, 4); - month = inputValue.slice(4, 6); - day = inputValue.slice(6, 8); +export const DateInput = ({ + labelName = '', + initialValue = { YYYY: '', MM: '', DD: '' }, + requireDateType = ['YYYY', 'MM', 'DD'], + onChange, +}: DateInputProps) => { + const { inputRefs, dateValues, handleInputChange, handleInputBlur } = useDateInput({ initialValue }); - if (inputValue.length < 8) { - formatted = formatDate([year, month, day], '.'); - } else { - formatted = isValidDate(year, month, day) - ? formatDate([year, month, day], '.') - : formatDate([year, month], '.'); - } - } - setFormattedValue(formatted); - onChange && onChange(formatted); - }; + useEffect(() => { + onChange && onChange(formatDate(requireDateType.map((type) => dateValues[type]))); + }, [dateValues, onChange, requireDateType]); return (
{labelName} - +
+ {requireDateType.map((type) => ( + handleInputChange(e, type)} + onBlur={(e) => handleInputBlur(e, type)} + /> + ))} +
); }; diff --git a/src/features/goal/utils/date.ts b/src/features/goal/utils/date.ts index 304c523b..696e7370 100644 --- a/src/features/goal/utils/date.ts +++ b/src/features/goal/utils/date.ts @@ -1,4 +1,8 @@ -export const formatDate = (splitedDate: string[], separator: string) => { +import { DATE_SEPARATOR } from '@/constants'; +import type { DateProps } from '@/hooks/useDateInput'; +import { typeToMaxLength } from '@/hooks/useDateInput'; + +export const formatDate = (splitedDate: string[], separator = DATE_SEPARATOR) => { return splitedDate.join(separator); }; @@ -11,3 +15,16 @@ export const isValidDate = (year: string, month: string, day: string) => { return date.getFullYear() === yearNum && date.getMonth() === monthNum && date.getDate() === dayNum; }; + +/** + * + * @param string DATE_SEPARATOR를 기준으로 split했을 때, 모든 아이템이 number여야만, 길이를 만족해야만 true를 반환합니다. + * @param format ex. 'YYYY.MM.DD' + */ +export const isValidDateFormat = (formattedDate = '', format: string) => { + const splittedFormat = format.split(DATE_SEPARATOR) as DateProps[]; + + return formattedDate + .split(DATE_SEPARATOR) + .every((date, index) => !isNaN(+date) && date.length === typeToMaxLength[splittedFormat[index]]); +}; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 49944b95..9294afd9 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,5 @@ export { useAuth } from './useAuth'; +export { useDateInput } from './useDateInput'; export { useDebounceCall } from './useDebounceCall'; export { useDownloadImage } from './useDownloadImage'; export { useFocusInput } from './useFocusInput'; diff --git a/src/hooks/useDateInput.ts b/src/hooks/useDateInput.ts new file mode 100644 index 00000000..c9b15c0b --- /dev/null +++ b/src/hooks/useDateInput.ts @@ -0,0 +1,55 @@ +import type { ChangeEvent, FocusEvent } from 'react'; +import { useRef, useState } from 'react'; + +export type DateProps = 'YYYY' | 'MM' | 'DD'; + +export type DateValueProps = Record; + +interface UseDateInputProps { + initialValue: DateValueProps; +} + +export const typeToMaxLength: Record = { + YYYY: 4, + MM: 2, + DD: 2, +} as const; + +export const useDateInput = ({ initialValue }: UseDateInputProps) => { + const [dateValues, setDateValues] = useState(initialValue); + + const yearInputRef = useRef(null); + const monthInputRef = useRef(null); + const dayInputRef = useRef(null); + + const inputRefs = { + YYYY: yearInputRef, + MM: monthInputRef, + DD: dayInputRef, + }; + + const handleInputChange = (event: ChangeEvent, type: DateProps) => { + let inputValue = event.target.value; + + if (type === 'YYYY' && inputValue.length === typeToMaxLength[type]) { + monthInputRef.current?.focus(); + } + + if (type === 'MM') { + inputValue = +inputValue > 12 ? '12' : inputValue; + if (inputValue.length === typeToMaxLength.MM) dayInputRef.current?.focus(); + } + + setDateValues((prev) => ({ ...prev, [type]: inputValue })); + }; + + const handleInputBlur = (event: FocusEvent, type: DateProps) => { + const inputValue = event.target.value; + + if ((type === 'MM' || type === 'DD') && inputValue.length === 1) { + setDateValues((prev) => ({ ...prev, [type]: `0${inputValue}` })); + } + }; + + return { dateValues, handleInputChange, handleInputBlur, inputRefs }; +}; From ec42fab721097381f7b7e22ae359434d66444a99 Mon Sep 17 00:00:00 2001 From: Doeunnkimm Date: Tue, 26 Mar 2024 22:31:40 +0900 Subject: [PATCH 03/21] =?UTF-8?q?chore:=20=EC=A0=95=EB=B3=B4=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=83=9D=EB=85=84=EC=9B=94=EC=9D=BC=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/my/components/update/UpdateForm.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/features/my/components/update/UpdateForm.tsx b/src/features/my/components/update/UpdateForm.tsx index 726182fc..9b7e1c8f 100644 --- a/src/features/my/components/update/UpdateForm.tsx +++ b/src/features/my/components/update/UpdateForm.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import { useController, useFormContext } from 'react-hook-form'; import { Button } from '@/components'; -import { MAX_DATE_LENGTH_UNTIL_DAY, MAX_NICKNAME_LENGTH, MAX_USERNAME_LENGTH } from '@/constants'; +import { MAX_NICKNAME_LENGTH, MAX_USERNAME_LENGTH } from '@/constants'; import { DateInput } from '@/features/goal/components/new/DateInput'; import { TextInput } from '@/features/goal/components/new/TextInput'; import { useGetMemberData } from '@/hooks/reactQuery/auth'; @@ -26,6 +26,8 @@ export const UpdateForm = () => { const { field: usernameField } = useController({ name: 'username', control }); const isValidBirth = useValidBirth(birthField.value); + const [defaultYYYY, defaultMM, defaultDD] = (memberData?.birth ?? '').split('-'); + const [isDisabledSubmit, setIsDisabledSubmit] = useState(true); useEffect(() => { @@ -85,8 +87,7 @@ export const UpdateForm = () => {
From 484ad32b6551525ce068dce0b638e3657215f55b Mon Sep 17 00:00:00 2001 From: Doeunnkimm Date: Tue, 26 Mar 2024 23:28:38 +0900 Subject: [PATCH 04/21] =?UTF-8?q?chore:=20default=EA=B0=92=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20=EB=B6=99=EB=8A=94=20separator?= =?UTF-8?q?=EB=A5=BC=20=EC=98=88=EC=99=B8=EC=8B=9C=ED=82=A4=EA=B8=B0=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20filter=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/date.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/date.ts b/src/utils/date.ts index 94cc4e97..80c837b2 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -1,7 +1,7 @@ import { DATE_SEPARATOR, HOURS_PER_DAY, MINUTES_PER_HOUR, SECONDS_PER_MINUTE } from '@/constants'; export const formatDate = (splitedDate: string[], separator = DATE_SEPARATOR) => { - return splitedDate.join(separator); + return splitedDate.filter((date) => date !== '').join(separator); }; export const isValidDate = (year: string, month: string, day: string) => { From 27c59396638146400be2426a8e8b351a3d99a37f Mon Sep 17 00:00:00 2001 From: Doeunnkimm Date: Tue, 26 Mar 2024 23:29:43 +0900 Subject: [PATCH 05/21] =?UTF-8?q?fix:=20formattedValue=EA=B0=80=20format?= =?UTF-8?q?=EA=B3=BC=20=EC=9D=BC=EC=B9=98=ED=95=98=EB=8A=94=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=ED=95=98=EB=8A=94=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/goal/utils/date.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/features/goal/utils/date.ts b/src/features/goal/utils/date.ts index 696e7370..5648df18 100644 --- a/src/features/goal/utils/date.ts +++ b/src/features/goal/utils/date.ts @@ -21,10 +21,13 @@ export const isValidDate = (year: string, month: string, day: string) => { * @param string DATE_SEPARATOR를 기준으로 split했을 때, 모든 아이템이 number여야만, 길이를 만족해야만 true를 반환합니다. * @param format ex. 'YYYY.MM.DD' */ -export const isValidDateFormat = (formattedDate = '', format: string) => { +export const isValidDateFormat = (formattedDate = '', format = 'YYYY.MM.DD') => { const splittedFormat = format.split(DATE_SEPARATOR) as DateProps[]; - return formattedDate - .split(DATE_SEPARATOR) - .every((date, index) => !isNaN(+date) && date.length === typeToMaxLength[splittedFormat[index]]); + return ( + formattedDate + .split(DATE_SEPARATOR) + .every((date, index) => !isNaN(+date) && date.length === typeToMaxLength[splittedFormat[index]]) && + formattedDate.split(DATE_SEPARATOR).length === splittedFormat.length + ); }; From e32c85e2117b1cfc50b652383b7f6e31e48ac4aa Mon Sep 17 00:00:00 2001 From: Doeunnkimm Date: Tue, 26 Mar 2024 23:30:20 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat:=20member/new/birth=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/member/components/new/BirthInputForm.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/member/components/new/BirthInputForm.tsx b/src/features/member/components/new/BirthInputForm.tsx index b9404d32..c6bbf4b2 100644 --- a/src/features/member/components/new/BirthInputForm.tsx +++ b/src/features/member/components/new/BirthInputForm.tsx @@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation'; import BirthIcon from '@/assets/icons/birth-icon.svg'; import { Button } from '@/components'; import { DateInput } from '@/features/goal/components/new/DateInput'; +import { isValidDateFormat } from '@/features/goal/utils/date'; import type { NewMemberFormValues } from '../../types'; @@ -36,7 +37,9 @@ export const BirthInputForm = () => {
- + From 5dbf0661f15daedd5c15e8227dbdbe537e621a51 Mon Sep 17 00:00:00 2001 From: minkyung Date: Wed, 27 Mar 2024 10:43:47 +0900 Subject: [PATCH 07/21] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=A1=B0=ED=9A=8C=20api=20=EB=B0=98=EC=98=81=20(#2?= =?UTF-8?q?31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 타임라인 조회 api 수정 * feat: api 응답값 변경에 따라 props 및 타입 수정 * feat: TimelineCardBody 컴포넌트 구현 * feat: 타임라인 조회 api 변경 --- .../home/components/timeline/Timeline.tsx | 13 +++-- .../home/components/timeline/TimelineCard.tsx | 24 ++++----- .../components/timeline/TimelineCardBody.tsx | 52 +++++++++++++++++++ src/hooks/reactQuery/goal/useGetTimeline.ts | 49 ++++++++++++----- 4 files changed, 106 insertions(+), 32 deletions(-) create mode 100644 src/features/home/components/timeline/TimelineCardBody.tsx diff --git a/src/features/home/components/timeline/Timeline.tsx b/src/features/home/components/timeline/Timeline.tsx index 44f22b9c..e83edfc6 100644 --- a/src/features/home/components/timeline/Timeline.tsx +++ b/src/features/home/components/timeline/Timeline.tsx @@ -12,9 +12,8 @@ import TimelineCard from './TimelineCard'; export const Timeline = () => { const pathname = usePathname(); const [, , username] = pathname.split('/'); - const { data: timeline, fetchNextPage, hasNextPage } = useGetTimeline(username); - const isEmptyGoal = timeline.pages[0].goals.length === 0; + const isEmptyGoal = timeline.pages[0].contents.length === 0; return ( <> @@ -23,14 +22,14 @@ export const Timeline = () => { ) : ( fetchNextPage()}>
- {timeline.pages.map(({ goals }) => - goals.map((timeline, index) => { + {timeline.pages.map(({ contents }) => + contents.map((timeline, index) => { const { goal } = timeline; return ( 0 && getYYYY(goal.deadline) === getYYYY(goals[index - 1].goal.deadline)} + key={goal.goalId} + timeline={timeline} + isSameYear={index > 0 && getYYYY(goal.deadline) === getYYYY(contents[index - 1].goal.deadline)} /> ); }), diff --git a/src/features/home/components/timeline/TimelineCard.tsx b/src/features/home/components/timeline/TimelineCard.tsx index 5770aed3..ba6771fe 100644 --- a/src/features/home/components/timeline/TimelineCard.tsx +++ b/src/features/home/components/timeline/TimelineCard.tsx @@ -1,21 +1,22 @@ import { Typography } from '@/components'; -import { FeedCardBody } from '@/features/feed/feedCard/FeedCardBody'; import { ReactionGroup } from '@/features/feed/feedCard/reactionGroup'; import { CommentButton } from '@/features/feed/feedCard/ViewCommentButton'; -import type { GoalFeedProps } from '@/hooks/reactQuery/goal/useGetGoalFeeds'; +import type { TimelineProps } from '@/hooks/reactQuery/goal/useGetTimeline'; import { getYYYY } from '@/utils/date'; +import { TimelineCardBody } from './TimelineCardBody'; + interface TimelineCardProps { - feedData: GoalFeedProps; + timeline: TimelineProps; isSameYear: boolean; } -const TimelineCard = ({ feedData, isSameYear }: TimelineCardProps) => { +const TimelineCard = ({ timeline, isSameYear }: TimelineCardProps) => { return (
{!isSameYear && ( - {`${getYYYY(feedData.goal.deadline)}년까지 달성할 목표`} + {`${getYYYY(timeline.goal.deadline)}년까지 달성할 목표`} )} @@ -26,16 +27,15 @@ const TimelineCard = ({ feedData, isSameYear }: TimelineCardProps) => { }`} />
- {/* TODO: 다른 유저가 이미 반응한 이모지 버튼 추가 */} - - + + } /> diff --git a/src/features/home/components/timeline/TimelineCardBody.tsx b/src/features/home/components/timeline/TimelineCardBody.tsx new file mode 100644 index 00000000..6504e0e8 --- /dev/null +++ b/src/features/home/components/timeline/TimelineCardBody.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; + +import VerticalBarIcon from '@/assets/icons/vertical-bar.svg'; +import { Span, Typography } from '@/components'; +import { blueDataURL } from '@/constants'; +import type { TimelineProps } from '@/hooks/reactQuery/goal/useGetTimeline'; +import { formatDotYYYYMM } from '@/utils/date'; + +interface TimelineCardBodyProps { + goal: TimelineProps['goal']; + counts: TimelineProps['counts']; + footer?: ReactNode; +} + +export const TimelineCardBody = ({ goal, counts, footer }: TimelineCardBodyProps) => { + return ( +
+ +
+
+ goal sticker + {goal.title} +
+
+ + {formatDotYYYYMM(goal.deadline)}까지 이룰거에요 + + + + {goal.tag} + + + + 세부목표 {counts.task}개 + +
+
+ + {footer} +
+ ); +}; diff --git a/src/hooks/reactQuery/goal/useGetTimeline.ts b/src/hooks/reactQuery/goal/useGetTimeline.ts index 125b47e2..2691220a 100644 --- a/src/hooks/reactQuery/goal/useGetTimeline.ts +++ b/src/hooks/reactQuery/goal/useGetTimeline.ts @@ -2,23 +2,46 @@ import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; import { api } from '@/apis'; -import type { GoalFeedResponse } from './useGetGoalFeeds'; +const PAGE_SIZE = 4; -const PAGE_SIZE = 10; +export type TimelineProps = { + goal: { + goalId: number; + title: string; + description: string; + deadline: string; + stickerUrl: string; + tag: string; + createdAt: string; + cursorId: number; + }; + counts: { + comment: number; + task: number; + }; + emojis: Array<{ + id: number; + name: string; + url: string; + reactCount: number; + isMyReaction: boolean; + }>; +}; + +export type TimelineResponse = { + contents: Array; + isLast: boolean; + nextCursor: number; +}; export const useGetTimeline = (username: string) => { - return useSuspenseInfiniteQuery({ + return useSuspenseInfiniteQuery({ queryKey: ['timeline'], - // NOTE: 새로운 api로 수정 예정 - queryFn: ({ pageParam }) => api.get('/goal/explore', { params: { cursor: pageParam } }), + queryFn: ({ pageParam }) => + api.get(`/open/life-map/timeline/${username}`, { + params: { cursor: pageParam, size: PAGE_SIZE }, + }), initialPageParam: null, - getNextPageParam: ({ goals, cursor }) => (cursor && goals.length === PAGE_SIZE ? cursor.next : null), - select: (data) => ({ - pages: data.pages.flatMap((page) => ({ - goals: page.goals.filter(({ user }) => user.username === username), - cursor: page.cursor, - })), - pageParams: data.pageParams, - }), + getNextPageParam: ({ isLast, nextCursor }) => (isLast ? null : nextCursor), }); }; From c7470ee7a917e2c328c7839145b64e0c406f242f Mon Sep 17 00:00:00 2001 From: Doeun Kim Date: Fri, 29 Mar 2024 13:05:00 +0900 Subject: [PATCH 08/21] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20api=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20ios=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=82=A4=ED=8C=A8=EB=93=9C=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EB=8C=80=EC=9D=91=20(#223)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: BottomSheet에 px 추가 * chore: props명 수정 및 시간정책 유틸 함수 적용 * chore: Comment 컴포넌트 애니메이션 적용 * feat: 댓글 조회 기능 추가 * feat: 댓글 추가 기능 구현 * feat: 댓글 삭제 기능 구현 * chore: 시간 정책 함수 주석 추가 * chore: atom → atoms 로 이동된 파일 적용 * chore: ios에서 키패드 이슈 대응............. * refactor: isMyGoalAtom 위치 이동 * feat: 추가된 isMyGoal 값 적용 및 일부 스타일 수정 * chore: optimistic update하는 값 수정 * chore: GoalDetailContent에 aside 추가 * feat: 새로운 댓글이 있는지 조회하는 기능 추가 * chore: 변경 사항에 맞게 반영 * feat: 타임라인 조회 api 반영 (#231) * feat: 타임라인 조회 api 수정 * feat: api 응답값 변경에 따라 props 및 타입 수정 * feat: TimelineCardBody 컴포넌트 구현 * feat: 타임라인 조회 api 변경 * chore: BottomSheet에 px 추가 * chore: props명 수정 및 시간정책 유틸 함수 적용 * chore: Comment 컴포넌트 애니메이션 적용 * feat: 댓글 조회 기능 추가 * feat: 댓글 추가 기능 구현 * feat: 댓글 삭제 기능 구현 * chore: 시간 정책 함수 주석 추가 * chore: atom → atoms 로 이동된 파일 적용 * chore: ios에서 키패드 이슈 대응............. * refactor: isMyGoalAtom 위치 이동 * feat: 추가된 isMyGoal 값 적용 및 일부 스타일 수정 * chore: optimistic update하는 값 수정 * chore: GoalDetailContent에 aside 추가 * feat: 새로운 댓글이 있는지 조회하는 기능 추가 * chore: 변경 사항에 맞게 반영 --------- Co-authored-by: minkyung --- src/components/atoms/bottomSheet/style.css | 6 +- .../molecules/comment/Comment.stories.tsx | 6 +- src/components/molecules/comment/Comment.tsx | 35 ++-- src/features/comment/AddCommentInput.tsx | 58 ++++--- .../comment/CommentBottomSheetLayout.tsx | 47 ++++- src/features/comment/CommentsBottomSheet.tsx | 163 ++++-------------- .../comment/DeleteCommentBottomSheet.tsx | 39 +++++ .../CommentBottomSheetLayout.styles.css | 4 + src/features/goal/atom/goalId.ts | 3 - src/features/goal/atom/index.ts | 2 - src/features/goal/atoms/index.ts | 1 + src/features/goal/{atom => atoms}/isMyGoal.ts | 0 .../goal/components/detail/DetailLayout.tsx | 9 +- .../components/detail/GoalDetailContent.tsx | 10 +- src/features/goal/components/detail/Tasks.tsx | 3 +- .../detail/comment/AddCommentButton.tsx | 15 +- .../goal/components/detail/emoji/Emojis.tsx | 2 +- .../components/detail/emoji/ReactedEmojis.tsx | 2 +- .../goal/components/detail/emoji/Reaction.tsx | 2 +- .../detail/emoji/ReactionUserTotalCount.tsx | 2 +- .../goal/components/detail/task/Task.tsx | 2 +- src/hooks/reactQuery/comment/index.ts | 4 + .../reactQuery/comment/useCreateComment.ts | 22 +++ .../reactQuery/comment/useDeleteComment.ts | 40 +++++ src/hooks/reactQuery/comment/useGetComment.ts | 35 ++++ .../reactQuery/comment/useGetHasNewComment.ts | 19 ++ src/utils/date.ts | 3 + src/utils/isIos.ts | 1 + 28 files changed, 329 insertions(+), 206 deletions(-) create mode 100644 src/features/comment/DeleteCommentBottomSheet.tsx delete mode 100644 src/features/goal/atom/goalId.ts delete mode 100644 src/features/goal/atom/index.ts rename src/features/goal/{atom => atoms}/isMyGoal.ts (100%) create mode 100644 src/hooks/reactQuery/comment/index.ts create mode 100644 src/hooks/reactQuery/comment/useCreateComment.ts create mode 100644 src/hooks/reactQuery/comment/useDeleteComment.ts create mode 100644 src/hooks/reactQuery/comment/useGetComment.ts create mode 100644 src/hooks/reactQuery/comment/useGetHasNewComment.ts create mode 100644 src/utils/isIos.ts diff --git a/src/components/atoms/bottomSheet/style.css b/src/components/atoms/bottomSheet/style.css index 2495f01f..1804fd00 100644 --- a/src/components/atoms/bottomSheet/style.css +++ b/src/components/atoms/bottomSheet/style.css @@ -13,11 +13,9 @@ } [data-rsbs-header] { - text-align: left; - padding-bottom: 0; - box-shadow: none; + @apply text-left pb-0 shadow-none px-xs; } [data-rsbs-footer] { - box-shadow: none; + @apply shadow-none px-xs; } diff --git a/src/components/molecules/comment/Comment.stories.tsx b/src/components/molecules/comment/Comment.stories.tsx index 316205ab..0e2e5d68 100644 --- a/src/components/molecules/comment/Comment.stories.tsx +++ b/src/components/molecules/comment/Comment.stories.tsx @@ -20,13 +20,13 @@ type Story = StoryObj; export const Basic: Story = { args: { - user: { - url: 'https://github.com/depromeet/amazing3-fe/assets/112946860/8ee0540f-4c8f-4d9e-80f4-671e60c292e8', + commenter: { + image: 'https://github.com/depromeet/amazing3-fe/assets/112946860/8ee0540f-4c8f-4d9e-80f4-671e60c292e8', nickname: '산타할아버지', username: 'BANDIBOODI-6', }, content: '엄청나다! 너의 목표', - createdAt: '2024-02-29T07:34:58.356Z', + writtenAt: '2024-02-29T07:34:58.356Z', }, }; diff --git a/src/components/molecules/comment/Comment.tsx b/src/components/molecules/comment/Comment.tsx index d2676d5e..dc5d50af 100644 --- a/src/components/molecules/comment/Comment.tsx +++ b/src/components/molecules/comment/Comment.tsx @@ -1,36 +1,39 @@ +'use client'; + import Image from 'next/image'; import Link from 'next/link'; +import { m } from 'framer-motion'; import TrashIcon from '@/assets/icons/trash-icon.svg'; import { Typography } from '@/components'; +import { convertTimeToElapsedTime } from '@/utils/date'; interface CommentProps { - user: { - url: string; + commenter: { + image: string; nickname: string; username: string; }; content: string; - createdAt: string; + writtenAt: string; isDeletable?: boolean; onDelete?: VoidFunction; } -export const Comment = ({ user, content, createdAt, isDeletable, onDelete }: CommentProps) => { +export const Comment = ({ commenter, content, writtenAt, isDeletable, onDelete }: CommentProps) => { return ( -
- - user_profile_image + + + user_profile_image
- - {user.nickname} + + {commenter.nickname} - {/* TODO: 시간 정책 맞게 변환해주는 유틸 함수 래핑 */} - {createdAt} + {convertTimeToElapsedTime(writtenAt)}
{isDeletable && ( @@ -41,6 +44,14 @@ export const Comment = ({ user, content, createdAt, isDeletable, onDelete }: Com
{content}
-
+ ); }; + +const animate = { + layout: true, + initial: { y: -20 }, + animate: { y: 0 }, + exit: { opacity: 0, y: -20, height: 0, overflow: 'hidden', transition: { duration: 0.3 } }, + transition: { duration: 0.3 }, +}; diff --git a/src/features/comment/AddCommentInput.tsx b/src/features/comment/AddCommentInput.tsx index 764463d9..16e600c1 100644 --- a/src/features/comment/AddCommentInput.tsx +++ b/src/features/comment/AddCommentInput.tsx @@ -1,7 +1,9 @@ -import { useRef } from 'react'; +import type { MutableRefObject } from 'react'; +import { forwardRef, useEffect } from 'react'; import { Input, type InputProps } from '@/components/atoms/input/Input'; import { useInput } from '@/hooks'; +import { useCreateComment } from '@/hooks/reactQuery/comment'; interface AddCommentInputProps extends InputProps { goalId: number; @@ -9,26 +11,34 @@ interface AddCommentInputProps extends InputProps { export const COMMENT_MAX_LENGTH = 30; -export const AddCommentInput = ({ goalId, ...props }: AddCommentInputProps) => { - const inputRef = useRef(null); - const { value: comment, handleChange: handleComment, reset } = useInput(''); - - const handleSubmit = () => { - // TODO: 댓글 등록 api 요청 - reset(); - inputRef.current?.focus(); - }; - - return ( - - ); -}; +export const AddCommentInput = forwardRef( + ({ goalId, ...props }: AddCommentInputProps, ref) => { + const { value: comment, handleChange: handleComment, reset } = useInput(''); + const { mutate } = useCreateComment(); + + const handleFocusAction = () => (ref as MutableRefObject).current?.focus(); + + const handleSubmit = () => { + mutate({ goalId, content: comment }); + reset(); + handleFocusAction(); + }; + + useEffect(() => handleFocusAction(), []); + + return ( + + ); + }, +); + +AddCommentInput.displayName = 'AddCommentInput'; diff --git a/src/features/comment/CommentBottomSheetLayout.tsx b/src/features/comment/CommentBottomSheetLayout.tsx index b19497cc..b97829f7 100644 --- a/src/features/comment/CommentBottomSheetLayout.tsx +++ b/src/features/comment/CommentBottomSheetLayout.tsx @@ -1,8 +1,9 @@ -import { type PropsWithChildren, useRef } from 'react'; +import { type PropsWithChildren, useEffect, useRef, useState } from 'react'; import type { BottomSheetRef } from 'react-spring-bottom-sheet'; import { BottomSheet, Typography } from '@/components'; import type { BottomSheetProps } from '@/components/atoms/bottomSheet/BottomSheet'; +import { isIOS } from '@/utils/isIos'; import { AddCommentInput } from './AddCommentInput'; @@ -26,10 +27,44 @@ export const CommentBottomSheetLayout = ({ children, ...props }: PropsWithChildren) => { + const [isFirstOpen, setFirstOpen] = useState(true); const sheetRef = useRef(null); + const overlayRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + const handleBlur = () => { + if (overlayRef.current) { + setFirstOpen(false); + + // 원래 상태로 복구 + overlayRef.current.style.bottom = ''; + overlayRef.current.style.height = ''; + overlayRef.current.style.paddingBottom = ''; + } + }; + if (open && isIOS() && isFirstOpen) { + // open 상태가 true로 변경된 후에, 오버레이를 찾기 위해 약간의 지연 + const timer = setTimeout(() => { + overlayRef.current = document.querySelector('[data-rsbs-overlay]') as HTMLDivElement; + + overlayRef.current.style.bottom = '15px'; // 키패드 높이 만큼 올라감 + overlayRef.current.style.height = '100%'; + overlayRef.current.style.paddingBottom = '65%'; + + inputRef.current?.addEventListener('blur', handleBlur); + }, 300); + + return () => { + clearTimeout(timer); + inputRef.current?.removeEventListener('blur', handleBlur); + }; + } + }, [isFirstOpen, open]); + const handleFocusInput = () => { - sheetRef.current?.snapTo(({ maxHeight }) => maxHeight * 0.6); + sheetRef.current?.snapTo(({ maxHeight }) => maxHeight * 0.55); }; return ( @@ -39,12 +74,12 @@ export const CommentBottomSheetLayout = ({ open={open} onDismiss={onClose} HeaderComponent={
} - FooterComponent={} - defaultSnap={({ maxHeight }) => maxHeight * 0.6} - snapPoints={({ maxHeight }) => [maxHeight / 2, maxHeight * 0.6, maxHeight * 0.99]} + FooterComponent={} + defaultSnap={({ maxHeight }) => maxHeight * 0.55} + snapPoints={({ maxHeight }) => [maxHeight * 0.55, maxHeight * 0.99]} {...props} > -
{children}
+
{children}
); }; diff --git a/src/features/comment/CommentsBottomSheet.tsx b/src/features/comment/CommentsBottomSheet.tsx index 3079d9a7..280ef6e0 100644 --- a/src/features/comment/CommentsBottomSheet.tsx +++ b/src/features/comment/CommentsBottomSheet.tsx @@ -1,6 +1,14 @@ +import { useOverlay } from '@toss/use-overlay'; +import { AnimatePresence } from 'framer-motion'; +import { useAtomValue } from 'jotai'; + import { Comment } from '@/components'; +import { useGetComment, useGetHasNewComment } from '@/hooks/reactQuery/comment'; + +import { isMyGoalAtom } from '../goal/atoms'; import { CommentBottomSheetLayout } from './CommentBottomSheetLayout'; +import { DeleteCommentBottomSheet } from './DeleteCommentBottomSheet'; import { EmptyComments } from './EmptyComments'; interface CommentsBottomSheetProps { @@ -9,136 +17,35 @@ interface CommentsBottomSheetProps { onClose: VoidFunction; } -// TODO: api 로직 추가 후 제거 -const data = { - totalComments: 2, - isMyGoal: true, - comments: [ - { - id: 1, - isMyComment: false, - user: { - url: 'https://github.com/depromeet/amazing3-fe/assets/112946860/8ee0540f-4c8f-4d9e-80f4-671e60c292e8', - nickname: '산타할아버지', - username: 'BANDIBOODI-6', - }, - content: '멋지다! 너의 목표1', - createdAt: '2024-02-29T07:34:58.356Z', - }, - { - id: 2, - isMyComment: false, - user: { - url: 'https://github.com/depromeet/amazing3-fe/assets/112946860/8ee0540f-4c8f-4d9e-80f4-671e60c292e8', - nickname: '산타할아버지', - username: 'BANDIBOODI-6', - }, - content: '멋지다! 너의 목표2', - createdAt: '2024-02-29T07:34:58.356Z', - }, - { - id: 3, - isMyComment: false, - user: { - url: 'https://github.com/depromeet/amazing3-fe/assets/112946860/8ee0540f-4c8f-4d9e-80f4-671e60c292e8', - nickname: '산타할아버지', - username: 'BANDIBOODI-6', - }, - content: '멋지다! 너의 목표3', - createdAt: '2024-02-29T07:34:58.356Z', - }, - { - id: 4, - isMyComment: false, - user: { - url: 'https://github.com/depromeet/amazing3-fe/assets/112946860/8ee0540f-4c8f-4d9e-80f4-671e60c292e8', - nickname: '산타할아버지', - username: 'BANDIBOODI-6', - }, - content: '멋지다! 너의 목표4', - createdAt: '2024-02-29T07:34:58.356Z', - }, - { - id: 5, - isMyComment: false, - user: { - url: 'https://github.com/depromeet/amazing3-fe/assets/112946860/8ee0540f-4c8f-4d9e-80f4-671e60c292e8', - nickname: '산타할아버지', - username: 'BANDIBOODI-6', - }, - content: '멋지다! 너의 목표5', - createdAt: '2024-02-29T07:34:58.356Z', - }, - { - id: 6, - isMyComment: false, - user: { - url: 'https://github.com/depromeet/amazing3-fe/assets/112946860/8ee0540f-4c8f-4d9e-80f4-671e60c292e8', - nickname: '산타할아버지', - username: 'BANDIBOODI-6', - }, - content: '멋지다! 너의 목표6', - createdAt: '2024-02-29T07:34:58.356Z', - }, - { - id: 7, - isMyComment: false, - user: { - url: 'https://github.com/depromeet/amazing3-fe/assets/112946860/8ee0540f-4c8f-4d9e-80f4-671e60c292e8', - nickname: '산타할아버지', - username: 'BANDIBOODI-6', - }, - content: '멋지다! 너의 목표7', - createdAt: '2024-02-29T07:34:58.356Z', - }, - { - id: 8, - isMyComment: false, - user: { - url: 'https://github.com/depromeet/amazing3-fe/assets/112946860/8ee0540f-4c8f-4d9e-80f4-671e60c292e8', - nickname: '산타할아버지', - username: 'BANDIBOODI-6', - }, - content: '멋지다! 너의 목표8', - createdAt: '2024-02-29T07:34:58.356Z', - }, - { - id: 9, - isMyComment: false, - user: { - url: 'https://github.com/depromeet/amazing3-fe/assets/112946860/8ee0540f-4c8f-4d9e-80f4-671e60c292e8', - nickname: '산타할아버지', - username: 'BANDIBOODI-6', - }, - content: '멋지다! 너의 목표9', - createdAt: '2024-02-29T07:34:58.356Z', - }, - { - id: 10, - isMyComment: false, - user: { - url: 'https://github.com/depromeet/amazing3-fe/assets/112946860/8ee0540f-4c8f-4d9e-80f4-671e60c292e8', - nickname: '산타할아버지', - username: 'BANDIBOODI-6', - }, - content: '멋지다! 너의 목표10', - createdAt: '2024-02-29T07:34:58.356Z', - }, - ], -}; - export const CommentsBottomSheet = ({ goalId, ...props }: CommentsBottomSheetProps) => { + const isMyGoal = useAtomValue(isMyGoalAtom); + const { open } = useOverlay(); + const { data } = useGetComment({ goalId }); + useGetHasNewComment({ goalId, isMyGoal }); + + const handleDelete = (commentId: number) => () => { + open(({ isOpen, close }) => ( + + )); + }; + return ( - - {data.totalComments ? ( - <> - {data.comments.map((comment) => ( -
- -
-
- ))} - + + {data && data?.commentCount ? ( +
+ + {data.comments.map((comment) => ( +
+ +
+
+ ))} + +
) : ( )} diff --git a/src/features/comment/DeleteCommentBottomSheet.tsx b/src/features/comment/DeleteCommentBottomSheet.tsx new file mode 100644 index 00000000..7cd95b01 --- /dev/null +++ b/src/features/comment/DeleteCommentBottomSheet.tsx @@ -0,0 +1,39 @@ +import { BottomSheet, Button, Typography } from '@/components'; +import { useDeleteComment } from '@/hooks/reactQuery/comment'; + +interface DeleteCommentBottomSheetProps { + open: boolean; + onClose: VoidFunction; + goalId: number; + commentId: number; +} + +export const DeleteCommentBottomSheet = ({ open, onClose, goalId, commentId }: DeleteCommentBottomSheetProps) => { + return ( + } + > + + 댓글을 삭제하시겠어요? + + + ); +}; + +const Footer = ({ goalId, commentId, onClose }: { goalId: number; commentId: number; onClose: VoidFunction }) => { + const { mutate } = useDeleteComment(); + + const handleDelete = () => { + mutate({ goalId, commentId }); + onClose(); + }; + + return ( + + ); +}; diff --git a/src/features/comment/styles/CommentBottomSheetLayout.styles.css b/src/features/comment/styles/CommentBottomSheetLayout.styles.css index 5e52847a..5bce603a 100644 --- a/src/features/comment/styles/CommentBottomSheetLayout.styles.css +++ b/src/features/comment/styles/CommentBottomSheetLayout.styles.css @@ -14,3 +14,7 @@ .commentBottomSheet [data-rsbs-content] { @apply w-full min-h-full flex flex-col items-center justify-center; } + +.commentBottomSheet [data-rsbs-footer] { + @apply pt-0; +} diff --git a/src/features/goal/atom/goalId.ts b/src/features/goal/atom/goalId.ts deleted file mode 100644 index 44af1c4f..00000000 --- a/src/features/goal/atom/goalId.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { atom } from 'jotai'; - -export const goalIdAtom = atom(-1); diff --git a/src/features/goal/atom/index.ts b/src/features/goal/atom/index.ts deleted file mode 100644 index 3cc1cc51..00000000 --- a/src/features/goal/atom/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { goalIdAtom } from './goalId'; -export { isMyGoalAtom } from './isMyGoal'; diff --git a/src/features/goal/atoms/index.ts b/src/features/goal/atoms/index.ts index cf7a54da..3cc1cc51 100644 --- a/src/features/goal/atoms/index.ts +++ b/src/features/goal/atoms/index.ts @@ -1 +1,2 @@ export { goalIdAtom } from './goalId'; +export { isMyGoalAtom } from './isMyGoal'; diff --git a/src/features/goal/atom/isMyGoal.ts b/src/features/goal/atoms/isMyGoal.ts similarity index 100% rename from src/features/goal/atom/isMyGoal.ts rename to src/features/goal/atoms/isMyGoal.ts diff --git a/src/features/goal/components/detail/DetailLayout.tsx b/src/features/goal/components/detail/DetailLayout.tsx index 44cba187..16c5be2c 100644 --- a/src/features/goal/components/detail/DetailLayout.tsx +++ b/src/features/goal/components/detail/DetailLayout.tsx @@ -1,15 +1,14 @@ import type { ReactNode } from 'react'; -import { AddCommentButton } from './comment'; - interface LayoutProps { header: ReactNode; sticker: ReactNode; + aside?: ReactNode; body: ReactNode; footer: ReactNode; } -export const DetailLayout = ({ header, sticker, body, footer }: LayoutProps) => { +export const DetailLayout = ({ header, sticker, aside, body, footer }: LayoutProps) => { return ( <>
@@ -17,9 +16,7 @@ export const DetailLayout = ({ header, sticker, body, footer }: LayoutProps) =>
{sticker} -
- -
+ {aside &&
{aside}
}
diff --git a/src/features/goal/components/detail/GoalDetailContent.tsx b/src/features/goal/components/detail/GoalDetailContent.tsx index 507c11c0..f4a92be7 100644 --- a/src/features/goal/components/detail/GoalDetailContent.tsx +++ b/src/features/goal/components/detail/GoalDetailContent.tsx @@ -1,14 +1,15 @@ 'use client'; import { Suspense, useEffect, useState } from 'react'; -import { useAtom, useSetAtom } from 'jotai'; +import { useSetAtom } from 'jotai'; import { Skeleton } from '@/components'; import { useGetGoal } from '@/hooks/reactQuery/goal'; -import { goalIdAtom, isMyGoalAtom } from '../../atom'; +import { goalIdAtom, isMyGoalAtom } from '../../atoms'; import { AddTaskInput } from './AddTaskInput'; +import { AddCommentButton } from './comment'; import DetailLayout from './DetailLayout'; import { Reaction } from './emoji'; import { Tasks } from './Tasks'; @@ -16,7 +17,7 @@ import { AddSubGoalPrompt, ContentBody, DetailFooterButton, DetailHeader, Sticke export const GoalDetailContent = ({ id }: { id: number }) => { const { data: goal } = useGetGoal({ goalId: Number(id) }); - const [isMyGoal, setIsMyGoal] = useAtom(isMyGoalAtom); + const setIsMyGoal = useSetAtom(isMyGoalAtom); const setGoalId = useSetAtom(goalIdAtom); const [isOpenTaskInput, setOpenTaskInput] = useState(false); @@ -32,6 +33,7 @@ export const GoalDetailContent = ({ id }: { id: number }) => { } sticker={goal && } + aside={} body={ goal && (
@@ -47,7 +49,7 @@ export const GoalDetailContent = ({ id }: { id: number }) => { {goal.tasks.length ? ( ) : ( - isMyGoal && !isOpenTaskInput && + goal.isMyGoal && !isOpenTaskInput && )}
) diff --git a/src/features/goal/components/detail/Tasks.tsx b/src/features/goal/components/detail/Tasks.tsx index a0dced72..f6625116 100644 --- a/src/features/goal/components/detail/Tasks.tsx +++ b/src/features/goal/components/detail/Tasks.tsx @@ -5,8 +5,7 @@ import { Typography } from '@/components'; import type { GoalTasksProps } from '@/hooks/reactQuery/goal/useGetGoal'; import { useUpdateIsDone } from '@/hooks/reactQuery/task'; -import { isMyGoalAtom } from '../../atom'; -import { goalIdAtom } from '../../atoms'; +import { goalIdAtom, isMyGoalAtom } from '../../atoms'; import { Task } from './task'; diff --git a/src/features/goal/components/detail/comment/AddCommentButton.tsx b/src/features/goal/components/detail/comment/AddCommentButton.tsx index ba370a8e..5705e005 100644 --- a/src/features/goal/components/detail/comment/AddCommentButton.tsx +++ b/src/features/goal/components/detail/comment/AddCommentButton.tsx @@ -4,15 +4,14 @@ import { useAtomValue } from 'jotai'; import BlueCommentIcon from '@/assets/icons/blue-comment-icon.svg'; import { CommentsBottomSheet } from '@/features/comment'; -import { goalIdAtom } from '@/features/goal/atoms'; +import { goalIdAtom, isMyGoalAtom } from '@/features/goal/atoms'; +import { useGetHasNewComment } from '@/hooks/reactQuery/comment'; -interface AddCommentButtonProps extends ButtonHTMLAttributes { - hasUnreadComments?: boolean; -} - -export const AddCommentButton = ({ hasUnreadComments, ...props }: AddCommentButtonProps) => { +export const AddCommentButton = (props: ButtonHTMLAttributes) => { const { open } = useOverlay(); const goalId = useAtomValue(goalIdAtom); + const isMyGoal = useAtomValue(isMyGoalAtom); + const { data: hasNewComments } = useGetHasNewComment({ goalId, isMyGoal }); const handleOpenComments = () => { open(({ isOpen, close }) => ); @@ -27,7 +26,9 @@ export const AddCommentButton = ({ hasUnreadComments, ...props }: AddCommentButt > - {hasUnreadComments && } + {isMyGoal && hasNewComments && ( + + )}
); }; diff --git a/src/features/goal/components/detail/emoji/Emojis.tsx b/src/features/goal/components/detail/emoji/Emojis.tsx index a7d928c1..3231c648 100644 --- a/src/features/goal/components/detail/emoji/Emojis.tsx +++ b/src/features/goal/components/detail/emoji/Emojis.tsx @@ -1,7 +1,7 @@ import { useAtomValue } from 'jotai'; import { EmojiGroup } from '@/components'; -import { goalIdAtom } from '@/features/goal/atom'; +import { goalIdAtom } from '@/features/goal/atoms'; import { useCreateEmoji, useGetAllEmoji } from '@/hooks/reactQuery/emoji'; interface EmojisProps { diff --git a/src/features/goal/components/detail/emoji/ReactedEmojis.tsx b/src/features/goal/components/detail/emoji/ReactedEmojis.tsx index 4fd8dc2b..19d2c846 100644 --- a/src/features/goal/components/detail/emoji/ReactedEmojis.tsx +++ b/src/features/goal/components/detail/emoji/ReactedEmojis.tsx @@ -2,7 +2,7 @@ import { m } from 'framer-motion'; import { useAtomValue } from 'jotai'; import { CountEmoji } from '@/components'; -import { goalIdAtom } from '@/features/goal/atom'; +import { goalIdAtom } from '@/features/goal/atoms'; import { useCreateEmoji, useDeleteReactedEmoji } from '@/hooks/reactQuery/emoji'; interface ReactedEmojis { diff --git a/src/features/goal/components/detail/emoji/Reaction.tsx b/src/features/goal/components/detail/emoji/Reaction.tsx index 9699a065..ad6d2e9f 100644 --- a/src/features/goal/components/detail/emoji/Reaction.tsx +++ b/src/features/goal/components/detail/emoji/Reaction.tsx @@ -3,7 +3,7 @@ import { useAtomValue } from 'jotai'; import { ToolTip } from '@/components'; import { ReactionAddButton } from '@/features/emoji/ReactionAddButton'; -import { goalIdAtom } from '@/features/goal/atom'; +import { goalIdAtom } from '@/features/goal/atoms'; import { ReactedEmojis } from '@/features/goal/components/detail/emoji/ReactedEmojis'; import { ReactionUserTotalCount } from '@/features/goal/components/detail/emoji/ReactionUserTotalCount'; import { useGetEmojiByGoal } from '@/hooks/reactQuery/emoji'; diff --git a/src/features/goal/components/detail/emoji/ReactionUserTotalCount.tsx b/src/features/goal/components/detail/emoji/ReactionUserTotalCount.tsx index 91683c4c..4204b1ab 100644 --- a/src/features/goal/components/detail/emoji/ReactionUserTotalCount.tsx +++ b/src/features/goal/components/detail/emoji/ReactionUserTotalCount.tsx @@ -6,7 +6,7 @@ import ArrowIcon from '@/assets/icons/arrow-icons.svg'; import ReactionMembersImage from '@/assets/images/reaction-members.png'; import { Typography } from '@/components'; import { ReactUserBottomSheet } from '@/features/emoji/BottomSheet'; -import { goalIdAtom } from '@/features/goal/atom'; +import { goalIdAtom } from '@/features/goal/atoms'; import { formatOver999 } from '@/utils/number'; import { formatOverLength } from '@/utils/string'; diff --git a/src/features/goal/components/detail/task/Task.tsx b/src/features/goal/components/detail/task/Task.tsx index 6266028a..e983a4be 100644 --- a/src/features/goal/components/detail/task/Task.tsx +++ b/src/features/goal/components/detail/task/Task.tsx @@ -8,7 +8,7 @@ import EllipsisVerticalIcon from '@/assets/icons/ellipsis-vertical.svg'; import CheckedIcon from '@/assets/icons/goal/radio/radio-checked.svg'; import UnCheckedIcon from '@/assets/icons/goal/radio/radio-unchecked.svg'; import { Typography } from '@/components'; -import { isMyGoalAtom } from '@/features/goal/atom'; +import { isMyGoalAtom } from '@/features/goal/atoms'; import { TaskMoreOptionBottomSheet } from '@/features/goal/components/detail/TaskMoreOptionBottomSheet'; import { useDebounceCall, useInput } from '@/hooks'; import { useUpdateDescription } from '@/hooks/reactQuery/task/useUpdateDescription'; diff --git a/src/hooks/reactQuery/comment/index.ts b/src/hooks/reactQuery/comment/index.ts new file mode 100644 index 00000000..450d1822 --- /dev/null +++ b/src/hooks/reactQuery/comment/index.ts @@ -0,0 +1,4 @@ +export { useCreateComment } from './useCreateComment'; +export { useDeleteComment } from './useDeleteComment'; +export { useGetComment } from './useGetComment'; +export { useGetHasNewComment } from './useGetHasNewComment'; diff --git a/src/hooks/reactQuery/comment/useCreateComment.ts b/src/hooks/reactQuery/comment/useCreateComment.ts new file mode 100644 index 00000000..8db7696a --- /dev/null +++ b/src/hooks/reactQuery/comment/useCreateComment.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { api } from '@/apis'; +import { useToast } from '@/hooks'; + +type CommentRequest = { + goalId: number; + content: string; +}; + +export const useCreateComment = () => { + const toast = useToast(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ goalId, content }: CommentRequest) => api.post(`/goal/${goalId}/comment`, { content }), + onSuccess: (_, variable) => queryClient.invalidateQueries({ queryKey: ['comment', variable.goalId] }), + onError: () => { + toast.warning('잠시후 다시 시도해주세요.'); + }, + }); +}; diff --git a/src/hooks/reactQuery/comment/useDeleteComment.ts b/src/hooks/reactQuery/comment/useDeleteComment.ts new file mode 100644 index 00000000..255e9a21 --- /dev/null +++ b/src/hooks/reactQuery/comment/useDeleteComment.ts @@ -0,0 +1,40 @@ +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/apis'; +import { useToast } from '@/hooks/useToast'; + +import { useOptimisticUpdate } from '../useOptimisticUpdate'; + +import type { CommentResponse } from './useGetComment'; + +type CommentDeleteRequest = { + goalId: number; + commentId: number; +}; + +export const useDeleteComment = () => { + const { queryClient, optimisticUpdater } = useOptimisticUpdate(); + const toast = useToast(); + + return useMutation({ + mutationFn: ({ commentId }: CommentDeleteRequest) => api.delete(`/comment/${commentId}`), + onMutate: async ({ goalId, commentId }) => { + const targetQueryKey = ['comment', goalId]; + + const updater = (old: CommentResponse): CommentResponse => { + const updatedComment = old.comments.filter((comment) => comment.id !== commentId); + return { ...old, comments: updatedComment, commentCount: old.commentCount - 1 }; + }; + const context = await optimisticUpdater({ queryKey: targetQueryKey, updater }); + return context; + }, + onSuccess: () => { + toast.success('댓글을 삭제했어요.'); + }, + onError: (_, variable, context) => { + toast.warning('댓글 삭제에 실패했어요.'); + const targetQueryKey = ['goal', variable.goalId]; + queryClient.setQueryData(targetQueryKey, context?.previous); + }, + }); +}; diff --git a/src/hooks/reactQuery/comment/useGetComment.ts b/src/hooks/reactQuery/comment/useGetComment.ts new file mode 100644 index 00000000..6d7c8f1f --- /dev/null +++ b/src/hooks/reactQuery/comment/useGetComment.ts @@ -0,0 +1,35 @@ +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/apis'; + +type CommentRequestParams = { + goalId: number; +}; + +export type CommentResponse = { + isMyGoal: boolean; + comments: CommentProps[]; + commentCount: number; +}; + +type CommentProps = { + id: number; + content: string; + writtenAt: string; + commenter: CommenterProps; + isMyComment: boolean; +}; + +type CommenterProps = { + username: string; + nickname: string; + image: string; +}; + +export const useGetComment = ({ goalId }: CommentRequestParams) => { + return useQuery({ + queryKey: ['comment', goalId], + queryFn: () => api.get(`/goal/${goalId}/comment`), + staleTime: 1000, + }); +}; diff --git a/src/hooks/reactQuery/comment/useGetHasNewComment.ts b/src/hooks/reactQuery/comment/useGetHasNewComment.ts new file mode 100644 index 00000000..cd4a9404 --- /dev/null +++ b/src/hooks/reactQuery/comment/useGetHasNewComment.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/apis'; + +type CommentRequestParams = { + isMyGoal: boolean; + goalId: number; +}; + +type CommentResponse = boolean; + +export const useGetHasNewComment = ({ goalId, isMyGoal }: CommentRequestParams) => { + return useQuery({ + queryKey: ['comment', 'new', goalId], + queryFn: () => api.get(`/goal/${goalId}/comment/new`), + enabled: goalId !== -1 || isMyGoal, + staleTime: 1000, + }); +}; diff --git a/src/utils/date.ts b/src/utils/date.ts index f61cd790..c6f58779 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -44,6 +44,9 @@ export const formatDotYYYYMM = (time: string) => { return YYYYMM.split('-').slice(0, -1).join('.'); }; +/** + * 시간 정책에 맞게 변환해주는 함수 + */ export const convertTimeToElapsedTime = (time: string) => { const start = new Date(time); const end = new Date(); diff --git a/src/utils/isIos.ts b/src/utils/isIos.ts new file mode 100644 index 00000000..19b0689e --- /dev/null +++ b/src/utils/isIos.ts @@ -0,0 +1 @@ +export const isIOS = () => /iPad|iPhone|iPod/.test(navigator.userAgent); From 1042a1e038aaa9bd3f810741bfaeccfe1fe4a993 Mon Sep 17 00:00:00 2001 From: Doeun Kim Date: Tue, 2 Apr 2024 21:01:08 +0900 Subject: [PATCH 09/21] =?UTF-8?q?refactor:=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20(#236)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 정해진 status code에 대해서는 StandardErrorPage로 분리 * chore: cSpell.words 설정값 추가 * chore: ErrorPageLayout 컴포넌트 추가 * chore: status code를 보여줘야하는 경우에 사용할 컴포넌트 추가 * chore: 전역적으로 status code로 정해져있는 오류 화면 컴포넌트 추가 * chore: 네이밍 피드백 반영 --- .vscode/settings.json | 3 +- src/app/error/page.tsx | 4 +- src/app/home/[username]/error.tsx | 4 +- src/app/not-found.tsx | 4 +- .../components/DefaultErrorPage.tsx | 46 +++++++++ .../errorPageLayout/ErrorPageLayout.tsx | 93 +++++-------------- .../errorPageLayout/ResetButton.tsx | 8 +- .../errorPageLayout/StatusCodeError.tsx | 24 +++++ .../components/errorPageLayout/index.ts | 1 + src/features/customErrors/components/index.ts | 2 +- 10 files changed, 105 insertions(+), 84 deletions(-) create mode 100644 src/features/customErrors/components/DefaultErrorPage.tsx create mode 100644 src/features/customErrors/components/errorPageLayout/StatusCodeError.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index aab4ce79..5f782b22 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,5 +12,6 @@ "source.fixAll.stylelint": "explicit", "source.addMissingImports": "explicit" }, - "editor.formatOnSave": true + "editor.formatOnSave": true, + "cSpell.words": ["bandiboodi", "bandi", "boodi"] } diff --git a/src/app/error/page.tsx b/src/app/error/page.tsx index cef76ee6..f9b0c04a 100644 --- a/src/app/error/page.tsx +++ b/src/app/error/page.tsx @@ -1,7 +1,7 @@ -import { ErrorPageLayout } from '@/features/customErrors/components'; +import { DefaultErrorPage } from '@/features/customErrors/components'; const ErrorPage = () => { - return ; + return ; }; export default ErrorPage; diff --git a/src/app/home/[username]/error.tsx b/src/app/home/[username]/error.tsx index df9006f9..ff636f34 100644 --- a/src/app/home/[username]/error.tsx +++ b/src/app/home/[username]/error.tsx @@ -1,9 +1,9 @@ 'use client'; -import { ErrorPageLayout } from '@/features/customErrors/components'; +import { DefaultErrorPage } from '@/features/customErrors/components'; const Error = ({ error }: { error: Error & { digest?: string } }) => { - return ; + return ; }; export default Error; diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 7077818a..c8d72ff8 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,7 +1,7 @@ -import { ErrorPageLayout } from '@/features/customErrors/components'; +import { DefaultErrorPage } from '@/features/customErrors/components'; const NotFoundPage = () => { - return ; + return ; }; export default NotFoundPage; diff --git a/src/features/customErrors/components/DefaultErrorPage.tsx b/src/features/customErrors/components/DefaultErrorPage.tsx new file mode 100644 index 00000000..7a0ea2c8 --- /dev/null +++ b/src/features/customErrors/components/DefaultErrorPage.tsx @@ -0,0 +1,46 @@ +'use client'; + +import Image from 'next/image'; + +import LockImage from '@/assets/images/error/lock.png'; + +import { ErrorPageLayout, type ErrorPageLayoutProps } from './errorPageLayout/ErrorPageLayout'; +import { ResetButton } from './errorPageLayout/ResetButton'; +import { StatusCodeError } from './errorPageLayout'; + +type StandardStatusCodeProps = 404 | 500 | 403; +export interface DefaultErrorPageProps { + statusCode: StandardStatusCodeProps; +} + +type ErrorValuesProps = Record; + +const ERROR_VALUES: ErrorValuesProps = { + 404: { + title: '원하시는 페이지를 찾을 수 없어요. \n 페이지 주소를 다시 확인해 주세요.', + TopComponent: , + bottomImage: 'default', + footer: , + }, + 500: { + title: '앗, 에러가 발생했어요. \n 다시 시도해 주세요.', + TopComponent: , + bottomImage: 'default', + footer: , + }, + 403: { + title: '공개되지 않은 인생지도에요. \n 돌아가세요.', + TopComponent: 403_error, + bottomImage: 'public', + footer: , + }, +}; + +export const DefaultErrorPage = ({ statusCode }: DefaultErrorPageProps) => ( + +); diff --git a/src/features/customErrors/components/errorPageLayout/ErrorPageLayout.tsx b/src/features/customErrors/components/errorPageLayout/ErrorPageLayout.tsx index 1ae1b746..ba569714 100644 --- a/src/features/customErrors/components/errorPageLayout/ErrorPageLayout.tsx +++ b/src/features/customErrors/components/errorPageLayout/ErrorPageLayout.tsx @@ -1,86 +1,35 @@ -'use client'; - import type { ReactNode } from 'react'; import type { StaticImport } from 'next/dist/shared/lib/get-img-props'; import Image from 'next/image'; import BandiboodiErrorImage from '@/assets/images/bandiboodi_error.png'; import BandiboodiPublicErrorImage from '@/assets/images/bandiboodi-public-error.png'; -import Error404Image from '@/assets/images/error/404.png'; -import Error500Image from '@/assets/images/error/500.png'; -import ErrorTextImage from '@/assets/images/error/error.png'; -import LockImage from '@/assets/images/error/lock.png'; import { Typography } from '@/components'; -import { ResetButton } from './ResetButton'; - +type BottomImageProps = 'default' | 'public'; export interface ErrorPageLayoutProps { - statusCode: 404 | 500 | 403; + TopComponent?: ReactNode; + title: string; + bottomImage?: BottomImageProps; + footer?: ReactNode; } -type ErrorValuesProps = { - [key in ErrorPageLayoutProps['statusCode']]: { - title: string | ReactNode; - statusImage: { src: StaticImport; width: number; height: number }; - }; -}; +const bottomImageToStaticImport: Record = { + default: BandiboodiErrorImage, + public: BandiboodiPublicErrorImage, +} as const; -const ERROR_VALUES: ErrorValuesProps = { - '404': { - title: '원하시는 페이지를 찾을 수 없어요. \n 페이지 주소를 다시 확인해 주세요.', - statusImage: { - src: Error404Image, - width: 270, - height: 230, - }, - }, - '500': { - title: '앗, 에러가 발생했어요. \n 다시 시도해 주세요.', - statusImage: { - src: Error500Image, - width: 270, - height: 230, - }, - }, - '403': { - title: '공개되지 않은 인생지도에요. \n 돌아가세요.', - statusImage: { - src: LockImage, - width: 146, - height: 189, - }, - }, -}; - -export const ErrorPageLayout = ({ statusCode }: ErrorPageLayoutProps) => ( -
-
-
-
- {`${statusCode}_error_image`} - error -
- - {ERROR_VALUES[statusCode].title} +export const ErrorPageLayout = ({ TopComponent, title, bottomImage = 'default', footer }: ErrorPageLayoutProps) => { + return ( +
+
+
{TopComponent}
+ + {title} -
- bandiboodi_error -
-
-
- + {`${bottomImage}_error`}
-
-
-); +
{footer}
+
+ ); +}; diff --git a/src/features/customErrors/components/errorPageLayout/ResetButton.tsx b/src/features/customErrors/components/errorPageLayout/ResetButton.tsx index 920d9fce..269d1946 100644 --- a/src/features/customErrors/components/errorPageLayout/ResetButton.tsx +++ b/src/features/customErrors/components/errorPageLayout/ResetButton.tsx @@ -4,9 +4,9 @@ import { useRouter } from 'next/navigation'; import { Button } from '@/components'; import { useGetMemberData } from '@/hooks/reactQuery/auth'; -import type { ErrorPageLayoutProps } from './ErrorPageLayout'; +import type { DefaultErrorPageProps } from '../DefaultErrorPage'; -export const ResetButton = ({ statusCode }: ErrorPageLayoutProps) => { +export const ResetButton = ({ statusCode }: DefaultErrorPageProps) => { const router = useRouter(); const { data: memberData } = useGetMemberData(); @@ -18,11 +18,11 @@ export const ResetButton = ({ statusCode }: ErrorPageLayoutProps) => { ); return ( - <> +
- +
); }; diff --git a/src/features/customErrors/components/errorPageLayout/StatusCodeError.tsx b/src/features/customErrors/components/errorPageLayout/StatusCodeError.tsx new file mode 100644 index 00000000..34aec1a6 --- /dev/null +++ b/src/features/customErrors/components/errorPageLayout/StatusCodeError.tsx @@ -0,0 +1,24 @@ +import type { StaticImport } from 'next/dist/shared/lib/get-img-props'; +import Image from 'next/image'; + +import Error404Image from '@/assets/images/error/404.png'; +import Error500Image from '@/assets/images/error/500.png'; +import ErrorTextImage from '@/assets/images/error/error.png'; + +const statusToImage: Record<404 | 500, StaticImport> = { + 404: Error404Image, + 500: Error500Image, +}; + +interface StatusCodeErrorProps { + status: keyof typeof statusToImage; +} + +export const StatusCodeError = ({ status }: StatusCodeErrorProps) => { + return ( +
+ {`${status}_error`} + error_text +
+ ); +}; diff --git a/src/features/customErrors/components/errorPageLayout/index.ts b/src/features/customErrors/components/errorPageLayout/index.ts index 78cee8c0..dcde7e6c 100644 --- a/src/features/customErrors/components/errorPageLayout/index.ts +++ b/src/features/customErrors/components/errorPageLayout/index.ts @@ -1 +1,2 @@ export { ErrorPageLayout } from './ErrorPageLayout'; +export { StatusCodeError } from './StatusCodeError'; diff --git a/src/features/customErrors/components/index.ts b/src/features/customErrors/components/index.ts index e7e35094..e7e2cca3 100644 --- a/src/features/customErrors/components/index.ts +++ b/src/features/customErrors/components/index.ts @@ -1 +1 @@ -export { ErrorPageLayout } from './errorPageLayout'; +export { DefaultErrorPage } from './DefaultErrorPage'; From 4645efa8ab3df9a7d480cab43296e1cdf8ba2dad Mon Sep 17 00:00:00 2001 From: Doeun Kim Date: Tue, 2 Apr 2024 21:53:14 +0900 Subject: [PATCH 10/21] =?UTF-8?q?fix:=20=EC=A1=B4=EC=9E=AC=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=9C=A0=EC=A0=80=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EC=A0=91=EA=B7=BC=EC=8B=9C=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20(#237)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/home/[username]/error.tsx | 23 +++++++++++++++++++++-- src/assets/images/error/close.png | Bin 0 -> 35312 bytes 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 src/assets/images/error/close.png diff --git a/src/app/home/[username]/error.tsx b/src/app/home/[username]/error.tsx index ff636f34..331caa52 100644 --- a/src/app/home/[username]/error.tsx +++ b/src/app/home/[username]/error.tsx @@ -1,9 +1,28 @@ 'use client'; +import Image from 'next/image'; +import type { AxiosError } from 'axios'; + +import NullUserErrorImage from '@/assets/images/error/close.png'; import { DefaultErrorPage } from '@/features/customErrors/components'; +import { ErrorPageLayout } from '@/features/customErrors/components/errorPageLayout'; +import { ResetButton } from '@/features/customErrors/components/errorPageLayout/ResetButton'; + +const ERROR_STATUS_CODE = { + public: 403, + nullUser: 404, +}; -const Error = ({ error }: { error: Error & { digest?: string } }) => { - return ; +const Error = ({ error }: { error: AxiosError & { digest?: string } }) => { + if (error.status === ERROR_STATUS_CODE.public) return ; + if (error.status === ERROR_STATUS_CODE.nullUser) + return ( + } + title={`앗, 존재하지 않는 인생지도에요. \n 페이지 주소를 다시 확인해주세요.`} + footer={} + /> + ); }; export default Error; diff --git a/src/assets/images/error/close.png b/src/assets/images/error/close.png new file mode 100644 index 0000000000000000000000000000000000000000..06b9a27df1839ad7d8616d5fae126961cecf3d7c GIT binary patch literal 35312 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 v Date: Tue, 2 Apr 2024 21:54:26 +0900 Subject: [PATCH 11/21] =?UTF-8?q?feat:=20=EC=9D=91=EC=9B=90=ED=95=9C=20?= =?UTF-8?q?=EC=82=AC=EB=9E=8C=20=EC=A1=B0=ED=9A=8C=20api=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B0=92=20=EC=88=98=EC=A0=95=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=20/=20=EC=9D=91=EC=9B=90=ED=95=98=EA=B8=B0=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20=EC=8B=9C=EC=97=90=EB=8F=84=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=86=A0=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EB=9C=A8=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 응원한 사람 조회 api 응답 값 수정 반영 * feat: 응원한 사람 조회 api 응답 값 수정 반영 * refactor: 응원 실패 시, early return * fix: 응원하기 성공했을 때에도 에러 토스트가 뜨는 버그 수정 --- .../lifeMap/PublicLifeMapBottomArea.tsx | 28 ++++++++++--------- .../reactQuery/cheering/useGetCheerer.ts | 14 +++++----- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/features/home/components/lifeMap/PublicLifeMapBottomArea.tsx b/src/features/home/components/lifeMap/PublicLifeMapBottomArea.tsx index da345df2..31331255 100644 --- a/src/features/home/components/lifeMap/PublicLifeMapBottomArea.tsx +++ b/src/features/home/components/lifeMap/PublicLifeMapBottomArea.tsx @@ -22,26 +22,23 @@ const PublicLifeMapBottomArea = ({ username }: { username: string }) => { const { open } = useOverlay(); const toast = useToast(); - const [isCheeringSuccess, setIsCheeringSuccess] = useState(false); + const [lastCheerTime, setLastCheerTime] = useState(0); // TODO: Lottie atom을 수정해서 로티 이미지를 플레이하는 방식으로 변경 const { mutate: cheer, isSuccess } = useCreateCheering(username); const CHEER_ANIMATION_INTERVAL = 5400; + const CHEER_LIMIT_INTERVAL = 6000; useEffect(() => { - let timeoutId: NodeJS.Timeout | undefined; - if (isSuccess) { - setIsCheeringSuccess(true); - timeoutId = setTimeout(() => { - setIsCheeringSuccess(false); - }, CHEER_ANIMATION_INTERVAL); - } + if (!isSuccess) return; + + const timeoutId = setTimeout(() => { + setLastCheerTime(0); + }, CHEER_ANIMATION_INTERVAL); return () => { - if (timeoutId) { - clearTimeout(timeoutId); - } + clearTimeout(timeoutId); }; }, [isSuccess]); @@ -55,14 +52,19 @@ const PublicLifeMapBottomArea = ({ username }: { username: string }) => { return; } - if (!isCheeringSuccess) toast.warning('1분 뒤에 응원할 수 있어요.'); + const now = Date.now(); + if (now - lastCheerTime < CHEER_LIMIT_INTERVAL && lastCheerTime !== 0) { + toast.warning('1분 뒤에 응원할 수 있어요.'); + return; + } + setLastCheerTime(now); throttleCheer(); }; return ( <> - {isCheeringSuccess && } + {isSuccess && }
diff --git a/src/hooks/reactQuery/cheering/useGetCheerer.ts b/src/hooks/reactQuery/cheering/useGetCheerer.ts index fbddaa14..9b31e9bf 100644 --- a/src/hooks/reactQuery/cheering/useGetCheerer.ts +++ b/src/hooks/reactQuery/cheering/useGetCheerer.ts @@ -2,17 +2,19 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import { api } from '@/apis'; import { useGetMemberData } from '@/hooks/reactQuery/auth/useGetMemberData'; -import { createQueryString } from '@/utils/queryString'; export type CheererResponse = { contents: { + cheererId: number; userId: number; userName: string; userNickName: string; userImageUrl: string; cheeringAt: string; + cursorId: number; }[]; - isLastPage: boolean; + isLast: boolean; + nextCursor: number; total: number; }; @@ -24,14 +26,12 @@ export const useGetCheerer = () => { return useInfiniteQuery({ queryKey: ['cheerer', my?.lifeMap.lifeMapId], queryFn: ({ pageParam }) => { - const queryString = createQueryString({ - pageSize: PAGE_SIZE, - lastCursorAt: pageParam, + return api.get(`/cheering/squad/${my?.lifeMap.lifeMapId}`, { + params: { cursor: pageParam, size: PAGE_SIZE }, }); - return api.get(`/cheering/squad/${my?.lifeMap.lifeMapId}?${queryString}`); }, initialPageParam: null, - getNextPageParam: ({ isLastPage, contents }) => (isLastPage ? null : contents[contents.length - 1].cheeringAt), + getNextPageParam: ({ isLast, nextCursor }) => (isLast ? null : nextCursor), enabled: my?.lifeMap.lifeMapId !== undefined, }); }; From 0264c2fe8c40c2f98e3bade6ddfe7c7ac662d414 Mon Sep 17 00:00:00 2001 From: Heojoooon <45158550+hjy0951@users.noreply.github.com> Date: Tue, 2 Apr 2024 22:08:58 +0900 Subject: [PATCH 12/21] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=AA=A8=EC=A7=80=20=EC=B6=94=EA=B0=80/=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EC=8B=9C=20=EB=82=99=EA=B4=80=EC=A0=81=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=A0=81=EC=9A=A9=20(#234)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 피드 이모지 추가/삭제시 낙관적 업데이트 적용 * refactor: 불필요한 코드 삭제 * feat: 피드페이지에 다시 접근시 캐싱 데이터를 불러오지 않도록 언마운트될 때 캐시 내용 삭제 --- src/features/feed/FeedBody.tsx | 10 +++ .../reactQuery/emoji/useCreateEmojiForFeed.ts | 65 ++++++++++++++++++- .../emoji/useDeleteReactedEmojiForFeed.ts | 53 ++++++++++++++- 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/src/features/feed/FeedBody.tsx b/src/features/feed/FeedBody.tsx index 9fbf84d6..6c1c0979 100644 --- a/src/features/feed/FeedBody.tsx +++ b/src/features/feed/FeedBody.tsx @@ -1,5 +1,8 @@ 'use client'; +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + import { InfiniteScroller } from '@/components'; import { usePrefetchAllEmoji } from '@/hooks/reactQuery/emoji/useGetAllEmoji'; import { type GoalFeedProps, useGetGoalFeeds } from '@/hooks/reactQuery/goal/useGetGoalFeeds'; @@ -7,9 +10,16 @@ import { type GoalFeedProps, useGetGoalFeeds } from '@/hooks/reactQuery/goal/use import FeedCard from './feedCard/FeedCard'; export const FeedBody = () => { + const queryClient = useQueryClient(); const { data: goalFeedsData, fetchNextPage, hasNextPage } = useGetGoalFeeds(); usePrefetchAllEmoji(); + useEffect(() => { + return () => { + queryClient.removeQueries({ queryKey: ['goalFeeds'] }); + }; + }, [queryClient]); + // TODO: 피드가 0개일 때, 처리 필요 return ( diff --git a/src/hooks/reactQuery/emoji/useCreateEmojiForFeed.ts b/src/hooks/reactQuery/emoji/useCreateEmojiForFeed.ts index c052f4b0..90a04a80 100644 --- a/src/hooks/reactQuery/emoji/useCreateEmojiForFeed.ts +++ b/src/hooks/reactQuery/emoji/useCreateEmojiForFeed.ts @@ -1,20 +1,79 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { InfiniteData } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { api } from '@/apis'; +import type { GoalFeedResponse } from '../goal/useGetGoalFeeds'; +import { useOptimisticUpdate } from '../useOptimisticUpdate'; + +import type { Emoji, EmojisResponse } from './useGetAllEmoji'; + type EmojiRequestParams = { goalId: number; emojiId: number; }; +interface GoalFeedInfinityResponse extends InfiniteData {} + +const NOT_FOUNDED_INDEX = -1; + export const useCreateEmojiForFeed = () => { - const queryClient = useQueryClient(); + const { queryClient, optimisticUpdater } = useOptimisticUpdate(); + const targetQueryKey = ['goalFeeds']; return useMutation({ mutationFn: ({ goalId, emojiId }: EmojiRequestParams) => api.post(`/goal/${goalId}/emoji/${emojiId}`), + onMutate: async ({ goalId, emojiId }) => { + const updater = (old: GoalFeedInfinityResponse): GoalFeedInfinityResponse => { + const newData = JSON.parse(JSON.stringify(old)) as GoalFeedInfinityResponse; + const reactEmojiData = queryClient + .getQueryData(['all-emoji']) + ?.find((emoji) => emoji.id === emojiId) as Emoji; + + let currentPage, + pageIndex = NOT_FOUNDED_INDEX, + goalIndex = NOT_FOUNDED_INDEX; + + while (pageIndex < newData.pages.length && goalIndex < 0) { + pageIndex++; + currentPage = newData.pages[pageIndex]; + goalIndex = currentPage.goals.findIndex(({ goal }) => goal.id === goalId); + } + + if (!currentPage) return old; + + const targetGoal = currentPage.goals[goalIndex]; + const targetEmojis = targetGoal.emojis; + const existedEmojiIndex = targetEmojis.findIndex(({ id }) => id === emojiId); + + let newEmoji = null; + + if (existedEmojiIndex === NOT_FOUNDED_INDEX) { + newEmoji = { ...reactEmojiData, reactCount: 1, isMyReaction: true }; + } else { + const targetEmoji = targetEmojis[existedEmojiIndex]; + newEmoji = { ...targetEmoji, reactCount: targetEmoji.reactCount + 1 }; + targetEmojis.splice(existedEmojiIndex, 1, newEmoji); + } + + const newEmojis = existedEmojiIndex >= 0 ? targetEmojis : [...targetEmojis, newEmoji]; + targetGoal.emojis = newEmojis; + targetGoal.count.reaction++; + + return newData; + }; + + const context = await optimisticUpdater({ + queryKey: targetQueryKey, + updater, + }); + return context; + }, onSuccess: (_, { goalId }) => { - queryClient.invalidateQueries({ queryKey: ['goalFeeds'] }); queryClient.invalidateQueries({ queryKey: ['emoji', goalId] }); }, + onError: (_, __, context) => { + queryClient.setQueryData(targetQueryKey, context?.previous); + }, }); }; diff --git a/src/hooks/reactQuery/emoji/useDeleteReactedEmojiForFeed.ts b/src/hooks/reactQuery/emoji/useDeleteReactedEmojiForFeed.ts index 10eee5bf..9481deb9 100644 --- a/src/hooks/reactQuery/emoji/useDeleteReactedEmojiForFeed.ts +++ b/src/hooks/reactQuery/emoji/useDeleteReactedEmojiForFeed.ts @@ -1,20 +1,67 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { InfiniteData } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { api } from '@/apis'; +import type { GoalFeedResponse } from '../goal/useGetGoalFeeds'; +import { useOptimisticUpdate } from '../useOptimisticUpdate'; + type EmojiRequestParams = { goalId: number; emojiId: number; }; +interface GoalFeedInfinityResponse extends InfiniteData {} + +const NOT_FOUNDED_INDEX = -1; + export const useDeleteReactedEmojiForFeed = () => { - const queryClient = useQueryClient(); + const { queryClient, optimisticUpdater } = useOptimisticUpdate(); + const targetQueryKey = ['goalFeeds']; return useMutation({ mutationFn: ({ goalId, emojiId }: EmojiRequestParams) => api.delete(`/goal/${goalId}/emoji/${emojiId}`), + onMutate: async ({ goalId, emojiId }) => { + const updater = (old: GoalFeedInfinityResponse): GoalFeedInfinityResponse => { + const newData = JSON.parse(JSON.stringify(old)) as GoalFeedInfinityResponse; + + let currentPage, + pageIndex = NOT_FOUNDED_INDEX, + goalIndex = NOT_FOUNDED_INDEX; + + while (pageIndex < newData.pages.length && goalIndex < 0) { + pageIndex++; + currentPage = newData.pages[pageIndex]; + goalIndex = currentPage.goals.findIndex(({ goal }) => goal.id === goalId); + } + + if (!currentPage) return old; + + const targetGoal = currentPage.goals[goalIndex]; + const targetEmojis = targetGoal.emojis; + const existedEmojiIndex = targetEmojis.findIndex(({ id }) => id === emojiId); + + if (existedEmojiIndex === NOT_FOUNDED_INDEX) return old; + + const targetEmoji = targetEmojis[existedEmojiIndex]; + targetEmoji.reactCount--; + if (targetEmoji.reactCount === 0) targetEmojis.splice(existedEmojiIndex, 1); + + return newData; + }; + + const context = await optimisticUpdater({ + queryKey: targetQueryKey, + updater, + }); + return context; + }, onSuccess: (_, { goalId }) => { - queryClient.invalidateQueries({ queryKey: ['goalFeeds'] }); + // queryClient.invalidateQueries({ queryKey: ['goalFeeds'] }); queryClient.invalidateQueries({ queryKey: ['emoji', goalId] }); }, + onError: (_, __, context) => { + queryClient.setQueryData(targetQueryKey, context?.previous); + }, }); }; From c360db8493ebcfc73fc2a66825aeb6ecf4c92836 Mon Sep 17 00:00:00 2001 From: Hongsuk Ryu <34956359+deepbig@users.noreply.github.com> Date: Tue, 2 Apr 2024 22:23:27 +0900 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20=EB=AA=A9=ED=91=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EB=B0=9C=20(#192)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 레이아웃 버그 수정 및 한글 문법 수정 * Textarea atom 버그 수정 * React Hook Form을 사용하는 TextField molecule 생성 * GoalUpdateForm 추가 * 불필요한 코드 삭제 및 RHFTextField.tsx에 Controller 반영 * RHFTextField를 textarea, input 태그 둘 다 핸들링 할 수 있게 수정 * RHFTextField 함수를 더 읽기 좋게 수정 * RHFDateField 생성 * RHFImagePicker와 RHFTagPicker 생성 * RHFTextField 로직 분리 * GoalUpdateForm 로직 완성 * 테스트하기 위해 추가된 promise 제거 * fix build error --- src/app/goal/detail/layout.tsx | 2 +- src/app/goal/new/layout.tsx | 2 +- src/app/goal/update/[id]/page.tsx | 29 ++++ src/app/goal/update/layout.tsx | 9 ++ src/components/atoms/input/Input.tsx | 2 +- src/components/atoms/textarea/Textarea.tsx | 5 +- .../molecules/reactHookForm/RHFDateField.tsx | 108 +++++++++++++++ .../reactHookForm/RHFImagePicker.tsx | 66 +++++++++ .../molecules/reactHookForm/RHFTagPicker.tsx | 54 ++++++++ .../molecules/reactHookForm/RHFTextField.tsx | 70 ++++++++++ .../molecules/reactHookForm/index.ts | 1 + .../goal/components/detail/ContentBody.tsx | 2 +- .../goal/components/detail/DetailHeader.tsx | 24 ++-- .../goal/components/update/GoalUpdateForm.tsx | 125 ++++++++++++++++++ .../components/update/GoalUpdateHeader.tsx | 24 ++++ .../reactQuery/auth/useUpdateMemberData.ts | 2 +- src/hooks/reactQuery/goal/useUpdateGoal.ts | 36 +++++ src/hooks/useInput.ts | 2 +- src/layout/FormLayout.tsx | 17 +++ src/utils/date.test.ts | 16 +-- src/utils/date.ts | 20 ++- 21 files changed, 588 insertions(+), 28 deletions(-) create mode 100644 src/app/goal/update/[id]/page.tsx create mode 100644 src/app/goal/update/layout.tsx create mode 100644 src/components/molecules/reactHookForm/RHFDateField.tsx create mode 100644 src/components/molecules/reactHookForm/RHFImagePicker.tsx create mode 100644 src/components/molecules/reactHookForm/RHFTagPicker.tsx create mode 100644 src/components/molecules/reactHookForm/RHFTextField.tsx create mode 100644 src/components/molecules/reactHookForm/index.ts create mode 100644 src/features/goal/components/update/GoalUpdateForm.tsx create mode 100644 src/features/goal/components/update/GoalUpdateHeader.tsx create mode 100644 src/hooks/reactQuery/goal/useUpdateGoal.ts create mode 100644 src/layout/FormLayout.tsx diff --git a/src/app/goal/detail/layout.tsx b/src/app/goal/detail/layout.tsx index 3720312a..bf77c3af 100644 --- a/src/app/goal/detail/layout.tsx +++ b/src/app/goal/detail/layout.tsx @@ -1,7 +1,7 @@ import type { PropsWithChildren } from 'react'; const layout = ({ children }: PropsWithChildren) => { - return
{children}
; + return
{children}
; }; export default layout; diff --git a/src/app/goal/new/layout.tsx b/src/app/goal/new/layout.tsx index e5943221..1b63c673 100644 --- a/src/app/goal/new/layout.tsx +++ b/src/app/goal/new/layout.tsx @@ -12,7 +12,7 @@ import CreateGoalFormProvider from '@/features/goal/contexts/CreateGoalFormProvi const Layout = ({ children }: PropsWithChildren) => { return ( -
+
{ + const goalId = +params['id']; + const { data: goal } = useGetGoal({ goalId }); + const router = useRouter(); + + useEffect(() => { + if (goal?.isMyGoal === false) { + router.push(`/goal/detail/${goalId}`); + } + }, [goal, goalId, router]); + + return goal?.isMyGoal && ; +}; + +export default UpdateGoalPage; diff --git a/src/app/goal/update/layout.tsx b/src/app/goal/update/layout.tsx new file mode 100644 index 00000000..2b034d7d --- /dev/null +++ b/src/app/goal/update/layout.tsx @@ -0,0 +1,9 @@ +'use client'; + +import type { PropsWithChildren } from 'react'; + +const UpdateGoalLayout = ({ children }: PropsWithChildren) => { + return
{children}
; +}; + +export default UpdateGoalLayout; diff --git a/src/components/atoms/input/Input.tsx b/src/components/atoms/input/Input.tsx index 242986a3..b16e77a6 100644 --- a/src/components/atoms/input/Input.tsx +++ b/src/components/atoms/input/Input.tsx @@ -42,9 +42,9 @@ export const Input = forwardRef( {includeSubmitButton && (
diff --git a/src/components/atoms/textarea/Textarea.tsx b/src/components/atoms/textarea/Textarea.tsx index e5fd841e..ba04312a 100644 --- a/src/components/atoms/textarea/Textarea.tsx +++ b/src/components/atoms/textarea/Textarea.tsx @@ -1,5 +1,4 @@ -import type { HTMLAttributes } from 'react'; -import { forwardRef } from 'react'; +import { forwardRef, type TextareaHTMLAttributes } from 'react'; import type { VariantProps } from 'class-variance-authority'; import { cva } from 'class-variance-authority'; @@ -9,7 +8,7 @@ const textareaVariants = cva( focus-visible:outline-none resize-none', ); -interface TextareaProps extends HTMLAttributes, VariantProps {} +interface TextareaProps extends TextareaHTMLAttributes, VariantProps {} export const Textarea = forwardRef( ({ className, ...props }: TextareaProps, ref) => { diff --git a/src/components/molecules/reactHookForm/RHFDateField.tsx b/src/components/molecules/reactHookForm/RHFDateField.tsx new file mode 100644 index 00000000..006595b7 --- /dev/null +++ b/src/components/molecules/reactHookForm/RHFDateField.tsx @@ -0,0 +1,108 @@ +import { type InputHTMLAttributes, useEffect, useState } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { Input, Typography } from '@/components/atoms'; + +export interface DateFieldProps extends InputHTMLAttributes { + label?: string; + helperText?: string; + isDayIncluded?: boolean; +} + +export const RHFDateField = ({ label, helperText, isDayIncluded = false }: DateFieldProps) => { + return ( +
+ {label &&
+ ); +}; + +RHFDateField.displayName = 'RHFDateField'; + +const Label = ({ label }: { label: string }) => ( +
+ + {label} + +
+); + +const YearField = () => { + const { control } = useFormContext(); + + return ( + ( + + )} + /> + ); +}; + +const MonthField = () => { + const { control } = useFormContext(); + + return ( + ( + + )} + /> + ); +}; + +const DayField = () => { + const { control, watch, setValue, getValues } = useFormContext(); + const year = watch('year'); + const month = watch('month'); + + // State to hold the maximum number of days for the current month/year + const [maxDays, setMaxDays] = useState(31); + + useEffect(() => { + if (!year || !month) return; + + // Calculate the maximum number of days in the given month/year + const daysInMonth = new Date(year, month, 0).getDate(); + setMaxDays(daysInMonth); + }, [year, month]); + + return ( + ( + + )} + /> + ); +}; + +const HelperText = ({ text }: { text: string }) => ( +
+ + {text} + +
+); diff --git a/src/components/molecules/reactHookForm/RHFImagePicker.tsx b/src/components/molecules/reactHookForm/RHFImagePicker.tsx new file mode 100644 index 00000000..9808714d --- /dev/null +++ b/src/components/molecules/reactHookForm/RHFImagePicker.tsx @@ -0,0 +1,66 @@ +import { Controller, useFormContext } from 'react-hook-form'; +import Image from 'next/image'; + +import { Spinner } from '@/components/atoms/spinner'; +import { Typography } from '@/components/atoms/typography'; + +import { RHFTagPicker } from './RHFTagPicker'; + +interface ImagePickerProps { + name: string; + label?: string; + images: ImagesProps[] | undefined; +} + +interface ImagesProps { + id: number; + name: string; + url: string; +} + +export const RHFImagePicker = ({ name, label, images }: ImagePickerProps) => { + const { control } = useFormContext(); + + return ( +
+ {label &&
+ ); +}; +RHFTagPicker.displayName = 'RHFImagePicker'; + +const Label = ({ label }: { label: string }) => ( +
+ + {label} + +
+); diff --git a/src/components/molecules/reactHookForm/RHFTagPicker.tsx b/src/components/molecules/reactHookForm/RHFTagPicker.tsx new file mode 100644 index 00000000..e643cfea --- /dev/null +++ b/src/components/molecules/reactHookForm/RHFTagPicker.tsx @@ -0,0 +1,54 @@ +import { Controller, useFormContext } from 'react-hook-form'; + +import { Spinner } from '@/components/atoms/spinner'; +import { Tag } from '@/components/atoms/tag'; +import { Typography } from '@/components/atoms/typography'; + +interface TagPickerProps { + name: string; + label?: string; + tags: TagsProps[] | undefined; +} + +export interface TagsProps { + id: number; + content: string; +} + +export const RHFTagPicker = ({ name, label, tags }: TagPickerProps) => { + const { control } = useFormContext(); + + return ( +
+ {label &&
+ ); +}; +RHFTagPicker.displayName = 'RHFTagPicker'; + +const Label = ({ label }: { label: string }) => ( +
+ + {label} + +
+); diff --git a/src/components/molecules/reactHookForm/RHFTextField.tsx b/src/components/molecules/reactHookForm/RHFTextField.tsx new file mode 100644 index 00000000..0b8259be --- /dev/null +++ b/src/components/molecules/reactHookForm/RHFTextField.tsx @@ -0,0 +1,70 @@ +import { type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'; +import type { ControllerRenderProps } from 'react-hook-form'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { Input, Textarea, Typography } from '@/components/atoms'; + +export interface TextFieldProps { + label?: string; + helperText?: string; +} +export interface InputTextFieldProps extends InputHTMLAttributes, TextFieldProps { + name: string; +} + +export interface TextareaTextFieldProps extends TextareaHTMLAttributes, TextFieldProps { + name: string; +} + +export const RHFTextField = (props: InputTextFieldProps | TextareaTextFieldProps) => { + const { name, label, helperText, maxLength } = props; + const { control } = useFormContext(); + const isTextarea = 'rows' in props; + + const renderField = (field: ControllerRenderProps) => { + if (isTextarea) { + return