diff --git a/apps/web/src/apis/community/api.ts b/apps/web/src/apis/community/api.ts index 64eab740..52e7ba6d 100644 --- a/apps/web/src/apis/community/api.ts +++ b/apps/web/src/apis/community/api.ts @@ -1,4 +1,5 @@ import type { AxiosResponse } from "axios"; +import { COMMUNITY_MAX_UPLOAD_IMAGES } from "@/constants/community"; import type { CommentCreateRequest, CommentIdResponse, @@ -91,7 +92,7 @@ export const communityApi = { "postCreateRequest", new Blob([JSON.stringify(request.postCreateRequest)], { type: "application/json" }), ); - request.file.forEach((file) => { + request.file.slice(0, COMMUNITY_MAX_UPLOAD_IMAGES).forEach((file) => { convertedRequest.append("file", file); }); @@ -111,7 +112,7 @@ export const communityApi = { "postUpdateRequest", new Blob([JSON.stringify(request.postUpdateRequest)], { type: "application/json" }), ); - request.file.forEach((file) => { + request.file.slice(0, COMMUNITY_MAX_UPLOAD_IMAGES).forEach((file) => { convertedRequest.append("file", file); }); diff --git a/apps/web/src/app/community/[boardCode]/[postId]/Content.tsx b/apps/web/src/app/community/[boardCode]/[postId]/Content.tsx index 92d3fd29..714d630e 100644 --- a/apps/web/src/app/community/[boardCode]/[postId]/Content.tsx +++ b/apps/web/src/app/community/[boardCode]/[postId]/Content.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useDeleteLike, usePostLike } from "@/apis/community"; import Image from "@/components/ui/FallbackImage"; import LinkifyText from "@/components/ui/LinkifyText"; +import { COMMUNITY_MAX_UPLOAD_IMAGES } from "@/constants/community"; import { IconCloseFilled, IconPostLikeFilled, IconPostLikeOutline } from "@/public/svgs"; import { IconCommunication } from "@/public/svgs/community"; import type { PostImage as PostImageType, Post as PostType } from "@/types/community"; @@ -22,6 +23,7 @@ const Content = ({ post, postId }: ContentProps) => { const [selectedImageIndex, setSelectedImageIndex] = useState(null); const [likeCount, setLikeCount] = useState(0); const [isLiked, setIsLiked] = useState(false); + const postImages = (post.postFindPostImageResponses || []).slice(0, COMMUNITY_MAX_UPLOAD_IMAGES); const postLikeMutation = usePostLike(); const deleteLikeMutation = useDeleteLike(); @@ -85,12 +87,12 @@ const Content = ({ post, postId }: ContentProps) => {
- +
- {selectedImageIndex !== null && ( + {selectedImageIndex !== null && postImages[selectedImageIndex] && ( )} diff --git a/apps/web/src/app/community/[boardCode]/[postId]/modify/PostModifyForm.tsx b/apps/web/src/app/community/[boardCode]/[postId]/modify/PostModifyForm.tsx index 81299f47..cb96d586 100644 --- a/apps/web/src/app/community/[boardCode]/[postId]/modify/PostModifyForm.tsx +++ b/apps/web/src/app/community/[boardCode]/[postId]/modify/PostModifyForm.tsx @@ -1,9 +1,10 @@ "use client"; import { useRouter } from "next/navigation"; -import { type ChangeEvent, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useUpdatePost } from "@/apis/community"; +import useCommunityImageUpload from "@/app/community/_hooks/useCommunityImageUpload"; import { toast } from "@/lib/zustand/useToastStore"; import { IconArrowBackFilled, IconImage, IconPostCheckboxFilled, IconPostCheckboxOutlined } from "@/public/svgs"; @@ -29,9 +30,20 @@ const PostModifyForm = ({ const [isQuestion, setIsQuestion] = useState(defaultIsQuestion); const textareaRef = useRef(null); const titleRef = useRef(null); - const imageUploadRef = useRef(null); - const [selectedImage, setSelectedImage] = useState(null); - const [imagePreviewUrl, setImagePreviewUrl] = useState(null); + const { + maxImages, + imageUploadRef, + selectedImages, + imagePreviewUrls, + isDraggingImage, + handleImageChange, + handleImageDragEnter, + handleImageDragOver, + handleImageDragLeave, + handleImageDrop, + removeSelectedImage, + openImagePicker, + } = useCommunityImageUpload(); const router = useRouter(); const updatePostMutation = useUpdatePost(); @@ -63,43 +75,25 @@ const PostModifyForm = ({ return () => {}; }, []); - useEffect(() => { - if (!selectedImage) { - setImagePreviewUrl(null); - return; - } - - const objectUrl = URL.createObjectURL(selectedImage); - setImagePreviewUrl(objectUrl); - - return () => { - URL.revokeObjectURL(objectUrl); - }; - }, [selectedImage]); - - const handleImageChange = (event: ChangeEvent) => { - const file = event.target.files?.[0] ?? null; - setSelectedImage(file); - }; - - const removeSelectedImage = () => { - setSelectedImage(null); - if (imageUploadRef.current) { - imageUploadRef.current.value = ""; - } - }; - const submitPost = async () => { - if (!title.trim()) { + const trimmedTitle = title.trim(); + const trimmedContent = content.trim(); + + if (!trimmedTitle) { toast.error("제목을 입력해주세요."); return; } - if (!content.trim()) { + if (!trimmedContent) { toast.error("내용을 입력해주세요."); return; } + if (trimmedContent.length > 255) { + toast.error("내용은 255자 이하로 입력해주세요."); + return; + } + updatePostMutation.mutate( { postId, @@ -107,10 +101,10 @@ const PostModifyForm = ({ data: { postUpdateRequest: { postCategory: isQuestion ? "질문" : "자유", - title, - content, + title: trimmedTitle, + content: trimmedContent, }, - file: selectedImage ? [selectedImage] : [], + file: selectedImages, }, }, { @@ -128,7 +122,18 @@ const PostModifyForm = ({ return ( <> -
+
+ {isDraggingImage ? ( +
+ 이미지를 놓아 업로드하세요 +
+ ) : null}
{ - imageUploadRef.current?.click(); + openImagePicker(); }} aria-label="이미지 추가" > - +
@@ -173,22 +185,36 @@ const PostModifyForm = ({ className="placeholder:text-gray-250/87 mt-4 box-border h-90 w-full resize-none border-0 px-5 text-black outline-none typo-regular-1" placeholder="내용을 입력하세요" value={content} + maxLength={255} onChange={(e) => setContent(e.target.value)} />
- {imagePreviewUrl ? ( + {imagePreviewUrls.length > 0 ? (
-

첨부 이미지

-
- 업로드 이미지 미리보기 - +

+ 첨부 이미지 ({selectedImages.length}/{maxImages}) +

+
+ {imagePreviewUrls.map((imagePreviewUrl, index) => ( +
+ {`업로드 + +
+ ))}
) : null} diff --git a/apps/web/src/app/community/[boardCode]/create/PostForm.tsx b/apps/web/src/app/community/[boardCode]/create/PostForm.tsx index 7995a564..eaa6e44c 100644 --- a/apps/web/src/app/community/[boardCode]/create/PostForm.tsx +++ b/apps/web/src/app/community/[boardCode]/create/PostForm.tsx @@ -1,9 +1,11 @@ "use client"; import { useRouter } from "next/navigation"; -import { type ChangeEvent, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useCreatePost } from "@/apis/community"; +import useCommunityImageUpload from "@/app/community/_hooks/useCommunityImageUpload"; import TopDetailNavigation from "@/components/layout/TopDetailNavigation"; +import { toast } from "@/lib/zustand/useToastStore"; import { IconImage, IconPostCheckboxFilled, IconPostCheckboxOutlined } from "@/public/svgs"; type PostFormProps = { @@ -14,11 +16,22 @@ const PostForm = ({ boardCode }: PostFormProps) => { const textareaRef = useRef(null); const titleRef = useRef(null); const [content, setContent] = useState(""); - const imageUploadRef = useRef(null); const router = useRouter(); const [isQuestion, setIsQuestion] = useState(false); - const [selectedImage, setSelectedImage] = useState(null); - const [imagePreviewUrl, setImagePreviewUrl] = useState(null); + const { + maxImages, + imageUploadRef, + selectedImages, + imagePreviewUrls, + isDraggingImage, + handleImageChange, + handleImageDragEnter, + handleImageDragOver, + handleImageDragLeave, + handleImageDrop, + removeSelectedImage, + openImagePicker, + } = useCommunityImageUpload(); const createPostMutation = useCreatePost(); @@ -45,43 +58,35 @@ const PostForm = ({ boardCode }: PostFormProps) => { return () => {}; }, []); - useEffect(() => { - if (!selectedImage) { - setImagePreviewUrl(null); + const submitPost = async () => { + const titleValue = titleRef.current?.querySelector("textarea")?.value.trim() || ""; + const trimmedContent = content.trim(); + + if (!titleValue) { + toast.error("제목을 입력해주세요."); return; } - const objectUrl = URL.createObjectURL(selectedImage); - setImagePreviewUrl(objectUrl); - - return () => { - URL.revokeObjectURL(objectUrl); - }; - }, [selectedImage]); - - const handleImageChange = (event: ChangeEvent) => { - const file = event.target.files?.[0] ?? null; - setSelectedImage(file); - }; + if (!trimmedContent) { + toast.error("내용을 입력해주세요."); + return; + } - const removeSelectedImage = () => { - setSelectedImage(null); - if (imageUploadRef.current) { - imageUploadRef.current.value = ""; + if (trimmedContent.length > 255) { + toast.error("내용은 255자 이하로 입력해주세요."); + return; } - }; - const submitPost = async () => { createPostMutation.mutate( { postCreateRequest: { boardCode: boardCode, postCategory: isQuestion ? "질문" : "자유", - title: titleRef.current?.querySelector("textarea")?.value || "", - content, + title: titleValue, + content: trimmedContent, isQuestion, }, - file: selectedImage ? [selectedImage] : [], + file: selectedImages, }, { onSuccess: (data) => { @@ -109,7 +114,18 @@ const PostForm = ({ boardCode }: PostFormProps) => { } /> -
+
+ {isDraggingImage ? ( +
+ 이미지를 놓아 업로드하세요 +
+ ) : null}
{
- +
@@ -152,22 +175,36 @@ const PostForm = ({ boardCode }: PostFormProps) => { className="placeholder:text-gray-250/87 mt-4 box-border h-90 w-full resize-none border-0 px-5 text-black outline-none typo-regular-1" placeholder="내용을 입력하세요" value={content} + maxLength={255} onChange={(e) => setContent(e.target.value)} />
- {imagePreviewUrl ? ( + {imagePreviewUrls.length > 0 ? (
-

첨부 이미지

-
- 업로드 이미지 미리보기 - +

+ 첨부 이미지 ({selectedImages.length}/{maxImages}) +

+
+ {imagePreviewUrls.map((imagePreviewUrl, index) => ( +
+ {`업로드 + +
+ ))}
) : null} diff --git a/apps/web/src/app/community/_hooks/useCommunityImageUpload.ts b/apps/web/src/app/community/_hooks/useCommunityImageUpload.ts new file mode 100644 index 00000000..43bfa482 --- /dev/null +++ b/apps/web/src/app/community/_hooks/useCommunityImageUpload.ts @@ -0,0 +1,127 @@ +"use client"; + +import { type ChangeEvent, type DragEvent, useEffect, useRef, useState } from "react"; +import { COMMUNITY_MAX_UPLOAD_IMAGES } from "@/constants/community"; +import { toast } from "@/lib/zustand/useToastStore"; + +type UseCommunityImageUploadOptions = { + maxImages?: number; +}; + +const useCommunityImageUpload = ({ maxImages = COMMUNITY_MAX_UPLOAD_IMAGES }: UseCommunityImageUploadOptions = {}) => { + const imageUploadRef = useRef(null); + const dragDepthRef = useRef(0); + const selectedImageCountRef = useRef(0); + const [selectedImages, setSelectedImages] = useState([]); + const [imagePreviewUrls, setImagePreviewUrls] = useState([]); + const [isDraggingImage, setIsDraggingImage] = useState(false); + + useEffect(() => { + selectedImageCountRef.current = selectedImages.length; + const objectUrls = selectedImages.map((image) => URL.createObjectURL(image)); + setImagePreviewUrls(objectUrls); + + return () => { + objectUrls.forEach((url) => URL.revokeObjectURL(url)); + }; + }, [selectedImages]); + + const appendImageFiles = (files: File[]) => { + if (files.length === 0) return; + + const imageFiles = files.filter((file) => file.type.startsWith("image/")); + if (imageFiles.length !== files.length) { + toast.error("이미지 파일만 업로드할 수 있습니다."); + } + + if (imageFiles.length === 0) return; + + const remainingSlots = maxImages - selectedImageCountRef.current; + if (remainingSlots <= 0) { + toast.error(`이미지는 최대 ${maxImages}장까지 업로드할 수 있습니다.`); + return; + } + + if (imageFiles.length > remainingSlots) { + toast.error(`이미지는 최대 ${maxImages}장까지 업로드할 수 있습니다.`); + } + + setSelectedImages((prev) => { + const currentRemainingSlots = Math.max(0, maxImages - prev.length); + const nextImages = [...prev, ...imageFiles.slice(0, currentRemainingSlots)]; + selectedImageCountRef.current = nextImages.length; + return nextImages; + }); + }; + + const handleImageChange = (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + appendImageFiles(files); + event.target.value = ""; + }; + + const handleImageDragEnter = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + + dragDepthRef.current += 1; + setIsDraggingImage(true); + }; + + const handleImageDragOver = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + }; + + const handleImageDragLeave = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + + dragDepthRef.current -= 1; + if (dragDepthRef.current <= 0) { + dragDepthRef.current = 0; + setIsDraggingImage(false); + } + }; + + const handleImageDrop = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + + dragDepthRef.current = 0; + setIsDraggingImage(false); + + const files = Array.from(event.dataTransfer.files ?? []); + appendImageFiles(files); + }; + + const removeSelectedImage = (index: number) => { + setSelectedImages((prev) => { + const nextImages = prev.filter((_, imageIndex) => imageIndex !== index); + selectedImageCountRef.current = nextImages.length; + return nextImages; + }); + }; + + const openImagePicker = () => { + imageUploadRef.current?.click(); + }; + + return { + maxImages, + imageUploadRef, + selectedImages, + imagePreviewUrls, + isDraggingImage, + handleImageChange, + handleImageDragEnter, + handleImageDragOver, + handleImageDragLeave, + handleImageDrop, + removeSelectedImage, + openImagePicker, + }; +}; + +export default useCommunityImageUpload; diff --git a/apps/web/src/constants/community.ts b/apps/web/src/constants/community.ts index 60824f66..198a9770 100644 --- a/apps/web/src/constants/community.ts +++ b/apps/web/src/constants/community.ts @@ -6,3 +6,5 @@ export const COMMUNITY_BOARDS = [ ]; export const COMMUNITY_CATEGORIES = ["전체", "자유", "질문"]; + +export const COMMUNITY_MAX_UPLOAD_IMAGES = 5;