diff --git a/src/app/(main)/project/[id]/_components/ProjectImage.tsx b/src/app/(main)/project/[id]/_components/ProjectImage.tsx new file mode 100644 index 00000000..e569778e --- /dev/null +++ b/src/app/(main)/project/[id]/_components/ProjectImage.tsx @@ -0,0 +1,22 @@ +import { ResponseProject } from '@/types/project'; +import Image from 'next/image'; + +const ProjectImage = ({ images }: ResponseProject) => { + if (!images || images.length === 0) return null; + return ( +
+ {images.map((img, index) => ( + {`Project + ))} +
+ ); +}; + +export default ProjectImage; diff --git a/src/app/(main)/project/[id]/_components/ProjectInfo.tsx b/src/app/(main)/project/[id]/_components/ProjectInfo.tsx index e11e10dd..7cf55a2b 100644 --- a/src/app/(main)/project/[id]/_components/ProjectInfo.tsx +++ b/src/app/(main)/project/[id]/_components/ProjectInfo.tsx @@ -5,6 +5,7 @@ import ProjectTitle from './ProjectTitle'; import InfoCard from './InfoCard'; import Profile1 from '@/components/profile/Profile1'; import { useGetProject } from '@/hooks/queries/useProject'; +import ProjectImage from './ProjectImage'; const ProjectInfo = ({ id }: { id: string }) => { const { data } = useGetProject( @@ -29,6 +30,7 @@ const ProjectInfo = ({ id }: { id: string }) => {
+
diff --git a/src/app/(main)/project/_components/ProjectList.tsx b/src/app/(main)/project/_components/ProjectList.tsx index e4885de3..a7e62ea6 100644 --- a/src/app/(main)/project/_components/ProjectList.tsx +++ b/src/app/(main)/project/_components/ProjectList.tsx @@ -107,7 +107,7 @@ const ProjectListPage = () => {
-
+
{tabletCards.length > 0 ? tabletCards.map((card: ResponseProject) => ( @@ -128,7 +128,9 @@ const ProjectListPage = () => { /> )) : !isLoading && ( -

프로젝트가 없습니다.

+

+ 프로젝트가 없습니다. +

)}
diff --git a/src/app/(main)/teampsylog/_components/BottomComment.tsx b/src/app/(main)/teampsylog/_components/BottomComment.tsx index a8919071..30c05628 100644 --- a/src/app/(main)/teampsylog/_components/BottomComment.tsx +++ b/src/app/(main)/teampsylog/_components/BottomComment.tsx @@ -27,23 +27,19 @@ const BottomComment = ({ isOpen, onClose, children }: BottomSheetProps) => { return ( <>
{/* 바텀 시트 */}
{ ? 'translateY(0)' : 'translateY(100%)', transition: isDragging ? 'none' : 'transform 0.3s ease-out', - visibility: shouldShow ? 'visible' : 'hidden', pointerEvents: shouldShow ? 'auto' : 'none', + overscrollBehavior: 'contain', }} >
{ - */}
{/* mobile */} diff --git a/src/components/recruit/RecruitForm.tsx b/src/components/recruit/RecruitForm.tsx index d42e9df5..d00c87fb 100644 --- a/src/components/recruit/RecruitForm.tsx +++ b/src/components/recruit/RecruitForm.tsx @@ -13,6 +13,7 @@ import Button from '@/components/common/Button'; import { Project } from '@/types/project'; import { useRecruitForm } from './useRecruitForm'; import { RecruitFormType } from '@/libs/schemas/projectSchema'; +import { FormProvider } from 'react-hook-form'; type RecruitFormProps = { mode?: 'create' | 'edit'; @@ -31,15 +32,7 @@ const RecruitForm = ({ onNext, initialFormData, }: RecruitFormProps) => { - const { - handleSubmit, - control, - watch, - reset, - formState: { isValid, errors }, - onSubmit, - onError, - } = useRecruitForm({ + const { formMethods, onSubmit, onError } = useRecruitForm({ mode, initialData, postId, @@ -47,6 +40,13 @@ const RecruitForm = ({ onNext, initialFormData, }); + const { + handleSubmit, + control, + watch, + reset, + formState: { isValid, errors }, + } = formMethods; useEffect(() => { if (initialFormData && mode === 'create' && !showProfileList) { @@ -70,43 +70,45 @@ const RecruitForm = ({ }; return ( -
-
- -
- {/* 모집분야/인원 */} - - {/* 진행방법 */} - - {/* 프로젝트 기간 및 연락처 */} -
- - - - + + +
+ +
+ {/* 모집분야/인원 */} + + {/* 진행방법 */} + + {/* 프로젝트 기간 및 연락처 */} +
+ + + + +
+ + {mode === 'create' && showProfileList && } +
+
+
- - {mode === 'create' && showProfileList && } -
-
-
- + + ); }; diff --git a/src/components/recruit/editor/PostImage.tsx b/src/components/recruit/editor/PostImage.tsx new file mode 100644 index 00000000..d748087f --- /dev/null +++ b/src/components/recruit/editor/PostImage.tsx @@ -0,0 +1,210 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import Image from 'next/image'; +import { RecruitFormType } from '@/libs/schemas/projectSchema'; +import { uploadPostImages } from '@/libs/api/image'; +import { ProjectImage } from '@/types/project'; + +const MAX_IMAGE_COUNT = 2; + +type ImageItem = { + objectKey: string; + previewUrl: string; +}; + +type PostImageProps = { + initialImages?: ProjectImage[]; +}; + +export default function PostImage({ initialImages }: PostImageProps) { + const { setValue } = useFormContext(); + const [images, setImages] = useState(() => + (initialImages ?? []).map((img) => ({ + objectKey: img.objectKey, + previewUrl: img.imageUrl, // 서버 URL을 previewUrl로 + })), + ); + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + + const syncImageKeys = (updated: ImageItem[]) => { + setValue( + 'imageKeys', + updated.map((img) => img.objectKey), + { shouldDirty: true }, + ); + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []); + if (!files.length) return; + + const remaining = MAX_IMAGE_COUNT - images.length; + + if (files.length > remaining) { + alert(`이미지는 최대 ${MAX_IMAGE_COUNT}장까지 첨부할 수 있어요.`); + } + + const filesToUpload = files.slice(0, remaining); + if (!filesToUpload.length) return; + + setIsUploading(true); + try { + const objectKeys = await uploadPostImages(filesToUpload); + const newItems: ImageItem[] = filesToUpload.map((file, i) => ({ + objectKey: objectKeys[i], + previewUrl: URL.createObjectURL(file), + })); + + const updated = [...images, ...newItems]; + setImages(updated); + syncImageKeys(updated); + } catch (err) { + console.error('이미지 업로드 실패:', err); + alert('이미지 업로드에 실패했어요. 다시 시도해주세요.'); + } finally { + setIsUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const handleDeleteImage = (index: number) => { + const confirmed = window.confirm('이미지를 삭제할까요?'); + if (!confirmed) return; + + URL.revokeObjectURL(images[index].previewUrl); + + const updated = images.filter((_, i) => i !== index); + setImages(updated); + syncImageKeys(updated); + }; + + const canAddMore = images.length < MAX_IMAGE_COUNT; + + return ( +
+ {/* 이미지 없을 때: 카메라 버튼 */} + {images.length === 0 && ( + + )} + + {/* 이미지 미리보기 */} + {images.length > 0 && ( +
+ {images.map((img, index) => ( +
+ {`첨부 + +
+ ))} + + {/* 이미지 1장일 때 추가 버튼 */} + {canAddMore && ( + + )} +
+ )} + + +
+ ); +} + +function CameraIcon() { + return ( + + + + + ); +} + +function CloseIcon() { + return ( + + + + + ); +} + +function PlusIcon() { + return ( + + + + + ); +} diff --git a/src/components/recruit/editor/TextContent.tsx b/src/components/recruit/editor/TextContent.tsx index 4d3494db..0ccab893 100644 --- a/src/components/recruit/editor/TextContent.tsx +++ b/src/components/recruit/editor/TextContent.tsx @@ -9,13 +9,16 @@ import { Control, useController } from 'react-hook-form'; import { RecruitFormType } from '@/libs/schemas/projectSchema'; import { TableBubbleMenu } from './table/TableBubbleMenu'; import { TableHandles } from './table/TableHandles'; +import PostImage from './PostImage'; +import { ProjectImage } from '@/types/project'; type Props = { control: Control; name?: 'content'; + initialImages?: ProjectImage[]; }; -const TextContent = ({ control, name = 'content' }: Props) => { +const TextContent = ({ control, name = 'content', initialImages }: Props) => { const { field } = useController({ name, control }); const value = field.value ?? ''; const onChange = field.onChange as (html: string) => void; @@ -87,6 +90,7 @@ const TextContent = ({ control, name = 'content' }: Props) => {

{textLength}

+
diff --git a/src/components/recruit/useRecruitForm.ts b/src/components/recruit/useRecruitForm.ts index fb140b1e..06248013 100644 --- a/src/components/recruit/useRecruitForm.ts +++ b/src/components/recruit/useRecruitForm.ts @@ -52,7 +52,7 @@ export const useRecruitForm = ({ const formMethods = useForm({ mode: 'onChange', reValidateMode: 'onChange', - resolver: zodResolver(getSchema()), + resolver: zodResolver(getSchema()) as any, defaultValues: mode === 'edit' && initialData ? { @@ -64,6 +64,8 @@ export const useRecruitForm = ({ deadline: convertDateFormat(initialData.deadline), contactWay: initialData.contactWay, content: initialData.content, + // TODO: 서버 확인 및 objectKey 전달 방식 확인 + imageKeys: initialData?.images?.map((img) => img.objectKey) || [], } : initialFormData || { title: '', @@ -75,6 +77,7 @@ export const useRecruitForm = ({ contactWay: '', content: '', profileId: undefined, + imageKeys: [], // create 모드 초기값 }, }); @@ -91,6 +94,7 @@ export const useRecruitForm = ({ contactWay: formData.contactWay, content: formData.content, status: initialData?.status || 'OPEN', + imageKeys: initialData?.images?.map((img) => img.objectKey) ?? initialData?.imageKeys ?? [], }; updateProject( @@ -112,6 +116,7 @@ export const useRecruitForm = ({ ...formData, profileId: formData.profileId, status: 'OPEN', + imageKeys: formData.imageKeys ?? [], }; createProject(projectData, { @@ -146,7 +151,7 @@ export const useRecruitForm = ({ }; return { - ...formMethods, + formMethods, onSubmit, onError, }; diff --git a/src/hooks/queries/useRecruitingPosts.ts b/src/hooks/queries/useRecruitingPosts.ts index 26d74640..963244b0 100644 --- a/src/hooks/queries/useRecruitingPosts.ts +++ b/src/hooks/queries/useRecruitingPosts.ts @@ -22,7 +22,6 @@ export const useRecruitingPosts = (filters: Filters, pageNumber: number, pageSiz size: pageSize, sort: [''], }); - console.log(result); return result; }, }); diff --git a/src/libs/api/image.ts b/src/libs/api/image.ts index abf97c3e..779d7113 100644 --- a/src/libs/api/image.ts +++ b/src/libs/api/image.ts @@ -50,3 +50,48 @@ export const uploadImage = async (file: File): Promise => { return null; } }; + +// 게시글 이미지용 presigned URL 여러 장 한번에 발급 +export async function getPostImagePresignedUrls( + fileNames: string[], +): Promise { + try { + const params = new URLSearchParams(); + fileNames.forEach((name) => params.append('fileNames', name)); + + const { data } = await api.post>( + `/post-images/presigned-url?${params.toString()}`, + ); + + if (!data.isSuccess) { + throw new Error(data.message || 'Failed to get post image presigned URLs'); + } + + return data.result; + } catch (error) { + console.error('getPostImagePresignedUrls Error:', error); + throw error; + } +} + +// 게시글 이미지 여러 장 S3 업로드 → objectKey[] 반환 +export async function uploadPostImages(files: File[]): Promise { + const presignedList = await getPostImagePresignedUrls(files.map((f) => f.name)); + + if (presignedList.length !== files.length) { + throw new Error( + `Presigned URL count mismatch: expected ${files.length}, got ${presignedList.length}`, + ); + } + + const results = await Promise.all( + files.map(async (file, i) => { + const target = presignedList[i]; + const success = await uploadToS3(target.preSignedUrl, file); + if (!success) throw new Error(`S3 upload failed: ${file.name}`); + return target.objectKey; + }), + ); + + return results; +} diff --git a/src/libs/api/recruitingPosts.ts b/src/libs/api/recruitingPosts.ts index 4a4a1e50..8c221a63 100644 --- a/src/libs/api/recruitingPosts.ts +++ b/src/libs/api/recruitingPosts.ts @@ -12,7 +12,6 @@ interface GetRecruitingPostsParams { } export const getRecruitingPosts = async (params: GetRecruitingPostsParams) => { - console.log(params); const response = await axios.get<{ result: PagedProjects }>( `${process.env.NEXT_PUBLIC_API_BASE_URL}/recruiting-posts`, { diff --git a/src/libs/schemas/projectSchema.ts b/src/libs/schemas/projectSchema.ts index bf834a48..74303ecd 100644 --- a/src/libs/schemas/projectSchema.ts +++ b/src/libs/schemas/projectSchema.ts @@ -49,6 +49,7 @@ export const recruitFormSchema = z.object({ { message: '최소 50자 이상 작성해주세요.' }, ), profileId: z.number().min(1).optional(), + imageKeys: z.array(z.string()).default([]), }); export type RecruitFormType = z.infer; diff --git a/src/types/project.ts b/src/types/project.ts index 713a5f1c..b698e07c 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -6,6 +6,11 @@ export type RecruitingPosition = { count: number; }; +export type ProjectImage = { + imageUrl: string; + objectKey: string; +}; + export type Project = { progressWay: ProgressWayType; contactWay: string; @@ -16,6 +21,8 @@ export type Project = { content: string; title: string; recruitingPositions: RecruitingPosition[]; + imageKeys?: string[]; + images?: ProjectImage[]; }; export type CreateProject = Project & {