diff --git a/src/api/feeds/createFeed.ts b/src/api/feeds/createFeed.ts new file mode 100644 index 00000000..a153147b --- /dev/null +++ b/src/api/feeds/createFeed.ts @@ -0,0 +1,55 @@ +import { apiClient } from '../index'; + +/** 서버에 보낼 request JSON 페이로드 */ +export interface CreateFeedBody { + isbn: string; + contentBody: string; + isPublic: boolean; + tagList?: string[]; +} + +/** 성공 응답 */ +export interface CreateFeedSuccess { + isSuccess: true; + code: number; + message: string; + data: { + feedId: number; + }; +} + +/** 실패 응답 */ +export interface CreateFeedFail { + isSuccess: false; + code: number; + message: string; +} + +export type CreateFeedResponse = CreateFeedSuccess | CreateFeedFail; + +/** + * 피드 작성 API + * - multipart/form-data + * - request: application/json (Blob로 감싸 전송) + * - images: File[] (선택값, 없으면 미첨부) + */ +export const createFeed = async ( + body: CreateFeedBody, + images?: File[], +): Promise => { + const form = new FormData(); + + // 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)); + } + + const { data } = await apiClient.post('/feeds', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + return data; +}; 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/api/images/uploadImage.ts b/src/api/images/uploadImage.ts new file mode 100644 index 00000000..05d301d3 --- /dev/null +++ b/src/api/images/uploadImage.ts @@ -0,0 +1,128 @@ +import { apiClient } from '../index'; + +/** 단일 이미지 업로드 성공 시 데이터 */ +export interface UploadImageData { + imageUrl: string; +} + +/** 서버 공통 응답 타입 */ +export interface UploadImageResponse { + isSuccess: boolean; + code: number; + message: string; + data?: UploadImageData; // 성공 시에만 존재 +} + +/** 내부 유틸: 허용 확장자 */ +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 { data } = await apiClient.post('/images/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + signal: options?.signal, + }); + + return data; +}; + +/** + * 다중 이미지 업로드 + * - 전부 성공하면 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); + + // 병렬 업로드 (각 요청 독립) + const results = await Promise.allSettled( + files.map(file => uploadImage(file, { signal: options?.signal })), + ); + + const successUrls: string[] = []; + const failures: { index: number; reason: string }[] = []; + + 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 || '네트워크 오류로 파일 업로드에 실패하였습니다.', + }); + } + }); + + if (failures.length > 0) { + // 어떤 항목이 왜 실패했는지 상세 메시지 + const detail = failures.map(f => `#${f.index + 1}: ${f.reason}`).join(' / '); + throw new Error(`일부 이미지 업로드에 실패했습니다. (${detail})`); + } + + return successUrls; +}; + +/* +사용 예시: + +// 단일 +try { + const res = await uploadImage(file); + if (res.isSuccess) { + console.log('업로드된 URL:', res.data?.imageUrl); + } else { + console.error('실패:', res.message); + } +} catch (e) { + console.error('오류:', e); +} + +// 다중 +try { + 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 b8aa5dff..88843320 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: '9780374500016', }, { 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/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 && ( void; +} + +export const useCreateFeed = (options?: UseCreateFeedProps) => { + const [loading, setLoading] = useState(false); + const { openSnackbar, closePopup } = usePopupActions(); + + const createNewFeed = async (body: CreateFeedBody, images?: File[]) => { + try { + setLoading(true); + + // ===== 클라이언트 선검증 (선택값일 때만 검사) ===== + 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 (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) { + options.onSuccess(res.data.feedId); + } + + return { success: true as const, feedId: res.data.feedId }; + } else { + openSnackbar({ + message: res.message || '피드 작성에 실패했습니다.', + variant: 'top', + onClose: closePopup, + }); + return { success: false as const, error: res.message }; + } + } catch (error) { + console.error('피드 작성 실패:', error); + openSnackbar({ + message: '피드 작성 중 오류가 발생했습니다.', + variant: 'top', + onClose: closePopup, + }); + return { success: false as const, error: '피드 작성 중 오류가 발생했습니다.' }; + } finally { + setLoading(false); + } + }; + + return { createNewFeed, loading }; +}; diff --git a/src/mocks/searchBook.mock.ts b/src/mocks/searchBook.mock.ts index 8e202754..7352295f 100644 --- a/src/mocks/searchBook.mock.ts +++ b/src/mocks/searchBook.mock.ts @@ -46,7 +46,7 @@ export const mockSearchBook = { bookTitle: '제목입니다', bookAuthor: '작가입니다', contentBody: '내용입니다…', - contentsUrl: ['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: '내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다', - contentsUrl: [], + contentUrls: [], likeCount: 125, commentCount: 125, isSaved: true, diff --git a/src/pages/post/CreatePost.tsx b/src/pages/post/CreatePost.tsx index 38eb55ca..9cf4885b 100644 --- a/src/pages/post/CreatePost.tsx +++ b/src/pages/post/CreatePost.tsx @@ -10,12 +10,35 @@ 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'; +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; title: string; author: string; cover: string; + isbn: string; } const CreatePost = () => { @@ -27,63 +50,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 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); - - // TODO: API 호출하여 글 등록 - // 완료 후 이전 페이지로 이동 - navigate(-1); - }; - - const handleBookSearchOpen = () => { - setIsBookSearchOpen(true); - }; + const candidates = makeIsbnCandidates(selectedBook!.isbn); + + // images: 선택값 (없으면 undefined 전달 → FormData에 미첨부) + const filesOrUndefined = selectedPhotos.length ? selectedPhotos : undefined; + + // 최대 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 } : {}), + }; + + 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 handleChangeBook = () => { - setIsBookSearchOpen(true); + // 여기까지 왔다면 모든 시도가 실패 + openSnackbar({ + message: + 'ISBN으로 책이 조회되지 않아요. ISBN-13(하이픈 없이)으로 다시 선택하시거나 다른 책으로 시도해 주세요.', + variant: 'top', + onClose: closePopup, + }); }; - const handleBookSearchClose = () => { - setIsBookSearchOpen(false); - }; + const handleBookSearchOpen = () => setIsBookSearchOpen(true); + const handleChangeBook = () => setIsBookSearchOpen(true); + const handleBookSearchClose = () => setIsBookSearchOpen(false); const handleBookSelect = (book: Book) => { setSelectedBook(book); + setIsBookSearchOpen(false); }; 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 ( @@ -91,10 +144,10 @@ const CreatePost = () => { } title="새 글" - rightButton="완료" + rightButton={loading ? '작성 중...' : '완료'} onLeftClick={handleBackClick} onRightClick={handleCompleteClick} - isNextActive={isFormValid} + isNextActive={isFormValid && !loading} /> ; // 대댓글(SubReply) 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; +};