- {visible.map(({ userId, src, username }) => (
-
handleProfileClick(userId)}>
-

-
{username}
+ {visible.map(({ userId, profileImageUrl, nickname }) => (
+
handleProfileClick(userId)}>
+

+
{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;
+};