diff --git a/apps/web/src/app/main/community/votesboard/[votesboardId]/edit/page.tsx b/apps/web/src/app/main/community/votesboard/[votesboardId]/edit/page.tsx new file mode 100644 index 00000000..ea9b8e6a --- /dev/null +++ b/apps/web/src/app/main/community/votesboard/[votesboardId]/edit/page.tsx @@ -0,0 +1,52 @@ +// apps/web/src/app/main/community/votesboard/[votesboardId]/edit/page.tsx +'use client'; + +import { useParams, notFound } from 'next/navigation'; +import { Header } from '@/components/header/Header'; +import { useGetVotePost } from '@/generated/api/endpoints/voteboard/voteboard'; +import { VoteboardFormSkeleton } from '../../components/VoteBoardForm.Skeleton'; +import { VoteboardForm } from '../../components/VoteboardForm'; + +/** + * 투표 글 수정 페이지 + * + * @description + * 기존 투표 게시글을 수정하는 페이지입니다. + * URL 파라미터에서 투표 게시글 ID를 받아 상세 데이터를 로딩하고, + * VoteboardForm 컴포넌트를 통해 수정 UI를 제공합니다. + * + * @remarks + * - 게시글 데이터 로딩 중에는 VoteboardFormSkeleton을 표시합니다. + * - 로딩 완료 후 VoteboardForm에 voteId와 initialData를 전달합니다. + */ + +export default function VoteboardEditPage() { + const params = useParams(); + const voteId = Number(params.votesboardId); + + // 투표 게시글 상세 데이터 조회 + const { data, isLoading, error } = useGetVotePost(voteId); + + if (!isLoading && (!data || error)) { + notFound(); + } + + return ( +
+
+ + + + 투표 글 수정 +
+ +
+ {isLoading ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/src/app/main/community/votesboard/components/VoteBoardForm.Skeleton.tsx b/apps/web/src/app/main/community/votesboard/components/VoteBoardForm.Skeleton.tsx new file mode 100644 index 00000000..5b242679 --- /dev/null +++ b/apps/web/src/app/main/community/votesboard/components/VoteBoardForm.Skeleton.tsx @@ -0,0 +1,136 @@ +/** + * VoteboardForm 로딩 스켈레톤 + * + * @description + * VoteboardForm과 최대한 동일한 레이아웃의 로딩 스켈레톤입니다. + * 각 필드의 정확한 높이, 간격, 스타일을 반영하여 깜빡임(CLS)을 최소화합니다. + * + * @remarks + * - 다크모드 지원 + * - 접근성 속성 포함 (role, aria-label) + * - 반응형 레이아웃 + */ +export function VoteboardFormSkeleton() { + return ( +
+ {/* Form 영역 스켈레톤 */} +
+ {/* 카테고리 필드 스켈레톤 */} +
+ {/* Label 스켈레톤 */} +
+
+
+
+ {/* Select Trigger 스켈레톤 */} +
+
+ + {/* 제목 Input 스켈레톤 */} +
+ {/* Label 스켈레톤 */} +
+
+
+
+ {/* Input 필드 스켈레톤 */} +
+ {/* Message 영역 (공간 확보) */} +
+
+ + {/* 내용 TextArea 스켈레톤 */} +
+ {/* Label 스켈레톤 */} +
+
+
+
+ {/* TextArea 필드 스켈레톤 (rows={6} 근사) */} +
+
+ {/* 글자 수 표시 위치 스켈레톤 (있다면 이 위치에 올 것) */} +
+
+ {/* Message 영역 (공간 확보) */} +
+
+ + {/* 마감 기간 필드 스켈레톤 */} +
+ {/* Label 스켈레톤 */} +
+
+
+
+ {/* duration Select Trigger 스켈레톤 */} +
+ {/* Message 영역 */} +
+
+ + {/* 투표 옵션 스켈레톤 */} +
+ {/* 헤더 (라벨 + 옵션 추가 버튼 자리) */} +
+
+
+
+
+
+
+ {/* 옵션 입력 필드 2~3개 정도 */} +
+
+
+
+
+ {/* 전체 에러 메시지 영역 */} +
+
+ + {/* 설정 (복수 선택 / 재투표) 스켈레톤 */} +
+
+
+
+
+
+
+
+
+
+ + {/* 이미지 업로더 스켈레톤 */} +
+ {/* Label 스켈레톤 */} +
+
+
+ {/* ImageUploader 영역 스켈레톤 */} +
+ {/* 추가 버튼 스켈레톤 */} +
+ {/* 썸네일 자리 1~2개 정도 */} +
+
+
+
+
+ + {/* 제출 버튼 스켈레톤 */} +
+
+
+ + {/* 스크린 리더용 안내 */} + + 투표 게시글 데이터를 불러오는 중입니다. 잠시만 기다려주세요. + +
+ ); +} diff --git a/apps/web/src/app/main/community/votesboard/components/VoteboardForm.tsx b/apps/web/src/app/main/community/votesboard/components/VoteboardForm.tsx new file mode 100644 index 00000000..b31510b4 --- /dev/null +++ b/apps/web/src/app/main/community/votesboard/components/VoteboardForm.tsx @@ -0,0 +1,323 @@ +'use client'; + +import React, { useMemo, useState } from 'react'; +import { useForm, useFieldArray, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import Input from '@/components/inputs/Input'; +import TextArea from '@/components/inputs/TextArea'; +import { Button } from '@/components/buttons/Button'; +import { useVoteboardMutation } from '@/hooks/useVoteboardMutation'; +import { + type VoteboardFormData, + voteboardSchema, +} from '../schema/voteboardSchema'; +import type { VotePostDetailResponse } from '@/generated/api/models'; +import { Plus } from 'lucide-react'; +import { VoteboardOptionField } from './VoteoptionField'; +import { CATEGORIES, Category } from '../../constants/categories'; +import { ImageUploader } from '@/components/ImageUploader'; +import { Select } from '@/components/select/Select'; + +export interface VoteboardFormProps { + /** 수정할 투표 게시글 ID (없으면 생성 모드) */ + voteboardId?: number; + /** 초기 폼 데이터 (수정 모드에서 사용) */ + initialData?: VotePostDetailResponse; + /** 초기 선택된 카테고리 (생성 모드에서 사용) */ + initialCategory?: Category; +} + +/** + * VoteboardForm 컴포넌트 + * 자유게시판 게시글 작성 및 수정 폼 + * + * @description + * 투표 게시글을 작성하거나 수정할 수 있는 폼 컴포넌트입니다. + * react-hook-form과 zod를 사용하여 폼 상태 관리 및 유효성 검사를 수행합니다. + * voteboardId가 주어지면 수정 모드로 동작하며, 그렇지 않으면 생성 모드로 동작합니다. + * + * @remarks + * - 이미지 업로드 기능 포함 + * - 동적 투표 옵션 필드 추가/삭제 지원 + * - 생성 및 수정 모드 모두 지원 + * + */ +export function VoteboardForm({ + voteboardId, + initialData, + initialCategory, +}: VoteboardFormProps) { + const isEdit = !!voteboardId; + + const [deleteImageIds, setDeleteImageIds] = useState([]); + + const defaultVals = useMemo( + () => ({ + title: initialData?.title ?? '', + content: initialData?.content ?? '', + category: + initialData?.category ?? + initialCategory ?? + CATEGORIES[0].value, + duration: '3d', + allowMultipleChoice: initialData?.allowMultipleChoice ?? false, + allowRevote: initialData?.allowRevote ?? false, + voteOptions: initialData?.voteOptions ?? [ + { content: '찬성' }, + { content: '반대' }, + ], + }), + [initialData, initialCategory], + ); + + const { + register, + control, + setValue, + handleSubmit, + formState: { errors, touchedFields, isValid }, + } = useForm({ + resolver: zodResolver(voteboardSchema), + mode: 'onTouched', + reValidateMode: 'onChange', + defaultValues: defaultVals, + }); + + // 새 이미지 선택 핸들러 + const handleImageSelect = (files: File[]) => { + setValue('images', files, { + shouldValidate: true, + shouldDirty: true, + }); + }; + + // 기존 이미지 삭제 핸들러(수정용) + const handleDeleteExisting = (deletedIds: number[]) => { + setDeleteImageIds(deletedIds); + }; + + // 동적 옵션 필드 + const { fields, append, remove } = useFieldArray({ + control, + name: 'voteOptions', + }); + + // 생성/수정 mutation 훅 + const { submitPost, isPending } = useVoteboardMutation(voteboardId); + + // 폼 제출 핸들러 + const onSubmit = (data: VoteboardFormData) => { + console.log( + '폼 제출 데이터:', + data, + '삭제 이미지 IDs:', + deleteImageIds, + ); + submitPost(data, deleteImageIds); + }; + + return ( +
+
+
+ + ( + + )} + /> +
+ {/* 제목 */} + + + {/* 내용 */} +