From 9650cc733896e5775471839b0098a6b9a5e7477a Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Tue, 12 Aug 2025 21:44:11 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/feeds/createFeed.ts | 59 +++++++++++++++++++++ src/api/images/uploadImage.ts | 71 +++++++++++++++++++++++++ src/hooks/useCreateFeed.ts | 69 +++++++++++++++++++++++++ src/pages/post/CreatePost.tsx | 97 ++++++++++++++++++++++++++++++----- 4 files changed, 282 insertions(+), 14 deletions(-) create mode 100644 src/api/feeds/createFeed.ts create mode 100644 src/api/images/uploadImage.ts create mode 100644 src/hooks/useCreateFeed.ts diff --git a/src/api/feeds/createFeed.ts b/src/api/feeds/createFeed.ts new file mode 100644 index 00000000..ac52eecd --- /dev/null +++ b/src/api/feeds/createFeed.ts @@ -0,0 +1,59 @@ +import { apiClient } from '../index'; + +// 피드 작성 요청 바디 타입 +export interface CreateFeedRequest { + request: { + isbn: string; + contentBody: string; + isPublic: boolean; + tagList: string[]; + }; + images: string[]; // 이미지 URL들의 배열 (별도로 업로드된 이미지 URL들) +} + +// 피드 작성 응답 데이터 타입 +export interface CreateFeedData { + feedId: number; +} + +// API 응답 타입 +export interface CreateFeedResponse { + isSuccess: boolean; + code: number; + message: string; + data?: CreateFeedData; // 성공 시에만 존재 +} + +// 피드 작성 API 함수 +export const createFeed = async (feedData: CreateFeedRequest) => { + const response = await apiClient.post('/feeds', feedData); + return response.data; +}; + +/* +사용 방법: + +const feedData: CreateFeedRequest = { + request: { + isbn: "9780306406157", + contentBody: "이 책은 정말 좋습니다!", + isPublic: true, + tagList: ["한국소설", "외국소설", "AI"] + }, + images: ["string"] // 업로드된 이미지 URL들 +}; + +try { + const result = await createFeed(feedData); + if (result.isSuccess) { + console.log('피드 작성 성공:', result.data?.feedId); + // 성공 처리 로직 + } else { + console.log('피드 작성 실패:', result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error('API 호출 오류:', error); + // 에러 처리 로직 +} +*/ diff --git a/src/api/images/uploadImage.ts b/src/api/images/uploadImage.ts new file mode 100644 index 00000000..81b2add9 --- /dev/null +++ b/src/api/images/uploadImage.ts @@ -0,0 +1,71 @@ +import { apiClient } from '../index'; + +// 이미지 업로드 응답 데이터 타입 +export interface UploadImageData { + imageUrl: string; +} + +// API 응답 타입 +export interface UploadImageResponse { + isSuccess: boolean; + code: number; + message: string; + data?: UploadImageData; // 성공 시에만 존재 +} + +// 단일 이미지 업로드 API 함수 +export const uploadImage = async (file: File) => { + const formData = new FormData(); + formData.append('image', file); + + const response = await apiClient.post('/images/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return response.data; +}; + +// 다중 이미지 업로드 API 함수 +export const uploadMultipleImages = async (files: File[]) => { + const uploadPromises = files.map(file => uploadImage(file)); + + try { + const results = await Promise.all(uploadPromises); + + // 모든 업로드가 성공한 경우에만 URL 반환 + const successResults = results.filter(result => result.isSuccess); + + if (successResults.length !== files.length) { + throw new Error('일부 이미지 업로드에 실패했습니다.'); + } + + return successResults.map(result => result.data!.imageUrl); + } catch (error) { + console.error('이미지 업로드 실패:', error); + throw error; + } +}; + +/* +사용 방법: + +// 단일 이미지 업로드 +try { + const result = await uploadImage(file); + if (result.isSuccess) { + console.log('업로드된 이미지 URL:', result.data?.imageUrl); + } +} catch (error) { + console.error('이미지 업로드 실패:', error); +} + +// 다중 이미지 업로드 +try { + const imageUrls = await uploadMultipleImages(files); + console.log('업로드된 이미지 URLs:', imageUrls); +} catch (error) { + console.error('다중 이미지 업로드 실패:', error); +} +*/ diff --git a/src/hooks/useCreateFeed.ts b/src/hooks/useCreateFeed.ts new file mode 100644 index 00000000..c55e6d6b --- /dev/null +++ b/src/hooks/useCreateFeed.ts @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { createFeed, type CreateFeedRequest } from '@/api/feeds/createFeed'; +import { usePopupActions } from './usePopupActions'; + +interface UseCreateFeedProps { + onSuccess?: (feedId: number) => void; +} + +export const useCreateFeed = (options?: UseCreateFeedProps) => { + const [loading, setLoading] = useState(false); + const { openSnackbar, closePopup } = usePopupActions(); + + const createNewFeed = async (feedData: CreateFeedRequest) => { + try { + setLoading(true); + + const response = await createFeed(feedData); + + if (response.isSuccess) { + openSnackbar({ + message: '피드가 작성되었습니다.', + variant: 'top', + onClose: closePopup, + }); + + // 성공 콜백 호출 + if (options?.onSuccess && response.data?.feedId) { + options.onSuccess(response.data.feedId); + } + + return { + success: true, + feedId: response.data?.feedId, + }; + } else { + openSnackbar({ + message: response.message || '피드 작성에 실패했습니다.', + variant: 'top', + onClose: closePopup, + }); + + return { + success: false, + error: response.message, + }; + } + } catch (error) { + console.error('피드 작성 실패:', error); + + openSnackbar({ + message: '피드 작성 중 오류가 발생했습니다.', + variant: 'top', + onClose: closePopup, + }); + + return { + success: false, + error: '피드 작성 중 오류가 발생했습니다.', + }; + } finally { + setLoading(false); + } + }; + + return { + createNewFeed, + loading, + }; +}; diff --git a/src/pages/post/CreatePost.tsx b/src/pages/post/CreatePost.tsx index 38eb55ca..539b9eea 100644 --- a/src/pages/post/CreatePost.tsx +++ b/src/pages/post/CreatePost.tsx @@ -10,12 +10,15 @@ import TagSelectionSection from '../../components/createpost/TagSelectionSection import leftarrow from '../../assets/common/leftArrow.svg'; import { Container } from './CreatePost.styled'; import { Section } from '../group/CommonSection.styled'; +import { useCreateFeed } from '@/hooks/useCreateFeed'; +import { usePopupActions } from '@/hooks/usePopupActions'; interface Book { id: number; title: string; author: string; cover: string; + isbn?: string; } const CreatePost = () => { @@ -27,28 +30,93 @@ const CreatePost = () => { const [selectedTags, setSelectedTags] = useState([]); const [isBookSearchOpen, setIsBookSearchOpen] = useState(false); + const { openSnackbar, closePopup } = usePopupActions(); + const { createNewFeed, loading } = useCreateFeed({ + onSuccess: feedId => { + console.log('피드 작성 성공! 피드 ID:', feedId); + // 피드 페이지로 이동 + navigate('/feed'); + }, + }); + const handleBackClick = () => { navigate(-1); }; - const handleCompleteClick = () => { + // 이미지 업로드 함수 + const uploadImages = async (files: File[]): Promise => { + if (files.length === 0) return []; + + try { + // 이미지 업로드 API 사용 (실제 구현 시 import 추가 필요) + // import { uploadMultipleImages } from '@/api/images/uploadImage'; + // return await uploadMultipleImages(files); + + // 임시로 base64 변환 사용 (개발용) + const promises = files.map(file => { + return new Promise(resolve => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(file); + }); + }); + return Promise.all(promises); + } catch (error) { + console.error('이미지 업로드 실패:', error); + throw new Error('이미지 업로드에 실패했습니다.'); + } + }; + + const handleCompleteClick = async () => { // 필수 항목 검증 if (!isFormValid) { - console.log('필수 항목을 입력해주세요.'); + openSnackbar({ + message: '책 선택과 글 내용을 입력해주세요.', + variant: 'top', + onClose: closePopup, + }); return; } - // 글 작성 완료 로직 - console.log('글 작성 완료'); - console.log('필수 - 선택된 책:', selectedBook); - console.log('필수 - 글 내용:', postContent); - console.log('선택 - 선택된 사진:', selectedPhotos); - console.log('선택 - 공개 설정:', isPrivate ? '비공개' : '공개'); - console.log('선택 - 선택된 태그:', selectedTags); + if (!selectedBook?.isbn) { + openSnackbar({ + message: 'ISBN 정보가 없는 책입니다. 다른 책을 선택해주세요.', + variant: 'top', + onClose: closePopup, + }); + return; + } - // TODO: API 호출하여 글 등록 - // 완료 후 이전 페이지로 이동 - navigate(-1); + try { + // 선택된 사진들을 업로드 + let imageUrls: string[] = []; + if (selectedPhotos.length > 0) { + imageUrls = await uploadImages(selectedPhotos); + console.log('이미지 업로드 완료:', imageUrls.length, '개'); + } + + const feedData = { + request: { + isbn: selectedBook.isbn, + contentBody: postContent.trim(), + isPublic: !isPrivate, // UI에서는 "비공개" 토글이므로 반대로 처리 + tagList: selectedTags, + }, + images: imageUrls, + }; + + console.log('피드 작성 요청 데이터:', feedData); + + // 피드 작성 API 호출 + await createNewFeed(feedData); + } catch (error) { + console.error('피드 작성 실패:', error); + openSnackbar({ + message: '피드 작성 중 오류가 발생했습니다.', + variant: 'top', + onClose: closePopup, + }); + } }; const handleBookSearchOpen = () => { @@ -65,6 +133,7 @@ const CreatePost = () => { const handleBookSelect = (book: Book) => { setSelectedBook(book); + setIsBookSearchOpen(false); }; const handlePhotoAdd = (files: File[]) => { @@ -91,10 +160,10 @@ const CreatePost = () => { } title="새 글" - rightButton="완료" + rightButton={loading ? '작성 중...' : '완료'} onLeftClick={handleBackClick} onRightClick={handleCompleteClick} - isNextActive={isFormValid} + isNextActive={isFormValid && !loading} /> Date: Tue, 12 Aug 2025 21:46:57 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20BookSearchBottomSheet=20=EB=8D=94?= =?UTF-8?q?=EB=AF=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=97=90=20ISBN=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BookSearchBottomSheet/BookSearchBottomSheet.tsx | 8 +++++++- src/pages/post/CreatePost.tsx | 11 +---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index b8aa5dff..616f1ad9 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -26,6 +26,7 @@ interface Book { title: string; author: string; cover: string; + isbn: string; } interface BookSearchBottomSheetProps { @@ -36,25 +37,27 @@ interface BookSearchBottomSheetProps { type TabType = 'saved' | 'group'; -// Mock Data const mockSavedBooks: Book[] = [ { id: 1, title: '토마토 컵라면', author: '작가명', cover: '/src/assets/books/tomato.svg', + isbn: '9781234567890', }, { id: 2, title: '사슴', author: '작가명', cover: '/src/assets/books/deer.svg', + isbn: '9781234567891', }, { id: 3, title: '호르몬 체인지', author: '작가명', cover: '/src/assets/books/hormone.svg', + isbn: '9781234567892', }, ]; @@ -64,18 +67,21 @@ const mockGroupBooks: Book[] = [ title: '단 한번의 삶', author: '작가명', cover: '/src/assets/books/life.svg', + isbn: '9781234567893', }, { id: 5, title: '호르몬 체인지', author: '작가명', cover: '/src/assets/books/hormone.svg', + isbn: '9781234567892', }, { id: 6, title: '토마토 컵라면', author: '작가명', cover: '/src/assets/books/tomato.svg', + isbn: '9781234567890', }, ]; diff --git a/src/pages/post/CreatePost.tsx b/src/pages/post/CreatePost.tsx index 539b9eea..e3eb9020 100644 --- a/src/pages/post/CreatePost.tsx +++ b/src/pages/post/CreatePost.tsx @@ -18,7 +18,7 @@ interface Book { title: string; author: string; cover: string; - isbn?: string; + isbn: string; } const CreatePost = () => { @@ -78,15 +78,6 @@ const CreatePost = () => { return; } - if (!selectedBook?.isbn) { - openSnackbar({ - message: 'ISBN 정보가 없는 책입니다. 다른 책을 선택해주세요.', - variant: 'top', - onClose: closePopup, - }); - return; - } - try { // 선택된 사진들을 업로드 let imageUrls: string[] = []; From 7674110721eb04207fcbf98ed0a5922d1d0c991a Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Wed, 13 Aug 2025 01:28:26 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EC=83=88=20=EA=B8=80=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20API=20=EC=97=B0=EB=8F=99=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/feeds/createFeed.ts | 88 ++++++------ src/api/images/uploadImage.ts | 125 ++++++++++++----- .../BookSearchBottomSheet.tsx | 2 +- src/hooks/useCreateFeed.ts | 94 +++++++++---- src/pages/post/CreatePost.tsx | 130 +++++++++--------- src/utils/isbn.ts | 23 ++++ 6 files changed, 287 insertions(+), 175 deletions(-) create mode 100644 src/utils/isbn.ts diff --git a/src/api/feeds/createFeed.ts b/src/api/feeds/createFeed.ts index ac52eecd..a153147b 100644 --- a/src/api/feeds/createFeed.ts +++ b/src/api/feeds/createFeed.ts @@ -1,59 +1,55 @@ import { apiClient } from '../index'; -// 피드 작성 요청 바디 타입 -export interface CreateFeedRequest { - request: { - isbn: string; - contentBody: string; - isPublic: boolean; - tagList: string[]; - }; - images: string[]; // 이미지 URL들의 배열 (별도로 업로드된 이미지 URL들) +/** 서버에 보낼 request JSON 페이로드 */ +export interface CreateFeedBody { + isbn: string; + contentBody: string; + isPublic: boolean; + tagList?: string[]; } -// 피드 작성 응답 데이터 타입 -export interface CreateFeedData { - feedId: number; +/** 성공 응답 */ +export interface CreateFeedSuccess { + isSuccess: true; + code: number; + message: string; + data: { + feedId: number; + }; } -// API 응답 타입 -export interface CreateFeedResponse { - isSuccess: boolean; +/** 실패 응답 */ +export interface CreateFeedFail { + isSuccess: false; code: number; message: string; - data?: CreateFeedData; // 성공 시에만 존재 } -// 피드 작성 API 함수 -export const createFeed = async (feedData: CreateFeedRequest) => { - const response = await apiClient.post('/feeds', feedData); - return response.data; -}; +export type CreateFeedResponse = CreateFeedSuccess | CreateFeedFail; -/* -사용 방법: - -const feedData: CreateFeedRequest = { - request: { - isbn: "9780306406157", - contentBody: "이 책은 정말 좋습니다!", - isPublic: true, - tagList: ["한국소설", "외국소설", "AI"] - }, - images: ["string"] // 업로드된 이미지 URL들 -}; +/** + * 피드 작성 API + * - multipart/form-data + * - request: application/json (Blob로 감싸 전송) + * - images: File[] (선택값, 없으면 미첨부) + */ +export const createFeed = async ( + body: CreateFeedBody, + images?: File[], +): Promise => { + const form = new FormData(); -try { - const result = await createFeed(feedData); - if (result.isSuccess) { - console.log('피드 작성 성공:', result.data?.feedId); - // 성공 처리 로직 - } else { - console.log('피드 작성 실패:', result.message); - // 실패 처리 로직 + // request 파트(JSON) - 필수 + form.append('request', new Blob([JSON.stringify(body)], { type: 'application/json' })); + + // images 파트들 - 선택 + if (images && images.length > 0) { + images.forEach(file => form.append('images', file)); } -} catch (error) { - console.error('API 호출 오류:', error); - // 에러 처리 로직 -} -*/ + + const { data } = await apiClient.post('/feeds', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + return data; +}; diff --git a/src/api/images/uploadImage.ts b/src/api/images/uploadImage.ts index 81b2add9..05d301d3 100644 --- a/src/api/images/uploadImage.ts +++ b/src/api/images/uploadImage.ts @@ -1,11 +1,11 @@ import { apiClient } from '../index'; -// 이미지 업로드 응답 데이터 타입 +/** 단일 이미지 업로드 성공 시 데이터 */ export interface UploadImageData { imageUrl: string; } -// API 응답 타입 +/** 서버 공통 응답 타입 */ export interface UploadImageResponse { isSuccess: boolean; code: number; @@ -13,59 +13,116 @@ export interface UploadImageResponse { data?: UploadImageData; // 성공 시에만 존재 } -// 단일 이미지 업로드 API 함수 -export const uploadImage = async (file: File) => { +/** 내부 유틸: 허용 확장자 */ +const IMAGE_EXT_REGEX = /\.(jpe?g|png|gif)$/i; +/** 가이드 최대 업로드 개수 (서버는 FEED 생성 시 최대 3장 제약) */ +export const MAX_IMAGES = 3; + +/** 파일 사전 검증: 빈 파일 / 확장자 */ +function validateFile(file: File) { + if (!file || file.size === 0) { + // 서버 코드 170001과 의미 일치 + throw new Error('업로드하려는 이미지가 비어있습니다.'); + } + if (!IMAGE_EXT_REGEX.test(file.name)) { + // 서버 코드 170003과 의미 일치 + throw new Error('파일 형식은 jpg, jpeg, png, gif만 가능합니다.'); + } +} + +/** 단일 이미지 업로드 */ +export const uploadImage = async ( + file: File, + options?: { signal?: AbortSignal }, +): Promise => { + // 사전 검증 + validateFile(file); + const formData = new FormData(); + // 서버가 단일 업로드에서 기대하는 필드명이 image라면 유지 formData.append('image', file); - const response = await apiClient.post('/images/upload', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, + const { data } = await apiClient.post('/images/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + signal: options?.signal, }); - return response.data; + return data; }; -// 다중 이미지 업로드 API 함수 -export const uploadMultipleImages = async (files: File[]) => { - const uploadPromises = files.map(file => uploadImage(file)); +/** + * 다중 이미지 업로드 + * - 전부 성공하면 URL 배열 반환 + * - 하나라도 실패하면 실패 내역을 포함해 throw + */ +export const uploadMultipleImages = async ( + files: File[], + options?: { signal?: AbortSignal; enforceMax?: boolean }, +): Promise => { + // 개수 제한(선택) – FEED 생성 정책에 맞춰 사전 차단하고 싶을 때 사용 + if (options?.enforceMax && files.length > MAX_IMAGES) { + throw new Error(`이미지는 최대 ${MAX_IMAGES}장까지 업로드할 수 있습니다.`); + } + + // 파일별 사전 검증 + files.forEach(validateFile); - try { - const results = await Promise.all(uploadPromises); + // 병렬 업로드 (각 요청 독립) + const results = await Promise.allSettled( + files.map(file => uploadImage(file, { signal: options?.signal })), + ); - // 모든 업로드가 성공한 경우에만 URL 반환 - const successResults = results.filter(result => result.isSuccess); + const successUrls: string[] = []; + const failures: { index: number; reason: string }[] = []; - if (successResults.length !== files.length) { - throw new Error('일부 이미지 업로드에 실패했습니다.'); + results.forEach((res, idx) => { + if (res.status === 'fulfilled') { + const value = res.value; + if (value.isSuccess && value.data?.imageUrl) { + successUrls.push(value.data.imageUrl); + } else { + failures.push({ + index: idx, + reason: value.message || '파일 업로드에 실패하였습니다.', + }); + } + } else { + failures.push({ + index: idx, + reason: (res.reason as Error)?.message || '네트워크 오류로 파일 업로드에 실패하였습니다.', + }); } + }); - return successResults.map(result => result.data!.imageUrl); - } catch (error) { - console.error('이미지 업로드 실패:', error); - throw error; + if (failures.length > 0) { + // 어떤 항목이 왜 실패했는지 상세 메시지 + const detail = failures.map(f => `#${f.index + 1}: ${f.reason}`).join(' / '); + throw new Error(`일부 이미지 업로드에 실패했습니다. (${detail})`); } + + return successUrls; }; /* -사용 방법: +사용 예시: -// 단일 이미지 업로드 +// 단일 try { - const result = await uploadImage(file); - if (result.isSuccess) { - console.log('업로드된 이미지 URL:', result.data?.imageUrl); + const res = await uploadImage(file); + if (res.isSuccess) { + console.log('업로드된 URL:', res.data?.imageUrl); + } else { + console.error('실패:', res.message); } -} catch (error) { - console.error('이미지 업로드 실패:', error); +} catch (e) { + console.error('오류:', e); } -// 다중 이미지 업로드 +// 다중 try { - const imageUrls = await uploadMultipleImages(files); - console.log('업로드된 이미지 URLs:', imageUrls); -} catch (error) { - console.error('다중 이미지 업로드 실패:', error); + const urls = await uploadMultipleImages(files, { enforceMax: true }); + console.log('업로드된 URL들:', urls); +} catch (e) { + console.error('다중 업로드 실패:', e); } */ diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index 616f1ad9..88843320 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -43,7 +43,7 @@ const mockSavedBooks: Book[] = [ title: '토마토 컵라면', author: '작가명', cover: '/src/assets/books/tomato.svg', - isbn: '9781234567890', + isbn: '9780374500016', }, { id: 2, diff --git a/src/hooks/useCreateFeed.ts b/src/hooks/useCreateFeed.ts index c55e6d6b..bff4cd79 100644 --- a/src/hooks/useCreateFeed.ts +++ b/src/hooks/useCreateFeed.ts @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { createFeed, type CreateFeedRequest } from '@/api/feeds/createFeed'; +import { createFeed, type CreateFeedBody, type CreateFeedResponse } from '@/api/feeds/createFeed'; import { usePopupActions } from './usePopupActions'; interface UseCreateFeedProps { @@ -10,60 +10,100 @@ export const useCreateFeed = (options?: UseCreateFeedProps) => { const [loading, setLoading] = useState(false); const { openSnackbar, closePopup } = usePopupActions(); - const createNewFeed = async (feedData: CreateFeedRequest) => { + const createNewFeed = async (body: CreateFeedBody, images?: File[]) => { try { setLoading(true); - const response = await createFeed(feedData); + // ===== 클라이언트 선검증 (선택값일 때만 검사) ===== + if (body.tagList) { + // 최대 5개 + if (body.tagList.length > 5) { + openSnackbar({ + message: '태그는 최대 5개까지 입력할 수 있어요.', + variant: 'top', + onClose: closePopup, + }); + return { success: false as const }; + } + // 중복 제거 체크 + const trimmed = body.tagList.map(t => t.trim()).filter(Boolean); + const uniq = new Set(trimmed); + if (uniq.size !== trimmed.length) { + openSnackbar({ + message: '태그는 중복될 수 없어요.', + variant: 'top', + onClose: closePopup, + }); + return { success: false as const }; + } + } - if (response.isSuccess) { + if (images && images.length > 0) { + // 최대 3장 + if (images.length > 3) { + openSnackbar({ + message: '이미지는 최대 3장까지 업로드할 수 있어요.', + variant: 'top', + onClose: closePopup, + }); + return { success: false as const }; + } + // 빈 파일 금지 + if (images.some(f => f.size === 0)) { + openSnackbar({ + message: '빈 이미지 파일이 포함되어 있어요.', + variant: 'top', + onClose: closePopup, + }); + return { success: false as const }; + } + // 확장자 제한 + const extOk = (name: string) => /\.(jpe?g|png|gif)$/i.test(name); + if (images.some(f => !extOk(f.name))) { + openSnackbar({ + message: '파일 형식은 jpg, jpeg, png, gif만 가능해요.', + variant: 'top', + onClose: closePopup, + }); + return { success: false as const }; + } + } + // ===== 선검증 끝 ===== + + const res: CreateFeedResponse = await createFeed(body, images); + + if (res.isSuccess) { openSnackbar({ message: '피드가 작성되었습니다.', variant: 'top', onClose: closePopup, }); - // 성공 콜백 호출 - if (options?.onSuccess && response.data?.feedId) { - options.onSuccess(response.data.feedId); + if (options?.onSuccess) { + options.onSuccess(res.data.feedId); } - return { - success: true, - feedId: response.data?.feedId, - }; + return { success: true as const, feedId: res.data.feedId }; } else { openSnackbar({ - message: response.message || '피드 작성에 실패했습니다.', + message: res.message || '피드 작성에 실패했습니다.', variant: 'top', onClose: closePopup, }); - - return { - success: false, - error: response.message, - }; + return { success: false as const, error: res.message }; } } catch (error) { console.error('피드 작성 실패:', error); - openSnackbar({ message: '피드 작성 중 오류가 발생했습니다.', variant: 'top', onClose: closePopup, }); - - return { - success: false, - error: '피드 작성 중 오류가 발생했습니다.', - }; + return { success: false as const, error: '피드 작성 중 오류가 발생했습니다.' }; } finally { setLoading(false); } }; - return { - createNewFeed, - loading, - }; + return { createNewFeed, loading }; }; diff --git a/src/pages/post/CreatePost.tsx b/src/pages/post/CreatePost.tsx index e3eb9020..4d442b8b 100644 --- a/src/pages/post/CreatePost.tsx +++ b/src/pages/post/CreatePost.tsx @@ -12,6 +12,26 @@ import { Container } from './CreatePost.styled'; import { Section } from '../group/CommonSection.styled'; import { useCreateFeed } from '@/hooks/useCreateFeed'; import { usePopupActions } from '@/hooks/usePopupActions'; +import type { CreateFeedBody } from '@/api/feeds/createFeed'; +import { ensureIsbn13 } from '@/utils/isbn'; + +// 🔧 보조 유틸: 하이픈/공백 제거 + 대문자 X 유지 +const normalizeIsbn = (raw: string) => raw.replace(/[^0-9Xx]/g, '').toUpperCase(); +const isIsbn10 = (isbn: string) => /^[0-9]{9}[0-9X]$/.test(isbn); + +// ISBN 후보군 생성: 13자리(우선) → 원본정규화 → (가능하면) 10자리 +const makeIsbnCandidates = (raw: string) => { + const candidates: string[] = []; + const normalized = normalizeIsbn(raw); + const isbn13 = ensureIsbn13(raw); // 13으로 변환 성공 시 + if (isbn13) candidates.push(isbn13); + // 혹시 서버가 10자리로만 붙는 경우 대비(일부 API 환경에서 존재) + if (isIsbn10(normalized)) candidates.push(normalized); + // 마지막으로 raw 정규화 값(13도 10도 아니면 그래도 시도) + if (!candidates.includes(normalized)) candidates.push(normalized); + // 중복 제거 + return Array.from(new Set(candidates)); +}; interface Book { id: number; @@ -34,7 +54,6 @@ const CreatePost = () => { const { createNewFeed, loading } = useCreateFeed({ onSuccess: feedId => { console.log('피드 작성 성공! 피드 ID:', feedId); - // 피드 페이지로 이동 navigate('/feed'); }, }); @@ -43,32 +62,7 @@ const CreatePost = () => { navigate(-1); }; - // 이미지 업로드 함수 - const uploadImages = async (files: File[]): Promise => { - if (files.length === 0) return []; - - try { - // 이미지 업로드 API 사용 (실제 구현 시 import 추가 필요) - // import { uploadMultipleImages } from '@/api/images/uploadImage'; - // return await uploadMultipleImages(files); - - // 임시로 base64 변환 사용 (개발용) - const promises = files.map(file => { - return new Promise(resolve => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.readAsDataURL(file); - }); - }); - return Promise.all(promises); - } catch (error) { - console.error('이미지 업로드 실패:', error); - throw new Error('이미지 업로드에 실패했습니다.'); - } - }; - const handleCompleteClick = async () => { - // 필수 항목 검증 if (!isFormValid) { openSnackbar({ message: '책 선택과 글 내용을 입력해주세요.', @@ -78,49 +72,54 @@ const CreatePost = () => { return; } - try { - // 선택된 사진들을 업로드 - let imageUrls: string[] = []; - if (selectedPhotos.length > 0) { - imageUrls = await uploadImages(selectedPhotos); - console.log('이미지 업로드 완료:', imageUrls.length, '개'); - } + const candidates = makeIsbnCandidates(selectedBook!.isbn); + console.log('[CreatePost] ISBN candidates:', candidates); - const feedData = { - request: { - isbn: selectedBook.isbn, - contentBody: postContent.trim(), - isPublic: !isPrivate, // UI에서는 "비공개" 토글이므로 반대로 처리 - tagList: selectedTags, - }, - images: imageUrls, - }; + // images: 선택값 (없으면 undefined 전달 → FormData에 미첨부) + const filesOrUndefined = selectedPhotos.length ? selectedPhotos : undefined; - console.log('피드 작성 요청 데이터:', feedData); + // 최대 2회까지(총 3회) 재시도: 13 → (10) → (정규화원본) + for (let i = 0; i < Math.min(candidates.length, 3); i++) { + const isbnToSend = candidates[i]; + const body: CreateFeedBody = { + isbn: isbnToSend, + contentBody: postContent.trim(), + isPublic: !isPrivate, + ...(selectedTags.length ? { tagList: selectedTags } : {}), + }; - // 피드 작성 API 호출 - await createNewFeed(feedData); - } catch (error) { - console.error('피드 작성 실패:', error); - openSnackbar({ - message: '피드 작성 중 오류가 발생했습니다.', - variant: 'top', - onClose: closePopup, - }); + console.log(`[CreatePost] Try #${i + 1} with ISBN:`, isbnToSend); + + try { + const result = await createNewFeed(body, filesOrUndefined); + if (result?.success) { + // onSuccess에서 이동 처리됨 + return; + } else { + // useCreateFeed에서 서버 메시지를 스낵바로 띄움 + // 80009면 다음 후보로 자동 재시도, 그 외면 바로 중단 + // (result.errorCode를 반환하도록 훅을 확장했다면 여기서 체크) + // 현재 훅은 errorCode를 안 주니, 다음 후보가 있으면 조용히 다음 루프 진행 + } + } catch (error) { + console.error(`[CreatePost] Try #${i + 1} failed:`, error); + // 네트워크/타임아웃 등은 바로 중단 + break; + } } - }; - const handleBookSearchOpen = () => { - setIsBookSearchOpen(true); + // 여기까지 왔다면 모든 시도가 실패 + openSnackbar({ + message: + 'ISBN으로 책이 조회되지 않아요. ISBN-13(하이픈 없이)으로 다시 선택하시거나 다른 책으로 시도해 주세요.', + variant: 'top', + onClose: closePopup, + }); }; - const handleChangeBook = () => { - setIsBookSearchOpen(true); - }; - - const handleBookSearchClose = () => { - setIsBookSearchOpen(false); - }; + const handleBookSearchOpen = () => setIsBookSearchOpen(true); + const handleChangeBook = () => setIsBookSearchOpen(true); + const handleBookSearchClose = () => setIsBookSearchOpen(false); const handleBookSelect = (book: Book) => { setSelectedBook(book); @@ -128,22 +127,19 @@ const CreatePost = () => { }; const handlePhotoAdd = (files: File[]) => { - setSelectedPhotos(prev => [...prev, ...files].slice(0, 3)); // 최대 3개까지 + setSelectedPhotos(prev => [...prev, ...files].slice(0, 3)); }; const handlePhotoRemove = (index: number) => { setSelectedPhotos(prev => prev.filter((_, i) => i !== index)); }; - const handlePrivacyToggle = () => { - setIsPrivate(!isPrivate); - }; + const handlePrivacyToggle = () => setIsPrivate(v => !v); const handleTagToggle = (tag: string) => { setSelectedTags(prev => (prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])); }; - // 책 선택과 글 내용만 필수, 나머지는 선택사항 const isFormValid = !!selectedBook && postContent.trim() !== ''; return ( diff --git a/src/utils/isbn.ts b/src/utils/isbn.ts new file mode 100644 index 00000000..058e8972 --- /dev/null +++ b/src/utils/isbn.ts @@ -0,0 +1,23 @@ +export const normalizeIsbn = (raw: string) => raw.replace(/[^0-9Xx]/g, '').toUpperCase(); + +export const isIsbn10 = (isbn: string) => /^[0-9]{9}[0-9X]$/.test(isbn); +export const isIsbn13 = (isbn: string) => /^[0-9]{13}$/.test(isbn); + +/** ISBN-10 → ISBN-13 변환 (prefix 978 + 체크디지트 재계산) */ +export const isbn10to13 = (isbn10: string) => { + const core = '978' + isbn10.slice(0, 9); // 기존 체크디지트 제외 + const sum = core + .split('') + .map(Number) + .reduce((acc, n, i) => acc + n * (i % 2 === 0 ? 1 : 3), 0); + const check = (10 - (sum % 10)) % 10; + return core + String(check); +}; + +/** 하이픈/공백 제거 → 10이면 13으로 변환 → 최종 13자리 숫자 반환 */ +export const ensureIsbn13 = (raw: string): string | null => { + const n = normalizeIsbn(raw); + if (isIsbn13(n)) return n; + if (isIsbn10(n)) return isbn10to13(n); + return null; +}; From c7e3e9c287862715384435ae414442560f3e3af7 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Wed, 13 Aug 2025 13:13:40 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=AA=85=20=EB=B6=88=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(contentsUrl=20=E2=86=92=20contentUrls)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/feeds/getFeedDetail.ts | 4 ++-- src/api/feeds/getMyFeed.ts | 2 +- src/api/feeds/getOtherFeed.ts | 4 ++-- src/api/feeds/getTotalFeed.ts | 2 +- src/components/common/Post/PostBody.tsx | 6 +++--- src/components/feed/FeedDetailPostBody.tsx | 8 ++++---- src/data/postData.ts | 8 ++++---- src/mocks/searchBook.mock.ts | 4 ++-- src/types/post.ts | 4 ++-- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/api/feeds/getFeedDetail.ts b/src/api/feeds/getFeedDetail.ts index a5388c03..f71415b5 100644 --- a/src/api/feeds/getFeedDetail.ts +++ b/src/api/feeds/getFeedDetail.ts @@ -13,7 +13,7 @@ export interface FeedDetailData { bookTitle: string; bookAuthor: string; contentBody: string; - contentsUrl: string[]; + contentUrls: string[]; likeCount: number; commentCount: number; isSaved: boolean; @@ -23,7 +23,7 @@ export interface FeedDetailData { // API 응답 타입 export interface FeedDetailResponse { - success: boolean; + isSuccess: boolean; code: number; message: string; data: FeedDetailData; diff --git a/src/api/feeds/getMyFeed.ts b/src/api/feeds/getMyFeed.ts index 062a19c9..88954cc4 100644 --- a/src/api/feeds/getMyFeed.ts +++ b/src/api/feeds/getMyFeed.ts @@ -10,7 +10,7 @@ export interface MyFeedData { // API 응답 타입 export interface MyFeedResponse { - success: boolean; + isSuccess: boolean; code: number; message: string; data: MyFeedData; diff --git a/src/api/feeds/getOtherFeed.ts b/src/api/feeds/getOtherFeed.ts index a9603167..fc7c3fc3 100644 --- a/src/api/feeds/getOtherFeed.ts +++ b/src/api/feeds/getOtherFeed.ts @@ -8,7 +8,7 @@ export interface OtherFeedItem { bookTitle: string; bookAuthor: string; contentBody: string; - contentsUrl: string[]; + contentUrls: string[]; likeCount: number; commentCount: number; isSaved: boolean; @@ -21,7 +21,7 @@ export interface OtherFeedData { // API 응답 타입 export interface OtherFeedResponse { - success: boolean; + isSuccess: boolean; code: number; message: string; data: OtherFeedData; diff --git a/src/api/feeds/getTotalFeed.ts b/src/api/feeds/getTotalFeed.ts index 2008f996..9fb77e39 100644 --- a/src/api/feeds/getTotalFeed.ts +++ b/src/api/feeds/getTotalFeed.ts @@ -10,7 +10,7 @@ export interface TotalFeedData { // API 응답 타입 export interface TotalFeedResponse { - success: boolean; + isSuccess: boolean; code: number; message: string; data: TotalFeedData; diff --git a/src/components/common/Post/PostBody.tsx b/src/components/common/Post/PostBody.tsx index 0e48816f..dc8f04e9 100644 --- a/src/components/common/Post/PostBody.tsx +++ b/src/components/common/Post/PostBody.tsx @@ -50,10 +50,10 @@ const PostBody = ({ bookAuthor, contentBody, feedId, - contentsUrl = [], + contentUrls = [], }: PostBodyProps) => { const navigate = useNavigate(); - const hasImage = contentsUrl.length > 0; + const hasImage = contentUrls.length > 0; const handlePostClick = (feedId: number) => { // if (!isClickable) return; @@ -68,7 +68,7 @@ const PostBody = ({
{contentBody}
{hasImage && (
- {contentsUrl.map((src: string, i: number) => ( + {contentUrls.map((src: string, i: number) => ( ))}
diff --git a/src/components/feed/FeedDetailPostBody.tsx b/src/components/feed/FeedDetailPostBody.tsx index 3755556e..d92276be 100644 --- a/src/components/feed/FeedDetailPostBody.tsx +++ b/src/components/feed/FeedDetailPostBody.tsx @@ -81,13 +81,13 @@ const FeedDetailPostBody = ({ isbn, bookAuthor, contentBody, - contentsUrl = [], + contentUrls = [], tags = [], }: FeedDetailPostBodyProps) => { const [isImageViewerOpen, setIsImageViewerOpen] = useState(false); const [selectedImageIndex, setSelectedImageIndex] = useState(0); - const hasImage = contentsUrl.length > 0; + const hasImage = contentUrls.length > 0; const hasTag = tags.length > 0; const handleImageClick = (index: number) => { @@ -106,7 +106,7 @@ const FeedDetailPostBody = ({
{contentBody}
{hasImage && (
- {contentsUrl.map((src: string, i: number) => ( + {contentUrls.map((src: string, i: number) => ( {`이미지 handleImageClick(i)} /> ))}
@@ -126,7 +126,7 @@ const FeedDetailPostBody = ({ {isImageViewerOpen && ( ; // 대댓글(SubReply) From 9fc773766f2eaa3c4b6c0a2115f9af60be407a94 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Wed, 13 Aug 2025 13:19:15 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20SearchBook=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=ED=83=80=EC=9E=85=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/searchBook.mock.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mocks/searchBook.mock.ts b/src/mocks/searchBook.mock.ts index 3b8c8adb..7352295f 100644 --- a/src/mocks/searchBook.mock.ts +++ b/src/mocks/searchBook.mock.ts @@ -46,7 +46,7 @@ export const mockSearchBook = { bookTitle: '제목입니다', bookAuthor: '작가입니다', contentBody: '내용입니다…', - contentUrl: ['https://placehold.co/100x100', 'https://placehold.co/100x100'], + contentUrls: ['https://placehold.co/100x100', 'https://placehold.co/100x100'], likeCount: 125, commentCount: 125, isSaved: false, @@ -65,7 +65,7 @@ export const mockSearchBook = { bookAuthor: '작가입니다', contentBody: '내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다', - contentUrl: [], + contentUrls: [], likeCount: 125, commentCount: 125, isSaved: true, From 28642ddb6fd78c30ba9c356e23a258181f939901 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Wed, 13 Aug 2025 13:33:57 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20console.log=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/post/CreatePost.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/pages/post/CreatePost.tsx b/src/pages/post/CreatePost.tsx index 4d442b8b..9cf4885b 100644 --- a/src/pages/post/CreatePost.tsx +++ b/src/pages/post/CreatePost.tsx @@ -73,7 +73,6 @@ const CreatePost = () => { } const candidates = makeIsbnCandidates(selectedBook!.isbn); - console.log('[CreatePost] ISBN candidates:', candidates); // images: 선택값 (없으면 undefined 전달 → FormData에 미첨부) const filesOrUndefined = selectedPhotos.length ? selectedPhotos : undefined; @@ -88,8 +87,6 @@ const CreatePost = () => { ...(selectedTags.length ? { tagList: selectedTags } : {}), }; - console.log(`[CreatePost] Try #${i + 1} with ISBN:`, isbnToSend); - try { const result = await createNewFeed(body, filesOrUndefined); if (result?.success) {