diff --git a/src/api/useGetPost.jsx b/src/api/useGetPost.jsx index 24ea8ac..700377c 100644 --- a/src/api/useGetPost.jsx +++ b/src/api/useGetPost.jsx @@ -1,27 +1,21 @@ +// useGetPost.jsx import api from "./config"; export const getPost = async (postId) => { try { const response = await api.get(`/api/v1/posts/${postId}`); - - // Log the entire response for debugging console.log("API Response:", response); - // Validate the response structure const data = response.data; if (!data || typeof data !== "object") { throw new Error("Invalid response format"); } console.log("Received data:", data); - - // Check if the data is nested inside a 'data' property const postData = data.data || data; - - // Log the structure of the data console.log("Post data structure:", Object.keys(postData)); - // Validate required fields + // 필수 필드 검증 const requiredFields = [ "id", "title", @@ -37,7 +31,7 @@ export const getPost = async (postId) => { } } - // Validate member object + // member 객체 검증 const memberFields = ["id", "role", "course", "name", "nameEnglish"]; for (const field of memberFields) { if (!(field in postData.member)) { @@ -45,14 +39,14 @@ export const getPost = async (postId) => { } } - // Transform the data to match our expected structure return { id: postData.id, title: postData.title, content: postData.content, createdAt: postData.createdAt, updatedAt: postData.updatedAt, - imageUrl: "", // 빈 문자열로 설정 + imageUrls: postData.cloudFrontPaths || [], // cloudFrontPath 사용 + s3ImageUrls: postData.s3ImagePaths || [], // s3ImagePaths 보관 likes: (postData.likes !== undefined ? postData.likes : 0).toString(), author: { id: postData.member.id, @@ -60,7 +54,7 @@ export const getPost = async (postId) => { course: postData.member.course, name: postData.member.name, nameEnglish: postData.member.nameEnglish, - profileImage: "", // 빈 문자열로 설정 + profileImage: postData.member.profileImage || "", }, }; } catch (error) { @@ -72,3 +66,57 @@ export const getPost = async (postId) => { throw error; } }; + +// useGetPosts.jsx +export const getPosts = async (pageNo = 0, pageSize = 10) => { + try { + const response = await api.get("/api/v1/posts", { + params: { + pageNo, + pageSize, + }, + }); + + const transformedData = { + content: response.data.content.map((post) => ({ + post_id: post.id, + post_title: post.title, + post_content: post.content, + created_at: post.createdAt, + updated_at: post.updatedAt, + member: { + member_id: post.member.id, + member_name: post.member.name, + member_name_english: post.member.nameEnglish, + course: post.member.course, + role: post.member.role, + }, + // cloudFrontPaths 사용 + imageUrls: post.cloudFrontPaths || [], + // 첫 번째 이미지를 대표 이미지로 사용 + mainImageUrl: post.cloudFrontPaths?.[0] || "", + // S3 URL도 보관 + s3ImageUrls: post.s3ImagePaths || [], + })), + totalPages: response.data.totalPages, + totalElements: response.data.totalElements, + size: response.data.size, + number: response.data.number, + first: response.data.first, + last: response.data.last, + }; + + return transformedData; + } catch (error) { + console.error("Error fetching posts:", error); + return { + content: [], + totalPages: 0, + totalElements: 0, + size: pageSize, + number: pageNo, + first: true, + last: true, + }; + } +}; diff --git a/src/api/useImageUpload.jsx b/src/api/useImageUpload.jsx new file mode 100644 index 0000000..3526479 --- /dev/null +++ b/src/api/useImageUpload.jsx @@ -0,0 +1,63 @@ +// useImageUpload.jsx +import api from "./config"; +import { getPresignedUrl, uploadToS3 } from "./useImageUrl"; + +export const useImageUpload = () => { + const handleImageUpload = async (postId, files, onProgress = () => {}) => { + try { + // 1. 파일 검증 + const validFiles = files.filter((file) => { + const maxSize = 5 * 1024 * 1024; // 5MB + const validTypes = ["image/jpeg", "image/png", "image/gif"]; + return file.size <= maxSize && validTypes.includes(file.type); + }); + + if (validFiles.length === 0) { + throw new Error("업로드할 수 있는 파일이 없습니다."); + } + + // 2. Presigned URL 요청 + const presignedData = await getPresignedUrl( + postId, + validFiles.map((f) => f.name) + ); + + if (!presignedData?.urls?.length) { + throw new Error("업로드 URL을 받지 못했습니다."); + } + + // 3. S3 업로드 + const uploadResults = []; + const totalFiles = presignedData.urls.length; + + for (let i = 0; i < totalFiles; i++) { + const urlInfo = presignedData.urls[i]; + const file = validFiles[i]; + + console.log(`Uploading file ${i + 1}/${totalFiles}: ${file.name}`); + + const s3Url = await uploadToS3(urlInfo.url, file, (progress) => { + const totalProgress = (i * 100 + progress) / totalFiles; + onProgress(totalProgress); + }); + + uploadResults.push({ + fileName: urlInfo.fileName, + url: s3Url, + }); + } + + // 4. 이미지 URL 저장 + const response = await api.post(`/api/v1/posts/${postId}/images`, { + urls: uploadResults, + }); + + return response.data.images; + } catch (error) { + console.error("Image upload process failed:", error); + throw error; + } + }; + + return { handleImageUpload }; +}; diff --git a/src/api/useImageUrl.jsx b/src/api/useImageUrl.jsx new file mode 100644 index 0000000..6663e6d --- /dev/null +++ b/src/api/useImageUrl.jsx @@ -0,0 +1,49 @@ +import axios from "axios"; +import api from "./config"; + +export const uploadToS3 = async (presignedUrl, file, onProgress = () => {}) => { + try { + console.log("Starting upload:", { + fileName: file.name, + fileType: file.type, + fileSize: file.size, + }); + + const response = await axios.put(presignedUrl, file, { + headers: { + "Content-Type": file.type, + }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + const percentCompleted = Math.round( + (progressEvent.loaded * 100) / progressEvent.total + ); + onProgress(percentCompleted); + } + }, + }); + + console.log("Upload response:", response); + + // 성공 시 S3 URL 반환 + const s3Url = presignedUrl.split("?")[0]; + return s3Url; + } catch (error) { + console.error("Upload error:", error); + throw new Error(`파일 업로드 실패: ${error.message}`); + } +}; + +export const getPresignedUrl = async (postId, fileNames) => { + try { + const response = await api.post( + `/api/v1/posts/${postId}/images/presigned-url`, + { fileNames } + ); + console.log("Presigned URL Response:", response.data); + return response.data; + } catch (error) { + console.error("Error getting presigned URL:", error); + throw new Error("사전 서명된 URL을 가져오는데 실패했습니다."); + } +}; diff --git a/src/api/useSaveImage.jsx b/src/api/useSaveImage.jsx new file mode 100644 index 0000000..3d7305a --- /dev/null +++ b/src/api/useSaveImage.jsx @@ -0,0 +1,21 @@ +// useSaveImage.jsx +import api from "./config"; + +export const saveImages = async (postId, imageUrls) => { + try { + console.log("Saving images to backend:", { postId, imageUrls }); + + const response = await api.post(`/api/v1/posts/${postId}/images`, { + urls: imageUrls.map((url) => ({ + fileName: url.fileName, + url: url.url, + })), + }); + + console.log("Backend save response:", response.data); + return response.data; + } catch (error) { + console.error("Failed to save image URLs:", error); + throw new Error("이미지 URL 저장 중 오류가 발생했습니다."); + } +}; diff --git a/src/component/Postlist.jsx b/src/component/Postlist.jsx index a3f1e32..35ffd28 100644 --- a/src/component/Postlist.jsx +++ b/src/component/Postlist.jsx @@ -6,7 +6,19 @@ const Postlist = ({ id, title, imageUrl, likes }) => { return (
- 게시물 이미지 + {imageUrl ? ( + 게시물 이미지 { + console.error("Image load error:", imageUrl); + e.target.onerror = null; + e.target.src = "/default-image.png"; // 기본 이미지 경로 + }} + /> + ) : ( +
+ )}
{title}
@@ -21,8 +33,12 @@ const Postlist = ({ id, title, imageUrl, likes }) => { Postlist.propTypes = { id: PropTypes.number.isRequired, title: PropTypes.string.isRequired, - imageUrl: PropTypes.string.isRequired, + imageUrl: PropTypes.string, likes: PropTypes.string.isRequired, }; +Postlist.defaultProps = { + imageUrl: "", +}; + export default Postlist; diff --git a/src/containers/PostForm.jsx b/src/containers/PostForm.jsx index 5d88a8b..b8fb7b1 100644 --- a/src/containers/PostForm.jsx +++ b/src/containers/PostForm.jsx @@ -152,6 +152,18 @@ const Post = ({ post, currentUserId }) => {
{post.post_content}
+ {post.imageUrls && post.imageUrls.length > 0 && ( +
+ {post.imageUrls.map((url, index) => ( + {`게시물 + ))} +
+ )}
@@ -205,6 +217,8 @@ Post.propTypes = { post_content: PropTypes.string.isRequired, created_at: PropTypes.string.isRequired, updated_at: PropTypes.string.isRequired, + imageUrls: PropTypes.arrayOf(PropTypes.string), // 이미지 URL 배열 추가 + s3ImageUrls: PropTypes.arrayOf(PropTypes.string), // S3 URL 배열 추가 member: PropTypes.shape({ member_id: PropTypes.number.isRequired, member_name: PropTypes.string.isRequired, @@ -213,7 +227,13 @@ Post.propTypes = { role: PropTypes.string.isRequired, }).isRequired, }).isRequired, - currentUserId: PropTypes.number.isRequired, // 추가: currentUserId prop 정의 + currentUserId: PropTypes.number.isRequired, }; +Post.defaultProps = { + post: { + imageUrls: [], // 기본값 설정 + s3ImageUrls: [], // 기본값 설정 + }, +}; export default Post; diff --git a/src/containers/WriteForm.jsx b/src/containers/WriteForm.jsx index 7f12af6..4e4369e 100644 --- a/src/containers/WriteForm.jsx +++ b/src/containers/WriteForm.jsx @@ -4,8 +4,8 @@ import { useState, useEffect } from "react"; import "./styles/WriteForm.scss"; import Uploadicon from "../image/Uploadicon.svg"; import { createPost } from "../api/useCreatePost"; -import { uploadAttachments } from "../api/useAttachment"; import { editPost } from "../api/useEditPost"; +import { useImageUpload } from "../api/useImageUpload"; // 추가된 import const WriteForm = ({ onClose, @@ -30,6 +30,8 @@ const WriteForm = ({ memberName: "", memberNameEnglish: "", }); + const [isUploading, setIsUploading] = useState(false); // 업로드 상태 추가 + const { handleImageUpload } = useImageUpload(); // useImageUpload hook 사용 useEffect(() => { // localStorage에서 사용자 정보 가져오기 @@ -48,6 +50,8 @@ const WriteForm = ({ } return `${userInfo.memberNameEnglish}(${userInfo.memberName})`; }; + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadStatus, setUploadStatus] = useState(""); const handleSubmit = async () => { try { @@ -56,42 +60,84 @@ const WriteForm = ({ return; } - let updatedPost; - if (editMode) { - // 수정 모드일 때 - updatedPost = await editPost(initialPost.id, initialPost.author.id, { - title, - content, - }); - } else { - // 새 게시글 작성 모드일 때 - const memberId = localStorage.getItem("userId"); - const postData = { - title, - content, - memberId: parseInt(memberId), - }; - updatedPost = await createPost(postData); - } + setIsUploading(true); + setUploadStatus("게시글 저장 중..."); + + // 게시글 저장 + const memberId = localStorage.getItem("userId"); + const postData = { + title, + content, + memberId: parseInt(memberId), + }; + let updatedPost = editMode + ? await editPost(initialPost.id, initialPost.author.id, postData) + : await createPost(postData); + + // 이미지 업로드 if (files.length > 0) { - await uploadAttachments(updatedPost.id, files); + try { + setUploadStatus("이미지 업로드 중..."); + + const images = await handleImageUpload( + updatedPost.id, + files, + (progress) => { + setUploadProgress(progress); + setUploadStatus(`이미지 업로드 중... ${Math.round(progress)}%`); + } + ); + + updatedPost = { ...updatedPost, images }; + setUploadStatus("업로드 완료!"); + } catch (uploadError) { + console.error("Image upload failed:", uploadError); + const shouldProceed = window.confirm( + `이미지 업로드 실패: ${uploadError.message}\n\n이미지 없이 게시글을 저장하시겠습니까?` + ); + if (!shouldProceed) { + throw new Error("사용자가 게시글 저장을 취소했습니다."); + } + } } onPostCreated(updatedPost); + onClose(); } catch (error) { - console.error("Error submitting post:", error); - alert( - editMode - ? "게시글 수정 중 오류가 발생했습니다." - : "게시글 작성 중 오류가 발생했습니다." - ); + console.error("Post submission failed:", error); + alert(error.message || "게시글 저장 중 오류가 발생했습니다."); + } finally { + setIsUploading(false); + setUploadStatus(""); + setUploadProgress(0); } }; const handleFileChange = (e) => { - setFiles(Array.from(e.target.files)); + const selectedFiles = Array.from(e.target.files); + + // 파일 크기 및 형식 검증 + const validFiles = selectedFiles.filter((file) => { + const maxSize = 5 * 1024 * 1024; // 5MB + const validTypes = ["image/jpeg", "image/png", "image/gif"]; + + if (file.size > maxSize) { + alert(`${file.name}의 크기가 5MB를 초과합니다.`); + return false; + } + if (!validTypes.includes(file.type)) { + alert( + `${file.name}은(는) 지원하지 않는 파일 형식입니다. (지원 형식: JPG, PNG, GIF)` + ); + return false; + } + return true; + }); + + setFiles(validFiles); }; + const handleOverlayClick = (e) => { if (e.target.className === "write-form-overlay") { onClose(); @@ -127,7 +173,27 @@ const WriteForm = ({ - {files.length > 0 &&

{files.length}개의 파일이 선택됨

} + {files.length > 0 && ( +
+

{files.length}개의 파일이 선택됨

+
    + {Array.from(files).map((file, index) => ( +
  • {file.name}
  • + ))} +
+
+ )} + {isUploading && uploadProgress > 0 && ( +
+
+
+
+ {uploadStatus} +
+ )}
@@ -143,7 +209,8 @@ const WriteForm = ({ alt={editMode ? "수정" : "업로드"} className="upload-icon" onClick={handleSubmit} - style={{ cursor: "pointer" }} + style={{ cursor: isUploading ? "not-allowed" : "pointer" }} + disabled={isUploading} />
@@ -154,6 +221,7 @@ const WriteForm = ({ onChange={(e) => setTitle(e.target.value)} placeholder="제목" required + disabled={isUploading} />
@@ -163,6 +231,7 @@ const WriteForm = ({ onChange={(e) => setContent(e.target.value)} placeholder="내용을 입력하세요" required + disabled={isUploading} />
diff --git a/src/containers/styles/WriteForm.scss b/src/containers/styles/WriteForm.scss index d809852..7dc2016 100644 --- a/src/containers/styles/WriteForm.scss +++ b/src/containers/styles/WriteForm.scss @@ -152,7 +152,48 @@ } } } +.upload-progress { + margin-top: 1rem; + width: 100%; +} + +.progress-bar { + width: 100%; + height: 8px; + background-color: #f0f0f0; + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background-color: #4caf50; + transition: width 0.3s ease-in-out; +} + +.progress-text { + display: block; + margin-top: 0.5rem; + font-size: 0.875rem; + color: #666; + text-align: center; +} + +.file-list { + margin-top: 1rem; +} + +.file-list ul { + list-style: none; + padding: 0; + margin: 0.5rem 0; +} +.file-list li { + font-size: 0.875rem; + color: #666; + margin-bottom: 0.25rem; +} @media (max-width: 1130px) { .write-form-container { height: 80vh;