diff --git a/package-lock.json b/package-lock.json index 1e77e57..e0d26eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,18 @@ "version": "0.1.0", "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", "next": "^16.1.6", - "react": "19.2.0", + "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" }, @@ -317,6 +321,20 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=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", @@ -1894,6 +1912,59 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.93.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.93.0.tgz", + "integrity": "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.91.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.3.tgz", + "integrity": "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.93.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.90.20", + "react": "^18 || ^19" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -5994,6 +6065,21 @@ "react-dom": ">=16" } }, + "node_modules/react-intersection-observer": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.0.3.tgz", + "integrity": "sha512-luICLMbs0zxTO/70Zy7K5jOXkABPEVSAF8T3FdZUlctsrIaPLmx8TZe2SSA+CY2HGWfz2INyNTnp82pxNNsShA==", + "license": "MIT", + "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 + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index eeddb52..40fc017 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@vercel/speed-insights": "^1.3.1", "js-cookie": "^3.0.5", "next": "^16.1.6", - "react": "19.2.0", + "react": "^19.2.0", "react-dom": "19.2.0", "react-hot-toast": "^2.6.0", "react-intersection-observer": "^10.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3166a0..6fbfb08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,7 +36,7 @@ importers: specifier: ^16.1.6 version: 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: - specifier: 19.2.0 + specifier: ^19.2.0 version: 19.2.0 react-dom: specifier: 19.2.0 diff --git a/src/app/(admin)/admin/(app)/users/[id]/page.tsx b/src/app/(admin)/admin/(app)/users/[id]/page.tsx new file mode 100644 index 0000000..95505bc --- /dev/null +++ b/src/app/(admin)/admin/(app)/users/[id]/page.tsx @@ -0,0 +1,62 @@ +"use client"; + +import React, { useState } from "react"; +import AdminUserProfile from "@/components/base-ui/Admin/users/AdminUserProfile"; +import AdminCategory from "@/components/base-ui/Admin/users/AdminCategory"; +import AdminUserTabs, { + type AdminUserTabId, +} from "@/components/base-ui/Admin/users/AdminUserTab"; + +import MeetingList from "@/components/base-ui/Admin/users/MeetingList"; +import BookStoryList from "@/components/base-ui/Admin/users/BookStoryList"; +import NewsList from "@/components/base-ui/Admin/users/NewsList"; +import ReportList from "@/components/base-ui/Admin/users/ReportList"; + +export default function Page() { + const [activeTab, setActiveTab] = useState("meetings"); + + // 더미 + // TODO: 관리자 사용자 상세 조회 API 연동 후, params.id 기반으로 user 데이터 교체 예정 + const user = { + userId: "hy_0716", + name: "윤현일", + email: "yh9839@naver.com", + phone: "010-1234-5678", + intro: + "이건 다양한 책을 읽고 서로의 생각을 나누는 책무새라서 시작했습니다. 한 권의 책이 주는 작은 울림이 일상에 변화를 만든다고 믿어요.", + profileImage: null as string | null, + }; + + // TODO: 관리자 사용자 카테고리 조회 API 연동 후 params.id 기반으로 교체 예정 + const selectedCategories = [ + "KOREAN_NOVEL", + "ESSAY", + "HUMANITIES", + "SELF_IMPROVEMENT", + ]; + + return ( +
+
+
+
+ +
+ + +
+ +
+ + + {activeTab === "meetings" && } + {activeTab === "stories" && } + {activeTab === "posts" && } + {activeTab === "reports" && } +
+
+
+ ); +} \ 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 index e1ece21..5dd63ae 100644 --- a/src/app/(admin)/admin/(app)/users/page.tsx +++ b/src/app/(admin)/admin/(app)/users/page.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import AdminSearchHeader from "@/components/layout/AdminSearchHeader"; +import Link from "next/link"; type UserRow = { id: string; @@ -129,9 +130,12 @@ export default function UsersPage() { {u.email} {u.phone} - + ))} diff --git a/src/components/base-ui/Admin/users/AdminCategory.tsx b/src/components/base-ui/Admin/users/AdminCategory.tsx new file mode 100644 index 0000000..0e9a370 --- /dev/null +++ b/src/components/base-ui/Admin/users/AdminCategory.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { CATEGORIES } from "@/constants/categories"; + +interface Props { + selectedCategories?: string[]; +} + +export default function AdminCategory({ + selectedCategories = [], +}: Props) { + return ( +
+ {/* 4 x 4 grid */} +
+ {CATEGORIES.map((cat) => { + const isSelected = selectedCategories.includes(cat.value); + + return ( +
+ + {cat.label} + +
+ ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/base-ui/Admin/users/AdminUserProfile.tsx b/src/components/base-ui/Admin/users/AdminUserProfile.tsx new file mode 100644 index 0000000..0085aea --- /dev/null +++ b/src/components/base-ui/Admin/users/AdminUserProfile.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; + +type AdminUserProfileProps = { + user: { + userId: string; + name: string; + intro: string; + profileImage?: string | null; + email?: string | null; + phone?: string | null; + }; +}; + +const AdminUserProfile = ({ user }: AdminUserProfileProps) => { + const email = user.email ?? "example@email.com"; + const phone = user.phone ?? "010-0000-0000"; + + return ( +
+
+
+
+ {user.profileImage ? ( + Profile + ) : ( +
+ )} +
+ +
+
아이디
+
{user.userId}
+ +
이름
+
{user.name}
+ +
이메일
+
{email}
+ +
전화번호
+
{phone}
+
+
+ +

+ {user.intro} +

+
+
+ ); +}; + +export default AdminUserProfile; \ No newline at end of file diff --git a/src/components/base-ui/Admin/users/AdminUserTab.tsx b/src/components/base-ui/Admin/users/AdminUserTab.tsx new file mode 100644 index 0000000..641b927 --- /dev/null +++ b/src/components/base-ui/Admin/users/AdminUserTab.tsx @@ -0,0 +1,41 @@ +"use client"; + +import React from "react"; + +export type AdminUserTabId = "meetings" | "stories" | "posts" | "reports"; + +const USER_TABS: Array<{ id: AdminUserTabId; label: string }> = [ + { id: "meetings", label: "가입 모임" }, + { id: "stories", label: "책 이야기" }, + { id: "posts", label: "등록 소식" }, + { id: "reports", label: "신고 목록" }, +]; + +interface AdminUserTabsProps { + activeTab: AdminUserTabId; + onTabChange: (id: AdminUserTabId) => void; +} + +const AdminUserTabs = ({ activeTab, onTabChange }: AdminUserTabsProps) => { + return ( +
+ {USER_TABS.map((tab) => ( + + ))} +
+ ); +}; + +export default AdminUserTabs; \ No newline at end of file diff --git a/src/components/base-ui/Admin/users/BookStoryList.tsx b/src/components/base-ui/Admin/users/BookStoryList.tsx new file mode 100644 index 0000000..709dec1 --- /dev/null +++ b/src/components/base-ui/Admin/users/BookStoryList.tsx @@ -0,0 +1,91 @@ +"use client"; + +import React, { useEffect } from "react"; +import BookStoryCard from "./items/AdminBookStoryCard"; +import { useMyInfiniteStoriesQuery } from "@/hooks/queries/useStoryQueries"; +import { useInView } from "react-intersection-observer"; + +type Props = { + /** 관리자 상세 페이지의 대상 유저 ID (현재는 구조만 맞추기 위해 받음) */ + userId: string; +}; + +const BookStoryList = ({ userId }: Props) => { + /** + * TODO: + * 관리자 전용 "특정 사용자(userId) 책 이야기 목록" API/쿼리가 아직 없어서 + * 임시로 "내 책 이야기" 쿼리를 사용 중입니다. + * 추후 useUserInfiniteStoriesQuery(userId) 같은 형태로 교체 예정. + * + * - 현재 userId는 props로 받고만 있으며, 쿼리 교체 시 사용됩니다. + */ + void userId; + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useMyInfiniteStoriesQuery(); + + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const stories = data?.pages.flatMap((page) => page.basicInfoList) || []; + + return ( +
+ {isLoading && ( +

로딩 중...

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

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

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

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

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

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

+ )} +
+ ); +}; + +export default BookStoryList; \ No newline at end of file diff --git a/src/components/base-ui/Admin/users/MeetingList.tsx b/src/components/base-ui/Admin/users/MeetingList.tsx new file mode 100644 index 0000000..5361741 --- /dev/null +++ b/src/components/base-ui/Admin/users/MeetingList.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React from "react"; +import AdminMeetingCard from "./items/AdminMeetingCard"; +import { useMyClubsQuery } from "@/hooks/queries/useClubQueries"; + +type Props = { + /** 관리자 상세 페이지의 대상 유저 ID */ + userId: string; +}; + +const MeetingList = ({ userId }: Props) => { + /** + * TODO: + * 관리자 전용 "특정 사용자(userId) 모임 목록 조회" API가 아직 없어 + * 임시로 useMyClubsQuery()를 사용 중입니다. + * 추후 useUserClubsQuery(userId) 형태로 교체 예정입니다. + */ + void userId; // 현재는 사용하지 않지만 구조 통일을 위해 유지 + + const { data, isLoading, isError } = useMyClubsQuery(); + const clubs = data?.clubList || []; + + if (isLoading) { + return ( +
+ 불러오는 중... +
+ ); + } + + if (isError) { + return ( +
+ 독서 모임을 불러오는 데 실패했습니다. +
+ ); + } + + if (clubs.length === 0) { + return ( +
+

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

+
+ ); + } + + return ( +
+ {clubs.map((club) => ( + + ))} +
+ ); +}; + +export default MeetingList; \ No newline at end of file diff --git a/src/components/base-ui/Admin/users/NewsList.tsx b/src/components/base-ui/Admin/users/NewsList.tsx new file mode 100644 index 0000000..b7f9052 --- /dev/null +++ b/src/components/base-ui/Admin/users/NewsList.tsx @@ -0,0 +1,32 @@ +"use client"; + +import NewsItem from "./items/AdminNewsItem"; + +type Props = { + /** 관리자 상세 페이지의 대상 유저 ID */ + userId: string; +}; + +// TODO: 관리자 사용자 뉴스 조회 API 연동 후 userId 기반 실제 데이터로 교체 예정 +export default function NewsList({ userId }: Props) { + // 현재는 구조 통일을 위해 props만 받고 사용하지 않음 + void userId; + + const posts = [ + { + id: 1, + imageUrl: "/...", + title: "...", + content: "...", + date: "2025.01.01", + }, + ]; + + return ( +
+ {posts.map((post) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/base-ui/Admin/users/ReportList.tsx b/src/components/base-ui/Admin/users/ReportList.tsx new file mode 100644 index 0000000..c37aa18 --- /dev/null +++ b/src/components/base-ui/Admin/users/ReportList.tsx @@ -0,0 +1,39 @@ +"use client"; + +import ReportItem from "./items/AdminReportItem"; + +type Props = { + /** 관리자 상세 페이지의 대상 유저 ID */ + userId: string; +}; + +// TODO: 관리자 사용자 신고 목록 조회 API 연동 후 userId 기반 실제 데이터로 교체 예정 +export default function ReportList({ userId }: Props) { + // 현재는 구조 통일을 위해 props만 받고 사용하지 않음 + void userId; + + const reports = [ + { + id: 1, + category: "일반", + reporterName: "hy_0716", + content: "부적절한 언어 사용이 있습니다.", + date: "2025.01.01", + }, + { + id: 2, + category: "욕설", + reporterName: "user123", + content: "커뮤니티 가이드 위반입니다.", + date: "2025.01.02", + }, + ]; + + return ( +
+ {reports.map((report) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/base-ui/Admin/users/items/AdminBookStoryCard.tsx b/src/components/base-ui/Admin/users/items/AdminBookStoryCard.tsx new file mode 100644 index 0000000..63d2a6a --- /dev/null +++ b/src/components/base-ui/Admin/users/items/AdminBookStoryCard.tsx @@ -0,0 +1,168 @@ +"use client"; + +import Image from "next/image"; +import { formatTimeAgo } from "@/utils/time"; + +type Props = { + authorName: string; + profileImgSrc?: string; + createdAt: string; + viewCount: number; + coverImgSrc?: string; + title: string; + content: string; + likeCount?: number; + commentCount?: number; + + onSubscribeClick?: () => void; + subscribeText?: string; + hideSubscribeButton?: boolean; + + onDeleteClick?: () => void; + hideDeleteButton?: boolean; + + onClick?: () => void; +}; + +export default function BookStoryCard({ + authorName, + profileImgSrc = "/profile2.svg", + createdAt, + viewCount, + coverImgSrc = "/bookstorycard.svg", + title, + content, + likeCount = 1, + commentCount = 1, + onSubscribeClick, + subscribeText = "구독", + hideSubscribeButton = false, + onDeleteClick, + hideDeleteButton = false, + onClick, +}: Props) { + return ( +
+ {/* 우측 상단 삭제 버튼 */} + {!hideDeleteButton && ( + + )} + + {/* 1. 상단 프로필 (모바일 숨김 / 데스크탑 노출) */} +
+
+ {authorName} +
+ +
+

{authorName}

+

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

+
+ + {!hideSubscribeButton && ( + + )} +
+ + {/* 2. 책 이미지 (모바일: flex-1 / 데스크탑: h-36) */} +
+ {coverImgSrc && ( + cover + )} +
+ + {/* 3. 제목 + 내용 */} +
+ {/* 제목 */} +

+ {title} +

+ + {/* 내용 */} +
+ {content} +
+
+ + {/* 4. 하단 통계 (좋아요/댓글) */} + {/* 모바일 Footer */} +
+
+ 좋아요 + {likeCount} +
+
+ 댓글 + {commentCount} +
+
+ + {/* 데스크탑 Footer */} +
+
+ 좋아요 + 좋아요 {likeCount} +
+
+
+ 댓글 + 댓글 {commentCount} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/base-ui/Admin/users/items/AdminMeetingCard.tsx b/src/components/base-ui/Admin/users/items/AdminMeetingCard.tsx new file mode 100644 index 0000000..2628d20 --- /dev/null +++ b/src/components/base-ui/Admin/users/items/AdminMeetingCard.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +import { MyClubInfo } from "@/types/club"; + +interface MyMeetingCardProps { + club: MyClubInfo; +} + +const MyMeetingCard = ({ club }: MyMeetingCardProps) => { + return ( +
+ + {club.clubName} + + + +
+ ); +}; + +export default MyMeetingCard; \ No newline at end of file diff --git a/src/components/base-ui/Admin/users/items/AdminNewsItem.tsx b/src/components/base-ui/Admin/users/items/AdminNewsItem.tsx new file mode 100644 index 0000000..50b464c --- /dev/null +++ b/src/components/base-ui/Admin/users/items/AdminNewsItem.tsx @@ -0,0 +1,73 @@ +"use client"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; + +type NewsListProps = { + id?: number; + imageUrl: string; + title: string; + content: string; + date: string; + className?: string; +}; + +export default function NewsList({ + id, + imageUrl, + title, + content, + date, + className = "", +}: NewsListProps) { + const router = useRouter(); + + const handleClick = () => { + if (id) { + router.push(`/news/${id}`); + } + }; + + return ( +
+ {/* 좌측 그룹 */} +
+ {/* 이미지 */} +
+ {title} +
+ + {/* 텍스트 컬럼 */} +
+ {/* 제목 */} +
+

+ {title} +

+
+ + {/* 내용 */} +

+ {content} +

+
+
+ + {/* 우측 날짜 */} +
+ + {date} + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/base-ui/Admin/users/items/AdminReportItem.tsx b/src/components/base-ui/Admin/users/items/AdminReportItem.tsx new file mode 100644 index 0000000..f4b98bf --- /dev/null +++ b/src/components/base-ui/Admin/users/items/AdminReportItem.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Image from "next/image"; + +type Props = { + category: string; + reporterName: string; + content: string; + date: string; +}; + +export default function ReportItem({ + category, + reporterName, + content, + date, +}: Props) { + return ( +
+ {/* 카테고리 뱃지 */} +
+ + {category} + +
+ + {/* 컨텐츠 영역 */} +
+
+
+
+ profile +
+ {reporterName} +
+ + {date} + +
+ +

+ {content} +

+
+
+ ); +}