diff --git a/prisma/migrations/20251102045339_add_comment_parent_depth/migration.sql b/prisma/migrations/20251102045339_add_comment_parent_depth/migration.sql new file mode 100644 index 0000000..4ebd1f0 --- /dev/null +++ b/prisma/migrations/20251102045339_add_comment_parent_depth/migration.sql @@ -0,0 +1,26 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_comments" ( + "id" TEXT NOT NULL PRIMARY KEY, + "postId" TEXT NOT NULL, + "parentId" TEXT, + "depth" INTEGER NOT NULL DEFAULT 0, + "authorId" TEXT, + "content" TEXT NOT NULL, + "isAnonymous" BOOLEAN NOT NULL DEFAULT false, + "isSecret" BOOLEAN NOT NULL DEFAULT false, + "secretPasswordHash" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "comments_postId_fkey" FOREIGN KEY ("postId") REFERENCES "posts" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "comments_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "comments" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "comments_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users" ("userId") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_comments" ("authorId", "content", "createdAt", "id", "isAnonymous", "isSecret", "postId", "secretPasswordHash", "updatedAt") SELECT "authorId", "content", "createdAt", "id", "isAnonymous", "isSecret", "postId", "secretPasswordHash", "updatedAt" FROM "comments"; +DROP TABLE "comments"; +ALTER TABLE "new_comments" RENAME TO "comments"; +CREATE INDEX "comments_postId_createdAt_idx" ON "comments"("postId", "createdAt"); +CREATE INDEX "comments_parentId_idx" ON "comments"("parentId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8fb989c..f6c0bce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -324,6 +324,8 @@ model UserRole { model Comment { id String @id @default(cuid()) postId String + parentId String? // 부모 댓글 ID (null이면 최상위 댓글) + depth Int @default(0) // 댓글 깊이 (0=최상위, 1=1단계 대댓글, 2=2단계 대댓글) authorId String? content String isAnonymous Boolean @default(false) @@ -332,11 +334,14 @@ model Comment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - post Post @relation(fields: [postId], references: [id], onDelete: Cascade) - author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull) - reports Report[] + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade) + replies Comment[] @relation("CommentReplies") + author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull) + reports Report[] @@index([postId, createdAt]) + @@index([parentId]) @@map("comments") } diff --git a/public/uploads/1762059929501-cxe093gyep7.webp b/public/uploads/1762059929501-cxe093gyep7.webp new file mode 100644 index 0000000..c3b8a12 Binary files /dev/null and b/public/uploads/1762059929501-cxe093gyep7.webp differ diff --git a/public/uploads/1762060014485-401jthedn6x.webp b/public/uploads/1762060014485-401jthedn6x.webp new file mode 100644 index 0000000..5eb8c1c Binary files /dev/null and b/public/uploads/1762060014485-401jthedn6x.webp differ diff --git a/public/uploads/1762060051441-h7kz3p9myc6.webp b/public/uploads/1762060051441-h7kz3p9myc6.webp new file mode 100644 index 0000000..d4e219a Binary files /dev/null and b/public/uploads/1762060051441-h7kz3p9myc6.webp differ diff --git a/public/uploads/1762060249110-ut9vnyatzc.webp b/public/uploads/1762060249110-ut9vnyatzc.webp new file mode 100644 index 0000000..b9c3f33 Binary files /dev/null and b/public/uploads/1762060249110-ut9vnyatzc.webp differ diff --git a/public/uploads/1762060270463-di302vcwbg.webp b/public/uploads/1762060270463-di302vcwbg.webp new file mode 100644 index 0000000..f2119cb Binary files /dev/null and b/public/uploads/1762060270463-di302vcwbg.webp differ diff --git a/public/uploads/1762060431071-kpio217ffh7.webp b/public/uploads/1762060431071-kpio217ffh7.webp new file mode 100644 index 0000000..e6a5220 Binary files /dev/null and b/public/uploads/1762060431071-kpio217ffh7.webp differ diff --git a/src/app/api/comments/route.ts b/src/app/api/comments/route.ts index fcfa6ba..0e0d803 100644 --- a/src/app/api/comments/route.ts +++ b/src/app/api/comments/route.ts @@ -2,9 +2,11 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { z } from "zod"; import { hashPassword } from "@/lib/password"; +import { getUserIdOrAdmin } from "@/lib/auth"; const createCommentSchema = z.object({ postId: z.string().min(1), + parentId: z.string().nullable().optional(), // 부모 댓글 ID (대댓글). 최상위 댓글은 null 허용 authorId: z.string().optional(), content: z.string().min(1), isAnonymous: z.boolean().optional(), @@ -18,17 +20,51 @@ export async function POST(req: Request) { if (!parsed.success) { return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); } - const { postId, authorId, content, isAnonymous, isSecret, secretPassword } = parsed.data; + const { postId, parentId, authorId, content, isAnonymous, isSecret, secretPassword } = parsed.data; + // 개발 편의: 인증 미설정 시 admin 계정으로 댓글 작성 처리 + const effectiveAuthorId = authorId ?? (await getUserIdOrAdmin(req)); + + // 대댓글인 경우 부모 댓글 확인 및 depth 계산 + let depth = 0; + if (parentId) { + const parent = await prisma.comment.findUnique({ + where: { id: parentId }, + select: { depth: true, postId: true }, + }); + if (!parent) { + return NextResponse.json({ error: "부모 댓글을 찾을 수 없습니다." }, { status: 404 }); + } + if (parent.postId !== postId) { + return NextResponse.json({ error: "게시글이 일치하지 않습니다." }, { status: 400 }); + } + depth = parent.depth + 1; + if (depth > 2) { + return NextResponse.json({ error: "최대 3단계까지 대댓글을 작성할 수 있습니다." }, { status: 400 }); + } + } + const secretPasswordHash = secretPassword ? hashPassword(secretPassword) : null; const comment = await prisma.comment.create({ data: { postId, - authorId: authorId ?? null, + parentId: parentId ?? null, + depth, + authorId: effectiveAuthorId ?? null, content, isAnonymous: !!isAnonymous, isSecret: !!isSecret, secretPasswordHash, }, + include: { + author: { select: { userId: true, nickname: true, profileImage: true } }, + }, + }); + + // 통계 업데이트: 댓글 수 증가 + await prisma.postStat.upsert({ + where: { postId }, + update: { commentsCount: { increment: 1 } }, + create: { postId, commentsCount: 1 }, }); return NextResponse.json({ comment }, { status: 201 }); } diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts new file mode 100644 index 0000000..7d97600 --- /dev/null +++ b/src/app/api/me/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { getUserIdOrAdmin } from "@/lib/auth"; + +export async function GET(req: Request) { + const userId = await getUserIdOrAdmin(req); + if (!userId) return NextResponse.json({ user: null }); + const user = await prisma.user.findUnique({ + where: { userId }, + select: { + userId: true, + nickname: true, + profileImage: true, + points: true, + level: true, + grade: true, + }, + }); + return NextResponse.json({ user }); +} + + diff --git a/src/app/api/posts/[id]/comments/route.ts b/src/app/api/posts/[id]/comments/route.ts index 629efb5..e658a99 100644 --- a/src/app/api/posts/[id]/comments/route.ts +++ b/src/app/api/posts/[id]/comments/route.ts @@ -3,26 +3,51 @@ import prisma from "@/lib/prisma"; export async function GET(_: Request, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; - const comments = await prisma.comment.findMany({ - where: { postId: id }, + + // 최상위 댓글만 가져오기 + const topComments = await prisma.comment.findMany({ + where: { + postId: id, + parentId: null, // 최상위 댓글만 + }, orderBy: { createdAt: "asc" }, - select: { - id: true, - content: true, - isAnonymous: true, - isSecret: true, - secretPasswordHash: true, - createdAt: true, + include: { + author: { select: { userId: true, nickname: true, profileImage: true } }, + replies: { + include: { + author: { select: { userId: true, nickname: true, profileImage: true } }, + replies: { + include: { + author: { select: { userId: true, nickname: true, profileImage: true } }, + }, + orderBy: { createdAt: "asc" }, + }, + }, + orderBy: { createdAt: "asc" }, + }, }, }); - const presented = comments.map((c) => ({ - id: c.id, - content: c.isSecret ? "비밀댓글입니다." : c.content, - isAnonymous: c.isAnonymous, - isSecret: c.isSecret, - anonId: c.isAnonymous ? c.id.slice(-6) : undefined, - createdAt: c.createdAt, - })); + + // 재귀적으로 댓글 구조 변환 + const transformComment = (comment: any) => ({ + id: comment.id, + parentId: comment.parentId, + depth: comment.depth, + content: comment.isSecret ? "비밀댓글입니다." : comment.content, + isAnonymous: comment.isAnonymous, + isSecret: comment.isSecret, + author: comment.author ? { + userId: comment.author.userId, + nickname: comment.author.nickname, + profileImage: comment.author.profileImage, + } : null, + anonId: comment.isAnonymous ? comment.id.slice(-6) : undefined, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + replies: comment.replies ? comment.replies.map(transformComment) : [], + }); + + const presented = topComments.map(transformComment); return NextResponse.json({ comments: presented }); } diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index b223126..283e4d8 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { z } from "zod"; +import { getUserIdOrAdmin } from "@/lib/auth"; const createPostSchema = z.object({ boardId: z.string().min(1), @@ -17,6 +18,8 @@ export async function POST(req: Request) { return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); } const { boardId, authorId, title, content, isAnonymous } = parsed.data; + // 개발 편의를 위해, 인증 정보가 없으면 admin 사용자로 대체 + const effectiveAuthorId = authorId ?? (await getUserIdOrAdmin(req)); const board = await prisma.board.findUnique({ where: { id: boardId } }); // 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개 const isImageOnly = (board?.requiredFields as any)?.imageOnly; @@ -30,7 +33,7 @@ export async function POST(req: Request) { const post = await prisma.post.create({ data: { boardId, - authorId: authorId ?? null, + authorId: effectiveAuthorId ?? null, title, content, isAnonymous: !!isAnonymous, diff --git a/src/app/boards/[id]/page.tsx b/src/app/boards/[id]/page.tsx index 50aa719..33219f4 100644 --- a/src/app/boards/[id]/page.tsx +++ b/src/app/boards/[id]/page.tsx @@ -1,5 +1,6 @@ import { PostList } from "@/app/components/PostList"; import { HeroBanner } from "@/app/components/HeroBanner"; +import Link from "next/link"; import { BoardToolbar } from "@/app/components/BoardToolbar"; import { headers } from "next/headers"; import prisma from "@/lib/prisma"; @@ -26,6 +27,11 @@ export default async function BoardDetail({ params, searchParams }: { params: an const id = board?.id as string; const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id); const categoryName = board?.category?.name ?? ""; + // 메인배너 표시 설정 + const SETTINGS_KEY = "mainpage_settings" as const; + const settingRow = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } }); + const parsed = settingRow ? JSON.parse(settingRow.value as string) : {}; + const showBanner: boolean = parsed.showBanner ?? true; // 리스트 뷰 타입 확인 (특수랭킹일 경우 게시글 대신 랭킹 노출) const boardView = await prisma.board.findUnique({ @@ -47,12 +53,34 @@ export default async function BoardDetail({ params, searchParams }: { params: an return (
{/* 상단 배너 (서브카테고리 표시) */} -
- ({ id: b.id, name: b.name, href: `/boards/${b.slug}` }))} - activeSubId={id} - /> -
+ {showBanner ? ( +
+ ({ id: b.id, name: b.name, href: `/boards/${b.slug}` }))} + activeSubId={id} + /> +
+ ) : ( +
+
+
+ {siblingBoards.map((b: any) => ( + + {b.name} + + ))} +
+
+
+ )} {/* 검색/필터 툴바 + 리스트 */}
diff --git a/src/app/components/AppFooter.tsx b/src/app/components/AppFooter.tsx index e1d1f07..0e9f57f 100644 --- a/src/app/components/AppFooter.tsx +++ b/src/app/components/AppFooter.tsx @@ -1,13 +1,15 @@ +import Link from "next/link"; + export function AppFooter() { return (