From 9650cc733896e5775471839b0098a6b9a5e7477a Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Tue, 12 Aug 2025 21:44:11 +0900 Subject: [PATCH 01/15] =?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 02/15] =?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 03/15] =?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 04/15] =?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 05/15] =?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 06/15] =?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) { From 412a91f8b124f99fe5b07a54744e7fcf6ba62e0a Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Wed, 13 Aug 2025 14:11:38 +0900 Subject: [PATCH 07/15] =?UTF-8?q?design:=20BookInfoCard=20width=20?= =?UTF-8?q?=EC=88=98=EC=A0=95(=EB=B0=98=EC=9D=91=ED=98=95=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/feed/BookInfoCard.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/feed/BookInfoCard.tsx b/src/components/feed/BookInfoCard.tsx index 59e10fcc..f512436c 100644 --- a/src/components/feed/BookInfoCard.tsx +++ b/src/components/feed/BookInfoCard.tsx @@ -6,6 +6,8 @@ const BookContainer = styled.div` display: flex; height: 44px; padding: 8px 4px 8px 12px; + min-width: 280px; + max-width: 500px; flex-direction: row; align-items: center; justify-content: space-between; @@ -15,7 +17,7 @@ const BookContainer = styled.div` .left { overflow: hidden; - max-width: 340px; + width: 220px; white-space: nowrap; color: var(--color-white); text-overflow: ellipsis; @@ -38,7 +40,7 @@ const BookContainer = styled.div` line-height: 24px; .name { - max-width: 100px; + width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; From 94c0a87b1a24797bc9a63830cc546a40cdb5f077 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Wed, 13 Aug 2025 14:55:41 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=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/getFeedDetail.ts | 4 +- src/api/feeds/updateFeed.ts | 82 +++++++ .../creategroup/BookSelectionSection.tsx | 10 +- src/components/createpost/PhotoSection.tsx | 85 ++++++-- src/hooks/useUpdateFeed.ts | 78 +++++++ src/pages/feed/FeedDetailPage.tsx | 5 +- src/pages/index.tsx | 2 + src/pages/post/UpdatePost.tsx | 204 ++++++++++++++++++ 8 files changed, 445 insertions(+), 25 deletions(-) create mode 100644 src/api/feeds/updateFeed.ts create mode 100644 src/hooks/useUpdateFeed.ts create mode 100644 src/pages/post/UpdatePost.tsx diff --git a/src/api/feeds/getFeedDetail.ts b/src/api/feeds/getFeedDetail.ts index 65a33fb1..75e5d338 100644 --- a/src/api/feeds/getFeedDetail.ts +++ b/src/api/feeds/getFeedDetail.ts @@ -6,7 +6,7 @@ export interface FeedDetailData { creatorId: number; creatorNickname: string; creatorProfileImageUrl: string; - aliasName: string; + alias: string; aliasColor: string; postDate: string; isbn: string; @@ -18,6 +18,7 @@ export interface FeedDetailData { commentCount: number; isSaved: boolean; isLiked: boolean; + isPublic: boolean; tagList: string[]; } @@ -40,4 +41,5 @@ export const getFeedDetail = async (feedId: number) => { const feedDetail = await getFeedDetail(123); console.log(feedDetail.data.feedId); // 123 console.log(feedDetail.data.tagList); // ["태그1", "태그2"] +console.log(feedDetail.data.isPublic); // true or false */ diff --git a/src/api/feeds/updateFeed.ts b/src/api/feeds/updateFeed.ts new file mode 100644 index 00000000..b5a14750 --- /dev/null +++ b/src/api/feeds/updateFeed.ts @@ -0,0 +1,82 @@ +import { apiClient } from '../index'; + +/** 피드 수정 요청 바디 타입 */ +export interface UpdateFeedBody { + contentBody: string; + isPublic: boolean; + tagList?: string[]; // 선택 필드 + remainImageUrls?: string[]; // 기존 이미지 중 유지할 URL들 +} + +/** 성공 응답 */ +export interface UpdateFeedSuccess { + isSuccess: true; + code: number; + message: string; +} + +/** 실패 응답 */ +export interface UpdateFeedFail { + isSuccess: false; + code: number; + message: string; +} + +export type UpdateFeedResponse = UpdateFeedSuccess | UpdateFeedFail; + +/** + * 피드 수정 API + * - multipart/form-data + * - request: application/json (Blob로 감싸 전송) + * - 이미지 추가는 불가능, 기존 이미지 삭제만 가능 + */ +export const updateFeed = async ( + feedId: number, + body: UpdateFeedBody, +): Promise => { + const form = new FormData(); + + // request 파트(JSON) - 필수 + form.append('request', new Blob([JSON.stringify(body)], { type: 'application/json' })); + + // 수정 모드에서는 새 이미지 추가 불가 + + const { data } = await apiClient.patch(`/feeds/${feedId}`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + return data; +}; + +/* +사용 예시: + +// 기존 이미지 일부 유지 (새 이미지 추가는 불가) +const updateBody: UpdateFeedBody = { + contentBody: "수정된 글 내용입니다!", + isPublic: true, + tagList: ["한국소설", "책추천", "역사"], + remainImageUrls: ["https://img.domain.com/1.jpg"] // 기존 이미지 중 유지할 것들 +}; + +try { + const result = await updateFeed(123, updateBody); + if (result.isSuccess) { + console.log('피드 수정 성공:', result.message); + } else { + console.error('피드 수정 실패:', result.message); + } +} catch (error) { + console.error('네트워크 오류:', error); +} + +// 모든 이미지 삭제 후 텍스트만 수정 +const textOnlyUpdate: UpdateFeedBody = { + contentBody: "텍스트만 수정", + isPublic: false, + tagList: [], + remainImageUrls: [] // 모든 기존 이미지 삭제 +}; + +const result = await updateFeed(123, textOnlyUpdate); +*/ diff --git a/src/components/creategroup/BookSelectionSection.tsx b/src/components/creategroup/BookSelectionSection.tsx index 73a2e7c5..e65b7e99 100644 --- a/src/components/creategroup/BookSelectionSection.tsx +++ b/src/components/creategroup/BookSelectionSection.tsx @@ -16,19 +16,21 @@ interface BookSelectionSectionProps { selectedBook: { cover: string; title: string; author: string } | null; onSearchClick: () => void; onChangeClick: () => void; + readOnly?: boolean; } const BookSelectionSection = ({ selectedBook, onSearchClick, onChangeClick, + readOnly = false, }: BookSelectionSectionProps) => { return (
책 선택 {selectedBook ? ( <> @@ -41,14 +43,16 @@ const BookSelectionSection = ({ {selectedBook.author} 저 - 변경 + {!readOnly && 변경} ) : ( <> 검색 - 검색해서 찾기 + + {readOnly ? '책 정보' : '검색해서 찾기'} + )} diff --git a/src/components/createpost/PhotoSection.tsx b/src/components/createpost/PhotoSection.tsx index 5a3cd9dc..f61899b6 100644 --- a/src/components/createpost/PhotoSection.tsx +++ b/src/components/createpost/PhotoSection.tsx @@ -13,19 +13,34 @@ import plusDisabledIcon from '../../assets/post/plus-disabled.svg'; import closeIcon from '../../assets/post/close.svg'; interface PhotoSectionProps { - photos: File[]; + photos: File[]; // 새로 추가할 이미지들 onPhotoAdd: (files: File[]) => void; onPhotoRemove: (index: number) => void; + existingImageUrls?: string[]; // 기존 이미지 URL들 (수정 시에만 사용) + onExistingImageRemove?: (imageUrl: string) => void; // 기존 이미지 제거 함수 (수정 시에만 사용) + readOnly?: boolean; // 읽기 전용 모드 + isEditMode?: boolean; // 수정 모드 (사진 추가 버튼 숨김) } -const PhotoSection = ({ photos, onPhotoAdd, onPhotoRemove }: PhotoSectionProps) => { +const PhotoSection = ({ + photos, + onPhotoAdd, + onPhotoRemove, + existingImageUrls = [], + onExistingImageRemove, + readOnly = false, + isEditMode = false, +}: PhotoSectionProps) => { const fileInputRef = useRef(null); const handleFileInputClick = () => { + if (readOnly || isEditMode) return; fileInputRef.current?.click(); }; const handleFileChange = (e: React.ChangeEvent) => { + if (readOnly || isEditMode) return; + const files = Array.from(e.target.files || []); if (files.length > 0) { onPhotoAdd(files); @@ -38,34 +53,64 @@ const PhotoSection = ({ photos, onPhotoAdd, onPhotoRemove }: PhotoSectionProps) return URL.createObjectURL(file); }; - const isDisabled = photos.length >= 3; + const totalImageCount = existingImageUrls.length + photos.length; + const isDisabled = totalImageCount >= 3 || readOnly || isEditMode; return (
사진 추가 - - 사진 추가 - + {/* 새 이미지 추가 버튼 - 수정 모드에서는 숨김 */} + {!readOnly && !isEditMode && ( + + 사진 추가 + + )} + + {/* 기존 이미지들 (수정 모드에서만 표시) */} + {existingImageUrls.map((imageUrl, index) => ( +
+ + {!readOnly && onExistingImageRemove && ( + onExistingImageRemove(imageUrl)}> + 삭제 + + )} +
+ ))} + + {/* 새로 추가된 이미지들 */} {photos.map((photo, index) => ( -
- - onPhotoRemove(index)}> - 삭제 - +
+ + {!readOnly && ( + onPhotoRemove(index)}> + 삭제 + + )}
))} - {photos.length}/3개 - + + {totalImageCount}/3개 + + {!readOnly && !isEditMode && ( + + )}
); diff --git a/src/hooks/useUpdateFeed.ts b/src/hooks/useUpdateFeed.ts new file mode 100644 index 00000000..7f558b8f --- /dev/null +++ b/src/hooks/useUpdateFeed.ts @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { updateFeed, type UpdateFeedBody, type UpdateFeedResponse } from '@/api/feeds/updateFeed'; +import { usePopupActions } from './usePopupActions'; + +interface UseUpdateFeedProps { + onSuccess?: (feedId: number) => void; +} + +export const useUpdateFeed = (options?: UseUpdateFeedProps) => { + const [loading, setLoading] = useState(false); + const { openSnackbar, closePopup } = usePopupActions(); + + const updateExistingFeed = async (feedId: number, body: UpdateFeedBody) => { + 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 }; + } + } + // ===== 선검증 끝 ===== + + const res: UpdateFeedResponse = await updateFeed(feedId, body); + + if (res.isSuccess) { + openSnackbar({ + message: '피드가 수정되었습니다.', + variant: 'top', + onClose: closePopup, + }); + + if (options?.onSuccess) { + options.onSuccess(feedId); + } + + return { success: true as const, 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 { updateExistingFeed, loading }; +}; diff --git a/src/pages/feed/FeedDetailPage.tsx b/src/pages/feed/FeedDetailPage.tsx index a87c727f..048346ba 100644 --- a/src/pages/feed/FeedDetailPage.tsx +++ b/src/pages/feed/FeedDetailPage.tsx @@ -87,7 +87,10 @@ const FeedDetailPage = () => { const handleMoreClick = () => { openMoreMenu({ - onEdit: () => console.log('수정하기 클릭'), + onEdit: () => { + closePopup(); + navigate(`/post/update/${feedId}`); + }, onClose: () => { closePopup(); }, diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 343e91c2..b7cba507 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -11,6 +11,7 @@ import SignupNickname from './signup/SignupNickname'; import SignupDone from './signup/SignupDone'; import CreateGroup from './group/CreateGroup'; import CreatePost from './post/CreatePost'; +import UpdatePost from './post/UpdatePost'; import Group from './group/Group'; import Feed from './feed/Feed'; import GroupSearch from './groupSearch/GroupSearch'; @@ -51,6 +52,7 @@ const Router = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/post/UpdatePost.tsx b/src/pages/post/UpdatePost.tsx new file mode 100644 index 00000000..1b266a1e --- /dev/null +++ b/src/pages/post/UpdatePost.tsx @@ -0,0 +1,204 @@ +import { useState, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import TitleHeader from '../../components/common/TitleHeader'; +import BookSelectionSection from '../../components/creategroup/BookSelectionSection'; +import PostContentSection from '../../components/createpost/PostContentSection'; +import PhotoSection from '../../components/createpost/PhotoSection'; +import PrivacyToggleSection from '../../components/createpost/PrivacyToggleSection'; +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 { useUpdateFeed } from '@/hooks/useUpdateFeed'; +import { usePopupActions } from '@/hooks/usePopupActions'; +import { getFeedDetail } from '@/api/feeds/getFeedDetail'; +import type { UpdateFeedBody } from '@/api/feeds/updateFeed'; + +interface Book { + id: number; + title: string; + author: string; + cover: string; + isbn: string; +} + +const UpdatePost = () => { + const navigate = useNavigate(); + const { feedId } = useParams<{ feedId: string }>(); + const [selectedBook, setSelectedBook] = useState(null); + const [postContent, setPostContent] = useState(''); + const [selectedPhotos] = useState([]); // 수정 모드에서는 사용하지 않음 + const [remainImageUrls, setRemainImageUrls] = useState([]); // 유지할 기존 이미지들 + const [isPrivate, setIsPrivate] = useState(false); + const [selectedTags, setSelectedTags] = useState([]); + const [loading, setLoading] = useState(true); + + const { openSnackbar, closePopup } = usePopupActions(); + const { updateExistingFeed, loading: updateLoading } = useUpdateFeed({ + onSuccess: feedId => { + console.log('피드 수정 성공! 피드 ID:', feedId); + navigate(`/feed/${feedId}`); + }, + }); + + // 피드 상세 정보 로드 + useEffect(() => { + const loadFeedDetail = async () => { + if (!feedId) { + openSnackbar({ + message: '잘못된 피드 ID입니다.', + variant: 'top', + onClose: closePopup, + }); + navigate(-1); + return; + } + + try { + setLoading(true); + const response = await getFeedDetail(Number(feedId)); + const data = response.data; + + // 기존 데이터로 폼 초기화 + setSelectedBook({ + id: 0, // API에서 bookId가 없다면 임시값 + title: data.bookTitle, + author: data.bookAuthor, + cover: '', // API에서 bookCover가 없다면 빈 문자열 + isbn: data.isbn, + }); + + setPostContent(data.contentBody); + setIsPrivate(!data.isPublic); // isPublic 필드 사용 + setSelectedTags(data.tagList || []); + setRemainImageUrls(data.contentUrls || []); // 처음에는 모든 기존 이미지 유지 + } catch (error) { + console.error('피드 상세 정보 로드 실패:', error); + openSnackbar({ + message: '피드 정보를 불러오는데 실패했습니다.', + variant: 'top', + onClose: closePopup, + }); + navigate(-1); + } finally { + setLoading(false); + } + }; + + loadFeedDetail(); + }, [feedId, navigate, openSnackbar, closePopup]); + + const handleBackClick = () => { + navigate(-1); + }; + + const handleCompleteClick = async () => { + if (!isFormValid) { + openSnackbar({ + message: '글 내용을 입력해주세요.', + variant: 'top', + onClose: closePopup, + }); + return; + } + + if (!feedId) return; + + const body: UpdateFeedBody = { + contentBody: postContent.trim(), + isPublic: !isPrivate, + ...(selectedTags.length ? { tagList: selectedTags } : {}), + ...(remainImageUrls.length ? { remainImageUrls } : {}), + }; + + // 수정 모드에서는 새 이미지 추가 불가 + const result = await updateExistingFeed(Number(feedId), body); + + if (!result?.success) { + // 에러는 useUpdateFeed에서 이미 처리됨 + return; + } + }; + + // 새로 추가할 이미지 핸들러 (수정 모드에서는 사용하지 않음) + const handlePhotoAdd = () => { + // 수정 모드에서는 새 이미지 추가 불가 + return; + }; + + // 새로 추가한 이미지 제거 (수정 모드에서는 사용하지 않음) + const handlePhotoRemove = () => { + // 수정 모드에서는 새 이미지 추가 불가 + return; + }; + + // 기존 이미지 제거 (remainImageUrls에서 제외) + const handleExistingImageRemove = (imageUrl: string) => { + setRemainImageUrls(prev => prev.filter(url => url !== imageUrl)); + }; + + const handlePrivacyToggle = () => setIsPrivate(v => !v); + + const handleTagToggle = (tag: string) => { + setSelectedTags(prev => (prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])); + }; + + const isFormValid = postContent.trim() !== ''; + + // 로딩 중 + if (loading) { + return ( +
+ 피드 정보를 불러오는 중... +
+ ); + } + + return ( + <> + } + title="글 수정" + rightButton={updateLoading ? '수정 중...' : '완료'} + onLeftClick={handleBackClick} + onRightClick={handleCompleteClick} + isNextActive={isFormValid && !updateLoading} + /> + + {/* 책 정보는 수정할 수 없음 (읽기 전용으로 표시) */} + {}} // 비활성화 + onChangeClick={() => {}} // 비활성화 + readOnly={true} // 읽기 전용 모드 + /> + +
+ + + +
+ + {/* 기존 이미지 삭제만 가능, 추가는 불가 */} + + +
+ + + +
+ + + + + ); +}; + +export default UpdatePost; From f22057d9eb3b4b2751fb66b2c0c65b9e4f0073fb Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Wed, 13 Aug 2025 14:58:00 +0900 Subject: [PATCH 09/15] =?UTF-8?q?fix:=20=EB=AC=B4=ED=95=9C=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/post/UpdatePost.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/post/UpdatePost.tsx b/src/pages/post/UpdatePost.tsx index 1b266a1e..1d5f4c95 100644 --- a/src/pages/post/UpdatePost.tsx +++ b/src/pages/post/UpdatePost.tsx @@ -61,17 +61,17 @@ const UpdatePost = () => { // 기존 데이터로 폼 초기화 setSelectedBook({ - id: 0, // API에서 bookId가 없다면 임시값 + id: 0, title: data.bookTitle, author: data.bookAuthor, - cover: '', // API에서 bookCover가 없다면 빈 문자열 + cover: '', isbn: data.isbn, }); setPostContent(data.contentBody); - setIsPrivate(!data.isPublic); // isPublic 필드 사용 + setIsPrivate(!data.isPublic); setSelectedTags(data.tagList || []); - setRemainImageUrls(data.contentUrls || []); // 처음에는 모든 기존 이미지 유지 + setRemainImageUrls(data.contentUrls || []); } catch (error) { console.error('피드 상세 정보 로드 실패:', error); openSnackbar({ @@ -86,7 +86,7 @@ const UpdatePost = () => { }; loadFeedDetail(); - }, [feedId, navigate, openSnackbar, closePopup]); + }, [feedId]); // 의존성 배열에서 함수들 제거 const handleBackClick = () => { navigate(-1); From 0bf6ec7456887fe4b655905de59e557c8e7d84c6 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Wed, 13 Aug 2025 14:58:53 +0900 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20=EB=B9=88=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20src=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creategroup/BookSelectionSection.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/creategroup/BookSelectionSection.tsx b/src/components/creategroup/BookSelectionSection.tsx index e65b7e99..2c345ef8 100644 --- a/src/components/creategroup/BookSelectionSection.tsx +++ b/src/components/creategroup/BookSelectionSection.tsx @@ -36,7 +36,25 @@ const BookSelectionSection = ({ <> - {selectedBook.title} + {selectedBook.cover && selectedBook.cover.trim() !== '' ? ( + {selectedBook.title} + ) : ( +
+ 책표지 +
+ )}
{selectedBook.title} From fcf18eadb49464adc4aa799237d440fe6bdccba1 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Wed, 13 Aug 2025 15:04:00 +0900 Subject: [PATCH 11/15] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20API=20=EC=9A=94=EC=B2=AD=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=EC=9D=84=20JSON=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/feeds/updateFeed.ts | 31 +++++++++++++++++++++++-------- src/pages/post/UpdatePost.tsx | 8 ++++++-- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/api/feeds/updateFeed.ts b/src/api/feeds/updateFeed.ts index b5a14750..a4d6265d 100644 --- a/src/api/feeds/updateFeed.ts +++ b/src/api/feeds/updateFeed.ts @@ -34,18 +34,33 @@ export const updateFeed = async ( feedId: number, body: UpdateFeedBody, ): Promise => { - const form = new FormData(); + // FormData 대신 JSON으로 시도 + console.log('수정 API 요청 (JSON):', { + url: `/feeds/${feedId}`, + body: body, + }); - // request 파트(JSON) - 필수 - form.append('request', new Blob([JSON.stringify(body)], { type: 'application/json' })); + try { + const { data } = await apiClient.patch(`/feeds/${feedId}`, body, { + headers: { 'Content-Type': 'application/json' }, + }); - // 수정 모드에서는 새 이미지 추가 불가 + console.log('수정 API 응답:', data); + return data; + } catch (error) { + console.error('수정 API 에러:', error); - const { data } = await apiClient.patch(`/feeds/${feedId}`, form, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); + // FormData로 재시도 + console.log('FormData로 재시도...'); + const form = new FormData(); + form.append('request', new Blob([JSON.stringify(body)], { type: 'application/json' })); - return data; + const { data } = await apiClient.patch(`/feeds/${feedId}`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + return data; + } }; /* diff --git a/src/pages/post/UpdatePost.tsx b/src/pages/post/UpdatePost.tsx index 1d5f4c95..1e1a2792 100644 --- a/src/pages/post/UpdatePost.tsx +++ b/src/pages/post/UpdatePost.tsx @@ -111,11 +111,15 @@ const UpdatePost = () => { ...(remainImageUrls.length ? { remainImageUrls } : {}), }; - // 수정 모드에서는 새 이미지 추가 불가 + // API 요청 전 데이터 확인 + console.log('수정 요청 데이터:', { + feedId: Number(feedId), + body: body, + }); + const result = await updateExistingFeed(Number(feedId), body); if (!result?.success) { - // 에러는 useUpdateFeed에서 이미 처리됨 return; } }; From f1c5f03cd49b0da4df6ee6d438ccf14094dd1fbc Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Wed, 13 Aug 2025 15:07:10 +0900 Subject: [PATCH 12/15] =?UTF-8?q?fix:=20console.log=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/feeds/updateFeed.ts | 13 ++--------- src/components/createpost/PhotoSection.tsx | 13 ++++------- src/pages/post/UpdatePost.tsx | 27 ++++++---------------- 3 files changed, 14 insertions(+), 39 deletions(-) diff --git a/src/api/feeds/updateFeed.ts b/src/api/feeds/updateFeed.ts index a4d6265d..a6426b28 100644 --- a/src/api/feeds/updateFeed.ts +++ b/src/api/feeds/updateFeed.ts @@ -4,8 +4,8 @@ import { apiClient } from '../index'; export interface UpdateFeedBody { contentBody: string; isPublic: boolean; - tagList?: string[]; // 선택 필드 - remainImageUrls?: string[]; // 기존 이미지 중 유지할 URL들 + tagList?: string[]; + remainImageUrls?: string[]; } /** 성공 응답 */ @@ -34,24 +34,15 @@ export const updateFeed = async ( feedId: number, body: UpdateFeedBody, ): Promise => { - // FormData 대신 JSON으로 시도 - console.log('수정 API 요청 (JSON):', { - url: `/feeds/${feedId}`, - body: body, - }); - try { const { data } = await apiClient.patch(`/feeds/${feedId}`, body, { headers: { 'Content-Type': 'application/json' }, }); - console.log('수정 API 응답:', data); return data; } catch (error) { console.error('수정 API 에러:', error); - // FormData로 재시도 - console.log('FormData로 재시도...'); const form = new FormData(); form.append('request', new Blob([JSON.stringify(body)], { type: 'application/json' })); diff --git a/src/components/createpost/PhotoSection.tsx b/src/components/createpost/PhotoSection.tsx index f61899b6..d95911e1 100644 --- a/src/components/createpost/PhotoSection.tsx +++ b/src/components/createpost/PhotoSection.tsx @@ -13,13 +13,13 @@ import plusDisabledIcon from '../../assets/post/plus-disabled.svg'; import closeIcon from '../../assets/post/close.svg'; interface PhotoSectionProps { - photos: File[]; // 새로 추가할 이미지들 + photos: File[]; onPhotoAdd: (files: File[]) => void; onPhotoRemove: (index: number) => void; - existingImageUrls?: string[]; // 기존 이미지 URL들 (수정 시에만 사용) - onExistingImageRemove?: (imageUrl: string) => void; // 기존 이미지 제거 함수 (수정 시에만 사용) - readOnly?: boolean; // 읽기 전용 모드 - isEditMode?: boolean; // 수정 모드 (사진 추가 버튼 숨김) + existingImageUrls?: string[]; + onExistingImageRemove?: (imageUrl: string) => void; + readOnly?: boolean; + isEditMode?: boolean; } const PhotoSection = ({ @@ -61,14 +61,12 @@ const PhotoSection = ({ 사진 추가 - {/* 새 이미지 추가 버튼 - 수정 모드에서는 숨김 */} {!readOnly && !isEditMode && ( 사진 추가 )} - {/* 기존 이미지들 (수정 모드에서만 표시) */} {existingImageUrls.map((imageUrl, index) => (
))} - {/* 새로 추가된 이미지들 */} {photos.map((photo, index) => (
{ const { feedId } = useParams<{ feedId: string }>(); const [selectedBook, setSelectedBook] = useState(null); const [postContent, setPostContent] = useState(''); - const [selectedPhotos] = useState([]); // 수정 모드에서는 사용하지 않음 - const [remainImageUrls, setRemainImageUrls] = useState([]); // 유지할 기존 이미지들 + const [selectedPhotos] = useState([]); + const [remainImageUrls, setRemainImageUrls] = useState([]); const [isPrivate, setIsPrivate] = useState(false); const [selectedTags, setSelectedTags] = useState([]); const [loading, setLoading] = useState(true); @@ -86,7 +86,7 @@ const UpdatePost = () => { }; loadFeedDetail(); - }, [feedId]); // 의존성 배열에서 함수들 제거 + }, [feedId]); const handleBackClick = () => { navigate(-1); @@ -111,12 +111,6 @@ const UpdatePost = () => { ...(remainImageUrls.length ? { remainImageUrls } : {}), }; - // API 요청 전 데이터 확인 - console.log('수정 요청 데이터:', { - feedId: Number(feedId), - body: body, - }); - const result = await updateExistingFeed(Number(feedId), body); if (!result?.success) { @@ -124,19 +118,14 @@ const UpdatePost = () => { } }; - // 새로 추가할 이미지 핸들러 (수정 모드에서는 사용하지 않음) const handlePhotoAdd = () => { - // 수정 모드에서는 새 이미지 추가 불가 return; }; - // 새로 추가한 이미지 제거 (수정 모드에서는 사용하지 않음) const handlePhotoRemove = () => { - // 수정 모드에서는 새 이미지 추가 불가 return; }; - // 기존 이미지 제거 (remainImageUrls에서 제외) const handleExistingImageRemove = (imageUrl: string) => { setRemainImageUrls(prev => prev.filter(url => url !== imageUrl)); }; @@ -169,12 +158,11 @@ const UpdatePost = () => { isNextActive={isFormValid && !updateLoading} /> - {/* 책 정보는 수정할 수 없음 (읽기 전용으로 표시) */} {}} // 비활성화 - onChangeClick={() => {}} // 비활성화 - readOnly={true} // 읽기 전용 모드 + onSearchClick={() => {}} + onChangeClick={() => {}} + readOnly={true} />
@@ -183,14 +171,13 @@ const UpdatePost = () => {
- {/* 기존 이미지 삭제만 가능, 추가는 불가 */}
From 54e40454a78c092eead02cf2d2b97ae2694c7738 Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Wed, 13 Aug 2025 15:51:07 +0900 Subject: [PATCH 13/15] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20isFinalized=20response=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/recentsearch/getRecentSearch.ts | 33 +++++++++++++++++++++++++ src/api/users/getUsers.ts | 5 ++++ src/components/feed/UserProfileItem.tsx | 2 +- src/hooks/useUserSearch.ts | 11 +++++++-- src/pages/feed/UserSearch.tsx | 1 + 5 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 src/api/recentsearch/getRecentSearch.ts diff --git a/src/api/recentsearch/getRecentSearch.ts b/src/api/recentsearch/getRecentSearch.ts new file mode 100644 index 00000000..3f156880 --- /dev/null +++ b/src/api/recentsearch/getRecentSearch.ts @@ -0,0 +1,33 @@ +import { apiClient } from '../index'; + +// 최근 검색어 유형 +export type SearchType = 'USER' | 'ROOM' | 'BOOK'; + +// 최근 검색어 데이터 타입 +export interface RecentSearchData { + recentSearchId: number; + searchTerm: string; +} + +// API 응답 타입 +export interface GetRecentSearchResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + recentSearchList: RecentSearchData[]; + }; +} + +// 최근 검색어 조회 API 함수 +export const getRecentSearch = async (type: SearchType) => { + const response = await apiClient.get(`/recent-search?type=${type}`); + return response.data; +}; + +/* +// 사용 예시 +const recentUserSearches = await getRecentSearch('USER'); +const recentRoomSearches = await getRecentSearch('ROOM'); +const recentBookSearches = await getRecentSearch('BOOK'); +*/ diff --git a/src/api/users/getUsers.ts b/src/api/users/getUsers.ts index 76324127..27419034 100644 --- a/src/api/users/getUsers.ts +++ b/src/api/users/getUsers.ts @@ -21,6 +21,7 @@ export interface GetUsersResponse { export interface GetUsersParams { keyword?: string; size?: number; + isFinalized?: boolean; } export const getUsers = async (params?: GetUsersParams) => { @@ -34,6 +35,10 @@ export const getUsers = async (params?: GetUsersParams) => { searchParams.append('size', params.size.toString()); } + if (params?.isFinalized !== undefined) { + searchParams.append('isFinalized', params.isFinalized.toString()); + } + const queryString = searchParams.toString(); const url = queryString ? `/users?${queryString}` : '/users'; diff --git a/src/components/feed/UserProfileItem.tsx b/src/components/feed/UserProfileItem.tsx index a7ea0c83..d4c16639 100644 --- a/src/components/feed/UserProfileItem.tsx +++ b/src/components/feed/UserProfileItem.tsx @@ -56,7 +56,7 @@ const UserProfileItem = ({ )} {type === 'followerlist' && (
-
{followerCount}명이 띱하는 중
+
{followerCount ?? 0}명이 띱하는 중
)} diff --git a/src/hooks/useUserSearch.ts b/src/hooks/useUserSearch.ts index be7e6c5d..0b8f20e6 100644 --- a/src/hooks/useUserSearch.ts +++ b/src/hooks/useUserSearch.ts @@ -6,9 +6,15 @@ interface UseUserSearchProps { keyword: string; size?: number; delay?: number; + isFinalized?: boolean; } -export const useUserSearch = ({ keyword, size = 10, delay = 300 }: UseUserSearchProps) => { +export const useUserSearch = ({ + keyword, + size = 10, + delay = 300, + isFinalized = false, +}: UseUserSearchProps) => { const [userList, setUserList] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -31,6 +37,7 @@ export const useUserSearch = ({ keyword, size = 10, delay = 300 }: UseUserSearch const response = await getUsers({ keyword: searchKeyword, size, + isFinalized, }); const newUserList = response.data.userList; @@ -49,7 +56,7 @@ export const useUserSearch = ({ keyword, size = 10, delay = 300 }: UseUserSearch setLoading(false); } }, - [size], + [size, isFinalized], ); // 디바운스된 키워드가 변경될 때 검색 실행 diff --git a/src/pages/feed/UserSearch.tsx b/src/pages/feed/UserSearch.tsx index 5cbb4a68..ded4c001 100644 --- a/src/pages/feed/UserSearch.tsx +++ b/src/pages/feed/UserSearch.tsx @@ -20,6 +20,7 @@ const UserSearch = () => { keyword: searchTerm, size: 20, delay: 300, + isFinalized: isSearched, }); const [recentSearches, setRecentSearches] = useState([ From 654d9c44fea2756eaf0cc1debca65988c57c81ea Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:15:51 +0900 Subject: [PATCH 14/15] =?UTF-8?q?feat:=20=EB=82=B4=20=EB=9D=B1=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/users/getRecentFollowing.ts | 26 ++++++++++++ src/components/feed/FollowList.tsx | 66 +++++++++++++++++------------ 2 files changed, 66 insertions(+), 26 deletions(-) create mode 100644 src/api/users/getRecentFollowing.ts diff --git a/src/api/users/getRecentFollowing.ts b/src/api/users/getRecentFollowing.ts new file mode 100644 index 00000000..db0773bd --- /dev/null +++ b/src/api/users/getRecentFollowing.ts @@ -0,0 +1,26 @@ +import { apiClient } from '../index'; + +// 최근 글 작성자 데이터 타입 +export interface RecentWriterData { + userId: number; + nickname: string; + profileImageUrl: string; +} + +// API 응답 타입 +export interface GetRecentFollowingResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + recentWriters: RecentWriterData[]; + }; +} + +// 최근 글을 작성한 내 팔로우 리스트 조회 API 함수 +export const getRecentFollowing = async () => { + const response = await apiClient.get( + '/users/my-followings/recent-feeds', + ); + return response.data; +}; diff --git a/src/components/feed/FollowList.tsx b/src/components/feed/FollowList.tsx index 45f42c58..d259d57e 100644 --- a/src/components/feed/FollowList.tsx +++ b/src/components/feed/FollowList.tsx @@ -1,35 +1,47 @@ import styled from '@emotion/styled'; import { useNavigate } from 'react-router-dom'; +import { useState, useEffect } from 'react'; import rightArrow from '../../assets/feed/rightArrow.svg'; import people from '../../assets/feed/people.svg'; import character from '../../assets/feed/character.svg'; import { typography } from '@/styles/global/global'; - -const followerData = { - followers: [ - { userId: 1, src: 'https://placehold.co/36x36', username: 'user1' }, - { userId: 2, src: 'https://placehold.co/36x36', username: 'user2' }, - { userId: 3, src: 'https://placehold.co/36x36', username: 'user3' }, - { userId: 4, src: 'https://placehold.co/36x36', username: 'user4' }, - { userId: 5, src: 'https://placehold.co/36x36', username: 'user5' }, - { userId: 6, src: 'https://placehold.co/36x36', username: 'user6' }, - { userId: 7, src: 'https://placehold.co/36x36', username: 'user7' }, - { userId: 8, src: 'https://placehold.co/36x36', username: 'user8' }, - { userId: 9, src: 'https://placehold.co/36x36', username: 'user9' }, - { userId: 10, src: 'https://placehold.co/36x36', username: 'user10' }, - { userId: 11, src: 'https://placehold.co/36x36', username: 'user11' }, - { userId: 12, src: 'https://placehold.co/36x36', username: 'user12' }, - ], -}; +import { getRecentFollowing, type RecentWriterData } from '@/api/users/getRecentFollowing'; const FollowList = () => { const navigate = useNavigate(); - const { followers } = followerData; - const hasFollowers = followers.length > 0; - const visible = hasFollowers ? followers.slice(0, 10) : []; + const [recentWriters, setRecentWriters] = useState([]); + const [loading, setLoading] = useState(false); + + // API에서 최근 글 작성한 팔로우 리스트 조회 + const fetchRecentFollowing = async () => { + try { + setLoading(true); + const response = await getRecentFollowing(); + + if (response.isSuccess) { + setRecentWriters(response.data.recentWriters); + } else { + console.error('최근 팔로우 작성자 조회 실패:', response.message); + setRecentWriters([]); + } + } catch (error) { + console.error('최근 팔로우 작성자 조회 중 오류:', error); + setRecentWriters([]); + } finally { + setLoading(false); + } + }; + + // 컴포넌트 마운트 시 데이터 조회 + useEffect(() => { + fetchRecentFollowing(); + }, []); + + const hasFollowers = recentWriters.length > 0; + const visible = hasFollowers ? recentWriters.slice(0, 10) : []; const handleFindClick = () => { - navigate('/feed/usersearch'); + navigate('/feed/search'); }; const handleMoreClick = () => { @@ -46,13 +58,15 @@ const FollowList = () => {
내 띱
- {hasFollowers ? ( + {loading ? ( + <> + ) : hasFollowers ? (
- {visible.map(({ userId, src, username }) => ( -
handleProfileClick(userId)}> - -
{username}
+ {visible.map(({ userId, profileImageUrl, nickname }) => ( +
handleProfileClick(userId)}> + {nickname} +
{nickname}
))}
From 52cc5dc47240f25bde7ba4116f9b60a0a91d88f3 Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Thu, 14 Aug 2025 01:00:00 +0900 Subject: [PATCH 15/15] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=BF=A0=ED=82=A4=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 5 +- src/api/index.ts | 75 ++------------------- src/hooks/useOAuthToken.ts | 37 ++++++++++ src/hooks/useUserSearch.ts | 1 - src/pages/feed/Feed.tsx | 4 ++ src/pages/signup/SignupGenre.tsx | 101 +++++++--------------------- src/pages/signup/SignupNickname.tsx | 21 +++++- 7 files changed, 92 insertions(+), 152 deletions(-) create mode 100644 src/hooks/useOAuthToken.ts diff --git a/src/App.tsx b/src/App.tsx index bb3db881..2af98459 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,15 @@ import Router from './pages'; import { Global } from '@emotion/react'; -import { CookiesProvider } from 'react-cookie'; import { globalStyles } from './styles/global/global'; import PopupContainer from './components/common/Modal/PopupContainer'; const App = () => { return ( - + <> - + ); }; diff --git a/src/api/index.ts b/src/api/index.ts index 13693080..68563e02 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,87 +1,26 @@ import axios, { type AxiosResponse, type AxiosError } from 'axios'; -// 하드코딩된 액세스 토큰 -const ACCESS_TOKEN = - 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjksImlhdCI6MTc1NDM4MjY1MiwiZXhwIjoxNzU2OTc0NjUyfQ.CCb_F6OGe02_ITYsE-tqc2_PvSkRsxd96t8NWNIa1pI'; - -// 토큰 관리 유틸리티 -export const TokenManager = { - setAccessToken: (token: string) => localStorage.setItem('accessToken', token), - getAccessToken: (): string | null => localStorage.getItem('accessToken'), - // setRefreshToken: (token: string) => localStorage.setItem('refreshToken', token), - // getRefreshToken: (): string | null => localStorage.getItem('refreshToken'), - clearTokens: () => { - localStorage.removeItem('accessToken'); - // localStorage.removeItem('refreshToken'); - }, - hasValidToken: (): boolean => !!localStorage.getItem('accessToken'), -}; - -// API 기본 설정 +// API 기본 URL const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; -// 환경변수 확인용 -console.log('API_BASE_URL:', API_BASE_URL); - -// axios 인스턴스 생성 +// Axios 인스턴스 생성 export const apiClient = axios.create({ baseURL: API_BASE_URL, timeout: 10000, headers: { 'Content-Type': 'application/json', }, + withCredentials: true, // 쿠키 자동 전송 설정 }); -// 요청 인터셉터 -apiClient.interceptors.request.use( - config => { - // 로컬스토리지에서 토큰 먼저 확인 - const token = TokenManager.getAccessToken(); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } else { - // 토큰이 없으면 하드코딩된 토큰 사용 (개발용) - config.headers.Authorization = ACCESS_TOKEN; - } - return config; - }, - error => Promise.reject(error), -); - -// 응답 인터셉터 - 토큰 만료 처리 및 에러 처리 +// 응답 인터셉터 (에러 처리) apiClient.interceptors.response.use( (response: AxiosResponse) => response, (error: AxiosError) => { - const { status } = error.response || {}; - - // 에러 로깅 - console.error('API Error:', status, error.message); - - // 토큰 만료 또는 인증 실패 시 로그인 페이지로 리다이렉트 - if (status === 401) { - // alert('토큰이 만료되었거나 유효하지 않습니다. 로그인 페이지로 이동합니다.'); - - // 현재 페이지가 로그인 페이지가 아닌 경우에만 리다이렉트 - if (window.location.pathname !== '/') { - // alert('로그인이 필요합니다. 로그인 페이지로 이동합니다.'); - window.location.href = '/'; - } - } - - // 권한 없음 (403) 에러 처리 - if (status === 403) { - console.warn('접근 권한이 없습니다.'); - alert('접근 권한이 없습니다.'); + if (error.response?.status === 401) { + // 인증 실패 시 로그인 페이지로 리다이렉트 + // window.location.href = '/'; } - - // 서버 에러 (500번대) 처리 - if (status && status >= 500) { - console.error('서버 오류가 발생했습니다.'); - alert('서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.'); - } - return Promise.reject(error); }, ); - -export default apiClient; diff --git a/src/hooks/useOAuthToken.ts b/src/hooks/useOAuthToken.ts new file mode 100644 index 00000000..3cd202d0 --- /dev/null +++ b/src/hooks/useOAuthToken.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { apiClient } from '@/api/index'; + +export const useOAuthToken = () => { + const [isTokenRequested, setIsTokenRequested] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const loginTokenKey = params.get('loginTokenKey'); + + if (loginTokenKey && !isTokenRequested) { + console.log('=== 🔑 소셜 로그인 토큰 발급 요청 ==='); + console.log('📋 인가코드:', loginTokenKey); + + setIsTokenRequested(true); + + // 서버에 토큰 발급 요청 + apiClient + .post('/oauth-success', { loginTokenKey }, { withCredentials: true }) + .then(response => { + console.log('✅ 토큰 발급 성공:', response.data); + // URL에서 code 파라미터 제거 + const newUrl = window.location.pathname; + window.history.replaceState({}, document.title, newUrl); + }) + .catch(error => { + console.error('❌ 토큰 발급 실패:', error); + // 에러 발생 시 로그인 페이지로 이동 + navigate('/'); + }); + } + }, [isTokenRequested, navigate]); + + return { isTokenRequested }; +}; diff --git a/src/hooks/useUserSearch.ts b/src/hooks/useUserSearch.ts index 0b8f20e6..49fad7be 100644 --- a/src/hooks/useUserSearch.ts +++ b/src/hooks/useUserSearch.ts @@ -33,7 +33,6 @@ export const useUserSearch = ({ try { setLoading(true); setError(null); - const response = await getUsers({ keyword: searchKeyword, size, diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index 08da8f6e..7c8e3be7 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -9,6 +9,7 @@ import writefab from '../../assets/common/writefab.svg'; import { useNavigate, useLocation } from 'react-router-dom'; import { getTotalFeeds } from '@/api/feeds/getTotalFeed'; import { getMyFeeds } from '@/api/feeds/getMyFeed'; +import { useOAuthToken } from '@/hooks/useOAuthToken'; import type { PostData } from '@/types/post'; const tabs = ['피드', '내 피드']; @@ -19,6 +20,9 @@ const Feed = () => { const initialTabFromState = (location.state as { initialTab?: string } | null)?.initialTab; const [activeTab, setActiveTab] = useState(initialTabFromState ?? tabs[0]); + // 소셜 로그인 토큰 발급 처리 + useOAuthToken(); + // 최초 마운트 시에만 history state 제거하여 이후 재방문 시 영향 없도록 처리 useEffect(() => { if (initialTabFromState) { diff --git a/src/pages/signup/SignupGenre.tsx b/src/pages/signup/SignupGenre.tsx index 5d4984c3..2ac65561 100644 --- a/src/pages/signup/SignupGenre.tsx +++ b/src/pages/signup/SignupGenre.tsx @@ -1,11 +1,9 @@ import { useState, useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; -import { useCookies } from 'react-cookie'; import { Container } from './Signup.styled'; import leftarrow from '../../assets/common/leftArrow.svg'; import TitleHeader from '../../components/common/TitleHeader'; import { postSignup } from '@/api/users/postSignup'; -import { apiClient } from '@/api/index'; const SignupGenre = () => { const [genres, setGenres] = useState< @@ -23,73 +21,27 @@ const SignupGenre = () => { } | null>(null); const navigate = useNavigate(); const location = useLocation(); - const [cookies] = useCookies(['Authorization']); // SignupNickname에서 넘어온 nickname 받기 const nickname = location.state?.nickname; - // react-cookie를 사용하여 Authorization 토큰 추출 - const getAuthTokenFromCookie = () => { - console.log('=== react-cookie 디버깅 ==='); - console.log('현재 페이지 URL:', window.location.href); - console.log('현재 도메인:', window.location.hostname); - console.log('react-cookie로 읽은 Authorization:', cookies.Authorization); - - if (cookies.Authorization) { - console.log('react-cookie로 Authorization 토큰 발견:', cookies.Authorization); - return cookies.Authorization; - } - - // 방법 2: 직접 쿠키 이름으로 검색 - const authCookie = document.cookie - .split(';') - .find(cookie => cookie.trim().startsWith('Authorization=')); - - if (authCookie) { - const token = authCookie.split('=')[1]; - console.log('직접 검색으로 Authorization 토큰 발견:', token); - return token; - } - - // 방법 3: 정규식으로 검색 - const cookieMatch = document.cookie.match(/Authorization=([^;]+)/); - if (cookieMatch && cookieMatch[1]) { - console.log('정규식으로 Authorization 토큰 발견:', cookieMatch[1]); - return cookieMatch[1]; - } - - // 방법 4: 모든 쿠키를 순회하며 검색 - const allCookies = document.cookie.split(';'); - for (let i = 0; i < allCookies.length; i++) { - const cookie = allCookies[i].trim(); - if (cookie.startsWith('Authorization=')) { - const token = cookie.substring('Authorization='.length); - console.log('순회 검색으로 Authorization 토큰 발견:', token); - return token; - } - } - - // 방법 5: 쿠키가 비어있는지 확인 - if (!document.cookie || document.cookie.trim() === '') { - console.log('document.cookie가 비어있습니다.'); + // 페이지 로드 시 간단한 확인 + useEffect(() => { + console.log('=== 🔍 SignupGenre 페이지 로드 ==='); + console.log('📍 현재 페이지:', window.location.pathname); + console.log('👤 받은 nickname:', nickname); + + // nickname이 없으면 이전 페이지로 돌아가기 + if (!nickname) { + console.log('❌ nickname이 전달되지 않았습니다.'); + console.log('❌ 이전 페이지로 돌아갑니다.'); + navigate(-1); + return; } - // 방법 6: 쿠키 길이 확인 - console.log('쿠키 총 길이:', document.cookie.length); - console.log('쿠키 원본 문자열:', JSON.stringify(document.cookie)); - - console.log('react-cookie로 Authorization 토큰을 찾을 수 없습니다.'); - return null; - }; - - // 토큰을 헤더에 설정 - const setAuthTokenToHeader = (token: string) => { - // localStorage에 저장 (페이지 새로고침 시에도 유지) - localStorage.setItem('authToken', token); - - // apiClient 기본 헤더에 설정 - apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`; - }; + console.log('✅ nickname이 정상적으로 전달되었습니다.'); + console.log('✅ 쿠키는 브라우저가 자동으로 처리합니다.'); + }, [nickname, navigate]); useEffect(() => { fetch('/genres.json') @@ -105,27 +57,20 @@ const SignupGenre = () => { const handleNextClick = async () => { if (!selectedAlias || !nickname) return; - // 쿠키에서 토큰 추출 - const authToken = getAuthTokenFromCookie(); - if (!authToken) { - console.log('쿠키에서 Authorization 토큰을 찾을 수 없습니다.'); - console.log('토큰이 없어 회원가입을 진행할 수 없습니다.'); - return; // 토큰이 없으면 함수 종료하여 페이지에 머무름 - } - - // 토큰을 헤더에 설정 - setAuthTokenToHeader(authToken); - console.log('Authorization 토큰을 헤더에 설정했습니다.'); + console.log('=== 🚀 다음 버튼 클릭 ==='); + console.log('🎭 선택된 alias:', selectedAlias); + console.log('👤 nickname:', nickname); try { + console.log('🚀 postSignup API 호출 시작...'); + // ✅ 쿠키는 브라우저가 자동으로 전송 const result = await postSignup({ aliasName: selectedAlias.subTitle, nickName: nickname, }); if (result.success) { - console.log('회원가입 성공! 사용자 ID:', result.data.userId); - // 회원가입 완료 페이지로 이동 + console.log('🎉 회원가입 성공! 사용자 ID:', result.data.userId); navigate('/signupdone', { state: { aliasName: selectedAlias.subTitle, @@ -133,10 +78,10 @@ const SignupGenre = () => { }, }); } else { - console.error('회원가입 실패:', result.message); + console.error('❌ 회원가입 실패:', result.message); } } catch (error) { - console.error('회원가입 중 오류 발생:', error); + console.error('💥 회원가입 중 오류 발생:', error); } }; diff --git a/src/pages/signup/SignupNickname.tsx b/src/pages/signup/SignupNickname.tsx index 63cd7dc4..49d59feb 100644 --- a/src/pages/signup/SignupNickname.tsx +++ b/src/pages/signup/SignupNickname.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Container, InputBox, StyledInput, CharCount } from './Signup.styled'; import Header from '../../components/common/TitleHeader'; import { postNickname } from '@/api/users/postNickname'; +import { useOAuthToken } from '@/hooks/useOAuthToken'; const SignupNickname = () => { const [nickname, setNickname] = useState(''); @@ -10,8 +11,18 @@ const SignupNickname = () => { const maxLength = 10; const navigate = useNavigate(); + // 소셜 로그인 토큰 발급 처리 + useOAuthToken(); + const isNextActive = nickname.length >= 2 && nickname.length <= maxLength; + // 페이지 로드 시 간단한 확인 + useEffect(() => { + console.log('=== 🔍 SignupNickname 페이지 로드 ==='); + console.log('📍 현재 페이지:', window.location.pathname); + console.log('✅ 토큰 발급 후 쿠키는 브라우저가 자동으로 처리합니다.'); + }, []); + const handleBackClick = () => { navigate(-1); }; @@ -20,18 +31,24 @@ const SignupNickname = () => { if (!isNextActive) return; setError(''); + console.log('=== 🚀 닉네임 검증 시작 ==='); + console.log('👤 입력된 닉네임:', nickname); + try { + // ✅ 쿠키는 브라우저가 자동으로 전송 const result = await postNickname(nickname); if (result.data.isVerified) { + console.log('✅ 닉네임 검증 성공!'); // 닉네임 검증 성공 - 다음 단계로 진행 navigate('/signup/genre', { state: { nickname } }); } else { + console.log('❌ 닉네임 검증 실패 - 이미 사용중'); // 닉네임 검증 실패 - 우리가 정한 에러 메시지 setError('이미 사용중인 닉네임이에요.'); } } catch (error) { - console.error('닉네임 검증 실패:', error); + console.error('💥 닉네임 검증 중 오류 발생:', error); setError('닉네임 검증 중 오류가 발생했습니다.'); } };