diff --git a/prisma/seed.js b/prisma/seed.js index eba5007..f89f2af 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -168,7 +168,12 @@ async function upsertRoles() { async function upsertAdmin() { const admin = await prisma.user.upsert({ where: { nickname: "admin" }, - update: { passwordHash: hashPassword("1234") }, + update: { + passwordHash: hashPassword("1234"), + grade: 7, + points: 1650000, + level: 200, + }, create: { nickname: "admin", name: "Administrator", @@ -177,6 +182,9 @@ async function upsertAdmin() { passwordHash: hashPassword("1234"), agreementTermsAt: new Date(), authLevel: "ADMIN", + grade: 7, + points: 1650000, + level: 200, }, }); diff --git a/public/svgs/01_bronze.svg b/public/svgs/01_bronze.svg new file mode 100644 index 0000000..35258e7 --- /dev/null +++ b/public/svgs/01_bronze.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/svgs/02_silver.svg.svg b/public/svgs/02_silver.svg.svg new file mode 100644 index 0000000..1c8cf00 --- /dev/null +++ b/public/svgs/02_silver.svg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/svgs/03_gold.svg b/public/svgs/03_gold.svg new file mode 100644 index 0000000..b22c300 --- /dev/null +++ b/public/svgs/03_gold.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/svgs/04_platinum.svg b/public/svgs/04_platinum.svg new file mode 100644 index 0000000..9f5a33c --- /dev/null +++ b/public/svgs/04_platinum.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/svgs/05_diamond.svg b/public/svgs/05_diamond.svg new file mode 100644 index 0000000..2ed7671 --- /dev/null +++ b/public/svgs/05_diamond.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/svgs/06_master.svg b/public/svgs/06_master.svg new file mode 100644 index 0000000..c74b612 --- /dev/null +++ b/public/svgs/06_master.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/svgs/07_grandmaster.svg b/public/svgs/07_grandmaster.svg new file mode 100644 index 0000000..6e58412 --- /dev/null +++ b/public/svgs/07_grandmaster.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/svgs/08_god.svg b/public/svgs/08_god.svg new file mode 100644 index 0000000..ec4544b --- /dev/null +++ b/public/svgs/08_god.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/uploads/1762052938422-has0h33j4x6.webp b/public/uploads/1762052938422-has0h33j4x6.webp new file mode 100644 index 0000000..a02680e Binary files /dev/null and b/public/uploads/1762052938422-has0h33j4x6.webp differ diff --git a/public/uploads/1762053004600-0ewlk5af03i.webp b/public/uploads/1762053004600-0ewlk5af03i.webp new file mode 100644 index 0000000..5eb8c1c Binary files /dev/null and b/public/uploads/1762053004600-0ewlk5af03i.webp differ diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 139d97a..b223126 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -48,6 +48,7 @@ const listQuerySchema = z.object({ sort: z.enum(["recent", "popular"]).default("recent").optional(), tag: z.string().optional(), // Tag.slug author: z.string().optional(), // User.nickname contains + authorId: z.string().optional(), // User.userId exact match start: z.coerce.date().optional(), // createdAt >= start end: z.coerce.date().optional(), // createdAt <= end }); @@ -58,7 +59,7 @@ export async function GET(req: Request) { if (!parsed.success) { return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); } - const { page, pageSize, boardId, q, sort = "recent", tag, author, start, end } = parsed.data; + const { page, pageSize, boardId, q, sort = "recent", tag, author, authorId, start, end } = parsed.data; const where = { NOT: { status: "deleted" as const }, ...(boardId ? { boardId } : {}), @@ -75,7 +76,11 @@ export async function GET(req: Request) { postTags: { some: { tag: { slug: tag } } }, } : {}), - ...(author + ...(authorId + ? { + authorId, + } + : author ? { author: { nickname: { contains: author } }, } diff --git a/src/app/boards/[id]/page.tsx b/src/app/boards/[id]/page.tsx index 0605df8..50aa719 100644 --- a/src/app/boards/[id]/page.tsx +++ b/src/app/boards/[id]/page.tsx @@ -3,6 +3,11 @@ import { HeroBanner } from "@/app/components/HeroBanner"; import { BoardToolbar } from "@/app/components/BoardToolbar"; import { headers } from "next/headers"; import prisma from "@/lib/prisma"; +import { UserAvatar } from "@/app/components/UserAvatar"; +import { RankIcon1st } from "@/app/components/RankIcon1st"; +import { RankIcon2nd } from "@/app/components/RankIcon2nd"; +import { RankIcon3rd } from "@/app/components/RankIcon3rd"; +import { GradeIcon } from "@/app/components/GradeIcon"; // Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다. export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) { @@ -29,15 +34,15 @@ export default async function BoardDetail({ params, searchParams }: { params: an }); const isSpecialRanking = boardView?.listViewType?.key === "list_special_rank"; - let rankingItems: { userId: string; nickname: string; points: number }[] = []; + let rankingItems: { userId: string; nickname: string; points: number; profileImage: string | null; grade: number }[] = []; if (isSpecialRanking) { const topUsers = await prisma.user.findMany({ - select: { userId: true, nickname: true, points: true }, + select: { userId: true, nickname: true, points: true, profileImage: true, grade: true }, where: { status: "active" }, orderBy: { points: "desc" }, take: 100, }); - rankingItems = topUsers.map((u) => ({ userId: u.userId, nickname: u.nickname, points: u.points })); + rankingItems = topUsers.map((u) => ({ userId: u.userId, nickname: u.nickname, points: u.points, profileImage: u.profileImage, grade: u.grade })); } return (
@@ -54,24 +59,46 @@ export default async function BoardDetail({ params, searchParams }: { params: an {!isSpecialRanking && }
{isSpecialRanking ? ( -
-
-

포인트 랭킹

-
-
    - {rankingItems.map((i, idx) => ( -
  1. -
    - {idx + 1} - {i.nickname || "회원"} +
    +
    + {rankingItems.map((i, idx) => { + const rank = idx + 1; + return ( +
    +
    +
    + {(rank === 1 || rank === 2 || rank === 3) && ( +
    + {rank === 1 && } + {rank === 2 && } + {rank === 3 && } +
    + )} +
    + {rank}위 +
    +
    + + + {i.nickname || "회원"} + + +
    +
    +
    + + + + {i.points.toLocaleString()} +
    +
    -
    {i.points}점
    -
  2. - ))} - {rankingItems.length === 0 && ( -
  3. 랭킹 데이터가 없습니다.
  4. - )} -
+ ); + })} +
+ {rankingItems.length === 0 && ( +
랭킹 데이터가 없습니다.
+ )}
) : ( { + // 쿠키에 uid가 없으면 어드민으로 자동 로그인 + const checkCookie = () => { + const cookies = document.cookie.split(";"); + const uidCookie = cookies.find((cookie) => cookie.trim().startsWith("uid=")); + + if (!uidCookie) { + // 어드민 사용자 정보 가져오기 + fetch("/api/auth/session") + .then((res) => res.json()) + .then((data) => { + if (!data.ok || !data.user) { + // 어드민으로 로그인 시도 + fetch("/api/auth/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nickname: "admin", password: "1234" }), + }) + .then((res) => res.json()) + .then((loginData) => { + if (loginData.ok) { + // 페이지 새로고침하여 적용 + window.location.reload(); + } + }) + .catch(() => { + // 에러 무시 + }); + } + }) + .catch(() => { + // 에러 무시 + }); + } + }; + + checkCookie(); + }, []); + + return null; +} + diff --git a/src/app/components/BoardPanelClient.tsx b/src/app/components/BoardPanelClient.tsx index a9bad6c..5bebc63 100644 --- a/src/app/components/BoardPanelClient.tsx +++ b/src/app/components/BoardPanelClient.tsx @@ -8,6 +8,7 @@ import { RankIcon3rd } from "./RankIcon3rd"; import { UserAvatar } from "./UserAvatar"; import { ImagePlaceholderIcon } from "./ImagePlaceholderIcon"; import { PostList } from "./PostList"; +import { GradeIcon } from "./GradeIcon"; type BoardMeta = { id: string; @@ -21,12 +22,14 @@ type UserData = { nickname: string | null; points: number; profileImage: string | null; + grade: number; }; type PostData = { id: string; title: string; createdAt: Date; + content?: string | null; attachments?: { url: string }[]; stat?: { recommendCount: number | null }; }; @@ -61,6 +64,27 @@ export function BoardPanelClient({ return `${yyyy}.${mm}.${dd}`; } + function stripHtml(html: string | null | undefined): string { + if (!html) return ""; + // HTML 태그 제거 + return html.replace(/<[^>]*>/g, "").trim(); + } + + function extractImageFromContent(content: string | null | undefined): string | null { + if (!content) return null; + // img 태그에서 src 속성 추출 + const imgMatch = content.match(/]+src=["']([^"']+)["'][^>]*>/i); + if (imgMatch && imgMatch[1]) { + return imgMatch[1]; + } + // figure 안의 img 태그도 확인 + const figureMatch = content.match(/]*>.*?]+src=["']([^"']+)["'][^>]*>/is); + if (figureMatch && figureMatch[1]) { + return figureMatch[1]; + } + return null; + } + const isTextMain = board.mainTypeKey === "main_text"; const isSpecialRank = board.mainTypeKey === "main_special_rank"; const isPreview = board.mainTypeKey === "main_preview"; @@ -103,6 +127,9 @@ export function BoardPanelClient({ height={150} className="w-full h-full object-cover rounded-none" /> +
+ +
@@ -165,14 +192,15 @@ export function BoardPanelClient({
{selectedBoardData.previewPosts.map((post) => { - const firstImage = post.attachments?.[0]?.url; + // attachments에서 이미지를 먼저 찾고, 없으면 content에서 추출 + const firstImage = post.attachments?.[0]?.url || extractImageFromContent(post.content); return (
{firstImage ? ( {post.title} ) : ( @@ -191,7 +219,7 @@ export function BoardPanelClient({
n
- {post.title} + {stripHtml(post.title)}
@@ -244,14 +272,14 @@ export function BoardPanelClient({ {selectedBoardData.textPosts.map((p) => (
  • -
    +
    n
    - {p.title} + {stripHtml(p.title)} +{p.stat?.recommendCount ?? 0} -
    + {formatDateYmd(p.createdAt)}
  • diff --git a/src/app/components/GradeIcon.tsx b/src/app/components/GradeIcon.tsx new file mode 100644 index 0000000..6e2c975 --- /dev/null +++ b/src/app/components/GradeIcon.tsx @@ -0,0 +1,45 @@ +export function GradeIcon({ grade, width = 32, height = 32, className }: { grade: number; width?: number; height?: number; className?: string }) { + // grade: 0~7 (bronze, silver, gold, platinum, diamond, master, grandmaster, god) + const gradeImages = [ + "/svgs/01_bronze.svg", + "/svgs/02_silver.svg.svg", + "/svgs/03_gold.svg", + "/svgs/04_platinum.svg", + "/svgs/05_diamond.svg", + "/svgs/06_master.svg", + "/svgs/07_grandmaster.svg", + "/svgs/08_god.svg", + ]; + + const gradeIndex = Math.min(7, Math.max(0, grade)); + const imageSrc = gradeImages[gradeIndex]; + + return ( + {`등급 + ); +} + +// 등급 숫자를 등급 이름으로 변환하는 함수 +export function getGradeName(grade: number): string { + const gradeNames = [ + "Bronze", + "Silver", + "Gold", + "Platinum", + "Diamond", + "Master", + "Grandmaster", + "God", + ]; + + const gradeIndex = Math.min(7, Math.max(0, grade)); + return gradeNames[gradeIndex]; +} + diff --git a/src/app/components/PostList.tsx b/src/app/components/PostList.tsx index 42c055f..274d67c 100644 --- a/src/app/components/PostList.tsx +++ b/src/app/components/PostList.tsx @@ -29,20 +29,37 @@ type Resp = { const fetcher = (url: string) => fetch(url).then((r) => r.json()); -export function PostList({ boardId, sort = "recent", q, tag, author, start, end, variant = "default", newPostHref }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string }) { - const pageSize = 10; +function stripHtml(html: string | null | undefined): string { + if (!html) return ""; + // HTML 태그 제거 + return html.replace(/<[^>]*>/g, "").trim(); +} + +export function PostList({ boardId, sort = "recent", q, tag, author, authorId, start, end, variant = "default", newPostHref }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; authorId?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string }) { const sp = useSearchParams(); const listContainerRef = useRef(null); const [lockedMinHeight, setLockedMinHeight] = useState(null); + + // board 변형에서는 URL에서 pageSize를 읽고, 기본값은 20 + const defaultPageSize = variant === "board" ? 20 : 10; + const pageSizeParam = sp.get("pageSize"); + const pageSize = pageSizeParam ? Math.min(50, Math.max(10, parseInt(pageSizeParam, 10))) : defaultPageSize; + // board 변형: 번호 페이지네이션 + const initialPage = useMemo(() => Math.max(1, parseInt(sp.get("page") || "1", 10)), [sp]); + const [page, setPage] = useState(initialPage); + const [currentPageSize, setCurrentPageSize] = useState(pageSize); + const getKey = (index: number, prev: Resp | null) => { if (prev && prev.items.length === 0) return null; const page = index + 1; + // 무한 스크롤은 board variant가 아닐 때 사용되므로 pageSize 사용 const sp = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sort }); if (boardId) sp.set("boardId", boardId); if (q) sp.set("q", q); if (tag) sp.set("tag", tag); if (author) sp.set("author", author); + if (authorId) sp.set("authorId", authorId); if (start) sp.set("start", start); if (end) sp.set("end", end); return `/api/posts?${sp.toString()}`; @@ -50,29 +67,39 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end, // default(무한 스크롤 형태) const { data, size, setSize, isLoading } = useSWRInfinite(getKey, fetcher, { revalidateFirstPage: false }); const itemsInfinite = data?.flatMap((d) => d.items) ?? []; - const canLoadMore = (data?.at(-1)?.items.length ?? 0) === pageSize; + const effectivePageSize = variant === "board" ? currentPageSize : pageSize; + const canLoadMore = (data?.at(-1)?.items.length ?? 0) === effectivePageSize; const isEmptyInfinite = !isLoading && itemsInfinite.length === 0; - - // board 변형: 번호 페이지네이션 - const initialPage = useMemo(() => Math.max(1, parseInt(sp.get("page") || "1", 10)), [sp]); - const [page, setPage] = useState(initialPage); + useEffect(() => { setPage(initialPage); }, [initialPage]); + useEffect(() => { setCurrentPageSize(pageSize); }, [pageSize]); + const singleKey = useMemo(() => { if (variant !== "board") return null; - const usp = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sort }); + const usp = new URLSearchParams({ page: String(page), pageSize: String(currentPageSize), sort }); if (boardId) usp.set("boardId", boardId); if (q) usp.set("q", q); if (tag) usp.set("tag", tag); if (author) usp.set("author", author); + if (authorId) usp.set("authorId", authorId); if (start) usp.set("start", start); if (end) usp.set("end", end); return `/api/posts?${usp.toString()}`; - }, [variant, page, pageSize, sort, boardId, q, tag, author, start, end]); + }, [variant, page, currentPageSize, sort, boardId, q, tag, author, authorId, start, end]); const { data: singlePageResp, isLoading: isLoadingSingle } = useSWR(singleKey, fetcher); - const itemsSingle = singlePageResp?.items ?? []; - const totalSingle = singlePageResp?.total ?? 0; - const totalPages = Math.max(1, Math.ceil(totalSingle / pageSize)); - const isEmptySingle = !isLoadingSingle && itemsSingle.length === 0; + + // 이전 데이터를 유지하여 깜빡임 방지 + const [stableData, setStableData] = useState(null); + useEffect(() => { + if (singlePageResp) { + setStableData(singlePageResp); + } + }, [singlePageResp]); + + const itemsSingle = stableData?.items ?? []; + const totalSingle = stableData?.total ?? singlePageResp?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(totalSingle / currentPageSize)); + const isEmptySingle = !isLoadingSingle && itemsSingle.length === 0 && !stableData; const items = variant === "board" ? itemsSingle : itemsInfinite; const isEmpty = variant === "board" ? isEmptySingle : isEmptyInfinite; @@ -149,7 +176,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
    {p.isPinned && 공지} - {p.title} + {stripHtml(p.title)} {!!p.postTags?.length && (
    @@ -254,6 +281,30 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end, Next
    +
    + 표시 개수 + +
    {newPostHref && ( diff --git a/src/app/components/RankIcon1st.tsx b/src/app/components/RankIcon1st.tsx index 507c642..7840e07 100644 --- a/src/app/components/RankIcon1st.tsx +++ b/src/app/components/RankIcon1st.tsx @@ -1,6 +1,6 @@ -export function RankIcon1st() { +export function RankIcon1st({ width = 20, height = 21 }: { width?: number; height?: number }) { return ( - + diff --git a/src/app/components/RankIcon2nd.tsx b/src/app/components/RankIcon2nd.tsx index 03ca799..d4c0c67 100644 --- a/src/app/components/RankIcon2nd.tsx +++ b/src/app/components/RankIcon2nd.tsx @@ -1,6 +1,6 @@ -export function RankIcon2nd() { +export function RankIcon2nd({ width = 20, height = 21 }: { width?: number; height?: number }) { return ( - + diff --git a/src/app/components/RankIcon3rd.tsx b/src/app/components/RankIcon3rd.tsx index 46687bb..b45f2fd 100644 --- a/src/app/components/RankIcon3rd.tsx +++ b/src/app/components/RankIcon3rd.tsx @@ -1,6 +1,6 @@ -export function RankIcon3rd() { +export function RankIcon3rd({ width = 20, height = 20 }: { width?: number; height?: number }) { return ( - + diff --git a/src/app/globals.css b/src/app/globals.css index a1bb6df..9ab0af2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -70,4 +70,34 @@ html { scrollbar-gutter: stable both-edges; } .scrollbar-overlay { margin-bottom: -6px; /* 스크롤바 높이만큼 아래 마진 조정 */ padding-bottom: 6px; /* 스크롤바 공간 확보 */ +} + +/* 게시글 내용 스타일 */ +.prose figure { + margin: 1rem 0; + position: relative; +} + +.prose figure img { + max-width: 100%; + height: auto; + display: block; + margin: 0 auto; +} + +.prose figure .resize-handle, +.prose figure .delete-image-btn { + display: none; /* 읽기 전용에서는 리사이즈 핸들과 삭제 버튼 숨김 */ +} + +.prose div[style*="text-align"] { + margin: 0.5rem 0; +} + +.prose b { + font-weight: bold; +} + +.prose br { + line-height: 1.5; } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ea24eb1..6cea40a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import QueryProvider from "@/app/QueryProvider"; import { AppHeader } from "@/app/components/AppHeader"; import { AppFooter } from "@/app/components/AppFooter"; import { ToastProvider } from "@/app/components/ui/ToastProvider"; +import { AutoLoginAdmin } from "@/app/components/AutoLoginAdmin"; export const metadata: Metadata = { @@ -21,6 +22,7 @@ export default function RootLayout({ +
    diff --git a/src/app/my-page/page.tsx b/src/app/my-page/page.tsx new file mode 100644 index 0000000..603cdfe --- /dev/null +++ b/src/app/my-page/page.tsx @@ -0,0 +1,226 @@ +import { headers } from "next/headers"; +import prisma from "@/lib/prisma"; +import Link from "next/link"; +import { UserAvatar } from "@/app/components/UserAvatar"; +import { GradeIcon, getGradeName } from "@/app/components/GradeIcon"; +import { HeroBanner } from "@/app/components/HeroBanner"; +import { PostList } from "@/app/components/PostList"; +import ProfileLabelIcon from "@/app/svgs/profilelableicon"; +export default async function MyPage({ searchParams }: { searchParams: Promise<{ tab?: string; page?: string; sort?: string; q?: string }> }) { + const sp = await searchParams; + const activeTab = sp?.tab || "posts"; + const page = parseInt(sp?.page || "1", 10); + const sort = sp?.sort || "recent"; + const q = sp?.q || ""; + + // 현재 로그인한 사용자 정보 가져오기 + let currentUser: { + userId: string; + nickname: string; + profileImage: string | null; + points: number; + level: number; + grade: number; + } | null = null; + + try { + const h = await headers(); + const cookieHeader = h.get("cookie") || ""; + const uid = cookieHeader + .split(";") + .map((s) => s.trim()) + .find((pair) => pair.startsWith("uid=")) + ?.split("=")[1]; + + if (uid) { + const user = await prisma.user.findUnique({ + where: { userId: decodeURIComponent(uid) }, + select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true }, + }); + if (user) currentUser = user; + } + } catch (e) { + // 에러 무시 + } + + // 로그인되지 않은 경우 어드민 사용자 가져오기 + if (!currentUser) { + const admin = await prisma.user.findUnique({ + where: { nickname: "admin" }, + select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true }, + }); + if (admin) currentUser = admin; + } + + if (!currentUser) { + return
    로그인이 필요합니다.
    ; + } + + // 통계 정보 가져오기 + const [postsCount, commentsCount, receivedMessagesCount, sentMessagesCount] = await Promise.all([ + prisma.post.count({ where: { authorId: currentUser.userId } }), + prisma.comment.count({ where: { authorId: currentUser.userId } }), + prisma.message.count({ where: { receiverId: currentUser.userId } }), + prisma.message.count({ where: { senderId: currentUser.userId } }), + ]); + + // 등급 업그레이드에 필요한 포인트 계산 (예시: 다음 등급까지) + const nextGradePoints = (() => { + // 등급별 포인트 기준을 여기에 설정 (임시로 2M) + const gradeThresholds = [0, 200000, 400000, 800000, 1600000, 3200000, 6400000, 10000000]; + const currentGradeIndex = Math.min(7, Math.max(0, currentUser.grade)); + return gradeThresholds[currentGradeIndex + 1] || 2000000; + })(); + + const tabs = [ + { key: "posts", label: "내가 쓴 게시글", count: postsCount }, + { key: "comments", label: "내가 쓴 댓글", count: commentsCount }, + { key: "messages-received", label: "받은 쪽지함", count: receivedMessagesCount }, + { key: "messages-sent", label: "보낸 쪽지함", count: sentMessagesCount }, + ]; + + return ( +
    + {/* 히어로 배너 */} +
    + +
    + + {/* 프로필 섹션 */} +
    +
    + {/* 좌측: 프로필 이미지, 닉네임, 레벨/등급/포인트 */} +
    +
    + {/* 프로필 이미지 */} +
    +
    + + + +
    +
    + +
    +
    + {/* 닉네임 */} +
    + {currentUser.nickname || "사용자"} + +
    +
    + {/* 레벨/등급/포인트 정보 - 가로 배치 */} +
    +
    + + 레벨 + Lv.{currentUser.level || 1} +
    +
    + + 등급 + {getGradeName(currentUser.grade)} +
    +
    + + 포인트 + {currentUser.points.toLocaleString()} +
    +
    +
    + + {/* 구분선 */} +
    + + {/* 우측: 등급 배지 및 포인트 진행 상황 */} +
    +
    + +
    +
    +
    {getGradeName(currentUser.grade)}
    +
    + {(currentUser.points / 1000000).toFixed(1)}M + / {(nextGradePoints / 1000000).toFixed(1)}M +
    +
    +
    +
    +
    + + {/* 탭 버튼 */} +
    +
    + {tabs.map((tab) => ( + + + {tab.label} + +
    + + {tab.count.toLocaleString()} + +
    + + ))} +
    +
    + + {/* 컨텐츠 영역 */} +
    + {activeTab === "posts" && ( + + )} + {activeTab === "comments" && ( +
    +
    댓글 기능은 준비 중입니다.
    +
    + )} + {activeTab === "messages-received" && ( +
    +
    받은 쪽지함 기능은 준비 중입니다.
    +
    + )} + {activeTab === "messages-sent" && ( +
    +
    보낸 쪽지함 기능은 준비 중입니다.
    +
    + )} +
    +
    + ); +} + diff --git a/src/app/page.tsx b/src/app/page.tsx index f434d96..12aec2e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,12 +10,53 @@ import { RankIcon3rd } from "@/app/components/RankIcon3rd"; import { UserAvatar } from "@/app/components/UserAvatar"; import { ImagePlaceholderIcon } from "@/app/components/ImagePlaceholderIcon"; import { BoardPanelClient } from "@/app/components/BoardPanelClient"; +import { GradeIcon, getGradeName } from "@/app/components/GradeIcon"; import prisma from "@/lib/prisma"; +import { headers } from "next/headers"; export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: "recent" | "popular" } | undefined> }) { const sp = await searchParams; const sort = sp?.sort ?? "recent"; + // 로그인된 사용자 정보 가져오기 (기본값: 어드민) + let currentUser: { + userId: string; + nickname: string; + profileImage: string | null; + points: number; + level: number; + grade: number; + } | null = null; + + try { + const h = await headers(); + const cookieHeader = h.get("cookie") || ""; + const uid = cookieHeader + .split(";") + .map((s) => s.trim()) + .find((pair) => pair.startsWith("uid=")) + ?.split("=")[1]; + + if (uid) { + const user = await prisma.user.findUnique({ + where: { userId: decodeURIComponent(uid) }, + select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true }, + }); + if (user) currentUser = user; + } + } catch (e) { + // 에러 무시 + } + + // 로그인되지 않은 경우 어드민 사용자 가져오기 + if (!currentUser) { + const admin = await prisma.user.findUnique({ + where: { nickname: "admin" }, + select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true }, + }); + if (admin) currentUser = admin; + } + // 메인페이지 설정 불러오기 const SETTINGS_KEY = "mainpage_settings" as const; const settingRow = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } }); @@ -94,7 +135,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s if (isSpecialRank) { specialRankUsers = await prisma.user.findMany({ - select: { userId: true, nickname: true, points: true, profileImage: true }, + select: { userId: true, nickname: true, points: true, profileImage: true, grade: true }, where: { status: "active" }, orderBy: { points: "desc" }, take: 3, @@ -106,6 +147,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s id: true, title: true, createdAt: true, + content: true, attachments: { where: { type: "image" }, orderBy: { sortOrder: "asc" }, @@ -187,50 +229,52 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
    -
    - Lv -
    + {currentUser && ( +
    + +
    + )}
    -
    홍길동
    +
    {currentUser?.nickname || "사용자"}
    레벨
    -
    Lv. 79
    +
    Lv. {currentUser?.level || 1}
    등급
    -
    Iron
    +
    {getGradeName(currentUser?.grade || 0)}
    포인트
    -
    1,600,000
    +
    {(currentUser?.points || 0).toLocaleString()}
    - +