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/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 b39f6014..75e5d338 100644 --- a/src/api/feeds/getFeedDetail.ts +++ b/src/api/feeds/getFeedDetail.ts @@ -6,24 +6,25 @@ export interface FeedDetailData { creatorId: number; creatorNickname: string; creatorProfileImageUrl: string; - aliasName: string; + alias: string; aliasColor: string; postDate: string; isbn: string; bookTitle: string; bookAuthor: string; contentBody: string; - contentsUrl: string[]; + contentUrls: string[]; likeCount: number; commentCount: number; isSaved: boolean; isLiked: boolean; + isPublic: boolean; tagList: string[]; } // API 응답 타입 export interface FeedDetailResponse { - success: boolean; + isSuccess: boolean; code: number; message: string; data: FeedDetailData; @@ -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/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/feeds/updateFeed.ts b/src/api/feeds/updateFeed.ts new file mode 100644 index 00000000..a6426b28 --- /dev/null +++ b/src/api/feeds/updateFeed.ts @@ -0,0 +1,88 @@ +import { apiClient } from '../index'; + +/** 피드 수정 요청 바디 타입 */ +export interface UpdateFeedBody { + contentBody: string; + isPublic: boolean; + tagList?: string[]; + remainImageUrls?: string[]; +} + +/** 성공 응답 */ +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 => { + try { + const { data } = await apiClient.patch(`/feeds/${feedId}`, body, { + headers: { 'Content-Type': 'application/json' }, + }); + + return data; + } catch (error) { + console.error('수정 API 에러:', error); + + const form = new FormData(); + 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/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/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/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/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/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/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/creategroup/BookSelectionSection.tsx b/src/components/creategroup/BookSelectionSection.tsx index 73a2e7c5..2c345ef8 100644 --- a/src/components/creategroup/BookSelectionSection.tsx +++ b/src/components/creategroup/BookSelectionSection.tsx @@ -16,39 +16,61 @@ 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 ? ( <> - {selectedBook.title} + {selectedBook.cover && selectedBook.cover.trim() !== '' ? ( + {selectedBook.title} + ) : ( +
+ 책표지 +
+ )}
{selectedBook.title} {selectedBook.author} 저
- 변경 + {!readOnly && 변경} ) : ( <> 검색 - 검색해서 찾기 + + {readOnly ? '책 정보' : '검색해서 찾기'} + )}
diff --git a/src/components/createpost/PhotoSection.tsx b/src/components/createpost/PhotoSection.tsx index 5a3cd9dc..d95911e1 100644 --- a/src/components/createpost/PhotoSection.tsx +++ b/src/components/createpost/PhotoSection.tsx @@ -16,16 +16,31 @@ interface PhotoSectionProps { photos: File[]; onPhotoAdd: (files: File[]) => void; onPhotoRemove: (index: number) => void; + existingImageUrls?: string[]; + 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,61 @@ 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/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; 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 && ( { 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}
))}
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/data/postData.ts b/src/data/postData.ts index 92542610..e65d867a 100644 --- a/src/data/postData.ts +++ b/src/data/postData.ts @@ -14,7 +14,7 @@ export const mockPosts: PostData[] = [ 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, @@ -33,7 +33,7 @@ export const mockPosts: PostData[] = [ bookAuthor: '작가입니다', contentBody: '내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다', - contentsUrl: [], + contentUrls: [], likeCount: 125, commentCount: 125, isSaved: true, @@ -52,7 +52,7 @@ export const mockPosts: PostData[] = [ bookAuthor: '작가입니다', contentBody: '내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다내용입니다', - contentsUrl: [], + contentUrls: [], likeCount: 125, commentCount: 125, isSaved: false, @@ -74,7 +74,7 @@ export const mockFeedPost: FeedPostProps = { bookAuthor: '한강', contentBody: '정말 인상 깊게 읽은 책이에요.정말 인상 깊게 읽은 책이에요.정말 인상 깊게 읽은 책이에요.정말 인상 깊게 읽은 책이에요.정말 인상 깊게 읽은 책이에요.정말 인상 깊게 읽은 책이에요.정말 인상 깊게 읽은 책이에요.정말 인상 깊게 읽은 책이에요.정말 인상 깊게 읽은 책이에요.정말 인상 깊게 읽은 책이에요.정말 인상 깊게 읽은 책이에요.정말 인상 깊게 읽은 책이에요.정말 인상 깊게 읽은 책이에요.', - contentsUrl: [test2, 'https://placehold.co/300x300', test], + contentUrls: [test2, 'https://placehold.co/300x300', test], likeCount: 15, commentCount: 2, isSaved: true, diff --git a/src/hooks/useCreateFeed.ts b/src/hooks/useCreateFeed.ts new file mode 100644 index 00000000..bff4cd79 --- /dev/null +++ b/src/hooks/useCreateFeed.ts @@ -0,0 +1,109 @@ +import { useState } from 'react'; +import { createFeed, type CreateFeedBody, type CreateFeedResponse } 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 (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/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/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/hooks/useUserSearch.ts b/src/hooks/useUserSearch.ts index be7e6c5d..49fad7be 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); @@ -27,10 +33,10 @@ export const useUserSearch = ({ keyword, size = 10, delay = 300 }: UseUserSearch try { setLoading(true); setError(null); - const response = await getUsers({ keyword: searchKeyword, size, + isFinalized, }); const newUserList = response.data.userList; @@ -49,7 +55,7 @@ export const useUserSearch = ({ keyword, size = 10, delay = 300 }: UseUserSearch setLoading(false); } }, - [size], + [size, isFinalized], ); // 디바운스된 키워드가 변경될 때 검색 실행 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/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/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/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([ 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/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} /> { + 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, + title: data.bookTitle, + author: data.bookAuthor, + cover: '', + isbn: data.isbn, + }); + + setPostContent(data.contentBody); + setIsPrivate(!data.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]); + + 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) { + return; + } + }; + + const handlePhotoAdd = () => { + return; + }; + + const handlePhotoRemove = () => { + return; + }; + + 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; 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('닉네임 검증 중 오류가 발생했습니다.'); } }; diff --git a/src/types/post.ts b/src/types/post.ts index 00c70bff..44d23e38 100644 --- a/src/types/post.ts +++ b/src/types/post.ts @@ -9,7 +9,7 @@ export interface PostData { bookTitle: string; bookAuthor: string; contentBody: string; - contentsUrl: string[]; + contentUrls: string[]; likeCount: number; commentCount: number; isSaved?: boolean; @@ -34,7 +34,7 @@ export interface FeedPostProps extends PostData { export type PostBodyProps = Pick< PostData, - 'bookTitle' | 'bookAuthor' | 'contentBody' | 'feedId' | 'contentsUrl' | 'isbn' + 'bookTitle' | 'bookAuthor' | 'contentBody' | 'feedId' | 'contentUrls' | 'isbn' >; // 대댓글(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; +};