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 001/100] =?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 002/100] =?UTF-8?q?fix=20:=20=EC=B1=85=EC=9E=A5,=20?= =?UTF-8?q?=EC=B1=85=EC=9E=A5=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20API=EC=97=90=20=EB=A7=9E=EC=B6=B0=EC=84=9C=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 --- .../[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 003/100] =?UTF-8?q?chore=20:=20dnd-kit=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(=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 004/100] =?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 005/100] =?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 006/100] =?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 007/100] =?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 008/100] =?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 009/100] =?UTF-8?q?feat=20:=20=EC=A1=B0=20=ED=8E=B8?= =?UTF-8?q?=EC=84=B1=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20types=EC=99=80=20dummy=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=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 010/100] =?UTF-8?q?feat=20:=20=EC=A1=B0=20=ED=8E=B8?= =?UTF-8?q?=EC=84=B1=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 011/100] =?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 022/100] =?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 023/100] =?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 024/100] =?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 025/100] =?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 026/100] =?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 027/100] =?UTF-8?q?feat(ui):=20TanStack=20Query=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B1=85=EC=9D=B4=EC=95=BC=EA=B8=B0=20=EB=AC=B4?= =?UTF-8?q?=ED=95=9C=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 028/100] =?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 029/100] =?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 030/100] =?UTF-8?q?fix(queries):=20useQuery=EC=99=80=20use?= =?UTF-8?q?InfiniteQuery=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 444284d3fe02ca6fb5be37a1375666c67644a180 Mon Sep 17 00:00:00 2001 From: nonoididnt Date: Tue, 24 Feb 2026 00:16:10 +0900 Subject: [PATCH 031/100] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20UI?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(public)/admin/login/page.tsx | 97 +++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/app/(public)/admin/login/page.tsx diff --git a/src/app/(public)/admin/login/page.tsx b/src/app/(public)/admin/login/page.tsx new file mode 100644 index 0000000..c5468af --- /dev/null +++ b/src/app/(public)/admin/login/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; +import LoginLogo from "@/components/base-ui/Login/LoginLogo"; +import Toast from "@/components/common/Toast"; + +export default function AdminLoginPage() { + const [toastMsg, setToastMsg] = useState(null); + const [id, setId] = useState(""); + const [pw, setPw] = useState(""); + const isDisabled = !id.trim() || !pw.trim(); + + const handleLogin = (e: React.FormEvent) => { + e.preventDefault(); + + if (isDisabled) return; + + setToastMsg("토스트 테스트"); + }; + + return ( +
+
+ +
+
+ +
+ +
+ 관리자 로그인 +
+
+ +
+ setId(e.target.value)} + className=" + w-full h-[44px] + px-[16px] + rounded-[8px] + border border-[#EAE5E2] + bg-white + text-sm outline-none + " + placeholder="아이디" + /> + + setPw(e.target.value)} + className=" + mt-[8px] + w-full h-[44px] + px-[16px] + rounded-[8px] + border border-[#EAE5E2] + bg-white + text-sm outline-none + " + placeholder="비밀번호" + type="password" + /> + + + +
+ + {/* Toast */} + {toastMsg && ( + setToastMsg(null)} + /> + )} + +
+
+ ); +} \ No newline at end of file From 127755db378909e4ffdaf170166304ed6d11f576 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Tue, 24 Feb 2026 09:24:08 +0900 Subject: [PATCH 032/100] =?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 033/100] =?UTF-8?q?fix(search):=20API=20=EB=AA=85=EC=84=B8?= =?UTF-8?q?(keyword/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 034/100] =?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 035/100] =?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 036/100] 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 037/100] 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 038/100] 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 039/100] 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 040/100] 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 041/100] 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 042/100] 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 043/100] 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 044/100] 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 045/100] 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 046/100] 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 047/100] 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 048/100] 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 049/100] 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 050/100] 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 a58d4fb894624a82a3ecd5176d343bca745df088 Mon Sep 17 00:00:00 2001 From: nonoididnt Date: Wed, 25 Feb 2026 01:00:57 +0900 Subject: [PATCH 051/100] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=C2=B7=EB=AA=A8=EC=9E=84=C2=B7=EC=B1=85?= =?UTF-8?q?=EC=9D=B4=EC=95=BC=EA=B8=B0=C2=B7=EC=86=8C=EC=8B=9D=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(public)/admin/login/page.tsx | 2 +- src/app/admin/groups/page.tsx | 218 ++++++++++++++++++++++ src/app/admin/layout.tsx | 10 + src/app/admin/news/page.tsx | 234 ++++++++++++++++++++++++ src/app/admin/page.tsx | 5 + src/app/admin/stories/page.tsx | 244 +++++++++++++++++++++++++ src/app/admin/users/[id]/page.tsx | 0 src/app/admin/users/page.tsx | 205 +++++++++++++++++++++ src/components/layout/AdminHeader.tsx | 91 +++++++++ src/components/layout/AdminNavItem.tsx | 28 +++ 10 files changed, 1036 insertions(+), 1 deletion(-) create mode 100644 src/app/admin/groups/page.tsx create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/news/page.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/admin/stories/page.tsx create mode 100644 src/app/admin/users/[id]/page.tsx create mode 100644 src/app/admin/users/page.tsx create mode 100644 src/components/layout/AdminHeader.tsx create mode 100644 src/components/layout/AdminNavItem.tsx diff --git a/src/app/(public)/admin/login/page.tsx b/src/app/(public)/admin/login/page.tsx index c5468af..edd5527 100644 --- a/src/app/(public)/admin/login/page.tsx +++ b/src/app/(public)/admin/login/page.tsx @@ -19,7 +19,7 @@ export default function AdminLoginPage() { }; return ( -
+
diff --git a/src/app/admin/groups/page.tsx b/src/app/admin/groups/page.tsx new file mode 100644 index 0000000..e5676a7 --- /dev/null +++ b/src/app/admin/groups/page.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useMemo, useState, useEffect } from "react"; +import Image from "next/image"; + +type GroupRow = { + id: number; + name: string; + ownerEmail: string; + createdAt: string; + memberCount: number; +}; + +export default function GroupsPage() { + const [keyword, setKeyword] = useState(""); + const [page, setPage] = useState(1); + + // 더미 데이터 (테스트용) - 100개 모임 생성 + const groups: GroupRow[] = useMemo(() => { + const base = [ + { name: "러닝 크루", ownerEmail: "run@club.com" }, + { name: "독서 모임", ownerEmail: "book@club.com" }, + { name: "헬스 메이트", ownerEmail: "gym@club.com" }, + { name: "맛집 탐방", ownerEmail: "foodie@club.com" }, + { name: "여행 동행", ownerEmail: "trip@club.com" }, + ]; + + const toDate = (i: number) => { + const y = i % 2 === 0 ? 2025 : 2026; + const m = y === 2025 ? 10 + (i % 3) : 1 + (i % 2); + const d = 1 + (i % 28); + return `${y}.${String(m).padStart(2, "0")}.${String(d).padStart(2, "0")}`; + }; + + return Array.from({ length: 100 }).map((_, i) => { + const b = base[i % base.length]; + return { + id: 100 + i, + name: `${b.name} ${i + 1}`, + ownerEmail: b.ownerEmail, + createdAt: toDate(i), + memberCount: 5 + (i % 97), // 5~101 + }; + }); + }, []); + + const pageSize = 20; + + const filtered = useMemo(() => { + const q = keyword.trim().toLowerCase(); + if (!q) return groups; + return groups.filter((g) => { + return ( + String(g.id).includes(q) || + g.name.toLowerCase().includes(q) || + g.ownerEmail.toLowerCase().includes(q) + ); + }); + }, [groups, keyword]); + + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + + // 검색어 바뀌면 1페이지로 + useEffect(() => { + setPage(1); + }, [keyword]); + + const pageItems = useMemo(() => { + const start = (page - 1) * pageSize; + return filtered.slice(start, start + pageSize); + }, [filtered, page]); + + const handleSearch = () => { + console.log("검색:", keyword); + setPage(1); + }; + + const goTo = (p: number) => { + const next = Math.min(Math.max(1, p), totalPages); + setPage(next); + }; + + const pageButtons = useMemo(() => { + const max = 5; + let start = Math.max(1, page - Math.floor(max / 2)); + let end = start + max - 1; + + if (end > totalPages) { + end = totalPages; + start = Math.max(1, end - max + 1); + } + return Array.from({ length: end - start + 1 }).map((_, idx) => start + idx); + }, [page, totalPages]); + + return ( +
+
+

+ 모임 관리 +

+ +
+ setKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + placeholder="검색 하기 (모임 명)" + className="w-[1040px] h-[56px] rounded-[8px] border border-[#EAE5E2] bg-white pl-4 pr-14" + /> + +
+ + {/* 라인형 테이블 */} +
+ + + + + + + + + + + + + + + + + + + + + + + {pageItems.map((g) => ( + + + + + + + + + ))} + +
모임 ID이름개설자 이메일생성 일자가입자 수상세보기
{g.id}{g.name}{g.ownerEmail}{g.createdAt}{g.memberCount} + +
+ + {/* 페이지네이션 */} +
+ + + {pageButtons.map((p) => ( + + ))} + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..0064d0a --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,10 @@ +import AdminHeader from "@/components/layout/AdminHeader"; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
{children}
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/news/page.tsx b/src/app/admin/news/page.tsx new file mode 100644 index 0000000..fccfd3a --- /dev/null +++ b/src/app/admin/news/page.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useMemo, useState, useEffect } from "react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; + +type NewsRow = { + id: number; + title: string; + authorEmail: string; + createdAt: string; + postedAt: string; +}; + +export default function NewsPage() { + const router = useRouter(); + + const [keyword, setKeyword] = useState(""); + const [page, setPage] = useState(1); + + // 더미 데이터 (테스트용) - 100개 생성 + const newsList: NewsRow[] = useMemo(() => { + const base = [ + { title: "서비스 업데이트 안내", authorEmail: "yh9839@naver.com" }, + { title: "이벤트 오픈 공지", authorEmail: "minsu@test.com" }, + { title: "점검 일정 안내", authorEmail: "jieun@test.com" }, + { title: "신규 기능 출시", authorEmail: "seoyeon@test.com" }, + { title: "운영 정책 변경", authorEmail: "daeun@test.com" }, + ]; + + const toDate = (i: number, offsetDays = 0) => { + const y = i % 2 === 0 ? 2025 : 2026; + const m = y === 2025 ? 10 + (i % 3) : 1 + (i % 2); + const d = 1 + ((i + offsetDays) % 28); + return `${y}.${String(m).padStart(2, "0")}.${String(d).padStart(2, "0")}`; + }; + + return Array.from({ length: 100 }).map((_, i) => { + const b = base[i % base.length]; + + const start = toDate(i, 0); + const end = toDate(i, 7); + + return { + id: 100 + i, + title: `${b.title} ${i + 1}`, + authorEmail: b.authorEmail, + createdAt: toDate(i, 0), + postedAt: `${start} - ${end}`, + }; + }); + }, []); + + const pageSize = 20; + + const filtered = useMemo(() => { + const q = keyword.trim().toLowerCase(); + if (!q) return newsList; + + return newsList.filter((n) => { + return ( + String(n.id).includes(q) || + n.title.toLowerCase().includes(q) || + n.authorEmail.toLowerCase().includes(q) + ); + }); + }, [newsList, keyword]); + + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + + useEffect(() => { + setPage(1); + }, [keyword]); + + const pageItems = useMemo(() => { + const start = (page - 1) * pageSize; + return filtered.slice(start, start + pageSize); + }, [filtered, page]); + + const handleSearch = () => { + console.log("검색:", keyword); + setPage(1); + }; + + const goTo = (p: number) => { + const next = Math.min(Math.max(1, p), totalPages); + setPage(next); + }; + + const pageButtons = useMemo(() => { + const max = 5; + let start = Math.max(1, page - Math.floor(max / 2)); + let end = start + max - 1; + if (end > totalPages) { + end = totalPages; + start = Math.max(1, end - max + 1); + } + return Array.from({ length: end - start + 1 }).map((_, idx) => start + idx); + }, [page, totalPages]); + + return ( +
+
+

+ 소식 관리 +

+ +
+
+ setKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + placeholder="검색 하기 (소식 제목)" + className="w-full h-[56px] rounded-[8px] border border-[#EAE5E2] bg-white pl-4 pr-14" + /> + +
+ + +
+ + {/* 라인형 테이블 */} +
+ + + + + + + + + + + + + + + + + + + + + + + {pageItems.map((n) => ( + + + + + + + + + ))} + +
소식 ID소식 제목등록자 이메일등록 일자게시날짜상세보기
{n.id}{n.title}{n.authorEmail}{n.createdAt}{n.postedAt} + +
+ + {/* 페이지네이션 */} +
+ + + {pageButtons.map((p) => ( + + ))} + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..6cc7623 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AdminIndex() { + redirect("/admin/users"); +} \ No newline at end of file diff --git a/src/app/admin/stories/page.tsx b/src/app/admin/stories/page.tsx new file mode 100644 index 0000000..fcb224e --- /dev/null +++ b/src/app/admin/stories/page.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useMemo, useState, useEffect } from "react"; +import Image from "next/image"; + +type BookStoryRow = { + id: number; + title: string; + authorEmail: string; + bookTitle: string; + postedAt: string; + status: "등록" | "임시저장"; +}; + +export default function BookStoriesPage() { + const [keyword, setKeyword] = useState(""); + const [page, setPage] = useState(1); + + // 더미 데이터 (테스트용) - 100개 생성 + const stories: BookStoryRow[] = useMemo(() => { + const base = [ + { + title: "삶을 바꾸는 문장들", + authorEmail: "yh9839@naver.com", + bookTitle: "어린 왕자", + }, + { + title: "끝까지 읽게 되는 이유", + authorEmail: "minsu@test.com", + bookTitle: "데미안", + }, + { + title: "마음이 가벼워지는 독서", + authorEmail: "jieun@test.com", + bookTitle: "미움받을 용기", + }, + { + title: "기록하는 독서 습관", + authorEmail: "seoyeon@test.com", + bookTitle: "아토믹 해빗", + }, + { + title: "다시 시작하는 용기", + authorEmail: "daeun@test.com", + bookTitle: "나미야 잡화점의 기적", + }, + ]; + + const toDate = (i: number) => { + const y = i % 2 === 0 ? 2025 : 2026; + const m = y === 2025 ? 10 + (i % 3) : 1 + (i % 2); + const d = 1 + (i % 28); + return `${y}.${String(m).padStart(2, "0")}.${String(d).padStart(2, "0")}`; + }; + + return Array.from({ length: 100 }).map((_, i) => { + const b = base[i % base.length]; + return { + id: 100 + i, + title: `${b.title} ${i + 1}`, + authorEmail: b.authorEmail, + bookTitle: b.bookTitle, + postedAt: toDate(i), + status: i % 4 === 0 ? "임시저장" : "등록", + }; + }); + }, []); + + const pageSize = 20; + + const filtered = useMemo(() => { + const q = keyword.trim().toLowerCase(); + if (!q) return stories; + + return stories.filter((s) => { + return ( + String(s.id).includes(q) || + s.title.toLowerCase().includes(q) || + s.authorEmail.toLowerCase().includes(q) || + s.bookTitle.toLowerCase().includes(q) || + s.status.toLowerCase().includes(q) + ); + }); + }, [stories, keyword]); + + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + + useEffect(() => { + setPage(1); + }, [keyword]); + + const pageItems = useMemo(() => { + const start = (page - 1) * pageSize; + return filtered.slice(start, start + pageSize); + }, [filtered, page]); + + const handleSearch = () => { + console.log("검색:", keyword); + setPage(1); + }; + + const goTo = (p: number) => { + const next = Math.min(Math.max(1, p), totalPages); + setPage(next); + }; + + const pageButtons = useMemo(() => { + const max = 5; + let start = Math.max(1, page - Math.floor(max / 2)); + let end = start + max - 1; + if (end > totalPages) { + end = totalPages; + start = Math.max(1, end - max + 1); + } + return Array.from({ length: end - start + 1 }).map((_, idx) => start + idx); + }, [page, totalPages]); + + return ( +
+
+

+ 책 이야기 관리 +

+ +
+ setKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + placeholder="검색 하기 (책이야기 제목)" + className="w-[1040px] h-[56px] rounded-[8px] border border-[#EAE5E2] bg-white pl-4 pr-14" + /> + +
+ + {/* 라인형 테이블 */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + {pageItems.map((s) => ( + + + + + + + + + + ))} + +
책이야기 ID책이야기 제목등록자 이메일책 제목게시날짜등록여부상세보기
{s.id}{s.title}{s.authorEmail}{s.bookTitle}{s.postedAt}{s.status} + +
+ + {/* 페이지네이션 */} +
+ + + {pageButtons.map((p) => ( + + ))} + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/users/[id]/page.tsx b/src/app/admin/users/[id]/page.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 0000000..ab2abf3 --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useMemo, useState, useEffect } from "react"; +import Image from "next/image"; + +type UserRow = { + id: string; + name: string; + email: string; + phone: string; +}; + +export default function UsersPage() { + const [keyword, setKeyword] = useState(""); + const [page, setPage] = useState(1); + + // 더미 데이터 (테스트용) - 100명 생성 + const users: UserRow[] = useMemo(() => { + const base = [ + { name: "윤현일", email: "yh9839@naver.com", phone: "010-1234-5678" }, + { name: "김민수", email: "minsu@test.com", phone: "010-2222-3333" }, + { name: "박지은", email: "jieun@test.com", phone: "010-4444-5555" }, + { name: "이서연", email: "seoyeon@test.com", phone: "010-7777-8888" }, + { name: "정다은", email: "daeun@test.com", phone: "010-9999-0000" }, + ]; + return Array.from({ length: 100 }).map((_, i) => { + const b = base[i % base.length]; + const num = String(716 + i).padStart(4, "0"); + return { + id: `hy_${num}`, + name: b.name, + email: b.email, + phone: b.phone, + }; + }); + }, []); + + const pageSize = 20; + + const filtered = useMemo(() => { + const q = keyword.trim().toLowerCase(); + if (!q) return users; + return users.filter((u) => { + return ( + u.id.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q) || + u.name.toLowerCase().includes(q) + ); + }); + }, [users, keyword]); + + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + + // 검색어 바뀌면 1페이지로 + useEffect(() => { + setPage(1); + }, [keyword]); + + const pageItems = useMemo(() => { + const start = (page - 1) * pageSize; + return filtered.slice(start, start + pageSize); + }, [filtered, page]); + + const handleSearch = () => { + console.log("검색:", keyword); + setPage(1); + }; + + const goTo = (p: number) => { + const next = Math.min(Math.max(1, p), totalPages); + setPage(next); + }; + + const pageButtons = useMemo(() => { + const max = 5; + let start = Math.max(1, page - Math.floor(max / 2)); + let end = start + max - 1; + if (end > totalPages) { + end = totalPages; + start = Math.max(1, end - max + 1); + } + return Array.from({ length: end - start + 1 }).map((_, idx) => start + idx); + }, [page, totalPages]); + + return ( +
+
+

+ 회원 관리 +

+ +
+ setKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + placeholder="검색 하기 (아이디 또는 이메일)" + className="w-[840px] h-[56px] rounded-[8px] border border-[#EAE5E2] bg-white pl-4 pr-14" + /> + +
+ + {/* 라인형 테이블 */} +
+ + + + + + + + + + + + + + + + + + + + + {pageItems.map((u) => ( + + + + + + + + ))} + +
ID이름이메일전화번호상세보기
{u.id}{u.name}{u.email}{u.phone} + +
+ + {/* 페이지네이션 */} +
+ + + {pageButtons.map((p) => ( + + ))} + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/layout/AdminHeader.tsx b/src/components/layout/AdminHeader.tsx new file mode 100644 index 0000000..a7df5fc --- /dev/null +++ b/src/components/layout/AdminHeader.tsx @@ -0,0 +1,91 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import Image from "next/image"; +import { NavItem } from "./AdminNavItem"; + +const ADMIN_NAV = [ + { label: "회원 관리", href: "/admin/users" }, + { label: "모임 관리", href: "/admin/groups" }, + { label: "책 이야기 관리", href: "/admin/stories" }, + { label: "소식 관리", href: "/admin/news" }, +]; + +// 관리자: 현재 경로에 맞는 타이틀 +const getAdminPageTitle = (pathname: string) => { + if (pathname.startsWith("/admin/users")) return "회원 관리"; + if (pathname.startsWith("/admin/groups")) return "모임 관리"; + if (pathname.startsWith("/admin/stories")) return "책 이야기 관리"; + if (pathname.startsWith("/admin/news")) return "소식 관리"; + return "관리자"; +}; + +export default function AdminHeader() { + const pathname = usePathname(); + const pageTitle = getAdminPageTitle(pathname); + + return ( +
+
+
+ {/* 로고 + 메뉴 */} +
+ + 책모 로고 + + + {/* 태블릿부터: 네비게이션 메뉴 */} + +
+ + {/* 모바일: 중앙 타이틀 표시 */} + + {pageTitle} + + + {/* 아이콘 */} +
+ + 프로필 + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/layout/AdminNavItem.tsx b/src/components/layout/AdminNavItem.tsx new file mode 100644 index 0000000..d037264 --- /dev/null +++ b/src/components/layout/AdminNavItem.tsx @@ -0,0 +1,28 @@ +import Link from "next/link"; + +interface NavItemProps { + href: string; + label: string; + active: boolean; +} + +export function NavItem({ href, label, active }: NavItemProps) { + return ( + + + {label} + + + ); +} \ No newline at end of file From 6c6e67cfd11c75de0855778e9e8dd995d1be3e66 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 10:07:31 +0900 Subject: [PATCH 052/100] feat: implement profile image upload preview on mypage --- src/app/(main)/setting/profile/page.tsx | 29 ++++++++++++++++++- .../EditProfile/ProfileImageSection.tsx | 28 ++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/app/(main)/setting/profile/page.tsx b/src/app/(main)/setting/profile/page.tsx index 02ff2ad..60aa478 100644 --- a/src/app/(main)/setting/profile/page.tsx +++ b/src/app/(main)/setting/profile/page.tsx @@ -17,12 +17,19 @@ export default function ProfileEditPage() { user?.categories || [] ); + // Profile Image Upload States + const [profileImageFile, setProfileImageFile] = useState(null); + const [previewImage, setPreviewImage] = useState( + user?.profileImageUrl || null + ); + useEffect(() => { if (user) { setNickname(user.nickname || ""); setIntro(user.description || ""); setName(user.nickname || ""); setSelectedCategories(user.categories || []); + setPreviewImage(user.profileImageUrl || null); } }, [user]); @@ -36,6 +43,23 @@ export default function ProfileEditPage() { ); }; + const handleImageUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setProfileImageFile(file); + const reader = new FileReader(); + reader.onloadend = () => { + setPreviewImage(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + const handleResetImage = () => { + setProfileImageFile(null); + setPreviewImage(null); // or set to default image URL if needed + }; + const handleSave = () => { console.log("Saving profile changes:", { nickname, @@ -43,6 +67,7 @@ export default function ProfileEditPage() { name, phone, selectedCategories, + profileImageFile, }); // TODO: Connect to backend API for profile update toast.success("프로필 정보가 저장되었습니다."); @@ -67,7 +92,9 @@ export default function ProfileEditPage() { {/* 닉네임 */} diff --git a/src/components/base-ui/Settings/EditProfile/ProfileImageSection.tsx b/src/components/base-ui/Settings/EditProfile/ProfileImageSection.tsx index 8e61187..4c3c4b9 100644 --- a/src/components/base-ui/Settings/EditProfile/ProfileImageSection.tsx +++ b/src/components/base-ui/Settings/EditProfile/ProfileImageSection.tsx @@ -2,18 +2,29 @@ "use client"; import Image from "next/image"; +import { useRef } from "react"; interface Props { nickname?: string; intro?: string; - profileImageUrl?: string; + previewImage?: string | null; + onUpload: (e: React.ChangeEvent) => void; + onReset: () => void; } export default function ProfileImageSection({ nickname, intro, - profileImageUrl, + previewImage, + onUpload, + onReset, }: Props) { + const fileInputRef = useRef(null); + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + return (
{/* 이미지 및 정보 영역 */} @@ -27,13 +38,22 @@ export default function ProfileImageSection({ h-[100px] w-[100px] xl:h-[138px] xl:w-[138px]" > 프로필 이미지
+ {/* 숨김 처리된 파일 입력창 */} + + {/* 텍스트 정보 */}
From a5b5335523fc1f1d57e4e59b39dac55261b6af0b Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 10:28:01 +0900 Subject: [PATCH 054/100] feat: integrate update profile API and disable name field on settings page --- src/app/(main)/setting/profile/page.tsx | 31 ++++++++------ src/hooks/mutations/useMemberMutations.ts | 52 +++++++++++++++++++++++ src/lib/api/endpoints/member.ts | 1 + src/services/memberService.ts | 11 ++++- src/types/member.ts | 7 +++ 5 files changed, 88 insertions(+), 14 deletions(-) create mode 100644 src/hooks/mutations/useMemberMutations.ts diff --git a/src/app/(main)/setting/profile/page.tsx b/src/app/(main)/setting/profile/page.tsx index 46942eb..4afd486 100644 --- a/src/app/(main)/setting/profile/page.tsx +++ b/src/app/(main)/setting/profile/page.tsx @@ -7,6 +7,7 @@ import SettingsDetailLayout from "@/components/base-ui/Settings/SettingsDetailLa import { useState, useEffect } from "react"; import toast from "react-hot-toast"; import { authService } from "@/services/authService"; // API call +import { useUpdateProfileMutation } from "@/hooks/mutations/useMemberMutations"; export default function ProfileEditPage() { const { user } = useAuthStore(); @@ -27,10 +28,13 @@ export default function ProfileEditPage() { // Nickname Duplicate Check State const [isNicknameChecked, setIsNicknameChecked] = useState(true); // default true for existing user + const { mutate: updateProfile, isPending: isUpdating } = useUpdateProfileMutation(); + useEffect(() => { if (user) { setNickname(user.nickname || ""); setIntro(user.description || ""); + // 백엔드 명세상 email 외에 name 속성이 별도로 user 데이터에 있다면 그걸 쓰는게 맞지만, 현재 auth의 User 타입엔 없으므로 일단 nickname setName(user.nickname || ""); setSelectedCategories(user.categories || []); setPreviewImage(user.profileImageUrl || null); @@ -114,23 +118,23 @@ export default function ProfileEditPage() { return; } - console.log("Saving profile changes:", { + updateProfile({ nickname, - intro, - name, - phone, - selectedCategories, + description: intro, + categories: selectedCategories, profileImageFile, + }, { + onSuccess: () => { + toast.success("프로필 정보가 저장되었습니다."); + } }); - // TODO: Connect to backend API for profile update - toast.success("프로필 정보가 저장되었습니다."); }; // 공통 스타일 상수 const inputContainerClass = "flex items-center gap-[10px] rounded-[8px] border border-Subbrown-4 bg-White px-[16px] py-[12px] h-[36px] md:h-[52px]"; const inputClass = - "w-full bg-transparent outline-none text-Gray-7 placeholder:text-Gray-3 text-[12px] font-normal leading-[145%] tracking-[-0.012px] md:body_1_3"; + "w-full bg-transparent outline-none text-Gray-7 placeholder:text-Gray-3 text-[12px] font-normal leading-[145%] tracking-[-0.012px] md:body_1_3 disabled:text-Gray-4 disabled:bg-transparent"; const checkBtnClass = "flex items-center justify-center gap-[10px] rounded-[8px] border border-Subbrown-3 bg-Subbrown-4 h-[36px] w-[67px] md:h-[52px] md:w-[98px] xl:w-[132px]"; const checkBtnTextClass = @@ -189,11 +193,11 @@ export default function ProfileEditPage() { {/* 이름 */}
-
+
setName(e.target.value)} + disabled={true} placeholder="이름을 입력해주세요" />
@@ -220,11 +224,12 @@ export default function ProfileEditPage() { {/* 저장 버튼 */}
diff --git a/src/hooks/mutations/useMemberMutations.ts b/src/hooks/mutations/useMemberMutations.ts new file mode 100644 index 0000000..cbdb27d --- /dev/null +++ b/src/hooks/mutations/useMemberMutations.ts @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { memberService } from "@/services/memberService"; +import { authService } from "@/services/authService"; +import { useAuthStore } from "@/store/useAuthStore"; +import toast from "react-hot-toast"; + +interface UpdateProfilePayload { + nickname: string; + description: string; + categories: string[]; + profileImageFile: File | null; +} + +export const useUpdateProfileMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: UpdateProfilePayload) => { + let profileImageUrl = undefined; + + // 1. Image Upload (If file is selected) + if (payload.profileImageFile) { + // Assume default mime if not present + const contentType = payload.profileImageFile.type || "image/jpeg"; + // Get Pre-signed URL + const presignedRes = await authService.getPresignedUrl("PROFILE", payload.profileImageFile.name, contentType); + + // Upload to S3 + await authService.uploadToS3(presignedRes.result!.presignedUrl, payload.profileImageFile); + + // Use the returned image URL for update + profileImageUrl = presignedRes.result!.imageUrl; + } + + // 2. Update Profile Information + await memberService.updateProfile({ + nickname: payload.nickname, + description: payload.description, + categories: payload.categories, + profileImageUrl, + }); + }, + onSuccess: () => { + // Refresh the page to reload user data into auth store properly based on top level layout + window.location.reload(); + queryClient.invalidateQueries({ queryKey: ["member", "me"] }); + }, + onError: (error: any) => { + console.error("Failed to update profile:", error); + }, + }); +}; diff --git a/src/lib/api/endpoints/member.ts b/src/lib/api/endpoints/member.ts index 6d1a535..7c8cf35 100644 --- a/src/lib/api/endpoints/member.ts +++ b/src/lib/api/endpoints/member.ts @@ -2,4 +2,5 @@ import { API_BASE_URL } from "./base"; export const MEMBER_ENDPOINTS = { RECOMMEND: `${API_BASE_URL}/members/me/recommend`, + UPDATE_PROFILE: `${API_BASE_URL}/members/me`, }; diff --git a/src/services/memberService.ts b/src/services/memberService.ts index fa4a4fa..7499c2c 100644 --- a/src/services/memberService.ts +++ b/src/services/memberService.ts @@ -1,6 +1,6 @@ import { apiClient } from "@/lib/api/client"; import { MEMBER_ENDPOINTS } from "@/lib/api/endpoints/member"; -import { RecommendResponse } from "@/types/member"; +import { RecommendResponse, UpdateProfileRequest } from "@/types/member"; import { ApiResponse } from "@/types/auth"; export const memberService = { @@ -10,4 +10,13 @@ export const memberService = { ); return response.result!; }, + updateProfile: async (data: UpdateProfileRequest): Promise => { + const response = await apiClient.patch>( + MEMBER_ENDPOINTS.UPDATE_PROFILE, + data + ); + if (!response.isSuccess) { + throw new Error(response.message || "Failed to update profile"); + } + }, }; diff --git a/src/types/member.ts b/src/types/member.ts index bb76c9f..a54f4c7 100644 --- a/src/types/member.ts +++ b/src/types/member.ts @@ -6,3 +6,10 @@ export interface RecommendedMember { export interface RecommendResponse { friends: RecommendedMember[]; } + +export interface UpdateProfileRequest { + nickname: string; + description: string; + categories: string[]; + profileImageUrl?: string; +} From bb0d8b82a13b6b19868a5f5514837b5e14881db8 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 10:40:42 +0900 Subject: [PATCH 055/100] fix: enforce description length and preserve existing profileImage --- src/app/(main)/setting/profile/page.tsx | 4 +++- src/hooks/mutations/useMemberMutations.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/(main)/setting/profile/page.tsx b/src/app/(main)/setting/profile/page.tsx index 4afd486..28b674c 100644 --- a/src/app/(main)/setting/profile/page.tsx +++ b/src/app/(main)/setting/profile/page.tsx @@ -120,9 +120,10 @@ export default function ProfileEditPage() { updateProfile({ nickname, - description: intro, + description: intro.slice(0, 20), categories: selectedCategories, profileImageFile, + currentProfileImageUrl: previewImage, }, { onSuccess: () => { toast.success("프로필 정보가 저장되었습니다."); @@ -186,6 +187,7 @@ export default function ProfileEditPage() { value={intro} onChange={(e) => setIntro(e.target.value)} placeholder="20자 이내로 작성해주세요" + maxLength={20} />
diff --git a/src/hooks/mutations/useMemberMutations.ts b/src/hooks/mutations/useMemberMutations.ts index cbdb27d..5e631bf 100644 --- a/src/hooks/mutations/useMemberMutations.ts +++ b/src/hooks/mutations/useMemberMutations.ts @@ -9,6 +9,7 @@ interface UpdateProfilePayload { description: string; categories: string[]; profileImageFile: File | null; + currentProfileImageUrl: string | null; } export const useUpdateProfileMutation = () => { @@ -16,7 +17,7 @@ export const useUpdateProfileMutation = () => { return useMutation({ mutationFn: async (payload: UpdateProfilePayload) => { - let profileImageUrl = undefined; + let profileImageUrl = payload.currentProfileImageUrl || undefined; // 1. Image Upload (If file is selected) if (payload.profileImageFile) { @@ -30,6 +31,9 @@ export const useUpdateProfileMutation = () => { // Use the returned image URL for update profileImageUrl = presignedRes.result!.imageUrl; + } else if (payload.currentProfileImageUrl && payload.currentProfileImageUrl.startsWith("blob:")) { + // In case blob URL leaked without file, though shouldn't happen, clear it + profileImageUrl = undefined; } // 2. Update Profile Information @@ -37,7 +41,7 @@ export const useUpdateProfileMutation = () => { nickname: payload.nickname, description: payload.description, categories: payload.categories, - profileImageUrl, + profileImageUrl: profileImageUrl || "", // Backend might expect empty string for default }); }, onSuccess: () => { From 7874150eaeccded388e12f47a8632dc3c92a52a3 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 10:44:59 +0900 Subject: [PATCH 056/100] fix: align UpdateProfile payload with Swagger by removing nickname and correcting imgUrl key --- src/app/(main)/setting/profile/page.tsx | 1 - src/hooks/mutations/useMemberMutations.ts | 10 ++++------ src/types/member.ts | 3 +-- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/app/(main)/setting/profile/page.tsx b/src/app/(main)/setting/profile/page.tsx index 28b674c..f8ff338 100644 --- a/src/app/(main)/setting/profile/page.tsx +++ b/src/app/(main)/setting/profile/page.tsx @@ -119,7 +119,6 @@ export default function ProfileEditPage() { } updateProfile({ - nickname, description: intro.slice(0, 20), categories: selectedCategories, profileImageFile, diff --git a/src/hooks/mutations/useMemberMutations.ts b/src/hooks/mutations/useMemberMutations.ts index 5e631bf..f9637c1 100644 --- a/src/hooks/mutations/useMemberMutations.ts +++ b/src/hooks/mutations/useMemberMutations.ts @@ -5,7 +5,6 @@ import { useAuthStore } from "@/store/useAuthStore"; import toast from "react-hot-toast"; interface UpdateProfilePayload { - nickname: string; description: string; categories: string[]; profileImageFile: File | null; @@ -17,7 +16,7 @@ export const useUpdateProfileMutation = () => { return useMutation({ mutationFn: async (payload: UpdateProfilePayload) => { - let profileImageUrl = payload.currentProfileImageUrl || undefined; + let imgUrl = payload.currentProfileImageUrl || undefined; // 1. Image Upload (If file is selected) if (payload.profileImageFile) { @@ -30,18 +29,17 @@ export const useUpdateProfileMutation = () => { await authService.uploadToS3(presignedRes.result!.presignedUrl, payload.profileImageFile); // Use the returned image URL for update - profileImageUrl = presignedRes.result!.imageUrl; + imgUrl = presignedRes.result!.imageUrl; } else if (payload.currentProfileImageUrl && payload.currentProfileImageUrl.startsWith("blob:")) { // In case blob URL leaked without file, though shouldn't happen, clear it - profileImageUrl = undefined; + imgUrl = undefined; } // 2. Update Profile Information await memberService.updateProfile({ - nickname: payload.nickname, description: payload.description, categories: payload.categories, - profileImageUrl: profileImageUrl || "", // Backend might expect empty string for default + imgUrl: imgUrl || "", // Backend might expect empty string for default }); }, onSuccess: () => { diff --git a/src/types/member.ts b/src/types/member.ts index a54f4c7..2451b11 100644 --- a/src/types/member.ts +++ b/src/types/member.ts @@ -8,8 +8,7 @@ export interface RecommendResponse { } export interface UpdateProfileRequest { - nickname: string; description: string; categories: string[]; - profileImageUrl?: string; + imgUrl?: string; } From d918caab849dc5ba2fccbba7bdb8935fbef3e128 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 10:49:03 +0900 Subject: [PATCH 057/100] fix: use centralized CATEGORIES enum in profile settings to match Swagger spec and prevent 400 Bad Request --- .../Settings/EditProfile/CategorySelector.tsx | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/src/components/base-ui/Settings/EditProfile/CategorySelector.tsx b/src/components/base-ui/Settings/EditProfile/CategorySelector.tsx index 0e23368..ee67fda 100644 --- a/src/components/base-ui/Settings/EditProfile/CategorySelector.tsx +++ b/src/components/base-ui/Settings/EditProfile/CategorySelector.tsx @@ -1,23 +1,7 @@ // src/components/base-ui/Settings/EditProfile/CategorySelector.tsx "use client"; -const CATEGORIES = [ - { label: "소설/시/희곡", value: "FICTION_POETRY_DRAMA" }, - { label: "에세이", value: "ESSAY" }, - { label: "인문학", value: "HUMANITIES" }, - { label: "경영/경제", value: "ECONOMY_MANAGEMENT" }, - { label: "자기계발", value: "SELF_DEVELOPMENT" }, - { label: "사회과학", value: "SOCIAL_SCIENCE" }, - { label: "역사", value: "HISTORY_CULTURE" }, - { label: "예술/대중문화", value: "ART_POP_CULTURE" }, - { label: "만화", value: "COMIC" }, - { label: "장르소설", value: "GENRE_FICTION" }, - { label: "과학", value: "SCIENCE" }, - { label: "어린이/청소년", value: "CHILDREN_BOOKS" }, - { label: "여행", value: "TRAVEL" }, - { label: "요리", value: "COOKING" }, - { label: "기타", value: "OTHER" }, -]; +import { CATEGORIES } from "@/constants/categories"; interface Props { selectedCategories?: string[]; // Enum string values from backend From 950db9325880b2dd27c1c5d54d86ec8e2eba256d Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 10:59:17 +0900 Subject: [PATCH 058/100] feat: integrate password change api with client validation --- src/app/(main)/setting/password/page.tsx | 69 +++++++++++++++++-- .../base-ui/Settings/SettingsInputGroup.tsx | 6 ++ src/hooks/mutations/useMemberMutations.ts | 13 ++++ src/lib/api/endpoints/member.ts | 1 + src/services/memberService.ts | 11 ++- src/types/member.ts | 6 ++ 6 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/app/(main)/setting/password/page.tsx b/src/app/(main)/setting/password/page.tsx index 000322b..7c116f9 100644 --- a/src/app/(main)/setting/password/page.tsx +++ b/src/app/(main)/setting/password/page.tsx @@ -1,10 +1,55 @@ +"use client"; + import SettingsInputGroup from "@/components/base-ui/Settings/SettingsInputGroup"; import SettingsDetailLayout from "@/components/base-ui/Settings/SettingsDetailLayout"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { useUpdatePasswordMutation } from "@/hooks/mutations/useMemberMutations"; export default function PasswordChangePage() { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + const { mutate: updatePassword, isPending } = useUpdatePasswordMutation(); + + const handleSave = () => { + if (!currentPassword || !newPassword || !confirmPassword) { + toast.error("모든 필드를 입력해주세요."); + return; + } + + if (newPassword !== confirmPassword) { + toast.error("새 비밀번호와 비밀번호 확인이 일치하지 않습니다."); + return; + } + + const passwordRegex = /^(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{6,12}$/; + if (!passwordRegex.test(newPassword)) { + toast.error("비밀번호는 영문자, 특수문자를 포함하여 6~12자여야 합니다."); + return; + } + + updatePassword( + { currentPassword, newPassword, confirmPassword }, + { + onSuccess: () => { + toast.success("비밀번호가 성공적으로 변경되었습니다."); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + }, + onError: (error: any) => { + // Check backend error response format if available, otherwise generic + toast.error(error.message || "비밀번호 변경에 실패했습니다. 올바른 기존 비밀번호인지 확인하세요."); + }, + } + ); + }; + const buttonStyle = - "flex h-[48px] items-center justify-center gap-[10px] rounded-[8px] bg-Gray-1 px-[16px] py-[12px] w-[120px] md:w-[200px]"; - const buttonTextStyle = "body_1_1 text-Gray-3"; + "flex h-[48px] items-center justify-center gap-[10px] rounded-[8px] bg-primary-1 px-[16px] py-[12px] w-[120px] md:w-[200px] disabled:opacity-50"; + const buttonTextStyle = "body_1_1 text-White"; const inputContainerClass = "flex h-[52px] w-full items-center gap-[10px] rounded-[8px] border border-Subbrown-4 bg-White px-[16px] py-[12px]"; @@ -16,27 +61,39 @@ export default function PasswordChangePage() { label="기존 비밀번호" placeholder="기존 비밀번호를 입력해주세요" type="password" + value={currentPassword} + onChange={(e) => setCurrentPassword(e.target.value)} />
setNewPassword(e.target.value)} /> {/* 비밀번호 확인 인풋 */}
setConfirmPassword(e.target.value)} />
-
diff --git a/src/components/base-ui/Settings/SettingsInputGroup.tsx b/src/components/base-ui/Settings/SettingsInputGroup.tsx index 26d6ff9..7e47196 100644 --- a/src/components/base-ui/Settings/SettingsInputGroup.tsx +++ b/src/components/base-ui/Settings/SettingsInputGroup.tsx @@ -3,12 +3,16 @@ type Props = { label: string; placeholder: string; type?: "text" | "password"; + value?: string; + onChange?: React.ChangeEventHandler; }; export default function SettingsInputGroup({ label, placeholder, type = "text", + value, + onChange, }: Props) { return (
@@ -21,6 +25,8 @@ export default function SettingsInputGroup({ type={type} className="w-full bg-transparent outline-none body_1_3 text-Gray-7 placeholder:text-Gray-3" placeholder={placeholder} + value={value} + onChange={onChange} />
diff --git a/src/hooks/mutations/useMemberMutations.ts b/src/hooks/mutations/useMemberMutations.ts index f9637c1..da06958 100644 --- a/src/hooks/mutations/useMemberMutations.ts +++ b/src/hooks/mutations/useMemberMutations.ts @@ -52,3 +52,16 @@ export const useUpdateProfileMutation = () => { }, }); }; + +import { UpdatePasswordRequest } from "@/types/member"; + +export const useUpdatePasswordMutation = () => { + return useMutation({ + mutationFn: async (payload: UpdatePasswordRequest) => { + await memberService.updatePassword(payload); + }, + onError: (error: any) => { + console.error("Failed to update password:", error); + }, + }); +}; diff --git a/src/lib/api/endpoints/member.ts b/src/lib/api/endpoints/member.ts index 7c8cf35..bb85e2d 100644 --- a/src/lib/api/endpoints/member.ts +++ b/src/lib/api/endpoints/member.ts @@ -3,4 +3,5 @@ import { API_BASE_URL } from "./base"; export const MEMBER_ENDPOINTS = { RECOMMEND: `${API_BASE_URL}/members/me/recommend`, UPDATE_PROFILE: `${API_BASE_URL}/members/me`, + UPDATE_PASSWORD: `${API_BASE_URL}/members/me/update-password`, }; diff --git a/src/services/memberService.ts b/src/services/memberService.ts index 7499c2c..78b1b36 100644 --- a/src/services/memberService.ts +++ b/src/services/memberService.ts @@ -1,6 +1,6 @@ import { apiClient } from "@/lib/api/client"; import { MEMBER_ENDPOINTS } from "@/lib/api/endpoints/member"; -import { RecommendResponse, UpdateProfileRequest } from "@/types/member"; +import { RecommendResponse, UpdateProfileRequest, UpdatePasswordRequest } from "@/types/member"; import { ApiResponse } from "@/types/auth"; export const memberService = { @@ -19,4 +19,13 @@ export const memberService = { throw new Error(response.message || "Failed to update profile"); } }, + updatePassword: async (data: UpdatePasswordRequest): Promise => { + const response = await apiClient.patch>( + MEMBER_ENDPOINTS.UPDATE_PASSWORD, + data + ); + if (!response.isSuccess) { + throw new Error(response.message || "Failed to update password"); + } + }, }; diff --git a/src/types/member.ts b/src/types/member.ts index 2451b11..b7a1c18 100644 --- a/src/types/member.ts +++ b/src/types/member.ts @@ -12,3 +12,9 @@ export interface UpdateProfileRequest { categories: string[]; imgUrl?: string; } + +export interface UpdatePasswordRequest { + currentPassword?: string; + newPassword?: string; + confirmPassword?: string; +} From 8f85c82ecaf0008ddb86f0bb3b1d34f9ca9da744 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 11:02:53 +0900 Subject: [PATCH 059/100] fix: resolve React uncontrolled input warning by providing fallback value string --- src/components/base-ui/Settings/SettingsInputGroup.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/base-ui/Settings/SettingsInputGroup.tsx b/src/components/base-ui/Settings/SettingsInputGroup.tsx index 7e47196..0108a35 100644 --- a/src/components/base-ui/Settings/SettingsInputGroup.tsx +++ b/src/components/base-ui/Settings/SettingsInputGroup.tsx @@ -19,13 +19,12 @@ export default function SettingsInputGroup({ {/* 라벨: body_1_2, Primary_3 */} - {/* 인풋 컨테이너: h 52px, border Subbrown_4 */}
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 060/100] 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 89869cc1976c1af228ec7bbfc7beb03f74dc14af Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 11:26:19 +0900 Subject: [PATCH 061/100] style: set SettingsDetailLayout mode to wide in setting/news to align cards with title width --- src/app/(main)/setting/news/page.tsx | 91 ++++++++++++++++------------ 1 file changed, 52 insertions(+), 39 deletions(-) diff --git a/src/app/(main)/setting/news/page.tsx b/src/app/(main)/setting/news/page.tsx index de1f6d5..e7ef91c 100644 --- a/src/app/(main)/setting/news/page.tsx +++ b/src/app/(main)/setting/news/page.tsx @@ -1,53 +1,66 @@ "use client"; +import { useEffect } from "react"; import NewsList from "@/components/base-ui/News/news_list"; import SettingsDetailLayout from "@/components/base-ui/Settings/SettingsDetailLayout"; - -// UI 확인을 위한 Mock Data -const MOCK_NEWS = [ - { - id: 1, - title: "새로운 독서 모임이 등록되었습니다.", - content: - "회원님의 관심 카테고리인 '인문학' 분야의 새로운 모임 [철학으로 아침을 여는 사람들]이 개설되었습니다. 지금 바로 확인해보세요!", - date: "2025-10-09", - imageUrl: "/dummy_book_1.png", // public 폴더 내 이미지 경로 - }, - { - id: 2, - title: "10월 독서 마라톤 챌린지 안내", - content: - "풍성한 가을을 맞아 독서 마라톤 챌린지가 시작됩니다. 완주하신 분들께는 한정판 뱃지와 포인트가 지급됩니다. 자세한 내용은 공지사항을 참고해주세요.", - date: "2025-10-01", - imageUrl: "/dummy_book_2.png", - }, - { - id: 3, - title: "시스템 점검 안내 (10/15 02:00 ~ 06:00)", - content: - "더 안정적인 서비스 제공을 위해 서버 점검이 예정되어 있습니다. 점검 시간에는 서비스 이용이 제한되오니 양해 부탁드립니다.", - date: "2025-09-28", - imageUrl: "/dummy_book_3.png", - }, -]; +import { useInfiniteNewsQuery } from "@/hooks/queries/useNewsQueries"; +import { useInView } from "react-intersection-observer"; export default function MyNewsPage() { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useInfiniteNewsQuery(); + + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + // Flatten the pages to a single array of basicInfoList + const newsList = data?.pages.flatMap((page) => page.basicInfoList) || []; + return ( - {MOCK_NEWS.map((news) => ( - - ))} +
+
+ {isLoading &&

로딩 중...

} + + {!isLoading && newsList.length === 0 && ( +

등록된 소식이 없습니다.

+ )} + + {newsList.map((news) => ( + + ))} + + {/* Infinite Scroll Trigger */} +
+ + {isFetchingNextPage && ( +

추가 소식을 불러오는 중...

+ )} +
+
); } From 5c592e2cc4a67d7d0fc033d4c2c4a9677aecf492 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 11:36:58 +0900 Subject: [PATCH 062/100] feat: integrate notification settings GET and PATCH api with controlled toggles --- src/app/(main)/setting/notifications/page.tsx | 109 ++++++++++++------ src/app/(main)/setting/password/page.tsx | 4 +- .../Notification/NotificationItem.tsx | 24 ++-- .../base-ui/Settings/SettingsInputGroup.tsx | 1 + src/hooks/mutations/useMemberMutations.ts | 2 - .../mutations/useNotificationMutations.ts | 22 ++++ src/hooks/queries/useNewsQueries.ts | 22 ++++ src/hooks/queries/useNotificationQueries.ts | 14 +++ src/lib/api/endpoints/news.ts | 5 + src/lib/api/endpoints/notification.ts | 6 + src/services/newsService.ts | 18 +++ src/services/notificationService.ts | 21 ++++ src/types/news.ts | 14 +++ src/types/notification.ts | 16 +++ 14 files changed, 224 insertions(+), 54 deletions(-) create mode 100644 src/hooks/mutations/useNotificationMutations.ts create mode 100644 src/hooks/queries/useNewsQueries.ts create mode 100644 src/hooks/queries/useNotificationQueries.ts create mode 100644 src/lib/api/endpoints/news.ts create mode 100644 src/lib/api/endpoints/notification.ts create mode 100644 src/services/newsService.ts create mode 100644 src/services/notificationService.ts create mode 100644 src/types/news.ts create mode 100644 src/types/notification.ts diff --git a/src/app/(main)/setting/notifications/page.tsx b/src/app/(main)/setting/notifications/page.tsx index aad4ca8..a1425b6 100644 --- a/src/app/(main)/setting/notifications/page.tsx +++ b/src/app/(main)/setting/notifications/page.tsx @@ -1,50 +1,87 @@ "use client"; import NotificationItem from "@/components/base-ui/Settings/Notification/NotificationItem"; import SettingsDetailLayout from "@/components/base-ui/Settings/SettingsDetailLayout"; +import { useNotificationSettingsQuery } from "@/hooks/queries/useNotificationQueries"; +import { useToggleNotificationMutation } from "@/hooks/mutations/useNotificationMutations"; +import { NotificationSettingType } from "@/types/notification"; export default function NotificationPage() { + const { data: settings, isLoading } = useNotificationSettingsQuery(); + const { mutate: toggleSetting, isPending } = useToggleNotificationMutation(); + + const handleToggle = (settingType: NotificationSettingType) => { + toggleSetting(settingType); + }; + return ( - {/* 책모 알림 */} -
-

책모 알림

-
- - - -
-
+ {/* 로딩 영역 */} + {isLoading ? ( +
로딩 중...
+ ) : ( + <> + {/* 책모 알림 */} +
+

책모 알림

+
+ handleToggle("BOOK_STORY_LIKED")} + disabled={isPending} + /> + handleToggle("BOOK_STORY_COMMENT")} + disabled={isPending} + /> + handleToggle("NEW_FOLLOWER")} + disabled={isPending} + /> +
+
- {/* 독서 모임 알림 */} -
-

독서 모임 알림

-
- - - -
-
+ {/* 독서 모임 알림 */} +
+

독서 모임 알림

+
+ handleToggle("JOIN_CLUB")} + disabled={isPending} + /> + handleToggle("CLUB_NOTICE_CREATED")} + disabled={isPending} + /> + handleToggle("CLUB_MEETING_CREATED")} + disabled={isPending} + /> +
+
+ + )}
); } diff --git a/src/app/(main)/setting/password/page.tsx b/src/app/(main)/setting/password/page.tsx index 7c116f9..88e79d1 100644 --- a/src/app/(main)/setting/password/page.tsx +++ b/src/app/(main)/setting/password/page.tsx @@ -68,7 +68,7 @@ export default function PasswordChangePage() {
setNewPassword(e.target.value)} @@ -77,7 +77,7 @@ export default function PasswordChangePage() {
setConfirmPassword(e.target.value)} diff --git a/src/components/base-ui/Settings/Notification/NotificationItem.tsx b/src/components/base-ui/Settings/Notification/NotificationItem.tsx index 4eee8bb..c22b990 100644 --- a/src/components/base-ui/Settings/Notification/NotificationItem.tsx +++ b/src/components/base-ui/Settings/Notification/NotificationItem.tsx @@ -1,33 +1,29 @@ "use client"; -import { useState } from "react"; - type Props = { title: string; description: string; - initialChecked?: boolean; + isChecked?: boolean; + onToggle?: () => void; + disabled?: boolean; }; export default function NotificationItem({ title, description, - initialChecked = true, + isChecked = false, + onToggle, + disabled = false, }: Props) { - const [isChecked, setIsChecked] = useState(initialChecked); - - const handleToggle = () => { - setIsChecked((prev) => !prev); - }; - return ( // 전체 아이템 컨테이너
diff --git a/src/components/base-ui/Settings/SettingsInputGroup.tsx b/src/components/base-ui/Settings/SettingsInputGroup.tsx index 0108a35..b8a6b29 100644 --- a/src/components/base-ui/Settings/SettingsInputGroup.tsx +++ b/src/components/base-ui/Settings/SettingsInputGroup.tsx @@ -19,6 +19,7 @@ export default function SettingsInputGroup({ {/* 라벨: body_1_2, Primary_3 */} + {/* 인풋 컨테이너: h 52px, border Subbrown_4 */}
{ + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (settingType: NotificationSettingType) => { + await notificationService.updateSetting(settingType); + }, + onSuccess: () => { + // Invalidate the settings query to refetch fresh data + queryClient.invalidateQueries({ queryKey: notificationKeys.settings() }); + }, + onError: (error: any) => { + toast.error(error.message || "알림 설정 변경에 실패했습니다."); + }, + }); +}; diff --git a/src/hooks/queries/useNewsQueries.ts b/src/hooks/queries/useNewsQueries.ts new file mode 100644 index 0000000..f7409ad --- /dev/null +++ b/src/hooks/queries/useNewsQueries.ts @@ -0,0 +1,22 @@ +import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; +import { newsService } from "@/services/newsService"; +import { NewsListResponse } from "@/types/news"; + +export const newsKeys = { + all: ["news"] as const, + list: () => [...newsKeys.all, "list"] as const, + infiniteList: () => [...newsKeys.all, "infiniteList"] as const, +}; + +export const useInfiniteNewsQuery = () => { + return useInfiniteQuery({ + queryKey: newsKeys.infiniteList(), + // 임시 MOCK 데이터 함수 연결 (운영 배포 전 newsService.getNewsList 로 복구) + queryFn: ({ pageParam }) => newsService.getNewsList(pageParam ?? undefined), + initialPageParam: null as number | null, + getNextPageParam: (lastPage) => { + if (!lastPage || !lastPage.hasNext) return undefined; + return lastPage.nextCursor; + }, + }); +}; diff --git a/src/hooks/queries/useNotificationQueries.ts b/src/hooks/queries/useNotificationQueries.ts new file mode 100644 index 0000000..27bcd16 --- /dev/null +++ b/src/hooks/queries/useNotificationQueries.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { notificationService } from "@/services/notificationService"; + +export const notificationKeys = { + all: ["notifications"] as const, + settings: () => [...notificationKeys.all, "settings"] as const, +}; + +export const useNotificationSettingsQuery = () => { + return useQuery({ + queryKey: notificationKeys.settings(), + queryFn: () => notificationService.getSettings(), + }); +}; diff --git a/src/lib/api/endpoints/news.ts b/src/lib/api/endpoints/news.ts new file mode 100644 index 0000000..acf55c1 --- /dev/null +++ b/src/lib/api/endpoints/news.ts @@ -0,0 +1,5 @@ +import { API_BASE_URL } from "./base"; + +export const NEWS_ENDPOINTS = { + GET_NEWS_LIST: `${API_BASE_URL}/news`, +}; diff --git a/src/lib/api/endpoints/notification.ts b/src/lib/api/endpoints/notification.ts new file mode 100644 index 0000000..456986a --- /dev/null +++ b/src/lib/api/endpoints/notification.ts @@ -0,0 +1,6 @@ +import { API_BASE_URL } from "./base"; + +export const NOTIFICATION_ENDPOINTS = { + GET_SETTINGS: `${API_BASE_URL}/notifications/settings`, + UPDATE_SETTING: (settingType: string) => `${API_BASE_URL}/notifications/settings/${settingType}`, +}; diff --git a/src/services/newsService.ts b/src/services/newsService.ts new file mode 100644 index 0000000..3140a3f --- /dev/null +++ b/src/services/newsService.ts @@ -0,0 +1,18 @@ +import { apiClient } from "@/lib/api/client"; +import { NEWS_ENDPOINTS } from "@/lib/api/endpoints/news"; +import { NewsListResponse } from "@/types/news"; +import { ApiResponse } from "@/types/auth"; + +export const newsService = { + getNewsList: async (cursorId?: number): Promise => { + const url = new URL(NEWS_ENDPOINTS.GET_NEWS_LIST); + if (cursorId) { + url.searchParams.append("cursorId", cursorId.toString()); + } + + const response = await apiClient.get>( + url.toString() + ); + return response.result!; + }, +}; diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts new file mode 100644 index 0000000..ad0c5bc --- /dev/null +++ b/src/services/notificationService.ts @@ -0,0 +1,21 @@ +import { apiClient } from "@/lib/api/client"; +import { NOTIFICATION_ENDPOINTS } from "@/lib/api/endpoints/notification"; +import { NotificationSettings, NotificationSettingType } from "@/types/notification"; +import { ApiResponse } from "@/types/auth"; + +export const notificationService = { + getSettings: async (): Promise => { + const response = await apiClient.get>( + NOTIFICATION_ENDPOINTS.GET_SETTINGS + ); + return response.result!; + }, + updateSetting: async (settingType: NotificationSettingType): Promise => { + const response = await apiClient.patch>( + NOTIFICATION_ENDPOINTS.UPDATE_SETTING(settingType) + ); + if (!response.isSuccess) { + throw new Error(response.message || "Failed to update notification setting"); + } + }, +}; diff --git a/src/types/news.ts b/src/types/news.ts new file mode 100644 index 0000000..7e7d3ca --- /dev/null +++ b/src/types/news.ts @@ -0,0 +1,14 @@ +export interface NewsBasicInfo { + newsId: number; + title: string; + description: string; + thumbnailUrl: string; + publishStartAt: string; +} + +export interface NewsListResponse { + basicInfoList: NewsBasicInfo[]; + hasNext: boolean; + nextCursor: number | null; + pageSize: number; +} diff --git a/src/types/notification.ts b/src/types/notification.ts new file mode 100644 index 0000000..164e805 --- /dev/null +++ b/src/types/notification.ts @@ -0,0 +1,16 @@ +export type NotificationSettingType = + | "BOOK_STORY_LIKED" + | "BOOK_STORY_COMMENT" + | "CLUB_NOTICE_CREATED" + | "CLUB_MEETING_CREATED" + | "NEW_FOLLOWER" + | "JOIN_CLUB"; + +export interface NotificationSettings { + bookStoryLiked: boolean; + bookStoryComment: boolean; + clubNoticeCreated: boolean; + clubMeetingCreated: boolean; + newFollower: boolean; + joinClub: boolean; +} From 58dac3bf974f8ac43b33d2faa1d6ce579d9dce9b Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 11:43:30 +0900 Subject: [PATCH 063/100] fix: allow s3 bucket hostname in next.config.ts to resolve next/image error --- next.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/next.config.ts b/next.config.ts index c6b5d61..baf92c4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -9,6 +9,10 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "image.aladin.co.kr", }, + { + protocol: "https", + hostname: "checkmo-s3-presigned.s3.ap-northeast-2.amazonaws.com", + }, ], }, }; From 9efe72a52af1b565cb6dc525e3086d19c37e26f5 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 11:49:47 +0900 Subject: [PATCH 064/100] fix: reflect global user profile image in header and feed cards --- src/app/(main)/page.tsx | 2 ++ src/app/(main)/stories/page.tsx | 2 ++ src/components/layout/Header.tsx | 6 ++++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index 5d0fc5b..17e0b6f 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -79,6 +79,7 @@ export default function HomePage() { 프로필 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 065/100] =?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 3f8d4170a7d19a75a9f6114f4b48c9a48950032d Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 13:02:27 +0900 Subject: [PATCH 066/100] fix: add missing profile image props to tablet breakpoint cards in home feed --- src/app/(main)/page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index 17e0b6f..25d8436 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -122,6 +122,7 @@ export default function HomePage() { Date: Wed, 25 Feb 2026 13:09:21 +0900 Subject: [PATCH 067/100] refactor: apply code review feedback for profile mutation UX, notification UI sync, and news error state --- src/app/(main)/setting/news/page.tsx | 6 +++++- src/app/(main)/setting/notifications/page.tsx | 6 ++---- src/hooks/mutations/useMemberMutations.ts | 13 ++++++++++--- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/app/(main)/setting/news/page.tsx b/src/app/(main)/setting/news/page.tsx index e7ef91c..48dd93e 100644 --- a/src/app/(main)/setting/news/page.tsx +++ b/src/app/(main)/setting/news/page.tsx @@ -37,7 +37,11 @@ export default function MyNewsPage() {
{isLoading &&

로딩 중...

} - {!isLoading && newsList.length === 0 && ( + {!isLoading && isError && ( +

소식을 불러오는 데 실패했습니다.

+ )} + + {!isLoading && !isError && newsList.length === 0 && (

등록된 소식이 없습니다.

)} diff --git a/src/app/(main)/setting/notifications/page.tsx b/src/app/(main)/setting/notifications/page.tsx index a1425b6..2281695 100644 --- a/src/app/(main)/setting/notifications/page.tsx +++ b/src/app/(main)/setting/notifications/page.tsx @@ -70,10 +70,8 @@ export default function NotificationPage() { disabled={isPending} /> handleToggle("CLUB_MEETING_CREATED")} disabled={isPending} diff --git a/src/hooks/mutations/useMemberMutations.ts b/src/hooks/mutations/useMemberMutations.ts index 752d0c2..c0a51d0 100644 --- a/src/hooks/mutations/useMemberMutations.ts +++ b/src/hooks/mutations/useMemberMutations.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { memberService } from "@/services/memberService"; import { authService } from "@/services/authService"; +import { useAuthStore } from "@/store/useAuthStore"; interface UpdateProfilePayload { description: string; @@ -40,10 +41,16 @@ export const useUpdateProfileMutation = () => { imgUrl: imgUrl || "", // Backend might expect empty string for default }); }, - onSuccess: () => { - // Refresh the page to reload user data into auth store properly based on top level layout - window.location.reload(); + onSuccess: async () => { queryClient.invalidateQueries({ queryKey: ["member", "me"] }); + // Fetch the updated profile and sync it to the global auth store so the Header updates + const response = await authService.getProfile(); + if (response.isSuccess && response.result) { + useAuthStore.getState().login({ + ...response.result, + email: response.result.email || "", + }); + } }, onError: (error: any) => { console.error("Failed to update profile:", error); From 6244197a0a382ec2d252c6dc27d53b2c65fbf77c Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 13:13:27 +0900 Subject: [PATCH 068/100] =?UTF-8?q?refactor:=20=EC=95=8C=EB=A6=BC=20descre?= =?UTF-8?q?iption=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)/setting/notifications/page.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/(main)/setting/notifications/page.tsx b/src/app/(main)/setting/notifications/page.tsx index 2281695..a1425b6 100644 --- a/src/app/(main)/setting/notifications/page.tsx +++ b/src/app/(main)/setting/notifications/page.tsx @@ -70,8 +70,10 @@ export default function NotificationPage() { disabled={isPending} /> handleToggle("CLUB_MEETING_CREATED")} disabled={isPending} From bc9117c525add136c4525aca5671987cc946f9dd Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 13:29:25 +0900 Subject: [PATCH 069/100] feat: integrate My Book Stories API with infinite scroll and conditional buttons --- .../base-ui/BookStory/bookstory_card.tsx | 18 +++--- .../base-ui/MyPage/MyBookStoryList.tsx | 59 +++++++++++++++---- src/hooks/queries/useStoryQueries.ts | 13 ++++ src/lib/api/endpoints/bookstory.ts | 1 + src/services/storyService.ts | 9 +++ 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/components/base-ui/BookStory/bookstory_card.tsx b/src/components/base-ui/BookStory/bookstory_card.tsx index 2e8dbb7..496ede4 100644 --- a/src/components/base-ui/BookStory/bookstory_card.tsx +++ b/src/components/base-ui/BookStory/bookstory_card.tsx @@ -14,6 +14,7 @@ type Props = { commentCount?: number; onSubscribeClick?: () => void; subscribeText?: string; + hideSubscribeButton?: boolean; }; import { formatTimeAgo } from "@/utils/time"; @@ -30,6 +31,7 @@ export default function BookStoryCard({ commentCount = 1, onSubscribeClick, subscribeText = "구독", + hideSubscribeButton = false, }: Props) { return (
- + {!hideSubscribeButton && ( + + )}
{/* 2. 책 이미지 (모바일: flex-1 / 데스크탑: h-36) */} diff --git a/src/components/base-ui/MyPage/MyBookStoryList.tsx b/src/components/base-ui/MyPage/MyBookStoryList.tsx index 758ef03..43d93d3 100644 --- a/src/components/base-ui/MyPage/MyBookStoryList.tsx +++ b/src/components/base-ui/MyPage/MyBookStoryList.tsx @@ -1,28 +1,67 @@ "use client"; -import React from "react"; +import React, { useEffect } from "react"; import BookStoryCard from "@/components/base-ui/BookStory/bookstory_card"; -import { DUMMY_MY_STORIES } from "@/constants/mocks/mypage"; +import { useMyInfiniteStoriesQuery } from "@/hooks/queries/useStoryQueries"; +import { useInView } from "react-intersection-observer"; const MyBookStoryList = () => { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useMyInfiniteStoriesQuery(); + + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const stories = data?.pages.flatMap((page) => page.basicInfoList) || []; + return (
+ + {isLoading &&

로딩 중...

} + + {!isLoading && isError && ( +

책 이야기를 불러오는 데 실패했습니다.

+ )} + + {!isLoading && !isError && stories.length === 0 && ( +

작성한 책 이야기가 없습니다.

+ )} +
- {DUMMY_MY_STORIES.map((story) => ( + {stories.map((story) => ( ))}
+ + {/* Infinite Scroll Trigger */} +
+ + {isFetchingNextPage && ( +

추가 이야기를 불러오는 중...

+ )}
); }; diff --git a/src/hooks/queries/useStoryQueries.ts b/src/hooks/queries/useStoryQueries.ts index 94c6871..e68cbeb 100644 --- a/src/hooks/queries/useStoryQueries.ts +++ b/src/hooks/queries/useStoryQueries.ts @@ -6,6 +6,7 @@ export const storyKeys = { all: ["stories"] as const, list: () => [...storyKeys.all, "list"] as const, infiniteList: () => [...storyKeys.all, "infiniteList"] as const, + myList: () => [...storyKeys.all, "myList"] as const, detail: (id: number) => [...storyKeys.all, "detail", id] as const, }; @@ -35,3 +36,15 @@ export const useInfiniteStoriesQuery = () => { }, }); }; + +export const useMyInfiniteStoriesQuery = () => { + return useInfiniteQuery({ + queryKey: storyKeys.myList(), + queryFn: ({ pageParam }) => storyService.getMyStories(pageParam ?? undefined), + initialPageParam: null as number | null, + getNextPageParam: (lastPage) => { + if (!lastPage || !lastPage.hasNext) return undefined; + return lastPage.nextCursor; + }, + }); +}; diff --git a/src/lib/api/endpoints/bookstory.ts b/src/lib/api/endpoints/bookstory.ts index 7f13100..a88bb46 100644 --- a/src/lib/api/endpoints/bookstory.ts +++ b/src/lib/api/endpoints/bookstory.ts @@ -2,4 +2,5 @@ import { API_BASE_URL } from "./base"; export const STORY_ENDPOINTS = { LIST: `${API_BASE_URL}/book-stories`, + ME: `${API_BASE_URL}/book-stories/me`, }; diff --git a/src/services/storyService.ts b/src/services/storyService.ts index 9ff936e..b0e7d35 100644 --- a/src/services/storyService.ts +++ b/src/services/storyService.ts @@ -13,6 +13,15 @@ export const storyService = { ); return response.result!; }, + getMyStories: async (cursorId?: number): Promise => { + const response = await apiClient.get>( + STORY_ENDPOINTS.ME, + { + params: { cursorId }, + } + ); + return response.result!; + }, getStoryById: async (id: number): Promise => { const response = await apiClient.get>( `${STORY_ENDPOINTS.LIST}/${id}` From 1984edcf780d8dffb4c115f438532c3c356d0566 Mon Sep 17 00:00:00 2001 From: nonoididnt Date: Wed, 25 Feb 2026 14:25:22 +0900 Subject: [PATCH 070/100] chore: remove unfinished admin users detail route --- src/app/admin/users/[id]/page.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/app/admin/users/[id]/page.tsx diff --git a/src/app/admin/users/[id]/page.tsx b/src/app/admin/users/[id]/page.tsx deleted file mode 100644 index e69de29..0000000 From acd6e47c19a48374941d49b24e5cdbef60a85a63 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 14:34:02 +0900 Subject: [PATCH 071/100] =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=A7=81=20=EB=B0=A9=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../base-ui/MyPage/MyBookStoryList.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/base-ui/MyPage/MyBookStoryList.tsx b/src/components/base-ui/MyPage/MyBookStoryList.tsx index 43d93d3..6af632b 100644 --- a/src/components/base-ui/MyPage/MyBookStoryList.tsx +++ b/src/components/base-ui/MyPage/MyBookStoryList.tsx @@ -39,18 +39,18 @@ const MyBookStoryList = () => { )}
- {stories.map((story) => ( + {stories.map((story, index) => ( ))} From c602f72ac05256913cdeb07f4c90c567e3ecc66d Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 14:52:36 +0900 Subject: [PATCH 072/100] feat: integrate My Clubs API into MyMeetingList --- .../base-ui/MyPage/MyMeetingList.tsx | 35 +++++++++++++++++-- .../base-ui/MyPage/items/MyMeetingCard.tsx | 8 ++--- src/hooks/queries/useClubQueries.ts | 14 ++++++++ src/lib/api/endpoints/club.ts | 5 +++ src/services/clubService.ts | 13 +++++++ src/types/club.ts | 8 +++++ 6 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 src/hooks/queries/useClubQueries.ts create mode 100644 src/lib/api/endpoints/club.ts create mode 100644 src/services/clubService.ts create mode 100644 src/types/club.ts diff --git a/src/components/base-ui/MyPage/MyMeetingList.tsx b/src/components/base-ui/MyPage/MyMeetingList.tsx index e2cdf63..d126fb4 100644 --- a/src/components/base-ui/MyPage/MyMeetingList.tsx +++ b/src/components/base-ui/MyPage/MyMeetingList.tsx @@ -1,14 +1,43 @@ "use client"; import React from "react"; -import { DUMMY_MEETINGS } from "@/constants/mocks/mypage"; import MyMeetingCard from "./items/MyMeetingCard"; +import { useMyClubsQuery } from "@/hooks/queries/useClubQueries"; const MyMeetingList = () => { + const { data, isLoading, isError } = useMyClubsQuery(); + const clubs = data?.clubList || []; + + if (isLoading) { + return ( +
+ 불러오는 중... +
+ ); + } + + if (isError) { + return ( +
+ 독서 모임을 불러오는 데 실패했습니다. +
+ ); + } + + if (clubs.length === 0) { + return ( +
+

+ 가입한 독서 모임이 없습니다. +

+
+ ); + } + return (
- {DUMMY_MEETINGS.map((meeting) => ( - + {clubs.map((club) => ( + ))}
); diff --git a/src/components/base-ui/MyPage/items/MyMeetingCard.tsx b/src/components/base-ui/MyPage/items/MyMeetingCard.tsx index 0f5c99c..adb6de6 100644 --- a/src/components/base-ui/MyPage/items/MyMeetingCard.tsx +++ b/src/components/base-ui/MyPage/items/MyMeetingCard.tsx @@ -2,17 +2,17 @@ import React from "react"; import Image from "next/image"; -import { MyPageMeeting } from "@/types/mypage"; +import { MyClubInfo } from "@/types/club"; interface MyMeetingCardProps { - meeting: MyPageMeeting; + club: MyClubInfo; } -const MyMeetingCard = ({ meeting }: MyMeetingCardProps) => { +const MyMeetingCard = ({ club }: MyMeetingCardProps) => { return (
- {meeting.title} + {club.clubName} -
+ {/* 라인형 테이블 */}
diff --git a/src/app/admin/layout.tsx b/src/app/(admin)/admin/(app)/layout.tsx similarity index 100% rename from src/app/admin/layout.tsx rename to src/app/(admin)/admin/(app)/layout.tsx diff --git a/src/app/admin/news/page.tsx b/src/app/(admin)/admin/(app)/news/page.tsx similarity index 83% rename from src/app/admin/news/page.tsx rename to src/app/(admin)/admin/(app)/news/page.tsx index fccfd3a..d2a75c8 100644 --- a/src/app/admin/news/page.tsx +++ b/src/app/(admin)/admin/(app)/news/page.tsx @@ -1,9 +1,10 @@ "use client"; -import { useMemo, useState, useEffect } from "react"; -import Image from "next/image"; +import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; +import AdminSearchHeader from "@/components/layout/AdminSearchHeader"; + type NewsRow = { id: number; title: string; @@ -18,6 +19,11 @@ export default function NewsPage() { const [keyword, setKeyword] = useState(""); const [page, setPage] = useState(1); + const handleKeywordChange = (v: string) => { + setKeyword(v); + setPage(1); + }; + // 더미 데이터 (테스트용) - 100개 생성 const newsList: NewsRow[] = useMemo(() => { const base = [ @@ -37,7 +43,6 @@ export default function NewsPage() { return Array.from({ length: 100 }).map((_, i) => { const b = base[i % base.length]; - const start = toDate(i, 0); const end = toDate(i, 7); @@ -68,14 +73,10 @@ export default function NewsPage() { const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); - useEffect(() => { - setPage(1); - }, [keyword]); - const pageItems = useMemo(() => { const start = (page - 1) * pageSize; return filtered.slice(start, start + pageSize); - }, [filtered, page]); + }, [filtered, page, pageSize]); const handleSearch = () => { console.log("검색:", keyword); @@ -101,36 +102,23 @@ export default function NewsPage() { return (
-

- 소식 관리 -

- -
-
- setKeyword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - placeholder="검색 하기 (소식 제목)" - className="w-full h-[56px] rounded-[8px] border border-[#EAE5E2] bg-white pl-4 pr-14" - /> + router.push("/admin/news/new")} + className="flex-shrink-0 flex w-[187px] h-[48px] px-[16px] py-[12px] items-center justify-center gap-[10px] rounded-[8px] bg-[#7B6154] text-white text-[14px] font-semibold hover:opacity-90" > - 검색 + 소식 등록 -
- - -
+ } + /> {/* 라인형 테이블 */}
diff --git a/src/app/admin/page.tsx b/src/app/(admin)/admin/(app)/page.tsx similarity index 100% rename from src/app/admin/page.tsx rename to src/app/(admin)/admin/(app)/page.tsx diff --git a/src/app/admin/stories/page.tsx b/src/app/(admin)/admin/(app)/stories/page.tsx similarity index 88% rename from src/app/admin/stories/page.tsx rename to src/app/(admin)/admin/(app)/stories/page.tsx index fcb224e..7353fc4 100644 --- a/src/app/admin/stories/page.tsx +++ b/src/app/(admin)/admin/(app)/stories/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useMemo, useState, useEffect } from "react"; -import Image from "next/image"; +import { useMemo, useState } from "react"; +import AdminSearchHeader from "@/components/layout/AdminSearchHeader"; type BookStoryRow = { id: number; @@ -16,6 +16,11 @@ export default function BookStoriesPage() { const [keyword, setKeyword] = useState(""); const [page, setPage] = useState(1); + const handleKeywordChange = (v: string) => { + setKeyword(v); + setPage(1); + }; + // 더미 데이터 (테스트용) - 100개 생성 const stories: BookStoryRow[] = useMemo(() => { const base = [ @@ -85,14 +90,10 @@ export default function BookStoriesPage() { const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); - useEffect(() => { - setPage(1); - }, [keyword]); - const pageItems = useMemo(() => { const start = (page - 1) * pageSize; return filtered.slice(start, start + pageSize); - }, [filtered, page]); + }, [filtered, page, pageSize]); const handleSearch = () => { console.log("검색:", keyword); @@ -118,26 +119,14 @@ export default function BookStoriesPage() { return (
-

- 책 이야기 관리 -

- -
- setKeyword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - placeholder="검색 하기 (책이야기 제목)" - className="w-[1040px] h-[56px] rounded-[8px] border border-[#EAE5E2] bg-white pl-4 pr-14" - /> - -
+ {/* 라인형 테이블 */}
diff --git a/src/app/admin/users/page.tsx b/src/app/(admin)/admin/(app)/users/page.tsx similarity index 85% rename from src/app/admin/users/page.tsx rename to src/app/(admin)/admin/(app)/users/page.tsx index ab2abf3..8c9df1a 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/(admin)/admin/(app)/users/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useMemo, useState, useEffect } from "react"; -import Image from "next/image"; +import { useMemo, useState } from "react"; +import AdminSearchHeader from "@/components/layout/AdminSearchHeader"; type UserRow = { id: string; @@ -14,6 +14,11 @@ export default function UsersPage() { const [keyword, setKeyword] = useState(""); const [page, setPage] = useState(1); + const handleKeywordChange = (v: string) => { + setKeyword(v); + setPage(1); + }; + // 더미 데이터 (테스트용) - 100명 생성 const users: UserRow[] = useMemo(() => { const base = [ @@ -51,15 +56,10 @@ export default function UsersPage() { const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); - // 검색어 바뀌면 1페이지로 - useEffect(() => { - setPage(1); - }, [keyword]); - const pageItems = useMemo(() => { const start = (page - 1) * pageSize; return filtered.slice(start, start + pageSize); - }, [filtered, page]); + }, [filtered, page, pageSize]); const handleSearch = () => { console.log("검색:", keyword); @@ -85,26 +85,14 @@ export default function UsersPage() { return (
-

- 회원 관리 -

- -
- setKeyword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - placeholder="검색 하기 (아이디 또는 이메일)" - className="w-[840px] h-[56px] rounded-[8px] border border-[#EAE5E2] bg-white pl-4 pr-14" - /> - -
+ {/* 라인형 테이블 */}
diff --git a/src/app/(public)/admin/login/page.tsx b/src/app/(admin)/admin/(auth)/login/page.tsx similarity index 100% rename from src/app/(public)/admin/login/page.tsx rename to src/app/(admin)/admin/(auth)/login/page.tsx diff --git a/src/components/layout/AdminHeader.tsx b/src/components/layout/AdminHeader.tsx index a7df5fc..d30506c 100644 --- a/src/components/layout/AdminHeader.tsx +++ b/src/components/layout/AdminHeader.tsx @@ -12,13 +12,11 @@ const ADMIN_NAV = [ { label: "소식 관리", href: "/admin/news" }, ]; -// 관리자: 현재 경로에 맞는 타이틀 const getAdminPageTitle = (pathname: string) => { - if (pathname.startsWith("/admin/users")) return "회원 관리"; - if (pathname.startsWith("/admin/groups")) return "모임 관리"; - if (pathname.startsWith("/admin/stories")) return "책 이야기 관리"; - if (pathname.startsWith("/admin/news")) return "소식 관리"; - return "관리자"; + const item = ADMIN_NAV.find((n) => + pathname === n.href || pathname.startsWith(n.href + "/") + ); + return item?.label ?? "관리자"; }; export default function AdminHeader() { @@ -29,6 +27,7 @@ export default function AdminHeader() {
+ {/* 로고 + 메뉴 */}
{ADMIN_NAV.map((item) => { const active = - pathname === item.href || pathname.startsWith(item.href + "/"); + pathname === item.href || + pathname.startsWith(item.href + "/"); return (
+
diff --git a/src/components/layout/AdminSearchHeader.tsx b/src/components/layout/AdminSearchHeader.tsx new file mode 100644 index 0000000..a51f0d1 --- /dev/null +++ b/src/components/layout/AdminSearchHeader.tsx @@ -0,0 +1,60 @@ +"use client"; + +import Image from "next/image"; +import React from "react"; + +type Props = { + title: string; + + keyword: string; + onKeywordChange: (v: string) => void; + onSearch: () => void; + + placeholder?: string; + + rightAddon?: React.ReactNode; + + /** width 커스텀 */ + inputWidthClassName?: string; +}; + +export default function AdminSearchHeader({ + title, + keyword, + onKeywordChange, + onSearch, + placeholder = "검색 하기", + rightAddon, + inputWidthClassName = "w-[1040px]", +}: Props) { + return ( + <> +

+ {title} +

+ +
+
+ onKeywordChange(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onSearch()} + placeholder={placeholder} + className="w-full h-[56px] rounded-[8px] border border-[#EAE5E2] bg-white pl-4 pr-14" + /> + +
+ + {/* 소식관리에서 버튼 추가 */} + {rightAddon} +
+ + ); +} \ No newline at end of file From 99c455bff260b693ec7c7dafc76971c2a9fb77d5 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 18:04:38 +0900 Subject: [PATCH 079/100] feat: fetch MyPage profile info from API instead of global state --- src/components/base-ui/MyPage/UserProfile.tsx | 10 +++++----- src/hooks/queries/useMemberQueries.ts | 9 +++++++++ src/lib/api/endpoints/member.ts | 1 + src/services/memberService.ts | 8 +++++++- src/types/member.ts | 7 +++++++ 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/components/base-ui/MyPage/UserProfile.tsx b/src/components/base-ui/MyPage/UserProfile.tsx index 1d765a8..df7454e 100644 --- a/src/components/base-ui/MyPage/UserProfile.tsx +++ b/src/components/base-ui/MyPage/UserProfile.tsx @@ -4,19 +4,19 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import JoinButton from "@/components/base-ui/Join/JoinButton"; import { DUMMY_USER_PROFILE } from "@/constants/mocks/mypage"; -import { useAuthStore } from "@/store/useAuthStore"; +import { useProfileQuery } from "@/hooks/queries/useMemberQueries"; import FloatingFab from "../Float"; const UserProfile = () => { const router = useRouter(); - const { user: authUser } = useAuthStore(); + const { data: profileData, isLoading, isError } = useProfileQuery(); // 서버 데이터가 있으면 사용하고, 없으면 더미 데이터 사용 (구독자 수 등은 현재 API에 없음) const user = { ...DUMMY_USER_PROFILE, - name: authUser?.nickname || authUser?.email || DUMMY_USER_PROFILE.name, - intro: authUser?.description || DUMMY_USER_PROFILE.intro, - profileImage: authUser?.profileImageUrl || DUMMY_USER_PROFILE.profileImage, + name: profileData?.nickname || DUMMY_USER_PROFILE.name, + intro: profileData?.description || DUMMY_USER_PROFILE.intro, + profileImage: profileData?.profileImageUrl || DUMMY_USER_PROFILE.profileImage, }; return ( diff --git a/src/hooks/queries/useMemberQueries.ts b/src/hooks/queries/useMemberQueries.ts index 15c73a4..660db0c 100644 --- a/src/hooks/queries/useMemberQueries.ts +++ b/src/hooks/queries/useMemberQueries.ts @@ -4,6 +4,7 @@ import { memberService } from "@/services/memberService"; export const memberKeys = { all: ["members"] as const, recommended: () => [...memberKeys.all, "recommended"] as const, + profile: () => [...memberKeys.all, "profile"] as const, }; export const useRecommendedMembersQuery = (enabled: boolean = true) => { @@ -13,3 +14,11 @@ export const useRecommendedMembersQuery = (enabled: boolean = true) => { enabled, }); }; + +export const useProfileQuery = (enabled: boolean = true) => { + return useQuery({ + queryKey: memberKeys.profile(), + queryFn: () => memberService.getProfile(), + enabled, + }); +}; diff --git a/src/lib/api/endpoints/member.ts b/src/lib/api/endpoints/member.ts index bb85e2d..bdc973c 100644 --- a/src/lib/api/endpoints/member.ts +++ b/src/lib/api/endpoints/member.ts @@ -1,6 +1,7 @@ import { API_BASE_URL } from "./base"; export const MEMBER_ENDPOINTS = { + GET_PROFILE: `${API_BASE_URL}/members/me`, RECOMMEND: `${API_BASE_URL}/members/me/recommend`, UPDATE_PROFILE: `${API_BASE_URL}/members/me`, UPDATE_PASSWORD: `${API_BASE_URL}/members/me/update-password`, diff --git a/src/services/memberService.ts b/src/services/memberService.ts index 78b1b36..4bd95be 100644 --- a/src/services/memberService.ts +++ b/src/services/memberService.ts @@ -1,6 +1,6 @@ import { apiClient } from "@/lib/api/client"; import { MEMBER_ENDPOINTS } from "@/lib/api/endpoints/member"; -import { RecommendResponse, UpdateProfileRequest, UpdatePasswordRequest } from "@/types/member"; +import { RecommendResponse, UpdateProfileRequest, UpdatePasswordRequest, ProfileResponse } from "@/types/member"; import { ApiResponse } from "@/types/auth"; export const memberService = { @@ -28,4 +28,10 @@ export const memberService = { throw new Error(response.message || "Failed to update password"); } }, + getProfile: async (): Promise => { + const response = await apiClient.get>( + MEMBER_ENDPOINTS.GET_PROFILE + ); + return response.result!; + }, }; diff --git a/src/types/member.ts b/src/types/member.ts index b7a1c18..f2f0043 100644 --- a/src/types/member.ts +++ b/src/types/member.ts @@ -18,3 +18,10 @@ export interface UpdatePasswordRequest { newPassword?: string; confirmPassword?: string; } + +export interface ProfileResponse { + nickname: string; + description: string; + profileImageUrl: string; + categories: string[]; +} From ba77cd19a21952955763c61808e3c7b890bfda1b Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 18:10:10 +0900 Subject: [PATCH 080/100] refactor(mypage): remove excessive optional chaining in MyBookStoryList --- .../base-ui/MyPage/MyBookStoryList.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/base-ui/MyPage/MyBookStoryList.tsx b/src/components/base-ui/MyPage/MyBookStoryList.tsx index 6af632b..43d93d3 100644 --- a/src/components/base-ui/MyPage/MyBookStoryList.tsx +++ b/src/components/base-ui/MyPage/MyBookStoryList.tsx @@ -39,18 +39,18 @@ const MyBookStoryList = () => { )}
- {stories.map((story, index) => ( + {stories.map((story) => ( ))} From afcf6442d7ec238bf69f7bd24ee5051b1eb7c2f3 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 18:31:36 +0900 Subject: [PATCH 081/100] feat(home): Update Recommended Users section for empty/error/auth states --- src/app/(main)/page.tsx | 57 ++++++++++++------- .../base-ui/home/list_subscribe_large.tsx | 44 +++++++------- 2 files changed, 63 insertions(+), 38 deletions(-) diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index 25d8436..6f0d6c6 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -19,9 +19,10 @@ export default function HomePage() { const { isLoggedIn, isLoginModalOpen, openLoginModal, closeLoginModal } = useAuthStore(); const { data: storiesData, isLoading: isLoadingStories } = useStoriesQuery(); - const { data: membersData, isLoading: isLoadingMembers } = useRecommendedMembersQuery(isLoggedIn); + const { data: membersData, isLoading: isLoadingMembers, isError: isErrorMembers } = useRecommendedMembersQuery(isLoggedIn); const stories = storiesData?.basicInfoList || []; + // 멤버 데이터가 없으면 빈 배열 const recommendedUsers = membersData?.friends || []; const isLoading = isLoadingStories || (isLoggedIn && isLoadingMembers); @@ -54,21 +55,35 @@ export default function HomePage() {

독서모임

-
-

- 사용자 추천 -

-
- {recommendedUsers.slice(0, 3).map((u, idx) => ( - console.log("subscribe", u.nickname)} - /> - ))} + {/* 사용자 추천: 로그인한 회원에게만 노출 */} + {isLoggedIn && ( +
+

+ 사용자 추천 +

+
+ {isErrorMembers && ( +
+

추천 목록을 불러오지 못했어요.

+
+ )} + {!isErrorMembers && recommendedUsers.length === 0 && ( +
+

사용자 추천이 없습니다.

+
+ )} + {!isErrorMembers && recommendedUsers.length > 0 && + recommendedUsers.slice(0, 3).map((u, idx) => ( + console.log("subscribe", u.nickname)} + /> + ))} +
-
+ )}
@@ -111,7 +126,9 @@ export default function HomePage() {
- + {isLoggedIn && ( + + )}
@@ -145,9 +162,11 @@ export default function HomePage() { 독서모임 -
- -
+ {isLoggedIn && ( +
+ +
+ )} {/* 소식 + 책 이야기 */} diff --git a/src/components/base-ui/home/list_subscribe_large.tsx b/src/components/base-ui/home/list_subscribe_large.tsx index aecd648..83d0d9e 100644 --- a/src/components/base-ui/home/list_subscribe_large.tsx +++ b/src/components/base-ui/home/list_subscribe_large.tsx @@ -62,19 +62,14 @@ type ListSubscribeLargeProps = { subscribingCount?: number; subscribersCount?: number; }>; + isError?: boolean; }; export default function ListSubscribeLarge({ height = "h-[380px]", - users: propUsers, + users = [], + isError = false, }: ListSubscribeLargeProps) { - // Use prop users if provided, otherwise fallback to default dummy data - const users = propUsers || [ - { nickname: "hy_0716", subscribingCount: 17, subscribersCount: 32 }, - { nickname: "hy_0716", subscribingCount: 17, subscribersCount: 32 }, - { nickname: "hy_0716", subscribingCount: 17, subscribersCount: 32 }, - { nickname: "hy_0716", subscribingCount: 17, subscribersCount: 32 }, - ]; return (

사용자 추천

-
- {users.map((u) => ( - console.log("subscribe", u.nickname)} - /> - ))} +
+ {isError && ( +
+

추천 목록을 불러오지 못했어요.

+
+ )} + {!isError && users.length === 0 && ( +
+

사용자 추천이 없습니다.

+
+ )} + {!isError && users.length > 0 && + users.map((u, idx) => ( + console.log("subscribe", u.nickname)} + /> + ))}
); From ca6feecbda7cb4dcfb964a369b593eb7daa06d4a Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 18:47:43 +0900 Subject: [PATCH 082/100] fix(home): display book stories for non-logged in users --- src/app/(main)/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index 6f0d6c6..8ee9fb6 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -24,6 +24,8 @@ export default function HomePage() { const stories = storiesData?.basicInfoList || []; // 멤버 데이터가 없으면 빈 배열 const recommendedUsers = membersData?.friends || []; + + // isLoading 멤버 변수는 로그인 되어있을 때만 실제 로딩 상태를 반영해야 함 const isLoading = isLoadingStories || (isLoggedIn && isLoadingMembers); if (isLoading) { From 06f91ec29c53744be78afd8520dff6d7228cc260 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 18:51:40 +0900 Subject: [PATCH 083/100] fix(home): Adjust desktop layout alignment when user is logged out --- src/app/(main)/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index 8ee9fb6..5f4f1a6 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -157,7 +157,7 @@ export default function HomePage() {
{/* 데스크톱 */} -
+
{/* 독서모임 + 사용자 추천 */}

From fa074a4ccb16791942cc3e71da63894aaed041de Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 19:03:05 +0900 Subject: [PATCH 084/100] feat(home): integrate My Clubs API for HomeBookclub --- src/app/(main)/page.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index 5f4f1a6..a7159b2 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -13,13 +13,19 @@ import BookStoryCardLarge from "@/components/base-ui/BookStory/bookstory_card_la import { useAuthStore } from "@/store/useAuthStore"; import { useStoriesQuery } from "@/hooks/queries/useStoryQueries"; import { useRecommendedMembersQuery } from "@/hooks/queries/useMemberQueries"; +import { useMyClubsQuery } from "@/hooks/queries/useClubQueries"; export default function HomePage() { - const groups: { id: string; name: string }[] = []; const { isLoggedIn, isLoginModalOpen, openLoginModal, closeLoginModal } = useAuthStore(); const { data: storiesData, isLoading: isLoadingStories } = useStoriesQuery(); const { data: membersData, isLoading: isLoadingMembers, isError: isErrorMembers } = useRecommendedMembersQuery(isLoggedIn); + const { data: myClubsData } = useMyClubsQuery(); + + const groups = myClubsData?.clubList.map((club) => ({ + id: String(club.clubId), + name: club.clubName, + })) || []; const stories = storiesData?.basicInfoList || []; // 멤버 데이터가 없으면 빈 배열 @@ -157,7 +163,7 @@ export default function HomePage() {

{/* 데스크톱 */} -
+
{/* 독서모임 + 사용자 추천 */}

From fea4c6e7e18e7433716d86f117b7adf3e3f5b2b7 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 19:07:15 +0900 Subject: [PATCH 085/100] refactor(home): use MyClubInfo type directly in HomeBookclub --- src/app/(main)/page.tsx | 5 +---- src/components/base-ui/home/home_bookclub.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index a7159b2..90795eb 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -22,10 +22,7 @@ export default function HomePage() { const { data: membersData, isLoading: isLoadingMembers, isError: isErrorMembers } = useRecommendedMembersQuery(isLoggedIn); const { data: myClubsData } = useMyClubsQuery(); - const groups = myClubsData?.clubList.map((club) => ({ - id: String(club.clubId), - name: club.clubName, - })) || []; + const groups = myClubsData?.clubList || []; const stories = storiesData?.basicInfoList || []; // 멤버 데이터가 없으면 빈 배열 diff --git a/src/components/base-ui/home/home_bookclub.tsx b/src/components/base-ui/home/home_bookclub.tsx index 18e3d4d..1c742f2 100644 --- a/src/components/base-ui/home/home_bookclub.tsx +++ b/src/components/base-ui/home/home_bookclub.tsx @@ -3,10 +3,10 @@ import { useState } from 'react'; import Image from 'next/image'; -export type GroupSummary = { id: string; name: string }; +import { MyClubInfo } from '@/types/club'; type Props = { - groups: GroupSummary[]; + groups: MyClubInfo[]; }; export default function HomeBookclub({ groups }: Props) { @@ -43,10 +43,10 @@ export default function HomeBookclub({ groups }: Props) { > {displayGroups.map((group) => (
- {group.name} + {group.clubName}
))}

From c23e1104ecfbbe8d53deddbece27037034bb62d6 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 19:17:38 +0900 Subject: [PATCH 086/100] feat(home): add mockup data to HomeBookclub --- src/components/base-ui/home/home_bookclub.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/components/base-ui/home/home_bookclub.tsx b/src/components/base-ui/home/home_bookclub.tsx index 1c742f2..a5e5ae7 100644 --- a/src/components/base-ui/home/home_bookclub.tsx +++ b/src/components/base-ui/home/home_bookclub.tsx @@ -10,13 +10,23 @@ type Props = { }; export default function HomeBookclub({ groups }: Props) { - const count = groups.length; + // UI 확인을 위한 임시 목 데이터 (데이터가 없을 때만 표시) + const displayData = groups && groups.length > 0 ? groups : [ + { clubId: 101, clubName: "책 읽는 새벽 모임 (MOCK)" }, + { clubId: 102, clubName: "현대 소설 탐구 생활 (MOCK)" }, + { clubId: 103, clubName: "경제/경영 베스트셀러 (MOCK)" }, + { clubId: 104, clubName: "SF/판타지 덕후 모임 (MOCK)" }, + { clubId: 105, clubName: "철학하는 금요일 (MOCK)" }, + { clubId: 106, clubName: "미술관 가는 사람들 (MOCK)" }, + ]; + + const count = displayData.length; const isMany = count >= 5; const [open, setOpen] = useState(false); // 접힘: 6개만 / 펼침: 전체 - const displayGroups = isMany && !open ? groups.slice(0, 6) : groups; + const displayGroups = isMany && !open ? displayData.slice(0, 6) : displayData; return (
))} @@ -117,6 +118,7 @@ export default function StoriesPage() { commentCount={story.commentCount} coverImgSrc={story.bookInfo.imgUrl} subscribeText={story.authorInfo.following ? "구독중" : "구독"} + hideSubscribeButton={story.writtenByMe} />
))} From 4c299200cf263799fd1bc23572c1eb8ffa2bc6c3 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Wed, 25 Feb 2026 19:34:09 +0900 Subject: [PATCH 090/100] feat(stories): hide subscribe button for own book stories on detail page --- src/app/(main)/stories/[id]/page.tsx | 1 + .../base-ui/BookStory/bookstory_detail.tsx | 34 +++++++++++-------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/app/(main)/stories/[id]/page.tsx b/src/app/(main)/stories/[id]/page.tsx index b3ebf4b..fc34953 100644 --- a/src/app/(main)/stories/[id]/page.tsx +++ b/src/app/(main)/stories/[id]/page.tsx @@ -81,6 +81,7 @@ export default function StoryDetailPage() { createdAt={story.createdAt} viewCount={story.viewCount} likeCount={story.likes} + hideSubscribeButton={story.writtenByMe} /> {/* 책이야기 글 본문 */} diff --git a/src/components/base-ui/BookStory/bookstory_detail.tsx b/src/components/base-ui/BookStory/bookstory_detail.tsx index 888a811..a8cbdab 100644 --- a/src/components/base-ui/BookStory/bookstory_detail.tsx +++ b/src/components/base-ui/BookStory/bookstory_detail.tsx @@ -25,6 +25,7 @@ type BookstoryDetailProps = { authorHref?: string; // 기본: `/profile/${authorId}` className?: string; + hideSubscribeButton?: boolean; }; function timeAgo(iso: string) { @@ -57,6 +58,7 @@ export default function BookstoryDetail({ likeCount = 1, authorHref, className = "", + hideSubscribeButton = false, }: BookstoryDetailProps) { const href = authorHref ?? `/profile/${authorId}`; const [menuOpen, setMenuOpen] = useState(false); @@ -100,13 +102,15 @@ export default function BookstoryDetail({ {/* 구독 */} - + {!hideSubscribeButton && ( + + )}
{/* 모바일: 책 제목 + 햄버거 */} @@ -245,13 +249,15 @@ export default function BookstoryDetail({ {/* 구독 + 햄버거 */}
- + {!hideSubscribeButton && ( + + )} {/* 햄버거 */}
From 16c19dd558d71c39849e7abd213fb56b7830adb3 Mon Sep 17 00:00:00 2001 From: nonoididnt Date: Wed, 25 Feb 2026 20:00:00 +0900 Subject: [PATCH 091/100] style: move hardcoded styles to global.css --- src/app/(admin)/admin/(app)/groups/page.tsx | 56 +++++++++++-------- src/app/(admin)/admin/(app)/layout.tsx | 18 +++++- src/app/(admin)/admin/(app)/news/page.tsx | 56 +++++++++++-------- src/app/(admin)/admin/(app)/stories/page.tsx | 58 +++++++++++--------- src/app/(admin)/admin/(app)/users/page.tsx | 50 ++++++++++------- src/components/layout/AdminSearchHeader.tsx | 6 +- 6 files changed, 145 insertions(+), 99 deletions(-) diff --git a/src/app/(admin)/admin/(app)/groups/page.tsx b/src/app/(admin)/admin/(app)/groups/page.tsx index 26bb487..8480249 100644 --- a/src/app/(admin)/admin/(app)/groups/page.tsx +++ b/src/app/(admin)/admin/(app)/groups/page.tsx @@ -93,6 +93,9 @@ export default function GroupsPage() { return Array.from({ length: end - start + 1 }).map((_, idx) => start + idx); }, [page, totalPages]); + const isFirst = page === 1; + const isLast = page === totalPages; + return (
@@ -110,7 +113,7 @@ export default function GroupsPage() { - + @@ -118,26 +121,29 @@ export default function GroupsPage() { - - - - - - - + + + + + + + {pageItems.map((g) => ( - - - - - - + + + + + + @@ -147,20 +153,21 @@ export default function GroupsPage() {
모임 ID이름개설자 이메일생성 일자가입자 수상세보기
모임 ID이름개설자 이메일생성 일자가입자 수상세보기
{g.id}{g.name}{g.ownerEmail}{g.createdAt}{g.memberCount}
{g.id}{g.name}{g.ownerEmail}{g.createdAt}{g.memberCount} -
{/* 페이지네이션 */} -
+
@@ -182,17 +189,18 @@ export default function GroupsPage() {
); diff --git a/src/app/(admin)/admin/(app)/news/page.tsx b/src/app/(admin)/admin/(app)/news/page.tsx index d2a75c8..8bccbc5 100644 --- a/src/app/(admin)/admin/(app)/news/page.tsx +++ b/src/app/(admin)/admin/(app)/news/page.tsx @@ -99,6 +99,9 @@ export default function NewsPage() { return Array.from({ length: end - start + 1 }).map((_, idx) => start + idx); }, [page, totalPages]); + const isFirst = page === 1; + const isLast = page === totalPages; + return (
@@ -113,7 +116,7 @@ export default function NewsPage() { @@ -133,26 +136,29 @@ export default function NewsPage() { - - 소식 ID - 소식 제목 - 등록자 이메일 - 등록 일자 - 게시날짜 - 상세보기 + + 소식 ID + 소식 제목 + 등록자 이메일 + 등록 일자 + 게시날짜 + 상세보기 {pageItems.map((n) => ( - - {n.id} - {n.title} - {n.authorEmail} - {n.createdAt} - {n.postedAt} + + {n.id} + {n.title} + {n.authorEmail} + {n.createdAt} + {n.postedAt} - @@ -162,20 +168,21 @@ export default function NewsPage() { {/* 페이지네이션 */} -
+
@@ -197,17 +204,18 @@ export default function NewsPage() { @@ -173,20 +179,21 @@ export default function BookStoriesPage() { {/* 페이지네이션 */} -
+
@@ -208,17 +215,18 @@ export default function BookStoriesPage() { @@ -133,20 +139,21 @@ export default function UsersPage() { {/* 페이지네이션 */} -
+
@@ -168,17 +175,18 @@ export default function UsersPage() {
@@ -155,6 +158,7 @@ export default function HomePage() { coverImgSrc={story.bookInfo.imgUrl} subscribeText="구독" hideSubscribeButton={story.writtenByMe} + onClick={() => router.push(`/stories/${story.bookStoryId}`)} /> ))}
@@ -203,6 +207,7 @@ export default function HomePage() { coverImgSrc={story.bookInfo.imgUrl} subscribeText="구독" hideSubscribeButton={story.writtenByMe} + onClick={() => router.push(`/stories/${story.bookStoryId}`)} /> ))}
diff --git a/src/components/base-ui/BookStory/bookstory_card.tsx b/src/components/base-ui/BookStory/bookstory_card.tsx index 496ede4..054d172 100644 --- a/src/components/base-ui/BookStory/bookstory_card.tsx +++ b/src/components/base-ui/BookStory/bookstory_card.tsx @@ -15,6 +15,7 @@ type Props = { onSubscribeClick?: () => void; subscribeText?: string; hideSubscribeButton?: boolean; + onClick?: () => void; }; import { formatTimeAgo } from "@/utils/time"; @@ -32,10 +33,12 @@ export default function BookStoryCard({ onSubscribeClick, subscribeText = "구독", hideSubscribeButton = false, + onClick, }: Props) { return (
{ + e.stopPropagation(); + onSubscribeClick?.(); + }} className="h-8 rounded-lg bg-primary-2 px-[17px] body_2_1 text-White whitespace-nowrap" > {subscribeText} From 1fcaa7bdc1c962e75762e3d8d14474fe2148430e Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Thu, 26 Feb 2026 09:56:23 +0900 Subject: [PATCH 093/100] feat(home): add navigation to group creation page from home bookclub --- src/components/base-ui/home/home_bookclub.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/base-ui/home/home_bookclub.tsx b/src/components/base-ui/home/home_bookclub.tsx index 8467288..90ec5e6 100644 --- a/src/components/base-ui/home/home_bookclub.tsx +++ b/src/components/base-ui/home/home_bookclub.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import Image from 'next/image'; +import { useRouter } from 'next/navigation'; import { MyClubInfo } from '@/types/club'; @@ -10,6 +11,7 @@ type Props = { }; export default function HomeBookclub({ groups }: Props) { + const router = useRouter(); const count = groups.length; const isMany = count >= 5; @@ -91,8 +93,9 @@ export default function HomeBookclub({ groups }: Props) { - -
-

{title}

-

{author}

-
); diff --git a/src/components/base-ui/Search/search_recommendbook.tsx b/src/components/base-ui/Search/search_recommendbook.tsx index 8261eac..82616cc 100644 --- a/src/components/base-ui/Search/search_recommendbook.tsx +++ b/src/components/base-ui/Search/search_recommendbook.tsx @@ -28,43 +28,46 @@ export default function Search_BookCoverCard({ return (
- {title} + {/* 도서 커버 이미지 영역 */} +
+ {title} +
+ + {/* 정보 영역 (제목, 저자, 좋아요) */} +
+
+

+ {title} +

+

+ {author} +

+
-
- -
-

- {title} -

-

- {author} -

-
); From 924f327ad84fd5fc23b1bb6828c1bab3333f3e82 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Thu, 26 Feb 2026 10:17:10 +0900 Subject: [PATCH 096/100] fix(ui): add width constraints to book cover cards to prevent layout breaking with long titles --- src/components/base-ui/News/recommendbook_element.tsx | 2 +- src/components/base-ui/Search/search_recommendbook.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/base-ui/News/recommendbook_element.tsx b/src/components/base-ui/News/recommendbook_element.tsx index 516b6f7..6b8451f 100644 --- a/src/components/base-ui/News/recommendbook_element.tsx +++ b/src/components/base-ui/News/recommendbook_element.tsx @@ -36,7 +36,7 @@ export default function BookCoverCard({ return (
{/* 도서 커버 이미지 영역 */} diff --git a/src/components/base-ui/Search/search_recommendbook.tsx b/src/components/base-ui/Search/search_recommendbook.tsx index 82616cc..1092e2e 100644 --- a/src/components/base-ui/Search/search_recommendbook.tsx +++ b/src/components/base-ui/Search/search_recommendbook.tsx @@ -28,7 +28,7 @@ export default function Search_BookCoverCard({ return (
{/* 도서 커버 이미지 영역 */} From a85beeaa4f9588c17bd5367eddc00fa851b0eed6 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Thu, 26 Feb 2026 10:21:56 +0900 Subject: [PATCH 097/100] feat(news): integrate real recommended books API and fix type mismatch --- src/app/(main)/news/page.tsx | 48 ++++++++----------- .../base-ui/News/today_recommended_books.tsx | 8 ++-- 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/src/app/(main)/news/page.tsx b/src/app/(main)/news/page.tsx index 3d2c943..d88c84d 100644 --- a/src/app/(main)/news/page.tsx +++ b/src/app/(main)/news/page.tsx @@ -4,6 +4,8 @@ import Image from "next/image"; import NewsList from "@/components/base-ui/News/news_list"; import TodayRecommendedBooks from "@/components/base-ui/News/today_recommended_books"; import FloatingFab from "@/components/base-ui/Float"; +import { useRecommendedBooksQuery } from "@/hooks/queries/useBookQueries"; +import { useMemo } from "react"; const DUMMY_NEWS = [ { @@ -40,34 +42,18 @@ const DUMMY_NEWS = [ }, ]; -const DUMMY_BOOKS = [ - { - 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: "작가작가작가", - }, -]; - export default function NewsPage() { + const { data: recommendedData, isLoading: isLoadingRecommended } = useRecommendedBooksQuery(); + + const recommendedBooks = useMemo(() => { + return (recommendedData?.detailInfoList || []).map((book) => ({ + id: book.isbn, + imgUrl: book.imgUrl, + title: book.title, + author: book.author, + })); + }, [recommendedData]); + return (
@@ -87,7 +73,9 @@ export default function NewsPage() {
{/* 오늘의 추천 */} - + {!isLoadingRecommended && recommendedBooks.length > 0 && ( + + )} {/* 뉴스 리스트 */}
@@ -105,7 +93,9 @@ export default function NewsPage() {
- + {!isLoadingRecommended && recommendedBooks.length > 0 && ( + + )} >({}); + const [likedBooks, setLikedBooks] = useState>({}); - const handleLikeChange = (bookId: number, liked: boolean) => { + const handleLikeChange = (bookId: string | number, liked: boolean) => { setLikedBooks((prev) => ({ ...prev, [bookId]: liked, @@ -36,7 +36,7 @@ export default function TodayRecommendedBooks({

오늘의 추천 책

- + {/* 모바일: 2개 */}
{mobileBooks.map((book) => ( From cbf871b0e6d07d34759c3c684d00f6ae4373f3c1 Mon Sep 17 00:00:00 2001 From: shinwookkang Date: Thu, 26 Feb 2026 10:27:44 +0900 Subject: [PATCH 098/100] refactor: address code review feedback regarding React keys and code reuse --- src/app/(main)/page.tsx | 4 ++-- src/app/(main)/stories/page.tsx | 14 ++++++++------ .../base-ui/home/list_subscribe_large.tsx | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index b3c63d7..1bc0f71 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -80,9 +80,9 @@ export default function HomePage() {
)} {!isErrorMembers && recommendedUsers.length > 0 && - recommendedUsers.slice(0, 3).map((u, idx) => ( + recommendedUsers.slice(0, 3).map((u) => ( console.log("subscribe", u.nickname)} diff --git a/src/app/(main)/stories/page.tsx b/src/app/(main)/stories/page.tsx index 2ffa88b..dc49b4a 100644 --- a/src/app/(main)/stories/page.tsx +++ b/src/app/(main)/stories/page.tsx @@ -52,20 +52,23 @@ export default function StoriesPage() { const allStories = storiesData?.pages.flatMap((page) => page.basicInfoList) || []; const recommendedMembers = membersData?.friends || []; + const getCategoryClassName = (categoryId: string) => { + return `text-center body_1 t:subhead_2 leading-7 cursor-pointer hover:text-zinc-600 shrink-0 ${selectedCategory === categoryId ? "text-Gray-7 font-semibold" : "text-Gray-3" + }`; + }; + return (
setSelectedCategory("all")} - className={`text-center body_1 t:subhead_2 leading-7 cursor-pointer hover:text-zinc-600 shrink-0 ${selectedCategory === "all" ? "text-Gray-7 font-semibold" : "text-Gray-3" - }`} + className={getCategoryClassName("all")} > 전체
setSelectedCategory("following")} - className={`text-center body_1 t:subhead_2 leading-7 cursor-pointer hover:text-zinc-600 shrink-0 ${selectedCategory === "following" ? "text-Gray-7 font-semibold" : "text-Gray-3" - }`} + className={getCategoryClassName("following")} > 구독중
@@ -73,8 +76,7 @@ export default function StoriesPage() {
setSelectedCategory(club.clubId.toString())} - className={`text-center body_1 t:subhead_2 leading-7 cursor-pointer hover:text-zinc-600 shrink-0 ${selectedCategory === club.clubId.toString() ? "text-Gray-7 font-semibold" : "text-Gray-3" - }`} + className={getCategoryClassName(club.clubId.toString())} > {club.clubName}
diff --git a/src/components/base-ui/home/list_subscribe_large.tsx b/src/components/base-ui/home/list_subscribe_large.tsx index 83d0d9e..68e4694 100644 --- a/src/components/base-ui/home/list_subscribe_large.tsx +++ b/src/components/base-ui/home/list_subscribe_large.tsx @@ -89,9 +89,9 @@ export default function ListSubscribeLarge({
)} {!isError && users.length > 0 && - users.map((u, idx) => ( + users.map((u) => ( Date: Thu, 26 Feb 2026 10:40:36 +0900 Subject: [PATCH 099/100] refactor(ui): unify book cover card components into a single BookCoverCard with variants --- src/components/base-ui/Book/BookCoverCard.tsx | 104 ++++++++++++++++++ .../base-ui/News/recommendbook_element.tsx | 78 ------------- .../base-ui/News/today_recommended_books.tsx | 2 +- .../base-ui/Search/search_recommendbook.tsx | 74 ------------- src/components/layout/SearchModal.tsx | 5 +- 5 files changed, 108 insertions(+), 155 deletions(-) create mode 100644 src/components/base-ui/Book/BookCoverCard.tsx delete mode 100644 src/components/base-ui/News/recommendbook_element.tsx delete mode 100644 src/components/base-ui/Search/search_recommendbook.tsx diff --git a/src/components/base-ui/Book/BookCoverCard.tsx b/src/components/base-ui/Book/BookCoverCard.tsx new file mode 100644 index 0000000..620e980 --- /dev/null +++ b/src/components/base-ui/Book/BookCoverCard.tsx @@ -0,0 +1,104 @@ +'use client'; + +import Image from 'next/image'; + +type BookCoverCardVariant = 'search' | 'news'; + +type BookCoverCardProps = { + imgUrl?: string; + title: string; + author: string; + liked: boolean; + onLikeChange: (next: boolean) => void; + onCardClick?: () => void; + className?: string; + variant?: BookCoverCardVariant; + responsive?: boolean; // Only used for 'news' variant +}; + +export default function BookCoverCard({ + imgUrl, + title, + author, + liked, + onLikeChange, + onCardClick, + className = '', + variant = 'news', + responsive = false, +}: BookCoverCardProps) { + const coverSrc = imgUrl && imgUrl.length > 0 ? imgUrl : '/booksample.svg'; + + // variant에 따른 스타일 분리 + const isSearch = variant === 'search'; + + // 가로 너비 및 이미지 영역 크기 설정 + let containerWidth = ''; + let imageAreaSize = ''; + let imageSizes = ''; + + if (isSearch) { + containerWidth = 'w-[111px] t:w-[217px] d:w-[332px]'; + imageAreaSize = 'w-[111px] h-[144px] t:w-[217px] t:h-[286px] d:w-[332px] d:h-[436px]'; + imageSizes = '(max-width: 767px) 111px, (max-width: 1439px) 217px, 332px'; + } else { + // news variant (recommendbook_element 로직 재사용) + containerWidth = responsive ? 'w-[157px]' : 'w-61'; + imageAreaSize = responsive ? 'w-[157px] h-[206px]' : 'w-61 h-80'; + imageSizes = responsive ? '(max-width: 768px) 156px, 160px' : '244px'; + } + + // 텍스트 색상 설정 + const titleColor = isSearch ? 'text-white' : 'text-Gray-7'; + const authorColor = isSearch ? 'text-white/60' : 'text-Gray-5'; + + // 텍스트 스타일 설정 + const titleStyle = isSearch ? 'subhead_4 t:subhead_1' : 'subhead_2'; + const authorStyle = isSearch ? 'body_2_3 t:subhead_4' : 'body_2'; + + return ( +
+ {/* 도서 커버 이미지 영역 */} +
+ {title} +
+ + {/* 정보 영역 (제목, 저자, 좋아요) */} +
+
+

+ {title} +

+

+ {author} +

+
+ + +
+
+ ); +} diff --git a/src/components/base-ui/News/recommendbook_element.tsx b/src/components/base-ui/News/recommendbook_element.tsx deleted file mode 100644 index 6b8451f..0000000 --- a/src/components/base-ui/News/recommendbook_element.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'use client'; - -import Image from 'next/image'; - -type BookCoverCardProps = { - imgUrl?: string; - title: string; - author: string; - - liked: boolean; - onLikeChange: (next: boolean) => void; - - onCardClick?: () => void; - className?: string; - responsive?: boolean; // 모바일/태블릿 반응형 여부 -}; - -export default function BookCoverCard({ - imgUrl, - title, - author, - liked, - onLikeChange, - onCardClick, - className = '', - responsive = false, -}: BookCoverCardProps) { - const coverSrc = imgUrl && imgUrl.length > 0 ? imgUrl : '/booksample.svg'; - - const sizeClasses = responsive - ? 'w-[157px] h-[206px]' - : 'w-61 h-80'; - - const imageSizes = responsive ? '(max-width: 768px) 156px, 160px' : '244px'; - - return ( -
- {/* 도서 커버 이미지 영역 */} -
- {title} -
- - {/* 정보 영역 (제목, 저자, 좋아요) */} -
-
-

{title}

-

{author}

-
- - -
-
- ); -} diff --git a/src/components/base-ui/News/today_recommended_books.tsx b/src/components/base-ui/News/today_recommended_books.tsx index b09f403..bb4e876 100644 --- a/src/components/base-ui/News/today_recommended_books.tsx +++ b/src/components/base-ui/News/today_recommended_books.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import BookCoverCard from "./recommendbook_element"; +import BookCoverCard from "@/components/base-ui/Book/BookCoverCard"; type Book = { id: string | number; diff --git a/src/components/base-ui/Search/search_recommendbook.tsx b/src/components/base-ui/Search/search_recommendbook.tsx deleted file mode 100644 index 1092e2e..0000000 --- a/src/components/base-ui/Search/search_recommendbook.tsx +++ /dev/null @@ -1,74 +0,0 @@ -'use client'; - -import Image from 'next/image'; - -type BookCoverCardProps = { - imgUrl?: string; - title: string; - author: string; - - liked: boolean; - onLikeChange: (next: boolean) => void; - - onCardClick?: () => void; - className?: string; -}; - -export default function Search_BookCoverCard({ - imgUrl, - title, - author, - liked, - onLikeChange, - onCardClick, - className = '', -}: BookCoverCardProps) { - const coverSrc = imgUrl && imgUrl.length > 0 ? imgUrl : '/booksample.svg'; - - return ( -
- {/* 도서 커버 이미지 영역 */} -
- {title} -
- - {/* 정보 영역 (제목, 저자, 좋아요) */} -
-
-

- {title} -

-

- {author} -

-
- - -
-
- ); -} diff --git a/src/components/layout/SearchModal.tsx b/src/components/layout/SearchModal.tsx index 743bec3..6003233 100644 --- a/src/components/layout/SearchModal.tsx +++ b/src/components/layout/SearchModal.tsx @@ -4,7 +4,7 @@ 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 BookCoverCard from "@/components/base-ui/Book/BookCoverCard"; import { useInfiniteBookSearchQuery, useRecommendedBooksQuery } from "@/hooks/queries/useBookQueries"; import { useDebounce } from "@/hooks/useDebounce"; import { useInView } from "react-intersection-observer"; @@ -211,7 +211,8 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { ) : recommendedBooks.length > 0 ? ( recommendedBooks.map((book, index) => (
- Date: Thu, 26 Feb 2026 13:46:25 +0900 Subject: [PATCH 100/100] style: replace hardcoded colors with theme tokens --- src/app/(admin)/admin/(app)/layout.tsx | 3 +-- src/app/(admin)/admin/(auth)/login/page.tsx | 27 +++++++++------------ 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/app/(admin)/admin/(app)/layout.tsx b/src/app/(admin)/admin/(app)/layout.tsx index 6f7c1d1..a31ee7e 100644 --- a/src/app/(admin)/admin/(app)/layout.tsx +++ b/src/app/(admin)/admin/(app)/layout.tsx @@ -10,7 +10,6 @@ export default function AdminLayout({ }) { const pathname = usePathname(); - // Auth 페이지인지 체크 const isAuthPage = pathname.startsWith("/login") || pathname.startsWith("/signup"); @@ -18,7 +17,7 @@ export default function AdminLayout({ return (
{!isAuthPage && } -
{children}
+
{children}
); } \ No newline at end of file diff --git a/src/app/(admin)/admin/(auth)/login/page.tsx b/src/app/(admin)/admin/(auth)/login/page.tsx index edd5527..bb02231 100644 --- a/src/app/(admin)/admin/(auth)/login/page.tsx +++ b/src/app/(admin)/admin/(auth)/login/page.tsx @@ -12,14 +12,12 @@ export default function AdminLoginPage() { const handleLogin = (e: React.FormEvent) => { e.preventDefault(); - if (isDisabled) return; - setToastMsg("토스트 테스트"); }; return ( -
+
@@ -27,7 +25,7 @@ export default function AdminLoginPage() {
-
+
관리자 로그인
@@ -40,9 +38,9 @@ export default function AdminLoginPage() { w-full h-[44px] px-[16px] rounded-[8px] - border border-[#EAE5E2] - bg-white - text-sm outline-none + border border-Subbrown-4 + bg-White + body_1_3 outline-none " placeholder="아이디" /> @@ -55,9 +53,9 @@ export default function AdminLoginPage() { w-full h-[44px] px-[16px] rounded-[8px] - border border-[#EAE5E2] - bg-white - text-sm outline-none + border border-Subbrown-4 + bg-White + body_1_3 outline-none " placeholder="비밀번호" type="password" @@ -70,27 +68,24 @@ export default function AdminLoginPage() { mt-[32px] w-full h-[44px] rounded-[8px] - text-sm font-semibold + body_1_1 ${ isDisabled - ? "bg-[#DADADA] text-[#9E9E9E] cursor-not-allowed" - : "bg-[#7B6154] text-white" + ? "bg-Gray-2 text-Gray-4 cursor-not-allowed" + : "bg-primary-1 text-White" } `} > 로그인 - - {/* Toast */} {toastMsg && ( setToastMsg(null)} /> )} -
);