diff --git a/public/uploads/1762694665640-mepawpoqguh.webp b/public/uploads/1762694665640-mepawpoqguh.webp new file mode 100644 index 0000000..bda5920 Binary files /dev/null and b/public/uploads/1762694665640-mepawpoqguh.webp differ diff --git a/public/uploads/1762694722874-13r02smuxh0n.webp b/public/uploads/1762694722874-13r02smuxh0n.webp new file mode 100644 index 0000000..bda5920 Binary files /dev/null and b/public/uploads/1762694722874-13r02smuxh0n.webp differ diff --git a/public/uploads/1762694815229-575v2kyj72x.webp b/public/uploads/1762694815229-575v2kyj72x.webp new file mode 100644 index 0000000..bda5920 Binary files /dev/null and b/public/uploads/1762694815229-575v2kyj72x.webp differ diff --git a/public/uploads/1762695071878-s0a8nautp7d.webp b/public/uploads/1762695071878-s0a8nautp7d.webp new file mode 100644 index 0000000..bda5920 Binary files /dev/null and b/public/uploads/1762695071878-s0a8nautp7d.webp differ diff --git a/public/uploads/1762695378037-rbj4gzlxveq.webp b/public/uploads/1762695378037-rbj4gzlxveq.webp new file mode 100644 index 0000000..bda5920 Binary files /dev/null and b/public/uploads/1762695378037-rbj4gzlxveq.webp differ diff --git a/public/uploads/1762696083342-gwebeuwl0q4.webp b/public/uploads/1762696083342-gwebeuwl0q4.webp new file mode 100644 index 0000000..3a3f4c2 Binary files /dev/null and b/public/uploads/1762696083342-gwebeuwl0q4.webp differ diff --git a/public/uploads/1762696149731-1fom3wudm94.webp b/public/uploads/1762696149731-1fom3wudm94.webp new file mode 100644 index 0000000..3429c96 Binary files /dev/null and b/public/uploads/1762696149731-1fom3wudm94.webp differ diff --git a/src/app/api/auth/check-name/route.ts b/src/app/api/auth/check-name/route.ts new file mode 100644 index 0000000..11f9967 --- /dev/null +++ b/src/app/api/auth/check-name/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { nameSchema } from "@/lib/validation/auth"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const nameRaw = searchParams.get("name") ?? ""; + const name = nameRaw.trim(); + + const parsed = nameSchema.safeParse(name); + if (!parsed.success) { + const firstMsg = parsed.error.issues[0]?.message || "잘못된 닉네임 형식"; + return NextResponse.json( + { error: { fieldErrors: { name: [firstMsg] } } }, + { status: 400 } + ); + } + + const exists = await prisma.user.findFirst({ where: { name } }); + return NextResponse.json({ available: !exists }); +} + + diff --git a/src/app/api/auth/check-nickname/route.ts b/src/app/api/auth/check-nickname/route.ts new file mode 100644 index 0000000..42f52a0 --- /dev/null +++ b/src/app/api/auth/check-nickname/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { nicknameSchema } from "@/lib/validation/auth"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const nicknameRaw = searchParams.get("nickname") ?? ""; + const nickname = nicknameRaw.trim(); + + const parsed = nicknameSchema.safeParse(nickname); + if (!parsed.success) { + const firstMsg = parsed.error.issues[0]?.message || "잘못된 아이디 형식"; + return NextResponse.json( + { error: { fieldErrors: { nickname: [firstMsg] } } }, + { status: 400 } + ); + } + + const exists = await prisma.user.findUnique({ where: { nickname } }); + return NextResponse.json({ available: !exists }); +} + + diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 0bd7f46..61a83ef 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -12,11 +12,18 @@ export async function POST(req: Request) { const body = await req.json(); const parsed = loginSchema.safeParse(body); if (!parsed.success) - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - const { nickname, password } = parsed.data; - const user = await prisma.user.findUnique({ where: { nickname } }); + return NextResponse.json( + { error: "아이디 또는 비밀번호가 일치하지 않습니다" }, + { status: 401 } + ); + const { id, password } = parsed.data; + // DB에서는 로그인 아이디를 nickname 컬럼으로 보관 + const user = await prisma.user.findUnique({ where: { nickname: id } }); if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) { - return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 }); + return NextResponse.json( + { error: "아이디 또는 비밀번호가 일치하지 않습니다" }, + { status: 401 } + ); } return NextResponse.json({ user: { userId: user.userId, nickname: user.nickname } }); } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 2c3c376..49faedd 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -8,15 +8,40 @@ export async function POST(req: Request) { const parsed = registerSchema.safeParse(body); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - const { nickname, name, password } = parsed.data; - const exists = await prisma.user.findFirst({ where: { nickname } }); - if (exists) return NextResponse.json({ error: "이미 존재하는 사용자" }, { status: 409 }); + const { nickname, name, password, profileImage } = parsed.data as { + nickname: string; + name: string; + password: string; + profileImage?: string; + }; + // 아이디(닉네임 필드와 구분)를 우선 검사 + const nicknameExists = await prisma.user.findFirst({ where: { nickname } }); + if (nicknameExists) { + return NextResponse.json( + { error: { fieldErrors: { nickname: ["이미 사용 중인 아이디입니다"] } } }, + { status: 409 } + ); + } + // 표시용 닉네임(name)도 유일해야 함 + const nameExists = await prisma.user.findFirst({ where: { name } }); + if (nameExists) { + return NextResponse.json( + { error: { fieldErrors: { name: ["이미 사용 중인 닉네임입니다"] } } }, + { status: 409 } + ); + } const user = await prisma.user.create({ data: { nickname, name, passwordHash: hashPassword(password), agreementTermsAt: new Date(), + // 일부 환경에서 birth 컬럼이 NOT NULL 제약으로 남아있는 경우가 있어 안전 기본값을 기록 + birth: new Date(0), + // 일부 환경에서 phone 컬럼이 NOT NULL+UNIQUE 제약으로 남아있는 경우가 있어 + // 임시 유니크 플레이스홀더를 기록합니다. (후속 마이그레이션으로 NULL 허용 권장) + phone: `ph_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, + profileImage: profileImage || null, }, select: { userId: true, nickname: true }, }); diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index f9b2285..534fbee 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -29,11 +29,18 @@ export async function POST(req: Request) { const body = await req.json(); const parsed = loginSchema.safeParse(body); if (!parsed.success) - return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - const { nickname, password } = parsed.data; - const user = await prisma.user.findUnique({ where: { nickname } }); + return NextResponse.json( + { error: "아이디 또는 비밀번호가 일치하지 않습니다" }, + { status: 401 } + ); + const { id, password } = parsed.data; + // DB에서는 로그인 아이디를 nickname 컬럼으로 보관 + const user = await prisma.user.findUnique({ where: { nickname: id } }); if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) { - return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 }); + return NextResponse.json( + { error: "아이디 또는 비밀번호가 일치하지 않습니다" }, + { status: 401 } + ); } // 사용자의 관리자 권한 여부 확인 let isAdmin = false; diff --git a/src/app/api/me/password/route.ts b/src/app/api/me/password/route.ts new file mode 100644 index 0000000..8de062a --- /dev/null +++ b/src/app/api/me/password/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { getUserIdOrAdmin } from "@/lib/auth"; +import { verifyPassword, hashPassword } from "@/lib/password"; + +export async function PUT(req: Request) { + const userId = await getUserIdOrAdmin(req); + if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + try { + const body = await req.json(); + const currentPassword: string | undefined = body?.currentPassword; + const newPassword: string | undefined = body?.newPassword; + if (!currentPassword || !newPassword) { + return NextResponse.json({ error: "currentPassword and newPassword required" }, { status: 400 }); + } + if (newPassword.length < 8 || newPassword.length > 100) { + return NextResponse.json({ error: "password length invalid" }, { status: 400 }); + } + const user = await prisma.user.findUnique({ + where: { userId }, + select: { passwordHash: true }, + }); + if (!user || !user.passwordHash) { + return NextResponse.json({ error: "invalid user" }, { status: 400 }); + } + if (!verifyPassword(currentPassword, user.passwordHash)) { + return NextResponse.json({ error: "현재 비밀번호가 올바르지 않습니다" }, { status: 400 }); + } + if (verifyPassword(newPassword, user.passwordHash)) { + // 새 비밀번호가 기존과 동일 + return NextResponse.json({ error: "새 비밀번호가 기존과 동일합니다" }, { status: 400 }); + } + await prisma.user.update({ + where: { userId }, + data: { passwordHash: hashPassword(newPassword) }, + }); + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json({ error: "Bad Request" }, { status: 400 }); + } +} + + diff --git a/src/app/api/messages/route.ts b/src/app/api/messages/route.ts new file mode 100644 index 0000000..bb17a37 --- /dev/null +++ b/src/app/api/messages/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { z } from "zod"; +import { getUserIdFromRequest } from "@/lib/auth"; + +const sendSchema = z.object({ + receiverId: z.string().min(1, "수신자 정보가 없습니다"), + body: z.string().min(1, "메시지를 입력하세요").max(2000, "메시지는 2000자 이하여야 합니다"), +}); + +export async function POST(req: Request) { + const senderId = getUserIdFromRequest(req); + if (!senderId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const contentType = req.headers.get("content-type") || ""; + let data: any = {}; + if (contentType.includes("application/json")) { + data = await req.json().catch(() => ({})); + } else if (contentType.includes("application/x-www-form-urlencoded")) { + const form = await req.formData(); + data = Object.fromEntries(form.entries()); + } else { + // 기본적으로 form 으로 간주 + const form = await req.formData().catch(() => null); + if (form) data = Object.fromEntries(form.entries()); + } + const parsed = sendSchema.safeParse(data); + if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + const { receiverId, body } = parsed.data; + if (receiverId === senderId) { + return NextResponse.json({ error: "자기 자신에게는 보낼 수 없습니다" }, { status: 400 }); + } + const receiver = await prisma.user.findUnique({ where: { userId: receiverId }, select: { userId: true } }); + if (!receiver) return NextResponse.json({ error: "수신자를 찾을 수 없습니다" }, { status: 404 }); + const message = await prisma.message.create({ + data: { senderId, receiverId, body }, + select: { id: true, senderId: true, receiverId: true, body: true, createdAt: true }, + }); + return NextResponse.json({ message }, { status: 201 }); +} + +const listQuery = z.object({ + box: z.enum(["received", "sent"]).default("received").optional(), + page: z.coerce.number().min(1).default(1).optional(), + pageSize: z.coerce.number().min(1).max(100).default(20).optional(), +}); + +export async function GET(req: Request) { + const userId = getUserIdFromRequest(req); + if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const { searchParams } = new URL(req.url); + const parsed = listQuery.safeParse(Object.fromEntries(searchParams)); + if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + const { box = "received", page = 1, pageSize = 20 } = parsed.data; + const where = + box === "received" + ? { receiverId: userId } + : { senderId: userId }; + const [total, items] = await Promise.all([ + prisma.message.count({ where }), + prisma.message.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + body: true, + createdAt: true, + sender: { select: { userId: true, nickname: true } }, + receiver: { select: { userId: true, nickname: true } }, + }, + }), + ]); + return NextResponse.json({ total, page, pageSize, items }); +} + + diff --git a/src/app/api/posts/[id]/comments/route.ts b/src/app/api/posts/[id]/comments/route.ts index e658a99..e46f867 100644 --- a/src/app/api/posts/[id]/comments/route.ts +++ b/src/app/api/posts/[id]/comments/route.ts @@ -1,12 +1,19 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; +import { getUserIdFromRequest } from "@/lib/auth"; -export async function GET(_: Request, context: { params: Promise<{ id: string }> }) { +export async function GET(req: Request, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; - + const requesterId = getUserIdFromRequest(req); + const post = await prisma.post.findUnique({ + where: { id }, + select: { authorId: true }, + }); + const postAuthorId = post?.authorId ?? null; + // 최상위 댓글만 가져오기 const topComments = await prisma.comment.findMany({ - where: { + where: { postId: id, parentId: null, // 최상위 댓글만 }, @@ -27,26 +34,36 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }> }, }, }); - + // 재귀적으로 댓글 구조 변환 - 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 transformComment = (comment: any): any => { + const commentAuthorId: string | null = comment.author?.userId ?? null; + const canViewSecret = + !comment.isSecret || + (requesterId != null && + (requesterId === commentAuthorId || requesterId === postAuthorId)); + + return { + id: comment.id, + parentId: comment.parentId, + depth: comment.depth, + content: canViewSecret ? 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/[id]/route.ts b/src/app/api/posts/[id]/route.ts index 6085d36..3b4a743 100644 --- a/src/app/api/posts/[id]/route.ts +++ b/src/app/api/posts/[id]/route.ts @@ -9,6 +9,7 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }> const post = await prisma.post.findUnique({ where: { id }, include: { + author: { select: { userId: true, nickname: true } }, board: { select: { id: true, name: true, slug: true } }, }, }); diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index dc9f7cc..23efee5 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -117,9 +117,10 @@ export async function GET(req: Request) { title: true, createdAt: true, boardId: true, + board: { select: { id: true, name: true, slug: true } }, isPinned: true, status: true, - author: { select: { nickname: true } }, + author: { select: { userId: true, nickname: true } }, stat: { select: { recommendCount: true, views: true, commentsCount: true } }, postTags: { select: { tag: { select: { name: true, slug: true } } } }, }, diff --git a/src/app/components/AppHeader.tsx b/src/app/components/AppHeader.tsx index 2291810..485fed2 100644 --- a/src/app/components/AppHeader.tsx +++ b/src/app/components/AppHeader.tsx @@ -2,7 +2,7 @@ // 클라이언트 훅(useState/useEffect)을 사용하여 세션 표시/로그아웃을 처리합니다. import Image from "next/image"; import Link from "next/link"; -import { SearchBar } from "@/app/components/SearchBar"; +import { SearchBar } from "./SearchBar"; import useSWR from "swr"; import { UserAvatar } from "@/app/components/UserAvatar"; import { GradeIcon } from "@/app/components/GradeIcon"; @@ -577,14 +577,24 @@ export function AppHeader() { )} - +
{categories.map((cat) => (
{cat.name}
{cat.boards.map((b) => ( - setMobileOpen(false)} className="rounded px-2 py-1 text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900"> + setMobileOpen(false)} + className={`rounded px-2 py-1 text-sm transition-colors duration-150 ${ + activeBoardId === b.slug + ? "text-[var(--red-50,#F94B37)] font-semibold" + : "text-neutral-700 hover:text-[var(--red-50,#F94B37)]" + }`} + aria-current={activeBoardId === b.slug ? "page" : undefined} + > {b.name} ))} diff --git a/src/app/components/AutoLoginAdmin.tsx b/src/app/components/AutoLoginAdmin.tsx index e5e0dc7..9047e3b 100644 --- a/src/app/components/AutoLoginAdmin.tsx +++ b/src/app/components/AutoLoginAdmin.tsx @@ -19,7 +19,7 @@ export function AutoLoginAdmin() { fetch("/api/auth/session", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ nickname: "admin", password: "1234" }), + body: JSON.stringify({ id: "admin", password: "1234" }), }) .then((res) => res.json()) .then((loginData) => { diff --git a/src/app/components/CommentSection.tsx b/src/app/components/CommentSection.tsx index ff4ab6e..2897e9e 100644 --- a/src/app/components/CommentSection.tsx +++ b/src/app/components/CommentSection.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { UserAvatar } from "./UserAvatar"; import Link from "next/link"; +import { UserNameMenu } from "./UserNameMenu"; type CommentAuthor = { userId: string; @@ -27,6 +28,190 @@ type Props = { postId: string; }; +type CommentItemProps = { + comment: Comment; + depth?: number; + expandedReplies: Set; + toggleReplies: (commentId: string) => void; + replyingTo: string | null; + setReplyingTo: (id: string | null) => void; + replyContents: Record; + setReplyContents: React.Dispatch>>; + handleSubmitReply: (parentId: string | null) => void; + replySecretFlags: Record; + setReplySecretFlags: React.Dispatch>>; +}; + +function formatDate(dateString: string) { + const date = new Date(dateString); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return "방금 전"; + if (minutes < 60) return `${minutes}분 전`; + if (hours < 24) return `${hours}시간 전`; + if (days < 7) return `${days}일 전`; + return date.toLocaleDateString(); +} + +// 개별 댓글 아이템: 부모 렌더 시 타입이 바뀌어 리마운트되지 않도록 파일 최상위에 선언하고 memo 처리 +const CommentItem = memo(function CommentItem({ + comment, + depth = 0, + expandedReplies, + toggleReplies, + replyingTo, + setReplyingTo, + replyContents, + setReplyContents, + handleSubmitReply, + replySecretFlags, + setReplySecretFlags, +}: CommentItemProps) { + const canReply = depth < 2; // 최대 3단계까지만 + const hasReplies = comment.replies && comment.replies.length > 0; + const isExpanded = expandedReplies.has(comment.id); + const isReplyingHere = replyingTo === comment.id; + const replyRef = useRef(null); + + // 답글 폼이 열릴 때만 포커스를 보장 + useEffect(() => { + if (isReplyingHere) { + replyRef.current?.focus(); + } + }, [isReplyingHere]); + + return ( +
0 ? "ml-8 border-l-2 border-neutral-200 pl-4" : ""}`}> +
+ +
+
+ + {/* 닉네임 드롭다운 */} + {/* 익명 댓글이면 드롭다운 없이 표시 */} + {comment.isAnonymous ? ( + `익명${comment.anonId}` + ) : ( + + )} + + {formatDate(comment.createdAt)} + {comment.updatedAt !== comment.createdAt && ( + (수정됨) + )} +
+
+ {comment.content} +
+
+ {canReply && ( + + )} + {hasReplies && ( + + )} +
+ + {/* 답글 입력 폼 */} + {isReplyingHere && ( +
+