From ac3ad917ef839e0e9550d32370a6210efbd592cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Thu, 19 Feb 2026 20:45:09 +0900 Subject: [PATCH 01/62] =?UTF-8?q?chore=20:=20=EC=97=86=EC=96=B4=EC=A0=B8?= =?UTF-8?q?=EC=95=BC=ED=95=A0=20=ED=8E=98=EC=9D=B4=EC=A7=80=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/app/groups/[id]/admin/bookcase/page.tsx | 82 --------------------- 1 file changed, 82 deletions(-) delete mode 100644 src/app/groups/[id]/admin/bookcase/page.tsx diff --git a/src/app/groups/[id]/admin/bookcase/page.tsx b/src/app/groups/[id]/admin/bookcase/page.tsx deleted file mode 100644 index 5e027ad..0000000 --- a/src/app/groups/[id]/admin/bookcase/page.tsx +++ /dev/null @@ -1,82 +0,0 @@ -// src/app/groups/[id]/admin/bookcase/page.tsx - -"use client"; - -import { useRouter, useParams } from "next/navigation"; -import BookcaseCard from "@/components/base-ui/Bookcase/BookcaseCard"; - -// [1] 더미 데이터 생성 헬퍼 함수 (회원 페이지와 동일) -const createMockBooks = (generation: string, count: number) => - Array.from({ length: count }).map((_, i) => ({ - id: `${generation}-${i}`, // 실제 DB 연동 시 bookId가 들어감 - title: "채식주의자", - author: "한강 지음", - imageUrl: "/dummy_book_cover.png", - category: { - generation: generation, - genre: "소설/시/희곡", - }, - rating: 4.5, - })); - -// [2] 기수별 데이터 그룹화 -const BOOKCASE_DATA = [ - { - generation: "8기", - books: createMockBooks("8기", 4), - }, - { - generation: "7기", - books: createMockBooks("7기", 8), - }, -]; - -export default function AdminBookcaseListPage() { - const router = useRouter(); - const params = useParams(); - const groupId = params.id as string; - - // [이동 로직] 도서 상세(운영진) 페이지로 이동 - const handleGoToDetail = (bookId: string) => { - // 경로: /groups/[id]/admin/bookcase/[bookId] - router.push(`/groups/${groupId}/admin/bookcase/${bookId}`); - }; - - return ( - // [UI] 회원 페이지와 동일한 레이아웃 구조 -
- {/* 책장 리스트 영역 */} - {BOOKCASE_DATA.map((group) => ( -
- {/* 기수 라벨 */} -
- {group.generation} -
- - {/* 카드 리스트 */} - {/* 반응형 정렬: 모바일/태블릿(Center) -> 데스크탑(Start) */} -
- {group.books.map((book) => ( - handleGoToDetail(book.id)} - onReviewClick={() => handleGoToDetail(book.id)} - onMeetingClick={() => handleGoToDetail(book.id)} - /> - ))} -
-
- ))} -
- ); -} From 7ec87dcb2a34a0ff3f5564ccb1bebcedfecd2eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Thu, 19 Feb 2026 20:53:05 +0900 Subject: [PATCH 02/62] =?UTF-8?q?fix=20:=20=EC=B1=85=EC=9E=A5,=20=EC=B1=85?= =?UTF-8?q?=EC=9E=A5=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20API?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=B0=EC=84=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/admin/bookcase/[bookId]/layout.tsx | 5 + .../[id]/admin/bookcase/[bookId]/page.tsx | 7 + .../[id]/bookcase/MeetingTabSection.tsx | 172 ++++++++++++++++++ .../groups/[id]/bookcase/[bookId]/page.tsx | 136 +++++++------- src/app/groups/[id]/bookcase/page.tsx | 94 ++++++---- .../base-ui/Bookcase/BookDetailNav.tsx | 25 +-- .../base-ui/Bookcase/BookcaseCard.tsx | 2 +- .../base-ui/Bookcase/MeetingInfo.tsx | 66 +++---- src/types/groups/bookcasehome.ts | 93 ++++++++++ 9 files changed, 439 insertions(+), 161 deletions(-) create mode 100644 src/app/groups/[id]/admin/bookcase/[bookId]/layout.tsx create mode 100644 src/app/groups/[id]/admin/bookcase/[bookId]/page.tsx create mode 100644 src/app/groups/[id]/bookcase/MeetingTabSection.tsx create mode 100644 src/types/groups/bookcasehome.ts diff --git a/src/app/groups/[id]/admin/bookcase/[bookId]/layout.tsx b/src/app/groups/[id]/admin/bookcase/[bookId]/layout.tsx new file mode 100644 index 0000000..3463306 --- /dev/null +++ b/src/app/groups/[id]/admin/bookcase/[bookId]/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react'; + +export default function meetingeditLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/src/app/groups/[id]/admin/bookcase/[bookId]/page.tsx b/src/app/groups/[id]/admin/bookcase/[bookId]/page.tsx new file mode 100644 index 0000000..8a02d4f --- /dev/null +++ b/src/app/groups/[id]/admin/bookcase/[bookId]/page.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +export default function meetingeditpage() { + return ( +
meetingeditpage
+ ) +} diff --git a/src/app/groups/[id]/bookcase/MeetingTabSection.tsx b/src/app/groups/[id]/bookcase/MeetingTabSection.tsx new file mode 100644 index 0000000..ed23300 --- /dev/null +++ b/src/app/groups/[id]/bookcase/MeetingTabSection.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import MeetingInfo from "@/components/base-ui/Bookcase/MeetingInfo"; +import TeamFilter from "@/components/base-ui/Bookcase/bookid/TeamFilter"; +import TeamSection from "@/components/base-ui/Bookcase/bookid/TeamSection"; + +/** ===== API 타입 (네가 준 응답 기준) ===== */ +type MeetingDetailResult = { + meetingId: number; + title: string; + meetingTime: string; // "2026-02-10T15:08:24.373372" + location: string; + existingTeamNumbers: number[]; + teams: { + teamNumber: number; + members: { + clubMemberId: number; + memberInfo: { + nickname: string; + profileImageUrl: string | null; + }; + }[]; + }[]; + /** 서버에서 추가될 예정 */ + isAdmin?: boolean; +}; + +type Props = { + meetingId: number; + onManageTeamsClick?: () => void; +}; + +/** ===== 더미 (일단 동작 검증용) ===== */ +const MOCK_MEETING_DETAIL: MeetingDetailResult = { + meetingId: 1, + title: "살인자ㅇ난감 함께 읽기", + meetingTime: "2026-02-10T15:08:24.373372", + location: "강남역 2번 출구", + existingTeamNumbers: [1], + teams: [ + { + teamNumber: 1, + members: [ + { clubMemberId: 1, memberInfo: { nickname: "테스터1", profileImageUrl: null } }, + { clubMemberId: 2, memberInfo: { nickname: "테스터2", profileImageUrl: null } }, + ], + }, + ], + isAdmin: true, +}; + +function formatDateDot(iso: string) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) { + // fallback: 앞 10자리 "YYYY-MM-DD" + return iso.slice(0, 10).replaceAll("-", "."); + } + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}.${m}.${day}`; +} + +function teamNumberToLabel(teamNumber: number) { + // 1 -> A조, 2 -> B조 ... (너 기존 dummy가 "A조"라서 맞춰줌) + const code = 64 + teamNumber; // 'A' = 65 + if (code >= 65 && code <= 90) return `${String.fromCharCode(code)}조`; + return `${teamNumber}조`; +} + +export default function MeetingTabSection({ meetingId, onManageTeamsClick }: Props) { + const [data, setData] = useState(null); + + + useEffect(() => { + const load = async () => { + // TODO: 여기서 API 호출로 교체 + setData({ ...MOCK_MEETING_DETAIL, meetingId }); + }; + + load(); + }, [meetingId]); + + const teamLabels = useMemo(() => { + if (!data) return []; + const fromExisting = (data.existingTeamNumbers ?? []).map(teamNumberToLabel); + const fromTeams = (data.teams ?? []).map((t) => teamNumberToLabel(t.teamNumber)); + // 중복 제거 + 순서 유지 + return Array.from(new Set([...fromExisting, ...fromTeams])); + }, [data]); + + const [selectedTeam, setSelectedTeam] = useState(""); + + // data 로딩되면 첫 팀으로 초기화 + useEffect(() => { + if (teamLabels.length === 0) return; + if (!selectedTeam || !teamLabels.includes(selectedTeam)) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setSelectedTeam(teamLabels[0]); + } + }, [teamLabels, selectedTeam]); + + const currentTeamMembers = useMemo(() => { + if (!data) return null; + const labelToNumber = (label: string) => { + // "A조" -> 1, "B조" -> 2 ... + const ch = label?.[0]; + if (!ch) return null; + const code = ch.charCodeAt(0); + if (code >= 65 && code <= 90) return code - 64; + return null; + }; + + const teamNumber = labelToNumber(selectedTeam); + const team = data.teams.find((t) => t.teamNumber === teamNumber); + + // TeamSection이 원하는 형태로 매핑 (너 예전 코드 기준) + const members = + team?.members.map((m) => ({ + id: String(m.clubMemberId), + name: m.memberInfo.nickname, + profileImageUrl: m.memberInfo.profileImageUrl ?? "/profile4.svg", + })) ?? []; + + return { + teamName: selectedTeam, + members, + }; + }, [data, selectedTeam]); + + if (!data) { + return ( +
+
+ 모임 정보 불러오는 중... +
+
+ ); + } + + return ( +
+ {/* 2-1. 모임 정보 카드 */} + + + {/* 2-2. 조 + 멤버 (겉으로는 한 덩어리 UX) */} +
+ + + {currentTeamMembers && ( + + )} +
+
+ ); +} diff --git a/src/app/groups/[id]/bookcase/[bookId]/page.tsx b/src/app/groups/[id]/bookcase/[bookId]/page.tsx index 60d6366..39e3c26 100644 --- a/src/app/groups/[id]/bookcase/[bookId]/page.tsx +++ b/src/app/groups/[id]/bookcase/[bookId]/page.tsx @@ -1,45 +1,74 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { usePathname, useRouter, useSearchParams, useParams } from "next/navigation"; + import BookDetailCard from "@/components/base-ui/Bookcase/BookDetailCard"; -import BookDetailNav from "@/components/base-ui/Bookcase/BookDetailNav"; -import MeetingInfo from "@/components/base-ui/Bookcase/MeetingInfo"; +import BookDetailNav, { Tab as TabKey } from "@/components/base-ui/Bookcase/BookDetailNav"; import DebateSection from "./DebateSection"; -import TeamFilter from "@/components/base-ui/Bookcase/bookid/TeamFilter"; -import TeamSection from "@/components/base-ui/Bookcase/bookid/TeamSection"; import ReviewSection from "./ReviewSection"; + import { MOCK_BOOK_DETAIL, - MOCK_MEETING_INFO, MOCK_DEBATE_TOPICS, - MOCK_TEAMS_DATA, MOCK_REVIEWS, -} from './dummy'; +} from "./dummy"; +import MeetingTabSection from "../MeetingTabSection"; + +function isTabKey(v: string | null): v is TabKey { + return v === "topic" || v === "review" || v === "meeting"; +} export default function AdminBookDetailPage() { - const [activeTab, setActiveTab] = useState<"발제" | "한줄평" | "정기모임">( - "정기모임" - ); - const [MyprofileImageUrl, setMyprofileImageUrl] = useState("/profile4.svg"); - const [MyName, setMyName] = useState("aasdfsad"); + const router = useRouter(); + const pathname = usePathname(); // /groups/201/bookcase/3 + const searchParams = useSearchParams(); + const params = useParams(); - // 발제 - const [isDebateWriting, setIsDebateWriting] = useState(false); + const groupId = params.id as string; + const meetingIdParam = (params.meetingId ?? params.bookId) as string; // 폴더명 차이 커버 + const meetingId = Number(meetingIdParam); + + const [activeTab, setActiveTab] = useState("meeting"); + const [myProfileImageUrl] = useState("/profile4.svg"); + const [myName] = useState("aasdfsad"); + + const [isDebateWriting, setIsDebateWriting] = useState(false); const [isReviewWriting, setIsReviewWriting] = useState(false); - // 조 선택 상태 관리 - const [selectedTeam, setSelectedTeam] = useState("A조"); - - // 현재 선택된 조의 데이터 찾기 - const currentTeamData = MOCK_TEAMS_DATA.find( - (t) => t.teamName === selectedTeam - ); + + // URL -> state 동기화 (직접 ?tab=topic 들어와도 맞춰줌) + useEffect(() => { + const tab = searchParams.get("tab"); + if (isTabKey(tab) && tab !== activeTab) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setActiveTab(tab); + } + if (!tab) { + const next = new URLSearchParams(searchParams.toString()); + next.set("tab", "meeting"); + router.replace(`${pathname}?${next.toString()}`, { scroll: false }); + } + + }, [searchParams]); + + // state -> URL 동기화 (탭 바꾸면 ?tab=도 같이 바뀜) + const handleTabChange = (tab: TabKey) => { + setActiveTab(tab); + const next = new URLSearchParams(searchParams.toString()); + next.set("tab", tab); + router.replace(`${pathname}?${next.toString()}`, { scroll: false }); + }; + + const handleManageTeams = () => { + // ✅ 여기 네 라우트에 맞게 바꿔라. "관리자일 때만 버튼 노출"은 MeetingTabSection에서 처리함. + router.push(`/groups/${groupId}/admin/bookcase/${meetingId}`); + }; return (
- {/* 1. 도서 상세 카드 */} - {/* 2. 하단 상세 정보 영역 */}
- {/* 내비게이션 바 */} - + - {/* 탭 컨텐츠 영역 */}
- {activeTab === "정기모임" && ( - <> - {/* 2-1. 모임 정보 카드 */} - - - {/* 2-2. 조별 멤버 리스트 영역 (Frame 2087328794) */} -
- {/* 조 선택 필터 (Frame 2087328778) */} - t.teamName)} - selectedTeam={selectedTeam} - onSelect={setSelectedTeam} - /> - - {/* 선택된 조의 멤버 리스트 섹션 (Frame 2087328793) */} - {currentTeamData && ( - - )} -
- + {activeTab === "meeting" && ( + )} - {activeTab === '발제' && ( + {activeTab === "topic" && ( setIsDebateWriting((v) => !v)} onSendDebate={(text) => { - console.log('send:', text); - // TODO: API 붙일 곳 + console.log("topic send:", { meetingId, text }); + // TODO: topic API 연결부 return true; }} items={MOCK_DEBATE_TOPICS} /> )} - - {activeTab === '한줄평' && ( + {activeTab === "review" && ( setIsReviewWriting((v) => !v)} onSendReview={(text, rating) => { - console.log('review send:', { text, rating }); + console.log("review send:", { meetingId, text, rating }); + // TODO: review API 연결부 return true; }} items={MOCK_REVIEWS} - onClickMore={(id) => console.log('more:', id)} + onClickMore={(id) => console.log("more:", id)} /> )}
@@ -122,4 +126,4 @@ export default function AdminBookDetailPage() {
); -} +} \ No newline at end of file diff --git a/src/app/groups/[id]/bookcase/page.tsx b/src/app/groups/[id]/bookcase/page.tsx index 16fc909..d85cf86 100644 --- a/src/app/groups/[id]/bookcase/page.tsx +++ b/src/app/groups/[id]/bookcase/page.tsx @@ -1,80 +1,94 @@ "use client"; - import BookcaseCard from "@/components/base-ui/Bookcase/BookcaseCard"; import FloatingFab from "@/components/base-ui/Float"; +import { BookcaseApiResponse, groupByGeneration } from "@/types/groups/bookcasehome"; import { useParams, useRouter } from "next/navigation"; -// [1] 더미 데이터 생성 헬퍼 함수 -const createMockBooks = (generation: string, count: number) => - Array.from({ length: count }).map((_, i) => ({ - id: `${generation}-${i}`, - title: "채식주의자", - author: "한강 지음", - imageUrl: "/dummy_book_cover.png", - category: { - generation: generation, - genre: "소설/시/희곡", - }, - rating: 4.5, - })); - -// [2] 기수별 데이터 그룹화 -const BOOKCASE_DATA = [ - { - generation: "8기", - books: createMockBooks("8기", 8), - }, - { - generation: "7기", - books: createMockBooks("7기", 7), +//API 형태 그대로 +const MOCK_BOOKCASE_RESPONSE: BookcaseApiResponse = { + isSuccess: true, + code: "COMMON200", + message: "성공입니다.", + result: { + bookShelfInfoList: [ + { + meetingInfo: { meetingId: 2, generation: 1, tag: "MEETING", averageRate: 3 }, + bookInfo: { + bookId: "9791192625133", + title: "거인의 어깨 1 - 벤저민 그레이엄, 워런 버핏, 피터 린치에게 배우다", + author: "홍진채", + imgUrl: null, + }, + }, + { + meetingInfo: { meetingId: 1, generation: 1, tag: "MEETING", averageRate: 4.5 }, + bookInfo: { + bookId: "9791192005317", + title: "살인자ㅇ난감", + author: "꼬마비", + imgUrl: null, + }, + }, + { + meetingInfo: { meetingId: 3, generation: 2, tag: "MEETING", averageRate: 3.8 }, + bookInfo: { + bookId: "1", + title: "더미 책", + author: "더미 작가", + imgUrl: null, + }, + }, + ], + hasNext: false, + nextCursor: null, }, -]; +}; export default function BookcasePage() { const router = useRouter(); const params = useParams(); const groupId = params.id as string; - const handleGoToDetail = (bookId: string) => { - // 경로: /groups/[id]/admin/bookcase/[bookId] - router.push(`/groups/${groupId}/bookcase/${bookId}`); - }; + const sections = groupByGeneration(MOCK_BOOKCASE_RESPONSE.result.bookShelfInfoList); + + type TabParam = "topic" | "review" | "meeting"; + + const handleGoToDetail = (meetingId: number, tab: TabParam) => { + router.push(`/groups/${groupId}/bookcase/${meetingId}?tab=${tab}`); +}; return (
{/* 책장 리스트 영역 */} - {BOOKCASE_DATA.map((group) => ( + {sections.map((section) => (
{/* 기수 라벨 */}
- {group.generation} + {section.generationLabel}
{/* 카드 리스트 */} -
- {group.books.map((book) => ( + {section.books.map((book) => ( handleGoToDetail(book.id)} - onReviewClick={() => handleGoToDetail(book.id)} - onMeetingClick={() => handleGoToDetail(book.id)} + onTopicClick={() => handleGoToDetail(book.meetingId, "topic")} + onReviewClick={() => handleGoToDetail(book.meetingId, "review")} + onMeetingClick={() => handleGoToDetail(book.meetingId, "meeting")} /> ))}
-
))} diff --git a/src/components/base-ui/Bookcase/BookDetailNav.tsx b/src/components/base-ui/Bookcase/BookDetailNav.tsx index 1d38145..70ec3b7 100644 --- a/src/components/base-ui/Bookcase/BookDetailNav.tsx +++ b/src/components/base-ui/Bookcase/BookDetailNav.tsx @@ -2,20 +2,27 @@ import React from "react"; -type Tab = "발제" | "한줄평" | "정기모임"; +export type Tab = "topic" | "review" | "meeting"; type Props = { activeTab: Tab; onTabChange: (tab: Tab) => void; }; -export default function BookDetailNav({ activeTab, onTabChange }: Props) { - const tabs: Tab[] = ["발제", "한줄평", "정기모임"]; +const TABS: Tab[] = ["topic", "review", "meeting"]; + +const TAB_LABEL: Record = { + topic: "발제", + review: "한줄평", + meeting: "정기모임", +}; +export default function BookDetailNav({ activeTab, onTabChange }: Props) { return ( -
- {tabs.map((tab) => { +
+ {TABS.map((tab) => { const isActive = activeTab === tab; + return ( ); })} diff --git a/src/components/base-ui/Bookcase/BookcaseCard.tsx b/src/components/base-ui/Bookcase/BookcaseCard.tsx index 7ad0f7b..29d7c79 100644 --- a/src/components/base-ui/Bookcase/BookcaseCard.tsx +++ b/src/components/base-ui/Bookcase/BookcaseCard.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; type Props = { - imageUrl?: string; + imageUrl: string; title: string; author: string; category: { diff --git a/src/components/base-ui/Bookcase/MeetingInfo.tsx b/src/components/base-ui/Bookcase/MeetingInfo.tsx index 687f58a..2bfa91b 100644 --- a/src/components/base-ui/Bookcase/MeetingInfo.tsx +++ b/src/components/base-ui/Bookcase/MeetingInfo.tsx @@ -3,9 +3,11 @@ import Image from "next/image"; type Props = { + meetingId?: number; meetingName: string; - date: string; // 예: "2000.00.00" - location: string; // 예: "제이스 스터디룸" + date: string; // "2026.02.10" + location: string; + isAdmin?: boolean; onManageGroupClick?: () => void; }; @@ -13,6 +15,7 @@ export default function MeetingInfo({ meetingName, date, location, + isAdmin = false, onManageGroupClick, }: Props) { return ( @@ -21,52 +24,29 @@ export default function MeetingInfo({
{/* 상단: 모임 이름 + 조 관리 버튼 */}
- {/* 모임 이름 영역 */} -
-
-
-
- 모임 아이콘 -
- {/* 모임 이름: Subhead_4.1 */} - {meetingName} -
-
+ {/* 모임 이름 */} +
+ {meetingName}
- {/* 조 관리하기 버튼 */} - + 조 관리하기 +
+ 설정 +
+ + )}
- {/* 하단: 일정 및 장소 정보 */} -
- {/* 일정 */} -
-
- 달력 -
-
- {date} -
-
- - {/* 장소 */} -
-
- 위치 -
-
- {location} -
-
+ {/* 하단: 날짜 / 장소 */} +
+ {date} + {location}
diff --git a/src/types/groups/bookcasehome.ts b/src/types/groups/bookcasehome.ts new file mode 100644 index 0000000..e24ddd1 --- /dev/null +++ b/src/types/groups/bookcasehome.ts @@ -0,0 +1,93 @@ +// src/types/bookcase.ts +const DEFAULT_BOOK_COVER = "/dummy_book_cover.png"; +/** ===== API Raw Types ===== */ +export type BookcaseApiResponse = { + isSuccess: boolean; + code: string; + message: string; + result: BookcaseApiResult; +}; + +export type BookcaseApiResult = { + bookShelfInfoList: BookShelfInfo[]; + hasNext: boolean; + nextCursor: string | null; +}; + +export type BookShelfInfo = { + meetingInfo: MeetingInfo; + bookInfo: BookInfo; +}; + +export type MeetingInfo = { + meetingId: number; + generation: number; // 1,2,3... + tag: "MEETING" | string; // 서버가 더 늘리면 string으로 대응 + averageRate: number; // 평점(0~5 같은 값일 가능성) +}; + +export type BookInfo = { + bookId: string; // ISBN 같은 문자열 + title: string; + author: string; + imgUrl: string | null; +}; + +/** ===== UI View Model Types (BookcaseCard에 꽂기 좋은 형태) ===== */ +export type BookcaseCardCategory = { + generation: string; // "1기" 같은 라벨 + genre: string; // 서버에 장르 없으니 tag 등으로 대체 +}; + +export type BookcaseCardModel = { + bookId: string; + title: string; + author: string; + imageUrl: string; + category: BookcaseCardCategory; + rating: number; + meetingId: number; + generationNumber: number; + tag: string; +}; + +export type BookcaseSectionModel = { + generationNumber: number; + generationLabel: string; // "1기" + books: BookcaseCardModel[]; +}; + +/** ===== Mapper / Grouping Utils ===== */ +export const toBookcaseCardModel = (item: BookShelfInfo): BookcaseCardModel => ({ + bookId: item.bookInfo.bookId, + title: item.bookInfo.title, + author: item.bookInfo.author, + imageUrl: item.bookInfo.imgUrl ?? DEFAULT_BOOK_COVER, + category: { + generation: `${item.meetingInfo.generation}기`, + genre: item.meetingInfo.tag, // 서버에 genre 없으니 tag를 박아둠 + }, + rating: item.meetingInfo.averageRate ?? 0, + meetingId: item.meetingInfo.meetingId, + generationNumber: item.meetingInfo.generation, + tag: item.meetingInfo.tag, +}); + +export const groupByGeneration = (list: BookShelfInfo[]): BookcaseSectionModel[] => { + const map = new Map(); + + for (const item of list) { + const gen = item.meetingInfo.generation; + const arr = map.get(gen) ?? []; + arr.push(toBookcaseCardModel(item)); + map.set(gen, arr); + } + + return Array.from(map.entries()) + .sort((a, b) => b[0] - a[0]) // 최신 기수 먼저 (원하면 반대로) + .map(([generationNumber, books]) => ({ + generationNumber, + generationLabel: `${generationNumber}기`, + books, + })); +}; From 611bab39c98f31e90cb27cde7f97ee916bfcfd7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 20 Feb 2026 01:13:23 +0900 Subject: [PATCH 03/62] =?UTF-8?q?chore=20:=20dnd-kit=20=EC=B6=94=EA=B0=80(?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B8=B0=EB=8A=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 41 +++++++++++++++++++++++++++++++++++++++++ package.json | 2 ++ 2 files changed, 43 insertions(+) diff --git a/package-lock.json b/package-lock.json index c35a119..a87e0bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "checkmo", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", "js-cookie": "^3.0.5", @@ -287,6 +289,45 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", diff --git a/package.json b/package.json index 6732074..8b3e406 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "lint": "eslint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", "js-cookie": "^3.0.5", From d6591f1f6807f1b48c20e6ffd243f107fe49c377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 20 Feb 2026 01:13:42 +0900 Subject: [PATCH 04/62] =?UTF-8?q?chore=20:=20=EC=9D=B4=EB=AA=A8=ED=8B=B0?= =?UTF-8?q?=EC=BD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/ArrowLeft3.svg | 3 +++ public/Polygon7.svg | 3 +++ public/icon_plus_2.svg | 4 ++++ 3 files changed, 10 insertions(+) create mode 100644 public/ArrowLeft3.svg create mode 100644 public/Polygon7.svg create mode 100644 public/icon_plus_2.svg diff --git a/public/ArrowLeft3.svg b/public/ArrowLeft3.svg new file mode 100644 index 0000000..2ff3385 --- /dev/null +++ b/public/ArrowLeft3.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/Polygon7.svg b/public/Polygon7.svg new file mode 100644 index 0000000..270d420 --- /dev/null +++ b/public/Polygon7.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icon_plus_2.svg b/public/icon_plus_2.svg new file mode 100644 index 0000000..28e26cd --- /dev/null +++ b/public/icon_plus_2.svg @@ -0,0 +1,4 @@ + + + + From 83a9f1689f740bd1e469d5a69ea7c2364875c469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 20 Feb 2026 01:27:18 +0900 Subject: [PATCH 05/62] =?UTF-8?q?feat=20:=20=EC=B1=84=ED=8C=85=20UI=20?= =?UTF-8?q?=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/bookcase/[bookId]/meeting/page.tsx | 56 ++- .../base-ui/Bookcase/ChatTeamSelectModal.tsx | 336 ++++++++++++++++++ 2 files changed, 373 insertions(+), 19 deletions(-) create mode 100644 src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx diff --git a/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx b/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx index a93dd79..f9257f6 100644 --- a/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx +++ b/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx @@ -3,8 +3,9 @@ import React, { useEffect, useMemo, useState } from "react"; import Image from "next/image"; -import { useSearchParams } from "next/navigation"; +import { useSearchParams, useRouter } from "next/navigation"; import FloatingFab from "@/components/base-ui/Float"; +import ChatTeamSelectModal, { ChatTeam } from "@/components/base-ui/Bookcase/ChatTeamSelectModal" type Team = { teamId: string; @@ -19,8 +20,7 @@ type TeamDebateItem = { profileImageUrl?: string | null; }; - -const CHECKED_BG = "#F7FEF3"; +const CHECKED_BG = "#F7FEF3"; const DEFAULT_PROFILE = "/profile4.svg"; const normalizeSrc = (src?: string | null) => { @@ -40,7 +40,6 @@ const sortCheckedFirstStable = ( list: TeamDebateItem[], checkedMap: Record ) => { - // "현재 순서"를 기준으로 stable 정렬 const baseIndex = new Map(list.map((x, i) => [String(x.id), i])); const next = [...list]; @@ -48,10 +47,7 @@ const sortCheckedFirstStable = ( const ca = checkedMap[String(a.id)] ? 1 : 0; const cb = checkedMap[String(b.id)] ? 1 : 0; - // checked가 먼저 if (cb !== ca) return cb - ca; - - // 같은 그룹이면 기존 순서 유지 return (baseIndex.get(String(a.id)) ?? 0) - (baseIndex.get(String(b.id)) ?? 0); }); @@ -126,6 +122,7 @@ export default function MeetingPage({ }) { const { bookId } = params; const sp = useSearchParams(); + const router = useRouter(); const initialTeamName = sp.get("team"); // ?team=A조 const [teams, setTeams] = useState([]); @@ -134,10 +131,14 @@ export default function MeetingPage({ const [items, setItems] = useState([]); const [checkedMap, setCheckedMap] = useState>({}); + // ✅ 채팅 조 선택 모달 상태 + const [isChatTeamModalOpen, setIsChatTeamModalOpen] = useState(false); + const selectedTeam = useMemo( () => teams.find((t) => t.teamId === selectedTeamId) ?? null, [teams, selectedTeamId] ); + const selectedTeamName = selectedTeam?.teamName ?? ""; const teamNames = useMemo(() => teams.map((t) => t.teamName), [teams]); @@ -172,7 +173,6 @@ export default function MeetingPage({ return () => { ignore = true; }; - // initialTeamName으로 초기 선택 바뀔 수 있으니 포함 }, [bookId, initialTeamName]); /** 2) 팀 선택 바뀌면 발제 로드 + 체크 초기화 */ @@ -212,6 +212,23 @@ export default function MeetingPage({ setItems((prev) => sortCheckedFirstStable(prev, checkedMap)); }; + /** + * 조 선택하면 "채팅 페이지"로 이동 (입장만) + * - chat은 "현재 경로 하위의 chat"으로 이동시키는 상대 경로 + * - teamId/teamName을 쿼리로 넘겨서 다음 페이지에서 어떤 방인지 알게 함 + */ + const handleEnterChat = (team: ChatTeam) => { + setIsChatTeamModalOpen(false); + + // 상대경로: 현재 페이지가 .../meeting 이면 .../meeting/chat 로 감 + router.push( + `chat?teamId=${team.teamId}&teamName=${encodeURIComponent(team.teamName)}` + ); + + // ❗️만약 상대경로가 안 맞으면 절대경로로 바꿔야 함: + // router.push(`/groups/${params.groupId}/meeting/${bookId}/chat?teamId=${team.teamId}`); + }; + return (
@@ -307,15 +324,12 @@ export default function MeetingPage({ " style={isChecked ? { backgroundColor: CHECKED_BG } : undefined} > - {/* 모바일: grid로 2줄 구성 (위: 프로필+이름+체크 / 아래: 내용) - t 이상: flex 한 줄 */}
- {/* 프로필 + 이름: '한 덩어리' */}
- {/* t 이상에서만 내용이 같은 줄로 옴 */}

handleToggleCheck(id)} @@ -359,7 +371,6 @@ export default function MeetingPage({

- {/* 모바일에서만: 내용이 아래로 내려감 (name 아래 라인) */}

@@ -385,10 +394,19 @@ export default function MeetingPage({

)}
+ + {/* Floating 버튼 누르면 "채팅 모달" */} + iconSrc="/icons_chat.svg" + iconAlt="채팅" + onClick={() => setIsChatTeamModalOpen(true)} + /> + + setIsChatTeamModalOpen(false)} + />
); } diff --git a/src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx b/src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx new file mode 100644 index 0000000..8dcae7d --- /dev/null +++ b/src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx @@ -0,0 +1,336 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import Image from "next/image"; + +export type ChatTeam = { + teamId: string; + teamName: string; + memberCount: number; +}; + +type Props = { + isOpen: boolean; + teams: ChatTeam[]; + onClose: () => void; + title?: string; +}; + +const ICON_CLOSE = "/icon_minus_1.svg"; // 24x24 +const ICON_ARROW_RIGHT = "/ArrowRight2.svg"; // 24x24 +const ICON_BACK = "/ArrowLeft3.svg"; // 24x24 +const ICON_SEND = "/Send.svg"; // 24x24 + +function clamp(n: number, min: number, max: number) { + return Math.min(Math.max(n, min), max); +} + +function useIsTabletUp() { + const [isTabletUp, setIsTabletUp] = useState(false); + + useEffect(() => { + const mql = window.matchMedia("(min-width: 768px)"); + // eslint-disable-next-line react-hooks/set-state-in-effect + setIsTabletUp(mql.matches); + + const onChange = (e: MediaQueryListEvent) => setIsTabletUp(e.matches); + + if (mql.addEventListener) { + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mql as any).addListener?.(onChange); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return () => (mql as any).removeListener?.(onChange); + }, []); + + return isTabletUp; +} + +type ViewMode = "select" | "chat"; + +// ✅ 네 취향: 아이콘/버튼 hover +const HOVER_ICON = + "cursor-pointer transition-[filter,transform] duration-150 ease-out hover:brightness-50 hover:scale-[1.05] active:scale-[0.98]"; +const HOVER_SURFACE = + "cursor-pointer transition-[filter,transform] duration-150 ease-out hover:brightness-95 hover:scale-[1.02] active:scale-[0.99]"; +const HOVER_INPUT_WRAPPER = + "cursor-pointer transition-[filter] duration-150 ease-out hover:brightness-95"; + +export default function ChatTeamSelectModal({ + isOpen, + teams, + onClose, + title = "채팅 조 선택", +}: Props) { + const isTabletUp = useIsTabletUp(); + const panelRef = useRef(null); + + const [view, setView] = useState("select"); + const [activeTeam, setActiveTeam] = useState(null); + + const [pos, setPos] = useState({ x: 0, y: 0 }); + const posRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + useEffect(() => { + posRef.current = pos; + }, [pos]); + + const dragRef = useRef({ + dragging: false, + startX: 0, + startY: 0, + originX: 0, + originY: 0, + }); + + const inputRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + setView("select"); + setActiveTeam(null); + + if (!isTabletUp) { + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = ""; + }; + } + return; + }, [isOpen, isTabletUp]); + + useEffect(() => { + if (!isOpen) return; + if (!isTabletUp) return; + + const raf = requestAnimationFrame(() => { + const panel = panelRef.current; + const w = panel?.offsetWidth ?? 366; + const h = panel?.offsetHeight ?? 716; + + const margin = 8; + const maxX = Math.max(margin, window.innerWidth - w - margin); + const maxY = Math.max(margin, window.innerHeight - h - margin); + + const cx = clamp((window.innerWidth - w) / 2, margin, maxX); + const cy = clamp((window.innerHeight - h) / 2, margin, maxY); + + setPos({ x: cx, y: cy }); + }); + + return () => cancelAnimationFrame(raf); + }, [isOpen, isTabletUp]); + + useEffect(() => { + if (!isOpen) return; + + const onMove = (e: PointerEvent) => { + if (!dragRef.current.dragging) return; + const panel = panelRef.current; + if (!panel) return; + + const w = panel.offsetWidth; + const h = panel.offsetHeight; + + const margin = 8; + const maxX = Math.max(margin, window.innerWidth - w - margin); + const maxY = Math.max(margin, window.innerHeight - h - margin); + + const dx = e.clientX - dragRef.current.startX; + const dy = e.clientY - dragRef.current.startY; + + const nx = clamp(dragRef.current.originX + dx, margin, maxX); + const ny = clamp(dragRef.current.originY + dy, margin, maxY); + + setPos({ x: nx, y: ny }); + }; + + const onUp = () => { + dragRef.current.dragging = false; + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + }, [isOpen]); + + const handleHeaderPointerDown = (e: React.PointerEvent) => { + if (!isTabletUp) return; + if (e.button !== 0) return; + + dragRef.current.dragging = true; + dragRef.current.startX = e.clientX; + dragRef.current.startY = e.clientY; + dragRef.current.originX = posRef.current.x; + dragRef.current.originY = posRef.current.y; + }; + + const handleSelectTeam = (team: ChatTeam) => { + setActiveTeam(team); + setView("chat"); + }; + + const handleBack = () => { + setView("select"); + setActiveTeam(null); + }; + + const focusInput = () => { + inputRef.current?.focus(); + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* ===== 헤더 ===== */} + {view === "select" ? ( +
+

{title}

+ + +
+ ) : ( +
+ + +
+ {activeTeam?.teamName ?? ""} +
+ + +
+ )} + + {/* ===== 본문 ===== */} + {view === "select" ? ( +
+ {teams.map((team) => ( + + ))} +
+ ) : ( + <> + {/* 채팅 영역 */} +
+
+
+ 채팅 UI 영역 (메시지 리스트 들어올 자리) +
+
+
+ + {/* 입력창 */} +
+
+ + + +
+
+ + )} +
+
+ ); +} From 956b34c1aa87d109580b5d2ccdb7f8a9ce3300e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 20 Feb 2026 01:28:20 +0900 Subject: [PATCH 06/62] =?UTF-8?q?chore=20:=20URL=20=EC=88=98=EC=A0=95=20(?= =?UTF-8?q?=EC=9E=84=EC=8B=9C=EB=B0=A9=ED=8E=B8=20=EC=B6=94=ED=9B=84=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/app/groups/[id]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx index 76be133..3d5be6f 100644 --- a/src/app/groups/[id]/page.tsx +++ b/src/app/groups/[id]/page.tsx @@ -140,7 +140,7 @@ export default function AdminGroupHomePage() {
router.push(joinUrl)} + onClick={() => router.push(`${Number(groupId)}/notice/4`)} bgColorVar="--Primary_1" borderColorVar="--Primary_1" textColorVar="--White" From a6b2aecc83883d495ac40cd988008c4c88deb5ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 20 Feb 2026 01:29:11 +0900 Subject: [PATCH 07/62] =?UTF-8?q?chore=20:=20=ED=8F=B4=EB=8D=94=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/groups/[id]/bookcase/{ => [bookId]}/MeetingTabSection.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/app/groups/[id]/bookcase/{ => [bookId]}/MeetingTabSection.tsx (100%) diff --git a/src/app/groups/[id]/bookcase/MeetingTabSection.tsx b/src/app/groups/[id]/bookcase/[bookId]/MeetingTabSection.tsx similarity index 100% rename from src/app/groups/[id]/bookcase/MeetingTabSection.tsx rename to src/app/groups/[id]/bookcase/[bookId]/MeetingTabSection.tsx From 0194e3bf70b0f4e1f126116cf4ee7e95d6b8c011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 20 Feb 2026 01:29:48 +0900 Subject: [PATCH 08/62] =?UTF-8?q?chore=20:=20=EB=AC=B4=EC=9D=98=EB=AF=B8?= =?UTF-8?q?=ED=95=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/groups/[id]/admin/bookcase/[bookId]/layout.tsx | 5 ----- src/app/groups/[id]/admin/bookcase/[bookId]/page.tsx | 7 ------- 2 files changed, 12 deletions(-) delete mode 100644 src/app/groups/[id]/admin/bookcase/[bookId]/layout.tsx delete mode 100644 src/app/groups/[id]/admin/bookcase/[bookId]/page.tsx diff --git a/src/app/groups/[id]/admin/bookcase/[bookId]/layout.tsx b/src/app/groups/[id]/admin/bookcase/[bookId]/layout.tsx deleted file mode 100644 index 3463306..0000000 --- a/src/app/groups/[id]/admin/bookcase/[bookId]/layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type { ReactNode } from 'react'; - -export default function meetingeditLayout({ children }: { children: ReactNode }) { - return <>{children}; -} diff --git a/src/app/groups/[id]/admin/bookcase/[bookId]/page.tsx b/src/app/groups/[id]/admin/bookcase/[bookId]/page.tsx deleted file mode 100644 index 8a02d4f..0000000 --- a/src/app/groups/[id]/admin/bookcase/[bookId]/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react' - -export default function meetingeditpage() { - return ( -
meetingeditpage
- ) -} From 09f467688a03c83fe8e10f4f72fc8452bea162ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 20 Feb 2026 01:31:24 +0900 Subject: [PATCH 09/62] =?UTF-8?q?feat=20:=20=EC=A1=B0=20=ED=8E=B8=EC=84=B1?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?types=EC=99=80=20dummy=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=93=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/admin/bookcase/[meetingId]/dummy.ts | 48 +++++++++++++++++++ .../groups/[id]/bookcase/[bookId]/page.tsx | 9 ++-- src/app/groups/[id]/layout.tsx | 2 +- src/types/groups/bookcasedetail.ts | 45 +++++++++++++++++ 4 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 src/app/groups/[id]/admin/bookcase/[meetingId]/dummy.ts create mode 100644 src/types/groups/bookcasedetail.ts diff --git a/src/app/groups/[id]/admin/bookcase/[meetingId]/dummy.ts b/src/app/groups/[id]/admin/bookcase/[meetingId]/dummy.ts new file mode 100644 index 0000000..d8ac0b9 --- /dev/null +++ b/src/app/groups/[id]/admin/bookcase/[meetingId]/dummy.ts @@ -0,0 +1,48 @@ +import { GetMeetingTeamsResult } from "@/types/groups/bookcasedetail"; + + +export type ApiResponse = { + isSuccess: boolean; + code: string; + message: string; + result: T; +}; + +export const MEETING_TEAMS_DUMMY: ApiResponse = { + isSuccess: true, + code: "COMMON200", + message: "성공입니다.", + result: { + existingTeamNumbers: [1, 2], + members: [ + { + clubMemberId: 9, + memberInfo: { nickname: "문학러버", profileImageUrl: null }, + teamNumber: null, + }, + { + clubMemberId: 8, + memberInfo: { nickname: "독서광5", profileImageUrl: null }, + teamNumber: null, + }, + { + clubMemberId: 2, + memberInfo: { nickname: "테스터2", profileImageUrl: null }, + teamNumber: null, + }, + { + clubMemberId: 1, + memberInfo: { nickname: "테스터1", profileImageUrl: null }, + teamNumber: 1, + }, + ], + hasNext: false, + nextCursor: null, + }, +}; + +// 더미 fetch처럼 쓰려고 약간의 딜레이를 줌. +export async function fetchMeetingTeamsDummy() { + await new Promise((r) => setTimeout(r, 150)); + return MEETING_TEAMS_DUMMY; +} diff --git a/src/app/groups/[id]/bookcase/[bookId]/page.tsx b/src/app/groups/[id]/bookcase/[bookId]/page.tsx index 39e3c26..c713fdd 100644 --- a/src/app/groups/[id]/bookcase/[bookId]/page.tsx +++ b/src/app/groups/[id]/bookcase/[bookId]/page.tsx @@ -7,14 +7,14 @@ import BookDetailCard from "@/components/base-ui/Bookcase/BookDetailCard"; import BookDetailNav, { Tab as TabKey } from "@/components/base-ui/Bookcase/BookDetailNav"; import DebateSection from "./DebateSection"; import ReviewSection from "./ReviewSection"; - +import MeetingTabSection from "./MeetingTabSection"; import { MOCK_BOOK_DETAIL, MOCK_DEBATE_TOPICS, MOCK_REVIEWS, } from "./dummy"; -import MeetingTabSection from "../MeetingTabSection"; + function isTabKey(v: string | null): v is TabKey { return v === "topic" || v === "review" || v === "meeting"; @@ -62,8 +62,9 @@ export default function AdminBookDetailPage() { }; const handleManageTeams = () => { - // ✅ 여기 네 라우트에 맞게 바꿔라. "관리자일 때만 버튼 노출"은 MeetingTabSection에서 처리함. - router.push(`/groups/${groupId}/admin/bookcase/${meetingId}`); + router.push( + `/groups/${groupId}/admin/bookcase/${meetingId}?meetingName=${encodeURIComponent(MOCK_BOOK_DETAIL.title)}` + ); }; return ( diff --git a/src/app/groups/[id]/layout.tsx b/src/app/groups/[id]/layout.tsx index 6e061b4..77a7e43 100644 --- a/src/app/groups/[id]/layout.tsx +++ b/src/app/groups/[id]/layout.tsx @@ -18,7 +18,7 @@ export default function GroupDetailLayout({ const [isSidebarExpanded, setIsSidebarExpanded] = useState(false); // 공지사항 작성 페이지, 책장 작성 페이지, 회원 관리 페이지는 레이아웃 적용 X - if (pathname?.includes('/admin/notice/new') || pathname?.includes('/admin/bookcase/new') || pathname?.includes('/admin/members') || pathname?.includes('/admin/applicant')) { + if (pathname?.includes('/admin/notice/new') || pathname?.includes('/admin/bookcase') || pathname?.includes('/admin/members') || pathname?.includes('/admin/applicant')) { return <>{children}; } diff --git a/src/types/groups/bookcasedetail.ts b/src/types/groups/bookcasedetail.ts new file mode 100644 index 0000000..7c75b2e --- /dev/null +++ b/src/types/groups/bookcasedetail.ts @@ -0,0 +1,45 @@ +export type MemberInfo = { + nickname: string; + profileImageUrl: string | null; +}; + +export type TeamMember = { + // TODO(BE 연동): GET 응답에 clubMemberId가 포함되어야 함. + clubMemberId: number; + memberInfo: MemberInfo; + teamNumber: number | null; +}; + +export type GetMeetingTeamsResult = { + existingTeamNumbers: number[]; + members: TeamMember[]; + hasNext: boolean; + nextCursor: string | null; +}; + +export type TeamMemberListPutBody = { + teamMemberList: { + teamNumber: number; + clubMemberIds: number[]; + }[]; +}; + +export const MAX_TEAMS = 7; + +export function teamLabel(teamNumber: number) { + // 1 -> A, 2 -> B ... + const code = 64 + teamNumber; + if (code < 65 || code > 90) return `${teamNumber}`; + return String.fromCharCode(code); +} + +export function normalizeTeams(input: number[]) { + // 팀 번호가 이상하게 들어와도 1..N 형태로 정규화 + const uniqueSorted = Array.from(new Set(input)) + .filter((n) => Number.isFinite(n) && n > 0) + .sort((a, b) => a - b); + + if (uniqueSorted.length === 0) return [1]; + // 1..N이 아닐 수도 있으니 갯수만큼 1..len + return uniqueSorted.map((_, idx) => idx + 1); +} From 2f382328197d9203124ff7da207bf1e65f4447b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 20 Feb 2026 01:31:47 +0900 Subject: [PATCH 10/62] =?UTF-8?q?feat=20:=20=EC=A1=B0=20=ED=8E=B8=EC=84=B1?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/bookcase/[meetingId]/layout.tsx | 5 + .../[id]/admin/bookcase/[meetingId]/page.tsx | 261 ++++++++++++++++++ .../base-ui/Bookcase/Admin/.gitkeep | 0 .../Admin/bookdetailgrouping/MemberItem.tsx | 48 ++++ .../Admin/bookdetailgrouping/MemberPool.tsx | 203 ++++++++++++++ .../Admin/bookdetailgrouping/TeamBoard.tsx | 182 ++++++++++++ 6 files changed, 699 insertions(+) create mode 100644 src/app/groups/[id]/admin/bookcase/[meetingId]/layout.tsx create mode 100644 src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx create mode 100644 src/components/base-ui/Bookcase/Admin/.gitkeep create mode 100644 src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberItem.tsx create mode 100644 src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx create mode 100644 src/components/base-ui/Bookcase/Admin/bookdetailgrouping/TeamBoard.tsx diff --git a/src/app/groups/[id]/admin/bookcase/[meetingId]/layout.tsx b/src/app/groups/[id]/admin/bookcase/[meetingId]/layout.tsx new file mode 100644 index 0000000..3463306 --- /dev/null +++ b/src/app/groups/[id]/admin/bookcase/[meetingId]/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react'; + +export default function meetingeditLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx b/src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx new file mode 100644 index 0000000..dd1381a --- /dev/null +++ b/src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { + DndContext, + PointerSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragOverEvent, +} from "@dnd-kit/core"; + + +import { useEffect, useMemo, useState } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; + + +import { fetchMeetingTeamsDummy } from "./dummy"; +import { normalizeTeams, TeamMember, TeamMemberListPutBody } from "@/types/groups/bookcasedetail"; +import MemberPool from "@/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool"; +import TeamBoard from "@/components/base-ui/Bookcase/Admin/bookdetailgrouping/TeamBoard"; +import Image from "next/image"; +export default function AdminMeetingTeamManagePage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const params = useParams(); + + // Next가 params를 Record로 주는 케이스가 있어서 안전빵 + const groupId = Array.isArray(params?.id) ? params?.id[0] : (params?.id as string | undefined); + const meetingId = Array.isArray(params?.meetingId) + ? params?.meetingId[0] + : (params?.meetingId as string | undefined); + + const meetingName = + searchParams.get("meetingName") || searchParams.get("name") || "정기모임 이름"; + + const [isLoading, setIsLoading] = useState(true); + const [teams, setTeams] = useState([1]); + const [members, setMembers] = useState([]); + + // 드래그 하이라이트용 + const [dragOverTeamNumber, setDragOverTeamNumber] = useState(null); + const [isDragOverPool, setIsDragOverPool] = useState(false); + + useEffect(() => { + let alive = true; + + (async () => { + setIsLoading(true); + + // TODO(API 연동): 여기서 GET /groups/{groupId}/meetings/{meetingId}/teams 같은 걸 호출해서 + // existingTeamNumbers + members를 받아오면 됨. + const res = await fetchMeetingTeamsDummy(); + + if (!alive) return; + + const normalized = normalizeTeams(res.result.existingTeamNumbers); + setTeams(normalized); + setMembers(res.result.members); + setIsLoading(false); + })(); + + return () => { + alive = false; + }; + }, []); + + const unassigned = useMemo( + () => members.filter((m) => m.teamNumber == null), + [members] + ); + + const handleAddTeam = () => { + setTeams((prev) => { + if (prev.length >= 7) return prev; + return [...prev, prev.length + 1]; + }); + }; + + const handleRemoveTeam = (teamNumber: number) => { + if (teams.length <= 1) return; + + // C(3) 삭제 -> A(1)B(2)C(3) 로 당기고, 기존 C에 있던 애들은 null로 빠지게. + setTeams((prev) => { + const filtered = prev.filter((t) => t !== teamNumber); + return filtered.map((t) => (t > teamNumber ? t - 1 : t)); + }); + + setMembers((prev) => + prev.map((m) => { + if (m.teamNumber === teamNumber) return { ...m, teamNumber: null }; + if (m.teamNumber != null && m.teamNumber > teamNumber) + return { ...m, teamNumber: m.teamNumber - 1 }; + return m; + }) + ); + }; + + const handleMoveMember = (clubMemberId: number, toTeamNumber: number | null) => { + setMembers((prev) => + prev.map((m) => (m.clubMemberId === clubMemberId ? { ...m, teamNumber: toTeamNumber } : m)) + ); + }; + + const handleSubmit = async () => { + // PUT payload 만들기 + const body: TeamMemberListPutBody = { + teamMemberList: teams.map((teamNumber) => ({ + teamNumber, + clubMemberIds: members + .filter((m) => m.teamNumber === teamNumber) + .map((m) => m.clubMemberId), + })), + }; + + // TODO(API 연동): + // await fetch(`/api/groups/${groupId}/admin/bookcase/${meetingId}`, { method: 'PUT', body: JSON.stringify(body) }) + console.log("PUT payload:", body); + }; + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + // 모바일에서 스크롤/탭과 드래그 충돌 줄이려고 “롱프레스” 약간 줌 + useSensor(TouchSensor, { + activationConstraint: { delay: 150, tolerance: 8 }, + }) +); + +const handleDndOver = ({ over }: DragOverEvent) => { + const id = over?.id?.toString(); + if (!id) { + setDragOverTeamNumber(null); + setIsDragOverPool(false); + return; + } + + if (id === "pool") { + setIsDragOverPool(true); + setDragOverTeamNumber(null); + return; + } + + if (id.startsWith("team-")) { + const teamNumber = Number(id.replace("team-", "")); + setDragOverTeamNumber(Number.isFinite(teamNumber) ? teamNumber : null); + setIsDragOverPool(false); + } +}; + +const handleDndEnd = ({ active, over }: DragEndEvent) => { + try { + if (!over) return; + + const clubMemberId = + (active.data.current?.clubMemberId as number | undefined) ?? + Number(String(active.id).replace("member-", "")); + + if (!clubMemberId) return; + + const overId = over.id.toString(); + + if (overId === "pool") { + handleMoveMember(clubMemberId, null); + } else if (overId.startsWith("team-")) { + const toTeamNumber = Number(overId.replace("team-", "")); + if (Number.isFinite(toTeamNumber)) handleMoveMember(clubMemberId, toTeamNumber); + } + } finally { + // 하이라이트 정리 + setDragOverTeamNumber(null); + setIsDragOverPool(false); + } +}; + + + return ( + +
+ {/* 모바일 전용 뒤로가기 바 */} + + + {/* t 이상에서: 좌우 최소 40px 확보용 외곽 패딩 */} +
+ {/* 실제 컨텐츠 래퍼 */} +
+ {/* 타이틀 */} +

{meetingName}

+ + +
+ {/* 미배정 참여자 */} +
+ +
+ + {/* 팀 영역: 모바일 아래 / t 이상 왼쪽 */} +
+ {isLoading ? ( +
+ 불러오는 중... +
+ ) : ( + + )} +
+
+
+
+
+ +
+ +); +} diff --git a/src/components/base-ui/Bookcase/Admin/.gitkeep b/src/components/base-ui/Bookcase/Admin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberItem.tsx b/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberItem.tsx new file mode 100644 index 0000000..2090323 --- /dev/null +++ b/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberItem.tsx @@ -0,0 +1,48 @@ +"use client"; + +import Image from "next/image"; +import type { TeamMember } from "@/types/groups/bookcasedetail"; + +const DEFAULT_PROFILE = "/profile4.svg"; + +type Props = { + member: TeamMember; + draggable?: boolean; +}; + +export default function MemberItem({ member, draggable = true }: Props) { + const src = member.memberInfo.profileImageUrl ?? DEFAULT_PROFILE; + + return ( +
{ + if (!draggable) return; + e.dataTransfer.setData( + "application/x-checkmo-member", + JSON.stringify({ + clubMemberId: member.clubMemberId, + fromTeamNumber: member.teamNumber, + }) + ); + e.dataTransfer.effectAllowed = "move"; + }} + className={[ + "flex items-center gap-2.5", + "w-full self-stretch", + "px-5 py-4", + "rounded-[8px]", + "bg-White", + draggable ? "cursor-grab active:cursor-grabbing" : "", + ].join(" ")} + > +
+ profile +
+ + + {member.memberInfo.nickname} + +
+ ); +} diff --git a/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx b/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx new file mode 100644 index 0000000..b8ad014 --- /dev/null +++ b/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useDraggable, useDroppable } from "@dnd-kit/core"; +import { CSS } from "@dnd-kit/utilities"; +import { TeamMember } from "@/types/groups/bookcasedetail"; + +const DEFAULT_PROFILE = "/profile.svg"; + +type Props = { + unassigned: TeamMember[]; + + isDragOverPool: boolean; + onDragOverPool: (isOver: boolean) => void; + + // dnd-kit에서는 이동 처리를 page.tsx의 DndContext(onDragEnd)에서 하는 게 정석이라, + // 여기서는 안 씀(그래도 props 시그니처 유지) + onMoveMember: (clubMemberId: number, toTeamNumber: number | null) => void; + + onSubmit: () => void; +}; + +function DraggableUnassignedCard({ + member, + highlighted, +}: { + member: TeamMember; + highlighted: boolean; +}) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + id: `member-${member.clubMemberId}`, + data: { + clubMemberId: member.clubMemberId, + fromTeamNumber: member.teamNumber, + }, + }); + + const style: React.CSSProperties = { + transform: CSS.Translate.toString(transform), + opacity: isDragging ? 0.6 : 1, + }; + + return ( +
+
+ profile +
+ + {member.memberInfo.nickname} +
+ ); +} + +export default function MemberPool({ + unassigned, + isDragOverPool, + onDragOverPool, + onMoveMember: _onMoveMember, // 사용 안 함(린트용) + onSubmit, +}: Props) { + const router = useRouter(); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + + // ✅ pool droppable + const { setNodeRef, isOver } = useDroppable({ id: "pool" }); + + // 기존 하이라이트 상태 관리랑 맞춰주기 + useEffect(() => { + onDragOverPool(isOver); + }, [isOver, onDragOverPool]); + + const highlighted = isDragOverPool || isOver; + + const handleConfirmYes = () => { + onSubmit(); + setIsConfirmOpen(false); + router.back(); + }; + + return ( +
+ {/* 드랍 영역 전체(오른쪽 박스) */} +
+ {/* 헤더 */} +
+ 토론 참여자 +
+ + {/* 목록 */} +
+
+ {unassigned.length === 0 ? ( +
+
+ 남는 인원이 없어요 +
+
+ ) : ( + unassigned.map((m) => ( + + )) + )} +
+
+ + {/* 저장 버튼 */} +
+ +
+
+ + {/* 확인 모달 */} + {isConfirmOpen && ( + <> +
setIsConfirmOpen(false)} + /> +
+
+

수정하시겠습니까?

+ +
+ + +
+
+
+ + )} +
+ ); +} diff --git a/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/TeamBoard.tsx b/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/TeamBoard.tsx new file mode 100644 index 0000000..9968430 --- /dev/null +++ b/src/components/base-ui/Bookcase/Admin/bookdetailgrouping/TeamBoard.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useEffect } from "react"; +import Image from "next/image"; +import { useDroppable, useDraggable } from "@dnd-kit/core"; +import { CSS } from "@dnd-kit/utilities"; + +import { MAX_TEAMS, teamLabel, TeamMember } from "@/types/groups/bookcasedetail"; +import MemberItem from "./MemberItem"; + +type Props = { + teams: number[]; + members: TeamMember[]; + + dragOverTeamNumber: number | null; + onDragOverTeam: (teamNumber: number | null) => void; + + onAddTeam: () => void; + onRemoveTeam: (teamNumber: number) => void; + + // dnd-kit에선 이동 처리를 page.tsx onDragEnd에서 하는 게 정석이라 여기선 안 씀 + onMoveMember: (clubMemberId: number, toTeamNumber: number | null) => void; +}; + +function DraggableMember({ member }: { member: TeamMember }) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + id: `member-${member.clubMemberId}`, + data: { + clubMemberId: member.clubMemberId, + fromTeamNumber: member.teamNumber, + }, + }); + + const style: React.CSSProperties = { + transform: CSS.Translate.toString(transform), + opacity: isDragging ? 0.6 : 1, + }; + + return ( +
+ {/* ✅ UI는 기존 MemberItem 그대로 재사용 */} + +
+ ); +} + +function TeamDropCard({ + teamNumber, + label, + teamsLength, + teamMembers, + dragOverTeamNumber, + onDragOverTeam, + onRemoveTeam, +}: { + teamNumber: number; + label: string; + teamsLength: number; + teamMembers: TeamMember[]; + dragOverTeamNumber: number | null; + onDragOverTeam: (teamNumber: number | null) => void; + onRemoveTeam: (teamNumber: number) => void; +}) { + const { setNodeRef, isOver } = useDroppable({ id: `team-${teamNumber}` }); + + // 기존 하이라이트 상태(dragOverTeamNumber)를 쓰고 있길래 동기화만 해줌 + useEffect(() => { + if (isOver) onDragOverTeam(teamNumber); + else if (dragOverTeamNumber === teamNumber) onDragOverTeam(null); + }, [isOver, teamNumber, dragOverTeamNumber, onDragOverTeam]); + + const highlighted = isOver || dragOverTeamNumber === teamNumber; + + return ( +
+ {/* 위쪽 박스 */} +
+
+ {label}조 +
+ + +
+ + {/* 인물들 */} +
+ {teamMembers.length === 0 ? ( +
+ 여기로 드래그해서 추가 +
+ ) : ( + teamMembers.map((m) => ) + )} +
+
+ ); +} + +export default function TeamBoard({ + teams, + members, + dragOverTeamNumber, + onDragOverTeam, + onAddTeam, + onRemoveTeam, + onMoveMember: _onMoveMember, // 사용 안 함(린트용) +}: Props) { + const canAdd = teams.length < MAX_TEAMS; + + return ( +
+ {/* 상단 */} +
+
+ +
+ + +
+ + {/* 팀 리스트 */} +
+ {teams.map((teamNumber) => { + const label = teamLabel(teamNumber); + const teamMembers = members.filter((m) => m.teamNumber === teamNumber); + + return ( + + ); + })} +
+
+ ); +} From 476f075374976ad29627c0cd61027d120d73735f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Fri, 20 Feb 2026 02:02:11 +0900 Subject: [PATCH 11/62] =?UTF-8?q?fix=20:=20=EC=B1=84=ED=8C=85UI=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/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx | 7 ++++--- src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx | 7 +++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx b/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx index f9257f6..643ee95 100644 --- a/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx +++ b/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx @@ -287,10 +287,10 @@ export default function MeetingPage({ + {/* Button */} + {/* 모바일 버튼 */}
-
); } diff --git a/src/components/base-ui/home/list_subscribe_large.tsx b/src/components/base-ui/home/list_subscribe_large.tsx index 4ff5e36..c41eea4 100644 --- a/src/components/base-ui/home/list_subscribe_large.tsx +++ b/src/components/base-ui/home/list_subscribe_large.tsx @@ -4,8 +4,8 @@ import Image from 'next/image'; type ListSubscribeElementLargeProps = { name: string; - subscribingCount: number; - subscribersCount: number; + subscribingCount?: number; + subscribersCount?: number; profileSrc?: string; onSubscribeClick?: () => void; buttonText?: string; @@ -35,9 +35,11 @@ function ListSubscribeElementLarge({

{name}

-

- 구독중 {subscribingCount} 구독자 {subscribersCount} -

+ {subscribingCount !== undefined && subscribersCount !== undefined && ( +

+ 구독중 {subscribingCount} 구독자 {subscribersCount} +

+ )}
@@ -113,11 +113,11 @@ export default function StoriesPage() {
diff --git a/src/components/base-ui/BookStory/bookstory_card.tsx b/src/components/base-ui/BookStory/bookstory_card.tsx index 15a9e3f..2e8dbb7 100644 --- a/src/components/base-ui/BookStory/bookstory_card.tsx +++ b/src/components/base-ui/BookStory/bookstory_card.tsx @@ -16,19 +16,7 @@ type Props = { subscribeText?: string; }; -function timeAgo(iso: string) { - const diff = Date.now() - new Date(iso).getTime(); - const minutes = Math.floor(diff / 60000); - - if (minutes < 1) return "방금"; - if (minutes < 60) return `${minutes}분 전`; - - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}시간 전`; - - const days = Math.floor(hours / 24); - return `${days}일 전`; -} +import { formatTimeAgo } from "@/utils/time"; export default function BookStoryCard({ authorName, @@ -65,7 +53,7 @@ export default function BookStoryCard({

{authorName}

- {timeAgo(createdAt)} 조회수 {viewCount} + {formatTimeAgo(createdAt)} 조회수 {viewCount}

diff --git a/src/services/storyService.ts b/src/services/storyService.ts index abf848f..d08f215 100644 --- a/src/services/storyService.ts +++ b/src/services/storyService.ts @@ -1,6 +1,6 @@ import { apiClient } from "@/lib/api/client"; import { STORY_ENDPOINTS } from "@/lib/api/endpoints/bookstory"; -import { BookStoryListResponse } from "@/types/story"; +import { BookStoryListResponse, BookStoryDetail } from "@/types/story"; import { ApiResponse } from "@/types/auth"; export const storyService = { @@ -20,4 +20,17 @@ export const storyService = { } return undefined; }, + getStoryById: async (id: number): Promise => { + try { + const response = await apiClient.get>( + `/api/book-stories/${id}` + ); + if (response.isSuccess) { + return response.result; + } + } catch (error) { + console.error(`Failed to fetch story ${id}:`, error); + } + return undefined; + }, }; diff --git a/src/types/story.ts b/src/types/story.ts index 1f183eb..d063fc6 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -27,3 +27,43 @@ export interface BookStoryListResponse { nextCursor: number | null; pageSize: number; } + + +export interface BookInfo { + bookId: string; + title: string; + author: string; + imgUrl: string; +} + +export interface AuthorInfo { + nickname: string; + profileImageUrl: string; + following: boolean; +} + +export interface CommentInfo { + commentId: number; + content: string; + authorInfo: AuthorInfo; + createdAt: string; + writtenByMe: boolean; + deleted: boolean; +} + +export interface BookStoryDetail { + bookStoryId: number; + bookInfo: BookInfo; + authorInfo: AuthorInfo; + bookStoryTitle: string; + description: string; + likes: number; + likedByMe: boolean; + createdAt: string; + writtenByMe: boolean; + viewCount: number; + commentCount: number; + comments: CommentInfo[]; + prevBookStoryId: number; + nextBookStoryId: number; +} From aade0d43932dd754f0f54c24f62b30c82896622c Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Mon, 23 Feb 2026 15:18:47 +0900 Subject: [PATCH 22/62] =?UTF-8?q?fix(api):=20=EC=83=81=EC=84=B8=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20Server=20Component=20=EB=82=B4=20=EC=83=81=EB=8C=80?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20API=20=ED=98=B8=EC=B6=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/services/storyService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/storyService.ts b/src/services/storyService.ts index d08f215..d0b5b55 100644 --- a/src/services/storyService.ts +++ b/src/services/storyService.ts @@ -23,7 +23,7 @@ export const storyService = { getStoryById: async (id: number): Promise => { try { const response = await apiClient.get>( - `/api/book-stories/${id}` + `${STORY_ENDPOINTS.LIST}/${id}` ); if (response.isSuccess) { return response.result; From 8d2adef8351813a77fa4a2a0a0871a70a6d55a26 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Mon, 23 Feb 2026 15:24:23 +0900 Subject: [PATCH 23/62] =?UTF-8?q?fix(ui):=20=EC=83=81=EC=84=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=84=9C=EB=B2=84=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EB=A5=BC=20=EC=9D=B8=EC=A6=9D=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4(Cookie)=20=ED=8F=AC=ED=95=A8=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/stories/[id]/page.tsx | 65 +++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/src/app/(main)/stories/[id]/page.tsx b/src/app/(main)/stories/[id]/page.tsx index dd402af..6e9005c 100644 --- a/src/app/(main)/stories/[id]/page.tsx +++ b/src/app/(main)/stories/[id]/page.tsx @@ -1,23 +1,66 @@ +"use client"; + +import { useEffect, useState } from "react"; import BookstoryDetail from "@/components/base-ui/BookStory/bookstory_detail"; import StoryNavigation from "@/components/base-ui/BookStory/story_navigation"; import CommentSection from "@/components/base-ui/Comment/comment_section"; import Image from "next/image"; -import { notFound } from "next/navigation"; +import { useParams } from "next/navigation"; import { storyService } from "@/services/storyService"; +import { BookStoryDetail } from "@/types/story"; + +export default function StoryDetailPage() { + const params = useParams(); + const id = params?.id as string; + const [story, setStory] = useState(null); + const [isLoading, setIsLoading] = useState(true); -type Props = { - params: Promise<{ id: string }>; -}; + useEffect(() => { + if (!id) return; -export default async function StoryDetailPage({ params }: Props) { - const { id } = await params; + let isMounted = true; + const fetchStory = async () => { + setIsLoading(true); + try { + const data = await storyService.getStoryById(Number(id)); + if (isMounted) { + if (data) { + setStory(data); + } else { + setStory(null); + } + } + } catch (error) { + console.error("Failed to fetch story detail:", error); + if (isMounted) setStory(null); + } finally { + if (isMounted) setIsLoading(false); + } + }; - // API 서버에서 스토리 데이터 가져오기 - const story = await storyService.getStoryById(Number(id)); + fetchStory(); - // 스토리가 없으면 404 + return () => { + isMounted = false; + }; + }, [id]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + // 스토리가 없으면 404 UI if (!story) { - notFound(); + return ( +
+

404

+

해당 책 이야기를 찾을 수 없습니다.

+
+ ); } const prevId = story.prevBookStoryId !== 0 ? story.prevBookStoryId : null; @@ -61,7 +104,7 @@ export default async function StoryDetailPage({ params }: Props) { imageUrl={story.bookInfo.imgUrl || ""} authorName={story.authorInfo.nickname} authorNickname={story.authorInfo.nickname} - authorId={story.authorInfo.nickname} // TODO: 실제 author ID로 변경 (현재 명세에 nickname만 있음) + authorId={story.authorInfo.nickname} bookTitle={story.bookInfo.title} bookAuthor={story.bookInfo.author} bookDetail={story.description} From 4c5370028a36d17340f7ff6329d483d3626ce184 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Mon, 23 Feb 2026 15:27:17 +0900 Subject: [PATCH 24/62] =?UTF-8?q?chore(config):=20next/image=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=98=B8=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8(aladin)=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/next.config.ts b/next.config.ts index 66e1566..c6b5d61 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,14 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ reactCompiler: true, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "image.aladin.co.kr", + }, + ], + }, }; export default nextConfig; From 69c9e0ade21db1a85dc57ecdb40745d0418f7696 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Mon, 23 Feb 2026 15:33:27 +0900 Subject: [PATCH 25/62] =?UTF-8?q?feat(ui):=20=EC=B1=85=20=EC=9D=B4?= =?UTF-8?q?=EC=95=BC=EA=B8=B0=20=EB=A6=AC=EC=8A=A4=ED=8A=B8(=EC=B9=B4?= =?UTF-8?q?=EB=93=9C)=EC=97=90=20=EB=8F=84=EC=84=9C=20=EC=BB=A4=EB=B2=84?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=91=9C=EC=8B=9C=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/app/(main)/page.tsx | 3 +++ src/app/(main)/stories/page.tsx | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index c3044f2..6cc49bc 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -98,6 +98,7 @@ export default function HomePage() { content={story.description} likeCount={story.likes} commentCount={story.commentCount} + coverImgSrc={story.bookInfo.imgUrl} subscribeText="구독" /> ))} @@ -139,6 +140,7 @@ export default function HomePage() { content={story.description} likeCount={story.likes} commentCount={story.commentCount} + coverImgSrc={story.bookInfo.imgUrl} subscribeText="구독" /> ))} @@ -182,6 +184,7 @@ export default function HomePage() { content={story.description} likeCount={story.likes} commentCount={story.commentCount} + coverImgSrc={story.bookInfo.imgUrl} subscribeText="구독" /> ))} diff --git a/src/app/(main)/stories/page.tsx b/src/app/(main)/stories/page.tsx index 5bd1cb7..e790cdd 100644 --- a/src/app/(main)/stories/page.tsx +++ b/src/app/(main)/stories/page.tsx @@ -91,6 +91,7 @@ export default function StoriesPage() { content={story.description} likeCount={story.likes} commentCount={story.commentCount} + coverImgSrc={story.bookInfo.imgUrl} subscribeText={story.authorInfo.following ? "구독중" : "구독"} />
@@ -118,6 +119,7 @@ export default function StoriesPage() { content={story.description} likeCount={story.likes} commentCount={story.commentCount} + coverImgSrc={story.bookInfo.imgUrl} subscribeText={story.authorInfo.following ? "구독중" : "구독"} /> From 2451161f2e1bca2eb17a124f45f8642a94b27cc4 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Mon, 23 Feb 2026 15:40:23 +0900 Subject: [PATCH 26/62] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B7=B0=EC=96=B4=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20(=EC=97=90=EB=9F=AC=20=EC=A0=84=ED=8C=8C,=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=82=A4,=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EB=B6=80=20=EB=A0=8C=EB=8D=94=EB=A7=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/stories/page.tsx | 13 ++++--- .../base-ui/home/list_subscribe_large.tsx | 4 +- src/services/memberService.ts | 17 +++------ src/services/storyService.ts | 38 ++++++------------- 4 files changed, 26 insertions(+), 46 deletions(-) diff --git a/src/app/(main)/stories/page.tsx b/src/app/(main)/stories/page.tsx index e790cdd..0bed325 100644 --- a/src/app/(main)/stories/page.tsx +++ b/src/app/(main)/stories/page.tsx @@ -97,12 +97,13 @@ export default function StoriesPage() { ))} - {/* 두 번째 줄: 비로그인 시 추천 영역 표시 (디자인에 따라 다를 수 있음) */} - {/* 유저 요청: "UI를 보면 중간에 추천 친구 해주는 것도 있어." */} - + {/* 두 번째 줄: 추천 멤버가 있을 경우에만 추천 영역 표시 */} + {recommendedMembers.length > 0 && ( + + )} {/* 나머지 카드들 */} {stories.slice(4).map((story) => ( diff --git a/src/components/base-ui/home/list_subscribe_large.tsx b/src/components/base-ui/home/list_subscribe_large.tsx index c41eea4..aecd648 100644 --- a/src/components/base-ui/home/list_subscribe_large.tsx +++ b/src/components/base-ui/home/list_subscribe_large.tsx @@ -83,9 +83,9 @@ export default function ListSubscribeLarge({

사용자 추천

- {users.map((u, idx) => ( + {users.map((u) => ( => { - try { - const response = await apiClient.get>( - MEMBER_ENDPOINTS.RECOMMEND - ); - if (response.isSuccess) { - return response.result; - } - } catch (error) { - console.error("Failed to fetch recommended members:", error); - } - return undefined; + getRecommendedMembers: async (): Promise => { + const response = await apiClient.get>( + MEMBER_ENDPOINTS.RECOMMEND + ); + return response.result!; }, }; diff --git a/src/services/storyService.ts b/src/services/storyService.ts index d0b5b55..3e90dcd 100644 --- a/src/services/storyService.ts +++ b/src/services/storyService.ts @@ -4,33 +4,19 @@ import { BookStoryListResponse, BookStoryDetail } from "@/types/story"; import { ApiResponse } from "@/types/auth"; export const storyService = { - getAllStories: async (cursorId?: number): Promise => { - try { - const response = await apiClient.get>( - STORY_ENDPOINTS.LIST, - { - params: { cursorId }, - } - ); - if (response.isSuccess) { - return response.result; + getAllStories: async (cursorId?: number): Promise => { + const response = await apiClient.get>( + STORY_ENDPOINTS.LIST, + { + params: { cursorId }, } - } catch (error) { - console.error("Failed to fetch stories:", error); - } - return undefined; + ); + return response.result!; }, - getStoryById: async (id: number): Promise => { - try { - const response = await apiClient.get>( - `${STORY_ENDPOINTS.LIST}/${id}` - ); - if (response.isSuccess) { - return response.result; - } - } catch (error) { - console.error(`Failed to fetch story ${id}:`, error); - } - return undefined; + getStoryById: async (id: number): Promise => { + const response = await apiClient.get>( + `${STORY_ENDPOINTS.LIST}/${id}` + ); + return response.result!; }, }; From 698f747fac3fd6f7dff0a6fea7ead06640c6c51d Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Mon, 23 Feb 2026 15:50:50 +0900 Subject: [PATCH 27/62] =?UTF-8?q?feat(ui):=20TanStack=20Query=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B1=85=EC=9D=B4=EC=95=BC=EA=B8=B0=20=EB=AC=B4=ED=95=9C?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++ pnpm-lock.yaml | 56 +++++++++++++++++++++ src/app/(main)/stories/[id]/page.tsx | 39 ++------------- src/app/(main)/stories/page.tsx | 70 ++++++++++++++------------- src/app/layout.tsx | 17 ++++--- src/app/providers.tsx | 28 +++++++++++ src/hooks/queries/useMemberQueries.ts | 15 ++++++ src/hooks/queries/useStoryQueries.ts | 35 ++++++++++++++ 8 files changed, 187 insertions(+), 76 deletions(-) create mode 100644 src/app/providers.tsx create mode 100644 src/hooks/queries/useMemberQueries.ts create mode 100644 src/hooks/queries/useStoryQueries.ts diff --git a/package.json b/package.json index 13073fa..799a320 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "lint": "eslint" }, "dependencies": { + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query-devtools": "^5.91.3", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", "js-cookie": "^3.0.5", @@ -16,6 +18,7 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-hot-toast": "^2.6.0", + "react-intersection-observer": "^10.0.3", "zod": "^4.3.6", "zustand": "^5.0.10" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9732f85..c2ad06c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@tanstack/react-query': + specifier: ^5.90.21 + version: 5.90.21(react@19.2.0) + '@tanstack/react-query-devtools': + specifier: ^5.91.3 + version: 5.91.3(@tanstack/react-query@5.90.21(react@19.2.0))(react@19.2.0) '@vercel/analytics': specifier: ^1.6.1 version: 1.6.1(next@16.1.6(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) @@ -29,6 +35,9 @@ importers: react-hot-toast: specifier: ^2.6.0 version: 2.6.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-intersection-observer: + specifier: ^10.0.3 + version: 10.0.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) zod: specifier: ^4.3.6 version: 4.3.6 @@ -651,6 +660,23 @@ packages: '@tailwindcss/postcss@4.1.17': resolution: {integrity: sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==} + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/query-devtools@5.93.0': + resolution: {integrity: sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==} + + '@tanstack/react-query-devtools@5.91.3': + resolution: {integrity: sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA==} + peerDependencies: + '@tanstack/react-query': ^5.90.20 + react: ^18 || ^19 + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1891,6 +1917,15 @@ packages: react: '>=16' react-dom: '>=16' + react-intersection-observer@10.0.3: + resolution: {integrity: sha512-luICLMbs0zxTO/70Zy7K5jOXkABPEVSAF8T3FdZUlctsrIaPLmx8TZe2SSA+CY2HGWfz2INyNTnp82pxNNsShA==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react-dom: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -2682,6 +2717,21 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.17 + '@tanstack/query-core@5.90.20': {} + + '@tanstack/query-devtools@5.93.0': {} + + '@tanstack/react-query-devtools@5.91.3(@tanstack/react-query@5.90.21(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/query-devtools': 5.93.0 + '@tanstack/react-query': 5.90.21(react@19.2.0) + react: 19.2.0 + + '@tanstack/react-query@5.90.21(react@19.2.0)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.2.0 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -4019,6 +4069,12 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + react-intersection-observer@10.0.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + optionalDependencies: + react-dom: 19.2.0(react@19.2.0) + react-is@16.13.1: {} react@19.2.0: {} diff --git a/src/app/(main)/stories/[id]/page.tsx b/src/app/(main)/stories/[id]/page.tsx index 6e9005c..24ffc0b 100644 --- a/src/app/(main)/stories/[id]/page.tsx +++ b/src/app/(main)/stories/[id]/page.tsx @@ -1,49 +1,16 @@ "use client"; -import { useEffect, useState } from "react"; import BookstoryDetail from "@/components/base-ui/BookStory/bookstory_detail"; import StoryNavigation from "@/components/base-ui/BookStory/story_navigation"; import CommentSection from "@/components/base-ui/Comment/comment_section"; import Image from "next/image"; import { useParams } from "next/navigation"; -import { storyService } from "@/services/storyService"; -import { BookStoryDetail } from "@/types/story"; +import { useStoryDetailQuery } from "@/hooks/queries/useStoryQueries"; export default function StoryDetailPage() { const params = useParams(); const id = params?.id as string; - const [story, setStory] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - if (!id) return; - - let isMounted = true; - const fetchStory = async () => { - setIsLoading(true); - try { - const data = await storyService.getStoryById(Number(id)); - if (isMounted) { - if (data) { - setStory(data); - } else { - setStory(null); - } - } - } catch (error) { - console.error("Failed to fetch story detail:", error); - if (isMounted) setStory(null); - } finally { - if (isMounted) setIsLoading(false); - } - }; - - fetchStory(); - - return () => { - isMounted = false; - }; - }, [id]); + const { data: story, isLoading, isError } = useStoryDetailQuery(Number(id)); if (isLoading) { return ( @@ -54,7 +21,7 @@ export default function StoryDetailPage() { } // 스토리가 없으면 404 UI - if (!story) { + if (!story || isError) { return (

404

diff --git a/src/app/(main)/stories/page.tsx b/src/app/(main)/stories/page.tsx index 0bed325..1612dd0 100644 --- a/src/app/(main)/stories/page.tsx +++ b/src/app/(main)/stories/page.tsx @@ -5,49 +5,39 @@ import ListSubscribeLarge from "@/components/base-ui/home/list_subscribe_large"; import { useRouter } from "next/navigation"; import FloatingFab from "@/components/base-ui/Float"; import { useAuthStore } from "@/store/useAuthStore"; -import { storyService } from "@/services/storyService"; -import { memberService } from "@/services/memberService"; -import { BookStory } from "@/types/story"; -import { RecommendedMember } from "@/types/member"; +import { useInfiniteStoriesQuery } from "@/hooks/queries/useStoryQueries"; +import { useRecommendedMembersQuery } from "@/hooks/queries/useMemberQueries"; +import { useInView } from "react-intersection-observer"; export default function StoriesPage() { const router = useRouter(); const { isLoggedIn } = useAuthStore(); - const [stories, setStories] = useState([]); - const [recommendedMembers, setRecommendedMembers] = useState< - RecommendedMember[] - >([]); - const [isLoading, setIsLoading] = useState(true); + const { + data: storiesData, + isLoading: isLoadingStories, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteStoriesQuery(); - useEffect(() => { - const fetchData = async () => { - setIsLoading(true); - try { - const [storiesData, membersData] = await Promise.all([ - storyService.getAllStories(), - isLoggedIn ? memberService.getRecommendedMembers() : Promise.resolve(undefined), - ]); + const { data: membersData, isLoading: isLoadingMembers } = useRecommendedMembersQuery(isLoggedIn); - if (storiesData) { - setStories(storiesData.basicInfoList); - } - if (membersData) { - setRecommendedMembers(membersData.friends); - } - } catch (error) { - console.error("Error fetching stories page data:", error); - } finally { - setIsLoading(false); - } - }; + const { ref, inView } = useInView({ + threshold: 0, + }); - fetchData(); - }, [isLoggedIn]); + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); const handleCardClick = (id: number) => { router.push(`/stories/${id}`); }; + const isLoading = isLoadingStories || (isLoggedIn && isLoadingMembers); + if (isLoading) { return (
@@ -56,6 +46,9 @@ export default function StoriesPage() { ); } + const allStories = storiesData?.pages.flatMap((page) => page.basicInfoList) || []; + const recommendedMembers = membersData?.friends || []; + return (
@@ -77,7 +70,7 @@ export default function StoriesPage() {
{/* 첫 번째 줄 (처음 4개) */} - {stories.slice(0, 4).map((story) => ( + {allStories.slice(0, 4).map((story) => (
handleCardClick(story.bookStoryId)} @@ -106,7 +99,7 @@ export default function StoriesPage() { )} {/* 나머지 카드들 */} - {stories.slice(4).map((story) => ( + {allStories.slice(4).map((story) => (
handleCardClick(story.bookStoryId)} @@ -126,6 +119,17 @@ export default function StoriesPage() {
))}
+ + {/* 무한 스크롤 옵저버 타겟 */} + {hasNextPage && ( +
+ {isFetchingNextPage ? ( +
+ ) : ( +
+ )} +
+ )} {/* 글쓰기 버튼 */} - - - {children} - - - - + + + + {children} + + + + + diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..c2f3181 --- /dev/null +++ b/src/app/providers.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { useState } from "react"; + +export default function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + // Default settings for queries + staleTime: 60 * 1000, // 1 minute + refetchOnWindowFocus: false, + retry: 1, + }, + }, + }) + ); + + return ( + + {children} + + + ); +} diff --git a/src/hooks/queries/useMemberQueries.ts b/src/hooks/queries/useMemberQueries.ts new file mode 100644 index 0000000..15c73a4 --- /dev/null +++ b/src/hooks/queries/useMemberQueries.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { memberService } from "@/services/memberService"; + +export const memberKeys = { + all: ["members"] as const, + recommended: () => [...memberKeys.all, "recommended"] as const, +}; + +export const useRecommendedMembersQuery = (enabled: boolean = true) => { + return useQuery({ + queryKey: memberKeys.recommended(), + queryFn: () => memberService.getRecommendedMembers(), + enabled, + }); +}; diff --git a/src/hooks/queries/useStoryQueries.ts b/src/hooks/queries/useStoryQueries.ts new file mode 100644 index 0000000..0741033 --- /dev/null +++ b/src/hooks/queries/useStoryQueries.ts @@ -0,0 +1,35 @@ +import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; +import { storyService } from "@/services/storyService"; + +export const storyKeys = { + all: ["stories"] as const, + list: () => [...storyKeys.all, "list"] as const, + detail: (id: number) => [...storyKeys.all, "detail", id] as const, +}; + +export const useStoriesQuery = () => { + return useQuery({ + queryKey: storyKeys.list(), + queryFn: () => storyService.getAllStories(), + }); +}; + +export const useStoryDetailQuery = (id: number) => { + return useQuery({ + queryKey: storyKeys.detail(id), + queryFn: () => storyService.getStoryById(id), + enabled: !!id, + }); +}; + +export const useInfiniteStoriesQuery = () => { + return useInfiniteQuery({ + queryKey: storyKeys.list(), + queryFn: ({ pageParam }) => storyService.getAllStories(pageParam), + initialPageParam: undefined as number | undefined, + getNextPageParam: (lastPage) => { + if (!lastPage.hasNext) return undefined; + return lastPage.nextCursor; + }, + }); +}; From c319e56a925998af3237133504eb620694df1c69 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Mon, 23 Feb 2026 15:55:43 +0900 Subject: [PATCH 28/62] =?UTF-8?q?refactor(home):=20TanStack=20Query=20?= =?UTF-8?q?=ED=9B=85=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=ED=99=88=20=ED=99=94=EB=A9=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=8C=A8=EC=B9=AD=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/page.tsx | 45 +++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index 6cc49bc..5d0fc5b 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -11,40 +11,27 @@ import LoginModal from "@/components/base-ui/Login/LoginModal"; import BookStoryCardLarge from "@/components/base-ui/BookStory/bookstory_card_large"; import { useAuthStore } from "@/store/useAuthStore"; -import { memberService } from "@/services/memberService"; -import { storyService } from "@/services/storyService"; -import { BookStory } from "@/types/story"; -import { RecommendedMember } from "@/types/member"; +import { useStoriesQuery } from "@/hooks/queries/useStoryQueries"; +import { useRecommendedMembersQuery } from "@/hooks/queries/useMemberQueries"; export default function HomePage() { const groups: { id: string; name: string }[] = []; const { isLoggedIn, isLoginModalOpen, openLoginModal, closeLoginModal } = useAuthStore(); - const [recommendedUsers, setRecommendedUsers] = useState([]); - const [stories, setStories] = useState([]); - const [isLoadingUsers, setIsLoadingUsers] = useState(false); - const [isLoadingStories, setIsLoadingStories] = useState(false); - - useEffect(() => { - async function fetchData() { - if (isLoggedIn) { - setIsLoadingUsers(true); - const recommendRes = await memberService.getRecommendedMembers(); - if (recommendRes && recommendRes.friends) { - setRecommendedUsers(recommendRes.friends); - } - setIsLoadingUsers(false); - } - - setIsLoadingStories(true); - const storiesRes = await storyService.getAllStories(); - if (storiesRes && storiesRes.basicInfoList) { - setStories(storiesRes.basicInfoList); - } - setIsLoadingStories(false); - } - fetchData(); - }, [isLoggedIn]); + const { data: storiesData, isLoading: isLoadingStories } = useStoriesQuery(); + const { data: membersData, isLoading: isLoadingMembers } = useRecommendedMembersQuery(isLoggedIn); + + const stories = storiesData?.basicInfoList || []; + const recommendedUsers = membersData?.friends || []; + const isLoading = isLoadingStories || (isLoggedIn && isLoadingMembers); + + if (isLoading) { + return ( +
+
+
+ ); + } return (
From 3d2d4fb9070f8528f286fb53de8123b07deb301f Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Mon, 23 Feb 2026 16:02:00 +0900 Subject: [PATCH 29/62] =?UTF-8?q?fix(queries):=20story=20=EB=AC=B4?= =?UTF-8?q?=ED=95=9C=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A4=91=20length=20=EC=B0=B8=EC=A1=B0=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/queries/useStoryQueries.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/queries/useStoryQueries.ts b/src/hooks/queries/useStoryQueries.ts index 0741033..362a45c 100644 --- a/src/hooks/queries/useStoryQueries.ts +++ b/src/hooks/queries/useStoryQueries.ts @@ -25,10 +25,10 @@ export const useStoryDetailQuery = (id: number) => { export const useInfiniteStoriesQuery = () => { return useInfiniteQuery({ queryKey: storyKeys.list(), - queryFn: ({ pageParam }) => storyService.getAllStories(pageParam), - initialPageParam: undefined as number | undefined, + queryFn: ({ pageParam }) => storyService.getAllStories(pageParam ?? undefined), + initialPageParam: null as number | null, getNextPageParam: (lastPage) => { - if (!lastPage.hasNext) return undefined; + if (!lastPage || !lastPage.hasNext) return undefined; return lastPage.nextCursor; }, }); From fd9834c8a983c39588a8e231722b7ef29ce1eda5 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Mon, 23 Feb 2026 16:04:06 +0900 Subject: [PATCH 30/62] =?UTF-8?q?fix(queries):=20useQuery=EC=99=80=20useIn?= =?UTF-8?q?finiteQuery=20=EC=BA=90=EC=8B=9C=20=ED=82=A4=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/queries/useStoryQueries.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/queries/useStoryQueries.ts b/src/hooks/queries/useStoryQueries.ts index 362a45c..4d9af93 100644 --- a/src/hooks/queries/useStoryQueries.ts +++ b/src/hooks/queries/useStoryQueries.ts @@ -4,6 +4,7 @@ import { storyService } from "@/services/storyService"; export const storyKeys = { all: ["stories"] as const, list: () => [...storyKeys.all, "list"] as const, + infiniteList: () => [...storyKeys.all, "infiniteList"] as const, detail: (id: number) => [...storyKeys.all, "detail", id] as const, }; @@ -24,7 +25,7 @@ export const useStoryDetailQuery = (id: number) => { export const useInfiniteStoriesQuery = () => { return useInfiniteQuery({ - queryKey: storyKeys.list(), + queryKey: storyKeys.infiniteList(), queryFn: ({ pageParam }) => storyService.getAllStories(pageParam ?? undefined), initialPageParam: null as number | null, getNextPageParam: (lastPage) => { From 127755db378909e4ffdaf170166304ed6d11f576 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 09:24:08 +0900 Subject: [PATCH 31/62] =?UTF-8?q?feat(search):=20=EC=B1=85=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EA=B2=80=EC=83=89=20=EB=B0=8F=20=EB=AC=B4?= =?UTF-8?q?=ED=95=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/search/page.tsx | 74 +++++----- src/components/layout/SearchModal.tsx | 192 ++++++++++++++++---------- src/hooks/queries/useBookQueries.ts | 29 ++++ src/hooks/useDebounce.ts | 17 +++ src/lib/api/endpoints/book.ts | 5 + src/lib/api/endpoints/index.ts | 1 + src/services/bookService.ts | 19 +++ src/types/book.ts | 14 ++ 8 files changed, 246 insertions(+), 105 deletions(-) create mode 100644 src/hooks/queries/useBookQueries.ts create mode 100644 src/hooks/useDebounce.ts create mode 100644 src/lib/api/endpoints/book.ts create mode 100644 src/services/bookService.ts create mode 100644 src/types/book.ts diff --git a/src/app/(main)/search/page.tsx b/src/app/(main)/search/page.tsx index d899d30..fd29fc3 100644 --- a/src/app/(main)/search/page.tsx +++ b/src/app/(main)/search/page.tsx @@ -1,9 +1,11 @@ "use client"; -import { useState, useEffect, Suspense } from "react"; +import { useState, useEffect, Suspense, useMemo } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import Image from "next/image"; import SearchBookResult from "@/components/base-ui/Search/search_bookresult"; +import { useInfiniteBookSearchQuery } from "@/hooks/queries/useBookQueries"; +import { useInView } from "react-intersection-observer"; function SearchContent() { const searchParams = useSearchParams(); @@ -28,30 +30,31 @@ function SearchContent() { } }; - // 더미 검색 결과 데이터 - const searchResults = [ - { - id: 1, - imgUrl: "/booksample.svg", - title: "어린 왕자", - author: "김개미, 연수", - detail: "최대 500(넘어가면...으로)", - }, - { - id: 2, - imgUrl: "/booksample.svg", - title: "어린 왕자", - author: "김개미, 연수", - detail: "최대 500(넘어가면...으로)", - }, - { - id: 3, - imgUrl: "/booksample.svg", - title: "어린 왕자", - author: "김개미, 연수", - detail: "최대 500(넘어가면...으로)", - }, - ]; + const { + data: searchData, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useInfiniteBookSearchQuery(query); + + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const searchResults = useMemo(() => { + return searchData?.pages.flatMap((page) => page.books) || []; + }, [searchData]); + + const totalResults = useMemo(() => { + // Note: If the API doesn't return total count, we can only show what's loaded + // or just say "many" or keep it as is. Assuming we show loaded count for now. + return searchResults.length; + }, [searchResults]); return ( <> @@ -100,29 +103,36 @@ function SearchContent() {

- 총 {searchResults.length}개의 검색결과가 있습니다. + 총 {totalResults}개의 검색결과가 있습니다.

{searchResults.map((result) => ( - setLikedResults((prev) => ({ ...prev, [result.id]: liked })) + setLikedResults((prev) => ({ ...prev, [Number(result.bookId)]: liked })) } onPencilClick={() => { - router.push(`/books/${result.id}`); //필요한지 확인 필요 + router.push(`/books/${result.bookId}`); //필요한지 확인 필요 }} onCardClick={() => { - router.push(`/books/${result.id}`); + router.push(`/books/${result.bookId}`); }} /> ))}
+ + {/* 무한 스크롤 로딩 트리거 */} +
+ {isFetchingNextPage && ( +
+ )} +
diff --git a/src/components/layout/SearchModal.tsx b/src/components/layout/SearchModal.tsx index 92f5c0d..f23f30e 100644 --- a/src/components/layout/SearchModal.tsx +++ b/src/components/layout/SearchModal.tsx @@ -5,6 +5,8 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import Search_BookCoverCard from "@/components/base-ui/Search/search_recommendbook"; +import { useBookSearchQuery } from "@/hooks/queries/useBookQueries"; +import { useDebounce } from "@/hooks/useDebounce"; type SearchModalProps = { isOpen: boolean; @@ -16,6 +18,9 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { const [topOffset, setTopOffset] = useState(0); const [likedBooks, setLikedBooks] = useState>({}); const [searchValue, setSearchValue] = useState(""); + const debouncedSearchValue = useDebounce(searchValue, 300); + + const { data: searchResults, isLoading: isSearching } = useBookSearchQuery(debouncedSearchValue); // 더미 추천 책 데이터 const recommendedBooks = [ @@ -25,6 +30,8 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { { id: 4, imgUrl: "/booksample.svg", title: "책 제목", author: "작가작가작가" }, ]; + const booksToDisplay = searchResults?.books.slice(0, 4) || []; + const handleSearch = () => { if (searchValue.trim()) { router.push(`/search?q=${encodeURIComponent(searchValue.trim())}`); @@ -80,93 +87,132 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { onClick={onClose} style={{ top: `${topOffset}px` }} /> - + {/* 모달 */}
-
-
-
- 검색 -
-
- setSearchValue(e.target.value)} - onKeyDown={handleKeyDown} - className="w-full h-full bg-transparent text-white subhead_4 t:subhead_1 d:headline_3 placeholder:subbrown_3 focus:outline-none pr-10" - autoFocus - /> - {searchValue && ( - - )} +
+
+
+ 검색 +
+
+ setSearchValue(e.target.value)} + onKeyDown={handleKeyDown} + className="w-full h-full bg-transparent text-white subhead_4 t:subhead_1 d:headline_3 placeholder:subbrown_3 focus:outline-none pr-10" + autoFocus + /> + {searchValue && ( + + )} +
-
-
-
-
+
+
+
- {/* 오늘의 추천 책 */} -
-
-

오늘의 추천 책

-
- {recommendedBooks.slice(0, 4).map((book, index) => ( -
- - setLikedBooks((prev) => ({ ...prev, [book.id]: liked })) - } + {/* 실시간 검색 결과 리스트 */} + {searchValue.trim() !== "" && ( +
+
+ {isSearching ? ( +
검색 중...
+ ) : booksToDisplay.length > 0 ? ( +
+ {booksToDisplay.map((book) => ( +
{ + router.push(`/search?q=${encodeURIComponent(book.title)}`); + onClose(); + }} + className="flex items-center gap-4 p-3 hover:bg-white/10 cursor-pointer rounded-lg transition-colors" + > +
+ {book.title} +
+
+ {book.title} + {book.author} +
+
+ ))} +
+ ) : ( + debouncedSearchValue &&
검색 결과가 없습니다.
+ )} +
+
+ )} + + {/* 오늘의 추천 책 */} +
+
+

오늘의 추천 책

+
+ {recommendedBooks.slice(0, 4).map((book, index) => ( +
+ + setLikedBooks((prev) => ({ ...prev, [book.id]: liked })) + } + /> +
+ ))} +
+
+
+ + 알라딘 랭킹 더 보러가기 +
+ 알라딘
- ))} +
-
- - 알라딘 랭킹 더 보러가기 -
- 알라딘 -
- -
-
); } diff --git a/src/hooks/queries/useBookQueries.ts b/src/hooks/queries/useBookQueries.ts new file mode 100644 index 0000000..c1cb89a --- /dev/null +++ b/src/hooks/queries/useBookQueries.ts @@ -0,0 +1,29 @@ +import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; +import { bookService } from "@/services/bookService"; + +export const bookKeys = { + all: ["books"] as const, + search: (title: string) => [...bookKeys.all, "search", title] as const, + infiniteSearch: (title: string) => [...bookKeys.all, "infiniteSearch", title] as const, +}; + +export const useBookSearchQuery = (title: string) => { + return useQuery({ + queryKey: bookKeys.search(title), + queryFn: () => bookService.searchBooks(title), + enabled: title.trim().length > 0, + }); +}; + +export const useInfiniteBookSearchQuery = (title: string) => { + return useInfiniteQuery({ + queryKey: bookKeys.infiniteSearch(title), + queryFn: ({ pageParam }) => bookService.searchBooks(title, pageParam ?? undefined), + initialPageParam: null as number | null, + enabled: title.trim().length > 0, + getNextPageParam: (lastPage) => { + if (!lastPage || !lastPage.hasNext) return undefined; + return lastPage.nextCursor; + }, + }); +}; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..00c9101 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/lib/api/endpoints/book.ts b/src/lib/api/endpoints/book.ts new file mode 100644 index 0000000..05d92fe --- /dev/null +++ b/src/lib/api/endpoints/book.ts @@ -0,0 +1,5 @@ +import { API_BASE_URL } from "./base"; + +export const BOOK_ENDPOINTS = { + SEARCH: `${API_BASE_URL}/books/search`, +}; diff --git a/src/lib/api/endpoints/index.ts b/src/lib/api/endpoints/index.ts index f482f6a..463a8a2 100644 --- a/src/lib/api/endpoints/index.ts +++ b/src/lib/api/endpoints/index.ts @@ -2,3 +2,4 @@ export * from "./base"; export * from "./auth"; export * from "./bookstory"; export * from "./member"; +export * from "./book"; diff --git a/src/services/bookService.ts b/src/services/bookService.ts new file mode 100644 index 0000000..fe3ed5b --- /dev/null +++ b/src/services/bookService.ts @@ -0,0 +1,19 @@ +import { apiClient } from "@/lib/api/client"; +import { BOOK_ENDPOINTS } from "@/lib/api/endpoints/book"; +import { ApiResponse } from "@/types/auth"; +import { BookSearchResponse } from "@/types/book"; + +export const bookService = { + searchBooks: async (title: string, cursorId?: number): Promise => { + const response = await apiClient.get>( + BOOK_ENDPOINTS.SEARCH, + { + params: { + title, + cursorId + }, + } + ); + return response.result!; + }, +}; diff --git a/src/types/book.ts b/src/types/book.ts new file mode 100644 index 0000000..8886401 --- /dev/null +++ b/src/types/book.ts @@ -0,0 +1,14 @@ +export interface Book { + bookId: string; + title: string; + author: string; + imgUrl: string; + description: string; +} + +export interface BookSearchResponse { + books: Book[]; + hasNext: boolean; + nextCursor: number | null; + pageSize: number; +} From 7efbe7418e6922f8f7e746b8fd0113ab4b43de97 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 09:28:57 +0900 Subject: [PATCH 32/62] =?UTF-8?q?fix(search):=20API=20=EB=AA=85=EC=84=B8(k?= =?UTF-8?q?eyword/page/detailInfoList)=EC=97=90=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/search/page.tsx | 14 +++++++------- src/components/layout/SearchModal.tsx | 10 +++++----- src/hooks/queries/useBookQueries.ts | 20 ++++++++++---------- src/services/bookService.ts | 6 +++--- src/types/book.ts | 9 +++++---- 5 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/app/(main)/search/page.tsx b/src/app/(main)/search/page.tsx index fd29fc3..f0be4d1 100644 --- a/src/app/(main)/search/page.tsx +++ b/src/app/(main)/search/page.tsx @@ -12,7 +12,7 @@ function SearchContent() { const router = useRouter(); const query = searchParams.get("q") || ""; const [searchValue, setSearchValue] = useState(query); - const [likedResults, setLikedResults] = useState>({}); + const [likedResults, setLikedResults] = useState>({}); useEffect(() => { setSearchValue(query); @@ -47,7 +47,7 @@ function SearchContent() { }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); const searchResults = useMemo(() => { - return searchData?.pages.flatMap((page) => page.books) || []; + return searchData?.pages.flatMap((page) => page.detailInfoList) || []; }, [searchData]); const totalResults = useMemo(() => { @@ -108,20 +108,20 @@ function SearchContent() {
{searchResults.map((result) => ( - setLikedResults((prev) => ({ ...prev, [Number(result.bookId)]: liked })) + setLikedResults((prev) => ({ ...prev, [result.isbn]: liked })) } onPencilClick={() => { - router.push(`/books/${result.bookId}`); //필요한지 확인 필요 + router.push(`/books/${result.isbn}`); //필요한지 확인 필요 }} onCardClick={() => { - router.push(`/books/${result.bookId}`); + router.push(`/books/${result.isbn}`); }} /> ))} diff --git a/src/components/layout/SearchModal.tsx b/src/components/layout/SearchModal.tsx index f23f30e..0dfd508 100644 --- a/src/components/layout/SearchModal.tsx +++ b/src/components/layout/SearchModal.tsx @@ -16,7 +16,7 @@ type SearchModalProps = { export default function SearchModal({ isOpen, onClose }: SearchModalProps) { const router = useRouter(); const [topOffset, setTopOffset] = useState(0); - const [likedBooks, setLikedBooks] = useState>({}); + const [likedBooks, setLikedBooks] = useState>({}); const [searchValue, setSearchValue] = useState(""); const debouncedSearchValue = useDebounce(searchValue, 300); @@ -30,7 +30,7 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { { id: 4, imgUrl: "/booksample.svg", title: "책 제목", author: "작가작가작가" }, ]; - const booksToDisplay = searchResults?.books.slice(0, 4) || []; + const booksToDisplay = searchResults?.detailInfoList.slice(0, 4) || []; const handleSearch = () => { if (searchValue.trim()) { @@ -146,7 +146,7 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
{booksToDisplay.map((book) => (
{ router.push(`/search?q=${encodeURIComponent(book.title)}`); onClose(); @@ -186,9 +186,9 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { imgUrl={book.imgUrl} title={book.title} author={book.author} - liked={likedBooks[book.id] || false} + liked={likedBooks[book.id.toString()] || false} onLikeChange={(liked) => - setLikedBooks((prev) => ({ ...prev, [book.id]: liked })) + setLikedBooks((prev) => ({ ...prev, [book.id.toString()]: liked })) } />
diff --git a/src/hooks/queries/useBookQueries.ts b/src/hooks/queries/useBookQueries.ts index c1cb89a..3542669 100644 --- a/src/hooks/queries/useBookQueries.ts +++ b/src/hooks/queries/useBookQueries.ts @@ -7,23 +7,23 @@ export const bookKeys = { infiniteSearch: (title: string) => [...bookKeys.all, "infiniteSearch", title] as const, }; -export const useBookSearchQuery = (title: string) => { +export const useBookSearchQuery = (keyword: string) => { return useQuery({ - queryKey: bookKeys.search(title), - queryFn: () => bookService.searchBooks(title), - enabled: title.trim().length > 0, + queryKey: bookKeys.search(keyword), + queryFn: () => bookService.searchBooks(keyword), + enabled: keyword.trim().length > 0, }); }; -export const useInfiniteBookSearchQuery = (title: string) => { +export const useInfiniteBookSearchQuery = (keyword: string) => { return useInfiniteQuery({ - queryKey: bookKeys.infiniteSearch(title), - queryFn: ({ pageParam }) => bookService.searchBooks(title, pageParam ?? undefined), - initialPageParam: null as number | null, - enabled: title.trim().length > 0, + queryKey: bookKeys.infiniteSearch(keyword), + queryFn: ({ pageParam }) => bookService.searchBooks(keyword, pageParam), + initialPageParam: 1, + enabled: keyword.trim().length > 0, getNextPageParam: (lastPage) => { if (!lastPage || !lastPage.hasNext) return undefined; - return lastPage.nextCursor; + return lastPage.currentPage + 1; }, }); }; diff --git a/src/services/bookService.ts b/src/services/bookService.ts index fe3ed5b..ff676bb 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -4,13 +4,13 @@ import { ApiResponse } from "@/types/auth"; import { BookSearchResponse } from "@/types/book"; export const bookService = { - searchBooks: async (title: string, cursorId?: number): Promise => { + searchBooks: async (keyword: string, page: number = 1): Promise => { const response = await apiClient.get>( BOOK_ENDPOINTS.SEARCH, { params: { - title, - cursorId + keyword, + page }, } ); diff --git a/src/types/book.ts b/src/types/book.ts index 8886401..11b992f 100644 --- a/src/types/book.ts +++ b/src/types/book.ts @@ -1,14 +1,15 @@ export interface Book { - bookId: string; + isbn: string; title: string; author: string; imgUrl: string; + publisher: string; description: string; + link: string; } export interface BookSearchResponse { - books: Book[]; + detailInfoList: Book[]; hasNext: boolean; - nextCursor: number | null; - pageSize: number; + currentPage: number; } From 104bf2dc889c6fa7a48ca2a8ffa3aa44fa830af9 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 09:38:59 +0900 Subject: [PATCH 33/62] =?UTF-8?q?feat(search):=20=EC=98=A4=EB=8A=98?= =?UTF-8?q?=EC=9D=98=20=EC=B6=94=EC=B2=9C=20=EC=B1=85=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EA=B5=AC=ED=98=84=20(/api/books/recommend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/SearchModal.tsx | 50 +++++++++++++++------------ src/hooks/queries/useBookQueries.ts | 9 +++++ src/hooks/useDebounce.ts | 2 ++ src/lib/api/endpoints/book.ts | 1 + src/services/bookService.ts | 6 ++++ 5 files changed, 45 insertions(+), 23 deletions(-) diff --git a/src/components/layout/SearchModal.tsx b/src/components/layout/SearchModal.tsx index 0dfd508..d595294 100644 --- a/src/components/layout/SearchModal.tsx +++ b/src/components/layout/SearchModal.tsx @@ -1,11 +1,11 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import Search_BookCoverCard from "@/components/base-ui/Search/search_recommendbook"; -import { useBookSearchQuery } from "@/hooks/queries/useBookQueries"; +import { useBookSearchQuery, useRecommendedBooksQuery } from "@/hooks/queries/useBookQueries"; import { useDebounce } from "@/hooks/useDebounce"; type SearchModalProps = { @@ -22,13 +22,11 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { const { data: searchResults, isLoading: isSearching } = useBookSearchQuery(debouncedSearchValue); - // 더미 추천 책 데이터 - const recommendedBooks = [ - { id: 1, imgUrl: "/booksample.svg", title: "책 제목", author: "작가작가작가" }, - { id: 2, imgUrl: "/booksample.svg", title: "책 제목", author: "작가작가작가" }, - { id: 3, imgUrl: "/booksample.svg", title: "책 제목", author: "작가작가작가" }, - { id: 4, imgUrl: "/booksample.svg", title: "책 제목", author: "작가작가작가" }, - ]; + const { data: recommendedData, isLoading: isLoadingRecommended } = useRecommendedBooksQuery(); + + const recommendedBooks = useMemo(() => { + return (recommendedData?.detailInfoList || []).slice(0, 4); + }, [recommendedData]); const booksToDisplay = searchResults?.detailInfoList.slice(0, 4) || []; @@ -179,20 +177,26 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {

오늘의 추천 책

-
- {recommendedBooks.slice(0, 4).map((book, index) => ( -
- - setLikedBooks((prev) => ({ ...prev, [book.id.toString()]: liked })) - } - /> -
- ))} +
+ {isLoadingRecommended ? ( +
추천 도서를 불러오는 중...
+ ) : recommendedBooks.length > 0 ? ( + recommendedBooks.map((book, index) => ( +
+ + setLikedBooks((prev) => ({ ...prev, [book.isbn]: liked })) + } + /> +
+ )) + ) : ( +
추천 도서가 없습니다.
+ )}
diff --git a/src/hooks/queries/useBookQueries.ts b/src/hooks/queries/useBookQueries.ts index 3542669..af71d3b 100644 --- a/src/hooks/queries/useBookQueries.ts +++ b/src/hooks/queries/useBookQueries.ts @@ -5,6 +5,7 @@ export const bookKeys = { all: ["books"] as const, search: (title: string) => [...bookKeys.all, "search", title] as const, infiniteSearch: (title: string) => [...bookKeys.all, "infiniteSearch", title] as const, + recommend: () => [...bookKeys.all, "recommend"] as const, }; export const useBookSearchQuery = (keyword: string) => { @@ -27,3 +28,11 @@ export const useInfiniteBookSearchQuery = (keyword: string) => { }, }); }; + +export const useRecommendedBooksQuery = () => { + return useQuery({ + queryKey: bookKeys.recommend(), + queryFn: () => bookService.getRecommendedBooks(), + staleTime: 1000 * 60 * 60, // 1 hour (recommended books don't change often) + }); +}; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts index 00c9101..596df10 100644 --- a/src/hooks/useDebounce.ts +++ b/src/hooks/useDebounce.ts @@ -1,3 +1,5 @@ +"use client"; + import { useState, useEffect } from "react"; export function useDebounce(value: T, delay: number): T { diff --git a/src/lib/api/endpoints/book.ts b/src/lib/api/endpoints/book.ts index 05d92fe..9cdaf75 100644 --- a/src/lib/api/endpoints/book.ts +++ b/src/lib/api/endpoints/book.ts @@ -2,4 +2,5 @@ import { API_BASE_URL } from "./base"; export const BOOK_ENDPOINTS = { SEARCH: `${API_BASE_URL}/books/search`, + RECOMMEND: `${API_BASE_URL}/books/recommend`, }; diff --git a/src/services/bookService.ts b/src/services/bookService.ts index ff676bb..4d78aba 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -16,4 +16,10 @@ export const bookService = { ); return response.result!; }, + getRecommendedBooks: async (): Promise => { + const response = await apiClient.get>( + BOOK_ENDPOINTS.RECOMMEND + ); + return response.result!; + }, }; From 75c39cca7d13dd1164383f3eab91ef8f3871bb4d Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 09:51:05 +0900 Subject: [PATCH 34/62] =?UTF-8?q?chore:=20=EC=B1=85=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=20=EC=8B=9C=20=EB=AA=A8=EB=8B=AC=20=EB=86=92=EC=9D=B4=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/SearchModal.tsx | 88 ++++++++++++++------------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/src/components/layout/SearchModal.tsx b/src/components/layout/SearchModal.tsx index d595294..756e130 100644 --- a/src/components/layout/SearchModal.tsx +++ b/src/components/layout/SearchModal.tsx @@ -28,7 +28,7 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { return (recommendedData?.detailInfoList || []).slice(0, 4); }, [recommendedData]); - const booksToDisplay = searchResults?.detailInfoList.slice(0, 4) || []; + const booksToDisplay = searchResults?.detailInfoList.slice(0, 10) || []; const handleSearch = () => { if (searchValue.trim()) { @@ -141,7 +141,7 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { {isSearching ? (
검색 중...
) : booksToDisplay.length > 0 ? ( -
+
{booksToDisplay.map((book) => (
)} - {/* 오늘의 추천 책 */} -
-
-

오늘의 추천 책

-
- {isLoadingRecommended ? ( -
추천 도서를 불러오는 중...
- ) : recommendedBooks.length > 0 ? ( - recommendedBooks.map((book, index) => ( -
- - setLikedBooks((prev) => ({ ...prev, [book.isbn]: liked })) - } + {/* 오늘의 추천 책 - 검색어가 없을 때만 표시 */} + {!searchValue.trim() && ( + <> +
+
+

오늘의 추천 책

+
+ {isLoadingRecommended ? ( +
추천 도서를 불러오는 중...
+ ) : recommendedBooks.length > 0 ? ( + recommendedBooks.map((book, index) => ( +
+ + setLikedBooks((prev) => ({ ...prev, [book.isbn]: liked })) + } + /> +
+ )) + ) : ( +
추천 도서가 없습니다.
+ )} +
+
+
+ + 알라딘 랭킹 더 보러가기 +
+ 알라딘
- )) - ) : ( -
추천 도서가 없습니다.
- )} -
-
-
- - 알라딘 랭킹 더 보러가기 -
- 알라딘 +
- -
-
+
+ + )}
); From 6d8654692ab67d9a271e5d5ba0979bd2200012ae Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 10:17:07 +0900 Subject: [PATCH 35/62] feat: implement book detail page and fix navigation 404 --- src/app/(main)/books/[id]/page.tsx | 145 ++++++++++++++------------ src/components/layout/SearchModal.tsx | 6 +- src/hooks/queries/useBookQueries.ts | 9 ++ src/lib/api/endpoints/book.ts | 1 + src/services/bookService.ts | 8 +- 5 files changed, 100 insertions(+), 69 deletions(-) diff --git a/src/app/(main)/books/[id]/page.tsx b/src/app/(main)/books/[id]/page.tsx index f2b6472..71de064 100644 --- a/src/app/(main)/books/[id]/page.tsx +++ b/src/app/(main)/books/[id]/page.tsx @@ -1,84 +1,95 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useParams, useRouter } from "next/navigation"; import SearchBookResult from "@/components/base-ui/Search/search_bookresult"; import { DUMMY_STORIES } from "@/data/dummyStories"; import BookStoryCardLarge from "@/components/base-ui/BookStory/bookstory_card_large"; +import { useBookDetailQuery } from "@/hooks/queries/useBookQueries"; export default function BookDetailPage() { - const params = useParams(); - const router = useRouter(); - const bookId = Number(params.id); - const [liked, setLiked] = useState(false); + const params = useParams(); + const router = useRouter(); + const isbn = params.id as string; + const [liked, setLiked] = useState(false); - // 더미 데이터 - const bookData = { - id: 1, - imgUrl: "/booksample.svg", - title: "어린 왕자", - author: "김개미, 연수", - detail: "최대 500(넘어가면...으로)", - }; + const { data: bookData, isLoading, isError } = useBookDetailQuery(isbn); - // 관련된 책 이야기들 (더미 데이터에서 필터링) - const relatedStories = DUMMY_STORIES.filter( - (story) => story.bookTitle === bookData.title, - ); + // 관련된 책 이야기들 (더미 데이터에서 필터링) + const relatedStories = useMemo(() => { + if (!bookData) return []; + return DUMMY_STORIES.filter((story) => story.bookTitle === bookData.title); + }, [bookData]); - return ( -
-
-

- 도서 선택 {bookData.title} 중 -

+ if (isLoading) { + return ( +
+

도서 정보를 불러오는 중...

+
+ ); + } - {/* 선택한 책 카드 */} -
- { - router.push(`/stories/new?bookId=${bookId}`); - }} - /> -
+ if (isError || !bookData) { + return ( +
+

도서 정보를 찾을 수 없습니다.

+
+ ); + } - {/* 책이야기 */} -
-

- 책이야기{" "} - {relatedStories.length} -

-
+ return ( +
+
+

+ 도서 선택 {bookData.title} 중 +

+ + {/* 선택한 책 카드 */} +
+ { + router.push(`/stories/new?isbn=${isbn}`); + }} + /> +
+ + {/* 책이야기 */} +
+

+ 책이야기{" "} + {relatedStories.length} +

+
- {/* 책 이야기 카드 */} -
- {relatedStories.map((story) => ( -
router.push(`/stories/${story.id}`)} - className="cursor-pointer" - > - + {/* 책 이야기 카드 */} +
+ {relatedStories.map((story) => ( +
router.push(`/stories/${story.id}`)} + className="cursor-pointer" + > + +
+ ))} +
- ))}
-
-
- ); + ); } diff --git a/src/components/layout/SearchModal.tsx b/src/components/layout/SearchModal.tsx index 756e130..e2c31a5 100644 --- a/src/components/layout/SearchModal.tsx +++ b/src/components/layout/SearchModal.tsx @@ -146,7 +146,7 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
{ - router.push(`/search?q=${encodeURIComponent(book.title)}`); + router.push(`/books/${book.isbn}`); onClose(); }} className="flex items-center gap-4 p-3 hover:bg-white/10 cursor-pointer rounded-lg transition-colors" @@ -193,6 +193,10 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { onLikeChange={(liked) => setLikedBooks((prev) => ({ ...prev, [book.isbn]: liked })) } + onCardClick={() => { + router.push(`/books/${book.isbn}`); + onClose(); + }} />
)) diff --git a/src/hooks/queries/useBookQueries.ts b/src/hooks/queries/useBookQueries.ts index af71d3b..5acebdd 100644 --- a/src/hooks/queries/useBookQueries.ts +++ b/src/hooks/queries/useBookQueries.ts @@ -6,6 +6,7 @@ export const bookKeys = { search: (title: string) => [...bookKeys.all, "search", title] as const, infiniteSearch: (title: string) => [...bookKeys.all, "infiniteSearch", title] as const, recommend: () => [...bookKeys.all, "recommend"] as const, + detail: (isbn: string) => [...bookKeys.all, "detail", isbn] as const, }; export const useBookSearchQuery = (keyword: string) => { @@ -36,3 +37,11 @@ export const useRecommendedBooksQuery = () => { staleTime: 1000 * 60 * 60, // 1 hour (recommended books don't change often) }); }; + +export const useBookDetailQuery = (isbn: string) => { + return useQuery({ + queryKey: bookKeys.detail(isbn), + queryFn: () => bookService.getBookDetail(isbn), + enabled: !!isbn, + }); +}; diff --git a/src/lib/api/endpoints/book.ts b/src/lib/api/endpoints/book.ts index 9cdaf75..a35809c 100644 --- a/src/lib/api/endpoints/book.ts +++ b/src/lib/api/endpoints/book.ts @@ -3,4 +3,5 @@ import { API_BASE_URL } from "./base"; export const BOOK_ENDPOINTS = { SEARCH: `${API_BASE_URL}/books/search`, RECOMMEND: `${API_BASE_URL}/books/recommend`, + DETAIL: (isbn: string) => `${API_BASE_URL}/books/${isbn}`, }; diff --git a/src/services/bookService.ts b/src/services/bookService.ts index 4d78aba..c30481d 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -1,7 +1,7 @@ import { apiClient } from "@/lib/api/client"; import { BOOK_ENDPOINTS } from "@/lib/api/endpoints/book"; import { ApiResponse } from "@/types/auth"; -import { BookSearchResponse } from "@/types/book"; +import { Book, BookSearchResponse } from "@/types/book"; export const bookService = { searchBooks: async (keyword: string, page: number = 1): Promise => { @@ -22,4 +22,10 @@ export const bookService = { ); return response.result!; }, + getBookDetail: async (isbn: string): Promise => { + const response = await apiClient.get>( + BOOK_ENDPOINTS.DETAIL(isbn) + ); + return response.result!; + }, }; From 4591604117787fadcba5d6b05c7adcc384c1e358 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 11:13:00 +0900 Subject: [PATCH 36/62] feat: implement infinite scroll in book selection modal --- src/app/(main)/stories/new/page.tsx | 20 +-- .../groups/[id]/admin/bookcase/new/page.tsx | 38 ++--- src/components/layout/BookSelectModal.tsx | 130 ++++++++++-------- 3 files changed, 90 insertions(+), 98 deletions(-) diff --git a/src/app/(main)/stories/new/page.tsx b/src/app/(main)/stories/new/page.tsx index 5d09d79..1f792da 100644 --- a/src/app/(main)/stories/new/page.tsx +++ b/src/app/(main)/stories/new/page.tsx @@ -6,25 +6,17 @@ import Image from "next/image"; import BookstoryText from "@/components/base-ui/BookStory/bookstory_text"; import BookstoryChoosebook from "@/components/base-ui/BookStory/bookstory_choosebook"; import BookSelectModal from "@/components/layout/BookSelectModal"; +import { useBookDetailQuery } from "@/hooks/queries/useBookQueries"; function StoryNewContent() { const router = useRouter(); const searchParams = useSearchParams(); - const bookId = searchParams.get("bookId"); + const isbn = searchParams.get("isbn"); const [title, setTitle] = useState(""); const [detail, setDetail] = useState(""); const [isBookSelectModalOpen, setIsBookSelectModalOpen] = useState(false); - // 더미 데이터 - const selectedBook = bookId - ? { - id: Number(bookId), - imgUrl: "/booksample.svg", - title: "어린 왕자", - author: "김개미, 연수", - detail: "최대 500(넘어가면...으로)", - } - : null; + const { data: selectedBook } = useBookDetailQuery(isbn || ""); const handleCancel = () => { router.back(); @@ -36,8 +28,8 @@ function StoryNewContent() { router.push("/stories"); }; - const handleBookSelect = (selectedBookId: number) => { - router.push(`/stories/new?bookId=${selectedBookId}`); + const handleBookSelect = (selectedIsbn: string) => { + router.push(`/stories/new?isbn=${selectedIsbn}`); }; return ( @@ -84,7 +76,7 @@ function StoryNewContent() { bookUrl={selectedBook.imgUrl} bookName={selectedBook.title} author={selectedBook.author} - bookDetail={selectedBook.detail} + bookDetail={selectedBook.description} onButtonClick={() => { setIsBookSelectModalOpen(true); }} diff --git a/src/app/groups/[id]/admin/bookcase/new/page.tsx b/src/app/groups/[id]/admin/bookcase/new/page.tsx index 1a3ecd5..a27157f 100644 --- a/src/app/groups/[id]/admin/bookcase/new/page.tsx +++ b/src/app/groups/[id]/admin/bookcase/new/page.tsx @@ -5,6 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import Image from 'next/image'; import BookSelectModal from '@/components/layout/BookSelectModal'; import BookstoryChoosebook from '@/components/base-ui/BookStory/bookstory_choosebook'; +import { useBookDetailQuery } from '@/hooks/queries/useBookQueries'; import { useHeaderTitle } from '@/contexts/HeaderTitleContext'; const TAGS = [ @@ -36,7 +37,7 @@ export default function NewBookshelfPage() { const router = useRouter(); const searchParams = useSearchParams(); const groupId = params.id as string; - const bookId = searchParams.get('bookId'); + const isbn = searchParams.get('isbn'); const { setCustomTitle } = useHeaderTitle(); // 모바일 헤더 타이틀 설정 @@ -45,16 +46,7 @@ export default function NewBookshelfPage() { return () => setCustomTitle(null); }, [setCustomTitle]); - // 더미 데이터 - const selectedBook = bookId - ? { - id: Number(bookId), - imgUrl: '/booksample.svg', - title: '어린 왕자', - author: '김개미, 연수', - detail: '최대 500(넘어가면...으로)', - } - : null; + const { data: selectedBook } = useBookDetailQuery(isbn || ''); const [generation, setGeneration] = useState('1'); const [isGenerationOpen, setIsGenerationOpen] = useState(false); @@ -93,8 +85,8 @@ export default function NewBookshelfPage() { ); }; - const handleBookSelect = (selectedBookId: number) => { - router.push(`/groups/${groupId}/admin/bookcase/new?bookId=${selectedBookId}`); + const handleBookSelect = (selectedIsbn: string) => { + router.push(`/groups/${groupId}/admin/bookcase/new?isbn=${selectedIsbn}`); }; const handleBack = () => { @@ -131,7 +123,7 @@ export default function NewBookshelfPage() { bookUrl={selectedBook.imgUrl} bookName={selectedBook.title} author={selectedBook.author} - bookDetail={selectedBook.detail} + bookDetail={selectedBook.description} onButtonClick={() => setIsBookSelectModalOpen(true)} /> ) : ( @@ -175,11 +167,10 @@ export default function NewBookshelfPage() { setGeneration(num.toString()); setIsGenerationOpen(false); }} - className={`w-22 h-8 px-3 text-left subhead_4_1 cursor-pointer ${ - generation === num.toString() - ? 'bg-Subbrown-4 text-Gray-7' - : 'bg-White text-Gray-7 hover:bg-Subbrown-4' - }`} + className={`w-22 h-8 px-3 text-left subhead_4_1 cursor-pointer ${generation === num.toString() + ? 'bg-Subbrown-4 text-Gray-7' + : 'bg-White text-Gray-7 hover:bg-Subbrown-4' + }`} > {num} @@ -200,11 +191,10 @@ export default function NewBookshelfPage() { key={index} type="button" onClick={() => handleTagToggle(index)} - className={`h-10 px-4 py-1 rounded-[8px] body_2_2 cursor-pointer transition-colors ${ - isSelected - ? `${getTagBgColor(index)} text-White` - : 'bg-transparent text-Gray-4 border border-Gray-2' - }`} + className={`h-10 px-4 py-1 rounded-[8px] body_2_2 cursor-pointer transition-colors ${isSelected + ? `${getTagBgColor(index)} text-White` + : 'bg-transparent text-Gray-4 border border-Gray-2' + }`} > {label} diff --git a/src/components/layout/BookSelectModal.tsx b/src/components/layout/BookSelectModal.tsx index a2cd8e8..f7ac1bd 100644 --- a/src/components/layout/BookSelectModal.tsx +++ b/src/components/layout/BookSelectModal.tsx @@ -1,14 +1,18 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import Image from "next/image"; import { useRouter } from "next/navigation"; +import { useInfiniteBookSearchQuery } from "@/hooks/queries/useBookQueries"; +import { useDebounce } from "@/hooks/useDebounce"; import SearchBookResult from "@/components/base-ui/Search/search_bookresult"; +import { useInView } from "react-intersection-observer"; +import { Book } from "@/types/book"; type BookSelectModalProps = { isOpen: boolean; onClose: () => void; - onSelect: (bookId: number) => void; + onSelect: (isbn: string) => void; }; export default function BookSelectModal({ @@ -16,41 +20,32 @@ export default function BookSelectModal({ onClose, onSelect, }: BookSelectModalProps) { - const router = useRouter(); - const [likedResults, setLikedResults] = useState>({}); + const [likedResults, setLikedResults] = useState>({}); const [searchValue, setSearchValue] = useState(""); + const debouncedSearchValue = useDebounce(searchValue, 500); - // 더미 데이터 - const searchResults = [ - { - id: 1, - imgUrl: "/booksample.svg", - title: "어린 왕자", - author: "김개미, 연수", - detail: "최대 500(넘어가면...으로)", - }, - { - id: 2, - imgUrl: "/booksample.svg", - title: "어린 왕자", - author: "김개미, 연수", - detail: "최대 500(넘어가면...으로)", - }, - { - id: 3, - imgUrl: "/booksample.svg", - title: "어린 왕자", - author: "김개미, 연수", - detail: "최대 500(넘어가면...으로)", - }, - ]; + const { + data: searchData, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useInfiniteBookSearchQuery(debouncedSearchValue); - const handleSearch = () => { - if (searchValue.trim()) { - router.push(`/search?q=${encodeURIComponent(searchValue.trim())}`); - setSearchValue(""); - onClose(); + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const searchResults = useMemo(() => { + return searchData?.pages.flatMap((page) => page.detailInfoList) || []; + }, [searchData]); + + const handleSearch = () => { + // 실시간 검색으로 대체 }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -136,33 +131,48 @@ export default function BookSelectModal({ {/* 검색 결과 */}
-

- 총 {searchResults.length}개 - 의 검색결과가 있습니다. -

-
- {searchResults.map((result) => ( - - setLikedResults((prev) => ({ ...prev, [result.id]: liked })) - } - onPencilClick={() => { - onSelect(result.id); - onClose(); - }} - onCardClick={() => { - onSelect(result.id); - onClose(); - }} - /> - ))} -
+ {isLoading ? ( +
+

검색 중...

+
+ ) : ( + <> +

+ 총 {searchResults.length}개 + 의 검색결과가 있습니다. +

+
+ {searchResults.map((result: Book) => ( + + setLikedResults((prev) => ({ ...prev, [result.isbn]: liked })) + } + onPencilClick={() => { + onSelect(result.isbn); + onClose(); + }} + onCardClick={() => { + onSelect(result.isbn); + onClose(); + }} + /> + ))} +
+ + {/* 무한 스크롤 로딩 트리거 */} +
+ {isFetchingNextPage && ( +
+ )} +
+ + )}
From 73bc57182d55f376c9a5f53ed19c1d8e658929da Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 11:16:43 +0900 Subject: [PATCH 37/62] feat: implement infinite scroll in header search modal --- src/components/layout/SearchModal.tsx | 34 +++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/components/layout/SearchModal.tsx b/src/components/layout/SearchModal.tsx index e2c31a5..743bec3 100644 --- a/src/components/layout/SearchModal.tsx +++ b/src/components/layout/SearchModal.tsx @@ -5,8 +5,10 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import Search_BookCoverCard from "@/components/base-ui/Search/search_recommendbook"; -import { useBookSearchQuery, useRecommendedBooksQuery } from "@/hooks/queries/useBookQueries"; +import { useInfiniteBookSearchQuery, useRecommendedBooksQuery } from "@/hooks/queries/useBookQueries"; import { useDebounce } from "@/hooks/useDebounce"; +import { useInView } from "react-intersection-observer"; +import { Book } from "@/types/book"; type SearchModalProps = { isOpen: boolean; @@ -20,7 +22,25 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { const [searchValue, setSearchValue] = useState(""); const debouncedSearchValue = useDebounce(searchValue, 300); - const { data: searchResults, isLoading: isSearching } = useBookSearchQuery(debouncedSearchValue); + const { + data: searchData, + isLoading: isSearching, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useInfiniteBookSearchQuery(debouncedSearchValue); + + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const searchResults = useMemo(() => { + return searchData?.pages.flatMap((page) => page.detailInfoList) || []; + }, [searchData]); const { data: recommendedData, isLoading: isLoadingRecommended } = useRecommendedBooksQuery(); @@ -28,7 +48,7 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { return (recommendedData?.detailInfoList || []).slice(0, 4); }, [recommendedData]); - const booksToDisplay = searchResults?.detailInfoList.slice(0, 10) || []; + const booksToDisplay = searchResults; const handleSearch = () => { if (searchValue.trim()) { @@ -142,7 +162,7 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
검색 중...
) : booksToDisplay.length > 0 ? (
- {booksToDisplay.map((book) => ( + {booksToDisplay.map((book: Book) => (
{ @@ -165,6 +185,12 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
))} + {/* 무한 스크롤 로딩 트리거 */} +
+ {isFetchingNextPage && ( +
+ )} +
) : ( debouncedSearchValue &&
검색 결과가 없습니다.
From 9fd5a2e401a132029951366e4a2489ae24c67191 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 11:35:01 +0900 Subject: [PATCH 38/62] feat: implement book story creation API integration --- src/app/(main)/stories/new/page.tsx | 46 ++++++++++++++++++++++++---- src/hooks/queries/useStoryQueries.ts | 13 +++++++- src/services/storyService.ts | 9 +++++- src/types/story.ts | 13 ++++++++ 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/app/(main)/stories/new/page.tsx b/src/app/(main)/stories/new/page.tsx index 1f792da..fc519e7 100644 --- a/src/app/(main)/stories/new/page.tsx +++ b/src/app/(main)/stories/new/page.tsx @@ -7,6 +7,7 @@ import BookstoryText from "@/components/base-ui/BookStory/bookstory_text"; import BookstoryChoosebook from "@/components/base-ui/BookStory/bookstory_choosebook"; import BookSelectModal from "@/components/layout/BookSelectModal"; import { useBookDetailQuery } from "@/hooks/queries/useBookQueries"; +import { useCreateBookStoryMutation } from "@/hooks/queries/useStoryQueries"; function StoryNewContent() { const router = useRouter(); @@ -17,15 +18,46 @@ function StoryNewContent() { const [isBookSelectModalOpen, setIsBookSelectModalOpen] = useState(false); const { data: selectedBook } = useBookDetailQuery(isbn || ""); + const createStoryMutation = useCreateBookStoryMutation(); const handleCancel = () => { router.back(); }; const handleSubmit = () => { - // TODO: 실제 저장 로직 구현 - console.log("저장:", { title, detail }); - router.push("/stories"); + if (!selectedBook) { + alert("책을 선택해 주세요."); + return; + } + if (!title.trim()) { + alert("제목을 입력해 주세요."); + return; + } + if (!detail.trim()) { + alert("내용을 입력해 주세요."); + return; + } + + createStoryMutation.mutate({ + bookInfo: { + isbn: selectedBook.isbn, + title: selectedBook.title, + author: selectedBook.author, + imgUrl: selectedBook.imgUrl, + publisher: selectedBook.publisher, + description: selectedBook.description, + }, + title, + description: detail, + }, { + onSuccess: () => { + router.push("/stories"); + }, + onError: (error) => { + console.error("스토리 등록 실패:", error); + alert("스토리 등록에 실패했습니다. 다시 시도해 주세요."); + } + }); }; const handleBookSelect = (selectedIsbn: string) => { @@ -110,16 +142,18 @@ function StoryNewContent() {
diff --git a/src/hooks/queries/useStoryQueries.ts b/src/hooks/queries/useStoryQueries.ts index 4d9af93..ac731cf 100644 --- a/src/hooks/queries/useStoryQueries.ts +++ b/src/hooks/queries/useStoryQueries.ts @@ -1,5 +1,6 @@ -import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; +import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { storyService } from "@/services/storyService"; +import { CreateBookStoryRequest } from "@/types/story"; export const storyKeys = { all: ["stories"] as const, @@ -34,3 +35,13 @@ export const useInfiniteStoriesQuery = () => { }, }); }; + +export const useCreateBookStoryMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateBookStoryRequest) => storyService.createBookStory(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: storyKeys.all }); + }, + }); +}; diff --git a/src/services/storyService.ts b/src/services/storyService.ts index 3e90dcd..bc2f894 100644 --- a/src/services/storyService.ts +++ b/src/services/storyService.ts @@ -1,6 +1,6 @@ import { apiClient } from "@/lib/api/client"; import { STORY_ENDPOINTS } from "@/lib/api/endpoints/bookstory"; -import { BookStoryListResponse, BookStoryDetail } from "@/types/story"; +import { BookStoryListResponse, BookStoryDetail, CreateBookStoryRequest } from "@/types/story"; import { ApiResponse } from "@/types/auth"; export const storyService = { @@ -19,4 +19,11 @@ export const storyService = { ); return response.result!; }, + createBookStory: async (data: CreateBookStoryRequest): Promise => { + const response = await apiClient.post>( + STORY_ENDPOINTS.LIST, + data + ); + return response.result!; + }, }; diff --git a/src/types/story.ts b/src/types/story.ts index d063fc6..3359506 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -67,3 +67,16 @@ export interface BookStoryDetail { prevBookStoryId: number; nextBookStoryId: number; } + +export interface CreateBookStoryRequest { + bookInfo: { + isbn: string; + title: string; + author: string; + imgUrl: string; + publisher: string; + description: string; + }; + title: string; + description: string; +} From b1fe955530a230569ccb9936454f844f36ab4b89 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 11:40:28 +0900 Subject: [PATCH 39/62] refactor: separate queries and mutations in hooks directory --- src/app/(main)/stories/new/page.tsx | 2 +- src/hooks/mutations/useStoryMutations.ts | 13 +++++++++++++ src/hooks/queries/useStoryQueries.ts | 14 ++------------ 3 files changed, 16 insertions(+), 13 deletions(-) create mode 100644 src/hooks/mutations/useStoryMutations.ts diff --git a/src/app/(main)/stories/new/page.tsx b/src/app/(main)/stories/new/page.tsx index fc519e7..57a8e6b 100644 --- a/src/app/(main)/stories/new/page.tsx +++ b/src/app/(main)/stories/new/page.tsx @@ -7,7 +7,7 @@ import BookstoryText from "@/components/base-ui/BookStory/bookstory_text"; import BookstoryChoosebook from "@/components/base-ui/BookStory/bookstory_choosebook"; import BookSelectModal from "@/components/layout/BookSelectModal"; import { useBookDetailQuery } from "@/hooks/queries/useBookQueries"; -import { useCreateBookStoryMutation } from "@/hooks/queries/useStoryQueries"; +import { useCreateBookStoryMutation } from "@/hooks/mutations/useStoryMutations"; function StoryNewContent() { const router = useRouter(); diff --git a/src/hooks/mutations/useStoryMutations.ts b/src/hooks/mutations/useStoryMutations.ts new file mode 100644 index 0000000..cbb9ef6 --- /dev/null +++ b/src/hooks/mutations/useStoryMutations.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { storyService } from "@/services/storyService"; +import { CreateBookStoryRequest, storyKeys } from "@/hooks/queries/useStoryQueries"; + +export const useCreateBookStoryMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateBookStoryRequest) => storyService.createBookStory(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: storyKeys.all }); + }, + }); +}; diff --git a/src/hooks/queries/useStoryQueries.ts b/src/hooks/queries/useStoryQueries.ts index ac731cf..94c6871 100644 --- a/src/hooks/queries/useStoryQueries.ts +++ b/src/hooks/queries/useStoryQueries.ts @@ -1,6 +1,6 @@ -import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; import { storyService } from "@/services/storyService"; -import { CreateBookStoryRequest } from "@/types/story"; +export type { CreateBookStoryRequest } from "@/types/story"; export const storyKeys = { all: ["stories"] as const, @@ -35,13 +35,3 @@ export const useInfiniteStoriesQuery = () => { }, }); }; - -export const useCreateBookStoryMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: CreateBookStoryRequest) => storyService.createBookStory(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: storyKeys.all }); - }, - }); -}; From e62950fee0c3d319bd7e2c6c0a8ed16fb4f4f92f Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 11:48:26 +0900 Subject: [PATCH 40/62] style: replace alert with toast and refine button states in stories new page --- src/app/(main)/stories/new/page.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/app/(main)/stories/new/page.tsx b/src/app/(main)/stories/new/page.tsx index 57a8e6b..75689b9 100644 --- a/src/app/(main)/stories/new/page.tsx +++ b/src/app/(main)/stories/new/page.tsx @@ -3,6 +3,7 @@ import { useState, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Image from "next/image"; +import { toast } from "react-hot-toast"; import BookstoryText from "@/components/base-ui/BookStory/bookstory_text"; import BookstoryChoosebook from "@/components/base-ui/BookStory/bookstory_choosebook"; import BookSelectModal from "@/components/layout/BookSelectModal"; @@ -26,15 +27,15 @@ function StoryNewContent() { const handleSubmit = () => { if (!selectedBook) { - alert("책을 선택해 주세요."); + toast.error("책을 선택해 주세요."); return; } if (!title.trim()) { - alert("제목을 입력해 주세요."); + toast.error("제목을 입력해 주세요."); return; } if (!detail.trim()) { - alert("내용을 입력해 주세요."); + toast.error("내용을 입력해 주세요."); return; } @@ -51,11 +52,12 @@ function StoryNewContent() { description: detail, }, { onSuccess: () => { + toast.success("스토리가 등록되었습니다!"); router.push("/stories"); }, onError: (error) => { console.error("스토리 등록 실패:", error); - alert("스토리 등록에 실패했습니다. 다시 시도해 주세요."); + toast.error("스토리 등록에 실패했습니다. 다시 시도해 주세요."); } }); }; @@ -142,8 +144,7 @@ function StoryNewContent() { From 9356a0974365eb39cc8fa1fadd3909f435eac1e9 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 13:44:38 +0900 Subject: [PATCH 41/62] feat: implement comment loading in story detail page --- src/app/(main)/stories/[id]/page.tsx | 6 +- .../base-ui/Comment/comment_section.tsx | 61 ++++++++++--------- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/app/(main)/stories/[id]/page.tsx b/src/app/(main)/stories/[id]/page.tsx index 24ffc0b..b1251b4 100644 --- a/src/app/(main)/stories/[id]/page.tsx +++ b/src/app/(main)/stories/[id]/page.tsx @@ -89,7 +89,11 @@ export default function StoryDetailPage() {
{/* 댓글 */}
- +
diff --git a/src/components/base-ui/Comment/comment_section.tsx b/src/components/base-ui/Comment/comment_section.tsx index 66b7689..441fbb7 100644 --- a/src/components/base-ui/Comment/comment_section.tsx +++ b/src/components/base-ui/Comment/comment_section.tsx @@ -1,47 +1,50 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import CommentList, { Comment } from "./comment_list"; +import { CommentInfo } from "@/types/story"; -// 더미 댓글 데이터 -const DUMMY_COMMENTS: Comment[] = [ - { - id: 1, - authorName: "hy_1234", - profileImgSrc: "/profile2.svg", - content: "인정합니다.", - createdAt: "2025-09-22T10:00:00", - isAuthor: true, // 글 작성자 - isMine: true, // 내가 쓴 댓글 - }, - { - id: 3, - authorName: "hy-123456", - profileImgSrc: "/profile4.svg", - content: "인정합니다.", - createdAt: "2025-09-22T12:00:00", - isAuthor: false, - isMine: false, - }, -]; - -// 어떤 글의 댓글인지 구분(나중에 api 연동 시 사용) +// 어떤 글의 댓글인지 구분 type CommentSectionProps = { storyId: number; + initialComments?: CommentInfo[]; + storyAuthorNickname?: string; }; -export default function CommentSection({ storyId }: CommentSectionProps) { - const [comments, setComments] = useState(DUMMY_COMMENTS); +export default function CommentSection({ + storyId, + initialComments = [], + storyAuthorNickname +}: CommentSectionProps) { + // API 데이터를 UI용 Comment 형식으로 변환 + const mapApiToUiComments = (apiComments: CommentInfo[]): Comment[] => { + return apiComments.map((c) => ({ + id: c.commentId, + authorName: c.authorInfo.nickname, + profileImgSrc: c.authorInfo.profileImageUrl, + content: c.content, + createdAt: c.createdAt, + isAuthor: c.authorInfo.nickname === storyAuthorNickname, + isMine: c.writtenByMe, + })); + }; + + const [comments, setComments] = useState(() => mapApiToUiComments(initialComments)); + + // 데이터가 변경되면 상태 업데이트 + useEffect(() => { + setComments(mapApiToUiComments(initialComments)); + }, [initialComments, storyAuthorNickname]); const handleAddComment = (content: string) => { const newComment: Comment = { id: Date.now(), - authorName: "유빈", // TODO: 실제 로그인 유저 정보 + authorName: "유빈", // TODO: 실제 로그인 유저 정보 연동 필요 profileImgSrc: "/profile2.svg", content, createdAt: new Date().toISOString(), isAuthor: false, - isMine: true, // 내가 쓴 댓글 + isMine: true, }; setComments([newComment, ...comments]); }; @@ -50,7 +53,7 @@ export default function CommentSection({ storyId }: CommentSectionProps) { // 새 답글 생성 const newReply: Comment = { id: Date.now(), - authorName: "유빈", + authorName: "유빈", profileImgSrc: "/profile2.svg", content, createdAt: new Date().toISOString(), From b57d0cf0fefc12f48af45e695fd4b93e87e51e25 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 13:45:38 +0900 Subject: [PATCH 42/62] fix: provide fallback for empty profile image URLs to prevent Next.js Image error --- src/app/(main)/stories/[id]/page.tsx | 1 + src/components/base-ui/Comment/comment_section.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/(main)/stories/[id]/page.tsx b/src/app/(main)/stories/[id]/page.tsx index b1251b4..c93c351 100644 --- a/src/app/(main)/stories/[id]/page.tsx +++ b/src/app/(main)/stories/[id]/page.tsx @@ -72,6 +72,7 @@ export default function StoryDetailPage() { authorName={story.authorInfo.nickname} authorNickname={story.authorInfo.nickname} authorId={story.authorInfo.nickname} + profileImgSrc={story.authorInfo.profileImageUrl || "/profile2.svg"} bookTitle={story.bookInfo.title} bookAuthor={story.bookInfo.author} bookDetail={story.description} diff --git a/src/components/base-ui/Comment/comment_section.tsx b/src/components/base-ui/Comment/comment_section.tsx index 441fbb7..3e323da 100644 --- a/src/components/base-ui/Comment/comment_section.tsx +++ b/src/components/base-ui/Comment/comment_section.tsx @@ -21,7 +21,7 @@ export default function CommentSection({ return apiComments.map((c) => ({ id: c.commentId, authorName: c.authorInfo.nickname, - profileImgSrc: c.authorInfo.profileImageUrl, + profileImgSrc: c.authorInfo.profileImageUrl || "/profile2.svg", content: c.content, createdAt: c.createdAt, isAuthor: c.authorInfo.nickname === storyAuthorNickname, From b78d2af49b4be3d55cb0c595f678c44aee32890a Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 13:47:13 +0900 Subject: [PATCH 43/62] fix: ignore literal 'string' placeholder URLs from API and provide fallback --- src/app/(main)/stories/[id]/page.tsx | 10 ++++++++-- src/components/base-ui/Comment/comment_section.tsx | 10 +++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/app/(main)/stories/[id]/page.tsx b/src/app/(main)/stories/[id]/page.tsx index c93c351..23dd3b5 100644 --- a/src/app/(main)/stories/[id]/page.tsx +++ b/src/app/(main)/stories/[id]/page.tsx @@ -33,6 +33,12 @@ export default function StoryDetailPage() { const prevId = story.prevBookStoryId !== 0 ? story.prevBookStoryId : null; const nextId = story.nextBookStoryId !== 0 ? story.nextBookStoryId : null; + // URL 유효성 검사 (Swagger 기본값 "string" 또는 빈 값 처리) + const isValidUrl = (url: string | null | undefined) => { + if (!url || url === "string" || url.trim() === "") return false; + return url.startsWith("/") || url.startsWith("http"); + }; + return (
{/* 책이야기 > 상세보기 */} @@ -68,11 +74,11 @@ export default function StoryDetailPage() {
{ + if (!url || url === "string" || url.trim() === "") return false; + return url.startsWith("/") || url.startsWith("http"); +}; + export default function CommentSection({ storyId, initialComments = [], @@ -21,7 +27,9 @@ export default function CommentSection({ return apiComments.map((c) => ({ id: c.commentId, authorName: c.authorInfo.nickname, - profileImgSrc: c.authorInfo.profileImageUrl || "/profile2.svg", + profileImgSrc: isValidUrl(c.authorInfo.profileImageUrl) + ? c.authorInfo.profileImageUrl + : "/profile2.svg", content: c.content, createdAt: c.createdAt, isAuthor: c.authorInfo.nickname === storyAuthorNickname, From 7e1b6b8431218b53ae4acd7a7e80841146fefe7e Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 13:51:47 +0900 Subject: [PATCH 44/62] refactor: move isValidUrl to utils and fix comment detail mapping --- src/app/(main)/stories/[id]/page.tsx | 6 +----- src/components/base-ui/Comment/comment_section.tsx | 6 +----- src/utils/url.ts | 7 +++++++ 3 files changed, 9 insertions(+), 10 deletions(-) create mode 100644 src/utils/url.ts diff --git a/src/app/(main)/stories/[id]/page.tsx b/src/app/(main)/stories/[id]/page.tsx index 23dd3b5..b3ebf4b 100644 --- a/src/app/(main)/stories/[id]/page.tsx +++ b/src/app/(main)/stories/[id]/page.tsx @@ -4,6 +4,7 @@ import BookstoryDetail from "@/components/base-ui/BookStory/bookstory_detail"; import StoryNavigation from "@/components/base-ui/BookStory/story_navigation"; import CommentSection from "@/components/base-ui/Comment/comment_section"; import Image from "next/image"; +import { isValidUrl } from "@/utils/url"; import { useParams } from "next/navigation"; import { useStoryDetailQuery } from "@/hooks/queries/useStoryQueries"; @@ -33,11 +34,6 @@ export default function StoryDetailPage() { const prevId = story.prevBookStoryId !== 0 ? story.prevBookStoryId : null; const nextId = story.nextBookStoryId !== 0 ? story.nextBookStoryId : null; - // URL 유효성 검사 (Swagger 기본값 "string" 또는 빈 값 처리) - const isValidUrl = (url: string | null | undefined) => { - if (!url || url === "string" || url.trim() === "") return false; - return url.startsWith("/") || url.startsWith("http"); - }; return (
diff --git a/src/components/base-ui/Comment/comment_section.tsx b/src/components/base-ui/Comment/comment_section.tsx index 9ecf3a3..6459bf1 100644 --- a/src/components/base-ui/Comment/comment_section.tsx +++ b/src/components/base-ui/Comment/comment_section.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import CommentList, { Comment } from "./comment_list"; import { CommentInfo } from "@/types/story"; +import { isValidUrl } from "@/utils/url"; // 어떤 글의 댓글인지 구분 type CommentSectionProps = { @@ -11,11 +12,6 @@ type CommentSectionProps = { storyAuthorNickname?: string; }; -// URL 유효성 검사 (Swagger 기본값 "string" 또는 빈 값 처리) -const isValidUrl = (url: string | null | undefined) => { - if (!url || url === "string" || url.trim() === "") return false; - return url.startsWith("/") || url.startsWith("http"); -}; export default function CommentSection({ storyId, diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 0000000..9f53bf8 --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,7 @@ +/** + * URL 유효성 검사 (Swagger 기본값 "string" 또는 빈 값 처리) + */ +export const isValidUrl = (url: string | null | undefined): boolean => { + if (!url || url === "string" || url.trim() === "") return false; + return url.startsWith("/") || url.startsWith("http"); +}; From 2a214db628ca2e5a2b2f077362b3ce1a8c22212e Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 14:01:08 +0900 Subject: [PATCH 45/62] feat: implement comment and reply creation with API integration --- .../base-ui/Comment/comment_section.tsx | 67 +++++++++---------- src/hooks/mutations/useStoryMutations.ts | 11 +++ src/services/storyService.ts | 14 ++++ src/types/story.ts | 4 ++ 4 files changed, 60 insertions(+), 36 deletions(-) diff --git a/src/components/base-ui/Comment/comment_section.tsx b/src/components/base-ui/Comment/comment_section.tsx index 6459bf1..fff0325 100644 --- a/src/components/base-ui/Comment/comment_section.tsx +++ b/src/components/base-ui/Comment/comment_section.tsx @@ -4,6 +4,8 @@ import { useEffect, useState } from "react"; import CommentList, { Comment } from "./comment_list"; import { CommentInfo } from "@/types/story"; import { isValidUrl } from "@/utils/url"; +import { useCreateCommentMutation } from "@/hooks/mutations/useStoryMutations"; +import { toast } from "react-hot-toast"; // 어떤 글의 댓글인지 구분 type CommentSectionProps = { @@ -18,6 +20,8 @@ export default function CommentSection({ initialComments = [], storyAuthorNickname }: CommentSectionProps) { + const createCommentMutation = useCreateCommentMutation(storyId); + // API 데이터를 UI용 Comment 형식으로 변환 const mapApiToUiComments = (apiComments: CommentInfo[]): Comment[] => { return apiComments.map((c) => ({ @@ -40,58 +44,49 @@ export default function CommentSection({ setComments(mapApiToUiComments(initialComments)); }, [initialComments, storyAuthorNickname]); + // 댓글 추가 const handleAddComment = (content: string) => { - const newComment: Comment = { - id: Date.now(), - authorName: "유빈", // TODO: 실제 로그인 유저 정보 연동 필요 - profileImgSrc: "/profile2.svg", - content, - createdAt: new Date().toISOString(), - isAuthor: false, - isMine: true, - }; - setComments([newComment, ...comments]); + createCommentMutation.mutate( + { content }, + { + onSuccess: () => { + toast.success("댓글이 등록되었습니다."); + }, + onError: () => { + toast.error("댓글 등록에 실패했습니다."); + } + } + ); }; + // 답글 추가 const handleAddReply = (parentId: number, content: string) => { - // 새 답글 생성 - const newReply: Comment = { - id: Date.now(), - authorName: "유빈", - profileImgSrc: "/profile2.svg", - content, - createdAt: new Date().toISOString(), - isAuthor: false, - isMine: true, - }; - - // 원래 댓글 찾아서 replies 배열에 답글 추가 - setComments((prevComments) => { - return prevComments.map((comment) => { - if (comment.id === parentId) { - // 원래 댓글의 replies 배열에 새 답글 추가 - return { - ...comment, - replies: [...(comment.replies || []), newReply], - }; + createCommentMutation.mutate( + { content, parentCommentId: parentId }, + { + onSuccess: () => { + toast.success("답글이 등록되었습니다."); + }, + onError: () => { + toast.error("답글 등록에 실패했습니다."); } - return comment; - }); - }); + } + ); }; const handleEditComment = (id: number, content: string) => { - + // TODO: 댓글 수정 API 연동 }; const handleDeleteComment = (id: number) => { + // TODO: 댓글 삭제 API 연동 setComments(comments.filter((c) => c.id !== id)); }; const handleReportComment = (id: number) => { - // 신고 API 연동 + // TODO: 댓글 신고 API 연동 console.log("댓글 신고:", id); - alert("신고가 접수되었습니다."); + toast.success("신고가 접수되었습니다."); }; return ( diff --git a/src/hooks/mutations/useStoryMutations.ts b/src/hooks/mutations/useStoryMutations.ts index cbb9ef6..0e7641e 100644 --- a/src/hooks/mutations/useStoryMutations.ts +++ b/src/hooks/mutations/useStoryMutations.ts @@ -11,3 +11,14 @@ export const useCreateBookStoryMutation = () => { }, }); }; + +export const useCreateCommentMutation = (bookStoryId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (args: { content: string; parentCommentId?: number }) => + storyService.createComment(bookStoryId, { content: args.content }, args.parentCommentId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: storyKeys.detail(bookStoryId) }); + }, + }); +}; diff --git a/src/services/storyService.ts b/src/services/storyService.ts index bc2f894..00306d7 100644 --- a/src/services/storyService.ts +++ b/src/services/storyService.ts @@ -26,4 +26,18 @@ export const storyService = { ); return response.result!; }, + createComment: async ( + bookStoryId: number, + data: { content: string }, + parentCommentId?: number + ): Promise => { + const response = await apiClient.post>( + `${STORY_ENDPOINTS.LIST}/${bookStoryId}/comments`, + data, + { + params: { parentCommentId } + } + ); + return response.result!; + }, }; diff --git a/src/types/story.ts b/src/types/story.ts index 3359506..e7f4581 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -80,3 +80,7 @@ export interface CreateBookStoryRequest { title: string; description: string; } + +export interface CreateCommentRequest { + content: string; +} From 42f5d1f32bb7fc7b89ced1a3d564e4aa46a5fdb8 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 14:15:49 +0900 Subject: [PATCH 46/62] feat: sort comments and implement nested reply hierarchy --- .../base-ui/Comment/comment_section.tsx | 29 +++++++++++++++++-- src/types/story.ts | 2 ++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/components/base-ui/Comment/comment_section.tsx b/src/components/base-ui/Comment/comment_section.tsx index fff0325..bee8d3e 100644 --- a/src/components/base-ui/Comment/comment_section.tsx +++ b/src/components/base-ui/Comment/comment_section.tsx @@ -22,9 +22,9 @@ export default function CommentSection({ }: CommentSectionProps) { const createCommentMutation = useCreateCommentMutation(storyId); - // API 데이터를 UI용 Comment 형식으로 변환 + // API 데이터를 UI용 Comment 형식으로 변환 및 계층 구조화 const mapApiToUiComments = (apiComments: CommentInfo[]): Comment[] => { - return apiComments.map((c) => ({ + const flatComments: Comment[] = apiComments.map((c) => ({ id: c.commentId, authorName: c.authorInfo.nickname, profileImgSrc: isValidUrl(c.authorInfo.profileImageUrl) @@ -34,7 +34,32 @@ export default function CommentSection({ createdAt: c.createdAt, isAuthor: c.authorInfo.nickname === storyAuthorNickname, isMine: c.writtenByMe, + replies: [], })); + + const rootComments: Comment[] = []; + const commentMap = new Map(); + + flatComments.forEach(c => commentMap.set(c.id, c)); + + apiComments.forEach((c, index) => { + const uiComment = flatComments[index]; + if (c.parentCommentId && commentMap.has(c.parentCommentId)) { + commentMap.get(c.parentCommentId)!.replies!.push(uiComment); + } else { + rootComments.push(uiComment); + } + }); + + // 최상위 댓글 최신순(내림차순) 정렬 + rootComments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + // 대댓글은 등록순(오름차순) 유지 (일반적인 UI 패턴) + rootComments.forEach(c => { + c.replies?.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + }); + + return rootComments; }; const [comments, setComments] = useState(() => mapApiToUiComments(initialComments)); diff --git a/src/types/story.ts b/src/types/story.ts index e7f4581..402d779 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -49,8 +49,10 @@ export interface CommentInfo { createdAt: string; writtenByMe: boolean; deleted: boolean; + parentCommentId?: number | null; } + export interface BookStoryDetail { bookStoryId: number; bookInfo: BookInfo; From b044e984271e8c0a86c4f4a3670d86ce349bf7f2 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 14:22:19 +0900 Subject: [PATCH 47/62] feat: implement inline comment edit and delete with API integration --- .../base-ui/Comment/comment_item.tsx | 223 +++++++++++------- .../base-ui/Comment/comment_section.tsx | 32 ++- src/hooks/mutations/useStoryMutations.ts | 22 ++ src/lib/api/client.ts | 2 + src/services/storyService.ts | 20 ++ 5 files changed, 205 insertions(+), 94 deletions(-) diff --git a/src/components/base-ui/Comment/comment_item.tsx b/src/components/base-ui/Comment/comment_item.tsx index 5bdf30c..a626b08 100644 --- a/src/components/base-ui/Comment/comment_item.tsx +++ b/src/components/base-ui/Comment/comment_item.tsx @@ -44,6 +44,8 @@ export default function CommentItem({ onReport, }: CommentItemProps) { const [menuOpen, setMenuOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editContent, setEditContent] = useState(content); const menuRef = useRef(null); // 바깥 클릭 시 메뉴 닫기 @@ -57,7 +59,19 @@ export default function CommentItem({ return () => document.removeEventListener("mousedown", handleClickOutside); }, []); - // 댓글 본체 (프로필 + 이름 + 내용 + 날짜 + 메뉴) + const handleSaveEdit = () => { + if (editContent.trim() && editContent !== content) { + onEdit?.(id, editContent); + } + setIsEditing(false); + }; + + const handleCancelEdit = () => { + setEditContent(content); + setIsEditing(false); + }; + + // 댓글 본체 (프로필 + 이름 + 작성자 + 날짜 + 내용 + 메뉴) const commentBody = (
{/* 상단: 프로필 + 이름 + 작성자 + 날짜 */} @@ -82,106 +96,135 @@ export default function CommentItem({
{/* 댓글 내용 + 메뉴 (같은 줄) */} -
-

{content}

- {/* 메뉴 버튼 */} -
- + {!isEditing ? ( +
+

{content}

+ {/* 메뉴 버튼 */} +
+ - {/* 드롭다운 메뉴 */} - {menuOpen && ( -
- {/* 답글달기 - onReply가 있을 때만 표시 */} - {!isReply && onReply && ( - <> - -
- - )} - {/* 내 댓글일 때: 수정, 삭제 */} - {isMine ? ( - <> - {onDelete && ( + {/* 드롭다운 메뉴 */} + {menuOpen && ( +
+ {/* 답글달기 - onReply가 있을 때만 표시 */} + {!isReply && onReply && ( + <> - )} - {onEdit && onDelete && (
- )} - {onEdit && ( - - )} - - ) : ( - /* 남의 댓글일 때 */ - - )} -
- )} + + )} + {/* 내 댓글일 때: 수정, 삭제 */} + {isMine ? ( + <> + {onDelete && ( + + )} + {onEdit && onDelete && ( +
+ )} + {onEdit && ( + + )} + + ) : ( + /* 남의 댓글일 때 */ + + )} +
+ )} +
-
+ ) : ( + /* 수정 모드 */ +
+ setEditContent(e.target.value)} + className="w-full min-h-[48px] px-4 py-3 rounded-lg border border-Subbrown-4 bg-White Body_1_2 text-Gray-7 outline-none focus:border-primary-3" + autoFocus + /> +
+ + +
+
+ )}
); diff --git a/src/components/base-ui/Comment/comment_section.tsx b/src/components/base-ui/Comment/comment_section.tsx index bee8d3e..bb7ce00 100644 --- a/src/components/base-ui/Comment/comment_section.tsx +++ b/src/components/base-ui/Comment/comment_section.tsx @@ -4,7 +4,11 @@ import { useEffect, useState } from "react"; import CommentList, { Comment } from "./comment_list"; import { CommentInfo } from "@/types/story"; import { isValidUrl } from "@/utils/url"; -import { useCreateCommentMutation } from "@/hooks/mutations/useStoryMutations"; +import { + useCreateCommentMutation, + useUpdateCommentMutation, + useDeleteCommentMutation +} from "@/hooks/mutations/useStoryMutations"; import { toast } from "react-hot-toast"; // 어떤 글의 댓글인지 구분 @@ -21,6 +25,8 @@ export default function CommentSection({ storyAuthorNickname }: CommentSectionProps) { const createCommentMutation = useCreateCommentMutation(storyId); + const updateCommentMutation = useUpdateCommentMutation(storyId); + const deleteCommentMutation = useDeleteCommentMutation(storyId); // API 데이터를 UI용 Comment 형식으로 변환 및 계층 구조화 const mapApiToUiComments = (apiComments: CommentInfo[]): Comment[] => { @@ -100,12 +106,30 @@ export default function CommentSection({ }; const handleEditComment = (id: number, content: string) => { - // TODO: 댓글 수정 API 연동 + updateCommentMutation.mutate( + { commentId: id, content }, + { + onSuccess: () => { + toast.success("댓글이 수정되었습니다."); + }, + onError: () => { + toast.error("댓글 수정에 실패했습니다."); + } + } + ); }; const handleDeleteComment = (id: number) => { - // TODO: 댓글 삭제 API 연동 - setComments(comments.filter((c) => c.id !== id)); + if (confirm("댓글을 삭제하시겠습니까?")) { + deleteCommentMutation.mutate(id, { + onSuccess: () => { + toast.success("댓글이 삭제되었습니다."); + }, + onError: () => { + toast.error("댓글 삭제에 실패했습니다."); + } + }); + } }; const handleReportComment = (id: number) => { diff --git a/src/hooks/mutations/useStoryMutations.ts b/src/hooks/mutations/useStoryMutations.ts index 0e7641e..21fc4bf 100644 --- a/src/hooks/mutations/useStoryMutations.ts +++ b/src/hooks/mutations/useStoryMutations.ts @@ -22,3 +22,25 @@ export const useCreateCommentMutation = (bookStoryId: number) => { }, }); }; + +export const useUpdateCommentMutation = (bookStoryId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (args: { commentId: number; content: string }) => + storyService.updateComment(bookStoryId, args.commentId, { content: args.content }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: storyKeys.detail(bookStoryId) }); + }, + }); +}; + +export const useDeleteCommentMutation = (bookStoryId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (commentId: number) => + storyService.deleteComment(bookStoryId, commentId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: storyKeys.detail(bookStoryId) }); + }, + }); +}; diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 7b987f8..281d1a5 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -100,6 +100,8 @@ export const apiClient = { request(url, { ...options, method: "POST", body: body ? JSON.stringify(body) : undefined }), put: (url: string, body?: any, options?: RequestOptions) => request(url, { ...options, method: "PUT", body: body ? JSON.stringify(body) : undefined }), + patch: (url: string, body?: any, options?: RequestOptions) => + request(url, { ...options, method: "PATCH", body: body ? JSON.stringify(body) : undefined }), delete: (url: string, options?: RequestOptions) => request(url, { ...options, method: "DELETE" }), }; diff --git a/src/services/storyService.ts b/src/services/storyService.ts index 00306d7..9ff936e 100644 --- a/src/services/storyService.ts +++ b/src/services/storyService.ts @@ -40,4 +40,24 @@ export const storyService = { ); return response.result!; }, + updateComment: async ( + bookStoryId: number, + commentId: number, + data: { content: string } + ): Promise => { + const response = await apiClient.patch>( + `${STORY_ENDPOINTS.LIST}/${bookStoryId}/comments/${commentId}`, + data + ); + return response.result!; + }, + deleteComment: async ( + bookStoryId: number, + commentId: number + ): Promise => { + const response = await apiClient.delete>( + `${STORY_ENDPOINTS.LIST}/${bookStoryId}/comments/${commentId}` + ); + return response.result!; + }, }; From 6a35151d764dd6315ef57641e2cf0b55fcf6e458 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 14:45:45 +0900 Subject: [PATCH 48/62] refactor: apply code review feedback for comment UI and url validation --- .../base-ui/Comment/comment_item.tsx | 5 +- .../base-ui/Comment/comment_section.tsx | 58 ++++++++++----- src/components/common/ConfirmModal.tsx | 73 +++++++++++++++++++ src/utils/url.ts | 20 ++++- 4 files changed, 134 insertions(+), 22 deletions(-) create mode 100644 src/components/common/ConfirmModal.tsx diff --git a/src/components/base-ui/Comment/comment_item.tsx b/src/components/base-ui/Comment/comment_item.tsx index a626b08..419e616 100644 --- a/src/components/base-ui/Comment/comment_item.tsx +++ b/src/components/base-ui/Comment/comment_item.tsx @@ -200,11 +200,10 @@ export default function CommentItem({ ) : ( /* 수정 모드 */
- setEditContent(e.target.value)} - className="w-full min-h-[48px] px-4 py-3 rounded-lg border border-Subbrown-4 bg-White Body_1_2 text-Gray-7 outline-none focus:border-primary-3" + className="w-full min-h-[80px] px-4 py-3 rounded-lg border border-Subbrown-4 bg-White Body_1_2 text-Gray-7 outline-none focus:border-primary-3 resize-none whitespace-pre-wrap" autoFocus />
diff --git a/src/components/base-ui/Comment/comment_section.tsx b/src/components/base-ui/Comment/comment_section.tsx index bb7ce00..b1e6a41 100644 --- a/src/components/base-ui/Comment/comment_section.tsx +++ b/src/components/base-ui/Comment/comment_section.tsx @@ -10,6 +10,7 @@ import { useDeleteCommentMutation } from "@/hooks/mutations/useStoryMutations"; import { toast } from "react-hot-toast"; +import ConfirmModal from "@/components/common/ConfirmModal"; // 어떤 글의 댓글인지 구분 type CommentSectionProps = { @@ -69,6 +70,8 @@ export default function CommentSection({ }; const [comments, setComments] = useState(() => mapApiToUiComments(initialComments)); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [commentToDelete, setCommentToDelete] = useState(null); // 데이터가 변경되면 상태 업데이트 useEffect(() => { @@ -120,16 +123,24 @@ export default function CommentSection({ }; const handleDeleteComment = (id: number) => { - if (confirm("댓글을 삭제하시겠습니까?")) { - deleteCommentMutation.mutate(id, { - onSuccess: () => { - toast.success("댓글이 삭제되었습니다."); - }, - onError: () => { - toast.error("댓글 삭제에 실패했습니다."); - } - }); - } + setCommentToDelete(id); + setIsConfirmOpen(true); + }; + + const confirmDelete = () => { + if (commentToDelete === null) return; + deleteCommentMutation.mutate(commentToDelete, { + onSuccess: () => { + toast.success("댓글이 삭제되었습니다."); + setIsConfirmOpen(false); + setCommentToDelete(null); + }, + onError: () => { + toast.error("댓글 삭제에 실패했습니다."); + setIsConfirmOpen(false); + setCommentToDelete(null); + } + }); }; const handleReportComment = (id: number) => { @@ -139,14 +150,25 @@ export default function CommentSection({ }; return ( - + <> + + { + setIsConfirmOpen(false); + setCommentToDelete(null); + }} + /> + ); } diff --git a/src/components/common/ConfirmModal.tsx b/src/components/common/ConfirmModal.tsx new file mode 100644 index 0000000..13443cd --- /dev/null +++ b/src/components/common/ConfirmModal.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect } from "react"; + +type ConfirmModalProps = { + isOpen: boolean; + message: string; + onConfirm: () => void; + onCancel: () => void; +}; + +export default function ConfirmModal({ isOpen, message, onConfirm, onCancel }: ConfirmModalProps) { + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen) { + onCancel(); + } + }; + + if (isOpen) { + document.body.style.overflow = "hidden"; + document.addEventListener("keydown", handleEscape); + } else { + document.body.style.overflow = ""; + } + + return () => { + document.body.style.overflow = ""; + document.removeEventListener("keydown", handleEscape); + }; + }, [isOpen, onCancel]); + + if (!isOpen) return null; + + return ( + <> + {/* 백그라운드 딤 */} +
+ {/* 모달 컨테이너 */} +
e.stopPropagation()} + > +
+

{message}

+
+
+ + +
+
+
+ + ); +} diff --git a/src/utils/url.ts b/src/utils/url.ts index 9f53bf8..b27b404 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -3,5 +3,23 @@ */ export const isValidUrl = (url: string | null | undefined): boolean => { if (!url || url === "string" || url.trim() === "") return false; - return url.startsWith("/") || url.startsWith("http"); + + // 허용되는 상대 경로 패턴 (예: /profile2.svg) + if (url.startsWith("/")) return true; + + try { + new URL(url); + return true; // http, https 등 유효한 scheme이 있는 경우 + } catch { + // 프로토콜 상대 URL 지원 (예: //example.com/image.png) + if (url.startsWith("//")) { + try { + new URL(`https:${url}`); + return true; + } catch { + return false; + } + } + return false; + } }; From 8841fb5595c8c1cd1540e9a129236fd336386711 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 14:50:10 +0900 Subject: [PATCH 49/62] design: refine ConfirmModal UI sizing and typography for polished experience --- src/components/common/ConfirmModal.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/common/ConfirmModal.tsx b/src/components/common/ConfirmModal.tsx index 13443cd..dbbee40 100644 --- a/src/components/common/ConfirmModal.tsx +++ b/src/components/common/ConfirmModal.tsx @@ -36,22 +36,22 @@ export default function ConfirmModal({ isOpen, message, onConfirm, onCancel }: C <> {/* 백그라운드 딤 */}
{/* 모달 컨테이너 */}
e.stopPropagation()} > -
-

{message}

+
+

{message}

-
+
@@ -61,7 +61,7 @@ export default function ConfirmModal({ isOpen, message, onConfirm, onCancel }: C onConfirm(); onCancel(); }} - className="flex-1 h-[48px] rounded-lg bg-primary-3 text-White subhead_4_1 hover:bg-primary-3/90 transition-colors" + className="flex-1 h-[48px] rounded-lg bg-primary-3 text-White body_1_2 hover:bg-primary-3/90 transition-colors" > 확인 From 4d62795a2097fb7d67ad74062a8d15cd72efa219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Wed, 25 Feb 2026 11:13:40 +0900 Subject: [PATCH 50/62] fix : build error --- src/components/base-ui/Group/BookshelfModal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/base-ui/Group/BookshelfModal.tsx b/src/components/base-ui/Group/BookshelfModal.tsx index 01983a2..46cde61 100644 --- a/src/components/base-ui/Group/BookshelfModal.tsx +++ b/src/components/base-ui/Group/BookshelfModal.tsx @@ -79,10 +79,10 @@ export default function BookshelfModal({ isOpen, onClose, onSelect }: Props) { author={book.author} category={book.category} rating={book.rating} - onTopicClick={() => {}} - onReviewClick={() => {}} - onMeetingClick={() => {}} - /> + onTopicClick={() => { } } + onReviewClick={() => { } } + onMeetingClick={() => { } } + imageUrl={''} />
))}
From cf372ab81bfc0c9caa18048766426a5028eca97d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Wed, 25 Feb 2026 12:24:04 +0900 Subject: [PATCH 51/62] =?UTF-8?q?fix=20:=20build=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=A7=84=EC=A7=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +-- pnpm-lock.yaml | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a0765ba..eeddb52 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,10 @@ }, "dependencies": { "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@tanstack/react-query": "^5.90.21", "@tanstack/react-query-devtools": "^5.91.3", - "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", "js-cookie": "^3.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2ad06c..e3166a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.0) '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.0) @@ -161,6 +170,28 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.7.0': resolution: {integrity: sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==} @@ -2332,6 +2363,31 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@dnd-kit/accessibility@3.1.1(react@19.2.0)': + dependencies: + react: 19.2.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.0) + '@dnd-kit/utilities': 3.2.2(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@dnd-kit/utilities': 3.2.2(react@19.2.0) + react: 19.2.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.0)': + dependencies: + react: 19.2.0 + tslib: 2.8.1 + '@emnapi/core@1.7.0': dependencies: '@emnapi/wasi-threads': 1.1.0 From 230314956c5fb65e5addd94d8d99e25e8a38b3cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Wed, 25 Feb 2026 15:27:34 +0900 Subject: [PATCH 52/62] =?UTF-8?q?feat=20:=20APIResponse=20=ED=95=98?= =?UTF-8?q?=EB=82=98=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api/types.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/lib/api/types.ts diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts new file mode 100644 index 0000000..b835249 --- /dev/null +++ b/src/lib/api/types.ts @@ -0,0 +1,7 @@ +// ~/types/auth.ts에 정의되어 있는건 아는데 거기 있는 거보단 여기가 맞을 거 같아서 빼두겠습니다. +export type ApiResponse = { + isSuccess: boolean; + code: string; + message: string; + result: T; +}; \ No newline at end of file From 833509837a47b16d55234ed4f1d52c5cab082bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Wed, 25 Feb 2026 16:50:47 +0900 Subject: [PATCH 53/62] =?UTF-8?q?feat=20:=20=EB=AA=A8=EC=9E=84=EC=83=9D?= =?UTF-8?q?=EC=84=B1=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A3=BC=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api/endpoints/Clubs.ts | 6 ++++++ src/lib/api/endpoints/Image.ts | 8 ++++++++ src/lib/api/endpoints/index.ts | 2 ++ 3 files changed, 16 insertions(+) create mode 100644 src/lib/api/endpoints/Clubs.ts create mode 100644 src/lib/api/endpoints/Image.ts diff --git a/src/lib/api/endpoints/Clubs.ts b/src/lib/api/endpoints/Clubs.ts new file mode 100644 index 0000000..14c393e --- /dev/null +++ b/src/lib/api/endpoints/Clubs.ts @@ -0,0 +1,6 @@ +import { API_BASE_URL } from "../endpoints"; + +export const CLUBS = { + create: `${API_BASE_URL}/clubs`, // POST /api/clubs + checkName: `${API_BASE_URL}/clubs/check-name`, // GET /api/clubs/check-name?clubName= +} as const; \ No newline at end of file diff --git a/src/lib/api/endpoints/Image.ts b/src/lib/api/endpoints/Image.ts new file mode 100644 index 0000000..9237435 --- /dev/null +++ b/src/lib/api/endpoints/Image.ts @@ -0,0 +1,8 @@ +import { API_BASE_URL } from "../endpoints"; + +export type ImageUploadType = "PROFILE" | "CLUB" | "NOTICE"; + +export const IMAGE = { + uploadUrl: (type: ImageUploadType) => + `${API_BASE_URL}/image/${type}/upload-url`, // POST +} as const; \ No newline at end of file diff --git a/src/lib/api/endpoints/index.ts b/src/lib/api/endpoints/index.ts index 463a8a2..cdbbf5f 100644 --- a/src/lib/api/endpoints/index.ts +++ b/src/lib/api/endpoints/index.ts @@ -3,3 +3,5 @@ export * from "./auth"; export * from "./bookstory"; export * from "./member"; export * from "./book"; +export * from "./Clubs"; +export * from "./Image"; From 2e0b84fe1865b3ad7a617f175dc4a25ddfc52373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Wed, 25 Feb 2026 16:51:11 +0900 Subject: [PATCH 54/62] =?UTF-8?q?chore=20:=20=EB=AA=A8=EC=9E=84=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20type=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/groups/clubCreate.ts | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/types/groups/clubCreate.ts diff --git a/src/types/groups/clubCreate.ts b/src/types/groups/clubCreate.ts new file mode 100644 index 0000000..70324e3 --- /dev/null +++ b/src/types/groups/clubCreate.ts @@ -0,0 +1,61 @@ +// src/types/groups/clubCreate.ts +import type { BookCategory, ParticipantType } from "@/types/groups/groups"; + + +export type ClubLink = { + link: string; + label: string; +}; + +export type CreateClubRequest = { + name: string; + description: string; + profileImageUrl: string | null; // 없으면 null로 보내는 게 깔끔 + region: string; + category: ClubCategoryCode[]; + participantTypes: ParticipantType[]; + links: ClubLink[]; + open: boolean; +}; + + +// src/types/groups/clubCreate.ts + +export type ClubCategoryCode = + | "FICTION_POETRY_DRAMA" + | "ESSAY" + | "HUMANITIES" + | "SOCIAL_SCIENCE" + | "POLITICS_DIPLOMACY_DEFENSE" + | "ECONOMY_MANAGEMENT" + | "SELF_DEVELOPMENT" + | "HISTORY_CULTURE" + | "SCIENCE" + | "COMPUTER_IT" + | "ART_POP_CULTURE" + | "TRAVEL" + | "FOREIGN_LANGUAGE" + | "CHILDREN_BOOKS" + | "RELIGION_PHILOSOPHY"; + +export const BOOK_CATEGORY_TO_CODE: Record = { + "여행": "TRAVEL", + "외국어": "FOREIGN_LANGUAGE", + "어린이/청소년": "CHILDREN_BOOKS", + "종교/철학": "RELIGION_PHILOSOPHY", + "소설/시/희곡": "FICTION_POETRY_DRAMA", + "에세이": "ESSAY", + "인문학": "HUMANITIES", + "사회과학": "SOCIAL_SCIENCE", + "정치/외교/국방": "POLITICS_DIPLOMACY_DEFENSE", + "경제/경영": "ECONOMY_MANAGEMENT", + "자기계발": "SELF_DEVELOPMENT", + "역사/문화": "HISTORY_CULTURE", + "과학": "SCIENCE", + "컴퓨터/IT": "COMPUTER_IT", + "예술/대중문화": "ART_POP_CULTURE", +}; + +export function mapBookCategoriesToCodes(categories: BookCategory[]) { + return categories.map((c) => BOOK_CATEGORY_TO_CODE[c]); +} \ No newline at end of file From d2f45cbc9dac54613b7329c86f160f7d300a8303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=ED=98=84?= Date: Wed, 25 Feb 2026 16:51:49 +0900 Subject: [PATCH 55/62] =?UTF-8?q?feat=20:=20=EB=AA=A8=EC=9E=84=EC=83=9D?= =?UTF-8?q?=EC=84=B1=ED=8E=98=EC=9D=B4=EC=A7=80=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/groups/create/page.tsx | 993 ++++++++++--------- src/hooks/mutations/useCreateClubMutation.ts | 19 + src/hooks/queries/useCreateClubQueries.ts | 17 + src/services/clubService.ts | 19 + src/services/imageService.ts | 48 + 5 files changed, 622 insertions(+), 474 deletions(-) create mode 100644 src/hooks/mutations/useCreateClubMutation.ts create mode 100644 src/hooks/queries/useCreateClubQueries.ts create mode 100644 src/services/clubService.ts create mode 100644 src/services/imageService.ts diff --git a/src/app/groups/create/page.tsx b/src/app/groups/create/page.tsx index 5c14412..8effe14 100644 --- a/src/app/groups/create/page.tsx +++ b/src/app/groups/create/page.tsx @@ -1,13 +1,29 @@ -'use client' +"use client"; import React, { useMemo, useRef, useState } from "react"; -import Image from 'next/image'; +import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; + import StepDot from "@/components/base-ui/Group-Create/StepDot"; import Chip from "@/components/base-ui/Group-Create/Chip"; -import { BOOK_CATEGORIES, BookCategory, PARTICIPANT_LABEL_TO_TYPE, ParticipantLabel, PARTICIPANTS, ParticipantType } from "@/types/groups/groups"; +import { + BOOK_CATEGORIES, + BookCategory, + PARTICIPANT_LABEL_TO_TYPE, + ParticipantLabel, + PARTICIPANTS, + ParticipantType, +} from "@/types/groups/groups"; + +import { mapBookCategoriesToCodes } from "@/types/groups/clubCreate"; +import { useClubNameCheckQuery } from "@/hooks/queries/useCreateClubQueries"; +import { + useCreateClubMutation, + useUploadClubImageMutation, +} from "@/hooks/mutations/useCreateClubMutation"; type NameCheckState = "idle" | "checking" | "available" | "duplicate"; - type SnsLink = { label: string; url: string }; function cx(...classes: (string | false | null | undefined)[]) { @@ -15,26 +31,32 @@ function cx(...classes: (string | false | null | undefined)[]) { } const autoResize = (el: HTMLTextAreaElement) => { - el.style.height = "0px"; // shrink 먼저 + el.style.height = "0px"; const H = el.scrollHeight + 5; - el.style.height = `${H}px`; // 내용만큼 늘리기 + el.style.height = `${H}px`; }; export default function CreateClubWizardPreview() { + const router = useRouter(); + const [step, setStep] = useState(1); // Step 1 const [clubName, setClubName] = useState(""); const [clubDescription, setClubDescription] = useState(""); const [nameCheck, setNameCheck] = useState("idle"); + const DuplicationCheckisConfirmed = nameCheck === "available"; - const DuplicationCheckisDisabled = !clubName.trim() || nameCheck === "checking" || DuplicationCheckisConfirmed ; + const DuplicationCheckisDisabled = + !clubName.trim() || nameCheck === "checking" || DuplicationCheckisConfirmed; // Step 2 const [profileMode, setProfileMode] = useState<"default" | "upload">("default"); const [selectedImageUrl, setSelectedImageUrl] = useState(null); + const [profileImageUrl, setProfileImageUrl] = useState(null); const [visibility, setVisibility] = useState<"공개" | "비공개" | null>(null); const fileRef = useRef(null); + // Step 3 const [selectedCategories, setSelectedCategories] = useState([]); const [selectedParticipants, setSelectedParticipants] = useState([]); @@ -43,38 +65,90 @@ export default function CreateClubWizardPreview() { // Step 4 const [links, setLinks] = useState([{ label: "", url: "" }]); + // API hooks + const nameQuery = useClubNameCheckQuery(clubName); // enabled:false라 버튼에서 refetch로만 호출됨 + const uploadImage = useUploadClubImageMutation(); + const createClub = useCreateClubMutation(); + const canNext = useMemo(() => { if (step === 1) return Boolean(clubName.trim() && clubDescription.trim() && nameCheck === "available"); - if (step === 2) return Boolean(visibility); + + if (step === 2) { + // 공개/비공개는 필수 + if (!visibility) return false; + // 업로드 모드면 업로드 완료(=profileImageUrl 확보)까지 기다리게 + if (profileMode === "upload") return Boolean(profileImageUrl) && !uploadImage.isPending; + return true; + } + if (step === 3) return selectedCategories.length > 0 && selectedParticipants.length > 0 && activityArea.trim(); - if (step === 4) return true; // 선택 + if (step === 4) return true; return false; - }, [step, clubName, clubDescription,nameCheck ,visibility, selectedCategories, selectedParticipants, activityArea]); + }, [ + step, + clubName, + clubDescription, + nameCheck, + visibility, + profileMode, + profileImageUrl, + uploadImage.isPending, + selectedCategories, + selectedParticipants, + activityArea, + ]); const onPrev = () => setStep((v) => Math.max(1, v - 1)); const onNext = () => setStep((v) => Math.min(4, v + 1)); - const fakeCheckName = () => { - if (!clubName.trim()) return; + // 2) 모임 이름 중복 확인 + const onCheckName = async () => { + const name = clubName.trim(); + if (!name) return; + setNameCheck("checking"); - setTimeout(() => { - // 그냥 프리뷰용: 이름에 "중복" 들어가면 duplicate 처리 - if (clubName.includes("중복")) setNameCheck("duplicate"); - else setNameCheck("available"); - }, 500); + + try { + const r = await nameQuery.refetch(); + const isDuplicate = r.data; // boolean + + if (isDuplicate) { + setNameCheck("duplicate"); + toast.error("이미 존재하는 모임 이름입니다."); + } else { + setNameCheck("available"); + toast.success("사용 가능한 모임 이름입니다."); + } + } catch (e) { + setNameCheck("idle"); + toast.error("이름 중복 확인 실패"); + } }; - const pickImage = (file: File) => { + // 이미지 선택: 미리보기 + presigned 업로드 + imageUrl 저장 + const pickImage = async (file: File) => { + // 로컬 미리보기 const reader = new FileReader(); reader.onloadend = () => setSelectedImageUrl(reader.result as string); reader.readAsDataURL(file); + + try { + const imageUrl = await uploadImage.mutateAsync(file); + + setProfileImageUrl(imageUrl); + toast.success("프로필 이미지 업로드 완료"); + } catch (e) { + + setProfileImageUrl(null); + toast.error("이미지 업로드 실패"); + } }; const toggleWithLimit = (arr: T[], item: T, limit: number) => { - if (arr.includes(item)) return arr.filter((x) => x !== item); - if (arr.length >= limit) return arr; - return [...arr, item]; -}; + if (arr.includes(item)) return arr.filter((x) => x !== item); + if (arr.length >= limit) return arr; + return [...arr, item]; + }; const updateLink = (idx: number, patch: Partial) => { setLinks((prev) => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it))); @@ -88,407 +162,405 @@ export default function CreateClubWizardPreview() { setLinks((prev) => prev.filter((_, i) => i !== idx)); }; + // 모임 생성 (최종) + const onSubmitCreateClub = async () => { + try { + const category = mapBookCategoriesToCodes(selectedCategories); + const participantTypes: ParticipantType[] = selectedParticipants.map( + (label) => PARTICIPANT_LABEL_TO_TYPE[label] + ); + + const linksPayload = links + .map((l) => ({ label: l.label.trim(), link: l.url.trim() })) + .filter((l) => l.label && l.link); + + const payload = { + name: clubName.trim(), + description: clubDescription.trim(), + profileImageUrl: profileMode === "upload" ? profileImageUrl : null, + region: activityArea.trim(), + category, + participantTypes, + links: linksPayload, + open: visibility === "공개", + }; + + + const msg = await createClub.mutateAsync(payload); + + toast.success(typeof msg === "string" ? msg : msg?.result ?? "성공"); + router.push("/groups"); + } catch (e) { + toast.error("모임 생성 실패"); + } + }; + return (
- - - {/* breadcrumb */} -
- -

모임

- - - - - - -

새 모임 생성

-
- - -
- {/* step dots */} -
-
- - - - -
+ {/* breadcrumb */} +
+ +

모임

+ + + + +

새 모임 생성

+
+ +
+ {/* step dots */} +
+
+ + + +
+
- {/* 본문 박스 */}
- {/* STEP 1 */} {step === 1 && ( -
-

- 독서 모임 이름을 입력해주세요! -

- -
- { - setClubName(e.target.value); - setNameCheck("idle"); - }} - placeholder="독서 모임 이름을 입력해주세요." - className="w-full h-[44px] t:h-[56px] rounded-[8px] border border-[#EAE5E2] p-4 outline-none bg-white body_1_3 t:subhead_4_1" - /> - - - -
- -
- 다른 이름을 입력하거나, 기수 또는 지역명을 추가해 구분해주세요. -
- 예) 독서재량 2기, 독서재량 서울, 북적북적 인문학팀 -
- -
- {nameCheck === "available" && ( -

사용 가능한 모임 이름입니다.

- )} - {nameCheck === "duplicate" && ( -

이미 존재하는 모임 이름입니다.

- )} -
- -

- 모임의 소개글을 입력해주세요! -

-