diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..73ab239 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# API Endpoints +NEXT_PUBLIC_API_URL=https://api.checkmo.co.kr/api diff --git a/.gitignore b/.gitignore index 5ef6a52..7b8da95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/next.config.ts b/next.config.ts index 66e1566..baf92c4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,18 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ reactCompiler: true, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "image.aladin.co.kr", + }, + { + protocol: "https", + hostname: "checkmo-s3-presigned.s3.ap-northeast-2.amazonaws.com", + }, + ], + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 346fd42..1e77e57 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", @@ -288,6 +290,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 13073fa..eeddb52 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,11 @@ "lint": "eslint" }, "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", @@ -16,6 +21,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..e3166a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,21 @@ 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) + '@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 +44,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 @@ -152,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==} @@ -651,6 +691,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 +1948,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==} @@ -2297,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 @@ -2682,6 +2773,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 +4125,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/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 @@ + + + + diff --git a/src/app/(admin)/admin/(app)/groups/page.tsx b/src/app/(admin)/admin/(app)/groups/page.tsx new file mode 100644 index 0000000..8480249 --- /dev/null +++ b/src/app/(admin)/admin/(app)/groups/page.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useMemo, useState } from "react"; +import AdminSearchHeader from "@/components/layout/AdminSearchHeader"; + +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); + + const handleKeywordChange = (v: string) => { + setKeyword(v); + setPage(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)); + + const pageItems = useMemo(() => { + const start = (page - 1) * pageSize; + return filtered.slice(start, start + pageSize); + }, [filtered, page, pageSize]); + + 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]); + + const isFirst = page === 1; + const isLast = page === totalPages; + + return ( +
+
+ + + {/* 라인형 테이블 */} +
+ + + + + + + + + + + + + + + + + + + + + + + {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)/admin/(app)/layout.tsx b/src/app/(admin)/admin/(app)/layout.tsx new file mode 100644 index 0000000..a31ee7e --- /dev/null +++ b/src/app/(admin)/admin/(app)/layout.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import AdminHeader from "@/components/layout/AdminHeader"; + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + + const isAuthPage = + pathname.startsWith("/login") || + pathname.startsWith("/signup"); + + return ( +
+ {!isAuthPage && } +
{children}
+
+ ); +} \ No newline at end of file diff --git a/src/app/(admin)/admin/(app)/news/page.tsx b/src/app/(admin)/admin/(app)/news/page.tsx new file mode 100644 index 0000000..8bccbc5 --- /dev/null +++ b/src/app/(admin)/admin/(app)/news/page.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; + +import AdminSearchHeader from "@/components/layout/AdminSearchHeader"; + +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); + + const handleKeywordChange = (v: string) => { + setKeyword(v); + setPage(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)); + + const pageItems = useMemo(() => { + const start = (page - 1) * pageSize; + return filtered.slice(start, start + pageSize); + }, [filtered, page, pageSize]); + + 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]); + + const isFirst = page === 1; + const isLast = page === totalPages; + + return ( +
+
+ 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-primary-1 text-White body_1_1 hover:opacity-90" + > + 소식 등록 + + } + /> + + {/* 라인형 테이블 */} +
+ + + + + + + + + + + + + + + + + + + + + + + {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)/admin/(app)/page.tsx b/src/app/(admin)/admin/(app)/page.tsx new file mode 100644 index 0000000..6cc7623 --- /dev/null +++ b/src/app/(admin)/admin/(app)/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)/admin/(app)/stories/page.tsx b/src/app/(admin)/admin/(app)/stories/page.tsx new file mode 100644 index 0000000..e629be2 --- /dev/null +++ b/src/app/(admin)/admin/(app)/stories/page.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useMemo, useState } from "react"; +import AdminSearchHeader from "@/components/layout/AdminSearchHeader"; + +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); + + const handleKeywordChange = (v: string) => { + setKeyword(v); + setPage(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)); + + const pageItems = useMemo(() => { + const start = (page - 1) * pageSize; + return filtered.slice(start, start + pageSize); + }, [filtered, page, pageSize]); + + 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]); + + const isFirst = page === 1; + const isLast = page === totalPages; + + return ( +
+
+ + + {/* 라인형 테이블 */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + {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)/admin/(app)/users/page.tsx b/src/app/(admin)/admin/(app)/users/page.tsx new file mode 100644 index 0000000..e1ece21 --- /dev/null +++ b/src/app/(admin)/admin/(app)/users/page.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useMemo, useState } from "react"; +import AdminSearchHeader from "@/components/layout/AdminSearchHeader"; + +type UserRow = { + id: string; + name: string; + email: string; + phone: string; +}; + +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 = [ + { 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)); + + const pageItems = useMemo(() => { + const start = (page - 1) * pageSize; + return filtered.slice(start, start + pageSize); + }, [filtered, page, pageSize]); + + 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]); + + const isFirst = page === 1; + const isLast = page === totalPages; + + return ( +
+
+ + + {/* 라인형 테이블 */} +
+ + + + + + + + + + + + + + + + + + + + + {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/app/(admin)/admin/(auth)/login/page.tsx b/src/app/(admin)/admin/(auth)/login/page.tsx new file mode 100644 index 0000000..bb02231 --- /dev/null +++ b/src/app/(admin)/admin/(auth)/login/page.tsx @@ -0,0 +1,92 @@ +"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-Subbrown-4 + bg-White + body_1_3 outline-none + " + placeholder="아이디" + /> + + setPw(e.target.value)} + className=" + mt-[8px] + w-full h-[44px] + px-[16px] + rounded-[8px] + border border-Subbrown-4 + bg-White + body_1_3 outline-none + " + placeholder="비밀번호" + type="password" + /> + + +
+ + {toastMsg && ( + setToastMsg(null)} + /> + )} +
+
+ ); +} \ No newline at end of file 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/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 && ( + + )} +
+
+ ); + } + return (
{isLoginModalOpen && ( @@ -45,39 +62,56 @@ export default function HomePage() {

독서모임

-
-

- 사용자 추천 -

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

+ 사용자 추천 +

+
+ {isErrorMembers && ( +
+

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

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

사용자 추천이 없습니다.

+
+ )} + {!isErrorMembers && recommendedUsers.length > 0 && + recommendedUsers.slice(0, 3).map((u) => ( + console.log("subscribe", u.nickname)} + /> + ))} +
-
+ )}
{/* 책 이야기 카드 */}
- {DUMMY_STORIES.slice(0, 3).map((story) => ( + {stories.slice(0, 3).map((story) => ( router.push(`/stories/${story.bookStoryId}`)} /> ))}
@@ -101,24 +135,30 @@ export default function HomePage() {
- + {isLoggedIn && ( + + )}
{/* 책 이야기 카드 */}
- {DUMMY_STORIES.slice(0, 4).map((story) => ( + {stories.slice(0, 4).map((story) => ( router.push(`/stories/${story.bookStoryId}`)} /> ))}
@@ -133,9 +173,11 @@ export default function HomePage() { 독서모임 -
- -
+ {isLoggedIn && ( +
+ +
+ )}
{/* 소식 + 책 이야기 */} @@ -151,17 +193,21 @@ export default function HomePage() { {/* 책 이야기 카드 */}
- {DUMMY_STORIES.slice(0, 3).map((story) => ( + {stories.slice(0, 3).map((story) => ( router.push(`/stories/${story.bookStoryId}`)} /> ))}
diff --git a/src/app/(main)/search/page.tsx b/src/app/(main)/search/page.tsx index d899d30..f0be4d1 100644 --- a/src/app/(main)/search/page.tsx +++ b/src/app/(main)/search/page.tsx @@ -1,16 +1,18 @@ "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(); const router = useRouter(); const query = searchParams.get("q") || ""; const [searchValue, setSearchValue] = useState(query); - const [likedResults, setLikedResults] = useState>({}); + const [likedResults, setLikedResults] = useState>({}); useEffect(() => { setSearchValue(query); @@ -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.detailInfoList) || []; + }, [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, [result.isbn]: liked })) } onPencilClick={() => { - router.push(`/books/${result.id}`); //필요한지 확인 필요 + router.push(`/books/${result.isbn}`); //필요한지 확인 필요 }} onCardClick={() => { - router.push(`/books/${result.id}`); + router.push(`/books/${result.isbn}`); }} /> ))}
+ + {/* 무한 스크롤 로딩 트리거 */} +
+ {isFetchingNextPage && ( +
+ )} +
diff --git a/src/app/(main)/setting/news/page.tsx b/src/app/(main)/setting/news/page.tsx index de1f6d5..48dd93e 100644 --- a/src/app/(main)/setting/news/page.tsx +++ b/src/app/(main)/setting/news/page.tsx @@ -1,53 +1,70 @@ "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 && isError && ( +

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

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

등록된 소식이 없습니다.

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

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

+ )} +
+
); } 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 000322b..88e79d1 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,6 +61,8 @@ export default function PasswordChangePage() { label="기존 비밀번호" placeholder="기존 비밀번호를 입력해주세요" type="password" + value={currentPassword} + onChange={(e) => setCurrentPassword(e.target.value)} />
@@ -23,6 +70,8 @@ export default function PasswordChangePage() { label="새 비밀번호" placeholder="새 비밀번호를 입력해주세요" type="password" + value={newPassword} + onChange={(e) => setNewPassword(e.target.value)} /> {/* 비밀번호 확인 인풋 */}
@@ -30,13 +79,21 @@ export default function PasswordChangePage() { type="password" className="w-full bg-transparent outline-none body_1_3 text-Gray-7 placeholder:text-Gray-3" placeholder="비밀번호 확인" + value={confirmPassword} + onChange={(e) => setConfirmPassword(e.target.value)} />
-
diff --git a/src/app/(main)/setting/profile/page.tsx b/src/app/(main)/setting/profile/page.tsx index 02ff2ad..f8ff338 100644 --- a/src/app/(main)/setting/profile/page.tsx +++ b/src/app/(main)/setting/profile/page.tsx @@ -6,6 +6,8 @@ import ProfileImageSection from "@/components/base-ui/Settings/EditProfile/Profi import SettingsDetailLayout from "@/components/base-ui/Settings/SettingsDetailLayout"; 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(); @@ -17,12 +19,27 @@ export default function ProfileEditPage() { user?.categories || [] ); + // Profile Image Upload States + const [profileImageFile, setProfileImageFile] = useState(null); + const [previewImage, setPreviewImage] = useState( + user?.profileImageUrl || null + ); + + // 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); + // Reset check state if it matches original + setIsNicknameChecked(true); } }, [user]); @@ -36,23 +53,88 @@ 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 handleNicknameChange = (e: React.ChangeEvent) => { + const value = e.target.value; + // 회원가입과 동일한 닉네임 필터 로직: 영어 소문자 및 특수문자, 숫자만 사용 가능, 최대 20글자 + const filteredValue = value.replace(/[^a-z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/g, "").slice(0, 20); + setNickname(filteredValue); + + // 만약 원래 닉네임과 같다면 중복 확인 된 것으로 간주 + if (user && filteredValue === user.nickname) { + setIsNicknameChecked(true); + } else { + setIsNicknameChecked(false); + } + }; + + const handleCheckNickname = async () => { + if (!nickname) { + toast.error("닉네임을 입력해주세요!"); + return; + } + + // 본인 닉네임과 동일하면 체크할 필요 없음 + if (user && nickname === user.nickname) { + setIsNicknameChecked(true); + toast.success("기존 닉네임과 동일하므로 사용 가능합니다."); + return; + } + + try { + const response = await authService.checkNickname(nickname); + // Backend Spec: result: false (not duplicated/available), result: true (duplicated/taken) + if (response.isSuccess && response.result === false) { + setIsNicknameChecked(true); + toast.success("사용 가능한 닉네임입니다."); + } else { + toast.error("이미 사용 중인 닉네임입니다."); + setIsNicknameChecked(false); + } + } catch (error: any) { + toast.error(error.message || "닉네임 확인 중 오류가 발생했습니다."); + } + }; + const handleSave = () => { - console.log("Saving profile changes:", { - nickname, - intro, - name, - phone, - selectedCategories, + if (!isNicknameChecked) { + toast.error("닉네임 중복확인을 해주세요!"); + return; + } + + updateProfile({ + description: intro.slice(0, 20), + categories: selectedCategories, + profileImageFile, + currentProfileImageUrl: previewImage, + }, { + 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 = @@ -67,7 +149,9 @@ export default function ProfileEditPage() { {/* 닉네임 */} @@ -78,15 +162,17 @@ export default function ProfileEditPage() { setNickname(e.target.value)} + onChange={handleNicknameChange} placeholder="변경할 닉네임을 입력해주세요" />
@@ -100,6 +186,7 @@ export default function ProfileEditPage() { value={intro} onChange={(e) => setIntro(e.target.value)} placeholder="20자 이내로 작성해주세요" + maxLength={20} /> @@ -107,11 +194,11 @@ export default function ProfileEditPage() { {/* 이름 */}
-
+
setName(e.target.value)} + disabled={true} placeholder="이름을 입력해주세요" />
@@ -138,11 +225,12 @@ export default function ProfileEditPage() { {/* 저장 버튼 */}
diff --git a/src/app/(main)/stories/[id]/page.tsx b/src/app/(main)/stories/[id]/page.tsx index a3eaa88..fc34953 100644 --- a/src/app/(main)/stories/[id]/page.tsx +++ b/src/app/(main)/stories/[id]/page.tsx @@ -1,27 +1,39 @@ +"use client"; + 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 { getStoryById, getAdjacentStoryIds } from "@/data/dummyStories"; -import { notFound } from "next/navigation"; - -type Props = { - params: Promise<{ id: string }>; -}; +import { isValidUrl } from "@/utils/url"; +import { useParams } from "next/navigation"; +import { useStoryDetailQuery } from "@/hooks/queries/useStoryQueries"; -export default async function StoryDetailPage({ params }: Props) { - const { id } = await params; +export default function StoryDetailPage() { + const params = useParams(); + const id = params?.id as string; + const { data: story, isLoading, isError } = useStoryDetailQuery(Number(id)); - // id로 스토리 데이터 가져오기 - const story = getStoryById(Number(id)); + if (isLoading) { + return ( +
+
+
+ ); + } - // 스토리가 없으면 404 - if (!story) { - notFound(); + // 스토리가 없으면 404 UI + if (!story || isError) { + return ( +
+

404

+

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

+
+ ); } - // 이전/다음 스토리 ID - const { prevId, nextId } = getAdjacentStoryIds(Number(id)); + const prevId = story.prevBookStoryId !== 0 ? story.prevBookStoryId : null; + const nextId = story.nextBookStoryId !== 0 ? story.nextBookStoryId : null; + return (
@@ -56,30 +68,36 @@ export default async function StoryDetailPage({ params }: Props) {
{/* 메인 콘텐츠 영역 */}
- + {/* 책이야기 글 본문 */}
-

{story.title}

+

{story.bookStoryTitle}

- {story.content} + {story.description}

{/* 댓글 */}
- +
diff --git a/src/app/(main)/stories/new/page.tsx b/src/app/(main)/stories/new/page.tsx index 5d09d79..75689b9 100644 --- a/src/app/(main)/stories/new/page.tsx +++ b/src/app/(main)/stories/new/page.tsx @@ -3,41 +3,67 @@ 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"; +import { useBookDetailQuery } from "@/hooks/queries/useBookQueries"; +import { useCreateBookStoryMutation } from "@/hooks/mutations/useStoryMutations"; 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 createStoryMutation = useCreateBookStoryMutation(); const handleCancel = () => { router.back(); }; const handleSubmit = () => { - // TODO: 실제 저장 로직 구현 - console.log("저장:", { title, detail }); - router.push("/stories"); + if (!selectedBook) { + toast.error("책을 선택해 주세요."); + return; + } + if (!title.trim()) { + toast.error("제목을 입력해 주세요."); + return; + } + if (!detail.trim()) { + toast.error("내용을 입력해 주세요."); + 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: () => { + toast.success("스토리가 등록되었습니다!"); + router.push("/stories"); + }, + onError: (error) => { + console.error("스토리 등록 실패:", error); + toast.error("스토리 등록에 실패했습니다. 다시 시도해 주세요."); + } + }); }; - const handleBookSelect = (selectedBookId: number) => { - router.push(`/stories/new?bookId=${selectedBookId}`); + const handleBookSelect = (selectedIsbn: string) => { + router.push(`/stories/new?isbn=${selectedIsbn}`); }; return ( @@ -84,7 +110,7 @@ function StoryNewContent() { bookUrl={selectedBook.imgUrl} bookName={selectedBook.title} author={selectedBook.author} - bookDetail={selectedBook.detail} + bookDetail={selectedBook.description} onButtonClick={() => { setIsBookSelectModalOpen(true); }} @@ -125,9 +151,10 @@ function StoryNewContent() { diff --git a/src/app/(main)/stories/page.tsx b/src/app/(main)/stories/page.tsx index fa6ae07..dc49b4a 100644 --- a/src/app/(main)/stories/page.tsx +++ b/src/app/(main)/stories/page.tsx @@ -1,87 +1,162 @@ "use client"; +import { useEffect, useState } from "react"; import BookStoryCardLarge from "@/components/base-ui/BookStory/bookstory_card_large"; import ListSubscribeLarge from "@/components/base-ui/home/list_subscribe_large"; import { useRouter } from "next/navigation"; -import { DUMMY_STORIES } from "@/data/dummyStories"; import FloatingFab from "@/components/base-ui/Float"; - -// TODO: 실제 로그인 상태 여부는 나중에 -const isLoggedIn = false; // true: 로그인, false: 로그인X +import { useAuthStore } from "@/store/useAuthStore"; +import { useInfiniteStoriesQuery } from "@/hooks/queries/useStoryQueries"; +import { useRecommendedMembersQuery } from "@/hooks/queries/useMemberQueries"; +import { useMyClubsQuery } from "@/hooks/queries/useClubQueries"; +import { useInView } from "react-intersection-observer"; export default function StoriesPage() { const router = useRouter(); + const { isLoggedIn } = useAuthStore(); + const [selectedCategory, setSelectedCategory] = useState("all"); + + const { + data: storiesData, + isLoading: isLoadingStories, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteStoriesQuery(); + + const { data: membersData, isLoading: isLoadingMembers } = useRecommendedMembersQuery(isLoggedIn); + const { data: myClubsData, isLoading: isLoadingClubs } = useMyClubsQuery(); + const { ref, inView } = useInView({ + threshold: 0, + }); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); const handleCardClick = (id: number) => { router.push(`/stories/${id}`); }; + const isLoading = isLoadingStories || (isLoggedIn && isLoadingMembers) || isLoadingClubs; + + if (isLoading) { + return ( +
+
+
+ ); + } + + 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={getCategoryClassName("all")} + > 전체
-
+
setSelectedCategory("following")} + className={getCategoryClassName("following")} + > 구독중
-
- 긁적긁적 -
-
- 북적북적 -
+ {myClubsData?.clubList.map((club) => ( +
setSelectedCategory(club.clubId.toString())} + className={getCategoryClassName(club.clubId.toString())} + > + {club.clubName} +
+ ))}
{/* 메인 콘텐츠 영역 */}
- {/* 첫 번째 줄 */} - {DUMMY_STORIES.slice(0, 4).map((story) => ( + {/* 첫 번째 줄 (처음 4개) */} + {allStories.slice(0, 4).map((story) => (
handleCardClick(story.id)} + key={story.bookStoryId} + onClick={() => handleCardClick(story.bookStoryId)} className="cursor-pointer shrink-0" >
))} - {/* 두 번째 줄: 비로그인 시 사용자 추천 + 카드 3개, 로그인 시 카드 4개 */} - {!isLoggedIn && } - {DUMMY_STORIES.slice(4, isLoggedIn ? 8 : 7).map((story) => ( + {/* 두 번째 줄: 추천 멤버가 있을 경우에만 추천 영역 표시 */} + {recommendedMembers.length > 0 && ( + + )} + + {/* 나머지 카드들 */} + {allStories.slice(4).map((story) => (
handleCardClick(story.id)} + key={story.bookStoryId} + onClick={() => handleCardClick(story.bookStoryId)} className="cursor-pointer shrink-0" >
))}
+ + {/* 무한 스크롤 옵저버 타겟 */} + {hasNextPage && ( +
+ {isFetchingNextPage ? ( +
+ ) : ( +
+ )} +
+ )} {/* 글쓰기 버튼 */} router.push("/stories/new")} - /> + iconSrc="/icons_pencil.svg" + iconAlt="글쓰기" + onClick={() => router.push("/stories/new")} + />
); 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]/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..5f0cf3e --- /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; + + if (clubMemberId === undefined) { + 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/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/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)} - /> - ))} -
-
- ))} -
- ); -} diff --git a/src/app/groups/[id]/bookcase/[bookId]/MeetingTabSection.tsx b/src/app/groups/[id]/bookcase/[bookId]/MeetingTabSection.tsx new file mode 100644 index 0000000..ed23300 --- /dev/null +++ b/src/app/groups/[id]/bookcase/[bookId]/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]/meeting/page.tsx b/src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx index a93dd79..643ee95 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 (
@@ -270,10 +287,10 @@ export default function MeetingPage({
); } diff --git a/src/app/groups/[id]/bookcase/[bookId]/page.tsx b/src/app/groups/[id]/bookcase/[bookId]/page.tsx index 60d6366..c713fdd 100644 --- a/src/app/groups/[id]/bookcase/[bookId]/page.tsx +++ b/src/app/groups/[id]/bookcase/[bookId]/page.tsx @@ -1,45 +1,75 @@ "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 MeetingTabSection from "./MeetingTabSection"; import { MOCK_BOOK_DETAIL, - MOCK_MEETING_INFO, MOCK_DEBATE_TOPICS, - MOCK_TEAMS_DATA, MOCK_REVIEWS, -} from './dummy'; +} from "./dummy"; + + +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 = () => { + router.push( + `/groups/${groupId}/admin/bookcase/${meetingId}?meetingName=${encodeURIComponent(MOCK_BOOK_DETAIL.title)}` + ); + }; 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 +127,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/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/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" diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 05f0892..04e6c06 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import BottomNav from "@/components/layout/BottomNav"; import { Toaster } from "react-hot-toast"; import { AuthProvider } from "@/components/auth/AuthProvider"; import { HeaderTitleProvider } from "@/contexts/HeaderTitleContext"; +import Providers from "@/app/providers"; import "@/app/globals.css"; import { Analytics } from "@vercel/analytics/react"; import { SpeedInsights } from "@vercel/speed-insights/next"; @@ -44,13 +45,15 @@ export default function RootLayout({ - - - {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/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/BookStory/bookstory_card.tsx b/src/components/base-ui/BookStory/bookstory_card.tsx index 15a9e3f..054d172 100644 --- a/src/components/base-ui/BookStory/bookstory_card.tsx +++ b/src/components/base-ui/BookStory/bookstory_card.tsx @@ -14,21 +14,11 @@ type Props = { commentCount?: number; onSubscribeClick?: () => void; subscribeText?: string; + hideSubscribeButton?: boolean; + onClick?: () => void; }; -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, @@ -42,10 +32,13 @@ export default function BookStoryCard({ commentCount = 1, onSubscribeClick, subscribeText = "구독", + hideSubscribeButton = false, + onClick, }: Props) { return (

{authorName}

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

- + {!hideSubscribeButton && ( + + )}
{/* 2. 책 이미지 (모바일: flex-1 / 데스크탑: h-36) */} diff --git a/src/components/base-ui/BookStory/bookstory_card_large.tsx b/src/components/base-ui/BookStory/bookstory_card_large.tsx index 866fe5c..967d989 100644 --- a/src/components/base-ui/BookStory/bookstory_card_large.tsx +++ b/src/components/base-ui/BookStory/bookstory_card_large.tsx @@ -15,21 +15,10 @@ type Props = { onSubscribeClick?: () => void; subscribeText?: string; onClick?: () => void; + hideSubscribeButton?: boolean; }; -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 BookStoryCardLarge({ authorName, @@ -44,6 +33,7 @@ export default function BookStoryCardLarge({ onSubscribeClick, subscribeText = "구독", onClick, + hideSubscribeButton = false, }: Props) { return (

{authorName}

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

- + {!hideSubscribeButton && ( + + )}
{/* 책 이미지 */} 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 && ( + + )} {/* 햄버거 */}
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 ( + + ); + })} +
+
+ ); +} 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/ChatTeamSelectModal.tsx b/src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx new file mode 100644 index 0000000..f46b747 --- /dev/null +++ b/src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx @@ -0,0 +1,335 @@ +"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"; + +const HOVER_ICON = + "cursor-pointer transition-[filter,transform] duration-150 ease-out hover:brightness-50 "; +const HOVER_SURFACE = + "cursor-pointer transition-[filter,transform] duration-150 ease-out hover:brightness-95 "; +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 영역 (메시지 리스트 들어올 자리) +
+
+
+ + {/* 입력창 */} +
+
+ + + +
+
+ + )} +
+
+ ); +} 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/components/base-ui/Comment/comment_item.tsx b/src/components/base-ui/Comment/comment_item.tsx index 5bdf30c..419e616 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,134 @@ 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 && ( + + )} + + ) : ( + /* 남의 댓글일 때 */ + + )} +
+ )} +
-
+ ) : ( + /* 수정 모드 */ +
+