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 (