From fde2ba1f9c4b6308d7956fa8d2edbccebe12ad9f Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:36:07 +0900 Subject: [PATCH 01/86] feat: searchBooks api --- src/api/books/searchBooks.ts | 68 ++++++++++++++++++++++++++++++++++++ src/api/index.ts | 2 +- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/api/books/searchBooks.ts diff --git a/src/api/books/searchBooks.ts b/src/api/books/searchBooks.ts new file mode 100644 index 00000000..0c14f92d --- /dev/null +++ b/src/api/books/searchBooks.ts @@ -0,0 +1,68 @@ +import { apiClient } from '../index'; + +// 검색된 책 타입 (API 응답에서 받는 형태) +export interface BookSearchItem { + title: string; + imageUrl: string; + authorName: string; + publisher: string; + isbn: string; +} + +// 검색된 책 타입 (컴포넌트에서 사용하는 형태) +export interface SearchedBook { + id: number; + title: string; + author: string; + publisher: string; + coverUrl: string; + isbn: string; +} + +// API 응답 데이터 타입 +export interface SearchBooksData { + searchResult: BookSearchItem[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + last: boolean; + first: boolean; +} + +// API 응답 타입 +export interface SearchBooksResponse { + isSuccess: boolean; + code: number; + message: string; + data: SearchBooksData; +} + +export const searchBooks = async ( + query: string, + page: number = 1, +): Promise => { + try { + const response = await apiClient.get('/books', { + params: { + keyword: query.trim(), + page: page, + }, + }); + return response.data; + } catch (error) { + console.error('책 검색 API 오류:', error); + throw error; + } +}; + +export const convertToSearchedBooks = (apiBooks: BookSearchItem[]): SearchedBook[] => { + return apiBooks.map((book, index) => ({ + id: index + 1, + title: book.title, + author: book.authorName, + publisher: book.publisher, + coverUrl: book.imageUrl, + isbn: book.isbn, + })); +}; diff --git a/src/api/index.ts b/src/api/index.ts index a2bc9589..c6f61522 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,7 +2,7 @@ import axios, { type AxiosResponse, type AxiosError } from 'axios'; // 하드코딩된 액세스 토큰 const ACCESS_TOKEN = - 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc1NDIwMTY4OCwiZXhwIjoxNzU2NzkzNjg4fQ.oOyJ7JI_t2-Xq1-gfAv4ZaYNrbyplvqdxhCk76-Txe4'; + 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjgsImlhdCI6MTc1NDM4MjY1MiwiZXhwIjoxNzU2OTc0NjUyfQ.giRdeg9HWsdhLxg9JZhE0LaMg0hv7ReP0UBMsEUsNxs'; // 토큰 관리 유틸리티 export const TokenManager = { From bc6924471d0176088d7cf6f02a6f1aea71313c8b Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:36:59 +0900 Subject: [PATCH 02/86] =?UTF-8?q?feat:=20searchBooks=20api=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/search/Search.tsx | 167 ++++++++++++++++++++++++++---------- 1 file changed, 120 insertions(+), 47 deletions(-) diff --git a/src/pages/search/Search.tsx b/src/pages/search/Search.tsx index b5f4007a..d6581b2c 100644 --- a/src/pages/search/Search.tsx +++ b/src/pages/search/Search.tsx @@ -6,8 +6,10 @@ import RecentSearchTabs from '@/components/search/RecentSearchTabs'; import SearchBar from '@/components/search/SearchBar'; import { colors, typography } from '@/styles/global/global'; import styled from '@emotion/styled'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; +import { searchBooks, convertToSearchedBooks } from '@/api/books/searchBooks'; export interface SearchedBook { id: number; @@ -17,57 +19,107 @@ export interface SearchedBook { coverUrl: string; } -const dummySearchedBook: SearchedBook[] = [ - { - id: 1, - title: '채식주의자', - author: '한강', - publisher: '창비', - coverUrl: 'https://image.yes24.com/goods/17122707/XL', - }, - { - id: 2, - title: '채소 마스터 클래스', - author: '백지혜', - publisher: '세미콜론', - coverUrl: 'https://image.yes24.com/goods/109378551/XL', - }, - { - id: 3, - title: '채소 식탁', - author: '김경민', - publisher: '래디시', - coverUrl: 'https://image.yes24.com/goods/117194041/XL', - }, -]; const Search = () => { + const [searchParams, setSearchParams] = useSearchParams(); const [searchTerm, setSearchTerm] = useState(''); const [isSearching, setIsSearching] = useState(false); const [isSearched, setIsSearched] = useState(false); + const [searchResults, setSearchResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); - const [recentSearches, setRecentSearches] = useState([ - '딸기12', - '당근', - '수박245', - '참', - '메론1', - ]); + const [recentSearches, setRecentSearches] = useState([]); + const [searchTimeoutId, setSearchTimeoutId] = useState(null); const handleChange = (value: string) => { setSearchTerm(value); setIsSearched(false); setIsSearching(value.trim() !== ''); + + if (value.trim()) { + setSearchParams({ q: value.trim() }, { replace: true }); + + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + } + + const timeoutId = setTimeout(() => { + handleSearch(value.trim(), false); + }, 300); + + setSearchTimeoutId(timeoutId); + } else { + setSearchParams({}, { replace: true }); + + setSearchResults([]); + setIsSearched(false); + + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + } }; - const handleSearch = (term: string) => { + const handleSearch = useCallback(async (term: string, isManualSearch: boolean = false) => { if (!term.trim()) return; + setIsSearching(true); - setIsSearched(true); + + if (isManualSearch) { + setIsSearched(false); + } + + setIsLoading(true); + + try { + const response = await searchBooks(term, 1); + console.log('API 응답:', response); + + if (response.isSuccess) { + const convertedResults = convertToSearchedBooks(response.data.searchResult); + console.log('변환된 결과:', convertedResults); + setSearchResults(convertedResults); + } else { + console.log('검색 실패:', response.message); + setSearchResults([]); + } + + if (isManualSearch) { + setIsSearched(true); + } + } catch (error) { + console.error('검색 중 오류 발생:', error); + setSearchResults([]); + if (isManualSearch) { + setIsSearched(true); + } + } finally { + setIsLoading(false); + } + setRecentSearches(prev => { const filtered = prev.filter(t => t !== term); return [term, ...filtered].slice(0, 5); }); - }; + }, []); + + useEffect(() => { + const query = searchParams.get('q') || ''; + if (query && !isInitialized) { + setSearchTerm(query); + setIsSearching(true); + handleSearch(query, true); + setIsInitialized(true); + } + }, [searchParams, handleSearch, isInitialized]); + + useEffect(() => { + if (searchTerm.trim() === '') { + setIsSearching(false); + setIsSearched(false); + } + }, [searchTerm]); const handleDelete = (recentSearch: string) => { setRecentSearches(prev => prev.filter(t => t !== recentSearch)); @@ -75,14 +127,31 @@ const Search = () => { const handleRecentSearchClick = (recentSearch: string) => { setSearchTerm(recentSearch); - setIsSearched(true); - setIsSearching(true); + handleSearch(recentSearch, true); }; const handleBackButton = () => { + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + setSearchTerm(''); + setSearchResults([]); + setIsSearching(false); + setIsSearched(false); + setIsInitialized(false); + setSearchParams({}, { replace: true }); }; + useEffect(() => { + return () => { + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + } + }; + }, [searchTimeoutId]); + useEffect(() => { if (searchTerm.trim() === '') { setIsSearching(false); @@ -106,26 +175,21 @@ const Search = () => { placeholder="책 제목, 작가명을 검색해보세요." value={searchTerm} onChange={handleChange} - onSearch={() => handleSearch(searchTerm.trim())} + onSearch={() => handleSearch(searchTerm.trim(), true)} isSearched={isSearched} /> {isSearching ? ( <> - ( - {isSearched ? ( - + {isLoading ? ( + 검색 중... ) : ( + type={isSearched ? 'searched' : 'searching'} + searchedBookList={searchResults} + /> )} - ) ) : ( <> @@ -186,3 +250,12 @@ const SearchBarContainer = styled.div` const Content = styled.div` margin-top: 132px; `; + +const LoadingMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: ${colors.white}; + font-size: ${typography.fontSize.base}; +`; From 4ca579f146879992250bd41e112a12d6b00210de Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 15:04:12 +0900 Subject: [PATCH 03/86] feat: getBookDetail api --- src/api/books/getBookDetail.ts | 33 +++++++ src/components/search/BookSearchResult.tsx | 3 +- src/pages/index.tsx | 2 +- src/pages/search/Search.tsx | 1 + src/pages/searchBook/SearchBook.tsx | 104 ++++++++++++--------- 5 files changed, 96 insertions(+), 47 deletions(-) create mode 100644 src/api/books/getBookDetail.ts diff --git a/src/api/books/getBookDetail.ts b/src/api/books/getBookDetail.ts new file mode 100644 index 00000000..44b6d86b --- /dev/null +++ b/src/api/books/getBookDetail.ts @@ -0,0 +1,33 @@ +import { apiClient } from '../index'; + +// 책 상세 정보 타입 +export interface BookDetail { + title: string; + imageUrl: string; + authorName: string; + publisher: string; + isbn: string; + description: string; + recruitingRoomCount: number; + readCount: number; + isSaved: boolean; +} + +// API 응답 타입 +export interface BookDetailResponse { + isSuccess: boolean; + code: number; + message: string; + data: BookDetail; +} + +export const getBookDetail = async (isbn: string): Promise => { + try { + const response = await apiClient.get(`/books/${isbn}`); + + return response.data; + } catch (error) { + console.error('책 상세 정보 API 오류:', error); + throw error; + } +}; diff --git a/src/components/search/BookSearchResult.tsx b/src/components/search/BookSearchResult.tsx index 39b26624..a061e8ef 100644 --- a/src/components/search/BookSearchResult.tsx +++ b/src/components/search/BookSearchResult.tsx @@ -31,7 +31,7 @@ export function BookSearchResult({ type, searchedBookList }: BookSearchResultPro ) : ( searchedBookList.map(book => ( - + navigate(`/search/book/${book.isbn}`)}> {book.title} @@ -63,6 +63,7 @@ const BookItem = styled.div` display: flex; border-bottom: 1px solid ${colors.darkgrey.dark}; padding: 12px 0; + cursor: pointer; `; const ResultHeader = styled.div` diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 065a811c..85513ba0 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -59,7 +59,7 @@ const Router = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/pages/search/Search.tsx b/src/pages/search/Search.tsx index d6581b2c..09e912f1 100644 --- a/src/pages/search/Search.tsx +++ b/src/pages/search/Search.tsx @@ -17,6 +17,7 @@ export interface SearchedBook { author: string; publisher: string; coverUrl: string; + isbn: string; } const Search = () => { diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 9d3b0085..7fd37d6a 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -15,34 +15,55 @@ import { WritePostButton, SaveButton, FeedSection, - FeedTitle, - FilterContainer, - EmptyState, - EmptyTitle, - EmptySubText, } from './SearchBook.styled'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; import moreIcon from '../../assets/common/more.svg'; import { IconButton } from '@/components/common/IconButton'; -import { mockSearchBook } from '@/mocks/searchBook.mock'; import saveIcon from '../../assets/common/SaveIcon.svg'; import rightChevron from '../../assets/common/right-Chevron.svg'; import plusIcon from '../../assets/common/plus.svg'; -import { Filter } from '@/components/common/Filter'; -import { useState } from 'react'; -import FeedPost from '@/components/feed/FeedPost'; +import { useState, useEffect } from 'react'; import { IntroModal } from '@/components/search/IntroModal'; - -const FILTER = ['최신순', '인기순']; +import { getBookDetail, type BookDetail } from '@/api/books/getBookDetail'; const SearchBook = () => { - const { title, author, introduction, coverUrl, recruitGroups, posts } = mockSearchBook; - const [selectedFilter, setSelectedFilter] = useState('인기순'); + const { isbn } = useParams<{ isbn: string }>(); const [showIntroModal, setShowIntroModal] = useState(false); + const [bookDetail, setBookDetail] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); const navigate = useNavigate(); + useEffect(() => { + const fetchBookDetail = async () => { + if (!isbn) { + setError('ISBN이 필요합니다.'); + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + const response = await getBookDetail(isbn); + + if (response.isSuccess) { + setBookDetail(response.data); + } else { + setError(response.message); + } + } catch (error) { + console.error('책 상세 정보 조회 오류:', error); + setError('책 정보를 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + fetchBookDetail(); + }, [isbn]); + const handleBackButton = () => { navigate(-1); }; @@ -61,28 +82,40 @@ const SearchBook = () => { const handleSaveButton = () => {}; - const hasFeeds = mockSearchBook.posts.length > 0; + if (isLoading || error || !bookDetail) { + return ( + +
+ + +
+
+ {isLoading ? '로딩 중...' : error || '책 정보를 찾을 수 없습니다.'} +
+
+ ); + } return ( - +
- {title} - {author} + {bookDetail.title} + {bookDetail.authorName} 소개 - {introduction} + {bookDetail.description} - 모집중인 모임방 {recruitGroups.length}개{' '} + 모집중인 모임방 {bookDetail.recruitingRoomCount}개{' '} 오른쪽 화살표 아이콘 @@ -95,33 +128,14 @@ const SearchBook = () => { - - 피드 글 둘러보기 - - - - - {hasFeeds ? ( - <> - {posts.map(post => ( - - ))} - - ) : ( - - 이 책으로 작성된 피드가 없어요. - 첫 번째 피드를 작성해보세요! - - )} - - + 이 책과 관련된 피드 +
+ 관련 피드가 없습니다. +
+ {' '} {showIntroModal && ( - + )}
); From 3c120b45d60e741888883716aa8a9105f8d6b9f3 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 15:34:32 +0900 Subject: [PATCH 04/86] =?UTF-8?q?refactor:=20api=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD,=20isFinalized=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80=20searchBooks=20->=20getSearchBo?= =?UTF-8?q?oks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{searchBooks.ts => getSearchBooks.ts} | 4 ++- src/pages/search/Search.tsx | 31 +++++++--------- src/pages/searchBook/SearchBook.tsx | 36 ++++++++++++++++--- 3 files changed, 47 insertions(+), 24 deletions(-) rename src/api/books/{searchBooks.ts => getSearchBooks.ts} (93%) diff --git a/src/api/books/searchBooks.ts b/src/api/books/getSearchBooks.ts similarity index 93% rename from src/api/books/searchBooks.ts rename to src/api/books/getSearchBooks.ts index 0c14f92d..6f3add94 100644 --- a/src/api/books/searchBooks.ts +++ b/src/api/books/getSearchBooks.ts @@ -38,15 +38,17 @@ export interface SearchBooksResponse { data: SearchBooksData; } -export const searchBooks = async ( +export const getSearchBooks = async ( query: string, page: number = 1, + isFinalized: boolean = false, ): Promise => { try { const response = await apiClient.get('/books', { params: { keyword: query.trim(), page: page, + isFinalized: isFinalized, }, }); return response.data; diff --git a/src/pages/search/Search.tsx b/src/pages/search/Search.tsx index 09e912f1..ecb1f061 100644 --- a/src/pages/search/Search.tsx +++ b/src/pages/search/Search.tsx @@ -9,7 +9,7 @@ import styled from '@emotion/styled'; import { useEffect, useState, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; -import { searchBooks, convertToSearchedBooks } from '@/api/books/searchBooks'; +import { getSearchBooks, convertToSearchedBooks } from '@/api/books/getSearchBooks'; export interface SearchedBook { id: number; @@ -24,7 +24,7 @@ const Search = () => { const [searchParams, setSearchParams] = useSearchParams(); const [searchTerm, setSearchTerm] = useState(''); const [isSearching, setIsSearching] = useState(false); - const [isSearched, setIsSearched] = useState(false); + const [isFinalized, setIsFinalized] = useState(false); const [searchResults, setSearchResults] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isInitialized, setIsInitialized] = useState(false); @@ -34,7 +34,7 @@ const Search = () => { const handleChange = (value: string) => { setSearchTerm(value); - setIsSearched(false); + setIsFinalized(false); setIsSearching(value.trim() !== ''); if (value.trim()) { @@ -53,7 +53,7 @@ const Search = () => { setSearchParams({}, { replace: true }); setSearchResults([]); - setIsSearched(false); + setIsFinalized(false); if (searchTimeoutId) { clearTimeout(searchTimeoutId); @@ -68,13 +68,13 @@ const Search = () => { setIsSearching(true); if (isManualSearch) { - setIsSearched(false); + setIsFinalized(false); } setIsLoading(true); try { - const response = await searchBooks(term, 1); + const response = await getSearchBooks(term, 1, isManualSearch); console.log('API 응답:', response); if (response.isSuccess) { @@ -87,13 +87,13 @@ const Search = () => { } if (isManualSearch) { - setIsSearched(true); + setIsFinalized(true); } } catch (error) { console.error('검색 중 오류 발생:', error); setSearchResults([]); if (isManualSearch) { - setIsSearched(true); + setIsFinalized(true); } } finally { setIsLoading(false); @@ -118,7 +118,7 @@ const Search = () => { useEffect(() => { if (searchTerm.trim() === '') { setIsSearching(false); - setIsSearched(false); + setIsFinalized(false); } }, [searchTerm]); @@ -140,7 +140,7 @@ const Search = () => { setSearchTerm(''); setSearchResults([]); setIsSearching(false); - setIsSearched(false); + setIsFinalized(false); setIsInitialized(false); setSearchParams({}, { replace: true }); }; @@ -153,13 +153,6 @@ const Search = () => { }; }, [searchTimeoutId]); - useEffect(() => { - if (searchTerm.trim() === '') { - setIsSearching(false); - setIsSearched(false); - } - }, [searchTerm]); - return ( {isSearching ? ( @@ -177,7 +170,7 @@ const Search = () => { value={searchTerm} onChange={handleChange} onSearch={() => handleSearch(searchTerm.trim(), true)} - isSearched={isSearched} + isSearched={isFinalized} /> @@ -187,7 +180,7 @@ const Search = () => { 검색 중... ) : ( )} diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 7fd37d6a..4999b91b 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -15,6 +15,11 @@ import { WritePostButton, SaveButton, FeedSection, + FeedTitle, + FilterContainer, + EmptyState, + EmptyTitle, + EmptySubText, } from './SearchBook.styled'; import { useNavigate, useParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; @@ -26,9 +31,15 @@ import plusIcon from '../../assets/common/plus.svg'; import { useState, useEffect } from 'react'; import { IntroModal } from '@/components/search/IntroModal'; import { getBookDetail, type BookDetail } from '@/api/books/getBookDetail'; +import { Filter } from '@/components/common/Filter'; +import FeedPost from '@/components/feed/FeedPost'; +import { mockSearchBook } from '@/mocks/searchBook.mock'; + +const FILTER = ['최신순', '인기순']; const SearchBook = () => { const { isbn } = useParams<{ isbn: string }>(); + const [selectedFilter, setSelectedFilter] = useState('인기순'); const [showIntroModal, setShowIntroModal] = useState(false); const [bookDetail, setBookDetail] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -129,10 +140,27 @@ const SearchBook = () => { - 이 책과 관련된 피드 -
- 관련 피드가 없습니다. -
+ 피드 글 둘러보기 + + + + + {mockSearchBook.posts.length > 0 ? ( + <> + {mockSearchBook.posts.map((post, index) => ( + + ))} + + ) : ( + + 이 책으로 작성된 피드가 없어요. + 첫 번째 피드를 작성해보세요! + + )}
{' '} {showIntroModal && ( From ccfd27e6e0bad0c1e25387934313a67630582a85 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:20:49 +0900 Subject: [PATCH 05/86] feat: getRecruitingRooms API --- src/api/books/getRecruitingRooms.ts | 40 +++++++++++++++++++++ src/api/index.ts | 2 +- src/pages/searchBook/SearchBook.tsx | 45 +++++++++++++++++++----- src/pages/searchBook/SearchBookGroup.tsx | 38 ++++++++++++++++---- 4 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 src/api/books/getRecruitingRooms.ts diff --git a/src/api/books/getRecruitingRooms.ts b/src/api/books/getRecruitingRooms.ts new file mode 100644 index 00000000..1d09c9da --- /dev/null +++ b/src/api/books/getRecruitingRooms.ts @@ -0,0 +1,40 @@ +import { apiClient } from '../index'; + +// 모집중인 모임방 타입 +export interface RecruitingRoom { + roomId: number; + bookImageUrl: string; + roomName: string; + memberCount: number; + recruitCount: number; + deadlineEndDate: string; +} + +// API 응답 데이터 타입 +export interface RecruitingRoomsData { + recruitingRoomList: RecruitingRoom[]; + totalRoomCount: number; + nextCursor: string; + isLast: boolean; +} + +// API 응답 타입 +export interface RecruitingRoomsResponse { + isSuccess: boolean; + code: number; + message: string; + data: RecruitingRoomsData; +} + +export const getRecruitingRooms = async (isbn: string): Promise => { + try { + const response = await apiClient.get( + `/books/${isbn}/recruiting-rooms`, + ); + console.log(response.data); + return response.data; + } catch (error) { + console.error('모집중인 모임방 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/api/index.ts b/src/api/index.ts index c6f61522..55edf8a1 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,7 +2,7 @@ import axios, { type AxiosResponse, type AxiosError } from 'axios'; // 하드코딩된 액세스 토큰 const ACCESS_TOKEN = - 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjgsImlhdCI6MTc1NDM4MjY1MiwiZXhwIjoxNzU2OTc0NjUyfQ.giRdeg9HWsdhLxg9JZhE0LaMg0hv7ReP0UBMsEUsNxs'; + 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjgsImlhdCI6MTc1NTA2NjE4NywiZXhwIjoxNzU3NjU4MTg3fQ.2bWnH8Gi5thmzZaR0cjijoyEEr2hCgd351h3HtBqm14'; // 토큰 관리 유틸리티 export const TokenManager = { diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 4999b91b..06ebfc6c 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -31,6 +31,7 @@ import plusIcon from '../../assets/common/plus.svg'; import { useState, useEffect } from 'react'; import { IntroModal } from '@/components/search/IntroModal'; import { getBookDetail, type BookDetail } from '@/api/books/getBookDetail'; +import { getRecruitingRooms, type RecruitingRoomsData } from '@/api/books/getRecruitingRooms'; import { Filter } from '@/components/common/Filter'; import FeedPost from '@/components/feed/FeedPost'; import { mockSearchBook } from '@/mocks/searchBook.mock'; @@ -42,6 +43,7 @@ const SearchBook = () => { const [selectedFilter, setSelectedFilter] = useState('인기순'); const [showIntroModal, setShowIntroModal] = useState(false); const [bookDetail, setBookDetail] = useState(null); + const [recruitingRoomsData, setRecruitingRoomsData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -57,16 +59,26 @@ const SearchBook = () => { try { setIsLoading(true); - const response = await getBookDetail(isbn); - if (response.isSuccess) { - setBookDetail(response.data); + const [bookResponse, recruitingResponse] = await Promise.all([ + getBookDetail(isbn), + getRecruitingRooms(isbn), + ]); + + if (bookResponse.isSuccess) { + setBookDetail(bookResponse.data); + } else { + setError(bookResponse.message); + } + + if (recruitingResponse.isSuccess) { + setRecruitingRoomsData(recruitingResponse.data); } else { - setError(response.message); + console.error('모집중인 모임방 조회 실패:', recruitingResponse.message); } } catch (error) { - console.error('책 상세 정보 조회 오류:', error); - setError('책 정보를 불러오는데 실패했습니다.'); + console.error('데이터 조회 오류:', error); + setError('정보를 불러오는데 실패했습니다.'); } finally { setIsLoading(false); } @@ -86,7 +98,24 @@ const SearchBook = () => { const handleMoreButton = () => {}; const handleRecruitingGroupButton = () => { - navigate('./group'); + if (bookDetail) { + navigate('/search/book/group', { + state: { + recruitingRooms: recruitingRoomsData || { + recruitingRoomList: [], + totalRoomCount: 0, + nextCursor: '', + isLast: true, + }, + bookInfo: { + isbn: bookDetail.isbn, + title: bookDetail.title, + author: bookDetail.authorName, + imageUrl: bookDetail.imageUrl, + }, + }, + }); + } }; const handleWritePostButton = () => {}; @@ -126,7 +155,7 @@ const SearchBook = () => { - 모집중인 모임방 {bookDetail.recruitingRoomCount}개{' '} + 모집중인 모임방 {recruitingRoomsData?.totalRoomCount || 0}개{' '} 오른쪽 화살표 아이콘 diff --git a/src/pages/searchBook/SearchBookGroup.tsx b/src/pages/searchBook/SearchBookGroup.tsx index 02fe4941..7db261db 100644 --- a/src/pages/searchBook/SearchBookGroup.tsx +++ b/src/pages/searchBook/SearchBookGroup.tsx @@ -2,19 +2,33 @@ import TitleHeader from '@/components/common/TitleHeader'; import { colors, typography } from '@/styles/global/global'; import styled from '@emotion/styled'; import leftArrow from '../../assets/common/leftArrow.svg'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { GroupCard } from '@/components/group/GroupCard'; -import { mockSearchBookGroup } from '@/mocks/searchBook.mock'; +import { type RecruitingRoomsData } from '@/api/books/getRecruitingRooms'; + +interface LocationState { + recruitingRooms: RecruitingRoomsData; + bookInfo: { + isbn: string; + title: string; + author: string; + imageUrl: string; + }; +} const SearchBookGroup = () => { const navigate = useNavigate(); + const location = useLocation(); + const { recruitingRooms, bookInfo } = (location.state as LocationState) || {}; const handleBackButton = () => { navigate(-1); }; const handleMakeGroup = () => {}; - const hasGroups = mockSearchBookGroup.length > 0; + const groupList = recruitingRooms?.recruitingRoomList || []; + const totalCount = recruitingRooms?.totalRoomCount || 0; + const hasGroups = groupList.length > 0; return ( @@ -23,11 +37,23 @@ const SearchBookGroup = () => { leftIcon={뒤로 가기} onLeftClick={handleBackButton} /> - 전체 {mockSearchBookGroup.length} + 전체 {totalCount} {hasGroups ? ( - {mockSearchBookGroup.map(group => ( - + {groupList.map((room, index) => ( + ))} ) : ( From 24c3bde931de719fef7fc3a926aa663d25ddbae5 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:04:05 +0900 Subject: [PATCH 06/86] =?UTF-8?q?remove:=20=EC=BD=98=EC=86=94=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/books/getRecruitingRooms.ts | 2 +- src/pages/search/Search.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/api/books/getRecruitingRooms.ts b/src/api/books/getRecruitingRooms.ts index 1d09c9da..f83eb397 100644 --- a/src/api/books/getRecruitingRooms.ts +++ b/src/api/books/getRecruitingRooms.ts @@ -31,7 +31,7 @@ export const getRecruitingRooms = async (isbn: string): Promise( `/books/${isbn}/recruiting-rooms`, ); - console.log(response.data); + return response.data; } catch (error) { console.error('모집중인 모임방 조회 API 오류:', error); diff --git a/src/pages/search/Search.tsx b/src/pages/search/Search.tsx index ecb1f061..624c955f 100644 --- a/src/pages/search/Search.tsx +++ b/src/pages/search/Search.tsx @@ -75,11 +75,9 @@ const Search = () => { try { const response = await getSearchBooks(term, 1, isManualSearch); - console.log('API 응답:', response); if (response.isSuccess) { const convertedResults = convertToSearchedBooks(response.data.searchResult); - console.log('변환된 결과:', convertedResults); setSearchResults(convertedResults); } else { console.log('검색 실패:', response.message); @@ -209,7 +207,7 @@ const Wrapper = styled.div` flex-direction: column; min-width: 320px; max-width: 767px; - height: 100vh; + height: 100%; margin: 0 auto; background: ${colors.black.main}; `; From 4b2817e66ba5f5d913a6fc131fcb90475fe6b920 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:04:19 +0900 Subject: [PATCH 07/86] feat: getMostSearchedBooks API --- src/api/books/getMostSearchedBooks.ts | 32 ++++++ src/components/search/MostSearchedBooks.tsx | 113 ++++++++++++-------- 2 files changed, 100 insertions(+), 45 deletions(-) create mode 100644 src/api/books/getMostSearchedBooks.ts diff --git a/src/api/books/getMostSearchedBooks.ts b/src/api/books/getMostSearchedBooks.ts new file mode 100644 index 00000000..fbdbcf3e --- /dev/null +++ b/src/api/books/getMostSearchedBooks.ts @@ -0,0 +1,32 @@ +import { apiClient } from '../index'; + +// 인기 검색 도서 타입 +export interface MostSearchedBook { + rank: number; + title: string; + imageUrl: string; + isbn: string; +} + +// API 응답 데이터 타입 +export interface MostSearchedBooksData { + bookList: MostSearchedBook[]; +} + +// API 응답 타입 +export interface MostSearchedBooksResponse { + isSuccess: boolean; + code: number; + message: string; + data: MostSearchedBooksData; +} + +export const getMostSearchedBooks = async (): Promise => { + try { + const response = await apiClient.get('/books/most-searched'); + return response.data; + } catch (error) { + console.error('인기 검색 도서 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/components/search/MostSearchedBooks.tsx b/src/components/search/MostSearchedBooks.tsx index a485b25c..cacab507 100644 --- a/src/components/search/MostSearchedBooks.tsx +++ b/src/components/search/MostSearchedBooks.tsx @@ -1,64 +1,71 @@ import { colors, typography } from '@/styles/global/global'; import styled from '@emotion/styled'; +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { getMostSearchedBooks, type MostSearchedBook } from '@/api/books/getMostSearchedBooks'; -interface Book { - id: number; - title: string; - coverUrl: string; -} +export default function MostSearchedBooks() { + const [books, setBooks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const navigate = useNavigate(); -const dummyBooks: Book[] = [ - { - id: 1, - title: '토마토 컵라면', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 2, - title: '사슴', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 3, - title: '호르몬 체인지지', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 4, - title: '호르몬 체인지지', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 5, - title: '호르몬 체인지지', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 6, - title: '호르몬 체인지지', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, -]; + useEffect(() => { + const fetchMostSearchedBooks = async () => { + try { + setIsLoading(true); + const response = await getMostSearchedBooks(); -export default function MostSearchedBooks() { + if (response.isSuccess) { + setBooks(response.data.bookList); + } else { + setError(response.message); + } + } catch (error) { + console.error('인기 검색 도서 조회 오류:', error); + setError('인기 검색 도서를 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + fetchMostSearchedBooks(); + }, []); + + const handleBookClick = (isbn: string) => { + navigate(`/search/book/${isbn}`); + }; + + const getCurrentDate = () => { + const now = new Date(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${month}.${day}. 기준`; + }; return (
가장 많이 검색된 책 - {/* 서버 응답 포맷을 모르기에 우선 하드 코딩 */} - 01.12. 기준 + {getCurrentDate()}
- {dummyBooks.length === 0 ? ( + {isLoading ? ( + 로딩 중... + ) : error ? ( + + 데이터를 불러올 수 없어요. + {error} + + ) : books.length === 0 ? ( 아직 순위가 집계되지 않았어요. 조금만 기다려주세요! ) : ( - {dummyBooks.map((book, index) => ( - - {index + 1}. - + {books.map(book => ( + handleBookClick(book.isbn)}> + {book.rank}. + {book.title} ))} @@ -106,6 +113,12 @@ const BookItem = styled.li` align-items: center; padding: 12px 0; border-bottom: 1px solid ${colors.darkgrey.dark}; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: ${colors.darkgrey.main}; + } `; const Rank = styled.span` @@ -151,3 +164,13 @@ const SubText = styled.div` color: ${colors.grey[100]}; font-weight: ${typography.fontWeight.regular}; `; + +const LoadingMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 200px; + font-size: ${typography.fontSize.base}; + color: ${colors.grey[200]}; + font-weight: ${typography.fontWeight.regular}; +`; From 88367093fe081f1e103eba24a49a7c4526131333 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:43:40 +0900 Subject: [PATCH 08/86] feat: saveIcon --- src/assets/common/SaveIcon.svg | 5 +++-- src/assets/common/filledSaveIcon.svg | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 src/assets/common/filledSaveIcon.svg diff --git a/src/assets/common/SaveIcon.svg b/src/assets/common/SaveIcon.svg index f0f5b383..34504748 100644 --- a/src/assets/common/SaveIcon.svg +++ b/src/assets/common/SaveIcon.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/src/assets/common/filledSaveIcon.svg b/src/assets/common/filledSaveIcon.svg new file mode 100644 index 00000000..9fffb2da --- /dev/null +++ b/src/assets/common/filledSaveIcon.svg @@ -0,0 +1,4 @@ + + + + From 860200cd4a4aae890174b505d2bcf2b3241861fe Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:44:03 +0900 Subject: [PATCH 09/86] =?UTF-8?q?design:=20cursor=20pointer=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/searchBook/SearchBook.styled.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/searchBook/SearchBook.styled.ts b/src/pages/searchBook/SearchBook.styled.ts index 7ada1f38..20f5c38c 100644 --- a/src/pages/searchBook/SearchBook.styled.ts +++ b/src/pages/searchBook/SearchBook.styled.ts @@ -126,6 +126,7 @@ export const RecruitingGroupButton = styled.button` justify-content: center; align-items: center; padding: 10px 12px; + cursor: pointer; `; export const RightArea = styled.div` @@ -150,17 +151,19 @@ export const WritePostButton = styled.button` gap: 8px; min-width: 200px; border: none; + cursor: pointer; `; export const SaveButton = styled.button` width: 48px; height: 48px; background: transparent; - border: 1px solid ${colors.grey[200]}; + border: none; border-radius: 12px; display: flex; align-items: center; justify-content: center; + cursor: pointer; `; export const FeedSection = styled.section` From 8dc269a0afa73826e2ebd78bb80c14d0bd83533d Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:45:07 +0900 Subject: [PATCH 10/86] feat: postSaveBook API --- src/api/books/postSaveBook.ts | 32 +++++++++++++++++++++++++++++ src/pages/searchBook/SearchBook.tsx | 28 ++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 src/api/books/postSaveBook.ts diff --git a/src/api/books/postSaveBook.ts b/src/api/books/postSaveBook.ts new file mode 100644 index 00000000..8822e4d3 --- /dev/null +++ b/src/api/books/postSaveBook.ts @@ -0,0 +1,32 @@ +import { apiClient } from '../index'; + +// 북마크 요청 타입 +export interface SaveBookRequest { + type: boolean; +} + +// 북마크 응답 데이터 타입 +export interface SaveBookData { + isbn: string; + isSaved: boolean; +} + +// 북마크 응답 타입 +export interface SaveBookResponse { + isSuccess: boolean; + code: number; + message: string; + data: SaveBookData; +} + +export const postSaveBook = async (isbn: string, type: boolean): Promise => { + try { + const response = await apiClient.post(`/books/${isbn}/saved`, { + type: type, + }); + return response.data; + } catch (error) { + console.error('책 저장 API 오류:', error); + throw error; + } +}; diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 06ebfc6c..3aeb03b7 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -26,12 +26,14 @@ import leftArrow from '../../assets/common/leftArrow.svg'; import moreIcon from '../../assets/common/more.svg'; import { IconButton } from '@/components/common/IconButton'; import saveIcon from '../../assets/common/SaveIcon.svg'; +import filledSaveIcon from '../../assets/common/filledSaveIcon.svg'; import rightChevron from '../../assets/common/right-Chevron.svg'; import plusIcon from '../../assets/common/plus.svg'; import { useState, useEffect } from 'react'; import { IntroModal } from '@/components/search/IntroModal'; import { getBookDetail, type BookDetail } from '@/api/books/getBookDetail'; import { getRecruitingRooms, type RecruitingRoomsData } from '@/api/books/getRecruitingRooms'; +import { postSaveBook } from '@/api/books/postSaveBook'; import { Filter } from '@/components/common/Filter'; import FeedPost from '@/components/feed/FeedPost'; import { mockSearchBook } from '@/mocks/searchBook.mock'; @@ -44,6 +46,8 @@ const SearchBook = () => { const [showIntroModal, setShowIntroModal] = useState(false); const [bookDetail, setBookDetail] = useState(null); const [recruitingRoomsData, setRecruitingRoomsData] = useState(null); + const [isSaved, setIsSaved] = useState(false); + const [isSaving, setIsSaving] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -67,6 +71,7 @@ const SearchBook = () => { if (bookResponse.isSuccess) { setBookDetail(bookResponse.data); + setIsSaved(bookResponse.data.isSaved); } else { setError(bookResponse.message); } @@ -120,7 +125,24 @@ const SearchBook = () => { const handleWritePostButton = () => {}; - const handleSaveButton = () => {}; + const handleSaveButton = async () => { + if (!isbn || isSaving) return; + + try { + setIsSaving(true); + const response = await postSaveBook(isbn, !isSaved); + + if (response.isSuccess) { + setIsSaved(response.data.isSaved); + } else { + console.error('북마크 실패:', response.message); + } + } catch (error) { + console.error('북마크 중 오류 발생:', error); + } finally { + setIsSaving(false); + } + }; if (isLoading || error || !bookDetail) { return ( @@ -162,8 +184,8 @@ const SearchBook = () => { 피드에 글쓰기 더하기 아이콘 - - 저장 버튼 + + 저장 버튼
From 461789622de255b945906fbb396cb049271cdb64 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:08:04 +0900 Subject: [PATCH 11/86] design: minHeight add --- src/pages/search/Search.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/search/Search.tsx b/src/pages/search/Search.tsx index 624c955f..e71b882a 100644 --- a/src/pages/search/Search.tsx +++ b/src/pages/search/Search.tsx @@ -208,6 +208,7 @@ const Wrapper = styled.div` min-width: 320px; max-width: 767px; height: 100%; + min-height: 100vh; margin: 0 auto; background: ${colors.black.main}; `; From 857ed601d6a3fdebc13f69048107bc869d970f04 Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Thu, 14 Aug 2025 02:55:47 +0900 Subject: [PATCH 12/86] =?UTF-8?q?fix:=20=ED=97=A4=EB=8D=94=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=ED=95=98=EB=93=9C=ED=86=A0=ED=81=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/index.ts | 28 ++++++++++++++++- src/components/common/TokenStatus.tsx | 45 +++++++++++++++++++++++++++ src/hooks/useOAuthToken.ts | 9 +++++- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 src/components/common/TokenStatus.tsx diff --git a/src/api/index.ts b/src/api/index.ts index 68563e02..60a1b703 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -13,7 +13,33 @@ export const apiClient = axios.create({ withCredentials: true, // 쿠키 자동 전송 설정 }); -// 응답 인터셉터 (에러 처리) +// 임시 하드코딩된 토큰 (쿠키가 없을 때 사용) +const TEMP_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc1NDM4MjY1MiwiZXhwIjoxNzU2OTc0NjUyfQ.BSGuoMWlrzc0oKgSJXHEycxdzzY9-e7gD4xh-wSDemc'; + +// Request 인터셉터: 쿠키가 없을 때 임시 토큰을 헤더에 추가 +apiClient.interceptors.request.use( + config => { + // 쿠키에서 Authorization 확인 + const cookies = document.cookie.split(';'); + const hasAuthCookie = cookies.some(cookie => cookie.trim().startsWith('Authorization=')); + + // 쿠키가 없으면 임시 토큰을 헤더에 추가 + if (!hasAuthCookie) { + console.log('🔑 쿠키가 없어서 임시 토큰을 헤더에 추가합니다.'); + config.headers.Authorization = `Bearer ${TEMP_ACCESS_TOKEN}`; + } else { + console.log('✅ Authorization 쿠키가 있어서 자동으로 전송됩니다.'); + } + + return config; + }, + error => { + return Promise.reject(error); + }, +); + +// Response 인터셉터: 401 에러 시 로그인 페이지로 리다이렉트 apiClient.interceptors.response.use( (response: AxiosResponse) => response, (error: AxiosError) => { diff --git a/src/components/common/TokenStatus.tsx b/src/components/common/TokenStatus.tsx new file mode 100644 index 00000000..bd9cdd92 --- /dev/null +++ b/src/components/common/TokenStatus.tsx @@ -0,0 +1,45 @@ +import React, { useState, useEffect } from 'react'; + +const TokenStatus = () => { + const [tokenStatus, setTokenStatus] = useState('확인 중...'); + + useEffect(() => { + const checkToken = () => { + const cookies = document.cookie.split(';'); + const hasAuthCookie = cookies.some(cookie => + cookie.trim().startsWith('Authorization=') + ); + + if (hasAuthCookie) { + setTokenStatus('✅ Authorization 쿠키 있음'); + } else { + setTokenStatus('🔑 임시 토큰 사용 중'); + } + }; + + checkToken(); + // 5초마다 상태 확인 + const interval = setInterval(checkToken, 5000); + + return () => clearInterval(interval); + }, []); + + return ( +
+ {tokenStatus} +
+ ); +}; + +export default TokenStatus; \ No newline at end of file diff --git a/src/hooks/useOAuthToken.ts b/src/hooks/useOAuthToken.ts index 2b58f1d1..a3a138bb 100644 --- a/src/hooks/useOAuthToken.ts +++ b/src/hooks/useOAuthToken.ts @@ -28,8 +28,15 @@ export const useOAuthToken = () => { .catch(error => { console.error('❌ 토큰 발급 실패:', error); // 에러 발생 시 로그인 페이지로 이동 - navigate('/'); + // navigate('/'); + console.log('💡 임시 토큰을 사용하여 계속 진행합니다.'); + // 에러 발생 시에도 임시 토큰으로 계속 진행 + // URL에서 code 파라미터 제거 + const newUrl = window.location.pathname; + window.history.replaceState({}, document.title, newUrl); }); + } else if (!loginTokenKey) { + console.log('🔑 loginTokenKey가 없습니다. 임시 토큰을 사용합니다.'); } }, [isTokenRequested, navigate]); From 8d3e6ab843177cc4fab4e94906366cf7324c1427 Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Thu, 14 Aug 2025 03:10:57 +0900 Subject: [PATCH 13/86] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/TokenStatus.tsx | 38 +++++++++++++-------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/components/common/TokenStatus.tsx b/src/components/common/TokenStatus.tsx index bd9cdd92..b852dfe3 100644 --- a/src/components/common/TokenStatus.tsx +++ b/src/components/common/TokenStatus.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; const TokenStatus = () => { const [tokenStatus, setTokenStatus] = useState('확인 중...'); @@ -6,10 +6,8 @@ const TokenStatus = () => { useEffect(() => { const checkToken = () => { const cookies = document.cookie.split(';'); - const hasAuthCookie = cookies.some(cookie => - cookie.trim().startsWith('Authorization=') - ); - + const hasAuthCookie = cookies.some(cookie => cookie.trim().startsWith('Authorization=')); + if (hasAuthCookie) { setTokenStatus('✅ Authorization 쿠키 있음'); } else { @@ -20,26 +18,28 @@ const TokenStatus = () => { checkToken(); // 5초마다 상태 확인 const interval = setInterval(checkToken, 5000); - + return () => clearInterval(interval); }, []); return ( -
+
{tokenStatus}
); }; -export default TokenStatus; \ No newline at end of file +export default TokenStatus; From 41f85d92a7f1cb3437bd62bfdfca813b30bcab4f Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Thu, 14 Aug 2025 11:22:36 +0900 Subject: [PATCH 14/86] =?UTF-8?q?feat:=20=EC=83=88=20=EA=B8=80=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EB=B0=8F=20=ED=83=9C=EA=B7=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=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/feeds/getWriteInfo.ts | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/api/feeds/getWriteInfo.ts diff --git a/src/api/feeds/getWriteInfo.ts b/src/api/feeds/getWriteInfo.ts new file mode 100644 index 00000000..8e4d3ec8 --- /dev/null +++ b/src/api/feeds/getWriteInfo.ts @@ -0,0 +1,38 @@ +import { apiClient } from '../index'; + +// 카테고리 및 태그 데이터 타입 +export interface CategoryData { + category: string; + tagList: string[]; +} + +// API 응답 데이터 타입 +export interface WriteInfoData { + categoryList: CategoryData[]; +} + +// API 응답 타입 +export interface GetWriteInfoResponse { + isSuccess: boolean; + code: number; + message: string; + data: WriteInfoData; +} + +// 새 글 작성을 위한 카테고리 및 태그 조회 API 함수 +export const getWriteInfo = async () => { + const response = await apiClient.get('/feeds/write-info'); + return response.data; +}; + +/* +사용 예시: +const writeInfo = await getWriteInfo(); +console.log(writeInfo.data.categoryList); // CategoryData[] + +// 카테고리별 태그 접근 +writeInfo.data.categoryList.forEach(category => { + console.log(`카테고리: ${category.category}`); + console.log(`태그: ${category.tagList.join(', ')}`); +}); +*/ From 634b041109819e01ad6318ae2f613f59d9ab2be5 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Thu, 14 Aug 2025 11:23:20 +0900 Subject: [PATCH 15/86] =?UTF-8?q?refactor:=20TagSelectionSection=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A5=BC=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=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 --- .../createpost/TagSelectionSection.tsx | 70 +++++++++++++------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/src/components/createpost/TagSelectionSection.tsx b/src/components/createpost/TagSelectionSection.tsx index 78884b97..311dbd6b 100644 --- a/src/components/createpost/TagSelectionSection.tsx +++ b/src/components/createpost/TagSelectionSection.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Section, SectionTitle } from '../../pages/group/CommonSection.styled'; import { TagContainer, @@ -13,28 +13,42 @@ import { TagCount, } from './TagSelectionSection.styled'; import closeIcon from '../../assets/post/close.svg'; +import { getWriteInfo, type CategoryData } from '@/api/feeds/getWriteInfo'; interface TagSelectionSectionProps { selectedTags: string[]; onTagToggle: (tag: string) => void; } -// 상위 장르와 하위 태그 매핑 -const genreTagsMap: Record = { - 문학: ['소설', '시', '에세이', '인문학', '철학'], - '과학·IT': ['기술', '과학', 'AI', '데이터'], - 사회과학: ['정치', '경제', '사회학', '심리학', '역사'], - 인문학: ['철학', '역사', '문화', '언어학', '종교'], - 예술: ['미술', '음악', '영화', '디자인', '사진'], -}; +const TagSelectionSection = ({ selectedTags, onTagToggle }: TagSelectionSectionProps) => { + const [categories, setCategories] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(''); + const [loading, setLoading] = useState(true); -const availableGenres = Object.keys(genreTagsMap); + // API에서 카테고리 및 태그 데이터 로드 + useEffect(() => { + const loadWriteInfo = async () => { + try { + setLoading(true); + const response = await getWriteInfo(); -const TagSelectionSection = ({ selectedTags, onTagToggle }: TagSelectionSectionProps) => { - const [selectedGenre, setSelectedGenre] = useState('문학'); + if (response.isSuccess && response.data.categoryList.length > 0) { + setCategories(response.data.categoryList); + // 첫 번째 카테고리를 기본 선택 + setSelectedCategory(response.data.categoryList[0].category); + } + } catch (error) { + console.error('카테고리 정보 로드 실패:', error); + } finally { + setLoading(false); + } + }; - const handleGenreSelect = (genre: string) => { - setSelectedGenre(genre); + loadWriteInfo(); + }, []); + + const handleCategorySelect = (category: string) => { + setSelectedCategory(category); }; const handleTagToggle = (tag: string) => { @@ -54,28 +68,38 @@ const TagSelectionSection = ({ selectedTags, onTagToggle }: TagSelectionSectionP onTagToggle(tag); }; - const currentSubTags = genreTagsMap[selectedGenre] || []; + // 현재 선택된 카테고리의 태그 목록 + const currentTags = categories.find(cat => cat.category === selectedCategory)?.tagList || []; + + if (loading) { + return ( +
+ 태그 + +
로딩 중...
+
+
+ ); + } return (
태그 - {/* 상위 장르 선택 */} - {availableGenres.map(genre => ( + {categories.map(categoryData => ( handleGenreSelect(genre)} + key={categoryData.category} + active={selectedCategory === categoryData.category} + onClick={() => handleCategorySelect(categoryData.category)} > - {genre} + {categoryData.category} ))} - {/* 하위 태그 그리드 */} - {currentSubTags.map(tag => ( + {currentTags.map(tag => ( Date: Thu, 14 Aug 2025 14:27:20 +0900 Subject: [PATCH 16/86] feat: getMyRooms API --- src/api/rooms/getMyRooms.ts | 44 +++++++ src/components/group/MyGroupModal.tsx | 179 +++++++++++++------------- 2 files changed, 136 insertions(+), 87 deletions(-) create mode 100644 src/api/rooms/getMyRooms.ts diff --git a/src/api/rooms/getMyRooms.ts b/src/api/rooms/getMyRooms.ts new file mode 100644 index 00000000..445f0627 --- /dev/null +++ b/src/api/rooms/getMyRooms.ts @@ -0,0 +1,44 @@ +import { apiClient } from '../index'; + +export type RoomType = 'playingAndRecruiting' | 'recruiting' | 'playing' | 'expired'; + +// 방 데이터 타입 +export interface Room { + roomId: number; + bookImageUrl: string; + roomName: string; + recruitCount: number; + memberCount: number; + endDate: string; + type: string; +} + +// 내 방 조회 응답 타입 +export interface MyRoomsResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + roomList: Room[]; + nextCursor: string; + isLast: boolean; + }; +} + +export const getMyRooms = async ( + type: RoomType = 'playingAndRecruiting', + cursor: string | null = null, +): Promise => { + try { + const params = new URLSearchParams(); + params.append('type', type); + if (cursor) { + params.append('cursor', cursor); + } + const response = await apiClient.get(`/rooms/my?${params.toString()}`); + return response.data; + } catch (error) { + console.error('내 방 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/components/group/MyGroupModal.tsx b/src/components/group/MyGroupModal.tsx index dc036d90..8f0b796c 100644 --- a/src/components/group/MyGroupModal.tsx +++ b/src/components/group/MyGroupModal.tsx @@ -1,96 +1,65 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import styled from '@emotion/styled'; import TitleHeader from '../common/TitleHeader'; import leftArrow from '../../assets/common/leftArrow.svg'; import type { Group } from './MyGroupBox'; import { GroupCard } from './GroupCard'; import { Modal, Overlay } from './Modal.styles'; +import { getMyRooms, type Room, type RoomType } from '@/api/rooms/getMyRooms'; interface MyGroupModalProps { onClose: () => void; } -const dummyMyGroups: Group[] = [ - { - id: '1', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 1, - genre: '문학', - isOnGoing: true, - }, - { - id: '2', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 2, - genre: '문학', - isOnGoing: true, - }, - { - id: '3', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 3, - genre: '문학', - isOnGoing: true, - }, - { - id: '4', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '문학', - isOnGoing: true, - }, - { - id: '5', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - isOnGoing: false, - }, - { - id: '6', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - isOnGoing: false, - }, -]; - export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { const [selected, setSelected] = useState<'진행중' | '모집중' | ''>(''); + const [rooms, setRooms] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const getRoomType = useCallback((): RoomType => { + if (selected === '진행중') return 'playing'; + if (selected === '모집중') return 'recruiting'; + return 'playingAndRecruiting'; + }, [selected]); + + const fetchRooms = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + const roomType = getRoomType(); + const response = await getMyRooms(roomType, null); + console.log(response); + if (response.isSuccess) { + setRooms(response.data.roomList); + } else { + setError(response.message); + } + } catch (error) { + console.error('방 목록 조회 실패:', error); + setError('방 목록을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }, [getRoomType]); + + const convertRoomToGroup = (room: Room): Group => { + return { + id: room.roomId.toString(), + title: room.roomName, + participants: room.memberCount, + maximumParticipants: room.recruitCount, + coverUrl: room.bookImageUrl, + deadLine: 0, + isOnGoing: room.type === 'playing' || room.type === 'playingAndRecruiting', + }; + }; - const filtered = selected - ? dummyMyGroups.filter(g => (selected === '진행중' ? g.isOnGoing : !g.isOnGoing)) - : dummyMyGroups; + useEffect(() => { + fetchRooms(); + }, [fetchRooms]); + + const convertedGroups = rooms.map(convertRoomToGroup); return ( @@ -113,14 +82,23 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { - {filtered.map(group => ( - - ))} + {isLoading ? ( + 로딩 중... + ) : error ? ( + {error} + ) : convertedGroups.length > 0 ? ( + convertedGroups.map(group => ( + + )) + ) : ( + + {selected === '진행중' + ? '진행중인 모임방이 없습니다.' + : selected === '모집중' + ? '모집중인 모임방이 없습니다.' + : '참여한 모임방이 없습니다.'} + + )} @@ -158,3 +136,30 @@ const Content = styled.div` grid-template-columns: 1fr 1fr; } `; + +const LoadingMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: #fff; + font-size: var(--font-size-regular); +`; + +const ErrorMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: #ff6b6b; + font-size: var(--font-size-regular); +`; + +const EmptyMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: #999; + font-size: var(--font-size-regular); +`; From 33fab28f2d866fc965114c0761989cfe6f52a9bd Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:10:51 +0900 Subject: [PATCH 17/86] =?UTF-8?q?design:=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=EB=A7=81=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/group/MyGroupModal.tsx | 120 ++++++++++++++++---------- 1 file changed, 75 insertions(+), 45 deletions(-) diff --git a/src/components/group/MyGroupModal.tsx b/src/components/group/MyGroupModal.tsx index 8f0b796c..668e06c5 100644 --- a/src/components/group/MyGroupModal.tsx +++ b/src/components/group/MyGroupModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import styled from '@emotion/styled'; import TitleHeader from '../common/TitleHeader'; import leftArrow from '../../assets/common/leftArrow.svg'; @@ -6,6 +6,7 @@ import type { Group } from './MyGroupBox'; import { GroupCard } from './GroupCard'; import { Modal, Overlay } from './Modal.styles'; import { getMyRooms, type Room, type RoomType } from '@/api/rooms/getMyRooms'; +import { colors, typography } from '@/styles/global/global'; interface MyGroupModalProps { onClose: () => void; @@ -17,47 +18,51 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const getRoomType = useCallback((): RoomType => { - if (selected === '진행중') return 'playing'; - if (selected === '모집중') return 'recruiting'; - return 'playingAndRecruiting'; - }, [selected]); - - const fetchRooms = useCallback(async () => { - try { - setIsLoading(true); - setError(null); - const roomType = getRoomType(); - const response = await getMyRooms(roomType, null); - console.log(response); - if (response.isSuccess) { - setRooms(response.data.roomList); - } else { - setError(response.message); - } - } catch (error) { - console.error('방 목록 조회 실패:', error); - setError('방 목록을 불러오는데 실패했습니다.'); - } finally { - setIsLoading(false); - } - }, [getRoomType]); - const convertRoomToGroup = (room: Room): Group => { return { id: room.roomId.toString(), title: room.roomName, + userName: '', participants: room.memberCount, maximumParticipants: room.recruitCount, coverUrl: room.bookImageUrl, deadLine: 0, + genre: '', isOnGoing: room.type === 'playing' || room.type === 'playingAndRecruiting', }; }; useEffect(() => { + const fetchRooms = async () => { + try { + setIsLoading(true); + setError(null); + + const roomType: RoomType = + selected === '진행중' + ? 'playing' + : selected === '모집중' + ? 'recruiting' + : 'playingAndRecruiting'; + + const response = await getMyRooms(roomType, null); + console.log(response); + + if (response.isSuccess) { + setRooms(response.data.roomList); + } else { + setError(response.message); + } + } catch (error) { + console.error('방 목록 조회 실패:', error); + setError('방 목록을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + fetchRooms(); - }, [fetchRooms]); + }, [selected]); const convertedGroups = rooms.map(convertRoomToGroup); return ( @@ -91,13 +96,22 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { )) ) : ( - - {selected === '진행중' - ? '진행중인 모임방이 없습니다.' - : selected === '모집중' - ? '모집중인 모임방이 없습니다.' - : '참여한 모임방이 없습니다.'} - + + + {selected === '진행중' + ? '진행중인 모임방이 없어요' + : selected === '모집중' + ? '모집중인 모임방이 없어요' + : '참여중인 모임방이 없어요'} + + + {selected === '진행중' + ? '진행중인 모임방에 참여해보세요!' + : selected === '모집중' + ? '모집중인 모임방에 참여해보세요!' + : '첫 번째 모임방에 참여해보세요!'} + + )} @@ -113,13 +127,12 @@ const TabContainer = styled.div` const Tab = styled.button<{ selected: boolean }>` white-space: nowrap; - padding: 6px 12px; - font-size: var(--font-size-small03); - font-weight: var(--font-weight-regular); + padding: 8px 12px; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; border: none; border-radius: 16px; - background: ${({ selected }) => - selected ? 'var(--color-purple-main)' : 'var(--color-darkgrey-main)'}; + background: ${({ selected }) => (selected ? colors.purple.main : colors.darkgrey.main)}; color: #fff; cursor: pointer; `; @@ -143,7 +156,7 @@ const LoadingMessage = styled.div` align-items: center; padding: 40px 20px; color: #fff; - font-size: var(--font-size-regular); + font-size: ${typography.fontSize.base}; `; const ErrorMessage = styled.div` @@ -152,14 +165,31 @@ const ErrorMessage = styled.div` align-items: center; padding: 40px 20px; color: #ff6b6b; - font-size: var(--font-size-regular); + font-size: ${typography.fontSize.base}; `; -const EmptyMessage = styled.div` +const EmptyState = styled.div` + flex: 1; + min-height: 78vh; display: flex; + flex-direction: column; justify-content: center; align-items: center; padding: 40px 20px; - color: #999; - font-size: var(--font-size-regular); + margin-bottom: 70px; + color: ${colors.grey[100]}; + text-align: center; +`; + +const EmptyTitle = styled.p` + font-size: ${typography.fontSize.lg}; + font-weight: ${typography.fontWeight.semibold}; + margin-bottom: 8px; + color: ${colors.white}; +`; + +const EmptySubText = styled.p` + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; + color: ${colors.grey[100]}; `; From e5ca390333110dc84b25954356a326f12f428df2 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:23:33 +0900 Subject: [PATCH 18/86] =?UTF-8?q?feat:=20getMyRooms=20CompletedGroupModal?= =?UTF-8?q?=EC=97=90=20=EC=B6=94=EA=B0=80=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/group/CompletedGroupModal.tsx | 185 +++++++++++-------- src/components/group/MyGroupModal.tsx | 1 - 2 files changed, 104 insertions(+), 82 deletions(-) diff --git a/src/components/group/CompletedGroupModal.tsx b/src/components/group/CompletedGroupModal.tsx index b0954499..b2708cd1 100644 --- a/src/components/group/CompletedGroupModal.tsx +++ b/src/components/group/CompletedGroupModal.tsx @@ -1,86 +1,58 @@ +import { useState, useEffect } from 'react'; import styled from '@emotion/styled'; import leftArrow from '../../assets/common/leftArrow.svg'; import type { Group } from './MyGroupBox'; import { GroupCard } from './GroupCard'; import TitleHeader from '../common/TitleHeader'; import { Modal, Overlay } from './Modal.styles'; +import { getMyRooms, type Room } from '@/api/rooms/getMyRooms'; +import { colors, typography } from '@/styles/global/global'; interface CompletedGroupModalProps { onClose: () => void; } -const dummyCompletedGroups: Group[] = [ - { - id: '1', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 1, - genre: '문학', - }, - { - id: '2', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 2, - genre: '문학', - }, - { - id: '3', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 3, - genre: '문학', - }, - { - id: '4', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '문학', - }, - { - id: '5', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - }, - { - id: '6', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - }, -]; +const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => { + const [rooms, setRooms] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); -const userName = '00'; + const convertRoomToGroup = (room: Room): Group => { + return { + id: room.roomId.toString(), + title: room.roomName, + userName: '', + participants: room.memberCount, + maximumParticipants: room.recruitCount, + coverUrl: room.bookImageUrl, + deadLine: 0, + isOnGoing: false, + }; + }; -const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => { + useEffect(() => { + const fetchCompletedRooms = async () => { + try { + setIsLoading(true); + setError(null); + const response = await getMyRooms('expired', null); + if (response.isSuccess) { + setRooms(response.data.roomList); + } else { + setError(response.message); + } + } catch (error) { + console.error('완료된 방 목록 조회 실패:', error); + setError('완료된 방 목록을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + fetchCompletedRooms(); + }, []); + + const convertedGroups = rooms.map(convertRoomToGroup); return ( @@ -89,11 +61,20 @@ const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => { leftIcon={뒤로 가기} onLeftClick={onClose} /> - {userName}님이 참여했던 모임방들을 확인해보세요. - - {dummyCompletedGroups.map(group => ( - - ))} + 00님이 참여했던 모임방들을 확인해보세요. + + {isLoading ? ( + 로딩 중... + ) : error ? ( + {error} + ) : convertedGroups.length > 0 ? ( + convertedGroups.map(group => ) + ) : ( + + 완료된 모임방이 없어요 + 아직 완료된 모임방이 없습니다. + + )} @@ -103,21 +84,63 @@ const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => { export default CompletedGroupModal; const Text = styled.p` - font-size: var(--font-size-medium01); - font-weight: var(--font-weight-regular); - color: var(--color-white); + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; + color: ${colors.white}; margin: 96px 20px 20px 20px; `; -const Content = styled.div` +const Content = styled.div<{ isEmpty?: boolean }>` display: grid; gap: 20px; - overflow-y: auto; + overflow-y: ${({ isEmpty }) => (isEmpty ? 'visible' : 'auto')}; padding: 0 20px; - + flex: 1; grid-template-columns: 1fr; @media (min-width: 584px) { grid-template-columns: 1fr 1fr; } `; + +const LoadingMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: ${colors.white}; + font-size: ${typography.fontSize.base}; +`; + +const ErrorMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: #ff6b6b; + font-size: ${typography.fontSize.base}; +`; + +const EmptyState = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: ${colors.grey[100]}; + text-align: center; + height: 100%; +`; + +const EmptyTitle = styled.p` + font-size: ${typography.fontSize.lg}; + font-weight: ${typography.fontWeight.semibold}; + margin-bottom: 8px; + color: ${colors.white}; +`; + +const EmptySubText = styled.p` + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; + color: ${colors.grey[100]}; +`; diff --git a/src/components/group/MyGroupModal.tsx b/src/components/group/MyGroupModal.tsx index 668e06c5..3731fd7d 100644 --- a/src/components/group/MyGroupModal.tsx +++ b/src/components/group/MyGroupModal.tsx @@ -46,7 +46,6 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { : 'playingAndRecruiting'; const response = await getMyRooms(roomType, null); - console.log(response); if (response.isSuccess) { setRooms(response.data.roomList); From b136ba43e5729391a2ab1a68c570c9933eb0f02c Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:37:12 +0900 Subject: [PATCH 19/86] feat: getRoomDetail --- src/api/rooms/getRoomDetail.ts | 49 ++++++++++ src/pages/group/Group.tsx | 2 +- src/pages/groupDetail/GroupDetail.tsx | 134 ++++++++++++++++++++------ 3 files changed, 155 insertions(+), 30 deletions(-) create mode 100644 src/api/rooms/getRoomDetail.ts diff --git a/src/api/rooms/getRoomDetail.ts b/src/api/rooms/getRoomDetail.ts new file mode 100644 index 00000000..7a21b057 --- /dev/null +++ b/src/api/rooms/getRoomDetail.ts @@ -0,0 +1,49 @@ +import { apiClient } from '../index'; + +// 방 상세 정보 응답 타입 +export interface RoomDetailResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + isHost: boolean; + isJoining: boolean; + roomId: number; + roomName: string; + roomImageUrl: string; + isPublic: boolean; + progressStartDate: string; + progressEndDate: string; + recruitEndDate: string; + category: string; + roomDescription: string; + memberCount: number; + recruitCount: number; + isbn: string; + bookImageUrl: string; + bookTitle: string; + authorName: string; + bookDescription: string; + publisher: string; + recommendRooms: RecommendRoom[]; + }; +} + +export interface RecommendRoom { + roomId: number; + roomImageUrl: string; + roomName: string; + memberCount: number; + recruitCount: number; + recruitEndDate: string; +} + +export const getRoomDetail = async (roomId: number): Promise => { + try { + const response = await apiClient.get(`/rooms/${roomId}/recruiting`); + return response.data; + } catch (error) { + console.error('방 상세 정보 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/pages/group/Group.tsx b/src/pages/group/Group.tsx index 00adab86..f3fe2e7d 100644 --- a/src/pages/group/Group.tsx +++ b/src/pages/group/Group.tsx @@ -129,7 +129,7 @@ const Group = () => { const closeCompletedGroupModal = () => setIsCompletedGroupModalOpen(false); const handleSearchBarClick = () => { - navigate('/groupsearch'); + navigate('/group/search'); }; return ( diff --git a/src/pages/groupDetail/GroupDetail.tsx b/src/pages/groupDetail/GroupDetail.tsx index e7d6d5eb..78fc0a39 100644 --- a/src/pages/groupDetail/GroupDetail.tsx +++ b/src/pages/groupDetail/GroupDetail.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react'; import { Wrapper, TopBackground, @@ -27,51 +28,126 @@ import { } from './GroupDetail.styled'; import leftArrow from '../../assets/common/leftArrow.svg'; import moreIcon from '../../assets/common/more.svg'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { IconButton } from '@/components/common/IconButton'; -import { mockGroupDetail } from '../../mocks/groupDetail.mock'; import lockIcon from '../../assets/group/lock.svg'; import calendarIcon from '../../assets/group/calendar.svg'; import peopleIcon from '../../assets/common/darkPeople.svg'; import rightChevron from '../../assets/common/right-Chevron.svg'; import { GroupCard } from '@/components/group/GroupCard'; +import { + getRoomDetail, + type RoomDetailResponse, + type RecommendRoom, +} from '@/api/rooms/getRoomDetail'; +import type { Group } from '@/components/group/MyGroupBox'; const GroupDetail = () => { - const { - title, - isPrivate, - introduction, - activityPeriod, - members, - ddayText, - genre, - book, - recommendations, - } = mockGroupDetail; - + const { roomId } = useParams<{ roomId: string }>(); const navigate = useNavigate(); + const [roomData, setRoomData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const handleBackButton = () => { navigate(-1); }; const handleMoreButton = () => {}; + const convertRecommendRoomToGroup = (room: RecommendRoom): Group => { + return { + id: room.roomId.toString(), + title: room.roomName, + userName: '', + participants: room.memberCount, + maximumParticipants: room.recruitCount, + coverUrl: room.roomImageUrl, + deadLine: 0, + genre: '', + isOnGoing: true, + }; + }; + + const calculateDday = (recruitEndDate: string): string => { + const today = new Date(); + const endDate = new Date(recruitEndDate); + const diffTime = endDate.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays < 0) return '모집 종료'; + if (diffDays === 0) return 'D-DAY'; + return `D-${diffDays}`; + }; + + useEffect(() => { + const fetchRoomDetail = async () => { + if (!roomId) return; + + try { + setIsLoading(true); + setError(null); + + const response = await getRoomDetail(Number(roomId)); + console.log(response); + + if (response.isSuccess) { + setRoomData(response.data); + } else { + setError(response.message); + } + } catch (error) { + console.error('방 상세 정보 조회 실패:', error); + setError('방 정보를 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + fetchRoomDetail(); + }, [roomId]); + + if (isLoading) { + return
로딩 중...
; + } + + if (error || !roomData) { + return
에러: {error}
; + } + + const { + roomName, + isPublic, + roomDescription, + progressStartDate, + progressEndDate, + memberCount, + recruitCount, + recruitEndDate, + category, + bookTitle, + authorName, + bookDescription, + bookImageUrl, + recommendRooms, + } = roomData; + return ( - +
- {title} {isPrivate && 자물쇠 아이콘} + {roomName} {!isPublic && 자물쇠 아이콘}
소개글

- {introduction} + {roomDescription}
@@ -79,7 +155,7 @@ const GroupDetail = () => { 모임 활동기간 - {activityPeriod.start} ~ {activityPeriod.end} + {progressStartDate} ~ {progressEndDate} @@ -87,33 +163,33 @@ const GroupDetail = () => { 참여 중인 독서메이트 - {members.current} - / {members.max}명 + {memberCount} + / {recruitCount}명 - 모집 {ddayText} + 모집 {calculateDday(recruitEndDate)} - 장르 {genre} + 장르 {category}
-

{book.title}

+

{bookTitle}

- + -
{book.author}
+
{authorName}
도서 소개
-

{book.description}

+

{bookDescription}

@@ -121,10 +197,10 @@ const GroupDetail = () => { 이런 모임방은 어때요? - {recommendations.map(group => ( + {recommendRooms.map(room => ( Date: Thu, 14 Aug 2025 23:26:13 +0900 Subject: [PATCH 20/86] feat: getRoomsByCategory API --- src/api/rooms/getRoomsByCategory.ts | 33 +++++++ src/pages/group/Group.tsx | 137 +++++++++++++--------------- 2 files changed, 95 insertions(+), 75 deletions(-) create mode 100644 src/api/rooms/getRoomsByCategory.ts diff --git a/src/api/rooms/getRoomsByCategory.ts b/src/api/rooms/getRoomsByCategory.ts new file mode 100644 index 00000000..b2c55583 --- /dev/null +++ b/src/api/rooms/getRoomsByCategory.ts @@ -0,0 +1,33 @@ +import { apiClient } from '../index'; + +// 방 목록 응답 데이터 타입 +export interface RoomItem { + roomId: number; + bookImageUrl: string; + roomName: string; + recruitCount: number; + memberCount: number; + deadlineDate: string; +} + +export interface RoomsResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + deadlineRoomList: RoomItem[]; + popularRoomList: RoomItem[]; + }; +} + +export const getRoomsByCategory = async (category: string): Promise => { + try { + const response = await apiClient.get( + `/rooms?category=${encodeURIComponent(category)}`, + ); + return response.data; + } catch (error) { + console.error('방 목록 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/pages/group/Group.tsx b/src/pages/group/Group.tsx index f3fe2e7d..13b1b13a 100644 --- a/src/pages/group/Group.tsx +++ b/src/pages/group/Group.tsx @@ -6,11 +6,12 @@ import { MyGroupBox } from '../../components/group/MyGroupBox'; import Blank from '@/components/common/Blank'; import styled from '@emotion/styled'; import { RecruitingGroupCarousel, type Section } from '@/components/group/RecruitingGroupCarousel'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { MyGroupModal } from '@/components/group/MyGroupModal'; import CompletedGroupModal from '@/components/group/CompletedGroupModal'; import { useNavigate } from 'react-router-dom'; import makegroupfab from '../../assets/common/makegroupfab.svg'; +import { getRoomsByCategory, type RoomItem } from '@/api/rooms/getRoomsByCategory'; const dummyMyGroups: GroupType[] = [ { @@ -42,85 +43,70 @@ const dummyMyGroups: GroupType[] = [ }, ]; -const dummyRecruitingGroups: GroupType[] = [ - { - id: '1', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 1, - genre: '문학', - }, - { - id: '2', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 2, - genre: '문학', - }, - { - id: '3', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 3, - genre: '문학', - }, - { - id: '4', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '문학', - }, - { - id: '5', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - }, - { - id: '6', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - }, -]; - -const sections: Section[] = [ - { title: '마감 임박한 독서 모임방', groups: dummyRecruitingGroups }, - { title: '인기 있는 독서 모임방', groups: dummyRecruitingGroups }, - { title: '인플루언서·작가 독서 모임방', groups: dummyRecruitingGroups }, -]; +const convertRoomItemToGroup = ( + room: RoomItem, + category: string, + listType: 'deadline' | 'popular', +): GroupType => ({ + id: `${room.roomId}-${category}-${listType}`, + title: room.roomName, + participants: room.memberCount, + maximumParticipants: room.recruitCount, + coverUrl: room.bookImageUrl, + deadLine: Math.ceil( + (new Date(room.deadlineDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24), + ), + genre: category, +}); const Group = () => { const navigate = useNavigate(); const [isMyGroupModalOpen, setIsMyGroupModalOpen] = useState(false); const [isCompletedGroupModalOpen, setIsCompletedGroupModalOpen] = useState(false); + const [sections, setSections] = useState([ + { title: '마감 임박한 독서 모임방', groups: [] }, + { title: '인기 있는 독서 모임방', groups: [] }, + { title: '인플루언서·작가 독서 모임방', groups: [] }, + ]); + + const fetchAllRoomsData = async () => { + try { + const categories = ['문학', '인문학', '사회과학', '과학·IT', '예술']; + const deadlineRoomsData: GroupType[] = []; + const popularRoomsData: GroupType[] = []; + + for (const category of categories) { + const response = await getRoomsByCategory(category); + if (response.isSuccess) { + const deadlineGroups = response.data.deadlineRoomList.map(room => + convertRoomItemToGroup(room, category, 'deadline'), + ); + const popularGroups = response.data.popularRoomList.map(room => + convertRoomItemToGroup(room, category, 'popular'), + ); + deadlineRoomsData.push(...deadlineGroups); + popularRoomsData.push(...popularGroups); + } + } + + setSections([ + { title: '마감 임박한 독서 모임방', groups: deadlineRoomsData }, + { title: '인기 있는 독서 모임방', groups: popularRoomsData }, + { title: '인플루언서·작가 독서 모임방', groups: [] }, + ]); + } catch (error) { + console.error('방 목록 조회 오류:', error); + setSections([ + { title: '마감 임박한 독서 모임방', groups: [] }, + { title: '인기 있는 독서 모임방', groups: [] }, + { title: '인플루언서·작가 독서 모임방', groups: [] }, + ]); + } + }; + + useEffect(() => { + fetchAllRoomsData(); + }, []); const openMyGroupModal = () => setIsMyGroupModalOpen(true); const closeMyGroupModal = () => setIsMyGroupModalOpen(false); @@ -131,6 +117,7 @@ const Group = () => { const handleSearchBarClick = () => { navigate('/group/search'); }; + return ( {isMyGroupModalOpen && } From 10babe49eec6177f535af23933f52c8b731ce20d Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Thu, 14 Aug 2025 23:29:53 +0900 Subject: [PATCH 21/86] feat: RecruitingGroupBox EmpryState --- src/components/group/RecruitingGroupBox.tsx | 38 +++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/components/group/RecruitingGroupBox.tsx b/src/components/group/RecruitingGroupBox.tsx index 2b8c134b..a5ca9df5 100644 --- a/src/components/group/RecruitingGroupBox.tsx +++ b/src/components/group/RecruitingGroupBox.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import styled from '@emotion/styled'; import type { Group } from './MyGroupBox'; import { GroupCard } from './GroupCard'; +import { colors, typography } from '@/styles/global/global'; interface Props { groups: Group[]; @@ -26,9 +27,14 @@ export function RecruitingGroupBox({ groups, title }: Props) { ))} - {filtered.map(group => ( - - ))} + {filtered.length > 0 ? ( + filtered.map(group => ) + ) : ( + + 모임방이 아직 없어요. + 해당 장르의 모임방이 생기면 보여줄게요! + + )} ); @@ -95,3 +101,29 @@ const Grid = styled.div` grid-template-columns: 1fr 1fr; } `; + +const EmptyContent = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; + padding: 60px 20px; + grid-column: 1 / -1; +`; + +const EmptyMainText = styled.p` + color: ${colors.white}; + font-size: ${typography.fontSize.lg}; + font-weight: ${typography.fontWeight.semibold}; + text-align: center; + margin: 0; +`; + +const EmptySubText = styled.p` + color: ${colors.grey[100]}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; + text-align: center; + margin: 0; +`; From a79d94c2d511a899c249fe4af915b0b607ce6e6f Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:44:05 +0900 Subject: [PATCH 22/86] feat: getJoinedRooms API --- src/api/rooms/getJoinedRooms.ts | 34 ++++++++ src/components/group/MyGroupBox.tsx | 127 ++++++++++++++++++++++++---- src/pages/group/Group.tsx | 32 +------ 3 files changed, 144 insertions(+), 49 deletions(-) create mode 100644 src/api/rooms/getJoinedRooms.ts diff --git a/src/api/rooms/getJoinedRooms.ts b/src/api/rooms/getJoinedRooms.ts new file mode 100644 index 00000000..fb104d1b --- /dev/null +++ b/src/api/rooms/getJoinedRooms.ts @@ -0,0 +1,34 @@ +import { apiClient } from '../index'; + +// 가입한 방 목록 응답 데이터 타입 +export interface JoinedRoomItem { + roomId: number; + bookImageUrl: string; + roomTitle: string; + memberCount: number; + userPercentage: number; +} + +export interface JoinedRoomsResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + roomList: JoinedRoomItem[]; + nickname: string; + page: number; + size: number; + last: boolean; + first: boolean; + }; +} + +export const getJoinedRooms = async (page: number = 1): Promise => { + try { + const response = await apiClient.get(`/rooms/home/joined?page=${page}`); + return response.data; + } catch (error) { + console.error('가입한 방 목록 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/components/group/MyGroupBox.tsx b/src/components/group/MyGroupBox.tsx index 2a74a48d..33798ad4 100644 --- a/src/components/group/MyGroupBox.tsx +++ b/src/components/group/MyGroupBox.tsx @@ -2,6 +2,8 @@ import { MyGroupCard } from './MyGroupCard'; import { useInfiniteCarousel } from '../../hooks/useInfiniteCarousel'; import styled from '@emotion/styled'; import rightChevron from '../../assets/common/right-Chevron.svg'; +import { useState, useEffect } from 'react'; +import { getJoinedRooms, type JoinedRoomItem } from '@/api/rooms/getJoinedRooms'; export interface Group { id: number | string; @@ -16,12 +18,46 @@ export interface Group { isOnGoing?: boolean; } +const convertJoinedRoomToGroup = (room: JoinedRoomItem): Group => ({ + id: room.roomId, + title: room.roomTitle, + participants: room.memberCount, + coverUrl: room.bookImageUrl, + progress: room.userPercentage, +}); + interface MyGroupProps { - groups: Group[]; onMyGroupsClick: () => void; } -export function MyGroupBox({ groups, onMyGroupsClick }: MyGroupProps) { +export function MyGroupBox({ onMyGroupsClick }: MyGroupProps) { + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchJoinedRooms = async () => { + try { + setLoading(true); + setError(null); + const response = await getJoinedRooms(1); + + if (response.isSuccess) { + const convertedGroups = response.data.roomList.map(convertJoinedRoomToGroup); + setGroups(convertedGroups); + } + } catch (error) { + console.error('가입한 방 목록 조회 오류:', error); + setError('방 목록을 불러오는데 실패했습니다.'); + setGroups([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchJoinedRooms(); + }, []); + const { scrollRef, cardRefs, infiniteGroups, current } = useInfiniteCarousel(groups); return ( @@ -32,22 +68,38 @@ export function MyGroupBox({ groups, onMyGroupsClick }: MyGroupProps) { 내 모임방 버튼 - - {infiniteGroups.map((g, i) => ( - { - cardRefs.current[i] = el; - }} - /> - ))} - - - {groups.map((_, i) => ( - - ))} - + {loading ? ( + + 모임방을 불러오는 중... + + ) : error ? ( + + {error} + + ) : groups.length > 0 ? ( + <> + + {infiniteGroups.map((g, i) => ( + { + cardRefs.current[i] = el; + }} + /> + ))} + + + {groups.map((_, i) => ( + + ))} + + + ) : ( + + 가입한 모임방이 없어요 + + )} ); } @@ -109,3 +161,42 @@ const Dot = styled.div<{ active: boolean }>` background: ${({ active }) => (active ? 'var(--color-white)' : `var(--color-grey-300)`)}; transition: background-color 0.3s; `; + +const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 60px 20px; +`; + +const LoadingText = styled.p` + color: var(--color-grey-300); + font-size: var(--font-size-medium02); + margin: 0; +`; + +const ErrorContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 60px 20px; +`; + +const ErrorText = styled.p` + color: var(--color-red); + font-size: var(--font-size-medium02); + margin: 0; +`; + +const EmptyContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 60px 20px; +`; + +const EmptyText = styled.p` + color: var(--color-grey-300); + font-size: var(--font-size-medium02); + margin: 0; +`; diff --git a/src/pages/group/Group.tsx b/src/pages/group/Group.tsx index 13b1b13a..2d401992 100644 --- a/src/pages/group/Group.tsx +++ b/src/pages/group/Group.tsx @@ -13,36 +13,6 @@ import { useNavigate } from 'react-router-dom'; import makegroupfab from '../../assets/common/makegroupfab.svg'; import { getRoomsByCategory, type RoomItem } from '@/api/rooms/getRoomsByCategory'; -const dummyMyGroups: GroupType[] = [ - { - id: '1', - title: '호르몬 체인지 완독하는 방', - participants: 22, - userName: 'hoho', - progress: 40, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - }, - { - id: '2', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - progress: 0, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - }, - { - id: '3', - title: '일본 소설 좋아하는 사람들', - userName: 'hoho3', - participants: 30, - progress: 100, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - }, -]; - const convertRoomItemToGroup = ( room: RoomItem, category: string, @@ -124,7 +94,7 @@ const Group = () => { {isCompletedGroupModalOpen && } - + From 0c069c1856cbf7fdabfad0a93aac65d03580105d Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:49:44 +0900 Subject: [PATCH 23/86] =?UTF-8?q?design:=20global=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/group/MyGroupBox.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/components/group/MyGroupBox.tsx b/src/components/group/MyGroupBox.tsx index 33798ad4..a040cba0 100644 --- a/src/components/group/MyGroupBox.tsx +++ b/src/components/group/MyGroupBox.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import rightChevron from '../../assets/common/right-Chevron.svg'; import { useState, useEffect } from 'react'; import { getJoinedRooms, type JoinedRoomItem } from '@/api/rooms/getJoinedRooms'; +import { colors, typography } from '@/styles/global/global'; export interface Group { id: number | string; @@ -105,7 +106,7 @@ export function MyGroupBox({ onMyGroupsClick }: MyGroupProps) { } const Container = styled.div` - background-color: var(--color-main-black); + background-color: ${colors.black.main}; position: relative; width: 100%; overflow-x: hidden; @@ -119,9 +120,9 @@ const Header = styled.div` const Title = styled.h2` flex: 1; - font-size: var(--font-size-large02); - font-weight: var(--font-weight-bold); - color: var(--color-white); + font-size: ${typography.fontSize.xl}; + font-weight: ${typography.fontWeight.bold}; + color: ${colors.white}; margin: 0; `; @@ -158,7 +159,7 @@ const Dot = styled.div<{ active: boolean }>` width: 4px; height: 4px; border-radius: 50%; - background: ${({ active }) => (active ? 'var(--color-white)' : `var(--color-grey-300)`)}; + background: ${({ active }) => (active ? colors.white : colors.grey[300])}; transition: background-color 0.3s; `; @@ -170,8 +171,8 @@ const LoadingContainer = styled.div` `; const LoadingText = styled.p` - color: var(--color-grey-300); - font-size: var(--font-size-medium02); + color: ${colors.grey[300]}; + font-size: ${typography.fontSize.base}; margin: 0; `; @@ -183,8 +184,8 @@ const ErrorContainer = styled.div` `; const ErrorText = styled.p` - color: var(--color-red); - font-size: var(--font-size-medium02); + color: ${colors.red}; + font-size: ${typography.fontSize.base}; margin: 0; `; @@ -196,7 +197,7 @@ const EmptyContainer = styled.div` `; const EmptyText = styled.p` - color: var(--color-grey-300); - font-size: var(--font-size-medium02); + color: ${colors.grey[300]}; + font-size: ${typography.fontSize.base}; margin: 0; `; From a3770c2d2cabb5024f13d094e84c67eb6fc65099 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Fri, 15 Aug 2025 16:08:16 +0900 Subject: [PATCH 24/86] =?UTF-8?q?feat:=20groupCard=20click=20event=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/group/GroupCard.tsx | 5 ++-- src/components/group/RecruitingGroupBox.tsx | 27 ++++++++++++++++++++- src/pages/group/CreateGroup.tsx | 3 +-- src/pages/index.tsx | 2 +- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/components/group/GroupCard.tsx b/src/components/group/GroupCard.tsx index 1396b271..8c5192d4 100644 --- a/src/components/group/GroupCard.tsx +++ b/src/components/group/GroupCard.tsx @@ -9,12 +9,13 @@ interface Props { isOngoing?: boolean; type?: 'main' | 'search' | 'modal'; isRecommend?: boolean; + onClick?: () => void; } export const GroupCard = forwardRef( - ({ group, isOngoing, type = 'main', isRecommend = false }, ref) => { + ({ group, isOngoing, type = 'main', isRecommend = false, onClick }, ref) => { return ( - + {group.title} diff --git a/src/components/group/RecruitingGroupBox.tsx b/src/components/group/RecruitingGroupBox.tsx index a5ca9df5..cfa97b9a 100644 --- a/src/components/group/RecruitingGroupBox.tsx +++ b/src/components/group/RecruitingGroupBox.tsx @@ -3,6 +3,8 @@ import styled from '@emotion/styled'; import type { Group } from './MyGroupBox'; import { GroupCard } from './GroupCard'; import { colors, typography } from '@/styles/global/global'; +import { useNavigate } from 'react-router-dom'; +import { getRoomDetail } from '@/api/rooms/getRoomDetail'; interface Props { groups: Group[]; @@ -13,9 +15,25 @@ const GENRE = ['문학', '과학·IT', '사회과학', '인문학', '예술']; export function RecruitingGroupBox({ groups, title }: Props) { const [selected, setSelected] = useState('문학'); + const navigate = useNavigate(); const filtered = useMemo(() => groups.filter(g => g.genre === selected), [groups, selected]); + const handleGroupCardClick = async (groupId: number | string) => { + try { + const roomId = typeof groupId === 'string' ? parseInt(groupId) : groupId; + + const response = await getRoomDetail(roomId); + + if (response.isSuccess) { + navigate(`/group/detail/${roomId}`); + } + } catch (error) { + console.error('방 상세 정보 조회 오류:', error); + navigate(`/group/${groupId}`); + } + }; + return ( {title} @@ -28,7 +46,14 @@ export function RecruitingGroupBox({ groups, title }: Props) { {filtered.length > 0 ? ( - filtered.map(group => ) + filtered.map(group => ( + handleGroupCardClick(group.id)} + /> + )) ) : ( 모임방이 아직 없어요. diff --git a/src/pages/group/CreateGroup.tsx b/src/pages/group/CreateGroup.tsx index d6e975f7..0f9e84e0 100644 --- a/src/pages/group/CreateGroup.tsx +++ b/src/pages/group/CreateGroup.tsx @@ -137,9 +137,8 @@ const CreateGroup = () => { if (isSuccessful) { // 성공 시 모집 중인 방 상세 페이지로 이동 - navigate('/group/detail', { + navigate(`/group/detail/${response.data.roomId}`, { replace: true, - state: { roomId: response.data.roomId }, }); } else { alert(`방 생성에 실패했습니다: ${response.message} (코드: ${response.code})`); diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 62347f89..5d8ab67f 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -52,7 +52,7 @@ const Router = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> From 26edc6e678f15443d3b5bc0b5373d19f5095fbd8 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Fri, 15 Aug 2025 16:24:05 +0900 Subject: [PATCH 25/86] design: min size add --- src/components/group/GroupCard.tsx | 4 +++- src/pages/groupDetail/GroupDetail.tsx | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/group/GroupCard.tsx b/src/components/group/GroupCard.tsx index 8c5192d4..c238c7bf 100644 --- a/src/components/group/GroupCard.tsx +++ b/src/components/group/GroupCard.tsx @@ -53,7 +53,9 @@ const Card = styled.div<{ cardType: 'main' | 'search' | 'modal' }>` box-sizing: border-box; padding: ${({ cardType }) => (cardType === 'search' ? '24px 12px 12px 12px' : '12px')}; gap: 12px; - width: 100%; + min-width: 208px; + min-height: 80px; + padding: 12px; `; const Cover = styled.img<{ cardType: 'main' | 'search' | 'modal'; isRecommend?: boolean }>` diff --git a/src/pages/groupDetail/GroupDetail.tsx b/src/pages/groupDetail/GroupDetail.tsx index 78fc0a39..596974b7 100644 --- a/src/pages/groupDetail/GroupDetail.tsx +++ b/src/pages/groupDetail/GroupDetail.tsx @@ -76,9 +76,10 @@ const GroupDetail = () => { const diffTime = endDate.getTime() - today.getTime(); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + console.log(endDate); if (diffDays < 0) return '모집 종료'; - if (diffDays === 0) return 'D-DAY'; - return `D-${diffDays}`; + if (diffDays === 0) return '오늘 마감'; + return `${diffDays}일 남음`; }; useEffect(() => { From c2bd7ba197a2d9f81e29ac877024989c68487bec Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Fri, 15 Aug 2025 16:27:38 +0900 Subject: [PATCH 26/86] =?UTF-8?q?design:=20BookDetails=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/groupDetail/GroupDetail.styled.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/groupDetail/GroupDetail.styled.ts b/src/pages/groupDetail/GroupDetail.styled.ts index c34757ea..92ec1f63 100644 --- a/src/pages/groupDetail/GroupDetail.styled.ts +++ b/src/pages/groupDetail/GroupDetail.styled.ts @@ -176,13 +176,14 @@ export const BookDetails = styled.div` font-weight: ${typography.fontWeight.medium}; gap: 20px; color: ${colors.white}; - margin: auto 0; + margin-top: 8px; `; export const BookIntro = styled.div` > p { margin-top: 4px; color: ${colors.grey[200]}; + font-size: ${typography.fontSize['2xs']}; } `; From 4e5a55519b691a1c5835b8506ee1e0d868d5305d Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Fri, 15 Aug 2025 17:19:50 +0900 Subject: [PATCH 27/86] =?UTF-8?q?feat:=20=EA=B8=B0=EB=A1=9D=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=A0=95=EC=9D=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/record/createRecord.ts | 38 ++++++ .../MemoryAddButton/MemoryAddButton.tsx | 29 ++++- src/pages/index.tsx | 2 +- src/pages/recordwrite/RecordWrite.tsx | 112 ++++++++++++------ src/types/record.ts | 20 ++++ 5 files changed, 160 insertions(+), 41 deletions(-) create mode 100644 src/api/record/createRecord.ts create mode 100644 src/types/record.ts diff --git a/src/api/record/createRecord.ts b/src/api/record/createRecord.ts new file mode 100644 index 00000000..4d8cdc46 --- /dev/null +++ b/src/api/record/createRecord.ts @@ -0,0 +1,38 @@ +import { apiClient } from '../index'; +import type { CreateRecordRequest, CreateRecordData, ApiResponse } from '@/types/record'; + +// API 응답 타입 +export type CreateRecordResponse = ApiResponse; + +// 기록 생성 API 함수 +export const createRecord = async (roomId: number, recordData: CreateRecordRequest) => { + const response = await apiClient.post( + `/rooms/${roomId}/record`, + recordData, + ); + return response.data; +}; + +/* +사용 예시: +const recordData: CreateRecordRequest = { + page: 20, + isOverview: false, + content: "맘은 최고의 새버린다." +}; + +try { + const result = await createRecord(1, recordData); + if (result.isSuccess) { + console.log("생성된 기록 ID:", result.data.recordId); + console.log("방 ID:", result.data.roomId); + // 성공 처리 로직 + } else { + console.error("기록 생성 실패:", result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error("API 호출 오류:", error); + // 에러 처리 로직 +} +*/ diff --git a/src/components/memory/MemoryAddButton/MemoryAddButton.tsx b/src/components/memory/MemoryAddButton/MemoryAddButton.tsx index 13527d3b..42abb969 100644 --- a/src/components/memory/MemoryAddButton/MemoryAddButton.tsx +++ b/src/components/memory/MemoryAddButton/MemoryAddButton.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import plusIcon from '../../../assets/memory/plus.svg'; import penIcon from '../../../assets/memory/pen.svg'; import voteIcon from '../../../assets/memory/vote.svg'; @@ -7,6 +7,7 @@ import { AddButton, DropdownContainer, DropdownItem } from './MemoryAddButton.st const MemoryAddButton = () => { const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -33,14 +34,32 @@ const MemoryAddButton = () => { const handleRecordWrite = () => { setIsOpen(false); - navigate('/memory/record/write'); - console.log('기록 작성하기'); + + // roomId가 있는 경우 해당 방의 기록 작성 페이지로 이동 + if (roomId) { + navigate(`/rooms/${roomId}/record/write`); + } else { + // roomId가 없는 경우 기본 기록 작성 페이지로 이동 (또는 에러 처리) + navigate('/memory/record/write/1'); // 임시로 roomId 1 사용 + console.warn('roomId가 없어서 임시 roomId 1을 사용합니다.'); + } + + console.log('기록 작성하기 - roomId:', roomId); }; const handlePollCreate = () => { setIsOpen(false); - navigate('/memory/poll/write'); - console.log('투표 생성하기'); + + // roomId가 있는 경우 해당 방의 투표 생성 페이지로 이동 + if (roomId) { + navigate(`/rooms/${roomId}/poll/write`); + } else { + // roomId가 없는 경우 기본 투표 생성 페이지로 이동 (또는 에러 처리) + navigate('/memory/poll/write/1'); // 임시로 roomId 1 사용 + console.warn('roomId가 없어서 임시 roomId 1을 사용합니다.'); + } + + console.log('투표 생성하기 - roomId:', roomId); }; return ( diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 62347f89..06a15828 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -56,7 +56,7 @@ const Router = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/pages/recordwrite/RecordWrite.tsx b/src/pages/recordwrite/RecordWrite.tsx index 7f8c7490..ba50a6b2 100644 --- a/src/pages/recordwrite/RecordWrite.tsx +++ b/src/pages/recordwrite/RecordWrite.tsx @@ -1,14 +1,17 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import PageRangeSection from '../../components/recordwrite/PageRangeSection'; import RecordContentSection from '../../components/recordwrite/RecordContentSection'; import leftArrow from '../../assets/common/leftArrow.svg'; import { Container } from './RecordWrite.styled'; import type { Record } from '../memory/Memory'; +import { createRecord } from '../../api/record/createRecord'; +import type { CreateRecordRequest } from '../../types/record'; const RecordWrite = () => { const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); const [pageRange, setPageRange] = useState(''); const [content, setContent] = useState(''); const [isOverallEnabled, setIsOverallEnabled] = useState(false); @@ -26,55 +29,94 @@ const RecordWrite = () => { }; const handleCompleteClick = async () => { - if (isSubmitting) return; // 중복 실행 방지 + if (isSubmitting || !roomId) return; // 중복 실행 방지 및 roomId 체크 setIsSubmitting(true); try { - // 페이지 범위 결정: 입력값이 없으면 마지막 기록 페이지 사용 - const finalPageRange = isOverallEnabled - ? undefined + // 페이지 범위 결정: 총평이 아닌 경우 페이지 번호 필요 + const finalPage = isOverallEnabled + ? 0 // 총평인 경우 페이지는 0 : pageRange.trim() !== '' - ? pageRange - : lastRecordedPage.toString(); - - // 새 기록 객체 생성 (업로드 중 상태로) - const newRecord: Record & { isUploading?: boolean } = { - id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // 고유한 ID - user: 'user.01', // TODO: 실제 사용자 이름으로 변경 - userPoints: 132, // TODO: 실제 사용자 포인트로 변경 - content: content, - likeCount: 0, - commentCount: 0, - timeAgo: '12시간 전', - createdAt: new Date(), - type: 'text', - recordType: isOverallEnabled ? 'overall' : 'page', - pageRange: finalPageRange, // 최종 페이지 범위 저장 - isUploading: true, // 업로드 중 표시 + ? parseInt(pageRange.trim()) + : lastRecordedPage; // 입력값이 없으면 마지막 기록 페이지 사용 + + // API 요청 데이터 생성 + const recordData: CreateRecordRequest = { + page: finalPage, + isOverview: isOverallEnabled, + content: content.trim(), }; - console.log('기록 작성 완료', newRecord); - console.log('페이지 범위:', isOverallEnabled ? '전체범위' : `${finalPageRange}p`); - console.log('내용:', content); - console.log('총평 설정:', isOverallEnabled); + console.log('기록 생성 API 호출:', recordData); + + // API 호출 + const response = await createRecord(parseInt(roomId), recordData); + + if (response.isSuccess) { + console.log('기록 생성 성공:', response.data); + + // 임시로 Memory 페이지용 기록 객체 생성 (기존 인터페이스 호환성을 위해) + const newRecord: Record & { isUploading?: boolean } = { + id: response.data.recordId.toString(), + user: 'user.01', // TODO: 실제 사용자 정보로 변경 + userPoints: 132, // TODO: 실제 사용자 포인트로 변경 + content: content, + likeCount: 0, + commentCount: 0, + timeAgo: '방금 전', + createdAt: new Date(), + type: 'text', + recordType: isOverallEnabled ? 'overall' : 'page', + pageRange: isOverallEnabled ? undefined : finalPage.toString(), + isUploading: false, // API 호출이 완료되었으므로 false + }; - // TODO: API 호출하여 서버에 기록 저장 - // await api.createRecord(newRecord); + console.log('기록 작성 완료', newRecord); + console.log('페이지 범위:', isOverallEnabled ? '전체범위' : `${finalPage}p`); + console.log('내용:', content); + console.log('총평 설정:', isOverallEnabled); - // 바로 기록장으로 이동 (업로드 중인 기록과 함께) - navigate('/memory', { - state: { newRecord }, - replace: true, - }); + // 성공 시 기록장으로 이동 + navigate('/memory', { + state: { newRecord }, + replace: true, + }); + } else { + // API 에러 응답 처리 + console.error('기록 생성 실패:', response.message); + alert(`기록 생성에 실패했습니다: ${response.message}`); + setIsSubmitting(false); + } } catch (error) { console.error('기록 저장 실패:', error); + + // 에러 타입에 따른 메시지 처리 + let errorMessage = '기록 저장 중 오류가 발생했습니다.'; + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { + response?: { + status: number; + data?: { message?: string }; + }; + }; + + if (axiosError.response?.data?.message) { + errorMessage = axiosError.response.data.message; + } else if (axiosError.response?.status) { + errorMessage = `서버 오류 (${axiosError.response.status})`; + } + } + + alert(errorMessage); setIsSubmitting(false); } }; - // 총평이 켜져있으면 내용만 필요, 아니면 내용은 필수 (페이지는 기본값 사용 가능) - const isFormValid = content.trim() !== ''; + // 폼 유효성 검사: 내용은 필수, 총평이 아닌 경우 페이지 번호도 확인 + const isFormValid = + content.trim() !== '' && (isOverallEnabled || pageRange.trim() !== '' || lastRecordedPage > 0); return ( <> diff --git a/src/types/record.ts b/src/types/record.ts new file mode 100644 index 00000000..b88b4333 --- /dev/null +++ b/src/types/record.ts @@ -0,0 +1,20 @@ +// 기록 생성 요청 데이터 타입 +export interface CreateRecordRequest { + page: number; // 페이지 번호 + isOverview: boolean; // 총평 여부 + content: string; // 기록 내용 +} + +// 기록 생성 응답 데이터 타입 +export interface CreateRecordData { + recordId: number; // 생성된 기록 ID + roomId: number; // 방 ID +} + +// 공통 API 응답 타입 +export interface ApiResponse { + isSuccess: boolean; + code: number; + message: string; + data: T; +} From 104067330e974efb4de95770b8a3c8cc5901ed63 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Fri, 15 Aug 2025 17:27:35 +0900 Subject: [PATCH 28/86] =?UTF-8?q?refactor:=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MemoryAddButton/MemoryAddButton.tsx | 28 +++++-------------- src/pages/index.tsx | 2 +- src/pages/recordwrite/RecordWrite.tsx | 2 ++ 3 files changed, 10 insertions(+), 22 deletions(-) diff --git a/src/components/memory/MemoryAddButton/MemoryAddButton.tsx b/src/components/memory/MemoryAddButton/MemoryAddButton.tsx index 42abb969..b3abd1f3 100644 --- a/src/components/memory/MemoryAddButton/MemoryAddButton.tsx +++ b/src/components/memory/MemoryAddButton/MemoryAddButton.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import plusIcon from '../../../assets/memory/plus.svg'; import penIcon from '../../../assets/memory/pen.svg'; import voteIcon from '../../../assets/memory/vote.svg'; @@ -7,7 +7,6 @@ import { AddButton, DropdownContainer, DropdownItem } from './MemoryAddButton.st const MemoryAddButton = () => { const navigate = useNavigate(); - const { roomId } = useParams<{ roomId: string }>(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -35,31 +34,18 @@ const MemoryAddButton = () => { const handleRecordWrite = () => { setIsOpen(false); - // roomId가 있는 경우 해당 방의 기록 작성 페이지로 이동 - if (roomId) { - navigate(`/rooms/${roomId}/record/write`); - } else { - // roomId가 없는 경우 기본 기록 작성 페이지로 이동 (또는 에러 처리) - navigate('/memory/record/write/1'); // 임시로 roomId 1 사용 - console.warn('roomId가 없어서 임시 roomId 1을 사용합니다.'); - } + // 임시로 roomId 1을 사용해서 기록 작성 페이지로 이동 + // TODO: 실제로는 현재 선택된 방의 roomId를 사용해야 함 + const roomId = '1'; + navigate(`/memory/record/write/${roomId}`); console.log('기록 작성하기 - roomId:', roomId); }; const handlePollCreate = () => { setIsOpen(false); - - // roomId가 있는 경우 해당 방의 투표 생성 페이지로 이동 - if (roomId) { - navigate(`/rooms/${roomId}/poll/write`); - } else { - // roomId가 없는 경우 기본 투표 생성 페이지로 이동 (또는 에러 처리) - navigate('/memory/poll/write/1'); // 임시로 roomId 1 사용 - console.warn('roomId가 없어서 임시 roomId 1을 사용합니다.'); - } - - console.log('투표 생성하기 - roomId:', roomId); + navigate('/memory/poll/write'); + console.log('투표 생성하기'); }; return ( diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 06a15828..fc5aed46 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -56,7 +56,7 @@ const Router = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/pages/recordwrite/RecordWrite.tsx b/src/pages/recordwrite/RecordWrite.tsx index ba50a6b2..8f37e943 100644 --- a/src/pages/recordwrite/RecordWrite.tsx +++ b/src/pages/recordwrite/RecordWrite.tsx @@ -12,6 +12,7 @@ import type { CreateRecordRequest } from '../../types/record'; const RecordWrite = () => { const navigate = useNavigate(); const { roomId } = useParams<{ roomId: string }>(); + const [pageRange, setPageRange] = useState(''); const [content, setContent] = useState(''); const [isOverallEnabled, setIsOverallEnabled] = useState(false); @@ -49,6 +50,7 @@ const RecordWrite = () => { }; console.log('기록 생성 API 호출:', recordData); + console.log('roomId:', roomId); // API 호출 const response = await createRecord(parseInt(roomId), recordData); From 981d93ef7d0a32bcd818594dacda739199335877 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Fri, 15 Aug 2025 17:34:20 +0900 Subject: [PATCH 29/86] =?UTF-8?q?feat:=20=ED=88=AC=ED=91=9C=20=EC=83=9D?= =?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?=20=EA=B8=B0=EB=A1=9D=20=EC=83=9D=EC=84=B1=EA=B3=BC=20=EB=8F=99?= =?UTF-8?q?=EC=9D=BC=ED=95=9C=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/record/createVote.ts | 39 ++++++ .../MemoryAddButton/MemoryAddButton.tsx | 9 +- src/pages/index.tsx | 2 +- src/pages/pollwrite/PollWrite.tsx | 131 ++++++++++++------ src/types/record.ts | 19 +++ 5 files changed, 157 insertions(+), 43 deletions(-) create mode 100644 src/api/record/createVote.ts diff --git a/src/api/record/createVote.ts b/src/api/record/createVote.ts new file mode 100644 index 00000000..ebaaec73 --- /dev/null +++ b/src/api/record/createVote.ts @@ -0,0 +1,39 @@ +import { apiClient } from '../index'; +import type { CreateVoteRequest, CreateVoteData, ApiResponse } from '@/types/record'; + +// API 응답 타입 +export type CreateVoteResponse = ApiResponse; + +// 투표 생성 API 함수 +export const createVote = async (roomId: number, voteData: CreateVoteRequest) => { + const response = await apiClient.post(`/rooms/${roomId}/vote`, voteData); + return response.data; +}; + +/* +사용 예시: +const voteData: CreateVoteRequest = { + page: 20, + isOverview: true, + content: "맘은 최고의 새버린다. 셰익스피어의?", + voteItemList: [ + { itemName: "네" }, + { itemName: "아니오" } + ] +}; + +try { + const result = await createVote(1, voteData); + if (result.isSuccess) { + console.log("생성된 투표 ID:", result.data.voteId); + console.log("방 ID:", result.data.roomId); + // 성공 처리 로직 + } else { + console.error("투표 생성 실패:", result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error("API 호출 오류:", error); + // 에러 처리 로직 +} +*/ diff --git a/src/components/memory/MemoryAddButton/MemoryAddButton.tsx b/src/components/memory/MemoryAddButton/MemoryAddButton.tsx index b3abd1f3..89d2f042 100644 --- a/src/components/memory/MemoryAddButton/MemoryAddButton.tsx +++ b/src/components/memory/MemoryAddButton/MemoryAddButton.tsx @@ -44,8 +44,13 @@ const MemoryAddButton = () => { const handlePollCreate = () => { setIsOpen(false); - navigate('/memory/poll/write'); - console.log('투표 생성하기'); + + // 임시로 roomId 1을 사용해서 투표 생성 페이지로 이동 + // TODO: 실제로는 현재 선택된 방의 roomId를 사용해야 함 + const roomId = '1'; + + navigate(`/memory/poll/write/${roomId}`); + console.log('투표 생성하기 - roomId:', roomId); }; return ( diff --git a/src/pages/index.tsx b/src/pages/index.tsx index fc5aed46..778ed49b 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -57,7 +57,7 @@ const Router = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/pages/pollwrite/PollWrite.tsx b/src/pages/pollwrite/PollWrite.tsx index 90e73eae..d032bb86 100644 --- a/src/pages/pollwrite/PollWrite.tsx +++ b/src/pages/pollwrite/PollWrite.tsx @@ -1,14 +1,18 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import PageRangeSection from '../../components/recordwrite/PageRangeSection'; import PollCreationSection from '../../components/pollwrite/PollCreationSection'; import leftArrow from '../../assets/common/leftArrow.svg'; import { Container } from './PollWrite.styled'; import type { Record } from '../memory/Memory'; +import { createVote } from '../../api/record/createVote'; +import type { CreateVoteRequest } from '../../types/record'; const PollWrite = () => { const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); + const [pageRange, setPageRange] = useState(''); const [pollContent, setPollContent] = useState(''); const [pollOptions, setPollOptions] = useState(['', '']); @@ -27,61 +31,108 @@ const PollWrite = () => { }; const handleCompleteClick = async () => { - if (isSubmitting) return; // 중복 실행 방지 + if (isSubmitting || !roomId) return; // 중복 실행 방지 및 roomId 체크 setIsSubmitting(true); try { - // 페이지 범위 결정: 입력값이 없으면 마지막 기록 페이지 사용 - const finalPageRange = isOverallEnabled - ? undefined + // 페이지 범위 결정: 총평이 아닌 경우 페이지 번호 필요 + const finalPage = isOverallEnabled + ? 0 // 총평인 경우 페이지는 0 : pageRange.trim() !== '' - ? pageRange - : lastRecordedPage.toString(); + ? parseInt(pageRange.trim()) + : lastRecordedPage; // 입력값이 없으면 마지막 기록 페이지 사용 + + // 투표 옵션 필터링 (빈 옵션 제거) + const validOptions = pollOptions.filter(option => option.trim() !== ''); + + if (validOptions.length < 2) { + alert('투표 옵션은 최소 2개 이상이어야 합니다.'); + setIsSubmitting(false); + return; + } + + // API 요청 데이터 생성 + const voteData: CreateVoteRequest = { + page: finalPage, + isOverview: isOverallEnabled, + content: pollContent.trim(), + voteItemList: validOptions.map(option => ({ itemName: option.trim() })), + }; + + console.log('투표 생성 API 호출:', voteData); + console.log('roomId:', roomId); + + // API 호출 + const response = await createVote(parseInt(roomId), voteData); - // 투표 옵션 생성 - const pollOptionsData = pollOptions - .filter(option => option.trim() !== '') - .map((option, index) => ({ + if (response.isSuccess) { + console.log('투표 생성 성공:', response.data); + + // 투표 옵션 생성 (Memory 페이지 표시용) + const pollOptionsData = validOptions.map((option, index) => ({ id: `${index + 1}.`, text: option.trim(), percentage: index === 0 ? 90 : 10, // 첫 번째 옵션을 90%로 설정 (데모용) isHighest: index === 0, // 첫 번째 옵션이 최고값 })); - // 새 투표 기록 객체 생성 (업로드 중 상태로) - const newPollRecord: Record & { isUploading?: boolean } = { - id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // 고유한 ID - user: 'user.01', // TODO: 실제 사용자 이름으로 변경 - userPoints: 132, // TODO: 실제 사용자 포인트로 변경 - content: pollContent, - likeCount: 0, - commentCount: 0, - timeAgo: '12시간 전', - createdAt: new Date(), - type: 'poll', - recordType: isOverallEnabled ? 'overall' : 'page', - pageRange: finalPageRange, // 최종 페이지 범위 저장 - pollOptions: pollOptionsData, - isUploading: true, // 업로드 중 표시 - }; - - console.log('투표 생성 완료', newPollRecord); - console.log('페이지 범위:', isOverallEnabled ? '전체범위' : `${finalPageRange}p`); - console.log('투표 내용:', pollContent); - console.log('투표 옵션:', pollOptionsData); - console.log('총평 설정:', isOverallEnabled); + // 임시로 Memory 페이지용 투표 객체 생성 (기존 인터페이스 호환성을 위해) + const newPollRecord: Record & { isUploading?: boolean } = { + id: response.data.voteId.toString(), + user: 'user.01', // TODO: 실제 사용자 정보로 변경 + userPoints: 132, // TODO: 실제 사용자 포인트로 변경 + content: pollContent, + likeCount: 0, + commentCount: 0, + timeAgo: '방금 전', + createdAt: new Date(), + type: 'poll', + recordType: isOverallEnabled ? 'overall' : 'page', + pageRange: isOverallEnabled ? undefined : finalPage.toString(), + pollOptions: pollOptionsData, + isUploading: false, // API 호출이 완료되었으므로 false + }; - // TODO: API 호출하여 서버에 투표 저장 - // await api.createPoll(newPollRecord); + console.log('투표 생성 완료', newPollRecord); + console.log('페이지 범위:', isOverallEnabled ? '전체범위' : `${finalPage}p`); + console.log('투표 내용:', pollContent); + console.log('투표 옵션:', validOptions); + console.log('총평 설정:', isOverallEnabled); - // 바로 기록장으로 이동 (업로드 중인 투표와 함께) - navigate('/memory', { - state: { newRecord: newPollRecord }, - replace: true, - }); + // 성공 시 기록장으로 이동 + navigate('/memory', { + state: { newRecord: newPollRecord }, + replace: true, + }); + } else { + // API 에러 응답 처리 + console.error('투표 생성 실패:', response.message); + alert(`투표 생성에 실패했습니다: ${response.message}`); + setIsSubmitting(false); + } } catch (error) { console.error('투표 저장 실패:', error); + + // 에러 타입에 따른 메시지 처리 + let errorMessage = '투표 저장 중 오류가 발생했습니다.'; + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { + response?: { + status: number; + data?: { message?: string }; + }; + }; + + if (axiosError.response?.data?.message) { + errorMessage = axiosError.response.data.message; + } else if (axiosError.response?.status) { + errorMessage = `서버 오류 (${axiosError.response.status})`; + } + } + + alert(errorMessage); setIsSubmitting(false); } }; diff --git a/src/types/record.ts b/src/types/record.ts index b88b4333..e5e421fe 100644 --- a/src/types/record.ts +++ b/src/types/record.ts @@ -11,6 +11,25 @@ export interface CreateRecordData { roomId: number; // 방 ID } +// 투표 아이템 타입 +export interface VoteItem { + itemName: string; // 투표 옵션 이름 +} + +// 투표 생성 요청 데이터 타입 +export interface CreateVoteRequest { + page: number; // 페이지 번호 + isOverview: boolean; // 총평 여부 + content: string; // 투표 내용 + voteItemList: VoteItem[]; // 투표 옵션 리스트 +} + +// 투표 생성 응답 데이터 타입 +export interface CreateVoteData { + voteId: number; // 생성된 투표 ID + roomId: number; // 방 ID +} + // 공통 API 응답 타입 export interface ApiResponse { isSuccess: boolean; From 854225fbd48f80d2ddc7f3d808616e2b64b99b3c Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Fri, 15 Aug 2025 18:03:20 +0900 Subject: [PATCH 30/86] =?UTF-8?q?refactor:=20console.log=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/pollwrite/PollWrite.tsx | 6 ------ src/pages/recordwrite/RecordWrite.tsx | 5 ----- 2 files changed, 11 deletions(-) diff --git a/src/pages/pollwrite/PollWrite.tsx b/src/pages/pollwrite/PollWrite.tsx index d032bb86..87a56c4c 100644 --- a/src/pages/pollwrite/PollWrite.tsx +++ b/src/pages/pollwrite/PollWrite.tsx @@ -94,12 +94,6 @@ const PollWrite = () => { isUploading: false, // API 호출이 완료되었으므로 false }; - console.log('투표 생성 완료', newPollRecord); - console.log('페이지 범위:', isOverallEnabled ? '전체범위' : `${finalPage}p`); - console.log('투표 내용:', pollContent); - console.log('투표 옵션:', validOptions); - console.log('총평 설정:', isOverallEnabled); - // 성공 시 기록장으로 이동 navigate('/memory', { state: { newRecord: newPollRecord }, diff --git a/src/pages/recordwrite/RecordWrite.tsx b/src/pages/recordwrite/RecordWrite.tsx index 8f37e943..38e0dcf9 100644 --- a/src/pages/recordwrite/RecordWrite.tsx +++ b/src/pages/recordwrite/RecordWrite.tsx @@ -74,11 +74,6 @@ const RecordWrite = () => { isUploading: false, // API 호출이 완료되었으므로 false }; - console.log('기록 작성 완료', newRecord); - console.log('페이지 범위:', isOverallEnabled ? '전체범위' : `${finalPage}p`); - console.log('내용:', content); - console.log('총평 설정:', isOverallEnabled); - // 성공 시 기록장으로 이동 navigate('/memory', { state: { newRecord }, From 058d0fc8a372f5004fa74b8f6e661ec1ea661ced Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Fri, 15 Aug 2025 18:25:15 +0900 Subject: [PATCH 31/86] =?UTF-8?q?fix:=20getRecentSearch=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/recentsearch/getRecentSearch.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/api/recentsearch/getRecentSearch.ts b/src/api/recentsearch/getRecentSearch.ts index 3f156880..2d3a9670 100644 --- a/src/api/recentsearch/getRecentSearch.ts +++ b/src/api/recentsearch/getRecentSearch.ts @@ -19,15 +19,7 @@ export interface GetRecentSearchResponse { }; } -// 최근 검색어 조회 API 함수 export const getRecentSearch = async (type: SearchType) => { - const response = await apiClient.get(`/recent-search?type=${type}`); + const response = await apiClient.get(`/recent-searches?type=${type}`); return response.data; }; - -/* -// 사용 예시 -const recentUserSearches = await getRecentSearch('USER'); -const recentRoomSearches = await getRecentSearch('ROOM'); -const recentBookSearches = await getRecentSearch('BOOK'); -*/ From 2909bfaee70c6bbcedc6d6e7da8636f82fd06df2 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Fri, 15 Aug 2025 18:25:32 +0900 Subject: [PATCH 32/86] feat: deleteRecentSearch API --- src/api/recentsearch/deleteRecentSearch.ts | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/api/recentsearch/deleteRecentSearch.ts diff --git a/src/api/recentsearch/deleteRecentSearch.ts b/src/api/recentsearch/deleteRecentSearch.ts new file mode 100644 index 00000000..7983b4b9 --- /dev/null +++ b/src/api/recentsearch/deleteRecentSearch.ts @@ -0,0 +1,24 @@ +import { apiClient } from '../index'; + +// 최근 검색어 삭제 응답 타입 +export interface DeleteRecentSearchResponse { + isSuccess: boolean; + code: number; + message: string; + data: string; +} + +export const deleteRecentSearch = async ( + recentSearchId: number, + userId: number, +): Promise => { + try { + const response = await apiClient.delete( + `/recent-searches/${recentSearchId}?userId=${userId}`, + ); + return response.data; + } catch (error) { + console.error('최근 검색어 삭제 API 오류:', error); + throw error; + } +}; From 4aa1d5e4c21e5e225e511f846a7a87415e8a833d Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Fri, 15 Aug 2025 18:29:15 +0900 Subject: [PATCH 33/86] =?UTF-8?q?feat:=20GroupSearch=20=EC=B5=9C=EA=B7=BC?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=EC=96=B4=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/groupSearch/GroupSearch.tsx | 71 ++++++++++++++++++++------- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/src/pages/groupSearch/GroupSearch.tsx b/src/pages/groupSearch/GroupSearch.tsx index 447ed10d..1dae1663 100644 --- a/src/pages/groupSearch/GroupSearch.tsx +++ b/src/pages/groupSearch/GroupSearch.tsx @@ -3,40 +3,75 @@ import { Modal, Overlay } from '@/components/group/Modal.styles'; import leftArrow from '../../assets/common/leftArrow.svg'; import { useNavigate } from 'react-router-dom'; import SearchBar from '@/components/search/SearchBar'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import RecentSearchTabs from '@/components/search/RecentSearchTabs'; import GroupSearchResult from '@/components/search/GroupSearchResult'; +import { getRecentSearch, type RecentSearchData } from '@/api/recentsearch/getRecentSearch'; +import { deleteRecentSearch } from '@/api/recentsearch/deleteRecentSearch'; const GroupSearch = () => { const navigate = useNavigate(); const [searchTerm, setSearchTerm] = useState(''); const [isSearching, setIsSearching] = useState(false); + const [recentSearches, setRecentSearches] = useState([]); + const [isLoading, setIsLoading] = useState(false); - const [recentSearches, setRecentSearches] = useState([ - '딸기12', - '당근', - '수박245', - '참', - '메론1', - ]); + const fetchRecentSearches = async () => { + try { + setIsLoading(true); + const response = await getRecentSearch('ROOM'); - const handleSearch = (term: string) => { - setIsSearching(true); + if (response.isSuccess) { + setRecentSearches(response.data.recentSearchList); + } else { + console.error('최근 검색어 조회 실패:', response.message); + setRecentSearches([]); + } + } catch (error) { + console.error('최근 검색어 조회 오류:', error); + setRecentSearches([]); + } finally { + setIsLoading(false); + } + }; - setRecentSearches(prev => { - const filtered = prev.filter(t => t !== term); - return [term, ...filtered].slice(0, 5); - }); + useEffect(() => { + fetchRecentSearches(); + }, []); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleSearch = (_term: string) => { + setIsSearching(true); + // 검색 로직만 수행, 최근 검색어는 서버에서 관리 }; - const handleDelete = (recentSearch: string) => { - setRecentSearches(prev => prev.filter(t => t !== recentSearch)); + const handleDelete = async (recentSearchId: number) => { + try { + const userId = 1; // 임시 userId + + const response = await deleteRecentSearch(recentSearchId, userId); + + if (response.isSuccess) { + setRecentSearches(prev => prev.filter(item => item.recentSearchId !== recentSearchId)); + } else { + console.error('최근 검색어 삭제 실패:', response.message); + } + } catch (error) { + console.error('최근 검색어 삭제 오류:', error); + } }; const handleRecentSearchClick = (recentSearch: string) => { setSearchTerm(recentSearch); }; + const handleDeleteWrapper = (searchTerm: string) => { + const recentSearchItem = recentSearches.find(item => item.searchTerm === searchTerm); + if (recentSearchItem) { + handleDelete(recentSearchItem.recentSearchId); + } + }; + const handleBackButton = () => { navigate('/group'); }; @@ -60,8 +95,8 @@ const GroupSearch = () => { ) : ( item.searchTerm)} + handleDelete={handleDeleteWrapper} handleRecentSearchClick={handleRecentSearchClick} > )} From 791a44d9a62f72b1420a93d052113d8ca28c8ef9 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Fri, 15 Aug 2025 18:37:29 +0900 Subject: [PATCH 34/86] =?UTF-8?q?feat:=20Book=20Search=20=EC=B5=9C?= =?UTF-8?q?=EA=B7=BC=20=EA=B2=80=EC=83=89=EC=96=B4=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/search/Search.tsx | 56 ++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/src/pages/search/Search.tsx b/src/pages/search/Search.tsx index e71b882a..99eddef8 100644 --- a/src/pages/search/Search.tsx +++ b/src/pages/search/Search.tsx @@ -10,6 +10,8 @@ import { useEffect, useState, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; import { getSearchBooks, convertToSearchedBooks } from '@/api/books/getSearchBooks'; +import { getRecentSearch, type RecentSearchData } from '@/api/recentsearch/getRecentSearch'; +import { deleteRecentSearch } from '@/api/recentsearch/deleteRecentSearch'; export interface SearchedBook { id: number; @@ -29,9 +31,29 @@ const Search = () => { const [isLoading, setIsLoading] = useState(false); const [isInitialized, setIsInitialized] = useState(false); - const [recentSearches, setRecentSearches] = useState([]); + const [recentSearches, setRecentSearches] = useState([]); const [searchTimeoutId, setSearchTimeoutId] = useState(null); + const fetchRecentSearches = async () => { + try { + const response = await getRecentSearch('BOOK'); + + if (response.isSuccess) { + setRecentSearches(response.data.recentSearchList); + } else { + console.error('최근 검색어 조회 실패:', response.message); + setRecentSearches([]); + } + } catch (error) { + console.error('최근 검색어 조회 오류:', error); + setRecentSearches([]); + } + }; + + useEffect(() => { + fetchRecentSearches(); + }, []); + const handleChange = (value: string) => { setSearchTerm(value); setIsFinalized(false); @@ -96,11 +118,6 @@ const Search = () => { } finally { setIsLoading(false); } - - setRecentSearches(prev => { - const filtered = prev.filter(t => t !== term); - return [term, ...filtered].slice(0, 5); - }); }, []); useEffect(() => { @@ -120,8 +137,27 @@ const Search = () => { } }, [searchTerm]); - const handleDelete = (recentSearch: string) => { - setRecentSearches(prev => prev.filter(t => t !== recentSearch)); + const handleDelete = async (recentSearchId: number) => { + try { + const userId = 1; // 임시 userId + + const response = await deleteRecentSearch(recentSearchId, userId); + + if (response.isSuccess) { + setRecentSearches(prev => prev.filter(item => item.recentSearchId !== recentSearchId)); + } else { + console.error('최근 검색어 삭제 실패:', response.message); + } + } catch (error) { + console.error('최근 검색어 삭제 오류:', error); + } + }; + + const handleDeleteWrapper = (searchTerm: string) => { + const recentSearchItem = recentSearches.find(item => item.searchTerm === searchTerm); + if (recentSearchItem) { + handleDelete(recentSearchItem.recentSearchId); + } }; const handleRecentSearchClick = (recentSearch: string) => { @@ -186,8 +222,8 @@ const Search = () => { ) : ( <> item.searchTerm)} + handleDelete={handleDeleteWrapper} handleRecentSearchClick={handleRecentSearchClick} /> From 210d3056d5482c7a5decddae2e110417a6630728 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Fri, 15 Aug 2025 18:52:00 +0900 Subject: [PATCH 35/86] =?UTF-8?q?feat:=20UserSearch=20=EC=B5=9C=EA=B7=BC?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=EC=96=B4=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/feed/UserSearch.tsx | 61 ++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/src/pages/feed/UserSearch.tsx b/src/pages/feed/UserSearch.tsx index ded4c001..6dc819ee 100644 --- a/src/pages/feed/UserSearch.tsx +++ b/src/pages/feed/UserSearch.tsx @@ -9,6 +9,8 @@ import leftArrow from '../../assets/common/leftArrow.svg'; import { UserSearchResult } from './UserSearchResult'; import { useNavigate } from 'react-router-dom'; import { useUserSearch } from '@/hooks/useUserSearch'; +import { getRecentSearch, type RecentSearchData } from '@/api/recentsearch/getRecentSearch'; +import { deleteRecentSearch } from '@/api/recentsearch/deleteRecentSearch'; const UserSearch = () => { const navigate = useNavigate(); @@ -23,13 +25,27 @@ const UserSearch = () => { isFinalized: isSearched, }); - const [recentSearches, setRecentSearches] = useState([ - '닉네임', - '작가', - '하위', - 'Thip', - '책벌레', - ]); + const [recentSearches, setRecentSearches] = useState([]); + + const fetchRecentSearches = async () => { + try { + const response = await getRecentSearch('USER'); + + if (response.isSuccess) { + setRecentSearches(response.data.recentSearchList); + } else { + console.error('최근 검색어 조회 실패:', response.message); + setRecentSearches([]); + } + } catch (error) { + console.error('최근 검색어 조회 오류:', error); + setRecentSearches([]); + } + }; + + useEffect(() => { + fetchRecentSearches(); + }, []); const handleChange = (value: string) => { setSearchTerm(value); @@ -41,14 +57,29 @@ const UserSearch = () => { if (!term.trim()) return; setIsSearching(true); setIsSearched(true); - setRecentSearches(prev => { - const filtered = prev.filter(t => t !== term); - return [term, ...filtered].slice(0, 5); - }); }; - const handleDelete = (recentSearch: string) => { - setRecentSearches(prev => prev.filter(t => t !== recentSearch)); + const handleDelete = async (recentSearchId: number) => { + try { + const userId = 1; // 임시 userId + + const response = await deleteRecentSearch(recentSearchId, userId); + + if (response.isSuccess) { + setRecentSearches(prev => prev.filter(item => item.recentSearchId !== recentSearchId)); + } else { + console.error('최근 검색어 삭제 실패:', response.message); + } + } catch (error) { + console.error('최근 검색어 삭제 오류:', error); + } + }; + + const handleDeleteWrapper = (searchTerm: string) => { + const recentSearchItem = recentSearches.find(item => item.searchTerm === searchTerm); + if (recentSearchItem) { + handleDelete(recentSearchItem.recentSearchId); + } }; const handleRecentSearchClick = (recentSearch: string) => { @@ -108,8 +139,8 @@ const UserSearch = () => { ) : ( <> item.searchTerm)} + handleDelete={handleDeleteWrapper} handleRecentSearchClick={handleRecentSearchClick} /> From 310352ea1653115884ffa0d2437d7c1858011f6f Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Fri, 15 Aug 2025 19:51:36 +0900 Subject: [PATCH 36/86] =?UTF-8?q?feat:=20=EA=B8=B0=EB=A1=9D=EC=9E=A5=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=97=B0=EB=8F=99=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20-=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/memory/getMemoryPosts.ts | 99 ++++++++ .../MemoryAddButton/MemoryAddButton.tsx | 21 +- src/pages/index.tsx | 2 +- src/pages/memory/Memory.tsx | 237 +++++++++++------- src/pages/pollwrite/PollWrite.tsx | 2 +- src/pages/recordwrite/RecordWrite.tsx | 2 +- src/types/memory.ts | 56 +++++ 7 files changed, 315 insertions(+), 104 deletions(-) create mode 100644 src/api/memory/getMemoryPosts.ts create mode 100644 src/types/memory.ts diff --git a/src/api/memory/getMemoryPosts.ts b/src/api/memory/getMemoryPosts.ts new file mode 100644 index 00000000..acd7f374 --- /dev/null +++ b/src/api/memory/getMemoryPosts.ts @@ -0,0 +1,99 @@ +import { apiClient } from '../index'; +import type { GetMemoryPostsParams, GetMemoryPostsResponse } from '@/types/memory'; + +// 기록장 조회 API 함수 +export const getMemoryPosts = async ( + params: GetMemoryPostsParams, +): Promise => { + const { roomId, ...queryParams } = params; + + // 쿼리 파라미터 생성 + const searchParams = new URLSearchParams(); + + // 기본값 적용 + searchParams.append('type', queryParams.type || 'group'); + + // type이 group인 경우만 sort 파라미터 추가 + if ((queryParams.type || 'group') === 'group' && queryParams.sort) { + searchParams.append('sort', queryParams.sort); + } + + // 페이지 필터 파라미터 + if (queryParams.pageStart !== undefined && queryParams.pageStart !== null) { + searchParams.append('pageStart', queryParams.pageStart.toString()); + } + if (queryParams.pageEnd !== undefined && queryParams.pageEnd !== null) { + searchParams.append('pageEnd', queryParams.pageEnd.toString()); + } + + // 필터 파라미터 + if (queryParams.isOverview !== undefined) { + searchParams.append('isOverview', queryParams.isOverview.toString()); + } + if (queryParams.isPageFilter !== undefined) { + searchParams.append('isPageFilter', queryParams.isPageFilter.toString()); + } + + // 커서 파라미터 + if (queryParams.cursor) { + searchParams.append('cursor', queryParams.cursor); + } + + const url = `/rooms/${roomId}/posts?${searchParams.toString()}`; + + try { + const response = await apiClient.get(url); + return response.data; + } catch (error) { + console.error('기록장 조회 API 오류:', error); + throw error; + } +}; + +/* +사용 예시: + +// 그룹 기록 전체 조회 (기본) +const groupPosts = await getMemoryPosts({ + roomId: 1 +}); + +// 내 기록만 조회 +const myPosts = await getMemoryPosts({ + roomId: 1, + type: 'mine' +}); + +// 그룹 기록 인기순 정렬 +const popularPosts = await getMemoryPosts({ + roomId: 1, + type: 'group', + sort: 'like' +}); + +// 페이지 필터 적용 (10-20페이지) +const pagePosts = await getMemoryPosts({ + roomId: 1, + type: 'group', + pageStart: 10, + pageEnd: 20, + isPageFilter: true +}); + +// 총평 보기 필터 +const overviewPosts = await getMemoryPosts({ + roomId: 1, + type: 'group', + isOverview: true +}); + +// 페이지네이션 (다음 페이지) +const nextPagePosts = await getMemoryPosts({ + roomId: 1, + cursor: 'some-cursor-value' +}); + +console.log('Posts:', groupPosts.data.postList); +console.log('Next cursor:', groupPosts.data.nextCursor); +console.log('Is last page:', groupPosts.data.isLast); +*/ diff --git a/src/components/memory/MemoryAddButton/MemoryAddButton.tsx b/src/components/memory/MemoryAddButton/MemoryAddButton.tsx index 89d2f042..dbdc0eaf 100644 --- a/src/components/memory/MemoryAddButton/MemoryAddButton.tsx +++ b/src/components/memory/MemoryAddButton/MemoryAddButton.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import plusIcon from '../../../assets/memory/plus.svg'; import penIcon from '../../../assets/memory/pen.svg'; import voteIcon from '../../../assets/memory/vote.svg'; @@ -7,6 +7,7 @@ import { AddButton, DropdownContainer, DropdownItem } from './MemoryAddButton.st const MemoryAddButton = () => { const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); // useParams 추가 const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -34,23 +35,21 @@ const MemoryAddButton = () => { const handleRecordWrite = () => { setIsOpen(false); - // 임시로 roomId 1을 사용해서 기록 작성 페이지로 이동 - // TODO: 실제로는 현재 선택된 방의 roomId를 사용해야 함 - const roomId = '1'; + // URL에서 roomId를 가져오거나 기본값 1 사용 + const currentRoomId = roomId || '1'; - navigate(`/memory/record/write/${roomId}`); - console.log('기록 작성하기 - roomId:', roomId); + navigate(`/memory/record/write/${currentRoomId}`); + console.log('기록 작성하기 - roomId:', currentRoomId); }; const handlePollCreate = () => { setIsOpen(false); - // 임시로 roomId 1을 사용해서 투표 생성 페이지로 이동 - // TODO: 실제로는 현재 선택된 방의 roomId를 사용해야 함 - const roomId = '1'; + // URL에서 roomId를 가져오거나 기본값 1 사용 + const currentRoomId = roomId || '1'; - navigate(`/memory/poll/write/${roomId}`); - console.log('투표 생성하기 - roomId:', roomId); + navigate(`/memory/poll/write/${currentRoomId}`); + console.log('투표 생성하기 - roomId:', currentRoomId); }; return ( diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 778ed49b..ffd5b577 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -55,7 +55,7 @@ const Router = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/pages/memory/Memory.tsx b/src/pages/memory/Memory.tsx index 8c67bfc5..39b1f131 100644 --- a/src/pages/memory/Memory.tsx +++ b/src/pages/memory/Memory.tsx @@ -1,11 +1,13 @@ -import React, { useState, useMemo, useCallback } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; +import React, { useState, useMemo, useCallback, useEffect } from 'react'; +import { useNavigate, useLocation, useParams } from 'react-router-dom'; import type { SortType } from '../../components/memory/SortDropdown'; import MemoryHeader from '../../components/memory/MemoryHeader/MemoryHeader'; import MemoryContent from '../../components/memory/MemoryContent/MemoryContent'; import MemoryAddButton from '../../components/memory/MemoryAddButton/MemoryAddButton'; import Snackbar from '../../components/common/Modal/Snackbar'; import { Container, FixedHeader, ScrollableContent, FloatingElements } from './Memory.styled'; +import { getMemoryPosts } from '../../api/memory/getMemoryPosts'; +import type { Post } from '../../types/memory'; export type RecordType = 'group' | 'my'; export type FilterType = 'page' | 'overall'; @@ -32,6 +34,29 @@ export interface PollOption { isHighest?: boolean; } +// API 포스트를 기존 Record 타입으로 변환하는 함수 +const convertPostToRecord = (post: Post): Record => { + return { + id: post.postId.toString(), + user: post.nickName, + userPoints: 132, // TODO: 실제 포인트 데이터가 없어서 임시값 + content: post.content, + likeCount: post.likeCount, + commentCount: post.commentCount, + timeAgo: post.postDate, + createdAt: new Date(), // TODO: 실제 생성 날짜로 변경 필요 + type: post.postType === 'VOTE' ? 'poll' : 'text', + recordType: post.isOverview ? 'overall' : 'page', + pageRange: post.isOverview ? undefined : post.page.toString(), + pollOptions: post.voteItems.map((item, index) => ({ + id: item.voteItemId.toString(), + text: item.itemName, + percentage: item.percentage, + isHighest: index === 0, // 첫 번째 아이템을 최고값으로 임시 설정 + })), + }; +}; + const addRecordIfNotExists = (prevRecords: Record[], newRecord: Record) => { const exists = prevRecords.some(record => record.id === newRecord.id); if (exists) { @@ -43,6 +68,8 @@ const addRecordIfNotExists = (prevRecords: Record[], newRecord: Record) => { const Memory = () => { const navigate = useNavigate(); const location = useLocation(); + const { roomId } = useParams<{ roomId: string }>(); // URL에서 roomId 가져오기 + const [activeTab, setActiveTab] = useState('group'); const [activeFilter, setActiveFilter] = useState(null); const [selectedSort, setSelectedSort] = useState('latest'); @@ -52,59 +79,98 @@ const Memory = () => { null, ); + // API 관련 상태 + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [nextCursor, setNextCursor] = useState(null); + const [isLast, setIsLast] = useState(false); + // 업로드 프로그레스 상태 const [showUploadProgress, setShowUploadProgress] = useState(false); - // 개발용 상태 - 기록 유무 전환 + // 개발용 상태 - 기록 유무 전환 (API 연동 후에는 실제 데이터 기반으로 변경) const [hasRecords, setHasRecords] = useState(true); // 내 기록들을 별도로 관리 const [myRecords, setMyRecords] = useState([]); // 그룹 기록들을 별도로 관리 (내가 작성한 것도 포함) - const [groupRecords, setGroupRecords] = useState([ - { - id: '1', - user: 'user.01', - userPoints: 132, - content: - '공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다. 공백 포함 글자 입력입니다.', - likeCount: 123, - commentCount: 123, - timeAgo: '12시간 전', - createdAt: new Date('2024-01-15T12:00:00'), - type: 'text', - recordType: 'page', - pageRange: '132', - }, - { - id: '2', - user: 'user.01', - userPoints: 132, - content: '공백 포함 글자 입력입니다.', - likeCount: 123, - commentCount: 123, - timeAgo: '12시간 전', - createdAt: new Date('2024-01-15T16:00:00'), - type: 'poll', - recordType: 'page', - pageRange: '132', - pollOptions: [ - { - id: '1.', - text: '김땡땡', - percentage: 90, - isHighest: true, - }, - { - id: '2.', - text: '김땡땡', - percentage: 10, - isHighest: false, - }, - ], + const [groupRecords, setGroupRecords] = useState([]); + + // API 데이터 로드 + const loadMemoryPosts = useCallback( + async (isRefresh = false) => { + if (!roomId) return; + + setIsLoading(true); + setError(null); + + try { + // API 파라미터 구성 + const apiParams = { + roomId: parseInt(roomId), + type: activeTab === 'my' ? ('mine' as const) : ('group' as const), + sort: + activeTab === 'group' + ? ((selectedSort === 'latest' + ? 'latest' + : selectedSort === 'popular' + ? 'like' + : 'comment') as const) + : undefined, + pageStart: selectedPageRange ? selectedPageRange.start : null, + pageEnd: selectedPageRange ? selectedPageRange.end : null, + isOverview: activeFilter === 'overall' ? true : false, + isPageFilter: activeFilter === 'page' ? true : false, + cursor: isRefresh ? null : nextCursor, + }; + + console.log('API 호출 파라미터:', apiParams); + + const response = await getMemoryPosts(apiParams); + + if (response.isSuccess) { + const convertedRecords = response.data.postList.map(convertPostToRecord); + + if (activeTab === 'my') { + if (isRefresh) { + setMyRecords(convertedRecords); + } else { + setMyRecords(prev => [...prev, ...convertedRecords]); + } + } else { + if (isRefresh) { + setGroupRecords(convertedRecords); + } else { + setGroupRecords(prev => [...prev, ...convertedRecords]); + } + } + + setNextCursor(response.data.nextCursor); + setIsLast(response.data.isLast); + setHasRecords(convertedRecords.length > 0); + + console.log('API 응답 성공:', response.data); + } else { + setError(response.message); + console.error('API 응답 실패:', response.message); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : '기록을 불러오는 중 오류가 발생했습니다.'; + setError(errorMessage); + console.error('API 호출 오류:', error); + } finally { + setIsLoading(false); + } }, - ]); + [roomId, activeTab, selectedSort, selectedPageRange, activeFilter, nextCursor], + ); + + // 컴포넌트 마운트 시 및 필터/탭 변경 시 데이터 로드 + useEffect(() => { + loadMemoryPosts(true); // 새로운 필터/탭이므로 refresh + }, [roomId, activeTab, selectedSort, selectedPageRange, activeFilter]); // location.state에서 새로 추가된 기록 확인 React.useEffect(() => { @@ -131,7 +197,7 @@ const Memory = () => { // state 즉시 초기화 (중복 추가 방지) navigate(location.pathname, { replace: true, state: null }); } - }, [location.pathname]); + }, [location.pathname, navigate]); // 업로드 완료 처리 const handleUploadComplete = useCallback(() => { @@ -147,55 +213,24 @@ const Memory = () => { } }, [activeTab, myRecords, hasRecords, groupRecords]); - // 정렬된 기록 목록 + // 정렬된 기록 목록 (API에서 이미 정렬되어 오므로 그대로 사용) const sortedRecords = useMemo(() => { - const recordsToSort = [...currentRecords]; - - switch (selectedSort) { - case 'latest': - return recordsToSort.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - case 'popular': - return recordsToSort.sort((a, b) => b.likeCount - a.likeCount); - case 'comments': - return recordsToSort.sort((a, b) => b.commentCount - a.commentCount); - default: - return recordsToSort; - } - }, [currentRecords, selectedSort]); + return currentRecords; + }, [currentRecords]); - // 필터링된 기록 목록 + // 필터링된 기록 목록 (API에서 이미 필터링되어 오므로 그대로 사용) const filteredRecords = useMemo(() => { - if (activeTab === 'my') { - // 내 기록에서는 필터링 없이 모든 기록 표시 - return sortedRecords; - } - - if (!activeFilter) return sortedRecords; - - switch (activeFilter) { - case 'page': - if (selectedPageRange) { - // 페이지 범위가 선택된 경우, 해당 범위 내의 기록만 필터링 - return sortedRecords.filter(record => { - if (record.recordType !== 'page' || !record.pageRange) return false; - const recordPage = parseInt(record.pageRange); - return recordPage >= selectedPageRange.start && recordPage <= selectedPageRange.end; - }); - } else { - // 페이지별 보기: 총평이 아닌 기록만 표시 - return sortedRecords.filter(record => record.recordType === 'page'); - } - case 'overall': - // 총평 보기: 총평 기록만 표시 - return sortedRecords.filter(record => record.recordType === 'overall'); - default: - return sortedRecords; - } - }, [activeTab, activeFilter, selectedPageRange, sortedRecords]); + return sortedRecords; + }, [sortedRecords]); const handleBackClick = useCallback(() => { - navigate('/group'); - }, [navigate]); + // roomId가 있으면 해당 방으로, 없으면 그룹 페이지로 + if (roomId) { + navigate(`/rooms/${roomId}`); // 또는 적절한 방 상세 페이지 + } else { + navigate('/group'); + } + }, [navigate, roomId]); const handleTabChange = useCallback((tab: RecordType) => { setActiveTab(tab); @@ -235,6 +270,28 @@ const Memory = () => { setHasRecords(!hasRecords); }, [hasRecords]); + const handleLoadMore = useCallback(() => { + if (!isLast && !isLoading) { + loadMemoryPosts(false); + } + }, [isLast, isLoading, loadMemoryPosts]); + + if (error) { + return ( + + + + +
+ 오류가 발생했습니다: {error} + +
+
+ ); + } + return ( diff --git a/src/pages/pollwrite/PollWrite.tsx b/src/pages/pollwrite/PollWrite.tsx index 87a56c4c..44b67348 100644 --- a/src/pages/pollwrite/PollWrite.tsx +++ b/src/pages/pollwrite/PollWrite.tsx @@ -95,7 +95,7 @@ const PollWrite = () => { }; // 성공 시 기록장으로 이동 - navigate('/memory', { + navigate(`/rooms/${roomId}/memory`, { state: { newRecord: newPollRecord }, replace: true, }); diff --git a/src/pages/recordwrite/RecordWrite.tsx b/src/pages/recordwrite/RecordWrite.tsx index 38e0dcf9..d147f694 100644 --- a/src/pages/recordwrite/RecordWrite.tsx +++ b/src/pages/recordwrite/RecordWrite.tsx @@ -75,7 +75,7 @@ const RecordWrite = () => { }; // 성공 시 기록장으로 이동 - navigate('/memory', { + navigate(`/rooms/${roomId}/memory`, { state: { newRecord }, replace: true, }); diff --git a/src/types/memory.ts b/src/types/memory.ts new file mode 100644 index 00000000..ac5ebbf9 --- /dev/null +++ b/src/types/memory.ts @@ -0,0 +1,56 @@ +// 투표 아이템 타입 +export interface VoteItem { + voteItemId: number; + itemName: string; + percentage: number; + isVoted: boolean; +} + +// 기록/투표 포스트 타입 +export interface Post { + postId: number; + postDate: string; + postType: 'RECORD' | 'VOTE'; + page: number; + userId: number; + nickName: string; + profileImageUrl: string; + content: string; + likeCount: number; + commentCount: number; + isOverview: boolean; + isLiked: boolean; + isWriter: boolean; + isLocked: boolean; // 블러 처리 여부 + voteItems: VoteItem[]; +} + +// 기록장 조회 요청 파라미터 타입 +export interface GetMemoryPostsParams { + roomId: number; + type?: 'group' | 'mine'; // default: group + sort?: 'latest' | 'like' | 'comment'; // default: latest (type이 group인 경우만) + pageStart?: number | null; // 페이지 필터 시작 (default: null) + pageEnd?: number | null; // 페이지 필터 끝 (default: null) + isOverview?: boolean; // 총평 보기 필터 (default: false) + isPageFilter?: boolean; // 페이지 보기 필터 (default: false) + cursor?: string | null; // 페이지네이션 커서 +} + +// 기록장 조회 응답 데이터 타입 +export interface MemoryPostsData { + postList: Post[]; + roomId: number; + isOverviewEnabled: boolean; + isbn: string; + nextCursor: string | null; + isLast: boolean; +} + +// API 응답 타입 +export interface GetMemoryPostsResponse { + isSuccess: boolean; + code: number; + message: string; + data: MemoryPostsData; +} From 94b1f5ddfea1a2be082fea67f40bb6927320fd74 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Fri, 15 Aug 2025 20:59:03 +0900 Subject: [PATCH 37/86] =?UTF-8?q?fix:=20Memory=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20TypeScript=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20Memory=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/index.tsx | 1 + src/pages/memory/Memory.tsx | 181 ++++++++++++++++-------------------- 2 files changed, 81 insertions(+), 101 deletions(-) diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ffd5b577..21429b44 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -55,6 +55,7 @@ const Router = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/memory/Memory.tsx b/src/pages/memory/Memory.tsx index 39b1f131..5db3181e 100644 --- a/src/pages/memory/Memory.tsx +++ b/src/pages/memory/Memory.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { useNavigate, useLocation, useParams } from 'react-router-dom'; import type { SortType } from '../../components/memory/SortDropdown'; import MemoryHeader from '../../components/memory/MemoryHeader/MemoryHeader'; @@ -39,12 +39,12 @@ const convertPostToRecord = (post: Post): Record => { return { id: post.postId.toString(), user: post.nickName, - userPoints: 132, // TODO: 실제 포인트 데이터가 없어서 임시값 + userPoints: 132, content: post.content, likeCount: post.likeCount, commentCount: post.commentCount, timeAgo: post.postDate, - createdAt: new Date(), // TODO: 실제 생성 날짜로 변경 필요 + createdAt: new Date(), type: post.postType === 'VOTE' ? 'poll' : 'text', recordType: post.isOverview ? 'overall' : 'page', pageRange: post.isOverview ? undefined : post.page.toString(), @@ -52,7 +52,7 @@ const convertPostToRecord = (post: Post): Record => { id: item.voteItemId.toString(), text: item.itemName, percentage: item.percentage, - isHighest: index === 0, // 첫 번째 아이템을 최고값으로 임시 설정 + isHighest: index === 0, })), }; }; @@ -68,7 +68,7 @@ const addRecordIfNotExists = (prevRecords: Record[], newRecord: Record) => { const Memory = () => { const navigate = useNavigate(); const location = useLocation(); - const { roomId } = useParams<{ roomId: string }>(); // URL에서 roomId 가져오기 + const { roomId } = useParams<{ roomId: string }>(); const [activeTab, setActiveTab] = useState('group'); const [activeFilter, setActiveFilter] = useState(null); @@ -80,124 +80,112 @@ const Memory = () => { ); // API 관련 상태 - const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [nextCursor, setNextCursor] = useState(null); - const [isLast, setIsLast] = useState(false); // 업로드 프로그레스 상태 const [showUploadProgress, setShowUploadProgress] = useState(false); - // 개발용 상태 - 기록 유무 전환 (API 연동 후에는 실제 데이터 기반으로 변경) + // 개발용 상태 - 기록 유무 전환 const [hasRecords, setHasRecords] = useState(true); // 내 기록들을 별도로 관리 const [myRecords, setMyRecords] = useState([]); - // 그룹 기록들을 별도로 관리 (내가 작성한 것도 포함) + // 그룹 기록들을 별도로 관리 const [groupRecords, setGroupRecords] = useState([]); // API 데이터 로드 - const loadMemoryPosts = useCallback( - async (isRefresh = false) => { - if (!roomId) return; - - setIsLoading(true); - setError(null); - - try { - // API 파라미터 구성 - const apiParams = { - roomId: parseInt(roomId), - type: activeTab === 'my' ? ('mine' as const) : ('group' as const), - sort: - activeTab === 'group' - ? ((selectedSort === 'latest' - ? 'latest' - : selectedSort === 'popular' - ? 'like' - : 'comment') as const) - : undefined, - pageStart: selectedPageRange ? selectedPageRange.start : null, - pageEnd: selectedPageRange ? selectedPageRange.end : null, - isOverview: activeFilter === 'overall' ? true : false, - isPageFilter: activeFilter === 'page' ? true : false, - cursor: isRefresh ? null : nextCursor, - }; - - console.log('API 호출 파라미터:', apiParams); - - const response = await getMemoryPosts(apiParams); - - if (response.isSuccess) { - const convertedRecords = response.data.postList.map(convertPostToRecord); - - if (activeTab === 'my') { - if (isRefresh) { - setMyRecords(convertedRecords); - } else { - setMyRecords(prev => [...prev, ...convertedRecords]); - } - } else { - if (isRefresh) { - setGroupRecords(convertedRecords); - } else { - setGroupRecords(prev => [...prev, ...convertedRecords]); - } - } - - setNextCursor(response.data.nextCursor); - setIsLast(response.data.isLast); - setHasRecords(convertedRecords.length > 0); - - console.log('API 응답 성공:', response.data); + const loadMemoryPosts = useCallback(async () => { + // roomId가 없으면 기본값 1 사용 또는 API 호출 스킵 + const currentRoomId = roomId || '1'; + + setError(null); + + try { + // 정렬 타입 변환 + let sortType: 'latest' | 'like' | 'comment' | undefined = undefined; + if (activeTab === 'group') { + if (selectedSort === 'latest') sortType = 'latest'; + else if (selectedSort === 'popular') sortType = 'like'; + else if (selectedSort === 'comments') sortType = 'comment'; + } + + // API 타입에 맞는 파라미터 구성 + const requestParams: { + roomId: number; + type: 'group' | 'mine'; + sort?: 'latest' | 'like' | 'comment'; + pageStart?: number | null; + pageEnd?: number | null; + isOverview?: boolean; + isPageFilter?: boolean; + cursor?: string | null; + } = { + roomId: parseInt(currentRoomId), + type: activeTab === 'my' ? 'mine' : 'group', + pageStart: selectedPageRange ? selectedPageRange.start : null, + pageEnd: selectedPageRange ? selectedPageRange.end : null, + isOverview: activeFilter === 'overall' ? true : false, + isPageFilter: activeFilter === 'page' ? true : false, + cursor: null, + }; + + // sort는 group 타입일 때만 추가 + if (activeTab === 'group' && sortType) { + requestParams.sort = sortType; + } + + console.log('API 호출 파라미터:', requestParams); + + const response = await getMemoryPosts(requestParams); + + if (response.isSuccess) { + const convertedRecords = response.data.postList.map(convertPostToRecord); + + if (activeTab === 'my') { + setMyRecords(convertedRecords); } else { - setError(response.message); - console.error('API 응답 실패:', response.message); + setGroupRecords(convertedRecords); } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : '기록을 불러오는 중 오류가 발생했습니다.'; - setError(errorMessage); - console.error('API 호출 오류:', error); - } finally { - setIsLoading(false); + + setHasRecords(convertedRecords.length > 0); + + console.log('API 응답 성공:', response.data); + } else { + setError(response.message); + console.error('API 응답 실패:', response.message); } - }, - [roomId, activeTab, selectedSort, selectedPageRange, activeFilter, nextCursor], - ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : '기록을 불러오는 중 오류가 발생했습니다.'; + setError(errorMessage); + console.error('API 호출 오류:', error); + } + }, [roomId, activeTab, selectedSort, selectedPageRange, activeFilter]); // 컴포넌트 마운트 시 및 필터/탭 변경 시 데이터 로드 useEffect(() => { - loadMemoryPosts(true); // 새로운 필터/탭이므로 refresh - }, [roomId, activeTab, selectedSort, selectedPageRange, activeFilter]); + loadMemoryPosts(); + }, [loadMemoryPosts]); // location.state에서 새로 추가된 기록 확인 - React.useEffect(() => { + useEffect(() => { if (location.state?.newRecord) { const { isUploading, ...recordData } = location.state.newRecord as Record & { isUploading?: boolean; }; if (isUploading) { - // 업로드 프로그레스 시작 setShowUploadProgress(true); - const finalRecord: Record = recordData; - - // 내 기록에 추가 setMyRecords(prev => addRecordIfNotExists(prev, finalRecord)); - // 그룹 기록에도 추가 setGroupRecords(prev => addRecordIfNotExists(prev, finalRecord)); } - // 내 기록 탭으로 이동 setActiveTab('my'); - - // state 즉시 초기화 (중복 추가 방지) navigate(location.pathname, { replace: true, state: null }); } - }, [location.pathname, navigate]); + }, [location.state?.newRecord, location.pathname, navigate]); // 업로드 완료 처리 const handleUploadComplete = useCallback(() => { @@ -213,20 +201,19 @@ const Memory = () => { } }, [activeTab, myRecords, hasRecords, groupRecords]); - // 정렬된 기록 목록 (API에서 이미 정렬되어 오므로 그대로 사용) + // 정렬된 기록 목록 const sortedRecords = useMemo(() => { return currentRecords; }, [currentRecords]); - // 필터링된 기록 목록 (API에서 이미 필터링되어 오므로 그대로 사용) + // 필터링된 기록 목록 const filteredRecords = useMemo(() => { return sortedRecords; }, [sortedRecords]); const handleBackClick = useCallback(() => { - // roomId가 있으면 해당 방으로, 없으면 그룹 페이지로 if (roomId) { - navigate(`/rooms/${roomId}`); // 또는 적절한 방 상세 페이지 + navigate(`/rooms/${roomId}`); } else { navigate('/group'); } @@ -234,7 +221,6 @@ const Memory = () => { const handleTabChange = useCallback((tab: RecordType) => { setActiveTab(tab); - // 탭 변경 시 필터 초기화 setActiveFilter(null); setSelectedPageRange(null); }, []); @@ -242,7 +228,6 @@ const Memory = () => { const handleFilterChange = useCallback( (filter: FilterType) => { if (activeFilter === filter) { - // 같은 필터를 다시 클릭하면 해제 setActiveFilter(null); setSelectedPageRange(null); } else { @@ -263,19 +248,13 @@ const Memory = () => { const handlePageRangeSet = useCallback((range: { start: number; end: number }) => { setSelectedPageRange(range); - setActiveFilter('page'); // 페이지 범위 설정 시 페이지별 보기로 자동 변경 + setActiveFilter('page'); }, []); const handleToggleRecords = useCallback(() => { setHasRecords(!hasRecords); }, [hasRecords]); - const handleLoadMore = useCallback(() => { - if (!isLast && !isLoading) { - loadMemoryPosts(false); - } - }, [isLast, isLoading, loadMemoryPosts]); - if (error) { return ( @@ -284,7 +263,7 @@ const Memory = () => {
오류가 발생했습니다: {error} -
From c88b4a088062998ef97e2b8db586eaafecdd5828 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Fri, 15 Aug 2025 21:59:00 +0900 Subject: [PATCH 38/86] =?UTF-8?q?feat:=20=EA=B8=B0=EB=A1=9D=20=EB=B0=8F=20?= =?UTF-8?q?=ED=88=AC=ED=91=9C=20=EC=82=AD=EC=A0=9C=20API=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/record/deleteRecord.ts | 43 ++++++++++++++++++++++++++++++++++ src/api/record/deleteVote.ts | 38 ++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/api/record/deleteRecord.ts create mode 100644 src/api/record/deleteVote.ts diff --git a/src/api/record/deleteRecord.ts b/src/api/record/deleteRecord.ts new file mode 100644 index 00000000..5c8f5502 --- /dev/null +++ b/src/api/record/deleteRecord.ts @@ -0,0 +1,43 @@ +import { apiClient } from '../index'; +import type { ApiResponse } from '@/types/record'; + +// 기록 삭제 응답 데이터 타입 +export interface DeleteRecordData { + roomId: number; // 삭제된 기록이 속한 방 ID +} + +// API 응답 타입 +export type DeleteRecordResponse = ApiResponse; + +// 기록 삭제 API 함수 +export const deleteRecord = async ( + roomId: number, + recordId: number, +): Promise => { + try { + const response = await apiClient.delete( + `/rooms/${roomId}/record/${recordId}`, + ); + return response.data; + } catch (error) { + console.error('기록 삭제 API 오류:', error); + throw error; + } +}; + +/* +사용 예시: +try { + const result = await deleteRecord(1, 123); + if (result.isSuccess) { + console.log("기록 삭제 성공:", result.data.roomId); + // 성공 처리 로직 (예: 목록에서 제거, 성공 메시지 표시) + } else { + console.error("기록 삭제 실패:", result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error("API 호출 오류:", error); + // 에러 처리 로직 +} +*/ diff --git a/src/api/record/deleteVote.ts b/src/api/record/deleteVote.ts new file mode 100644 index 00000000..9ffd91c8 --- /dev/null +++ b/src/api/record/deleteVote.ts @@ -0,0 +1,38 @@ +import { apiClient } from '../index'; +import type { ApiResponse } from '@/types/record'; + +// 투표 삭제 응답 데이터 타입 +export interface DeleteVoteData { + roomId: number; // 삭제된 투표가 속한 방 ID +} + +// API 응답 타입 +export type DeleteVoteResponse = ApiResponse; + +// 투표 삭제 API 함수 +export const deleteVote = async (roomId: number, voteId: number): Promise => { + try { + const response = await apiClient.delete(`/rooms/${roomId}/vote/${voteId}`); + return response.data; + } catch (error) { + console.error('투표 삭제 API 오류:', error); + throw error; + } +}; + +/* +사용 예시: +try { + const result = await deleteVote(1, 456); + if (result.isSuccess) { + console.log("투표 삭제 성공:", result.data.roomId); + // 성공 처리 로직 (예: 목록에서 제거, 성공 메시지 표시) + } else { + console.error("투표 삭제 실패:", result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error("API 호출 오류:", error); + // 에러 처리 로직 +} +*/ From f8596cbeff88de92d7a31c4f3b71466a17510565 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Fri, 15 Aug 2025 22:08:33 +0900 Subject: [PATCH 39/86] =?UTF-8?q?refactor:=20=EB=82=B4=EA=B0=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=ED=95=9C=20=EA=B8=B0=EB=A1=9D=EC=9D=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=AC=EB=B6=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/memory/Memory.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/memory/Memory.tsx b/src/pages/memory/Memory.tsx index 5db3181e..aeb6de6a 100644 --- a/src/pages/memory/Memory.tsx +++ b/src/pages/memory/Memory.tsx @@ -25,6 +25,7 @@ export interface Record { recordType?: 'page' | 'overall'; pollOptions?: PollOption[]; pageRange?: string; + isWriter?: boolean; } export interface PollOption { @@ -48,6 +49,7 @@ const convertPostToRecord = (post: Post): Record => { type: post.postType === 'VOTE' ? 'poll' : 'text', recordType: post.isOverview ? 'overall' : 'page', pageRange: post.isOverview ? undefined : post.page.toString(), + isWriter: post.isWriter, pollOptions: post.voteItems.map((item, index) => ({ id: item.voteItemId.toString(), text: item.itemName, From 2e13a7e71090542f4aa7359d5a456cb99ca1abc3 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Fri, 15 Aug 2025 22:13:09 +0900 Subject: [PATCH 40/86] =?UTF-8?q?feat:=20RecordItem=EC=97=90=20=EA=B8=B8?= =?UTF-8?q?=EA=B2=8C=20=EB=88=84=EB=A5=B4=EA=B8=B0=20=EB=8D=94=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EB=A9=94=EB=89=B4=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../memory/RecordItem/RecordItem.tsx | 166 +++++++++++++++++- 1 file changed, 164 insertions(+), 2 deletions(-) diff --git a/src/components/memory/RecordItem/RecordItem.tsx b/src/components/memory/RecordItem/RecordItem.tsx index 8b7bb27f..1b516812 100644 --- a/src/components/memory/RecordItem/RecordItem.tsx +++ b/src/components/memory/RecordItem/RecordItem.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import { useState, useRef, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; import type { Record } from '../../../pages/memory/Memory'; import TextRecord from './TextRecord'; import PollRecord from './PollRecord'; @@ -17,6 +18,9 @@ import { ActionSection, ActionButton, } from './RecordItem.styled'; +import { usePopupActions } from '@/hooks/usePopupActions'; +import { deleteRecord } from '@/api/record/deleteRecord'; +import { deleteVote } from '@/api/record/deleteVote'; interface RecordItemProps { record: Record; @@ -24,6 +28,10 @@ interface RecordItemProps { } const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { + const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); + const { openMoreMenu, openConfirm, openSnackbar } = usePopupActions(); + const { user, content, @@ -34,12 +42,22 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { pollOptions, pageRange, recordType, + isWriter, } = record; // 좋아요 상태 관리 const [isLiked, setIsLiked] = useState(false); const [currentLikeCount, setCurrentLikeCount] = useState(likeCount); + // 길게 누르기 상태 관리 + const [isPressed, setIsPressed] = useState(false); + const longPressTimer = useRef(null); + const pressStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const hasTriggeredLongPress = useRef(false); + + // API에서 받은 isWriter 속성으로 내 기록인지 판단 + const isMyRecord = isWriter ?? false; + const handleLikeClick = () => { if (isLiked) { // 좋아요 취소 @@ -62,8 +80,152 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { return '0p'; // 기본값 }; + const handleEdit = useCallback(() => { + const currentRoomId = roomId || '1'; + + if (type === 'poll') { + navigate(`/memory/poll/edit/${currentRoomId}/${record.id}`); + } else { + navigate(`/memory/record/edit/${currentRoomId}/${record.id}`); + } + }, [roomId, type, record.id, navigate]); + + const handleDeleteConfirm = useCallback(() => { + const recordTypeName = type === 'poll' ? '투표' : '기록'; + + openConfirm({ + title: `${recordTypeName}을 삭제하시겠어요?`, + disc: `삭제된 ${recordTypeName}은 복구할 수 없습니다.`, + onConfirm: () => handleDelete(), + }); + }, [type, openConfirm]); + + const handleDelete = useCallback(async () => { + const currentRoomId = roomId || '1'; + const recordId = parseInt(record.id); + + try { + let response; + + if (type === 'poll') { + response = await deleteVote(parseInt(currentRoomId), recordId); + } else { + response = await deleteRecord(parseInt(currentRoomId), recordId); + } + + if (response.isSuccess) { + const recordTypeName = type === 'poll' ? '투표' : '기록'; + openSnackbar({ + message: `${recordTypeName}가 삭제되었습니다.`, + variant: 'top', + onClose: () => {}, + }); + + // TODO: 목록에서 해당 기록 제거 (부모 컴포넌트 업데이트 필요) + // 현재는 페이지 새로고침으로 임시 처리 + window.location.reload(); + } else { + openSnackbar({ + message: '삭제에 실패했습니다. 다시 시도해주세요.', + variant: 'top', + onClose: () => {}, + }); + } + } catch (error) { + console.error('삭제 중 오류 발생:', error); + openSnackbar({ + message: '삭제 중 오류가 발생했습니다.', + variant: 'top', + onClose: () => {}, + }); + } + }, [roomId, record.id, type, openSnackbar]); + + const handleReport = useCallback(() => { + openSnackbar({ + message: '신고가 접수되었습니다.', + variant: 'top', + onClose: () => {}, + }); + }, [openSnackbar]); + + const handleLongPress = useCallback(() => { + if (isMyRecord) { + // 내 기록: 수정하기, 삭제하기 (기존 MoreMenuProps 타입에 맞게 수정) + openMoreMenu({ + onEdit: handleEdit, + onDelete: handleDeleteConfirm, + onClose: () => {}, + }); + } else { + // 다른 사람 기록: 신고하기만 표시하는 경우 + // 현재 MoreMenuProps 타입에는 신고 옵션이 없으므로 스낵바로 처리 + handleReport(); + } + }, [isMyRecord, openMoreMenu, handleEdit, handleDeleteConfirm, handleReport]); + + const handleLongPressStart = useCallback( + (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + + pressStartPos.current = { x: clientX, y: clientY }; + hasTriggeredLongPress.current = false; + setIsPressed(true); + + longPressTimer.current = setTimeout(() => { + if (!hasTriggeredLongPress.current) { + hasTriggeredLongPress.current = true; + handleLongPress(); + } + }, 500); // 500ms 길게 누르기 + }, + [handleLongPress], + ); + + const handleLongPressEnd = useCallback(() => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + setIsPressed(false); + }, []); + + const handleLongPressMove = useCallback( + (e: React.MouseEvent | React.TouchEvent) => { + if (!longPressTimer.current) return; + + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + + const deltaX = Math.abs(clientX - pressStartPos.current.x); + const deltaY = Math.abs(clientY - pressStartPos.current.y); + + // 10px 이상 움직이면 길게 누르기 취소 + if (deltaX > 10 || deltaY > 10) { + handleLongPressEnd(); + } + }, + [handleLongPressEnd], + ); + return ( - + From 280d36bea45b0e436d3269255f51f97962c3648e Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Fri, 15 Aug 2025 22:26:02 +0900 Subject: [PATCH 41/86] =?UTF-8?q?fix:=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EB=B0=A9=ED=95=B4=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../memory/RecordItem/RecordItem.tsx | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/components/memory/RecordItem/RecordItem.tsx b/src/components/memory/RecordItem/RecordItem.tsx index 1b516812..51c0be0b 100644 --- a/src/components/memory/RecordItem/RecordItem.tsx +++ b/src/components/memory/RecordItem/RecordItem.tsx @@ -90,16 +90,6 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { } }, [roomId, type, record.id, navigate]); - const handleDeleteConfirm = useCallback(() => { - const recordTypeName = type === 'poll' ? '투표' : '기록'; - - openConfirm({ - title: `${recordTypeName}을 삭제하시겠어요?`, - disc: `삭제된 ${recordTypeName}은 복구할 수 없습니다.`, - onConfirm: () => handleDelete(), - }); - }, [type, openConfirm]); - const handleDelete = useCallback(async () => { const currentRoomId = roomId || '1'; const recordId = parseInt(record.id); @@ -141,6 +131,16 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { } }, [roomId, record.id, type, openSnackbar]); + const handleDeleteConfirm = useCallback(() => { + const recordTypeName = type === 'poll' ? '투표' : '기록'; + + openConfirm({ + title: `${recordTypeName}을 삭제하시겠어요?`, + disc: `삭제된 ${recordTypeName}은 복구할 수 없습니다.`, + onConfirm: handleDelete, + }); + }, [type, openConfirm, handleDelete]); + const handleReport = useCallback(() => { openSnackbar({ message: '신고가 접수되었습니다.', @@ -151,22 +151,23 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { const handleLongPress = useCallback(() => { if (isMyRecord) { - // 내 기록: 수정하기, 삭제하기 (기존 MoreMenuProps 타입에 맞게 수정) + // 내 기록: 수정하기, 삭제하기 openMoreMenu({ onEdit: handleEdit, onDelete: handleDeleteConfirm, onClose: () => {}, }); } else { - // 다른 사람 기록: 신고하기만 표시하는 경우 - // 현재 MoreMenuProps 타입에는 신고 옵션이 없으므로 스낵바로 처리 + // 다른 사람 기록: 신고하기 handleReport(); } }, [isMyRecord, openMoreMenu, handleEdit, handleDeleteConfirm, handleReport]); const handleLongPressStart = useCallback( (e: React.MouseEvent | React.TouchEvent) => { - e.preventDefault(); + if ('touches' in e) { + e.preventDefault(); + } const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; @@ -180,7 +181,7 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { hasTriggeredLongPress.current = true; handleLongPress(); } - }, 500); // 500ms 길게 누르기 + }, 500); }, [handleLongPress], ); @@ -193,17 +194,16 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { setIsPressed(false); }, []); - const handleLongPressMove = useCallback( - (e: React.MouseEvent | React.TouchEvent) => { + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { if (!longPressTimer.current) return; - const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; - const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + const clientX = e.touches[0].clientX; + const clientY = e.touches[0].clientY; const deltaX = Math.abs(clientX - pressStartPos.current.x); const deltaY = Math.abs(clientY - pressStartPos.current.y); - // 10px 이상 움직이면 길게 누르기 취소 if (deltaX > 10 || deltaY > 10) { handleLongPressEnd(); } @@ -217,13 +217,13 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { onMouseDown={handleLongPressStart} onMouseUp={handleLongPressEnd} onMouseLeave={handleLongPressEnd} - onMouseMove={handleLongPressMove} onTouchStart={handleLongPressStart} onTouchEnd={handleLongPressEnd} - onTouchMove={handleLongPressMove} + onTouchMove={handleTouchMove} style={{ transform: isPressed ? 'scale(0.98)' : 'scale(1)', transition: 'transform 0.1s ease', + touchAction: 'manipulation', }} > From 7bd99da1d0f462c1e62cd6f1c8e0ae22c5d10e33 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Fri, 15 Aug 2025 22:32:45 +0900 Subject: [PATCH 42/86] =?UTF-8?q?feat:=20=EB=8D=94=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EB=B9=88=20=EC=98=81=EC=97=AD=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=EC=9C=BC=EB=A1=9C=20=EB=8B=AB=EA=B8=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/memory/RecordItem/RecordItem.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/memory/RecordItem/RecordItem.tsx b/src/components/memory/RecordItem/RecordItem.tsx index 51c0be0b..a715d897 100644 --- a/src/components/memory/RecordItem/RecordItem.tsx +++ b/src/components/memory/RecordItem/RecordItem.tsx @@ -30,7 +30,7 @@ interface RecordItemProps { const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { const navigate = useNavigate(); const { roomId } = useParams<{ roomId: string }>(); - const { openMoreMenu, openConfirm, openSnackbar } = usePopupActions(); + const { openMoreMenu, openConfirm, openSnackbar, closePopup } = usePopupActions(); const { user, @@ -155,13 +155,13 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { openMoreMenu({ onEdit: handleEdit, onDelete: handleDeleteConfirm, - onClose: () => {}, + onClose: closePopup, }); } else { // 다른 사람 기록: 신고하기 handleReport(); } - }, [isMyRecord, openMoreMenu, handleEdit, handleDeleteConfirm, handleReport]); + }, [isMyRecord, openMoreMenu, handleEdit, handleDeleteConfirm, handleReport, closePopup]); const handleLongPressStart = useCallback( (e: React.MouseEvent | React.TouchEvent) => { From 9fc233cff62d376c44e6bc7833fe8c9c229c0d12 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 00:56:50 +0900 Subject: [PATCH 43/86] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=EC=B1=85=20=EB=B0=8F=20=EC=B0=B8=EC=97=AC=20=EC=A4=91=20?= =?UTF-8?q?=EB=AA=A8=EC=9E=84=20=EC=B1=85=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/books/getSavedBooks.ts | 39 ++++ .../BookSearchBottomSheet.styled.ts | 119 +++++++---- .../BookSearchBottomSheet.tsx | 202 +++++++++++------- 3 files changed, 241 insertions(+), 119 deletions(-) create mode 100644 src/api/books/getSavedBooks.ts diff --git a/src/api/books/getSavedBooks.ts b/src/api/books/getSavedBooks.ts new file mode 100644 index 00000000..8157a8b8 --- /dev/null +++ b/src/api/books/getSavedBooks.ts @@ -0,0 +1,39 @@ +import { apiClient } from '../index'; + +// 저장한 책 정보 타입 +export interface SavedBook { + bookId: number; + bookTitle: string; + authorName: string; + publisher: string; + bookImageUrl: string; + isbn: string; +} + +// API 응답 데이터 타입 +export interface SavedBooksData { + bookList: SavedBook[]; +} + +// API 응답 타입 +export interface SavedBooksResponse { + isSuccess: boolean; + code: number; + message: string; + data: SavedBooksData; +} + +// 저장한 책 또는 참여 중 모임의 책 조회 +export const getSavedBooks = async (type: 'saved' | 'joining'): Promise => { + try { + const response = await apiClient.get('/books/selectable-list', { + params: { + type: type.toUpperCase(), + }, + }); + return response.data; + } catch (error) { + console.error('저장한 책 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts index a75a27f4..7cea5f53 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts @@ -111,71 +111,65 @@ export const TabContainer = styled.div` export const Tab = styled.button<{ active: boolean }>` background: none; border: none; - color: ${({ active }) => (active ? semanticColors.text.primary : semanticColors.text.ghost)}; + color: ${({ active }) => (active ? colors.white : colors.grey[300])}; font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.semibold}; - padding: 8px 0 8px 0; cursor: pointer; position: relative; - ${({ active }) => - active && - ` - &::after { - content: ''; - position: absolute; - bottom: -1px; - left: 0; - right: 0; - height: 2px; - background-color: ${semanticColors.text.primary}; - } - `} + &:after { + content: ''; + position: absolute; + bottom: -8px; + left: 5px; + right: 5px; + height: 2px; + background-color: ${({ active }) => (active ? colors.white : 'transparent')}; + transition: background-color 0.2s ease; + } + + &:hover { + color: ${colors.white}; + } `; export const BookListContainer = styled.div` flex: 1; overflow-y: auto; - margin-right: -16px; - padding-right: 16px; + min-height: 0; &::-webkit-scrollbar { - width: 4px; + width: 3px; } &::-webkit-scrollbar-track { - background: ${colors.grey[400]}; - border-radius: 2px; - margin-right: 14px; + background: transparent; } &::-webkit-scrollbar-thumb { - background-color: ${colors.white}; - border-radius: 2px; + background: ${colors.white}; + border-radius: 3px; } &::-webkit-scrollbar-thumb:hover { - background-color: ${colors.grey[200]}; + background: ${colors.grey[100]}; } - - /* Firefox 스크롤바 */ - scrollbar-width: thin; - scrollbar-color: ${colors.white} ${colors.grey[400]}; `; export const BookList = styled.div` display: flex; flex-direction: column; - gap: 0; + gap: 12px; `; export const BookItem = styled.div` display: flex; align-items: center; - gap: 8px; - padding: 12px 0; - border-bottom: 1px solid ${colors.grey[400]}; + padding: 12px 2px; + background-color: none; + border-radius: 12px; cursor: pointer; + transition: background-color 0.2s ease; `; export const BookCover = styled.div` @@ -183,7 +177,8 @@ export const BookCover = styled.div` height: 60px; overflow: hidden; flex-shrink: 0; - background-color: ${semanticColors.background.card}; + margin-right: 8px; + border: 1px solid ${colors.grey[300]}; img { width: 100%; @@ -194,14 +189,62 @@ export const BookCover = styled.div` export const BookInfo = styled.div` flex: 1; - display: flex; - flex-direction: column; - gap: 4px; + min-width: 0; `; -export const BookTitle = styled.div` - color: ${semanticColors.text.primary}; +export const BookTitle = styled.h3` font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.regular}; + color: ${colors.white}; + margin: 0; line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +// 로딩 상태 스타일 +export const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 200px; +`; + +export const LoadingText = styled.p` + color: ${colors.grey[300]}; + font-size: ${typography.fontSize.base}; + margin: 0; +`; + +// 에러 상태 스타일 +export const ErrorContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 200px; +`; + +export const ErrorText = styled.p` + color: ${colors.red}; + font-size: ${typography.fontSize.base}; + margin: 0; + text-align: center; +`; + +// 빈 상태 스타일 +export const EmptyContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 200px; +`; + +export const EmptyText = styled.p` + color: ${colors.grey[300]}; + font-size: ${typography.fontSize.base}; + margin: 0; + text-align: center; `; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index 88843320..09d59436 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import closeIcon from '../../../assets/group/close.svg'; import whitesearchIcon from '../../../assets/group/search_white.svg'; +import { getSavedBooks, type SavedBook } from '@/api/books/getSavedBooks'; import { Overlay, BottomSheetContainer, @@ -18,6 +19,12 @@ import { BookCover, BookInfo, BookTitle, + LoadingContainer, + LoadingText, + ErrorContainer, + ErrorText, + EmptyContainer, + EmptyText, } from './BookSearchBottomSheet.styled.ts'; // Types @@ -37,78 +44,94 @@ interface BookSearchBottomSheetProps { type TabType = 'saved' | 'group'; -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', - }, -]; - -const mockGroupBooks: Book[] = [ - { - id: 4, - 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', - }, -]; - const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBottomSheetProps) => { // State const [searchQuery, setSearchQuery] = useState(''); - const [filteredBooks, setFilteredBooks] = useState(mockSavedBooks); + const [filteredBooks, setFilteredBooks] = useState([]); const [activeTab, setActiveTab] = useState('saved'); + const [savedBooks, setSavedBooks] = useState([]); + const [groupBooks, setGroupBooks] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // API에서 받은 데이터를 Book 타입으로 변환하는 함수 + const convertSavedBookToBook = (savedBook: SavedBook): Book => ({ + id: savedBook.bookId, + title: savedBook.bookTitle, + author: savedBook.authorName, + cover: savedBook.bookImageUrl, + isbn: savedBook.isbn, + }); + + // 저장한 책 데이터 가져오기 + const fetchSavedBooks = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await getSavedBooks('saved'); + + if (response.isSuccess && response.data) { + setSavedBooks(response.data.bookList); + } else { + setError(response.message || '저장한 책을 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('저장한 책 조회 오류:', err); + setError('저장한 책을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + // 모임 책 데이터 가져오기 + const fetchGroupBooks = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await getSavedBooks('joining'); + + if (response.isSuccess && response.data) { + setGroupBooks(response.data.bookList); + } else { + setError(response.message || '모임 책을 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('모임 책 조회 오류:', err); + setError('모임 책을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; - // Effects + // 컴포넌트가 열릴 때 초기 데이터 로드 useEffect(() => { - // 현재 활성화된 탭의 책 목록 가져오기 - const currentTabBooks = activeTab === 'saved' ? mockSavedBooks : mockGroupBooks; + if (isOpen) { + if (activeTab === 'saved' && savedBooks.length === 0) { + fetchSavedBooks(); + } else if (activeTab === 'group' && groupBooks.length === 0) { + fetchGroupBooks(); + } + } + }, [isOpen, activeTab, savedBooks.length, groupBooks.length]); + + // 필터링 로직 + useEffect(() => { + const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; + const convertedBooks = currentTabBooks.map(convertSavedBookToBook); if (searchQuery.trim() === '') { - // 검색어가 없을 때는 선택된 탭의 전체 목록 표시 - setFilteredBooks(currentTabBooks); + setFilteredBooks(convertedBooks); } else { - // 검색어가 있을 때는 선택된 탭 내에서만 검색 - const filtered = currentTabBooks.filter( + const filtered = convertedBooks.filter( book => book.title.toLowerCase().includes(searchQuery.toLowerCase()) || book.author.toLowerCase().includes(searchQuery.toLowerCase()), ); setFilteredBooks(filtered); } - }, [searchQuery, activeTab]); + }, [searchQuery, activeTab, savedBooks, groupBooks]); useEffect(() => { if (isOpen) { @@ -153,26 +176,25 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott setSearchQuery(''); }; - const handleTabChange = (tab: TabType) => { + const handleTabChange = async (tab: TabType) => { setActiveTab(tab); - // 탭 변경 시 현재 검색어로 새로운 탭에서 다시 검색 - const newTabBooks = tab === 'saved' ? mockSavedBooks : mockGroupBooks; + setError(null); - if (searchQuery.trim() === '') { - setFilteredBooks(newTabBooks); - } else { - const filtered = newTabBooks.filter( - book => - book.title.toLowerCase().includes(searchQuery.toLowerCase()) || - book.author.toLowerCase().includes(searchQuery.toLowerCase()), - ); - setFilteredBooks(filtered); + // 탭 변경 시 해당 탭의 데이터가 없으면 API 호출 + if (tab === 'saved' && savedBooks.length === 0) { + await fetchSavedBooks(); + } else if (tab === 'group' && groupBooks.length === 0) { + await fetchGroupBooks(); } }; // 검색어가 없을 때만 탭 표시 const showTabs = searchQuery.trim() === ''; + // 현재 탭의 책 개수 확인 + const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; + const hasBooks = currentTabBooks.length > 0; + return ( @@ -211,18 +233,36 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott {/* 책 목록 영역 */} - - {filteredBooks.map(book => ( - handleBookSelect(book)}> - - {book.title} - - - {book.title} - - - ))} - + {isLoading ? ( + + 책 목록을 불러오는 중... + + ) : error ? ( + + {error} + + ) : !hasBooks ? ( + + + {activeTab === 'saved' + ? '저장한 책이 없습니다.' + : '참여 중인 모임의 책이 없습니다.'} + + + ) : ( + + {filteredBooks.map(book => ( + handleBookSelect(book)}> + + {book.title} + + + {book.title} + + + ))} + + )} From 945328e12221c0c9670e2264ac0af7830abbace9 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 00:59:55 +0900 Subject: [PATCH 44/86] =?UTF-8?q?style:=20=EB=B6=81=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=AD=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=EC=84=A0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BookSearchBottomSheet/BookSearchBottomSheet.styled.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts index 7cea5f53..ffe50b6c 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts @@ -159,7 +159,7 @@ export const BookListContainer = styled.div` export const BookList = styled.div` display: flex; flex-direction: column; - gap: 12px; + gap: 0; `; export const BookItem = styled.div` @@ -167,9 +167,13 @@ export const BookItem = styled.div` align-items: center; padding: 12px 2px; background-color: none; - border-radius: 12px; cursor: pointer; transition: background-color 0.2s ease; + border-bottom: 1px solid ${colors.grey[400]}; + + &:last-child { + border-bottom: none; + } `; export const BookCover = styled.div` From f493da84e315527c6771769200734ebb53654bd9 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 01:12:41 +0900 Subject: [PATCH 45/86] =?UTF-8?q?feat:=20=EB=B9=88=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?UI=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=B1=85=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=ED=95=98=EA=B8=B0=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BookSearchBottomSheet.styled.ts | 26 ++++++++- .../BookSearchBottomSheet.tsx | 57 ++++++++++++++----- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts index ffe50b6c..68803dd3 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts @@ -104,7 +104,7 @@ export const IconButton = styled.button` export const TabContainer = styled.div` display: flex; gap: 34px; - margin-bottom: 24px; + margin-bottom: 16px; flex-shrink: 0; `; @@ -241,14 +241,34 @@ export const ErrorText = styled.p` // 빈 상태 스타일 export const EmptyContainer = styled.div` display: flex; + flex-direction: column; justify-content: center; align-items: center; height: 200px; + gap: 8px; `; export const EmptyText = styled.p` - color: ${colors.grey[300]}; - font-size: ${typography.fontSize.base}; + color: #e0e0e0;}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; margin: 0; text-align: center; `; + +export const ApplyButton = styled.button` + background-color: ${colors.purple.main}; + color: ${colors.white}; + padding: 10px 12px; + font-size: ${typography.fontSize.base}; + font-weight: ${typography.fontWeight.semibold}; + line-height: 24px; + border: none; + border-radius: 12px; + cursor: pointer; + margin-top: 16px; + + &:hover { + background-color: ${colors.purple.dark}; + } +`; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index 09d59436..f496b0fa 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import closeIcon from '../../../assets/group/close.svg'; import whitesearchIcon from '../../../assets/group/search_white.svg'; import { getSavedBooks, type SavedBook } from '@/api/books/getSavedBooks'; @@ -25,6 +26,7 @@ import { ErrorText, EmptyContainer, EmptyText, + ApplyButton, } from './BookSearchBottomSheet.styled.ts'; // Types @@ -40,11 +42,19 @@ interface BookSearchBottomSheetProps { isOpen: boolean; onClose: () => void; onSelectBook: (book: Book) => void; + forceEmpty?: boolean; // 임시방편: 빈 상태 강제 표시 } type TabType = 'saved' | 'group'; -const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBottomSheetProps) => { +const BookSearchBottomSheet = ({ + isOpen, + onClose, + onSelectBook, + forceEmpty = true, +}: BookSearchBottomSheetProps) => { + const navigate = useNavigate(); + // State const [searchQuery, setSearchQuery] = useState(''); const [filteredBooks, setFilteredBooks] = useState([]); @@ -107,17 +117,22 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott // 컴포넌트가 열릴 때 초기 데이터 로드 useEffect(() => { - if (isOpen) { + if (isOpen && !forceEmpty) { if (activeTab === 'saved' && savedBooks.length === 0) { fetchSavedBooks(); } else if (activeTab === 'group' && groupBooks.length === 0) { fetchGroupBooks(); } } - }, [isOpen, activeTab, savedBooks.length, groupBooks.length]); + }, [isOpen, activeTab, savedBooks.length, groupBooks.length, forceEmpty]); // 필터링 로직 useEffect(() => { + if (forceEmpty) { + setFilteredBooks([]); + return; + } + const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; const convertedBooks = currentTabBooks.map(convertSavedBookToBook); @@ -131,7 +146,7 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott ); setFilteredBooks(filtered); } - }, [searchQuery, activeTab, savedBooks, groupBooks]); + }, [searchQuery, activeTab, savedBooks, groupBooks, forceEmpty]); useEffect(() => { if (isOpen) { @@ -181,19 +196,27 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott setError(null); // 탭 변경 시 해당 탭의 데이터가 없으면 API 호출 - if (tab === 'saved' && savedBooks.length === 0) { - await fetchSavedBooks(); - } else if (tab === 'group' && groupBooks.length === 0) { - await fetchGroupBooks(); + if (!forceEmpty) { + if (tab === 'saved' && savedBooks.length === 0) { + await fetchSavedBooks(); + } else if (tab === 'group' && groupBooks.length === 0) { + await fetchGroupBooks(); + } } }; - // 검색어가 없을 때만 탭 표시 - const showTabs = searchQuery.trim() === ''; + const handleApplyBook = () => { + navigate('/apply-book'); + onClose(); + }; - // 현재 탭의 책 개수 확인 + // 현재 탭의 책 개수 확인 (임시방편: forceEmpty가 true면 강제로 빈 상태) const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; - const hasBooks = currentTabBooks.length > 0; + const hasBooks = !forceEmpty && currentTabBooks.length > 0; + const showEmptyState = forceEmpty || (!isLoading && !error && !hasBooks); + + // 검색어가 없을 때만 탭 표시 (단, 빈 상태일 때는 탭 숨김) + const showTabs = searchQuery.trim() === '' && !showEmptyState; return ( @@ -219,7 +242,7 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott - {/* 탭 영역 - 검색어가 없을 때만 표시 */} + {/* 탭 영역 - 검색어가 없고 빈 상태가 아닐 때만 표시 */} {showTabs && ( handleTabChange('saved')}> @@ -233,7 +256,13 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott {/* 책 목록 영역 */} - {isLoading ? ( + {showEmptyState ? ( + + 현재 등록된 책이 아닙니다. + 원하시는 책을 신청해주세요. + 책 신청하기 + + ) : isLoading ? ( 책 목록을 불러오는 중... From d295af05ff083bd57d70d9a03829253edbb9a681 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 01:15:48 +0900 Subject: [PATCH 46/86] =?UTF-8?q?refactor:=20=EC=9E=84=EC=8B=9C=EB=B0=A9?= =?UTF-8?q?=ED=8E=B8=20forceEmpty=20=EC=BD=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BookSearchBottomSheet.tsx | 53 ++++++------------- 1 file changed, 16 insertions(+), 37 deletions(-) diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index f496b0fa..a25f9740 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -42,17 +42,11 @@ interface BookSearchBottomSheetProps { isOpen: boolean; onClose: () => void; onSelectBook: (book: Book) => void; - forceEmpty?: boolean; // 임시방편: 빈 상태 강제 표시 } type TabType = 'saved' | 'group'; -const BookSearchBottomSheet = ({ - isOpen, - onClose, - onSelectBook, - forceEmpty = true, -}: BookSearchBottomSheetProps) => { +const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBottomSheetProps) => { const navigate = useNavigate(); // State @@ -117,22 +111,17 @@ const BookSearchBottomSheet = ({ // 컴포넌트가 열릴 때 초기 데이터 로드 useEffect(() => { - if (isOpen && !forceEmpty) { + if (isOpen) { if (activeTab === 'saved' && savedBooks.length === 0) { fetchSavedBooks(); } else if (activeTab === 'group' && groupBooks.length === 0) { fetchGroupBooks(); } } - }, [isOpen, activeTab, savedBooks.length, groupBooks.length, forceEmpty]); + }, [isOpen, activeTab, savedBooks.length, groupBooks.length]); // 필터링 로직 useEffect(() => { - if (forceEmpty) { - setFilteredBooks([]); - return; - } - const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; const convertedBooks = currentTabBooks.map(convertSavedBookToBook); @@ -146,7 +135,7 @@ const BookSearchBottomSheet = ({ ); setFilteredBooks(filtered); } - }, [searchQuery, activeTab, savedBooks, groupBooks, forceEmpty]); + }, [searchQuery, activeTab, savedBooks, groupBooks]); useEffect(() => { if (isOpen) { @@ -196,12 +185,10 @@ const BookSearchBottomSheet = ({ setError(null); // 탭 변경 시 해당 탭의 데이터가 없으면 API 호출 - if (!forceEmpty) { - if (tab === 'saved' && savedBooks.length === 0) { - await fetchSavedBooks(); - } else if (tab === 'group' && groupBooks.length === 0) { - await fetchGroupBooks(); - } + if (tab === 'saved' && savedBooks.length === 0) { + await fetchSavedBooks(); + } else if (tab === 'group' && groupBooks.length === 0) { + await fetchGroupBooks(); } }; @@ -210,10 +197,10 @@ const BookSearchBottomSheet = ({ onClose(); }; - // 현재 탭의 책 개수 확인 (임시방편: forceEmpty가 true면 강제로 빈 상태) + // 현재 탭의 책 개수 확인 const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; - const hasBooks = !forceEmpty && currentTabBooks.length > 0; - const showEmptyState = forceEmpty || (!isLoading && !error && !hasBooks); + const hasBooks = currentTabBooks.length > 0; + const showEmptyState = !isLoading && !error && !hasBooks; // 검색어가 없을 때만 탭 표시 (단, 빈 상태일 때는 탭 숨김) const showTabs = searchQuery.trim() === '' && !showEmptyState; @@ -256,13 +243,7 @@ const BookSearchBottomSheet = ({ {/* 책 목록 영역 */} - {showEmptyState ? ( - - 현재 등록된 책이 아닙니다. - 원하시는 책을 신청해주세요. - 책 신청하기 - - ) : isLoading ? ( + {isLoading ? ( 책 목록을 불러오는 중... @@ -270,13 +251,11 @@ const BookSearchBottomSheet = ({ {error} - ) : !hasBooks ? ( + ) : showEmptyState ? ( - - {activeTab === 'saved' - ? '저장한 책이 없습니다.' - : '참여 중인 모임의 책이 없습니다.'} - + 현재 등록된 책이 아닙니다. + 원하시는 책을 신청해주세요. + 책 신청하기 ) : ( From 35f22885b7656041229db6cf2f907b98fa1389a0 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 01:19:29 +0900 Subject: [PATCH 47/86] =?UTF-8?q?refactor:=20=EC=B1=85=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/BookSearchBottomSheet/BookSearchBottomSheet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index a25f9740..683f381e 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -193,7 +193,7 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott }; const handleApplyBook = () => { - navigate('/apply-book'); + navigate('/search/applybook'); onClose(); }; From 05c58a0303b1c49c92b1c489f92cca78e27dbd89 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 01:30:38 +0900 Subject: [PATCH 48/86] =?UTF-8?q?refactor:=20BookSearchBottomSheet=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/BookSearchBottomSheet/BookList.tsx | 43 +++ .../BookSearchBottomSheet.tsx | 254 +++--------------- .../BookSearchHeader.tsx | 52 ++++ .../BookSearchStates.tsx | 57 ++++ .../BookSearchBottomSheet/BookSearchTabs.tsx | 23 ++ .../BookSearchBottomSheet/useBookSearch.ts | 127 +++++++++ 6 files changed, 343 insertions(+), 213 deletions(-) create mode 100644 src/components/common/BookSearchBottomSheet/BookList.tsx create mode 100644 src/components/common/BookSearchBottomSheet/BookSearchHeader.tsx create mode 100644 src/components/common/BookSearchBottomSheet/BookSearchStates.tsx create mode 100644 src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx create mode 100644 src/components/common/BookSearchBottomSheet/useBookSearch.ts diff --git a/src/components/common/BookSearchBottomSheet/BookList.tsx b/src/components/common/BookSearchBottomSheet/BookList.tsx new file mode 100644 index 00000000..416211c7 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookList.tsx @@ -0,0 +1,43 @@ +import { + BookList as StyledBookList, + BookItem, + BookCover, + BookInfo, + BookTitle, +} from './BookSearchBottomSheet.styled'; + +export interface Book { + id: number; + title: string; + author: string; + cover: string; + isbn: string; +} + +interface BookListProps { + books: Book[]; + onBookSelect: (book: Book) => void; +} + +const BookList = ({ books, onBookSelect }: BookListProps) => { + const handleImageError = (e: React.SyntheticEvent) => { + e.currentTarget.style.display = 'none'; + }; + + return ( + + {books.map(book => ( + onBookSelect(book)}> + + {book.title} + + + {book.title} + + + ))} + + ); +}; + +export default BookList; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index 683f381e..8216743d 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -1,42 +1,15 @@ -import { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import closeIcon from '../../../assets/group/close.svg'; -import whitesearchIcon from '../../../assets/group/search_white.svg'; -import { getSavedBooks, type SavedBook } from '@/api/books/getSavedBooks'; +import { useEffect } from 'react'; import { Overlay, BottomSheetContainer, Content, - SearchContainer, - SearchInputWrapper, - SearchInput, - ButtonGroup, - IconButton, - TabContainer, - Tab, BookListContainer, - BookList, - BookItem, - BookCover, - BookInfo, - BookTitle, - LoadingContainer, - LoadingText, - ErrorContainer, - ErrorText, - EmptyContainer, - EmptyText, - ApplyButton, -} from './BookSearchBottomSheet.styled.ts'; - -// Types -interface Book { - id: number; - title: string; - author: string; - cover: string; - isbn: string; -} +} from './BookSearchBottomSheet.styled'; +import BookSearchHeader from './BookSearchHeader'; +import BookSearchTabs from './BookSearchTabs'; +import BookList, { type Book } from './BookList'; +import BookSearchStates from './BookSearchStates'; +import { useBookSearch } from './useBookSearch'; interface BookSearchBottomSheetProps { isOpen: boolean; @@ -44,99 +17,28 @@ interface BookSearchBottomSheetProps { onSelectBook: (book: Book) => void; } -type TabType = 'saved' | 'group'; - const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBottomSheetProps) => { - const navigate = useNavigate(); - - // State - const [searchQuery, setSearchQuery] = useState(''); - const [filteredBooks, setFilteredBooks] = useState([]); - const [activeTab, setActiveTab] = useState('saved'); - const [savedBooks, setSavedBooks] = useState([]); - const [groupBooks, setGroupBooks] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - // API에서 받은 데이터를 Book 타입으로 변환하는 함수 - const convertSavedBookToBook = (savedBook: SavedBook): Book => ({ - id: savedBook.bookId, - title: savedBook.bookTitle, - author: savedBook.authorName, - cover: savedBook.bookImageUrl, - isbn: savedBook.isbn, - }); - - // 저장한 책 데이터 가져오기 - const fetchSavedBooks = async () => { - try { - setIsLoading(true); - setError(null); - - const response = await getSavedBooks('saved'); - - if (response.isSuccess && response.data) { - setSavedBooks(response.data.bookList); - } else { - setError(response.message || '저장한 책을 불러오는데 실패했습니다.'); - } - } catch (err) { - console.error('저장한 책 조회 오류:', err); - setError('저장한 책을 불러오는데 실패했습니다.'); - } finally { - setIsLoading(false); - } - }; - - // 모임 책 데이터 가져오기 - const fetchGroupBooks = async () => { - try { - setIsLoading(true); - setError(null); - - const response = await getSavedBooks('joining'); - - if (response.isSuccess && response.data) { - setGroupBooks(response.data.bookList); - } else { - setError(response.message || '모임 책을 불러오는데 실패했습니다.'); - } - } catch (err) { - console.error('모임 책 조회 오류:', err); - setError('모임 책을 불러오는데 실패했습니다.'); - } finally { - setIsLoading(false); - } - }; + const { + searchQuery, + filteredBooks, + activeTab, + isLoading, + error, + showEmptyState, + showTabs, + setSearchQuery, + handleTabChange, + loadInitialData, + } = useBookSearch(); // 컴포넌트가 열릴 때 초기 데이터 로드 useEffect(() => { if (isOpen) { - if (activeTab === 'saved' && savedBooks.length === 0) { - fetchSavedBooks(); - } else if (activeTab === 'group' && groupBooks.length === 0) { - fetchGroupBooks(); - } - } - }, [isOpen, activeTab, savedBooks.length, groupBooks.length]); - - // 필터링 로직 - useEffect(() => { - const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; - const convertedBooks = currentTabBooks.map(convertSavedBookToBook); - - if (searchQuery.trim() === '') { - setFilteredBooks(convertedBooks); - } else { - const filtered = convertedBooks.filter( - book => - book.title.toLowerCase().includes(searchQuery.toLowerCase()) || - book.author.toLowerCase().includes(searchQuery.toLowerCase()), - ); - setFilteredBooks(filtered); + loadInitialData(); } - }, [searchQuery, activeTab, savedBooks, groupBooks]); + }, [isOpen]); + // 바디 스크롤 제어 useEffect(() => { if (isOpen) { document.body.style.overflow = 'hidden'; @@ -162,115 +64,41 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott }; const handleSearch = () => { - // 실제 검색 API 호출 로직 console.log('검색:', searchQuery); }; - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearch(); - } - }; - - const handleImageError = (e: React.SyntheticEvent) => { - e.currentTarget.style.display = 'none'; - }; - const handleClearSearch = () => { setSearchQuery(''); }; - const handleTabChange = async (tab: TabType) => { - setActiveTab(tab); - setError(null); - - // 탭 변경 시 해당 탭의 데이터가 없으면 API 호출 - if (tab === 'saved' && savedBooks.length === 0) { - await fetchSavedBooks(); - } else if (tab === 'group' && groupBooks.length === 0) { - await fetchGroupBooks(); - } - }; - - const handleApplyBook = () => { - navigate('/search/applybook'); - onClose(); - }; - - // 현재 탭의 책 개수 확인 - const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; - const hasBooks = currentTabBooks.length > 0; - const showEmptyState = !isLoading && !error && !hasBooks; - - // 검색어가 없을 때만 탭 표시 (단, 빈 상태일 때는 탭 숨김) - const showTabs = searchQuery.trim() === '' && !showEmptyState; + const showBookList = !isLoading && !error && !showEmptyState; return ( - {/* 검색 영역 */} - - - setSearchQuery(e.target.value)} - onKeyPress={handleKeyPress} - /> - - - - 닫기 - - - 검색 - - - + {/* 검색 헤더 */} + - {/* 탭 영역 - 검색어가 없고 빈 상태가 아닐 때만 표시 */} - {showTabs && ( - - handleTabChange('saved')}> - 저장한 책 - - handleTabChange('group')}> - 모임 책 - - - )} + {/* 탭 영역 */} + {showTabs && } {/* 책 목록 영역 */} - {isLoading ? ( - - 책 목록을 불러오는 중... - - ) : error ? ( - - {error} - - ) : showEmptyState ? ( - - 현재 등록된 책이 아닙니다. - 원하시는 책을 신청해주세요. - 책 신청하기 - - ) : ( - - {filteredBooks.map(book => ( - handleBookSelect(book)}> - - {book.title} - - - {book.title} - - - ))} - - )} + + + {showBookList && } diff --git a/src/components/common/BookSearchBottomSheet/BookSearchHeader.tsx b/src/components/common/BookSearchBottomSheet/BookSearchHeader.tsx new file mode 100644 index 00000000..8a602165 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookSearchHeader.tsx @@ -0,0 +1,52 @@ +import closeIcon from '../../../assets/group/close.svg'; +import whitesearchIcon from '../../../assets/group/search_white.svg'; +import { + SearchContainer, + SearchInputWrapper, + SearchInput, + ButtonGroup, + IconButton, +} from './BookSearchBottomSheet.styled'; + +interface BookSearchHeaderProps { + searchQuery: string; + onSearchChange: (value: string) => void; + onSearch: () => void; + onClear: () => void; +} + +const BookSearchHeader = ({ + searchQuery, + onSearchChange, + onSearch, + onClear, +}: BookSearchHeaderProps) => { + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onSearch(); + } + }; + + return ( + + + onSearchChange(e.target.value)} + onKeyPress={handleKeyPress} + /> + + + + 닫기 + + + 검색 + + + + ); +}; + +export default BookSearchHeader; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchStates.tsx b/src/components/common/BookSearchBottomSheet/BookSearchStates.tsx new file mode 100644 index 00000000..f67b2729 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookSearchStates.tsx @@ -0,0 +1,57 @@ +import { useNavigate } from 'react-router-dom'; +import { + LoadingContainer, + LoadingText, + ErrorContainer, + ErrorText, + EmptyContainer, + EmptyText, + ApplyButton, +} from './BookSearchBottomSheet.styled'; + +interface BookSearchStatesProps { + isLoading: boolean; + error: string | null; + isEmpty: boolean; + activeTab: 'saved' | 'group'; + onClose: () => void; +} + +const BookSearchStates = ({ isLoading, error, isEmpty, onClose }: BookSearchStatesProps) => { + const navigate = useNavigate(); + + const handleApplyBook = () => { + navigate('/search/applybook'); + onClose(); + }; + + if (isLoading) { + return ( + + 책 목록을 불러오는 중... + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (isEmpty) { + return ( + + 현재 등록된 책이 아닙니다. + 원하시는 책을 신청해주세요. + 책 신청하기 + + ); + } + + return null; +}; + +export default BookSearchStates; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx b/src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx new file mode 100644 index 00000000..ef5e8660 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx @@ -0,0 +1,23 @@ +import { TabContainer, Tab } from './BookSearchBottomSheet.styled'; + +export type TabType = 'saved' | 'group'; + +interface BookSearchTabsProps { + activeTab: TabType; + onTabChange: (tab: TabType) => void; +} + +const BookSearchTabs = ({ activeTab, onTabChange }: BookSearchTabsProps) => { + return ( + + onTabChange('saved')}> + 저장한 책 + + onTabChange('group')}> + 모임 책 + + + ); +}; + +export default BookSearchTabs; diff --git a/src/components/common/BookSearchBottomSheet/useBookSearch.ts b/src/components/common/BookSearchBottomSheet/useBookSearch.ts new file mode 100644 index 00000000..fe47675a --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/useBookSearch.ts @@ -0,0 +1,127 @@ +import { useState, useEffect } from 'react'; +import { getSavedBooks, type SavedBook } from '@/api/books/getSavedBooks'; +import type { Book } from './BookList'; +import type { TabType } from './BookSearchTabs'; + +export const useBookSearch = () => { + const [searchQuery, setSearchQuery] = useState(''); + const [filteredBooks, setFilteredBooks] = useState([]); + const [activeTab, setActiveTab] = useState('saved'); + const [savedBooks, setSavedBooks] = useState([]); + const [groupBooks, setGroupBooks] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // API에서 받은 데이터를 Book 타입으로 변환하는 함수 + const convertSavedBookToBook = (savedBook: SavedBook): Book => ({ + id: savedBook.bookId, + title: savedBook.bookTitle, + author: savedBook.authorName, + cover: savedBook.bookImageUrl, + isbn: savedBook.isbn, + }); + + // 저장한 책 데이터 가져오기 + const fetchSavedBooks = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await getSavedBooks('saved'); + + if (response.isSuccess && response.data) { + setSavedBooks(response.data.bookList); + } else { + setError(response.message || '저장한 책을 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('저장한 책 조회 오류:', err); + setError('저장한 책을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + // 모임 책 데이터 가져오기 + const fetchGroupBooks = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await getSavedBooks('joining'); + + if (response.isSuccess && response.data) { + setGroupBooks(response.data.bookList); + } else { + setError(response.message || '모임 책을 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('모임 책 조회 오류:', err); + setError('모임 책을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + // 필터링 로직 + useEffect(() => { + const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; + const convertedBooks = currentTabBooks.map(convertSavedBookToBook); + + if (searchQuery.trim() === '') { + setFilteredBooks(convertedBooks); + } else { + const filtered = convertedBooks.filter( + book => + book.title.toLowerCase().includes(searchQuery.toLowerCase()) || + book.author.toLowerCase().includes(searchQuery.toLowerCase()), + ); + setFilteredBooks(filtered); + } + }, [searchQuery, activeTab, savedBooks, groupBooks]); + + // 탭 변경 핸들러 + const handleTabChange = async (tab: TabType) => { + setActiveTab(tab); + setError(null); + + // 탭 변경 시 해당 탭의 데이터가 없으면 API 호출 + if (tab === 'saved' && savedBooks.length === 0) { + await fetchSavedBooks(); + } else if (tab === 'group' && groupBooks.length === 0) { + await fetchGroupBooks(); + } + }; + + // 초기 데이터 로드 + const loadInitialData = () => { + if (activeTab === 'saved' && savedBooks.length === 0) { + fetchSavedBooks(); + } else if (activeTab === 'group' && groupBooks.length === 0) { + fetchGroupBooks(); + } + }; + + // 현재 상태 계산 + const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; + const hasBooks = currentTabBooks.length > 0; + const showEmptyState = !isLoading && !error && !hasBooks; + const showTabs = searchQuery.trim() === '' && !showEmptyState; + + return { + // State + searchQuery, + filteredBooks, + activeTab, + isLoading, + error, + hasBooks, + showEmptyState, + showTabs, + + // Actions + setSearchQuery, + handleTabChange, + loadInitialData, + }; +}; From b8df306d8fa6cd37c5d35662223c5cd273d7e2d1 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 01:45:57 +0900 Subject: [PATCH 49/86] =?UTF-8?q?feat:=20=EC=A7=84=ED=96=89=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=A9=20=EC=83=81=EC=84=B8=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?API=20=EC=97=B0=EB=8F=99=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/rooms/getRoomPlaying.ts | 72 +++++++ .../groupDetail/ParticipatedGroupDetail.tsx | 196 +++++++++++------- src/pages/index.tsx | 2 +- 3 files changed, 195 insertions(+), 75 deletions(-) create mode 100644 src/api/rooms/getRoomPlaying.ts diff --git a/src/api/rooms/getRoomPlaying.ts b/src/api/rooms/getRoomPlaying.ts new file mode 100644 index 00000000..f19a120a --- /dev/null +++ b/src/api/rooms/getRoomPlaying.ts @@ -0,0 +1,72 @@ +import { apiClient } from '../index'; + +// 투표 아이템 타입 +export interface VoteItem { + itemName: string; +} + +// 현재 투표 타입 +export interface CurrentVote { + content: string; + page: number; + isOverview: boolean; + voteItems: VoteItem[]; +} + +// 진행중인 방 상세 정보 응답 타입 +export interface RoomPlayingResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + isHost: boolean; + roomId: number; + roomName: string; + roomImageUrl: string; + isPublic: boolean; + progressStartDate: string; + progressEndDate: string; + category: string; + categoryColor: string; + roomDescription: string; + memberCount: number; + recruitCount: number; + isbn: string; + bookTitle: string; + authorName: string; + currentPage: number; + userPercentage: number; + currentVotes: CurrentVote[]; + }; +} + +// HotTopicSection에서 사용할 Poll 타입 (API 데이터를 변환) +export interface Poll { + id: string; + question: string; + options: { id: string; text: string }[]; + pageNumber: number; +} + +// API 데이터를 Poll 형태로 변환하는 함수 +export const convertVotesToPolls = (currentVotes: CurrentVote[]): Poll[] => { + return currentVotes.map((vote, index) => ({ + id: index.toString(), + question: vote.content, + options: vote.voteItems.map((item, itemIndex) => ({ + id: itemIndex.toString(), + text: item.itemName, + })), + pageNumber: vote.page, + })); +}; + +export const getRoomPlaying = async (roomId: number): Promise => { + try { + const response = await apiClient.get(`/rooms/${roomId}/playing`); + return response.data; + } catch (error) { + console.error('진행중인 방 상세 정보 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/pages/groupDetail/ParticipatedGroupDetail.tsx b/src/pages/groupDetail/ParticipatedGroupDetail.tsx index 0b949ae0..c13588b1 100644 --- a/src/pages/groupDetail/ParticipatedGroupDetail.tsx +++ b/src/pages/groupDetail/ParticipatedGroupDetail.tsx @@ -1,3 +1,5 @@ +import { useState, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import { TopBackground, Header, @@ -25,29 +27,63 @@ import CommentSection from '../../components/group/CommentSection'; import HotTopicSection from '../../components/group/HotTopicSection'; import GroupBookSection from '../../components/group/GroupBookSection'; import GroupActionBottomSheet from '../../components/group/GroupActionBottomSheet'; -import type { Poll } from '../../components/group/HotTopicSection'; import { usePopupActions } from '@/hooks/usePopupActions'; +import { + getRoomPlaying, + type RoomPlayingResponse, + convertVotesToPolls, + type Poll, +} from '@/api/rooms/getRoomPlaying'; import rightChevron from '../../assets/group/right-chevron.svg'; - import leftArrow from '../../assets/common/leftArrow.svg'; import moreIcon from '../../assets/common/more.svg'; -import { useNavigate } from 'react-router-dom'; import { IconButton } from '@/components/common/IconButton'; -import { mockGroupDetail } from '../../mocks/groupDetail.mock'; import lockIcon from '../../assets/group/lock.svg'; import calendarIcon from '../../assets/group/calendar.svg'; import peopleIcon from '../../assets/common/darkPeople.svg'; -import { useState } from 'react'; +import styled from '@emotion/styled'; const ParticipatedGroupDetail = () => { - const { title, isPrivate, introduction, activityPeriod, members, genre, book } = mockGroupDetail; const { openConfirm } = usePopupActions(); - const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); + + // API 상태 관리 + const [roomData, setRoomData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // UI 상태 관리 const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false); - // 모임방 생성자 여부 (실제로는 API에서 받아와야 함) - const [isGroupOwner] = useState(false); // true면 생성자, false면 참여자 + // API 호출 + useEffect(() => { + const fetchRoomDetail = async () => { + if (!roomId) { + setError('방 ID가 없습니다.'); + setLoading(false); + return; + } + + try { + setLoading(true); + const response = await getRoomPlaying(parseInt(roomId)); + + if (response.isSuccess) { + setRoomData(response); + } else { + setError(response.message); + } + } catch (err) { + setError('방 정보를 불러오는 중 오류가 발생했습니다.'); + console.error('방 상세 정보 조회 오류:', err); + } finally { + setLoading(false); + } + }; + + fetchRoomDetail(); + }, [roomId]); const handleBackButton = () => { navigate(-1); @@ -67,7 +103,6 @@ const ParticipatedGroupDetail = () => { disc: '방을 삭제하게 되면\n독서메이트들과의 추억이 사라집니다.', onConfirm: () => { console.log('방 삭제 확정'); - // 실제 삭제 API 호출 후 홈으로 이동 navigate('/group'); }, }); @@ -79,107 +114,96 @@ const ParticipatedGroupDetail = () => { disc: '방을 나가시게 되면\n독서메이트들과의 추억이 사라집니다.', onConfirm: () => { console.log('방 나가기 확정'); - // 실제 나가기 API 호출 후 홈으로 이동 navigate('/group'); }, }); }; const handleReportGroup = () => { - // 방 신고하기 로직 console.log('방 신고하기'); - // 실제로는 신고 모달이나 페이지로 이동 }; const handleRecordSectionClick = () => { - navigate('/memory'); + navigate(`/memory/${roomId}`); }; const handleCommentSectionClick = () => { - navigate('/today-words'); + navigate(`/today-words/${roomId}`); }; const handleHotTopicSectionClick = () => { - // 뜨거운 감자 전체 페이지로 이동 - navigate('/memory'); // 또는 투표 전체 리스트 페이지 + navigate(`/memory/${roomId}`); }; const handleBookSectionClick = () => { - navigate(`/book/123`); + if (roomData?.data.isbn) { + navigate(`/book/${roomData.data.isbn}`); + } }; - // 투표 클릭 시 해당 페이지의 기록장으로 이동 const handlePollClick = (pageNumber: number) => { - // 해당 투표가 위치한 페이지 번호로 필터를 씌운 기록장 화면으로 이동 - navigate(`/memory?page=${pageNumber}&filter=poll`); + navigate(`/memory/${roomId}?page=${pageNumber}&filter=poll`); }; const handleMembersClick = () => { - navigate('/group/members'); // 또는 실제 독서메이트 페이지 경로 + navigate(`/group/${roomId}/members`); + }; + + // 로딩 상태 + if (loading) { + return ( + + 로딩 중... + + ); + } + + // 에러 상태 + if (error || !roomData) { + return ( + + {error || '데이터를 불러올 수 없습니다.'} + + ); + } + + const { data } = roomData; + + // API 데이터를 컴포넌트에 맞게 변환 + const polls: Poll[] = convertVotesToPolls(data.currentVotes); + const hasPolls = polls.length > 0; + + // 날짜 포맷팅 (YYYY-MM-DD -> YYYY.MM.DD) + const formatDate = (dateString: string) => { + return dateString.replace(/-/g, '.'); }; - // 모킹 데이터 - const recordData = { - currentPage: 1, - progress: 30, + // 장르에 따른 배경색 결정 (카테고리 컬러 사용) + const getGenreForBackground = () => { + // categoryColor를 사용하거나 기본값으로 장르명 사용 + return data.category; }; + // 댓글 섹션 메시지 const commentData = { message: '모임방 멤버들과 간단한 인사를 나눠보세요!', }; - // 투표 데이터 (투표 결과 없이 질문과 선택지만) - const mockPolls: Poll[] = [ - { - id: '1', - question: '3연에 나오는 심장은 무엇을 의미하는 걸까요?', - options: [ - { id: '1', text: '김땡땡' }, - { id: '2', text: '김땡땡' }, - ], - pageNumber: 456, // 해당 투표가 위치한 페이지 - }, - { - id: '2', - question: '또 다른 투표 질문입니다', - options: [ - { id: '1', text: '선택지 1' }, - { id: '2', text: '선택지 2' }, - { id: '3', text: '선택지 3' }, - ], - pageNumber: 123, - }, - { - id: '3', - question: '세 번째 투표입니다', - options: [ - { id: '1', text: 'A 답변' }, - { id: '2', text: 'B 답변' }, - ], - pageNumber: 789, - }, - ]; - - // 투표가 없을 때 테스트하려면 이걸 사용 - // const mockPolls: Poll[] = []; - - const hasPolls = mockPolls.length > 0; - return ( - +
- {title} {isPrivate && 자물쇠 아이콘} + {data.roomName} {!data.isPublic && 자물쇠 아이콘}
소개글

- {introduction} + {data.roomDescription}
@@ -187,7 +211,7 @@ const ParticipatedGroupDetail = () => { 모임 활동기간 - {activityPeriod.start} ~ {activityPeriod.end} + {formatDate(data.progressStartDate)} ~ {formatDate(data.progressEndDate)} @@ -201,7 +225,7 @@ const ParticipatedGroupDetail = () => { - {members.current} + {data.memberCount} 명 참여 중
@@ -210,24 +234,28 @@ const ParticipatedGroupDetail = () => { - 장르 {genre} + 장르 {data.category} - + { { ); }; +const LoadingContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-grey-200); + font-size: var(--string-size-base, 16px); +`; + +const ErrorContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-red); + font-size: var(--string-size-base, 16px); + text-align: center; + padding: 20px; +`; + export default ParticipatedGroupDetail; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 295b4ec3..5c793735 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -53,7 +53,7 @@ const Router = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> From 959bf05f8b343ad70ff32261a56e17eb6dce7eac Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 01:55:04 +0900 Subject: [PATCH 50/86] =?UTF-8?q?feat:=20=EB=8F=85=EC=84=9C=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/rooms/getRoomMembers.ts | 50 ++++++++++ src/components/members/MemberList.tsx | 13 ++- src/pages/groupMembers/GroupMembers.styled.ts | 29 ++++++ src/pages/groupMembers/GroupMembers.tsx | 91 ++++++++++++++++++- 4 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 src/api/rooms/getRoomMembers.ts diff --git a/src/api/rooms/getRoomMembers.ts b/src/api/rooms/getRoomMembers.ts new file mode 100644 index 00000000..fe0ff429 --- /dev/null +++ b/src/api/rooms/getRoomMembers.ts @@ -0,0 +1,50 @@ +import { apiClient } from '../index'; + +// 독서메이트 정보 타입 +export interface RoomMember { + userId: number; + nickname: string; + imageUrl: string; + alias: string; + subscriberCount: number; +} + +// 독서메이트 조회 응답 타입 +export interface RoomMembersResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + userList: RoomMember[]; + }; +} + +// 기존 Member 타입과 연결하기 위한 변환 함수 +export interface Member { + id: string; + nickname: string; + role: string; + followersCount?: number; + profileImageUrl?: string; +} + +// API 데이터를 Member 형태로 변환하는 함수 +export const convertRoomMembersToMembers = (roomMembers: RoomMember[]): Member[] => { + return roomMembers.map(member => ({ + id: member.userId.toString(), + nickname: member.nickname, + role: member.alias, // alias를 role로 사용 + followersCount: member.subscriberCount, + profileImageUrl: member.imageUrl, + })); +}; + +export const getRoomMembers = async (roomId: number): Promise => { + try { + const response = await apiClient.get(`/rooms/${roomId}/users`); + return response.data; + } catch (error) { + console.error('독서메이트 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/components/members/MemberList.tsx b/src/components/members/MemberList.tsx index bbbc9733..74e613e0 100644 --- a/src/components/members/MemberList.tsx +++ b/src/components/members/MemberList.tsx @@ -1,7 +1,7 @@ import { useNavigate } from 'react-router-dom'; import type { KeyboardEvent } from 'react'; import rightChevron from '../../assets/member/right-chevron.svg'; -import type { Member } from '../../mocks/members.mock'; +import type { Member } from '@/api/rooms/getRoomMembers'; import { Container, MemberItem, @@ -47,7 +47,11 @@ const MemberList = ({ members, onMemberClick }: MemberListProps) => { }} > - + {member.profileImageUrl ? ( + + ) : ( + + )} {member.nickname} {member.role} @@ -63,4 +67,9 @@ const MemberList = ({ members, onMemberClick }: MemberListProps) => { ); }; +// 프로필 이미지가 있을 때 사용하는 스타일드 컴포넌트 +const ProfileImageWithSrc = ({ src, alt }: { src: string; alt: string }) => ( + +); + export default MemberList; diff --git a/src/pages/groupMembers/GroupMembers.styled.ts b/src/pages/groupMembers/GroupMembers.styled.ts index 91944305..f12e9657 100644 --- a/src/pages/groupMembers/GroupMembers.styled.ts +++ b/src/pages/groupMembers/GroupMembers.styled.ts @@ -11,3 +11,32 @@ export const Wrapper = styled.div` margin: 0 auto; background-color: ${colors.black.main}; `; + +export const LoadingContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-grey-200); + font-size: var(--string-size-base, 16px); +`; + +export const ErrorContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-red); + font-size: var(--string-size-base, 16px); + text-align: center; + padding: 20px; +`; + +export const EmptyContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-grey-200); + font-size: var(--string-size-base, 16px); +`; diff --git a/src/pages/groupMembers/GroupMembers.tsx b/src/pages/groupMembers/GroupMembers.tsx index 5f15390e..a0886427 100644 --- a/src/pages/groupMembers/GroupMembers.tsx +++ b/src/pages/groupMembers/GroupMembers.tsx @@ -1,12 +1,57 @@ -import { useNavigate } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import MemberList from '../../components/members/MemberList'; import leftArrow from '../../assets/common/leftArrow.svg'; -import { mockMembers } from '../../mocks/members.mock'; -import { Wrapper } from './GroupMembers.styled'; +import { Wrapper, LoadingContainer, ErrorContainer, EmptyContainer } from './GroupMembers.styled'; +import { + getRoomMembers, + convertRoomMembersToMembers, + type Member, + type RoomMembersResponse, +} from '@/api/rooms/getRoomMembers'; const GroupMembers = () => { const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); + + // API 상태 관리 + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // API 호출 + useEffect(() => { + const fetchMembers = async () => { + // roomId 우선 순위: URL 파라미터 > localStorage > 기본값 1 + const currentRoomId = roomId || localStorage.getItem('currentRoomId') || '1'; + + if (!currentRoomId) { + setError('방 ID를 찾을 수 없습니다.'); + setLoading(false); + return; + } + + try { + setLoading(true); + const response: RoomMembersResponse = await getRoomMembers(parseInt(currentRoomId)); + + if (response.isSuccess) { + const convertedMembers = convertRoomMembersToMembers(response.data.userList); + setMembers(convertedMembers); + } else { + setError(response.message); + } + } catch (err) { + setError('독서메이트 목록을 불러오는 중 오류가 발생했습니다.'); + console.error('독서메이트 조회 오류:', err); + } finally { + setLoading(false); + } + }; + + fetchMembers(); + }, [roomId]); const handleBackClick = () => { navigate(-1); @@ -14,9 +59,41 @@ const GroupMembers = () => { const handleMemberClick = (memberId: string) => { // 특정 사용자 페이지로 이동 - navigate(`/user/${memberId}`); + navigate(`/otherfeed/${memberId}`); }; + // 로딩 상태 + if (loading) { + return ( + <> + } + title="독서메이트" + onLeftClick={handleBackClick} + /> + + 로딩 중... + + + ); + } + + // 에러 상태 + if (error) { + return ( + <> + } + title="독서메이트" + onLeftClick={handleBackClick} + /> + + {error} + + + ); + } + return ( <> { onLeftClick={handleBackClick} /> - + {members.length > 0 ? ( + + ) : ( + 독서메이트가 없습니다. + )} ); From b22d2abb48e2d920f0469ef9313df7d1bc636753 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 01:56:59 +0900 Subject: [PATCH 51/86] =?UTF-8?q?fix:=20MemberList=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20TypeScript=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/members/MemberList.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/members/MemberList.tsx b/src/components/members/MemberList.tsx index 74e613e0..f957d5b9 100644 --- a/src/components/members/MemberList.tsx +++ b/src/components/members/MemberList.tsx @@ -1,5 +1,6 @@ import { useNavigate } from 'react-router-dom'; import type { KeyboardEvent } from 'react'; +import styled from '@emotion/styled'; import rightChevron from '../../assets/member/right-chevron.svg'; import type { Member } from '@/api/rooms/getRoomMembers'; import { @@ -68,8 +69,13 @@ const MemberList = ({ members, onMemberClick }: MemberListProps) => { }; // 프로필 이미지가 있을 때 사용하는 스타일드 컴포넌트 -const ProfileImageWithSrc = ({ src, alt }: { src: string; alt: string }) => ( - -); +const ProfileImageWithSrc = styled.img` + width: 48px; + height: 48px; + border-radius: 50%; + background-color: var(--color-grey-400); + flex-shrink: 0; + object-fit: cover; +`; export default MemberList; From a7a2f90ca5bb65cd6e010e0b967b134892a7feb7 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 02:00:40 +0900 Subject: [PATCH 52/86] =?UTF-8?q?fix:=20=EB=8F=85=EC=84=9C=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/members/MemberList.tsx | 4 ++-- src/pages/index.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/members/MemberList.tsx b/src/components/members/MemberList.tsx index f957d5b9..b50d4347 100644 --- a/src/components/members/MemberList.tsx +++ b/src/components/members/MemberList.tsx @@ -70,8 +70,8 @@ const MemberList = ({ members, onMemberClick }: MemberListProps) => { // 프로필 이미지가 있을 때 사용하는 스타일드 컴포넌트 const ProfileImageWithSrc = styled.img` - width: 48px; - height: 48px; + width: 36px; + height: 36px; border-radius: 50%; background-color: var(--color-grey-400); flex-shrink: 0; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 5c793735..dfccc374 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -54,7 +54,7 @@ const Router = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> From d6eb1733e6014bd5511c1f1aa01aad1b555b2dc0 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 02:03:08 +0900 Subject: [PATCH 53/86] =?UTF-8?q?fix:=20=EB=8F=85=EC=84=9C=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20API=20=ED=95=84=EB=93=9C=EB=AA=85=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/rooms/getRoomMembers.ts | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/api/rooms/getRoomMembers.ts b/src/api/rooms/getRoomMembers.ts index fe0ff429..1510ea51 100644 --- a/src/api/rooms/getRoomMembers.ts +++ b/src/api/rooms/getRoomMembers.ts @@ -1,6 +1,5 @@ import { apiClient } from '../index'; -// 독서메이트 정보 타입 export interface RoomMember { userId: number; nickname: string; @@ -28,15 +27,28 @@ export interface Member { profileImageUrl?: string; } -// API 데이터를 Member 형태로 변환하는 함수 export const convertRoomMembersToMembers = (roomMembers: RoomMember[]): Member[] => { - return roomMembers.map(member => ({ - id: member.userId.toString(), - nickname: member.nickname, - role: member.alias, // alias를 role로 사용 - followersCount: member.subscriberCount, - profileImageUrl: member.imageUrl, - })); + const convertedMembers = roomMembers.map(member => { + const memberData = member as any; + + // 다양한 가능한 필드명들을 체크 + const alias = memberData.alias || memberData.aliasName || memberData.role || '독서메이트'; + const followerCount = + memberData.subscriberCount || memberData.followerCount || memberData.followersCount || 0; + const imageUrl = memberData.imageUrl || memberData.profileImageUrl || memberData.image; + + const convertedMember = { + id: member.userId.toString(), + nickname: member.nickname, + role: alias, + followersCount: followerCount, + profileImageUrl: imageUrl, + }; + + return convertedMember; + }); + + return convertedMembers; }; export const getRoomMembers = async (roomId: number): Promise => { From 959cfa98b8ce56c77843d81e21b82655821622d4 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 02:05:29 +0900 Subject: [PATCH 54/86] =?UTF-8?q?fix:=20getRoomMembers=20TypeScript=20any?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/rooms/getRoomMembers.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/api/rooms/getRoomMembers.ts b/src/api/rooms/getRoomMembers.ts index 1510ea51..659fbfef 100644 --- a/src/api/rooms/getRoomMembers.ts +++ b/src/api/rooms/getRoomMembers.ts @@ -5,7 +5,7 @@ export interface RoomMember { nickname: string; imageUrl: string; alias: string; - subscriberCount: number; + followerCount: number; } // 독서메이트 조회 응답 타입 @@ -29,20 +29,12 @@ export interface Member { export const convertRoomMembersToMembers = (roomMembers: RoomMember[]): Member[] => { const convertedMembers = roomMembers.map(member => { - const memberData = member as any; - - // 다양한 가능한 필드명들을 체크 - const alias = memberData.alias || memberData.aliasName || memberData.role || '독서메이트'; - const followerCount = - memberData.subscriberCount || memberData.followerCount || memberData.followersCount || 0; - const imageUrl = memberData.imageUrl || memberData.profileImageUrl || memberData.image; - - const convertedMember = { + const convertedMember: Member = { id: member.userId.toString(), - nickname: member.nickname, - role: alias, - followersCount: followerCount, - profileImageUrl: imageUrl, + nickname: member.nickname || '익명', + role: member.alias || '독서메이트', + followersCount: member.followerCount || 0, + profileImageUrl: member.imageUrl || undefined, }; return convertedMember; From 78299418448c0d36e75f9137b7d482c72438615f Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 02:06:17 +0900 Subject: [PATCH 55/86] =?UTF-8?q?fix:=20=EB=B3=80=EC=88=98=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/rooms/getRoomMembers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/rooms/getRoomMembers.ts b/src/api/rooms/getRoomMembers.ts index 659fbfef..2ba7f589 100644 --- a/src/api/rooms/getRoomMembers.ts +++ b/src/api/rooms/getRoomMembers.ts @@ -4,7 +4,7 @@ export interface RoomMember { userId: number; nickname: string; imageUrl: string; - alias: string; + aliasName: string; followerCount: number; } @@ -32,7 +32,7 @@ export const convertRoomMembersToMembers = (roomMembers: RoomMember[]): Member[] const convertedMember: Member = { id: member.userId.toString(), nickname: member.nickname || '익명', - role: member.alias || '독서메이트', + role: member.aliasName || '독서메이트', followersCount: member.followerCount || 0, profileImageUrl: member.imageUrl || undefined, }; From 842f143245d74bdfecdb47c9d9c23b751b6e7e6b Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 02:24:00 +0900 Subject: [PATCH 56/86] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=81=AC=EA=B8=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/members/MemberList.styled.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/members/MemberList.styled.ts b/src/components/members/MemberList.styled.ts index f3240c39..429be39b 100644 --- a/src/components/members/MemberList.styled.ts +++ b/src/components/members/MemberList.styled.ts @@ -48,8 +48,8 @@ export const ProfileSection = styled.div` `; export const ProfileImage = styled.div` - width: 48px; - height: 48px; + width: 36px; + height: 36px; border-radius: 50%; background-color: ${colors.grey['400']}; flex-shrink: 0; From d6447a00a078a58fb6103e56e583f1ca63ee0172 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 14:48:22 +0900 Subject: [PATCH 57/86] =?UTF-8?q?refactor:=20carousel=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/group/MyGroupBox.tsx | 22 +------ .../group/RecruitingGroupCarousel.tsx | 60 +++++-------------- src/hooks/useInfiniteCarousel.ts | 7 ++- src/pages/group/Group.tsx | 3 - 4 files changed, 20 insertions(+), 72 deletions(-) diff --git a/src/components/group/MyGroupBox.tsx b/src/components/group/MyGroupBox.tsx index a040cba0..09d9915d 100644 --- a/src/components/group/MyGroupBox.tsx +++ b/src/components/group/MyGroupBox.tsx @@ -59,7 +59,7 @@ export function MyGroupBox({ onMyGroupsClick }: MyGroupProps) { fetchJoinedRooms(); }, []); - const { scrollRef, cardRefs, infiniteGroups, current } = useInfiniteCarousel(groups); + const { scrollRef, cardRefs, infiniteGroups } = useInfiniteCarousel(groups); return ( @@ -90,11 +90,6 @@ export function MyGroupBox({ onMyGroupsClick }: MyGroupProps) { /> ))} - - {groups.map((_, i) => ( - - ))} - ) : ( @@ -148,21 +143,6 @@ const Carousel = styled.div` scroll-snap-type: x mandatory; `; -const Dots = styled.div` - display: flex; - justify-content: center; - gap: 12px; - margin: 30px 0; -`; - -const Dot = styled.div<{ active: boolean }>` - width: 4px; - height: 4px; - border-radius: 50%; - background: ${({ active }) => (active ? colors.white : colors.grey[300])}; - transition: background-color 0.3s; -`; - const LoadingContainer = styled.div` display: flex; justify-content: center; diff --git a/src/components/group/RecruitingGroupCarousel.tsx b/src/components/group/RecruitingGroupCarousel.tsx index 5f69cfd9..58ce5b28 100644 --- a/src/components/group/RecruitingGroupCarousel.tsx +++ b/src/components/group/RecruitingGroupCarousel.tsx @@ -1,7 +1,7 @@ -import { useRef, useEffect, useCallback } from 'react'; import styled from '@emotion/styled'; import type { Group } from './MyGroupBox'; import { RecruitingGroupBox } from './RecruitingGroupBox'; +import { useInfiniteCarousel } from '../../hooks/useInfiniteCarousel'; export interface Section { title: string; @@ -13,59 +13,28 @@ interface Props { } export function RecruitingGroupCarousel({ sections }: Props) { - const scrollRef = useRef(null); - const itemRefs = useRef>([]); + const groups = sections.map(sec => ({ ...sec.groups[0], title: sec.title, groups: sec.groups })); - const handleScroll = useCallback(() => { - const container = scrollRef.current; - if (!container) return; + const sectionGroups = sections.map(sec => ({ + ...sec.groups[0], + title: sec.title, + groups: sec.groups, + })); - const centerX = container.offsetWidth / 2; - const scrollLeft = container.scrollLeft; - - itemRefs.current.forEach(item => { - if (!item) return; - const itemCenter = item.offsetLeft + item.offsetWidth / 2; - const dist = Math.abs(itemCenter - scrollLeft - centerX); - const ratio = Math.min(dist / centerX, 1); - const scale = 1 - ratio * 0.1; - item.style.transform = `scale(${scale})`; - }); - }, []); - - useEffect(() => { - const container = scrollRef.current; - if (!container || sections.length === 0) return; - - const mid = Math.floor(sections.length / 2); - const midItem = itemRefs.current[mid]; - if (midItem) { - const centerX = container.offsetWidth / 2; - const targetScroll = midItem.offsetLeft + midItem.offsetWidth / 2 - centerX; - container.scrollTo({ left: targetScroll, behavior: 'auto' }); - } - handleScroll(); - }, [sections.length, handleScroll]); - - useEffect(() => { - const container = scrollRef.current; - if (!container) return; - container.addEventListener('scroll', handleScroll, { passive: true }); - return () => { - container.removeEventListener('scroll', handleScroll); - }; - }, [handleScroll]); + const { scrollRef, cardRefs, infiniteGroups } = useInfiniteCarousel(sectionGroups, { + scaleAmount: 0.08, + }); return ( - {sections.map((sec, i) => ( + {infiniteGroups.map((g, i) => ( { - itemRefs.current[i] = el; + cardRefs.current[i] = el; }} > - + ))} @@ -88,4 +57,5 @@ const Item = styled.div` max-width: 640px; scroll-snap-align: center; transition: transform 0.2s; + height: 800px; `; diff --git a/src/hooks/useInfiniteCarousel.ts b/src/hooks/useInfiniteCarousel.ts index 50afb840..e5888c7f 100644 --- a/src/hooks/useInfiniteCarousel.ts +++ b/src/hooks/useInfiniteCarousel.ts @@ -3,7 +3,7 @@ import type { Group } from '../components/group/MyGroupBox'; const CLONE_COUNT = 10; -export function useInfiniteCarousel(groups: Group[]) { +export function useInfiniteCarousel(groups: Group[], options?: { scaleAmount?: number }) { const scrollRef = useRef(null); const cardRefs = useRef<(HTMLDivElement | null)[]>([]); const [current, setCurrent] = useState(0); @@ -14,6 +14,7 @@ export function useInfiniteCarousel(groups: Group[]) { const middleIndex = useMemo(() => Math.floor(infiniteGroups.length / 2), [infiniteGroups]); + const scaleAmount = options?.scaleAmount ?? 0.17; const handleScroll = useCallback(() => { const container = scrollRef.current; if (!container) return; @@ -28,7 +29,7 @@ export function useInfiniteCarousel(groups: Group[]) { if (!card) return; const cardCenter = card.offsetLeft + card.offsetWidth / 2; const distance = Math.abs(center - (cardCenter - scrollLeft)); - const scale = Math.max(0.83, 1 - (distance / center) * 0.17); + const scale = Math.max(0.83, 1 - (distance / center) * scaleAmount); card.style.transform = `scale(${scale})`; if (distance < minDist) { @@ -49,7 +50,7 @@ export function useInfiniteCarousel(groups: Group[]) { container.scrollLeft = left; } } - }, [groups.length, middleIndex]); + }, [groups.length, middleIndex, scaleAmount]); useEffect(() => { const container = scrollRef.current; diff --git a/src/pages/group/Group.tsx b/src/pages/group/Group.tsx index 2d401992..415fcdf6 100644 --- a/src/pages/group/Group.tsx +++ b/src/pages/group/Group.tsx @@ -36,7 +36,6 @@ const Group = () => { const [sections, setSections] = useState([ { title: '마감 임박한 독서 모임방', groups: [] }, { title: '인기 있는 독서 모임방', groups: [] }, - { title: '인플루언서·작가 독서 모임방', groups: [] }, ]); const fetchAllRoomsData = async () => { @@ -62,14 +61,12 @@ const Group = () => { setSections([ { title: '마감 임박한 독서 모임방', groups: deadlineRoomsData }, { title: '인기 있는 독서 모임방', groups: popularRoomsData }, - { title: '인플루언서·작가 독서 모임방', groups: [] }, ]); } catch (error) { console.error('방 목록 조회 오류:', error); setSections([ { title: '마감 임박한 독서 모임방', groups: [] }, { title: '인기 있는 독서 모임방', groups: [] }, - { title: '인플루언서·작가 독서 모임방', groups: [] }, ]); } }; From 3d03c632f8f340d4f3b1861361a650b3e1c943f0 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 14:50:00 +0900 Subject: [PATCH 58/86] =?UTF-8?q?feat:=20=EC=98=A4=EB=8A=98=EC=9D=98=20?= =?UTF-8?q?=ED=95=9C=EB=A7=88=EB=94=94=20=EC=9E=91=EC=84=B1=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/rooms/createDailyGreeting.ts | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/api/rooms/createDailyGreeting.ts diff --git a/src/api/rooms/createDailyGreeting.ts b/src/api/rooms/createDailyGreeting.ts new file mode 100644 index 00000000..ebc697c3 --- /dev/null +++ b/src/api/rooms/createDailyGreeting.ts @@ -0,0 +1,58 @@ +import { apiClient } from '../index'; + +// 오늘의 한마디 작성 요청 데이터 타입 +export interface CreateDailyGreetingRequest { + content: string; // 오늘의 한마디 작성 내용 +} + +// 오늘의 한마디 작성 응답 데이터 타입 +export interface CreateDailyGreetingData { + attendanceCheckId: number; // 출석체크 ID +} + +// API 응답 타입 +export interface CreateDailyGreetingResponse { + isSuccess: boolean; + code: number; + message: string; + data: CreateDailyGreetingData; +} + +// 오늘의 한마디 작성 API 함수 +export const createDailyGreeting = async ( + roomId: number, + content: string, +): Promise => { + try { + const requestBody: CreateDailyGreetingRequest = { + content, + }; + + const response = await apiClient.post( + `/rooms/${roomId}/daily-greeting`, + requestBody, + ); + + return response.data; + } catch (error) { + console.error('오늘의 한마디 작성 API 오류:', error); + throw error; + } +}; + +/* +사용 예시: +try { + const result = await createDailyGreeting(1, "오늘도 좋은 하루 보내세요!"); + if (result.isSuccess) { + console.log("오늘의 한마디 작성 성공:", result.data.attendanceCheckId); + // 성공 처리 로직 (예: 성공 메시지 표시, 페이지 새로고침 등) + } else { + console.error("오늘의 한마디 작성 실패:", result.message); + // 실패 처리 로직 (예: 에러 메시지 표시) + } +} catch (error) { + console.error("API 호출 오류:", error); + // 네트워크 에러 처리 로직 +} +*/ From 3c4d65cdae073a9c3ed48acfe26f14bc5f8ac417 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 14:53:10 +0900 Subject: [PATCH 59/86] =?UTF-8?q?refactor:=20carousel=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/group/MyGroupBox.tsx | 22 ++++++- .../group/RecruitingGroupCarousel.tsx | 60 ++++++++++++++----- src/hooks/useInfiniteCarousel.ts | 7 +-- src/pages/group/Group.tsx | 3 + 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/src/components/group/MyGroupBox.tsx b/src/components/group/MyGroupBox.tsx index 09d9915d..a040cba0 100644 --- a/src/components/group/MyGroupBox.tsx +++ b/src/components/group/MyGroupBox.tsx @@ -59,7 +59,7 @@ export function MyGroupBox({ onMyGroupsClick }: MyGroupProps) { fetchJoinedRooms(); }, []); - const { scrollRef, cardRefs, infiniteGroups } = useInfiniteCarousel(groups); + const { scrollRef, cardRefs, infiniteGroups, current } = useInfiniteCarousel(groups); return ( @@ -90,6 +90,11 @@ export function MyGroupBox({ onMyGroupsClick }: MyGroupProps) { /> ))} + + {groups.map((_, i) => ( + + ))} + ) : ( @@ -143,6 +148,21 @@ const Carousel = styled.div` scroll-snap-type: x mandatory; `; +const Dots = styled.div` + display: flex; + justify-content: center; + gap: 12px; + margin: 30px 0; +`; + +const Dot = styled.div<{ active: boolean }>` + width: 4px; + height: 4px; + border-radius: 50%; + background: ${({ active }) => (active ? colors.white : colors.grey[300])}; + transition: background-color 0.3s; +`; + const LoadingContainer = styled.div` display: flex; justify-content: center; diff --git a/src/components/group/RecruitingGroupCarousel.tsx b/src/components/group/RecruitingGroupCarousel.tsx index 58ce5b28..5f69cfd9 100644 --- a/src/components/group/RecruitingGroupCarousel.tsx +++ b/src/components/group/RecruitingGroupCarousel.tsx @@ -1,7 +1,7 @@ +import { useRef, useEffect, useCallback } from 'react'; import styled from '@emotion/styled'; import type { Group } from './MyGroupBox'; import { RecruitingGroupBox } from './RecruitingGroupBox'; -import { useInfiniteCarousel } from '../../hooks/useInfiniteCarousel'; export interface Section { title: string; @@ -13,28 +13,59 @@ interface Props { } export function RecruitingGroupCarousel({ sections }: Props) { - const groups = sections.map(sec => ({ ...sec.groups[0], title: sec.title, groups: sec.groups })); + const scrollRef = useRef(null); + const itemRefs = useRef>([]); - const sectionGroups = sections.map(sec => ({ - ...sec.groups[0], - title: sec.title, - groups: sec.groups, - })); + const handleScroll = useCallback(() => { + const container = scrollRef.current; + if (!container) return; - const { scrollRef, cardRefs, infiniteGroups } = useInfiniteCarousel(sectionGroups, { - scaleAmount: 0.08, - }); + const centerX = container.offsetWidth / 2; + const scrollLeft = container.scrollLeft; + + itemRefs.current.forEach(item => { + if (!item) return; + const itemCenter = item.offsetLeft + item.offsetWidth / 2; + const dist = Math.abs(itemCenter - scrollLeft - centerX); + const ratio = Math.min(dist / centerX, 1); + const scale = 1 - ratio * 0.1; + item.style.transform = `scale(${scale})`; + }); + }, []); + + useEffect(() => { + const container = scrollRef.current; + if (!container || sections.length === 0) return; + + const mid = Math.floor(sections.length / 2); + const midItem = itemRefs.current[mid]; + if (midItem) { + const centerX = container.offsetWidth / 2; + const targetScroll = midItem.offsetLeft + midItem.offsetWidth / 2 - centerX; + container.scrollTo({ left: targetScroll, behavior: 'auto' }); + } + handleScroll(); + }, [sections.length, handleScroll]); + + useEffect(() => { + const container = scrollRef.current; + if (!container) return; + container.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + container.removeEventListener('scroll', handleScroll); + }; + }, [handleScroll]); return ( - {infiniteGroups.map((g, i) => ( + {sections.map((sec, i) => ( { - cardRefs.current[i] = el; + itemRefs.current[i] = el; }} > - + ))} @@ -57,5 +88,4 @@ const Item = styled.div` max-width: 640px; scroll-snap-align: center; transition: transform 0.2s; - height: 800px; `; diff --git a/src/hooks/useInfiniteCarousel.ts b/src/hooks/useInfiniteCarousel.ts index e5888c7f..50afb840 100644 --- a/src/hooks/useInfiniteCarousel.ts +++ b/src/hooks/useInfiniteCarousel.ts @@ -3,7 +3,7 @@ import type { Group } from '../components/group/MyGroupBox'; const CLONE_COUNT = 10; -export function useInfiniteCarousel(groups: Group[], options?: { scaleAmount?: number }) { +export function useInfiniteCarousel(groups: Group[]) { const scrollRef = useRef(null); const cardRefs = useRef<(HTMLDivElement | null)[]>([]); const [current, setCurrent] = useState(0); @@ -14,7 +14,6 @@ export function useInfiniteCarousel(groups: Group[], options?: { scaleAmount?: n const middleIndex = useMemo(() => Math.floor(infiniteGroups.length / 2), [infiniteGroups]); - const scaleAmount = options?.scaleAmount ?? 0.17; const handleScroll = useCallback(() => { const container = scrollRef.current; if (!container) return; @@ -29,7 +28,7 @@ export function useInfiniteCarousel(groups: Group[], options?: { scaleAmount?: n if (!card) return; const cardCenter = card.offsetLeft + card.offsetWidth / 2; const distance = Math.abs(center - (cardCenter - scrollLeft)); - const scale = Math.max(0.83, 1 - (distance / center) * scaleAmount); + const scale = Math.max(0.83, 1 - (distance / center) * 0.17); card.style.transform = `scale(${scale})`; if (distance < minDist) { @@ -50,7 +49,7 @@ export function useInfiniteCarousel(groups: Group[], options?: { scaleAmount?: n container.scrollLeft = left; } } - }, [groups.length, middleIndex, scaleAmount]); + }, [groups.length, middleIndex]); useEffect(() => { const container = scrollRef.current; diff --git a/src/pages/group/Group.tsx b/src/pages/group/Group.tsx index 415fcdf6..2d401992 100644 --- a/src/pages/group/Group.tsx +++ b/src/pages/group/Group.tsx @@ -36,6 +36,7 @@ const Group = () => { const [sections, setSections] = useState([ { title: '마감 임박한 독서 모임방', groups: [] }, { title: '인기 있는 독서 모임방', groups: [] }, + { title: '인플루언서·작가 독서 모임방', groups: [] }, ]); const fetchAllRoomsData = async () => { @@ -61,12 +62,14 @@ const Group = () => { setSections([ { title: '마감 임박한 독서 모임방', groups: deadlineRoomsData }, { title: '인기 있는 독서 모임방', groups: popularRoomsData }, + { title: '인플루언서·작가 독서 모임방', groups: [] }, ]); } catch (error) { console.error('방 목록 조회 오류:', error); setSections([ { title: '마감 임박한 독서 모임방', groups: [] }, { title: '인기 있는 독서 모임방', groups: [] }, + { title: '인플루언서·작가 독서 모임방', groups: [] }, ]); } }; From baa27d089f46daa375daf2c2f5630adcd14a3b2c Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 14:57:00 +0900 Subject: [PATCH 60/86] =?UTF-8?q?feat:=20TodayWords=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20roo?= =?UTF-8?q?mId=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/today-words/MessageInput.tsx | 27 +++- src/pages/index.tsx | 2 +- src/pages/today-words/TodayWords.tsx | 150 +++++++++++++++----- 3 files changed, 140 insertions(+), 39 deletions(-) diff --git a/src/components/today-words/MessageInput.tsx b/src/components/today-words/MessageInput.tsx index e79d6010..4ee9bc19 100644 --- a/src/components/today-words/MessageInput.tsx +++ b/src/components/today-words/MessageInput.tsx @@ -19,6 +19,7 @@ interface MessageInputProps { isReplying?: boolean; onCancelReply?: () => void; nickname?: string; + disabled?: boolean; } const MessageInput = ({ @@ -29,11 +30,13 @@ const MessageInput = ({ isReplying = false, onCancelReply, nickname, + disabled = false, }: MessageInputProps) => { const inputRef = useRef(null); const [isComposing, setIsComposing] = useState(false); const handleInputChange = (e: React.ChangeEvent) => { + if (disabled) return; onChange(e.target.value); if (inputRef.current) { @@ -43,7 +46,7 @@ const MessageInput = ({ }; const handleSend = () => { - if (!value.trim()) return; + if (!value.trim() || disabled) return; onSend(); onChange(''); if (inputRef.current) { @@ -52,6 +55,7 @@ const MessageInput = ({ }; const handleKeyDown = (e: React.KeyboardEvent) => { + if (disabled) return; if (e.key === 'Enter' && !e.shiftKey && !isComposing) { e.preventDefault(); handleSend(); @@ -83,15 +87,28 @@ const MessageInput = ({ setIsComposing(true)} - onCompositionEnd={() => setIsComposing(false)} + onCompositionStart={() => !disabled && setIsComposing(true)} // disabled일 때는 composing 상태 변경 안함 + onCompositionEnd={() => !disabled && setIsComposing(false)} rows={1} + disabled={disabled} + style={{ + opacity: disabled ? 0.6 : 1, + cursor: disabled ? 'not-allowed' : 'text', + }} /> - + 전송 diff --git a/src/pages/index.tsx b/src/pages/index.tsx index dfccc374..00dff702 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -69,7 +69,7 @@ const Router = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/pages/today-words/TodayWords.tsx b/src/pages/today-words/TodayWords.tsx index 86c91761..632cfc8a 100644 --- a/src/pages/today-words/TodayWords.tsx +++ b/src/pages/today-words/TodayWords.tsx @@ -1,5 +1,5 @@ -import { useState, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useState, useRef, useCallback } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import EmptyState from '../../components/today-words/EmptyState'; import MessageList from '../../components/today-words/MessageList/MessageList'; @@ -9,12 +9,17 @@ import leftarrow from '../../assets/common/leftArrow.svg'; import { Container, ContentArea } from './TodayWords.styled'; import type { Message } from '../../types/today'; import { dummyMessages } from '../../constants/today-constants'; +import { createDailyGreeting } from '../../api/rooms/createDailyGreeting'; +import { usePopupActions } from '../../hooks/usePopupActions'; const TodayWords = () => { const navigate = useNavigate(); + const { roomId } = useParams<{ roomId: string }>(); const messageListRef = useRef(null); const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const { openSnackbar } = usePopupActions(); // 개발용: 빈 상태와 글 있는 상태 토글 const [showMessages, setShowMessages] = useState(false); @@ -23,45 +28,123 @@ const TodayWords = () => { navigate(-1); }; - const handleSendMessage = () => { - if (inputValue.trim() === '') return; + const handleSendMessage = useCallback(async () => { + if (inputValue.trim() === '' || isSubmitting) return; - // 빈 상태에서 메시지를 보낼 때 실제 messages 상태를 업데이트 - if (!showMessages) { - // 새 메시지 생성 - const now = new Date(); - const newMessage: Message = { - id: Date.now().toString(), - user: 'user.01', - content: inputValue.trim(), - timestamp: now - .toLocaleDateString('ko-KR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }) - .replace(/\. /g, '.') - .replace(/\.$/, ''), - timeAgo: '방금 전', - createdAt: now, - }; - - // 실제 messages 상태에 추가 - setMessages(prevMessages => [...prevMessages, newMessage]); - } else { - // MessageList의 addMessage 함수 호출 (더미 데이터 상태일 때) - if (messageListRef.current) { - messageListRef.current.addMessage(inputValue.trim()); + // roomId가 없으면 에러 처리 + if (!roomId) { + openSnackbar({ + message: '방 정보를 찾을 수 없습니다.', + variant: 'top', + onClose: () => {}, + }); + return; + } + + setIsSubmitting(true); + + try { + // API 호출 - 오늘의 한마디 작성 + const response = await createDailyGreeting(parseInt(roomId), inputValue.trim()); + + if (response.isSuccess) { + // 성공 시 새 메시지 생성 + const now = new Date(); + const newMessage: Message = { + id: response.data.attendanceCheckId.toString(), + user: 'user.01', // TODO: 실제 사용자 정보로 변경 + content: inputValue.trim(), + timestamp: now + .toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + .replace(/\. /g, '.') + .replace(/\.$/, ''), + timeAgo: '방금 전', + createdAt: now, + }; + + // 실제 messages 상태에 추가 + setMessages(prevMessages => [...prevMessages, newMessage]); + + // 입력 필드 초기화 + setInputValue(''); + + // 성공 메시지 표시 + openSnackbar({ + message: '오늘의 한마디가 작성되었습니다.', + variant: 'top', + onClose: () => {}, + }); + + // 자동으로 스크롤을 아래로 이동 + setTimeout(() => { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + }, 100); + } else { + // API 에러 응답 처리 + openSnackbar({ + message: response.message || '오늘의 한마디 작성에 실패했습니다.', + variant: 'top', + onClose: () => {}, + }); } + } catch (error) { + console.error('오늘의 한마디 작성 오류:', error); + + // 에러 타입에 따른 메시지 처리 + let errorMessage = '오늘의 한마디 작성 중 오류가 발생했습니다.'; + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { + response?: { + data?: { + message?: string; + code?: number; + }; + }; + }; + + if (axiosError.response?.data?.message) { + errorMessage = axiosError.response.data.message; + } else if (axiosError.response?.data?.code === 400) { + errorMessage = '오늘의 한마디 작성 가능 횟수를 초과했습니다.'; + } else if (axiosError.response?.data?.code === 403) { + errorMessage = '방 접근 권한이 없습니다.'; + } else if (axiosError.response?.data?.code === 404) { + errorMessage = '존재하지 않는 방입니다.'; + } + } + + openSnackbar({ + message: errorMessage, + variant: 'top', + onClose: () => {}, + }); + } finally { + setIsSubmitting(false); } + }, [inputValue, roomId, isSubmitting, openSnackbar]); + // 더미 모드에서 메시지 전송 처리 (개발용) + const handleDummySendMessage = useCallback(() => { + if (inputValue.trim() === '') return; + + if (messageListRef.current) { + messageListRef.current.addMessage(inputValue.trim()); + } setInputValue(''); // 자동으로 스크롤을 아래로 이동 setTimeout(() => { window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); }, 100); - }; + }, [inputValue]); + + // 최종 메시지 전송 핸들러 + const finalHandleSendMessage = showMessages ? handleDummySendMessage : handleSendMessage; // MessageList에서 메시지가 삭제되었을 때 호출될 콜백 const handleMessageDelete = (messageId: string) => { @@ -97,8 +180,9 @@ const TodayWords = () => { {/* 개발용 토글 버튼 */} From 8a0a9f81fa07e6344abb95dab0270c53f6c30b5e Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Sat, 16 Aug 2025 15:05:34 +0900 Subject: [PATCH 61/86] =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/Modal/PopupContainer.tsx | 2 +- src/components/common/Modal/ReplyModal.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/common/Modal/PopupContainer.tsx b/src/components/common/Modal/PopupContainer.tsx index e3b3afae..29bdc1b1 100644 --- a/src/components/common/Modal/PopupContainer.tsx +++ b/src/components/common/Modal/PopupContainer.tsx @@ -69,7 +69,7 @@ const Wrapper = styled.div` min-width: 320px; max-width: 767px; margin: 0 auto; - background-color: rgba(18, 18, 18, 0.1); + background-color: rgba(18, 18, 18, 0.3); backdrop-filter: blur(2.5px); z-index: 1000; `; diff --git a/src/components/common/Modal/ReplyModal.tsx b/src/components/common/Modal/ReplyModal.tsx index 365667c0..2adb16be 100644 --- a/src/components/common/Modal/ReplyModal.tsx +++ b/src/components/common/Modal/ReplyModal.tsx @@ -111,7 +111,7 @@ const ModalContainer = styled.div` padding: 20px 12px; border-radius: 16px; border: 1px solid ${colors.grey[200]}; - background-color: ${colors.black.main}; + background-color: rgba(18, 18, 18, 0.3); z-index: 1001; `; From aaa7e6ca0ebb03eadc17cef5d4cf0f45df14eada Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Sat, 16 Aug 2025 15:06:44 +0900 Subject: [PATCH 62/86] =?UTF-8?q?fix:=20bookinfocard=20=EB=84=A4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/feed/BookInfoCard.tsx | 50 ++++++++++++++-------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/components/feed/BookInfoCard.tsx b/src/components/feed/BookInfoCard.tsx index f512436c..a4c9563f 100644 --- a/src/components/feed/BookInfoCard.tsx +++ b/src/components/feed/BookInfoCard.tsx @@ -2,6 +2,31 @@ import { useNavigate } from 'react-router-dom'; import styled from '@emotion/styled'; import rightArrow from '../../assets/common/rightArrow.svg'; +interface BookInfoCardProps { + bookTitle: string; + bookAuthor: string; + isbn: string; +} + +const BookInfoCard = ({ bookTitle, bookAuthor, isbn }: BookInfoCardProps) => { + const navigate = useNavigate(); + + const handleClick = () => { + navigate(`/search/book/${isbn}`); + }; + + return ( + +
{bookTitle}
+
+
{bookAuthor}
+
+ +
+
+ ); +}; + const BookContainer = styled.div` display: flex; height: 44px; @@ -48,29 +73,4 @@ const BookContainer = styled.div` } `; -interface BookInfoCardProps { - bookTitle: string; - bookAuthor: string; - isbn: string; -} - -const BookInfoCard = ({ bookTitle, bookAuthor, isbn }: BookInfoCardProps) => { - const navigate = useNavigate(); - - const handleClick = () => { - navigate(`/book/${isbn}`); - }; - - return ( - -
{bookTitle}
-
-
{bookAuthor}
-
- -
-
- ); -}; - export default BookInfoCard; From ada1d89a163d4e45028bbd72473d518043161acb Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 15:35:09 +0900 Subject: [PATCH 63/86] =?UTF-8?q?feat:=20myGroupCard=20navigate=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/group/MyGroupBox.tsx | 7 +++++++ src/components/group/MyGroupCard.tsx | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/group/MyGroupBox.tsx b/src/components/group/MyGroupBox.tsx index 09d9915d..b1e297b1 100644 --- a/src/components/group/MyGroupBox.tsx +++ b/src/components/group/MyGroupBox.tsx @@ -3,6 +3,7 @@ import { useInfiniteCarousel } from '../../hooks/useInfiniteCarousel'; import styled from '@emotion/styled'; import rightChevron from '../../assets/common/right-Chevron.svg'; import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { getJoinedRooms, type JoinedRoomItem } from '@/api/rooms/getJoinedRooms'; import { colors, typography } from '@/styles/global/global'; @@ -35,6 +36,7 @@ export function MyGroupBox({ onMyGroupsClick }: MyGroupProps) { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const navigate = useNavigate(); const fetchJoinedRooms = async () => { try { @@ -59,6 +61,10 @@ export function MyGroupBox({ onMyGroupsClick }: MyGroupProps) { fetchJoinedRooms(); }, []); + const handleCardClick = (roomId: number | string) => { + navigate(`detail/joined/${roomId}`); + }; + const { scrollRef, cardRefs, infiniteGroups } = useInfiniteCarousel(groups); return ( @@ -87,6 +93,7 @@ export function MyGroupBox({ onMyGroupsClick }: MyGroupProps) { ref={el => { cardRefs.current[i] = el; }} + onClick={() => handleCardClick(g.id)} /> ))} diff --git a/src/components/group/MyGroupCard.tsx b/src/components/group/MyGroupCard.tsx index e06586e6..8734c2b8 100644 --- a/src/components/group/MyGroupCard.tsx +++ b/src/components/group/MyGroupCard.tsx @@ -5,11 +5,13 @@ import type { Group } from './MyGroupBox'; interface MyGroupCardProps { group: Group; + onClick?: () => void; } -export const MyGroupCard = forwardRef(({ group }, ref) => { +export const MyGroupCard = forwardRef((props, ref) => { + const { group, onClick } = props; return ( - +
From c291955f5810976c9dc6d17da205d5a70fd75952 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 15:47:22 +0900 Subject: [PATCH 64/86] =?UTF-8?q?design:=20bannerSection=20margin-top=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/searchBook/SearchBook.styled.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/searchBook/SearchBook.styled.ts b/src/pages/searchBook/SearchBook.styled.ts index 20f5c38c..303d62da 100644 --- a/src/pages/searchBook/SearchBook.styled.ts +++ b/src/pages/searchBook/SearchBook.styled.ts @@ -59,7 +59,7 @@ export const BannerSection = styled.section` flex-direction: column; width: 100%; padding: 20px; - margin-top: 66px; + margin-top: 24%; gap: 32px; color: ${colors.white}; z-index: 10; From a60b5d4b4f1e9d79cbe91e674651ac7fc3e16d0f Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 15:57:33 +0900 Subject: [PATCH 65/86] feat: searchBook handleWritePostButton --- src/pages/searchBook/SearchBook.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 3aeb03b7..94e6d69e 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -123,7 +123,13 @@ const SearchBook = () => { } }; - const handleWritePostButton = () => {}; + const handleWritePostButton = () => { + if (isbn) { + navigate(`/group/create?isbn=${isbn}`); + } else { + navigate('/group/create'); + } + }; const handleSaveButton = async () => { if (!isbn || isSaving) return; From 5798717d65854aee499d73d5391215df60b47e21 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 16:22:48 +0900 Subject: [PATCH 66/86] =?UTF-8?q?feat:=20SearchBook=EC=97=90=EC=84=9C=20Cr?= =?UTF-8?q?eateGroup=20=EC=9D=B4=EB=8F=99=20=EC=8B=9C=20=EC=B1=85=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EB=8A=94=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/group/CreateGroup.tsx | 18 ++++++++++++++++-- src/pages/searchBook/SearchBook.tsx | 4 ++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/pages/group/CreateGroup.tsx b/src/pages/group/CreateGroup.tsx index 0f9e84e0..17394ef5 100644 --- a/src/pages/group/CreateGroup.tsx +++ b/src/pages/group/CreateGroup.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import BookSearchBottomSheet from '../../components/common/BookSearchBottomSheet/BookSearchBottomSheet'; import BookSelectionSection from '../../components/creategroup/BookSelectionSection'; @@ -24,7 +24,21 @@ interface Book { const CreateGroup = () => { const navigate = useNavigate(); - const [selectedBook, setSelectedBook] = useState(null); + const location = useLocation(); + + function convertBookDetailToBook(bookDetail: any): Book | null { + if (!bookDetail) return null; + return { + title: bookDetail.title, + author: bookDetail.authorName, + cover: bookDetail.imageUrl, + isbn: bookDetail.isbn, + }; + } + + const [selectedBook, setSelectedBook] = useState( + convertBookDetailToBook(location.state?.selectedBook), + ); const [selectedGenre, setSelectedGenre] = useState(''); const [roomTitle, setRoomTitle] = useState(''); const [roomDescription, setRoomDescription] = useState(''); diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 94e6d69e..31310934 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -124,8 +124,8 @@ const SearchBook = () => { }; const handleWritePostButton = () => { - if (isbn) { - navigate(`/group/create?isbn=${isbn}`); + if (bookDetail) { + navigate('/group/create', { state: { selectedBook: bookDetail } }); } else { navigate('/group/create'); } From 209919fca36cab913c2ae425a6f70fa3e2e576f8 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 16:27:34 +0900 Subject: [PATCH 67/86] =?UTF-8?q?remove:=20SearchBook=20=EB=8D=94=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EB=B2=84=ED=8A=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/searchBook/SearchBook.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 31310934..b3feb3d6 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -23,7 +23,6 @@ import { } from './SearchBook.styled'; import { useNavigate, useParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; -import moreIcon from '../../assets/common/more.svg'; import { IconButton } from '@/components/common/IconButton'; import saveIcon from '../../assets/common/SaveIcon.svg'; import filledSaveIcon from '../../assets/common/filledSaveIcon.svg'; @@ -100,8 +99,6 @@ const SearchBook = () => { const handleCloseIntroModal = () => setShowIntroModal(false); - const handleMoreButton = () => {}; - const handleRecruitingGroupButton = () => { if (bookDetail) { navigate('/search/book/group', { @@ -155,7 +152,6 @@ const SearchBook = () => {
-
{isLoading ? '로딩 중...' : error || '책 정보를 찾을 수 없습니다.'} @@ -169,7 +165,6 @@ const SearchBook = () => {
-
From 69eacbb69c398828a70b26f00a9b728c376ce4a4 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 16:30:25 +0900 Subject: [PATCH 68/86] =?UTF-8?q?remove:=20GroupDetail=20=EB=8D=94?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EB=B2=84=ED=8A=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/groupDetail/GroupDetail.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/pages/groupDetail/GroupDetail.tsx b/src/pages/groupDetail/GroupDetail.tsx index 596974b7..f38613f8 100644 --- a/src/pages/groupDetail/GroupDetail.tsx +++ b/src/pages/groupDetail/GroupDetail.tsx @@ -27,7 +27,6 @@ import { BottomButton, } from './GroupDetail.styled'; import leftArrow from '../../assets/common/leftArrow.svg'; -import moreIcon from '../../assets/common/more.svg'; import { useNavigate, useParams } from 'react-router-dom'; import { IconButton } from '@/components/common/IconButton'; import lockIcon from '../../assets/group/lock.svg'; @@ -54,8 +53,6 @@ const GroupDetail = () => { navigate(-1); }; - const handleMoreButton = () => {}; - const convertRecommendRoomToGroup = (room: RecommendRoom): Group => { return { id: room.roomId.toString(), @@ -139,7 +136,6 @@ const GroupDetail = () => {
-
From 8e2f3855d109c078aee9fdc2b1f72e7eeab887a1 Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Sat, 16 Aug 2025 16:42:58 +0900 Subject: [PATCH 69/86] =?UTF-8?q?feat:=20=EC=B1=85=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EB=AC=B4=ED=95=9C=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/search/BookSearchResult.tsx | 31 +++++++- src/pages/search/Search.tsx | 83 +++++++++++++++++++++- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/components/search/BookSearchResult.tsx b/src/components/search/BookSearchResult.tsx index a061e8ef..4d6c9d6d 100644 --- a/src/components/search/BookSearchResult.tsx +++ b/src/components/search/BookSearchResult.tsx @@ -6,10 +6,20 @@ import { useNavigate } from 'react-router-dom'; interface BookSearchResultProps { type: 'searching' | 'searched'; searchedBookList: SearchedBook[]; + hasMore?: boolean; + isLoading?: boolean; + lastBookElementCallback?: (node: HTMLDivElement | null) => void; } -export function BookSearchResult({ type, searchedBookList }: BookSearchResultProps) { +export function BookSearchResult({ + type, + searchedBookList, + hasMore = false, + isLoading = false, + lastBookElementCallback, +}: BookSearchResultProps) { const navigate = useNavigate(); + const isEmptySearchedBookList = () => { if (searchedBookList.length === 0) return true; else return false; @@ -18,6 +28,7 @@ export function BookSearchResult({ type, searchedBookList }: BookSearchResultPro const handleApplyBook = () => { navigate('/search/applybook'); }; + return ( @@ -30,8 +41,16 @@ export function BookSearchResult({ type, searchedBookList }: BookSearchResultPro 책 신청하기 ) : ( - searchedBookList.map(book => ( - navigate(`/search/book/${book.isbn}`)}> + searchedBookList.map((book, index) => ( + navigate(`/search/book/${book.isbn}`)} + ref={ + index === searchedBookList.length - 1 && lastBookElementCallback + ? lastBookElementCallback + : undefined + } + > {book.title} @@ -42,6 +61,12 @@ export function BookSearchResult({ type, searchedBookList }: BookSearchResultPro )) )} + + {/* 로딩 상태 표시 */} + {isLoading && searchedBookList.length > 0 && <>} + + {/* 더 이상 데이터가 없음을 표시 */} + {!hasMore && searchedBookList.length > 0 && <>} ); diff --git a/src/pages/search/Search.tsx b/src/pages/search/Search.tsx index 99eddef8..fb5a28ec 100644 --- a/src/pages/search/Search.tsx +++ b/src/pages/search/Search.tsx @@ -6,7 +6,7 @@ import RecentSearchTabs from '@/components/search/RecentSearchTabs'; import SearchBar from '@/components/search/SearchBar'; import { colors, typography } from '@/styles/global/global'; import styled from '@emotion/styled'; -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; import { getSearchBooks, convertToSearchedBooks } from '@/api/books/getSearchBooks'; @@ -31,9 +31,18 @@ const Search = () => { const [isLoading, setIsLoading] = useState(false); const [isInitialized, setIsInitialized] = useState(false); + // 무한스크롤 관련 상태 + const [hasMore, setHasMore] = useState(true); + const [page, setPage] = useState(1); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [recentSearches, setRecentSearches] = useState([]); const [searchTimeoutId, setSearchTimeoutId] = useState(null); + // Intersection Observer를 위한 ref + const observerRef = useRef(null); + const lastBookElementRef = useRef(null); + const fetchRecentSearches = async () => { try { const response = await getRecentSearch('BOOK'); @@ -54,10 +63,63 @@ const Search = () => { fetchRecentSearches(); }, []); + // 무한스크롤을 위한 Intersection Observer 설정 + const lastBookElementCallback = useCallback( + (node: HTMLDivElement | null) => { + if (isLoadingMore || !hasMore) return; + + if (observerRef.current) observerRef.current.disconnect(); + + observerRef.current = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && hasMore && !isLoadingMore) { + loadMore(); + } + }); + if (node) observerRef.current.observe(node); + lastBookElementRef.current = node; + }, + [isLoadingMore, hasMore], + ); + + // 추가 데이터 로드 함수 + const loadMore = async () => { + if (!searchTerm.trim() || isLoadingMore || !hasMore) return; + + try { + setIsLoadingMore(true); + const nextPage = page + 1; + + const response = await getSearchBooks(searchTerm, nextPage, isFinalized); + + if (response.isSuccess) { + const newResults = convertToSearchedBooks(response.data.searchResult); + + if (newResults.length > 0) { + setSearchResults(prev => [...prev, ...newResults]); + setPage(nextPage); + // 더 이상 데이터가 없으면 hasMore를 false로 설정 + setHasMore(newResults.length === 10); // size가 10이므로 + } else { + setHasMore(false); + } + } else { + console.error('추가 데이터 로드 실패:', response.message); + setHasMore(false); + } + } catch (error) { + console.error('추가 데이터 로드 중 오류 발생:', error); + setHasMore(false); + } finally { + setIsLoadingMore(false); + } + }; + const handleChange = (value: string) => { setSearchTerm(value); setIsFinalized(false); setIsSearching(value.trim() !== ''); + setHasMore(true); // 새로운 검색 시 hasMore 초기화 + setPage(1); // 페이지 초기화 if (value.trim()) { setSearchParams({ q: value.trim() }, { replace: true }); @@ -73,9 +135,10 @@ const Search = () => { setSearchTimeoutId(timeoutId); } else { setSearchParams({}, { replace: true }); - setSearchResults([]); setIsFinalized(false); + setHasMore(true); + setPage(1); if (searchTimeoutId) { clearTimeout(searchTimeoutId); @@ -94,6 +157,8 @@ const Search = () => { } setIsLoading(true); + setPage(1); // 검색 시 페이지 초기화 + setHasMore(true); // 검색 시 hasMore 초기화 try { const response = await getSearchBooks(term, 1, isManualSearch); @@ -101,9 +166,12 @@ const Search = () => { if (response.isSuccess) { const convertedResults = convertToSearchedBooks(response.data.searchResult); setSearchResults(convertedResults); + // 더 이상 데이터가 없으면 hasMore를 false로 설정 + setHasMore(convertedResults.length === 10); // size가 10이므로 } else { console.log('검색 실패:', response.message); setSearchResults([]); + setHasMore(false); } if (isManualSearch) { @@ -112,6 +180,7 @@ const Search = () => { } catch (error) { console.error('검색 중 오류 발생:', error); setSearchResults([]); + setHasMore(false); if (isManualSearch) { setIsFinalized(true); } @@ -176,6 +245,8 @@ const Search = () => { setIsSearching(false); setIsFinalized(false); setIsInitialized(false); + setHasMore(true); + setPage(1); setSearchParams({}, { replace: true }); }; @@ -184,6 +255,9 @@ const Search = () => { if (searchTimeoutId) { clearTimeout(searchTimeoutId); } + if (observerRef.current) { + observerRef.current.disconnect(); + } }; }, [searchTimeoutId]); @@ -210,12 +284,15 @@ const Search = () => { {isSearching ? ( <> - {isLoading ? ( + {isLoading && searchResults.length === 0 ? ( 검색 중... ) : ( )} From 798057063ea081964b7c7475cc40ca4570b6d148 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 16:49:05 +0900 Subject: [PATCH 70/86] =?UTF-8?q?feat:=20=EB=AA=A8=EC=A7=91=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EC=9E=84=EB=B0=A9=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=9E=84=EB=B0=A9=20=EB=A7=8C=EB=93=A4=EA=B8=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=B1=85=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/group/CreateGroup.tsx | 14 +++++++------- src/pages/searchBook/SearchBook.tsx | 2 +- src/pages/searchBook/SearchBookGroup.tsx | 10 +++++++++- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/pages/group/CreateGroup.tsx b/src/pages/group/CreateGroup.tsx index 17394ef5..845b3326 100644 --- a/src/pages/group/CreateGroup.tsx +++ b/src/pages/group/CreateGroup.tsx @@ -26,18 +26,18 @@ const CreateGroup = () => { const navigate = useNavigate(); const location = useLocation(); - function convertBookDetailToBook(bookDetail: any): Book | null { - if (!bookDetail) return null; + function convertBookInfoToBook(bookInfo: any): Book | null { + if (!bookInfo) return null; return { - title: bookDetail.title, - author: bookDetail.authorName, - cover: bookDetail.imageUrl, - isbn: bookDetail.isbn, + title: bookInfo.title, + author: bookInfo.author, + cover: bookInfo.cover, + isbn: bookInfo.isbn, }; } const [selectedBook, setSelectedBook] = useState( - convertBookDetailToBook(location.state?.selectedBook), + convertBookInfoToBook(location.state?.selectedBook ?? location.state?.bookInfo), ); const [selectedGenre, setSelectedGenre] = useState(''); const [roomTitle, setRoomTitle] = useState(''); diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index b3feb3d6..c1258224 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -182,7 +182,7 @@ const SearchBook = () => { 오른쪽 화살표 아이콘 - + 피드에 글쓰기 더하기 아이콘 diff --git a/src/pages/searchBook/SearchBookGroup.tsx b/src/pages/searchBook/SearchBookGroup.tsx index 7db261db..c3aff3d3 100644 --- a/src/pages/searchBook/SearchBookGroup.tsx +++ b/src/pages/searchBook/SearchBookGroup.tsx @@ -24,7 +24,15 @@ const SearchBookGroup = () => { const handleBackButton = () => { navigate(-1); }; - const handleMakeGroup = () => {}; + const handleMakeGroup = () => { + const selectedBook = { + title: bookInfo.title, + author: bookInfo.author, + cover: bookInfo.imageUrl, + isbn: bookInfo.isbn, + }; + navigate('/group/create', { state: { selectedBook } }); + }; const groupList = recruitingRooms?.recruitingRoomList || []; const totalCount = recruitingRooms?.totalRoomCount || 0; From 95755b0a4a2472a69f9459f3595be68b2d7542be Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Sat, 16 Aug 2025 16:52:44 +0900 Subject: [PATCH 71/86] =?UTF-8?q?feat:=20=EB=9D=B1=ED=95=98=EA=B8=B0,?= =?UTF-8?q?=EB=9D=B1=EC=B7=A8=EC=86=8C=20snackbar=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/feed/Profile.tsx | 13 +++++++++++++ src/components/feed/UserProfileItem.tsx | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/components/feed/Profile.tsx b/src/components/feed/Profile.tsx index d4cffed8..e33f04be 100644 --- a/src/components/feed/Profile.tsx +++ b/src/components/feed/Profile.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import styled from '@emotion/styled'; import MyFollower from './MyFollower'; import { postFollow } from '@/api/users/postFollow'; +import { usePopupStore } from '@/stores/usePopupStore'; export interface ProfileProps { showFollowButton?: boolean; @@ -27,6 +28,7 @@ const Profile = ({ userId, }: ProfileProps) => { const [followed, setFollowed] = useState(isFollowing); + const { openPopup } = usePopupStore(); useEffect(() => { setFollowed(isFollowing); @@ -50,6 +52,17 @@ const Profile = ({ // API 응답으로 팔로우 상태 업데이트 setFollowed(response.data.isFollowing); console.log(`${nickname} - ${response.data.isFollowing ? '띱 완료' : '띱 취소'}`); + + // Snackbar 표시 + const message = response.data.isFollowing + ? `${nickname}님을 띱 했어요.` + : `${nickname}님을 띱 취소했어요.`; + + openPopup('snackbar', { + message, + variant: 'top', + onClose: () => {} + }); } catch (error) { console.error('팔로우/언팔로우 실패:', error); // 에러 발생 시 상태 변경하지 않음 diff --git a/src/components/feed/UserProfileItem.tsx b/src/components/feed/UserProfileItem.tsx index d4c16639..9d19f961 100644 --- a/src/components/feed/UserProfileItem.tsx +++ b/src/components/feed/UserProfileItem.tsx @@ -5,6 +5,7 @@ import rightArrow from '../../assets/feed/rightArrow.svg'; import type { UserProfileItemProps } from '@/types/user'; import { colors, typography } from '@/styles/global/global'; import { postFollow } from '@/api/users/postFollow'; +import { usePopupStore } from '@/stores/usePopupStore'; const UserProfileItem = ({ profileImgUrl, @@ -19,6 +20,7 @@ const UserProfileItem = ({ }: UserProfileItemProps) => { const navigate = useNavigate(); const [followed, setFollowed] = useState(isFollowing); + const { openPopup } = usePopupStore(); const handleProfileClick = () => { navigate(`/otherfeed/${userId}`); @@ -32,6 +34,17 @@ const UserProfileItem = ({ // API 응답으로 팔로우 상태 업데이트 setFollowed(response.data.isFollowing); console.log(`${nickname} - ${response.data.isFollowing ? '띱 완료' : '띱 취소'}`); + + // Snackbar 표시 + const message = response.data.isFollowing + ? `${nickname}님을 띱 했어요.` + : `${nickname}님을 띱 취소했어요.`; + + openPopup('snackbar', { + message, + variant: 'top', + onClose: () => {} + }); } catch (error) { console.error('팔로우/언팔로우 실패:', error); } From 5b61563cc74df1ef8c2e7975c891cd430420c968 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 16:55:26 +0900 Subject: [PATCH 72/86] =?UTF-8?q?feat:=20book=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EC=97=90=20=EA=B8=80=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/group/CreateGroup.tsx | 2 +- src/pages/post/CreatePost.tsx | 20 +++++++++++++++++--- src/pages/searchBook/SearchBook.tsx | 12 +++++++++--- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/pages/group/CreateGroup.tsx b/src/pages/group/CreateGroup.tsx index 845b3326..e405b9ae 100644 --- a/src/pages/group/CreateGroup.tsx +++ b/src/pages/group/CreateGroup.tsx @@ -26,7 +26,7 @@ const CreateGroup = () => { const navigate = useNavigate(); const location = useLocation(); - function convertBookInfoToBook(bookInfo: any): Book | null { + function convertBookInfoToBook(bookInfo: Book): Book | null { if (!bookInfo) return null; return { title: bookInfo.title, diff --git a/src/pages/post/CreatePost.tsx b/src/pages/post/CreatePost.tsx index 9cf4885b..3c36d722 100644 --- a/src/pages/post/CreatePost.tsx +++ b/src/pages/post/CreatePost.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import BookSearchBottomSheet from '../../components/common/BookSearchBottomSheet/BookSearchBottomSheet'; import BookSelectionSection from '../../components/creategroup/BookSelectionSection'; @@ -34,7 +34,7 @@ const makeIsbnCandidates = (raw: string) => { }; interface Book { - id: number; + id?: number; title: string; author: string; cover: string; @@ -43,7 +43,21 @@ interface Book { const CreatePost = () => { const navigate = useNavigate(); - const [selectedBook, setSelectedBook] = useState(null); + const location = useLocation(); + + function convertBookInfoToBook(bookInfo: Book): Book | null { + if (!bookInfo) return null; + return { + title: bookInfo.title, + author: bookInfo.author, + cover: bookInfo.cover, + isbn: bookInfo.isbn, + }; + } + + const [selectedBook, setSelectedBook] = useState( + convertBookInfoToBook(location.state?.selectedBook), + ); const [postContent, setPostContent] = useState(''); const [selectedPhotos, setSelectedPhotos] = useState([]); const [isPrivate, setIsPrivate] = useState(false); diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index c1258224..287dfcf6 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -122,9 +122,15 @@ const SearchBook = () => { const handleWritePostButton = () => { if (bookDetail) { - navigate('/group/create', { state: { selectedBook: bookDetail } }); + const selectedBook = { + title: bookDetail.title, + author: bookDetail.authorName, + cover: bookDetail.imageUrl, + isbn: bookDetail.isbn, + }; + navigate('/post/create', { state: { selectedBook } }); } else { - navigate('/group/create'); + navigate('/post/create'); } }; @@ -182,7 +188,7 @@ const SearchBook = () => { 오른쪽 화살표 아이콘 - + 피드에 글쓰기 더하기 아이콘 From 3d0f8c7053e6c4db7e1c6c1034411db7721e7c71 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 16:59:55 +0900 Subject: [PATCH 73/86] =?UTF-8?q?feat:=20GroupDetail=EC=97=90=EC=84=9C=20B?= =?UTF-8?q?ook=20Click=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/groupDetail/GroupDetail.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/pages/groupDetail/GroupDetail.tsx b/src/pages/groupDetail/GroupDetail.tsx index f38613f8..910d2334 100644 --- a/src/pages/groupDetail/GroupDetail.tsx +++ b/src/pages/groupDetail/GroupDetail.tsx @@ -131,6 +131,13 @@ const GroupDetail = () => { recommendRooms, } = roomData; + const handleBookSectionClick = () => { + const isbn = roomData?.isbn; + if (isbn) { + navigate(`/search/book/${isbn}`); + } + }; + return ( @@ -175,10 +182,10 @@ const GroupDetail = () => {
- +

{bookTitle}

- +
From 3d7a9991b2615300769230efc5a4aac223996587 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 17:02:26 +0900 Subject: [PATCH 74/86] =?UTF-8?q?feat:=20GroupDetail=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EB=AA=A8=EC=9E=84=EB=B0=A9=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/groupDetail/GroupDetail.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pages/groupDetail/GroupDetail.tsx b/src/pages/groupDetail/GroupDetail.tsx index 910d2334..ac9e5440 100644 --- a/src/pages/groupDetail/GroupDetail.tsx +++ b/src/pages/groupDetail/GroupDetail.tsx @@ -138,6 +138,10 @@ const GroupDetail = () => { } }; + const handleRecommendGroupCardClick = (roomId: number | string) => { + navigate(`/group/detail/${roomId}`); + }; + return ( @@ -208,6 +212,7 @@ const GroupDetail = () => { isOngoing={true} isRecommend={true} type={'modal'} + onClick={() => handleRecommendGroupCardClick(room.roomId)} /> ))} From ca1bfb4ca2cfb89240c69ff209d3a4f916b8f736 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 17:22:50 +0900 Subject: [PATCH 75/86] feat: postJoinRoom --- src/api/rooms/postJoinRoom.ts | 23 ++++++++++++++++++++ src/pages/groupDetail/GroupDetail.tsx | 31 ++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/api/rooms/postJoinRoom.ts diff --git a/src/api/rooms/postJoinRoom.ts b/src/api/rooms/postJoinRoom.ts new file mode 100644 index 00000000..b4818893 --- /dev/null +++ b/src/api/rooms/postJoinRoom.ts @@ -0,0 +1,23 @@ +import { apiClient } from '../index'; + +export interface PostJoinRoomRequest { + type: 'join' | 'cancel'; +} + +export interface PostJoinRoomResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + roomId: number; + type: string; + }; +} + +export async function postJoinRoom( + roomId: number | string, + type: 'join' | 'cancel', +): Promise { + const response = await apiClient.post(`/rooms/${roomId}/join`, { type }); + return response.data; +} diff --git a/src/pages/groupDetail/GroupDetail.tsx b/src/pages/groupDetail/GroupDetail.tsx index ac9e5440..501b3ab0 100644 --- a/src/pages/groupDetail/GroupDetail.tsx +++ b/src/pages/groupDetail/GroupDetail.tsx @@ -39,6 +39,7 @@ import { type RoomDetailResponse, type RecommendRoom, } from '@/api/rooms/getRoomDetail'; +import { postJoinRoom } from '@/api/rooms/postJoinRoom'; import type { Group } from '@/components/group/MyGroupBox'; const GroupDetail = () => { @@ -49,6 +50,8 @@ const GroupDetail = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [isJoining, setIsJoining] = useState(null); + const handleBackButton = () => { navigate(-1); }; @@ -106,6 +109,12 @@ const GroupDetail = () => { fetchRoomDetail(); }, [roomId]); + useEffect(() => { + if (roomData) { + setIsJoining(roomData.isJoining); + } + }, [roomData]); + if (isLoading) { return
로딩 중...
; } @@ -142,6 +151,24 @@ const GroupDetail = () => { navigate(`/group/detail/${roomId}`); }; + const handleBottomButtonClick = async () => { + if (roomData.isHost) { + alert('모집 마감하기 API 호출'); + return; + } + const type = isJoining ? 'cancel' : 'join'; + try { + const result = await postJoinRoom(Number(roomId), type); + if (result.isSuccess) { + setIsJoining(type === 'join'); + } else { + alert(`요청 실패: ${result.message}`); + } + } catch { + alert('네트워크 오류 또는 서버 오류'); + } + }; + return ( @@ -217,7 +244,9 @@ const GroupDetail = () => { ))} - 참여하기 + + {roomData.isHost ? '모집 마감하기' : isJoining ? '참여 취소하기' : '참여하기'} + ); }; From 7f7a4d40d1668cb517057b1cb8cd6902b87e0625 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 17:25:20 +0900 Subject: [PATCH 76/86] feat: postCloseRoom API --- src/api/rooms/postCloseRoom.ts | 15 +++++++++++++++ src/pages/groupDetail/GroupDetail.tsx | 13 ++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/api/rooms/postCloseRoom.ts diff --git a/src/api/rooms/postCloseRoom.ts b/src/api/rooms/postCloseRoom.ts new file mode 100644 index 00000000..23da9516 --- /dev/null +++ b/src/api/rooms/postCloseRoom.ts @@ -0,0 +1,15 @@ +import { apiClient } from '../index'; + +export interface PostCloseRoomResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + roomId: number; + }; +} + +export async function postCloseRoom(roomId: number | string): Promise { + const response = await apiClient.post(`/rooms/${roomId}/close`); + return response.data; +} diff --git a/src/pages/groupDetail/GroupDetail.tsx b/src/pages/groupDetail/GroupDetail.tsx index 501b3ab0..b82181ae 100644 --- a/src/pages/groupDetail/GroupDetail.tsx +++ b/src/pages/groupDetail/GroupDetail.tsx @@ -40,6 +40,7 @@ import { type RecommendRoom, } from '@/api/rooms/getRoomDetail'; import { postJoinRoom } from '@/api/rooms/postJoinRoom'; +import { postCloseRoom } from '@/api/rooms/postCloseRoom'; import type { Group } from '@/components/group/MyGroupBox'; const GroupDetail = () => { @@ -153,13 +154,23 @@ const GroupDetail = () => { const handleBottomButtonClick = async () => { if (roomData.isHost) { - alert('모집 마감하기 API 호출'); + try { + const result = await postCloseRoom(Number(roomId)); + if (result.isSuccess) { + alert('모집 마감 성공!'); + } else { + alert(`요청 실패: ${result.message}`); + } + } catch { + alert('네트워크 오류 또는 서버 오류'); + } return; } const type = isJoining ? 'cancel' : 'join'; try { const result = await postJoinRoom(Number(roomId), type); if (result.isSuccess) { + alert(`${type === 'join' ? '참여' : '참여 취소'} 성공!`); setIsJoining(type === 'join'); } else { alert(`요청 실패: ${result.message}`); From 2208b3433c0c9ed62223077ed0816a24b63d058b Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 17:32:04 +0900 Subject: [PATCH 77/86] =?UTF-8?q?refactor:=20error=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?api=20=ED=95=A8=EC=88=98=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/rooms/closeRoom.ts | 20 ++++++++++++++++++++ src/api/rooms/postJoinRoom.ts | 9 +++++++-- src/pages/groupDetail/GroupDetail.tsx | 15 ++------------- 3 files changed, 29 insertions(+), 15 deletions(-) create mode 100644 src/api/rooms/closeRoom.ts diff --git a/src/api/rooms/closeRoom.ts b/src/api/rooms/closeRoom.ts new file mode 100644 index 00000000..4c900ecf --- /dev/null +++ b/src/api/rooms/closeRoom.ts @@ -0,0 +1,20 @@ +import { apiClient } from '../index'; + +export interface PostCloseRoomResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + roomId: number; + }; +} + +export async function postCloseRoom(roomId: number | string): Promise { + try { + const response = await apiClient.post(`/rooms/${roomId}/close`); + return response.data; + } catch (error) { + console.error('방 닫기 API 오류:', error); + throw error; + } +} diff --git a/src/api/rooms/postJoinRoom.ts b/src/api/rooms/postJoinRoom.ts index b4818893..4719ba37 100644 --- a/src/api/rooms/postJoinRoom.ts +++ b/src/api/rooms/postJoinRoom.ts @@ -18,6 +18,11 @@ export async function postJoinRoom( roomId: number | string, type: 'join' | 'cancel', ): Promise { - const response = await apiClient.post(`/rooms/${roomId}/join`, { type }); - return response.data; + try { + const response = await apiClient.post(`/rooms/${roomId}/join`, { type }); + return response.data; + } catch (error) { + console.error('방 참여/취소 API 오류:', error); + throw error; + } } diff --git a/src/pages/groupDetail/GroupDetail.tsx b/src/pages/groupDetail/GroupDetail.tsx index b82181ae..cd1d1dcb 100644 --- a/src/pages/groupDetail/GroupDetail.tsx +++ b/src/pages/groupDetail/GroupDetail.tsx @@ -155,12 +155,7 @@ const GroupDetail = () => { const handleBottomButtonClick = async () => { if (roomData.isHost) { try { - const result = await postCloseRoom(Number(roomId)); - if (result.isSuccess) { - alert('모집 마감 성공!'); - } else { - alert(`요청 실패: ${result.message}`); - } + await postCloseRoom(Number(roomId)); } catch { alert('네트워크 오류 또는 서버 오류'); } @@ -168,13 +163,7 @@ const GroupDetail = () => { } const type = isJoining ? 'cancel' : 'join'; try { - const result = await postJoinRoom(Number(roomId), type); - if (result.isSuccess) { - alert(`${type === 'join' ? '참여' : '참여 취소'} 성공!`); - setIsJoining(type === 'join'); - } else { - alert(`요청 실패: ${result.message}`); - } + await postJoinRoom(Number(roomId), type); } catch { alert('네트워크 오류 또는 서버 오류'); } From 44c02b6a80db79b148a9adb20ef9092cf880dbc9 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 17:32:52 +0900 Subject: [PATCH 78/86] =?UTF-8?q?design:=20GroupDetail=20button=20cursor?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/groupDetail/GroupDetail.styled.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/groupDetail/GroupDetail.styled.ts b/src/pages/groupDetail/GroupDetail.styled.ts index 92ec1f63..39c9486e 100644 --- a/src/pages/groupDetail/GroupDetail.styled.ts +++ b/src/pages/groupDetail/GroupDetail.styled.ts @@ -225,4 +225,5 @@ export const BottomButton = styled.button` font-weight: ${typography.fontWeight.semibold}; border: none; z-index: 10; + cursor: pointer; `; From d51715a1feed58b163ca6ff019343fb7f65c7089 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 17:34:10 +0900 Subject: [PATCH 79/86] =?UTF-8?q?remove:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20a?= =?UTF-8?q?pi=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/rooms/closeRoom.ts | 20 -------------------- src/api/rooms/postCloseRoom.ts | 9 +++++++-- 2 files changed, 7 insertions(+), 22 deletions(-) delete mode 100644 src/api/rooms/closeRoom.ts diff --git a/src/api/rooms/closeRoom.ts b/src/api/rooms/closeRoom.ts deleted file mode 100644 index 4c900ecf..00000000 --- a/src/api/rooms/closeRoom.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { apiClient } from '../index'; - -export interface PostCloseRoomResponse { - isSuccess: boolean; - code: number; - message: string; - data: { - roomId: number; - }; -} - -export async function postCloseRoom(roomId: number | string): Promise { - try { - const response = await apiClient.post(`/rooms/${roomId}/close`); - return response.data; - } catch (error) { - console.error('방 닫기 API 오류:', error); - throw error; - } -} diff --git a/src/api/rooms/postCloseRoom.ts b/src/api/rooms/postCloseRoom.ts index 23da9516..4c900ecf 100644 --- a/src/api/rooms/postCloseRoom.ts +++ b/src/api/rooms/postCloseRoom.ts @@ -10,6 +10,11 @@ export interface PostCloseRoomResponse { } export async function postCloseRoom(roomId: number | string): Promise { - const response = await apiClient.post(`/rooms/${roomId}/close`); - return response.data; + try { + const response = await apiClient.post(`/rooms/${roomId}/close`); + return response.data; + } catch (error) { + console.error('방 닫기 API 오류:', error); + throw error; + } } From 2b3e8d466b29d148545ed5548d8736b39a9ce3d9 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 17:57:02 +0900 Subject: [PATCH 80/86] =?UTF-8?q?fix:=20carousel=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/group/MyGroupBox.tsx | 22 +------ .../group/RecruitingGroupCarousel.tsx | 60 +++++-------------- src/hooks/useInfiniteCarousel.ts | 8 ++- src/pages/group/Group.tsx | 3 - 4 files changed, 21 insertions(+), 72 deletions(-) diff --git a/src/components/group/MyGroupBox.tsx b/src/components/group/MyGroupBox.tsx index 3d104d07..b1e297b1 100644 --- a/src/components/group/MyGroupBox.tsx +++ b/src/components/group/MyGroupBox.tsx @@ -65,7 +65,7 @@ export function MyGroupBox({ onMyGroupsClick }: MyGroupProps) { navigate(`detail/joined/${roomId}`); }; - const { scrollRef, cardRefs, infiniteGroups, current } = useInfiniteCarousel(groups); + const { scrollRef, cardRefs, infiniteGroups } = useInfiniteCarousel(groups); return ( @@ -97,11 +97,6 @@ export function MyGroupBox({ onMyGroupsClick }: MyGroupProps) { /> ))} - - {groups.map((_, i) => ( - - ))} - ) : ( @@ -155,21 +150,6 @@ const Carousel = styled.div` scroll-snap-type: x mandatory; `; -const Dots = styled.div` - display: flex; - justify-content: center; - gap: 12px; - margin: 30px 0; -`; - -const Dot = styled.div<{ active: boolean }>` - width: 4px; - height: 4px; - border-radius: 50%; - background: ${({ active }) => (active ? colors.white : colors.grey[300])}; - transition: background-color 0.3s; -`; - const LoadingContainer = styled.div` display: flex; justify-content: center; diff --git a/src/components/group/RecruitingGroupCarousel.tsx b/src/components/group/RecruitingGroupCarousel.tsx index 5f69cfd9..d4689f31 100644 --- a/src/components/group/RecruitingGroupCarousel.tsx +++ b/src/components/group/RecruitingGroupCarousel.tsx @@ -1,7 +1,7 @@ -import { useRef, useEffect, useCallback } from 'react'; import styled from '@emotion/styled'; import type { Group } from './MyGroupBox'; import { RecruitingGroupBox } from './RecruitingGroupBox'; +import { useInfiniteCarousel } from '@/hooks/useInfiniteCarousel'; export interface Section { title: string; @@ -13,59 +13,28 @@ interface Props { } export function RecruitingGroupCarousel({ sections }: Props) { - const scrollRef = useRef(null); - const itemRefs = useRef>([]); + const groups = sections.map(sec => ({ ...sec.groups[0], title: sec.title, groups: sec.groups })); - const handleScroll = useCallback(() => { - const container = scrollRef.current; - if (!container) return; + const sectionGroups = sections.map(sec => ({ + ...sec.groups[0], + title: sec.title, + groups: sec.groups, + })); - const centerX = container.offsetWidth / 2; - const scrollLeft = container.scrollLeft; - - itemRefs.current.forEach(item => { - if (!item) return; - const itemCenter = item.offsetLeft + item.offsetWidth / 2; - const dist = Math.abs(itemCenter - scrollLeft - centerX); - const ratio = Math.min(dist / centerX, 1); - const scale = 1 - ratio * 0.1; - item.style.transform = `scale(${scale})`; - }); - }, []); - - useEffect(() => { - const container = scrollRef.current; - if (!container || sections.length === 0) return; - - const mid = Math.floor(sections.length / 2); - const midItem = itemRefs.current[mid]; - if (midItem) { - const centerX = container.offsetWidth / 2; - const targetScroll = midItem.offsetLeft + midItem.offsetWidth / 2 - centerX; - container.scrollTo({ left: targetScroll, behavior: 'auto' }); - } - handleScroll(); - }, [sections.length, handleScroll]); - - useEffect(() => { - const container = scrollRef.current; - if (!container) return; - container.addEventListener('scroll', handleScroll, { passive: true }); - return () => { - container.removeEventListener('scroll', handleScroll); - }; - }, [handleScroll]); + const { scrollRef, cardRefs, infiniteGroups } = useInfiniteCarousel(sectionGroups, { + scaleAmount: 0.08, + }); return ( - {sections.map((sec, i) => ( + {infiniteGroups.map((g, i) => ( { - itemRefs.current[i] = el; + cardRefs.current[i] = el; }} > - + ))} @@ -88,4 +57,5 @@ const Item = styled.div` max-width: 640px; scroll-snap-align: center; transition: transform 0.2s; + height: 800px; `; diff --git a/src/hooks/useInfiniteCarousel.ts b/src/hooks/useInfiniteCarousel.ts index 50afb840..47c04504 100644 --- a/src/hooks/useInfiniteCarousel.ts +++ b/src/hooks/useInfiniteCarousel.ts @@ -3,7 +3,7 @@ import type { Group } from '../components/group/MyGroupBox'; const CLONE_COUNT = 10; -export function useInfiniteCarousel(groups: Group[]) { +export function useInfiniteCarousel(groups: Group[], options?: { scaleAmount?: number }) { const scrollRef = useRef(null); const cardRefs = useRef<(HTMLDivElement | null)[]>([]); const [current, setCurrent] = useState(0); @@ -14,6 +14,8 @@ export function useInfiniteCarousel(groups: Group[]) { const middleIndex = useMemo(() => Math.floor(infiniteGroups.length / 2), [infiniteGroups]); + const scaleAmount = options?.scaleAmount ?? 0.17; + const handleScroll = useCallback(() => { const container = scrollRef.current; if (!container) return; @@ -28,7 +30,7 @@ export function useInfiniteCarousel(groups: Group[]) { if (!card) return; const cardCenter = card.offsetLeft + card.offsetWidth / 2; const distance = Math.abs(center - (cardCenter - scrollLeft)); - const scale = Math.max(0.83, 1 - (distance / center) * 0.17); + const scale = Math.max(0.83, 1 - (distance / center) * scaleAmount); card.style.transform = `scale(${scale})`; if (distance < minDist) { @@ -49,7 +51,7 @@ export function useInfiniteCarousel(groups: Group[]) { container.scrollLeft = left; } } - }, [groups.length, middleIndex]); + }, [groups.length, middleIndex, scaleAmount]); useEffect(() => { const container = scrollRef.current; diff --git a/src/pages/group/Group.tsx b/src/pages/group/Group.tsx index 2d401992..415fcdf6 100644 --- a/src/pages/group/Group.tsx +++ b/src/pages/group/Group.tsx @@ -36,7 +36,6 @@ const Group = () => { const [sections, setSections] = useState([ { title: '마감 임박한 독서 모임방', groups: [] }, { title: '인기 있는 독서 모임방', groups: [] }, - { title: '인플루언서·작가 독서 모임방', groups: [] }, ]); const fetchAllRoomsData = async () => { @@ -62,14 +61,12 @@ const Group = () => { setSections([ { title: '마감 임박한 독서 모임방', groups: deadlineRoomsData }, { title: '인기 있는 독서 모임방', groups: popularRoomsData }, - { title: '인플루언서·작가 독서 모임방', groups: [] }, ]); } catch (error) { console.error('방 목록 조회 오류:', error); setSections([ { title: '마감 임박한 독서 모임방', groups: [] }, { title: '인기 있는 독서 모임방', groups: [] }, - { title: '인플루언서·작가 독서 모임방', groups: [] }, ]); } }; From 02e066ae21520f4b02cb216e23710955c888975c Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Sat, 16 Aug 2025 17:59:01 +0900 Subject: [PATCH 81/86] =?UTF-8?q?fix:=20=EC=95=88=EC=93=B0=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/group/RecruitingGroupCarousel.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/group/RecruitingGroupCarousel.tsx b/src/components/group/RecruitingGroupCarousel.tsx index d4689f31..157eff3a 100644 --- a/src/components/group/RecruitingGroupCarousel.tsx +++ b/src/components/group/RecruitingGroupCarousel.tsx @@ -13,8 +13,6 @@ interface Props { } export function RecruitingGroupCarousel({ sections }: Props) { - const groups = sections.map(sec => ({ ...sec.groups[0], title: sec.title, groups: sec.groups })); - const sectionGroups = sections.map(sec => ({ ...sec.groups[0], title: sec.title, From 35004c0476959f14292c8cb21b8a177817782aae Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Sat, 16 Aug 2025 22:32:36 +0900 Subject: [PATCH 82/86] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8A=9C=ED=86=A0=EB=A6=AC=EC=96=BC=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/signup/guide1.svg | 16 ++ src/assets/signup/guide2.svg | 16 ++ src/assets/signup/guide3.svg | 16 ++ src/assets/signup/guide4.svg | 16 ++ src/assets/signup/guide5.svg | 16 ++ src/assets/signup/guide6.svg | 16 ++ src/pages/Guide.tsx | 328 +++++++++++++++++++++++++++++++ src/pages/index.tsx | 4 +- src/pages/signup/SignupDone.tsx | 8 +- src/pages/signup/SignupGenre.tsx | 2 +- 10 files changed, 429 insertions(+), 9 deletions(-) create mode 100644 src/assets/signup/guide1.svg create mode 100644 src/assets/signup/guide2.svg create mode 100644 src/assets/signup/guide3.svg create mode 100644 src/assets/signup/guide4.svg create mode 100644 src/assets/signup/guide5.svg create mode 100644 src/assets/signup/guide6.svg create mode 100644 src/pages/Guide.tsx diff --git a/src/assets/signup/guide1.svg b/src/assets/signup/guide1.svg new file mode 100644 index 00000000..14b4b3a5 --- /dev/null +++ b/src/assets/signup/guide1.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/signup/guide2.svg b/src/assets/signup/guide2.svg new file mode 100644 index 00000000..4d53a696 --- /dev/null +++ b/src/assets/signup/guide2.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/signup/guide3.svg b/src/assets/signup/guide3.svg new file mode 100644 index 00000000..d15518f9 --- /dev/null +++ b/src/assets/signup/guide3.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/signup/guide4.svg b/src/assets/signup/guide4.svg new file mode 100644 index 00000000..95eacc97 --- /dev/null +++ b/src/assets/signup/guide4.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/signup/guide5.svg b/src/assets/signup/guide5.svg new file mode 100644 index 00000000..f1f07aed --- /dev/null +++ b/src/assets/signup/guide5.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/signup/guide6.svg b/src/assets/signup/guide6.svg new file mode 100644 index 00000000..95fbccba --- /dev/null +++ b/src/assets/signup/guide6.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/pages/Guide.tsx b/src/pages/Guide.tsx new file mode 100644 index 00000000..e5cd416f --- /dev/null +++ b/src/pages/Guide.tsx @@ -0,0 +1,328 @@ +import { useState, useRef, useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import styled from '@emotion/styled'; +import { colors, typography } from '@/styles/global/global'; + +// Import guide images +import guide1 from '@/assets/signup/guide1.svg'; +import guide2 from '@/assets/signup/guide2.svg'; +import guide3 from '@/assets/signup/guide3.svg'; +import guide4 from '@/assets/signup/guide4.svg'; +import guide5 from '@/assets/signup/guide5.svg'; +import guide6 from '@/assets/signup/guide6.svg'; + +interface GuideStep { + id: number; + title: string; + description: string; + image: string; +} + +const Guide = () => { + const navigate = useNavigate(); + const location = useLocation(); + const [currentStep, setCurrentStep] = useState(0); + const [touchStart, setTouchStart] = useState(null); + const [touchEnd, setTouchEnd] = useState(null); + const containerRef = useRef(null); + + // SignupGenre에서 전달받은 닉네임 + const nickname = location.state?.nickname || '사용자'; + const aliasName = location.state?.aliasName || '독서가'; + // 터치 시작 + const handleTouchStart = (e: React.TouchEvent) => { + setTouchStart(e.targetTouches[0].clientX); + }; + + // 터치 종료 + const handleTouchEnd = (e: React.TouchEvent) => { + setTouchEnd(e.changedTouches[0].clientX); + }; + + // 슬라이드 처리 + useEffect(() => { + if (!touchStart || !touchEnd) return; + + const distance = touchStart - touchEnd; + const isLeftSwipe = distance > 50; // 왼쪽으로 50px 이상 스와이프 + const isRightSwipe = distance < -50; // 오른쪽으로 50px 이상 스와이프 + + if (isLeftSwipe && currentStep < guideSteps.length - 1) { + // 왼쪽으로 스와이프: 다음 단계 + setCurrentStep(currentStep + 1); + } else if (isRightSwipe && currentStep > 0) { + // 오른쪽으로 스와이프: 이전 단계 + setCurrentStep(currentStep - 1); + } + + // 터치 상태 초기화 + setTouchStart(null); + setTouchEnd(null); + }, [touchStart, touchEnd, currentStep]); + + // 마우스 드래그 기능 + const [mouseStart, setMouseStart] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + const handleMouseDown = (e: React.MouseEvent) => { + setMouseStart(e.clientX); + setIsDragging(true); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDragging || mouseStart === null) return; + + const distance = mouseStart - e.clientX; + if (Math.abs(distance) > 50) { + if (distance > 0 && currentStep < guideSteps.length - 1) { + // 왼쪽으로 드래그: 다음 단계 + setCurrentStep(currentStep + 1); + } else if (distance < 0 && currentStep > 0) { + // 오른쪽으로 드래그: 이전 단계 + setCurrentStep(currentStep - 1); + } + setIsDragging(false); + setMouseStart(null); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + setMouseStart(null); + }; + + const guideSteps: GuideStep[] = [ + { + id: 1, + title: '피드', + description: '피드에서 책과 독서에 대한 생각을
자유롭게 나누어보세요!', + image: guide1, + }, + { + id: 2, + title: '피드', + description: + "칭호를 통해 내 독서 취향을 드러내고,
마음에 드는 유저를 '띱'하고 감상을 공유해보세요!", + image: guide2, + }, + { + id: 3, + title: '모임', + description: '모임방에서는 글은 물론 투표 기능을 통해
감상과 의견을 나눌 수 있어요.', + image: guide3, + }, + { + id: 4, + title: '모임', + description: + '읽고 싶은 책으로 나만의 독서 모임을 만들고,
독서메이트와 함께 기록을 나눌 수 있어요. ', + image: guide4, + }, + { + id: 5, + title: 'Thip+', + description: + '기록은 자유롭게, 감상은 방해없이.
읽지 않은 페이지에 대한 기록은
블라인드되어 스포일러 걱정없이 몰입할 수 있어요.', + image: guide5, + }, + { + id: 6, + title: 'Thip+', + description: "모임방의 인상깊은 기록을
'핀하기'로 피드에 다시 공유해보세요.", + image: guide6, + }, + ]; + + const handleNext = () => { + // active 상태일 때만 다음 단계로 이동 + if (currentStep < guideSteps.length - 1) { + setCurrentStep(currentStep + 1); + } else { + // 마지막 단계에서 완료 처리 - SignupDone으로 닉네임 전달 + navigate('/signup/done', { + state: { + nickName: nickname, + aliasName: aliasName, + }, + }); + } + }; + + const handleSkip = () => { + navigate('/signup/done', { + state: { + nickName: nickname, + aliasName: aliasName, + }, + }); + }; + + const handleIndicatorClick = (step: number) => { + setCurrentStep(step); + }; + + return ( + +
+
+ 다음 +
+
+ + + + <TitleText>{guideSteps[currentStep].title}</TitleText> + <Description dangerouslySetInnerHTML={{ __html: guideSteps[currentStep].description }} /> + + + {guideSteps[currentStep].title} + + + + {guideSteps.map((_, index) => ( + handleIndicatorClick(index)} + /> + ))} + + 건너뛰기 + + +
+ ); +}; + +export default Guide; + +// Styled Components +const Container = styled.div<{ isDragging?: boolean }>` + display: flex; + flex-direction: column; + min-width: 320px; + max-width: 767px; + min-height: 100vh; + margin: 0 auto; + align-items: center; + justify-content: center; + background: ${colors.black.main}; + color: ${colors.white}; + user-select: none; /* 텍스트 선택 방지 */ + cursor: ${({ isDragging }) => (isDragging ? 'grabbing' : 'grab')}; +`; + +const Header = styled.div<{ active: boolean }>` + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + max-width: 766px; + margin: 0 auto; + padding: 16px 20px; + background-color: ${colors.black.main}; + + .next-button { + cursor: ${({ active }) => (active ? 'pointer' : 'default')}; + width: 49px; + height: 28px; + padding: 4px 12px; + border-radius: 20px; + background-color: ${colors.purple.main}; + color: ${colors.white}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.semibold}; + line-height: 20px; + text-align: center; + margin-left: auto; + } +`; + +const Content = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 66px 20px 0 20px; + gap: 20px; + flex: 1; + min-height: calc(100vh - 66px); +`; + +const Title = styled.div` + display: flex; + flex-direction: column; + align-items: center; + height: 135px; + gap: 20px; +`; + +const TitleText = styled.div` + color: ${colors.white}; + font-size: ${typography.fontSize['xl']}; + font-weight: ${typography.fontWeight.bold}; + line-height: 24px; +`; + +const Description = styled.div` + color: ${colors.grey[100]}; + font-size: ${typography.fontSize.base}; + font-weight: ${typography.fontWeight.semibold}; + line-height: 24px; + text-align: center; +`; + +const MockupContainer = styled.div` + img { + width: 220px; + height: 453.052px; + } +`; + +const BottomSection = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: column; + gap: 20px; + min-width: 320px; + max-width: 767px; + padding: 0 20px; +`; + +const Indicators = styled.div` + display: flex; + justify-content: center; + left: 50%; +`; + +const Indicator = styled.div<{ active: boolean }>` + width: 4px; + height: 4px; + background: ${({ active }) => (active ? colors.white : colors.grey[300])}; + border-radius: 50%; + margin: 0 6px; + cursor: pointer; +`; + +const SkipButton = styled.div` + color: ${colors.grey[200]}; + font-size: ${typography.fontSize.xs}; + font-weight: ${typography.fontWeight.regular}; + line-height: normal; + cursor: pointer; +`; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index dfccc374..05c965f7 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -38,6 +38,7 @@ import EditPage from './mypage/EditPage'; import Notice from './notice/Notice'; import ParticipatedGroupDetail from './groupDetail/ParticipatedGroupDetail'; import GroupMembers from './groupMembers/GroupMembers'; +import Guide from './Guide'; const Router = () => { const router = createBrowserRouter( @@ -46,7 +47,8 @@ const Router = () => { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/pages/signup/SignupDone.tsx b/src/pages/signup/SignupDone.tsx index f5472185..87ef344f 100644 --- a/src/pages/signup/SignupDone.tsx +++ b/src/pages/signup/SignupDone.tsx @@ -12,19 +12,13 @@ const SignupDone = () => { const { nickName, aliasName } = location.state || {}; const handleBackClick = () => { - navigate('/signup/genre'); + navigate('/signup/guide'); }; const handleNextClick = () => { navigate('/feed'); }; - // state가 없으면 이전 페이지로 이동 - if (!nickName || !aliasName) { - navigate('/signup/nickname'); - return null; - } - return ( { if (result.success) { console.log('🎉 회원가입 성공! 사용자 ID:', result.data.userId); - navigate('/signupdone', { + navigate('/signup/guide', { state: { aliasName: selectedAlias.subTitle, nickname: nickname, From 523b74db3ca75c69627817ea0418b14617ffd9b5 Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Sat, 16 Aug 2025 22:36:17 +0900 Subject: [PATCH 83/86] =?UTF-8?q?fix:=20cursor=20pointer=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Guide.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pages/Guide.tsx b/src/pages/Guide.tsx index e5cd416f..015b7c53 100644 --- a/src/pages/Guide.tsx +++ b/src/pages/Guide.tsx @@ -172,7 +172,7 @@ const Guide = () => { onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} > -
+
e.stopPropagation()}>
다음
@@ -205,7 +205,6 @@ const Guide = () => { export default Guide; -// Styled Components const Container = styled.div<{ isDragging?: boolean }>` display: flex; flex-direction: column; @@ -226,7 +225,7 @@ const Header = styled.div<{ active: boolean }>` top: 0; left: 0; right: 0; - z-index: 100; + z-index: 1100; display: flex; flex-direction: row; justify-content: flex-end; @@ -237,7 +236,6 @@ const Header = styled.div<{ active: boolean }>` background-color: ${colors.black.main}; .next-button { - cursor: ${({ active }) => (active ? 'pointer' : 'default')}; width: 49px; height: 28px; padding: 4px 12px; @@ -249,6 +247,7 @@ const Header = styled.div<{ active: boolean }>` line-height: 20px; text-align: center; margin-left: auto; + cursor: pointer; } `; From d93cfb366d4f852ac96740a0a4a3df388c59a218 Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Sun, 17 Aug 2025 13:52:10 +0900 Subject: [PATCH 84/86] =?UTF-8?q?fix:=20=EB=82=B4=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84,=20=EB=8B=A4=EB=A5=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=95=84=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C?= =?UTF-8?q?=20=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/Post/PostHeader.tsx | 9 ++- src/components/common/Post/Reply.tsx | 3 + src/components/common/Post/SubReply.tsx | 3 + src/components/feed/OtherFeed.tsx | 14 +++- src/components/feed/Profile.tsx | 12 +-- src/hooks/useOAuthToken.ts | 2 +- src/pages/feed/MyFeedPage.tsx | 92 +++++++++++++++++++++++ src/pages/feed/OtherFeedPage.tsx | 1 + src/pages/index.tsx | 2 + src/types/post.ts | 3 + src/types/profile.ts | 1 + 11 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 src/pages/feed/MyFeedPage.tsx diff --git a/src/components/common/Post/PostHeader.tsx b/src/components/common/Post/PostHeader.tsx index ec49a977..522b2ba9 100644 --- a/src/components/common/Post/PostHeader.tsx +++ b/src/components/common/Post/PostHeader.tsx @@ -8,6 +8,7 @@ interface PostHeaderProps { postDate: string; creatorId?: number; type?: 'post' | 'reply'; + isWriter?: boolean; } const PostHeader = ({ @@ -18,12 +19,18 @@ const PostHeader = ({ postDate, creatorId, type = 'post', + isWriter, }: PostHeaderProps) => { const navigate = useNavigate(); const handleClick = () => { if (creatorId) { - navigate(`/otherfeed/${creatorId}`); + // isWriter가 true면 MyFeedPage로, false면 OtherFeedPage로 이동 + if (isWriter) { + navigate(`/myfeed/${creatorId}`); + } else { + navigate(`/otherfeed/${creatorId}`); + } } }; diff --git a/src/components/common/Post/Reply.tsx b/src/components/common/Post/Reply.tsx index e09c7176..59f2b791 100644 --- a/src/components/common/Post/Reply.tsx +++ b/src/components/common/Post/Reply.tsx @@ -12,6 +12,7 @@ import { deleteComment } from '@/api/comments/deleteComment'; interface ReplyProps extends CommentData { onDelete?: () => void; + isWriter?: boolean; } const Reply = ({ @@ -27,6 +28,7 @@ const Reply = ({ likeCount: initialLikeCount, isDeleted, onDelete, + isWriter, }: ReplyProps) => { const [liked, setLiked] = useState(isLike); const [likeCount, setLikeCount] = useState(initialLikeCount); @@ -121,6 +123,7 @@ const Reply = ({ aliasColor={aliasColor} postDate={postDate} creatorId={creatorId} + isWriter={isWriter} type="reply" /> diff --git a/src/components/common/Post/SubReply.tsx b/src/components/common/Post/SubReply.tsx index 6faae4c0..d83edffa 100644 --- a/src/components/common/Post/SubReply.tsx +++ b/src/components/common/Post/SubReply.tsx @@ -13,6 +13,7 @@ import { deleteComment } from '@/api/comments/deleteComment'; interface SubReplyProps extends ReplyData { onDelete?: () => void; + isWriter?: boolean; } const SubReply = ({ @@ -29,6 +30,7 @@ const SubReply = ({ isLike, isDeleted, onDelete, + isWriter, }: SubReplyProps) => { const [liked, setLiked] = useState(isLike); const [currentLikeCount, setCurrentLikeCount] = useState(likeCount); @@ -143,6 +145,7 @@ const SubReply = ({ aliasColor={aliasColor} postDate={postDate} creatorId={creatorId} + isWriter={isWriter} type="reply" /> diff --git a/src/components/feed/OtherFeed.tsx b/src/components/feed/OtherFeed.tsx index 08d3d5ca..ce36a001 100644 --- a/src/components/feed/OtherFeed.tsx +++ b/src/components/feed/OtherFeed.tsx @@ -12,9 +12,16 @@ interface OtherFeedProps { isMyFeed?: boolean; profileData?: OtherProfileData | null; userId?: number; + showFollowButton?: boolean; // showFollowButton prop 추가 } -const OtherFeed = ({ posts = [], profileData, userId }: OtherFeedProps) => { +const OtherFeed = ({ + posts = [], + profileData, + userId, + showFollowButton, + isMyFeed, +}: OtherFeedProps) => { const hasPosts = posts.length > 0; if (!profileData) { @@ -25,7 +32,7 @@ const OtherFeed = ({ posts = [], profileData, userId }: OtherFeedProps) => { { aliasColor={profileData.aliasColor} followerCount={profileData.followerCount} latestFollowerProfileImageUrls={profileData?.latestFollowerProfileImageUrls || []} + isMyFeed={isMyFeed} /> {hasPosts ? ( posts.map(post => ( - + )) ) : ( diff --git a/src/components/feed/Profile.tsx b/src/components/feed/Profile.tsx index e33f04be..14716783 100644 --- a/src/components/feed/Profile.tsx +++ b/src/components/feed/Profile.tsx @@ -14,6 +14,7 @@ export interface ProfileProps { followerCount: number; latestFollowerProfileImageUrls?: string[]; userId?: number; + isMyFeed?: boolean; } const Profile = ({ @@ -26,6 +27,7 @@ const Profile = ({ followerCount, latestFollowerProfileImageUrls = [], userId, + isMyFeed, }: ProfileProps) => { const [followed, setFollowed] = useState(isFollowing); const { openPopup } = usePopupStore(); @@ -54,14 +56,14 @@ const Profile = ({ console.log(`${nickname} - ${response.data.isFollowing ? '띱 완료' : '띱 취소'}`); // Snackbar 표시 - const message = response.data.isFollowing - ? `${nickname}님을 띱 했어요.` + const message = response.data.isFollowing + ? `${nickname}님을 띱 했어요.` : `${nickname}님을 띱 취소했어요.`; - + openPopup('snackbar', { message, variant: 'top', - onClose: () => {} + onClose: () => {}, }); } catch (error) { console.error('팔로우/언팔로우 실패:', error); @@ -81,7 +83,7 @@ const Profile = ({
- {showFollowButton && ( + {showFollowButton && !isMyFeed && (
{followed ? '띱 취소' : '띱 하기'}
diff --git a/src/hooks/useOAuthToken.ts b/src/hooks/useOAuthToken.ts index a3a138bb..9d6f63d5 100644 --- a/src/hooks/useOAuthToken.ts +++ b/src/hooks/useOAuthToken.ts @@ -18,7 +18,7 @@ export const useOAuthToken = () => { // 서버에 토큰 발급 요청 apiClient - .post('/api/set-cookie', { loginTokenKey }, { withCredentials: true }) + .post('/auth/set-cookie', { loginTokenKey }, { withCredentials: true }) .then(response => { console.log('✅ 토큰 발급 성공:', response.data); // URL에서 code 파라미터 제거 diff --git a/src/pages/feed/MyFeedPage.tsx b/src/pages/feed/MyFeedPage.tsx new file mode 100644 index 00000000..52186562 --- /dev/null +++ b/src/pages/feed/MyFeedPage.tsx @@ -0,0 +1,92 @@ +import { useNavigate, useParams } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +import styled from '@emotion/styled'; +import NavBar from '../../components/common/NavBar'; +import TitleHeader from '@/components/common/TitleHeader'; +import writefab from '../../assets/common/writefab.svg'; +import leftArrow from '../../assets/common/leftArrow.svg'; +import OtherFeed from '@/components/feed/OtherFeed'; +import { getOtherFeed, type OtherFeedItem } from '@/api/feeds/getOtherFeed'; +import { getOtherProfile } from '@/api/users/getOtherProfile'; +import type { OtherProfileData } from '@/types/profile'; + +const Container = styled.div` + min-width: 320px; + max-width: 767px; + margin: 0 auto; +`; + +const MyFeedPage = () => { + const navigate = useNavigate(); + const { userId } = useParams<{ userId: string }>(); + const [feedData, setFeedData] = useState([]); + const [profileData, setProfileData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const handleBackClick = () => { + navigate(-1); + }; + + // 다른 사용자 피드 및 프로필 데이터 로드 + useEffect(() => { + const loadOtherData = async () => { + if (!userId) { + setError('사용자 ID가 없습니다.'); + setLoading(false); + return; + } + + try { + setLoading(true); + + const [feedResponse, profileResponse] = await Promise.all([ + getOtherFeed(Number(userId)), + getOtherProfile(Number(userId)), + ]); + + console.log('🔍 MyFeedPage - Profile Response:', profileResponse.data); + console.log('🔍 MyFeedPage - isWriter 값:', profileResponse.data.isWriter); + + setFeedData(feedResponse.data.feedList); + setProfileData(profileResponse.data); + setError(null); + } catch (err) { + console.error('다른 사용자 데이터 로드 실패:', err); + setError('사용자 정보를 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + loadOtherData(); + }, [userId]); + + if (loading) { + return <>; + } + + if (error) { + return <>; + } + + return ( + + } + onLeftClick={handleBackClick} + /> + + + + ); +}; + +export default MyFeedPage; diff --git a/src/pages/feed/OtherFeedPage.tsx b/src/pages/feed/OtherFeedPage.tsx index db3fab8a..ea87a156 100644 --- a/src/pages/feed/OtherFeedPage.tsx +++ b/src/pages/feed/OtherFeedPage.tsx @@ -80,6 +80,7 @@ const OtherFeedPage = () => { posts={feedData} isMyFeed={false} profileData={profileData} + showFollowButton={!profileData?.isWriter} // isWriter가 true면 팔로우 버튼 숨김 />
diff --git a/src/pages/index.tsx b/src/pages/index.tsx index df5aa1df..5cf16c67 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -18,6 +18,7 @@ import GroupSearch from './groupSearch/GroupSearch'; import Search from './search/Search'; import ApplyBook from './search/ApplyBook'; import OtherFeedPage from './feed/OtherFeedPage'; +import MyFeedPage from './feed/MyFeedPage'; import FollowerListPage from './feed/FollowerListPage'; import TodayWords from './today-words/TodayWords'; import SearchBook from './searchBook/SearchBook'; @@ -69,6 +70,7 @@ const Router = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/types/post.ts b/src/types/post.ts index 44d23e38..f275a192 100644 --- a/src/types/post.ts +++ b/src/types/post.ts @@ -15,6 +15,7 @@ export interface PostData { isSaved?: boolean; isLiked?: boolean; isPublic?: boolean; + isWriter?: boolean; } export interface FeedListProps { @@ -50,6 +51,7 @@ export interface SubReplyDataProps { likeCount: number; replyId: number; isLike: boolean; + isWriter: boolean; } // 댓글(Reply) @@ -65,6 +67,7 @@ export interface ReplyDataProps { likeCount: number; isLike: boolean; isDeleted: boolean; + isWriter: boolean; replyList: SubReplyDataProps[]; } diff --git a/src/types/profile.ts b/src/types/profile.ts index 54d6d023..0a75c757 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -12,4 +12,5 @@ export interface MyProfileData { // 다른 사용자 프로필 정보 타입 (isFollowing 포함) export interface OtherProfileData extends MyProfileData { isFollowing: boolean; + isWriter?: boolean; } From 4a5ab816d323ff0496085fc017c2c83747951b9a Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Sun, 17 Aug 2025 13:56:00 +0900 Subject: [PATCH 85/86] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/postData.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/data/postData.ts b/src/data/postData.ts index e65d867a..94c504d1 100644 --- a/src/data/postData.ts +++ b/src/data/postData.ts @@ -20,6 +20,7 @@ export const mockPosts: PostData[] = [ isSaved: false, isLiked: true, isPublic: true, + isWriter: false, }, { feedId: 56, @@ -39,6 +40,7 @@ export const mockPosts: PostData[] = [ isSaved: true, isLiked: false, isPublic: false, + isWriter: false, }, { feedId: 58, @@ -58,6 +60,7 @@ export const mockPosts: PostData[] = [ isSaved: false, isLiked: false, isPublic: true, + isWriter: false, }, ]; @@ -80,6 +83,7 @@ export const mockFeedPost: FeedPostProps = { isSaved: true, isLiked: false, isPublic: true, + isWriter: false, }; // 📌 댓글/대댓글(Mock) @@ -97,6 +101,7 @@ export const mockCommentList: ReplyDataProps[] = [ likeCount: 1, isLike: false, isDeleted: false, + isWriter: false, replyList: [ { parentCommentCreatorNickname: 'User31', @@ -110,6 +115,7 @@ export const mockCommentList: ReplyDataProps[] = [ content: '맞아요, 저도 너무 좋았어요!맞아요, 저도 너무 좋았어요!', likeCount: 2, isLike: false, + isWriter: false, }, { parentCommentCreatorNickname: 'User35', @@ -123,6 +129,7 @@ export const mockCommentList: ReplyDataProps[] = [ content: '추천 감사합니다!', likeCount: 123, isLike: true, + isWriter: false, }, ], }, @@ -139,5 +146,6 @@ export const mockCommentList: ReplyDataProps[] = [ isLike: true, replyList: [], isDeleted: false, + isWriter: false, }, ]; From c692d12626eff13f7c2743a303ce191d06dcdd3b Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Sun, 17 Aug 2025 14:02:30 +0900 Subject: [PATCH 86/86] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20request=20body=EC=97=90=20isTokenRequired=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/users/postSignup.ts | 1 + src/pages/signup/SignupGenre.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/api/users/postSignup.ts b/src/api/users/postSignup.ts index 51b69377..dfc001d5 100644 --- a/src/api/users/postSignup.ts +++ b/src/api/users/postSignup.ts @@ -3,6 +3,7 @@ import { apiClient } from '../index'; export interface PostSignupRequest { aliasName: string; nickname: string; + isTokenRequired: boolean; } export interface PostSignupResponse { diff --git a/src/pages/signup/SignupGenre.tsx b/src/pages/signup/SignupGenre.tsx index 61a1f899..e81e7344 100644 --- a/src/pages/signup/SignupGenre.tsx +++ b/src/pages/signup/SignupGenre.tsx @@ -67,6 +67,7 @@ const SignupGenre = () => { const result = await postSignup({ aliasName: selectedAlias.subTitle, nickname: nickname, + isTokenRequired: false, }); if (result.success) {