-
Notifications
You must be signed in to change notification settings - Fork 4
Feat/markup/create meeting/DEVING-40 모임생성 및 수정 페이지 구현 #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 31 commits
bb814b2
7e6d5b1
168f256
764c7d2
6d92b47
62e840c
1220499
12d0937
4d321d9
4d60de8
455cfb4
aaae9fd
d136b73
905b65a
b90d265
0908578
0e4d01e
9aced32
b10c9e1
1447ee5
0daa7a5
ee42b65
3ec94dd
8db61bd
95dc4ac
48365b2
08edd96
1955267
eb6620a
696f895
3603efb
30d66f3
755b9e3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,13 +19,15 @@ | |
| "@svgr/webpack": "^8.1.0", | ||
| "@tanstack/react-query": "^5.66.7", | ||
| "@tanstack/react-query-devtools": "^5.66.7", | ||
| "@types/react-datepicker": "^7.0.0", | ||
| "autoprefixer": "^10.4.20", | ||
| "axios": "^1.7.9", | ||
| "class-variance-authority": "^0.7.1", | ||
| "clsx": "^2.1.1", | ||
| "lucide-react": "^0.475.0", | ||
| "next": "14.1.0", | ||
| "react": "^18.2.0", | ||
| "react-datepicker": "^8.1.0", | ||
|
||
| "react-dom": "^18.2.0", | ||
| "react-hook-form": "^7.54.2", | ||
| "tailwind-merge": "^3.0.1", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import Header from '@/components/common/Header'; | ||
| import ReactQueryProviders from '@/hooks/useReactQuery'; | ||
| import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; | ||
| import type { Metadata } from 'next'; | ||
| import localFont from 'next/font/local'; | ||
|
|
||
|
|
@@ -28,6 +29,7 @@ export default function RootLayout({ | |
| <ReactQueryProviders> | ||
| <Header /> | ||
| <div className="m-auto max-w-[1340px]">{children}</div> | ||
| <ReactQueryDevtools initialIsOpen={false} /> | ||
|
||
| </ReactQueryProviders> | ||
| </body> | ||
| </html> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| 'use client'; | ||
|
|
||
| import { | ||
| CategoryField, | ||
| DateField, | ||
| DescriptionField, | ||
| ImageField, | ||
| InfoMessage, | ||
| LocationField, | ||
| MemberLimitField, | ||
| PrivacyField, | ||
| RequireApprovalField, | ||
| SubmitButton, | ||
| TechStackField, | ||
| TitleField, | ||
| } from '@/app/meeting/components/form/form-filed'; | ||
| import useMeetingFormMutation from '@/hooks/mutations/useMeetingFormMutation'; | ||
| import { convertImageToBase64 } from '@/util/base64'; | ||
| import { useRouter } from 'next/navigation'; | ||
| import React from 'react'; | ||
| import { FormProvider, useForm } from 'react-hook-form'; | ||
| import { CreateMeetingPayload } from 'types/meetingForm'; | ||
|
|
||
| import { MEETING_TYPES } from '../../constants/meeting-form/meetingConstants'; | ||
|
|
||
| interface MeetingFormProps { | ||
| mode: 'create' | 'edit'; | ||
| initialData?: Partial<CreateMeetingPayload>; | ||
| meetingId?: string; | ||
| } | ||
|
|
||
| export default function MeetingForm({ | ||
| mode, | ||
| initialData = {}, | ||
| meetingId, | ||
| }: MeetingFormProps) { | ||
| const router = useRouter(); | ||
| const { createMeeting, isLoading } = useMeetingFormMutation(); | ||
|
|
||
| // 날짜 YYYY-MM-DD 형식으로 변환 | ||
| const today = new Date(); | ||
| const formattedToday = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; | ||
|
Comment on lines
+40
to
+42
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 요 부분은 util/date.ts에 같이 정리하면 좋을 것 같아요! 참고 |
||
|
|
||
| // 카테고리 라벨에서 ID 찾기 (URL 용) | ||
| const getCategoryId = (label: string) => { | ||
| const category = MEETING_TYPES.find((type) => type.label === label); | ||
| return category ? category.id : ''; | ||
| }; | ||
|
|
||
| const defaultValues: CreateMeetingPayload = { | ||
| meetingTitle: '', | ||
| categoryTitle: '', | ||
| imageName: '', | ||
| imageEncodedBase64: '', | ||
| content: '', | ||
| location: '', | ||
| maxMember: 0, | ||
| startDate: formattedToday, | ||
| isPublic: true, | ||
| requireApproval: false, | ||
| skillArray: [], | ||
| ...initialData, | ||
| }; | ||
|
|
||
| const methods = useForm<CreateMeetingPayload>({ | ||
| defaultValues, | ||
| }); | ||
|
|
||
| const { handleSubmit } = methods; | ||
|
|
||
| const onSubmit = async (data: CreateMeetingPayload) => { | ||
| try { | ||
| // 이미지 처리 | ||
| const fileInput = document.getElementById('image') as HTMLInputElement; | ||
| if (fileInput?.files && fileInput.files.length > 0) { | ||
| const imageData = await convertImageToBase64(fileInput.files[0]); | ||
| data.imageName = imageData.name; | ||
| data.imageEncodedBase64 = imageData.base64; | ||
| } | ||
|
|
||
| if (mode === 'create') { | ||
| // 모임 생성 | ||
| const result = await createMeeting.mutateAsync(data); | ||
|
|
||
| // 성공 시 상세 페이지로 이동 | ||
| const categoryId = getCategoryId(data.categoryTitle); | ||
| router.push(`/meeting/${categoryId}/${result.data.meetingId}`); | ||
| } else if (mode === 'edit' && meetingId) { | ||
| // 모임 수정 (TODO: API 구현 시 수정) | ||
| // const result = await updateMeeting.mutateAsync({ id: meetingId, data }); | ||
|
|
||
| // 수정 성공 시 상세 페이지로 이동 | ||
| const categoryId = getCategoryId(data.categoryTitle); | ||
| router.push(`/meeting/${categoryId}/${meetingId}`); | ||
| } | ||
| } catch (error) {} | ||
| }; | ||
|
|
||
| return ( | ||
| <FormProvider {...methods}> | ||
| <div className="mx-auto w-full max-w-3xl p-6"> | ||
| <h1 className="typo-heading1 mb-8 text-center"> | ||
| {mode === 'create' ? '모임 생성하기' : '모임 수정하기'} | ||
| </h1> | ||
|
|
||
| <form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> | ||
| <TitleField required /> | ||
| <CategoryField required /> | ||
| <LocationField required /> | ||
| <DateField required /> | ||
| <MemberLimitField required /> | ||
| <TechStackField maxSelections={5} /> | ||
| <ImageField required={true} /> | ||
| <DescriptionField required /> | ||
| <RequireApprovalField /> | ||
| <PrivacyField /> | ||
| <InfoMessage /> | ||
| <SubmitButton | ||
| text={mode === 'create' ? '모임 생성하기' : '모임 수정하기'} | ||
| isLoading={isLoading} | ||
| /> | ||
| </form> | ||
| </div> | ||
| </FormProvider> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import { MEETING_TYPES } from '@/app/meeting/constants/meeting-form/meetingConstants'; | ||
| import { cn } from '@/util/cn'; | ||
| import { Check } from 'lucide-react'; | ||
| import { Controller, useFormContext } from 'react-hook-form'; | ||
| import { CreateMeetingPayload } from 'types/meetingForm'; | ||
|
|
||
| import { meetingTypeValidation } from '../validation'; | ||
|
|
||
| interface CategoryFieldProps { | ||
| required?: boolean; | ||
| } | ||
|
|
||
| const CategoryField = ({ required = true }: CategoryFieldProps) => { | ||
| const { | ||
| control, | ||
| formState: { errors }, | ||
| } = useFormContext<CreateMeetingPayload>(); | ||
|
|
||
| const validation = required | ||
| ? meetingTypeValidation | ||
| : { ...meetingTypeValidation, required: false }; | ||
|
|
||
| return ( | ||
| <div className="space-y-2"> | ||
| <label | ||
| htmlFor="meeting-type-group" | ||
| className="typo-body1 font-medium text-Cgray700" | ||
| > | ||
| 모임 유형 | ||
| </label> | ||
| <Controller | ||
| name="categoryTitle" | ||
| control={control} | ||
| rules={validation} | ||
| render={({ field }) => ( | ||
| <div> | ||
| <div | ||
| className="grid grid-cols-2 gap-3" | ||
| id="meeting-type-group" | ||
| role="radiogroup" | ||
| > | ||
| {MEETING_TYPES.map((type) => ( | ||
| <div | ||
| key={type.id} | ||
| className={cn( | ||
| 'flex cursor-pointer items-center gap-2 rounded-md border p-3 transition-all', | ||
| field.value === type.label | ||
| ? 'border-main bg-default text-main' | ||
| : 'border-Cgray300 text-Cgray500', | ||
| )} | ||
| onClick={() => field.onChange(type.label)} | ||
| onKeyDown={(e) => { | ||
| if (e.key === 'Enter' || e.key === ' ') { | ||
| e.preventDefault(); | ||
| field.onChange(type.label); | ||
| } | ||
| }} | ||
| tabIndex={0} | ||
| role="radio" | ||
| aria-checked={field.value === type.label} | ||
| > | ||
| <div className="flex flex-1 items-center gap-2"> | ||
| {type.icon} | ||
| <span className="typo-body1">{type.label}</span> | ||
| </div> | ||
| {field.value === type.label && ( | ||
| <Check className="ml-auto h-5 w-5 text-main" /> | ||
| )} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| {errors.categoryTitle && ( | ||
| <p className="typo-caption1 mt-[10px] px-[10px] text-warning"> | ||
| {errors.categoryTitle.message} | ||
| </p> | ||
| )} | ||
| </div> | ||
| )} | ||
| /> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default CategoryField; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { DatePicker } from '@/components/ui/form/DatePicker'; | ||
| import { useFormContext } from 'react-hook-form'; | ||
| import { CreateMeetingPayload } from 'types/meetingForm'; | ||
|
|
||
| import { startDateValidation } from '../validation'; | ||
|
|
||
| interface DateFieldProps { | ||
| required?: boolean; | ||
| } | ||
|
|
||
| const DateField = ({ required = true }: DateFieldProps) => { | ||
| const { | ||
| formState: { errors }, | ||
| } = useFormContext<CreateMeetingPayload>(); | ||
|
|
||
| const validation = required | ||
| ? startDateValidation | ||
| : { ...startDateValidation, required: false }; | ||
|
|
||
| return ( | ||
| <DatePicker | ||
| id="startDate" | ||
| name="startDate" | ||
| label="시작 날짜" | ||
| placeholder="시작 날짜를 선택해주세요" | ||
| errorMessage={errors.startDate?.message as string} | ||
| required={required} | ||
| validationRules={validation} | ||
| /> | ||
| ); | ||
| }; | ||
|
|
||
| export default DateField; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
react-datepicker 타입 버전과 라이브러리 버전 불일치 확인 필요
@types/react-datepicker는 버전 ^7.0.0인데 비해,react-datepicker라이브러리는 버전 ^8.1.0입니다. 이러한 버전 불일치는 타입 정의가 최신 라이브러리 기능을 완전히 커버하지 못할 수 있어 타입 오류가 발생할 가능성이 있습니다.🌐 Web query:
💡 Result:
@types/react-datepicker 7.0.0과 react-datepicker 8.1.0은 호환되지 않으며 별도 설치가 필요없습니다. react-datepicker 8.1.0부터는 자체 타입 정의를 포함하고 있기 때문입니다[3][10].
주요 호환성 정보:
타입 정의 중복 문제
react-datepicker 2.0.0 버전 이후부터는 자체 타입 정의를 포함하고 있으며, @types/react-datepicker는 2023년 4월 27일부로 deprecated 처리되었습니다[7][10]. 두 패키지를 동시에 설치하면 타입 충돌이 발생할 수 있습니다.
권장 해결 방법
npm uninstall @types/react-datepicker # 또는 yarn remove @types/react-datepicker기존에 설치된 타입 정의 패키지를 제거해야 합니다[10].
타입 관련 에러가 지속될 경우 다음 명령어로 캐시를 완전히 삭제해야 합니다:
rm -rf node_modules yarn.lock package-lock.json npm install # 또는 yarn install이 방법은 검색 결과 [1]에서 제안된 의존성 문제 해결 방식과 일치합니다.
최신 버전 사용 시 주의사항:
showWeekPicker,selectsRange등 일부 프로퍼티는 타입 정의 업데이트 지연으로 인해 임시 타입 캐스팅이 필요할 수 있음[2]Citations:
리뷰 지적 사항:
@types/react-datepicker제거 필요package.json에@types/react-datepicker가 버전 ^7.0.0으로 포함되어 있으나, react-datepicker 8.1.0부터는 자체 타입 정의를 포함하고 있어 충돌 및 타입 오류가 발생할 수 있습니다.@types/react-datepicker의존성을 제거해주세요. (예:npm uninstall @types/react-datepicker또는yarn remove @types/react-datepicker)