This commit is contained in:
mota
2025-11-02 15:13:03 +09:00
parent b10d41532b
commit fadd402e63
31 changed files with 1105 additions and 192 deletions

View File

@@ -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;

View File

@@ -324,6 +324,8 @@ model UserRole {
model Comment { model Comment {
id String @id @default(cuid()) id String @id @default(cuid())
postId String postId String
parentId String? // 부모 댓글 ID (null이면 최상위 댓글)
depth Int @default(0) // 댓글 깊이 (0=최상위, 1=1단계 대댓글, 2=2단계 대댓글)
authorId String? authorId String?
content String content String
isAnonymous Boolean @default(false) isAnonymous Boolean @default(false)
@@ -333,10 +335,13 @@ model Comment {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
post Post @relation(fields: [postId], references: [id], onDelete: Cascade) 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) author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
reports Report[] reports Report[]
@@index([postId, createdAt]) @@index([postId, createdAt])
@@index([parentId])
@@map("comments") @@map("comments")
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@@ -2,9 +2,11 @@ import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { z } from "zod"; import { z } from "zod";
import { hashPassword } from "@/lib/password"; import { hashPassword } from "@/lib/password";
import { getUserIdOrAdmin } from "@/lib/auth";
const createCommentSchema = z.object({ const createCommentSchema = z.object({
postId: z.string().min(1), postId: z.string().min(1),
parentId: z.string().nullable().optional(), // 부모 댓글 ID (대댓글). 최상위 댓글은 null 허용
authorId: z.string().optional(), authorId: z.string().optional(),
content: z.string().min(1), content: z.string().min(1),
isAnonymous: z.boolean().optional(), isAnonymous: z.boolean().optional(),
@@ -18,17 +20,51 @@ export async function POST(req: Request) {
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); 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 secretPasswordHash = secretPassword ? hashPassword(secretPassword) : null;
const comment = await prisma.comment.create({ const comment = await prisma.comment.create({
data: { data: {
postId, postId,
authorId: authorId ?? null, parentId: parentId ?? null,
depth,
authorId: effectiveAuthorId ?? null,
content, content,
isAnonymous: !!isAnonymous, isAnonymous: !!isAnonymous,
isSecret: !!isSecret, isSecret: !!isSecret,
secretPasswordHash, 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 }); return NextResponse.json({ comment }, { status: 201 });
} }

22
src/app/api/me/route.ts Normal file
View File

@@ -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 });
}

View File

@@ -3,26 +3,51 @@ import prisma from "@/lib/prisma";
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) { export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params; 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" }, orderBy: { createdAt: "asc" },
select: { include: {
id: true, author: { select: { userId: true, nickname: true, profileImage: true } },
content: true, replies: {
isAnonymous: true, include: {
isSecret: true, author: { select: { userId: true, nickname: true, profileImage: true } },
secretPasswordHash: true, replies: {
createdAt: true, 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, const transformComment = (comment: any) => ({
isAnonymous: c.isAnonymous, id: comment.id,
isSecret: c.isSecret, parentId: comment.parentId,
anonId: c.isAnonymous ? c.id.slice(-6) : undefined, depth: comment.depth,
createdAt: c.createdAt, 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 }); return NextResponse.json({ comments: presented });
} }

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { z } from "zod"; import { z } from "zod";
import { getUserIdOrAdmin } from "@/lib/auth";
const createPostSchema = z.object({ const createPostSchema = z.object({
boardId: z.string().min(1), boardId: z.string().min(1),
@@ -17,6 +18,8 @@ export async function POST(req: Request) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
} }
const { boardId, authorId, title, content, isAnonymous } = parsed.data; const { boardId, authorId, title, content, isAnonymous } = parsed.data;
// 개발 편의를 위해, 인증 정보가 없으면 admin 사용자로 대체
const effectiveAuthorId = authorId ?? (await getUserIdOrAdmin(req));
const board = await prisma.board.findUnique({ where: { id: boardId } }); const board = await prisma.board.findUnique({ where: { id: boardId } });
// 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개 // 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개
const isImageOnly = (board?.requiredFields as any)?.imageOnly; const isImageOnly = (board?.requiredFields as any)?.imageOnly;
@@ -30,7 +33,7 @@ export async function POST(req: Request) {
const post = await prisma.post.create({ const post = await prisma.post.create({
data: { data: {
boardId, boardId,
authorId: authorId ?? null, authorId: effectiveAuthorId ?? null,
title, title,
content, content,
isAnonymous: !!isAnonymous, isAnonymous: !!isAnonymous,

View File

@@ -1,5 +1,6 @@
import { PostList } from "@/app/components/PostList"; import { PostList } from "@/app/components/PostList";
import { HeroBanner } from "@/app/components/HeroBanner"; import { HeroBanner } from "@/app/components/HeroBanner";
import Link from "next/link";
import { BoardToolbar } from "@/app/components/BoardToolbar"; import { BoardToolbar } from "@/app/components/BoardToolbar";
import { headers } from "next/headers"; import { headers } from "next/headers";
import prisma from "@/lib/prisma"; 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 id = board?.id as string;
const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id); const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id);
const categoryName = board?.category?.name ?? ""; 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({ const boardView = await prisma.board.findUnique({
@@ -47,12 +53,34 @@ export default async function BoardDetail({ params, searchParams }: { params: an
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 상단 배너 (서브카테고리 표시) */} {/* 상단 배너 (서브카테고리 표시) */}
{showBanner ? (
<section> <section>
<HeroBanner <HeroBanner
subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.slug}` }))} subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.slug}` }))}
activeSubId={id} activeSubId={id}
/> />
</section> </section>
) : (
<section>
<div className="h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
<div className="flex flex-wrap items-center gap-[8px]">
{siblingBoards.map((b: any) => (
<Link
key={b.id}
href={`/boards/${b.slug}`}
className={
b.id === id
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] leading-[28px] whitespace-nowrap"
}
>
{b.name}
</Link>
))}
</div>
</div>
</section>
)}
{/* 검색/필터 툴바 + 리스트 */} {/* 검색/필터 툴바 + 리스트 */}
<section> <section>

View File

@@ -1,13 +1,15 @@
import Link from "next/link";
export function AppFooter() { export function AppFooter() {
return ( return (
<footer className="py-[72px]"> <footer className="py-[72px]">
<div className="text-[#626262] text-[16px] leading-[14px] flex flex-row mb-[30px]"> <div className="text-[#626262] text-[16px] leading-[14px] flex flex-row mb-[30px]">
<div className="flex-1"></div> <div className="flex-1"></div>
<div className="border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></div> <Link href="/privacy" className="border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></Link>
<div className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </div> <Link href="/email-deny" className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </Link>
<div className=" border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </div> <Link href="/legal" className=" border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </Link>
<div className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></div> <Link href="/guide" className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></Link>
<div className="px-[8px] cursor-pointer hover:text-[#2BAF7E]"></div> <Link href="/contact" className="px-[8px] cursor-pointer hover:text-[#2BAF7E]"></Link>
<div className="flex-1"></div> <div className="flex-1"></div>
</div> </div>
<div className="text-[#888] text-center font-[Pretendard] text-[16px] font-normal leading-[100%] mt-[24px]"> <div className="text-[#888] text-center font-[Pretendard] text-[16px] font-normal leading-[100%] mt-[24px]">

View File

@@ -3,6 +3,9 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { SearchBar } from "@/app/components/SearchBar"; import { SearchBar } from "@/app/components/SearchBar";
import useSWR from "swr";
import { UserAvatar } from "@/app/components/UserAvatar";
import { GradeIcon } from "@/app/components/GradeIcon";
import React from "react"; import React from "react";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { SinglePageLogo } from "@/app/components/SinglePageLogo"; import { SinglePageLogo } from "@/app/components/SinglePageLogo";
@@ -23,6 +26,11 @@ export function AppHeader() {
const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({}); const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({});
const closeTimer = React.useRef<number | null>(null); const closeTimer = React.useRef<number | null>(null);
const [navMinWidths, setNavMinWidths] = React.useState<Record<string, number>>({}); const [navMinWidths, setNavMinWidths] = React.useState<Record<string, number>>({});
// 모바일 사이드바 열릴 때만 현재 사용자 정보를 가져옵니다(훅은 항상 동일한 순서로 호출)
const { data: meData } = useSWR<{ user: { userId: string; nickname: string; profileImage: string | null; points: number; level: number; grade: number } | null }>(
mobileOpen ? "/api/me" : null,
(u: string) => fetch(u).then((r) => r.json())
);
// 현재 경로 기반 활성 보드/카테고리 계산 // 현재 경로 기반 활성 보드/카테고리 계산
const pathname = usePathname(); const pathname = usePathname();
@@ -416,6 +424,28 @@ export function AppHeader() {
<div className="mb-3 h-10 flex items-center justify-between"> <div className="mb-3 h-10 flex items-center justify-between">
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* 미니 프로필 패널 */}
<div className="rounded-xl border border-neutral-200 p-3">
{meData?.user ? (
<div className="flex items-center gap-3">
<UserAvatar src={meData.user.profileImage} alt={meData.user.nickname || "프로필"} width={48} height={48} className="rounded-full" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-neutral-900 truncate">{meData.user.nickname}</span>
<GradeIcon grade={meData.user.grade} width={20} height={20} />
</div>
<div className="text-xs text-neutral-600">Lv.{meData.user.level} · {meData.user.points.toLocaleString()}</div>
</div>
</div>
) : (
<div className="text-sm text-neutral-600"> ...</div>
)}
<div className="grid grid-cols-3 gap-2 mt-3">
<Link href="/my-page?tab=points" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100"> </Link>
<Link href="/my-page?tab=posts" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100"> </Link>
<Link href="/my-page?tab=comments" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100"> </Link>
</div>
</div>
<SearchBar /> <SearchBar />
<Link <Link
href="/admin" href="/admin"

View File

@@ -31,7 +31,7 @@ type PostData = {
createdAt: Date; createdAt: Date;
content?: string | null; content?: string | null;
attachments?: { url: string }[]; attachments?: { url: string }[];
stat?: { recommendCount: number | null }; stat?: { recommendCount: number | null; commentsCount: number | null };
}; };
type BoardPanelData = { type BoardPanelData = {
@@ -78,7 +78,7 @@ export function BoardPanelClient({
return imgMatch[1]; return imgMatch[1];
} }
// figure 안의 img 태그도 확인 // figure 안의 img 태그도 확인
const figureMatch = content.match(/<figure[^>]*>.*?<img[^>]+src=["']([^"']+)["'][^>]*>/is); const figureMatch = content.match(/<figure[^>]*>[\s\S]*?<img[^>]+src=["']([^"']+)["'][^>]*>/i);
if (figureMatch && figureMatch[1]) { if (figureMatch && figureMatch[1]) {
return figureMatch[1]; return figureMatch[1];
} }
@@ -97,7 +97,7 @@ export function BoardPanelClient({
<div className="flex items-center gap-[8px] shrink-0"> <div className="flex items-center gap-[8px] shrink-0">
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div> <div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div>
</div> </div>
<div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 scrollbar-thin-x"> <div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 no-scrollbar">
{siblingBoards.map((sb) => ( {siblingBoards.map((sb) => (
<button <button
key={sb.id} key={sb.id}
@@ -113,12 +113,12 @@ export function BoardPanelClient({
</div> </div>
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col"> <div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
<div className="flex-1 min-h-0 overflow-hidden p-0"> <div className="flex-1 min-h-0 overflow-hidden p-0">
<div className="px-[24px] pt-[8px] pb-[16px]"> <div className="px-[0px] pt-[8px] pb-[16px]">
<div className="flex flex-col gap-[16px]"> <div className="flex flex-col gap-[16px]">
{selectedBoardData.specialRankUsers.map((user, idx) => { {selectedBoardData.specialRankUsers.map((user, idx) => {
const rank = idx + 1; const rank = idx + 1;
return ( return (
<div key={user.userId} className="flex h-[150px] items-center rounded-[16px] overflow-hidden bg-white"> <Link href="/boards/ranking" key={user.userId} className="flex h-[150px] items-center rounded-[16px] overflow-hidden bg-white hover:bg-neutral-50">
<div className="h-[150px] w-[160px] relative shrink-0 bg-[#d5d5d5] overflow-hidden"> <div className="h-[150px] w-[160px] relative shrink-0 bg-[#d5d5d5] overflow-hidden">
<UserAvatar <UserAvatar
src={user.profileImage} src={user.profileImage}
@@ -131,7 +131,7 @@ export function BoardPanelClient({
<GradeIcon grade={user.grade} width={40} height={40} /> <GradeIcon grade={user.grade} width={40} height={40} />
</div> </div>
</div> </div>
<div className="flex-1 flex items-center gap-[10px] px-[30px] py-[24px] min-w-0"> <div className="flex-1 flex items-center gap-[10px] px-[24px] md:px-[30px] py-[24px] min-w-0">
<div className="flex flex-col gap-[12px] min-w-0 flex-1"> <div className="flex flex-col gap-[12px] min-w-0 flex-1">
<div className="flex items-center gap-[12px]"> <div className="flex items-center gap-[12px]">
<div className="relative w-[20px] h-[20px] shrink-0"> <div className="relative w-[20px] h-[20px] shrink-0">
@@ -154,7 +154,7 @@ export function BoardPanelClient({
<span className="text-[20px] font-semibold text-[#5c5c5c] leading-[22px]">{user.points.toLocaleString()}</span> <span className="text-[20px] font-semibold text-[#5c5c5c] leading-[22px]">{user.points.toLocaleString()}</span>
</div> </div>
</div> </div>
</div> </Link>
); );
})} })}
</div> </div>
@@ -173,7 +173,7 @@ export function BoardPanelClient({
<div className="flex items-center gap-[8px] shrink-0"> <div className="flex items-center gap-[8px] shrink-0">
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div> <div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div>
</div> </div>
<div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 scrollbar-thin-x"> <div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 no-scrollbar">
{siblingBoards.map((sb) => ( {siblingBoards.map((sb) => (
<button <button
key={sb.id} key={sb.id}
@@ -189,7 +189,7 @@ export function BoardPanelClient({
</div> </div>
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col"> <div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
<div className="flex-1 min-h-0 overflow-hidden p-0"> <div className="flex-1 min-h-0 overflow-hidden p-0">
<div className="px-[24px] pt-[8px] pb-[16px]"> <div className="px-[0px] pt-[8px] pb-[16px]">
<div className="flex flex-col gap-[16px]"> <div className="flex flex-col gap-[16px]">
{selectedBoardData.previewPosts.map((post) => { {selectedBoardData.previewPosts.map((post) => {
// attachments에서 이미지를 먼저 찾고, 없으면 content에서 추출 // attachments에서 이미지를 먼저 찾고, 없으면 content에서 추출
@@ -209,7 +209,7 @@ export function BoardPanelClient({
</div> </div>
)} )}
</div> </div>
<div className="flex-1 flex items-center gap-[10px] px-[30px] py-[24px] min-w-0"> <div className="flex-1 flex items-center gap-[10px] px-[24px] md:px-[30px] py-[24px] min-w-0">
<div className="flex flex-col gap-[12px] min-w-0 flex-1"> <div className="flex flex-col gap-[12px] min-w-0 flex-1">
<div className="bg-white border border-[#d5d5d5] px-[16px] py-[5px] rounded-[12px] text-[14px] text-[#5c5c5c] shrink-0 w-fit"> <div className="bg-white border border-[#d5d5d5] px-[16px] py-[5px] rounded-[12px] text-[14px] text-[#5c5c5c] shrink-0 w-fit">
{board.name} {board.name}
@@ -220,6 +220,9 @@ export function BoardPanelClient({
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div> <div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
</div> </div>
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate">{stripHtml(post.title)}</span> <span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate">{stripHtml(post.title)}</span>
{(post.stat?.commentsCount ?? 0) > 0 && (
<span className="ml-1 text-[14px] text-[#f45f00] font-bold shrink-0">[{post.stat?.commentsCount}]</span>
)}
</div> </div>
<div className="h-[16px] relative"> <div className="h-[16px] relative">
<span className="absolute top-1/2 translate-y-[-50%] text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]"> <span className="absolute top-1/2 translate-y-[-50%] text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">
@@ -246,7 +249,7 @@ export function BoardPanelClient({
<div className="flex items-center gap-[8px] shrink-0"> <div className="flex items-center gap-[8px] shrink-0">
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div> <div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div>
</div> </div>
<div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 scrollbar-thin-x"> <div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 no-scrollbar">
{siblingBoards.map((sb) => ( {siblingBoards.map((sb) => (
<button <button
key={sb.id} key={sb.id}
@@ -278,7 +281,9 @@ export function BoardPanelClient({
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div> <div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
</div> </div>
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate max-w-[calc(100vw-280px)]">{stripHtml(p.title)}</span> <span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate max-w-[calc(100vw-280px)]">{stripHtml(p.title)}</span>
<span className="text-[16px] text-[#f45f00] font-bold">+{p.stat?.recommendCount ?? 0}</span> {(p.stat?.commentsCount ?? 0) > 0 && (
<span className="text-[14px] text-[#f45f00] font-bold">[{p.stat?.commentsCount}]</span>
)}
</Link> </Link>
<span className="text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">{formatDateYmd(p.createdAt)}</span> <span className="text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">{formatDateYmd(p.createdAt)}</span>
</div> </div>

View File

@@ -45,8 +45,19 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
}; };
return ( return (
<div className="flex items-center justify-between px-0 py-2"> <div className="px-0 py-2 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2"> {/* 검색바: 모바일에서는 상단 전체폭 */}
<form action={onSubmit} className="order-1 md:order-2 flex items-center gap-2 w-full md:w-auto">
<select name="scope" aria-label="검색대상" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm shrink-0" defaultValue={scope}>
<option value="q">+</option>
<option value="author"></option>
</select>
<input name="text" defaultValue={defaultText} placeholder="검색어를 입력해 주세요." className="h-8 w-full md:w-72 px-3 rounded-md border border-neutral-300 text-sm placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-300" />
<button type="submit" className="h-8 px-3 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 shrink-0"></button>
</form>
{/* 필터: 모바일에서는 검색 아래쪽 */}
<div className="order-2 md:order-1 flex items-center gap-2">
<select aria-label="정렬" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={sort} onChange={onChangeSort}> <select aria-label="정렬" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={sort} onChange={onChangeSort}>
<option value="recent"></option> <option value="recent"></option>
<option value="popular"></option> <option value="popular"></option>
@@ -58,14 +69,6 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
<option value="1m">1</option> <option value="1m">1</option>
</select> </select>
</div> </div>
<form action={onSubmit} className="flex items-center gap-2">
<select name="scope" aria-label="검색대상" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={scope}>
<option value="q">+</option>
<option value="author"></option>
</select>
<input name="text" defaultValue={defaultText} placeholder="검색어를 입력해 주세요." className="h-8 w-56 md:w-72 px-3 rounded-md border border-neutral-300 text-sm placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-300" />
<button type="submit" className="h-8 px-3 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800"></button>
</form>
</div> </div>
); );
} }

View File

@@ -0,0 +1,265 @@
"use client";
import { useEffect, useState } from "react";
import { UserAvatar } from "./UserAvatar";
import Link from "next/link";
type CommentAuthor = {
userId: string;
nickname: string | null;
profileImage: string | null;
};
type Comment = {
id: string;
parentId: string | null;
depth: number;
content: string;
isAnonymous: boolean;
isSecret: boolean;
author: CommentAuthor | null;
anonId?: string;
createdAt: string;
updatedAt: string;
replies: Comment[];
};
type Props = {
postId: string;
};
export function CommentSection({ postId }: Props) {
const [comments, setComments] = useState<Comment[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [newContent, setNewContent] = useState("");
const [replyContents, setReplyContents] = useState<Record<string, string>>({});
const [expandedReplies, setExpandedReplies] = useState<Set<string>>(new Set());
useEffect(() => {
loadComments();
}, [postId]);
async function loadComments() {
try {
const res = await fetch(`/api/posts/${postId}/comments`);
if (!res.ok) return;
const data = await res.json();
setComments(data.comments || []);
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
}
async function handleSubmitReply(parentId: string | null) {
const content = parentId ? (replyContents[parentId] ?? "") : newContent;
if (!content.trim()) return;
try {
const res = await fetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
postId,
parentId,
content,
}),
});
if (!res.ok) {
const error = await res.json();
alert(error.error || "댓글 작성 실패");
return;
}
if (parentId) {
setReplyContents((prev) => {
const next = { ...prev };
delete next[parentId!];
return next;
});
} else {
setNewContent("");
}
setReplyingTo(null);
loadComments();
} catch (e) {
console.error(e);
alert("댓글 작성 실패");
}
}
function toggleReplies(commentId: string) {
const newExpanded = new Set(expandedReplies);
if (newExpanded.has(commentId)) {
newExpanded.delete(commentId);
} else {
newExpanded.add(commentId);
}
setExpandedReplies(newExpanded);
}
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();
}
function CommentItem({ comment, depth = 0 }: { comment: Comment; depth?: number }) {
const canReply = depth < 2; // 최대 3단계까지만
const hasReplies = comment.replies && comment.replies.length > 0;
const isExpanded = expandedReplies.has(comment.id);
return (
<div className={`${depth > 0 ? "ml-8 border-l-2 border-neutral-200 pl-4" : ""}`}>
<div className="flex gap-3 py-3">
<UserAvatar
src={comment.author?.profileImage}
alt={comment.author?.nickname || "익명"}
width={40}
height={40}
className="rounded-full shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-neutral-900">
{comment.isAnonymous ? `익명${comment.anonId}` : comment.author?.nickname || "익명"}
</span>
<span className="text-xs text-neutral-500">{formatDate(comment.createdAt)}</span>
{comment.updatedAt !== comment.createdAt && (
<span className="text-xs text-neutral-400">()</span>
)}
</div>
<div className="text-sm text-neutral-700 mb-2 whitespace-pre-wrap break-words">
{comment.content}
</div>
<div className="flex items-center gap-3">
{canReply && (
<button
onClick={() => {
setReplyingTo(comment.id);
setReplyContents((prev) => ({ ...prev, [comment.id]: prev[comment.id] ?? "" }));
}}
className="text-xs text-neutral-500 hover:text-neutral-900"
>
</button>
)}
{hasReplies && (
<button
onClick={() => toggleReplies(comment.id)}
className="text-xs text-neutral-500 hover:text-neutral-900"
>
{isExpanded ? "답글 숨기기" : `답글 ${comment.replies.length}`}
</button>
)}
</div>
{/* 답글 입력 폼 */}
{replyingTo === comment.id && (
<div className="mt-3 p-3 bg-neutral-50 rounded-lg">
<textarea
value={replyContents[comment.id] ?? ""}
onChange={(e) => setReplyContents((prev) => ({ ...prev, [comment.id]: e.target.value }))}
placeholder="답글을 입력하세요..."
className="w-full p-2 border border-neutral-300 rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-neutral-400"
rows={3}
/>
<div className="flex gap-2 mt-2 justify-end">
<button
onClick={() => {
setReplyingTo(null);
setReplyContents((prev) => {
const next = { ...prev };
delete next[comment.id];
return next;
});
}}
className="px-3 py-1 text-xs border border-neutral-300 rounded-md hover:bg-neutral-100"
>
</button>
<button
onClick={() => handleSubmitReply(comment.id)}
className="px-3 py-1 text-xs bg-neutral-900 text-white rounded-md hover:bg-neutral-800"
>
</button>
</div>
</div>
)}
</div>
</div>
{/* 대댓글 목록 */}
{hasReplies && isExpanded && (
<div className="mt-2">
{comment.replies.map((reply) => (
<CommentItem key={reply.id} comment={reply} depth={depth + 1} />
))}
</div>
)}
</div>
);
}
if (isLoading) {
return (
<section className="rounded-xl overflow-hidden bg-white p-4">
<div className="text-center text-neutral-500 py-8"> ...</div>
</section>
);
}
return (
<section className="rounded-xl overflow-hidden bg-white">
<header className="px-4 py-3 border-b border-neutral-200">
<h2 className="text-lg font-bold text-neutral-900"> {comments.length}</h2>
</header>
<div className="p-4">
{/* 댓글 입력 폼 */}
<div className="mb-6 pb-6 border-b border-neutral-200">
<textarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
placeholder="댓글을 입력하세요..."
className="w-full p-3 border border-neutral-300 rounded-lg text-sm resize-none focus:outline-none focus:ring-2 focus:ring-neutral-400"
rows={4}
/>
<div className="flex justify-end mt-2">
<button
onClick={() => handleSubmitReply(null)}
disabled={!newContent.trim()}
className="px-4 py-2 text-sm bg-neutral-900 text-white rounded-md hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
{/* 댓글 목록 */}
{comments.length === 0 ? (
<div className="text-center text-neutral-500 py-8"> .</div>
) : (
<div className="space-y-0">
{comments.map((comment) => (
<CommentItem key={comment.id} comment={comment} />
))}
</div>
)}
</div>
</section>
);
}

View File

@@ -10,7 +10,7 @@ type SubItem = { id: string; name: string; href: string };
const fetcher = (url: string) => fetch(url).then((r) => r.json()); const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; activeSubId?: string }) { export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItems?: SubItem[]; activeSubId?: string; hideSubOnMobile?: boolean }) {
// SWR 캐시 사용: 페이지 전환 시 재요청/깜빡임 최소화 // SWR 캐시 사용: 페이지 전환 시 재요청/깜빡임 최소화
const { data } = useSWR<{ banners: Banner[] }>("/api/banners", fetcher, { const { data } = useSWR<{ banners: Banner[] }>("/api/banners", fetcher, {
revalidateOnFocus: false, revalidateOnFocus: false,
@@ -78,7 +78,7 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
<section className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white" aria-roledescription="carousel"> <section className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white" aria-roledescription="carousel">
<SelectedBanner className="h-56 sm:h-72 md:h-[264px]" /> <SelectedBanner className="h-56 sm:h-72 md:h-[264px]" />
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 (스켈레톤 상태에서도 동일 레이아웃 유지) */} {/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 (스켈레톤 상태에서도 동일 레이아웃 유지) */}
<div className="h-[58px] bg-black rounded-xl flex items-center justify-center px-2"> <div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-center justify-center px-2`}>
{Array.isArray(subItems) && subItems.length > 0 && ( {Array.isArray(subItems) && subItems.length > 0 && (
<div className="flex flex-wrap items-center gap-[8px]"> <div className="flex flex-wrap items-center gap-[8px]">
{subItems.map((s) => ( {subItems.map((s) => (
@@ -151,7 +151,7 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
</div> </div>
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */} {/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */}
<div className="h-[58px] bg-black rounded-xl flex items-center justify-center px-2"> <div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-center justify-center px-2`}>
{Array.isArray(subItems) && subItems.length > 0 && ( {Array.isArray(subItems) && subItems.length > 0 && (
<div className="flex flex-wrap items-center gap-[8px]"> <div className="flex flex-wrap items-center gap-[8px]">
{subItems.map((s) => ( {subItems.map((s) => (

View File

@@ -177,6 +177,9 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
<Link href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900"> <Link href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900">
{p.isPinned && <span className="mr-2 inline-flex items-center rounded bg-orange-100 text-orange-700 px-1.5 py-0.5 text-[11px]"></span>} {p.isPinned && <span className="mr-2 inline-flex items-center rounded bg-orange-100 text-orange-700 px-1.5 py-0.5 text-[11px]"></span>}
{stripHtml(p.title)} {stripHtml(p.title)}
{(p.stat?.commentsCount ?? 0) > 0 && (
<span className="ml-1 text-[12px] md:text-[12px] text-[#f94b37] align-middle">[{p.stat?.commentsCount}]</span>
)}
</Link> </Link>
{!!p.postTags?.length && ( {!!p.postTags?.length && (
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500"> <div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
@@ -205,7 +208,8 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
{/* 페이지네이션 */} {/* 페이지네이션 */}
{!isEmpty && ( {!isEmpty && (
variant === "board" ? ( variant === "board" ? (
<div className="mt-4 flex items-center justify-between px-4"> <div className="mt-4 px-4 space-y-3">
{/* 상단: 페이지 이동 컨트롤 */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Previous */} {/* Previous */}
<button <button
@@ -281,6 +285,8 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
Next Next
</button> </button>
</div> </div>
{/* 하단: 표시 개수 + 글쓰기 (모바일에서 아래로 분리) */}
<div className="flex items-center justify-between md:justify-end gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-neutral-600"> </span> <span className="text-xs text-neutral-600"> </span>
<select <select
@@ -311,6 +317,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
</Link> </Link>
)} )}
</div> </div>
</div>
) : ( ) : (
<div className="mt-3 flex justify-center"> <div className="mt-3 flex justify-center">
<button <button

27
src/app/contact/page.tsx Normal file
View File

@@ -0,0 +1,27 @@
export const dynamic = "force-static";
export default function ContactPage() {
return (
<div className="space-y-6">
<section className="rounded-xl overflow-hidden bg-white p-6 md:p-8">
<h1 className="text-2xl font-bold mb-2"></h1>
<div className="prose max-w-none text-sm leading-6 text-neutral-800">
<p> , , .</p>
<ul>
<li>이메일: support@assm.example</li>
<li>전화: 02-0000-0000 ( 10:00~18:00)</li>
</ul>
<h2> </h2>
<ul>
<li>: [] </li>
<li>내용: 발생 , / , </li>
<li>연락처: 회신 /</li>
</ul>
<p> .</p>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,28 @@
export const dynamic = "force-static";
export default function EmailDenyPage() {
return (
<div className="space-y-6">
<section className="rounded-xl overflow-hidden bg-white p-6 md:p-8">
<h1 className="text-2xl font-bold mb-2"> </h1>
<p className="text-sm text-neutral-500 mb-6">시행일: 2025-11-02</p>
<div className="prose max-w-none text-sm leading-6 text-neutral-800">
<p>
.
,
.
</p>
<h2> </h2>
<ul>
<li> ··· .</li>
<li> .</li>
</ul>
<h2></h2>
<p>contact@assm.example / 02-0000-0000</p>
</div>
</section>
</div>
);
}

View File

@@ -41,6 +41,13 @@ html { scrollbar-gutter: stable both-edges; }
display: none; /* Chrome, Safari, Opera */ display: none; /* Chrome, Safari, Opera */
} }
/* alias: keep scroll but hide scrollbar visuals */
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar { display: none; }
/* 커스텀 가로 스크롤바 - 얇고 예쁜 스타일, 컨텐츠를 밀어내지 않음 */ /* 커스텀 가로 스크롤바 - 얇고 예쁜 스타일, 컨텐츠를 밀어내지 않음 */
.scrollbar-thin-x { .scrollbar-thin-x {
scrollbar-width: thin; /* Firefox */ scrollbar-width: thin; /* Firefox */

31
src/app/guide/page.tsx Normal file
View File

@@ -0,0 +1,31 @@
export const dynamic = "force-static";
export default function ServiceGuidePage() {
return (
<div className="space-y-6">
<section className="rounded-xl overflow-hidden bg-white p-6 md:p-8">
<h1 className="text-2xl font-bold mb-2"></h1>
<div className="prose max-w-none text-sm leading-6 text-neutral-800">
<h2> </h2>
<ul>
<li> / .</li>
<li> .</li>
</ul>
<h2> </h2>
<ul>
<li>/ .</li>
<li> .</li>
</ul>
<h2> & </h2>
<ul>
<li>, , , .</li>
</ul>
<h2></h2>
<p>customer@assm.example / 02-0000-0000 ( 10:00~18:00)</p>
</div>
</section>
</div>
);
}

33
src/app/legal/page.tsx Normal file
View File

@@ -0,0 +1,33 @@
export const dynamic = "force-static";
export default function LegalDisclaimerPage() {
return (
<div className="space-y-6">
<section className="rounded-xl overflow-hidden bg-white p-6 md:p-8">
<h1 className="text-2xl font-bold mb-2"> </h1>
<p className="text-sm text-neutral-500 mb-6">시행일: 2025-11-02</p>
<div className="prose max-w-none text-sm leading-6 text-neutral-800">
<h2>1. </h2>
<p>
, .
, , .
</p>
<h2>2. </h2>
<p>
·· .
</p>
<h2>3. </h2>
<p>
.
</p>
<h2>4. </h2>
<p>
, .
</p>
</div>
</section>
</div>
);
}

View File

@@ -75,6 +75,7 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
const tabs = [ const tabs = [
{ key: "posts", label: "내가 쓴 게시글", count: postsCount }, { key: "posts", label: "내가 쓴 게시글", count: postsCount },
{ key: "comments", label: "내가 쓴 댓글", count: commentsCount }, { key: "comments", label: "내가 쓴 댓글", count: commentsCount },
{ key: "points", label: "포인트 히스토리", count: await prisma.pointTransaction.count({ where: { userId: currentUser.userId } }) },
{ key: "messages-received", label: "받은 쪽지함", count: receivedMessagesCount }, { key: "messages-received", label: "받은 쪽지함", count: receivedMessagesCount },
{ key: "messages-sent", label: "보낸 쪽지함", count: sentMessagesCount }, { key: "messages-sent", label: "보낸 쪽지함", count: sentMessagesCount },
]; ];
@@ -86,26 +87,26 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
<HeroBanner /> <HeroBanner />
</section> </section>
{/* 프로필 섹션 */} {/* 프로필 섹션 (모바일 대응) */}
<section className="bg-white rounded-[24px] px-[108px] py-[23px]"> <section className="bg-white rounded-[16px] px-4 py-4 md:rounded-[24px] md:px-[108px] md:py-[23px]">
<div className="flex gap-[64px] items-center justify-center"> <div className="flex flex-col md:flex-row gap-4 md:gap-[64px] items-center justify-center">
{/* 좌측: 프로필 이미지, 닉네임, 레벨/등급/포인트 */} {/* 좌측: 프로필 이미지, 닉네임, 레벨/등급/포인트 */}
<div className="flex flex-col gap-[16px] items-center"> <div className="flex flex-col gap-[16px] items-center">
<div className="flex flex-col gap-[16px] items-center"> <div className="flex flex-col gap-[16px] items-center">
{/* 프로필 이미지 */} {/* 프로필 이미지 */}
<div className="relative w-[161px] h-[162px]"> <div className="relative w-[112px] h-[112px] md:w-[161px] md:h-[162px]">
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg className="w-full h-full" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="80" cy="80" r="79" stroke="#8c8c8c" strokeWidth="2" fill="none" /> <circle cx="80" cy="80" r="79" stroke="#8c8c8c" strokeWidth="2" fill="none" />
</svg> </svg>
</div> </div>
<div className="absolute inset-[10px] flex items-center justify-center"> <div className="absolute inset-[8px] md:inset-[10px] flex items-center justify-center">
<UserAvatar <UserAvatar
src={currentUser.profileImage || null} src={currentUser.profileImage || null}
alt={currentUser.nickname || "프로필"} alt={currentUser.nickname || "프로필"}
width={140} width={140}
height={140} height={140}
className="rounded-full" className="rounded-full w-full h-full object-cover"
/> />
</div> </div>
</div> </div>
@@ -120,36 +121,36 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
</div> </div>
</div> </div>
{/* 레벨/등급/포인트 정보 - 가로 배치 */} {/* 레벨/등급/포인트 정보 - 가로 배치 */}
<div className="flex gap-[16px] items-center justify-center"> <div className="flex gap-3 md:gap-[16px] items-center justify-center">
<div className="flex items-center gap-[4px] min-w-0"> <div className="flex items-center gap-[4px] min-w-0">
<ProfileLabelIcon width={14} height={14} className="shrink-0" /> <ProfileLabelIcon width={14} height={14} className="shrink-0" />
<span className="text-[11px] text-[#8c8c8c] font-[700] shrink-0"></span> <span className="text-[11px] text-[#8c8c8c] font-[700] shrink-0"></span>
<span className="text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">Lv.{currentUser.level || 1}</span> <span className="text-[13px] md:text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">Lv.{currentUser.level || 1}</span>
</div> </div>
<div className="flex items-center gap-[4px] min-w-0"> <div className="flex items-center gap-[4px] min-w-0">
<ProfileLabelIcon width={14} height={14} className="shrink-0" /> <ProfileLabelIcon width={14} height={14} className="shrink-0" />
<span className="text-[11px] text-[#8c8c8c] font-[700] shrink-0"></span> <span className="text-[11px] text-[#8c8c8c] font-[700] shrink-0"></span>
<span className="text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">{getGradeName(currentUser.grade)}</span> <span className="text-[13px] md:text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">{getGradeName(currentUser.grade)}</span>
</div> </div>
<div className="flex items-center gap-[4px] min-w-0"> <div className="flex items-center gap-[4px] min-w-0">
<ProfileLabelIcon width={14} height={14} className="shrink-0" /> <ProfileLabelIcon width={14} height={14} className="shrink-0" />
<span className="text-[11px] text-[#8c8c8c] font-[700] shrink-0"></span> <span className="text-[11px] text-[#8c8c8c] font-[700] shrink-0"></span>
<span className="text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">{currentUser.points.toLocaleString()}</span> <span className="text-[13px] md:text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">{currentUser.points.toLocaleString()}</span>
</div> </div>
</div> </div>
</div> </div>
{/* 구분선 */} {/* 구분선 (모바일 숨김) */}
<div className="w-[8px] h-[162px] bg-[#d5d5d5] shrink-0"></div> <div className="hidden md:block w-[8px] h-[162px] bg-[#d5d5d5] shrink-0"></div>
{/* 우측: 등급 배지 및 포인트 진행 상황 */} {/* 우측: 등급 배지 및 포인트 진행 상황 */}
<div className="flex flex-col items-center justify-center w-[161px] shrink-0"> <div className="flex flex-col items-center justify-center w-[120px] md:w-[161px] shrink-0">
<div className="w-[125px] h-[125px] flex items-center justify-center mb-[16px]"> <div className="w-[96px] h-[96px] md:w-[125px] md:h-[125px] flex items-center justify-center mb-[16px]">
<GradeIcon grade={currentUser.grade} width={125} height={125} /> <GradeIcon grade={currentUser.grade} width={125} height={125} />
</div> </div>
<div className="flex flex-col gap-[16px] items-center"> <div className="flex flex-col gap-[16px] items-center">
<div className="text-[20px] font-bold text-[#5c5c5c]">{getGradeName(currentUser.grade)}</div> <div className="text-[16px] md:text-[20px] font-bold text-[#5c5c5c]">{getGradeName(currentUser.grade)}</div>
<div className="flex items-start gap-[8px] text-[20px] font-bold"> <div className="flex items-start gap-[8px] text-[16px] md:text-[20px] font-bold">
<span className="text-[#5c5c5c] truncate">{(currentUser.points / 1000000).toFixed(1)}M</span> <span className="text-[#5c5c5c] truncate">{(currentUser.points / 1000000).toFixed(1)}M</span>
<span className="text-[#8c8c8c] shrink-0">/ {(nextGradePoints / 1000000).toFixed(1)}M</span> <span className="text-[#8c8c8c] shrink-0">/ {(nextGradePoints / 1000000).toFixed(1)}M</span>
</div> </div>
@@ -158,31 +159,31 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
</div> </div>
</section> </section>
{/* 탭 버튼 */} {/* 탭 버튼 (모바일 가로 스크롤) */}
<section className="h-[67px]"> <section className="mt-4">
<div className="bg-white rounded-[24px] p-[10px] flex gap-[10px] items-center"> <div className="bg-white rounded-[16px] p-2 md:rounded-[24px] md:p-[10px] flex gap-2 md:gap-[10px] items-center overflow-x-auto scrollbar-thin-x">
{tabs.map((tab) => ( {tabs.map((tab) => (
<Link <Link
key={tab.key} key={tab.key}
href={`/my-page?tab=${tab.key}`} href={`/my-page?tab=${tab.key}`}
className={`flex-1 h-[46px] rounded-[16px] flex items-center justify-center gap-[8px] ${ className={`shrink-0 md:flex-1 h-10 md:h-[46px] rounded-[16px] flex items-center justify-center gap-[8px] px-3 ${
activeTab === tab.key activeTab === tab.key
? "bg-[#5c5c5c] text-white" ? "bg-[#5c5c5c] text-white"
: "bg-transparent text-[#5c5c5c]" : "bg-transparent text-[#5c5c5c]"
}`} }`}
> >
<span className={`text-[16px] ${activeTab === tab.key ? "font-medium" : "font-normal"}`}> <span className={`text-[14px] md:text-[16px] ${activeTab === tab.key ? "font-medium" : "font-normal"}`}>
{tab.label} {tab.label}
</span> </span>
<div <div
className={`h-[20px] px-[8px] py-[4px] rounded-[14px] ${ className={`inline-flex items-center justify-center h-[20px] px-[8px] rounded-[14px] ${
activeTab === tab.key activeTab === tab.key
? "bg-white" ? "bg-white"
: "border border-[#707070]" : "border border-[#707070]"
}`} }`}
> >
<span <span
className={`text-[10px] font-semibold ${ className={`text-[10px] leading-none font-semibold ${
activeTab === tab.key ? "text-[#707070]" : "text-[#707070]" activeTab === tab.key ? "text-[#707070]" : "text-[#707070]"
}`} }`}
> >
@@ -204,11 +205,55 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
variant="board" variant="board"
/> />
)} )}
{activeTab === "comments" && ( {activeTab === "comments" && (async () => {
const pageSize = 20;
const comments = await prisma.comment.findMany({
where: { authorId: currentUser.userId },
orderBy: { createdAt: "desc" },
take: pageSize,
skip: (page - 1) * pageSize,
select: { id: true, postId: true, content: true, createdAt: true },
});
return (
<div className="bg-white rounded-[24px] p-4"> <div className="bg-white rounded-[24px] p-4">
<div className="text-center text-neutral-500 py-10"> .</div> <ul className="divide-y divide-neutral-200">
{comments.map((c) => (
<li key={c.id} className="py-3 flex items-center justify-between gap-4">
<Link href={`/posts/${c.postId}`} className="text-sm text-neutral-800 hover:underline truncate max-w-[70%]">
{c.content.slice(0, 80)}{c.content.length > 80 ? "…" : ""}
</Link>
<span className="text-xs text-neutral-500 shrink-0">{new Date(c.createdAt).toLocaleString()}</span>
</li>
))}
</ul>
</div> </div>
)} );
})()}
{activeTab === "points" && (async () => {
const pageSize = 20;
const txns = await prisma.pointTransaction.findMany({
where: { userId: currentUser.userId },
orderBy: { createdAt: "desc" },
take: pageSize,
skip: (page - 1) * pageSize,
select: { id: true, amount: true, reason: true, createdAt: true },
});
return (
<div className="bg-white rounded-[24px] p-4">
<ul className="divide-y divide-neutral-200">
{txns.map((t) => (
<li key={t.id} className="py-3 flex items-center justify-between gap-4">
<div className="text-sm text-neutral-800 truncate max-w-[60%]">{t.reason}</div>
<div className={`text-sm font-semibold ${t.amount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{t.amount >= 0 ? `+${t.amount}` : `${t.amount}`}
</div>
<span className="text-xs text-neutral-500 shrink-0">{new Date(t.createdAt).toLocaleString()}</span>
</li>
))}
</ul>
</div>
);
})()}
{activeTab === "messages-received" && ( {activeTab === "messages-received" && (
<div className="bg-white rounded-[24px] p-4"> <div className="bg-white rounded-[24px] p-4">
<div className="text-center text-neutral-500 py-10"> .</div> <div className="text-center text-neutral-500 py-10"> .</div>

View File

@@ -154,6 +154,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
take: 1, take: 1,
select: { url: true }, select: { url: true },
}, },
stat: { select: { commentsCount: true } },
}, },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 3, take: 3,
@@ -161,7 +162,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
} else if (isTextMain) { } else if (isTextMain) {
textPosts = await prisma.post.findMany({ textPosts = await prisma.post.findMany({
where: { boardId: sb.id, status: "published" }, where: { boardId: sb.id, status: "published" },
select: { id: true, title: true, createdAt: true, stat: { select: { recommendCount: true } } }, select: { id: true, title: true, createdAt: true, stat: { select: { recommendCount: true, commentsCount: true } } },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 7, take: 7,
}); });
@@ -275,26 +276,24 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
<span className="ml-[8px]"> </span> <span className="ml-[8px]"> </span>
</span> </span>
</Link> </Link>
<button className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center"> <Link href="/my-page?tab=points" className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center">
<span className="absolute left-[100px] inline-flex items-center"> <span className="absolute left-[100px] inline-flex items-center">
<SearchIcon width={16} height={16} /> <SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </span> <span className="ml-[8px]"> </span>
</span> </span>
</button> </Link>
<button className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center"> <Link href={`/my-page?tab=posts`} className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center">
<span className="absolute left-[100px] inline-flex items-center"> <span className="absolute left-[100px] inline-flex items-center">
<SearchIcon width={16} height={16} /> <SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </span> <span className="ml-[8px]"> </span>
</span> </span>
<span className="absolute right-[8px] w-[47px] h-[18px] rounded-full bg-white text-[#707070] text-[10px] font-[600] leading-[18px] flex items-center justify-end pr-[6px]">12 </span> </Link>
</button> <Link href={`/my-page?tab=comments`} className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center">
<button className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center">
<span className="absolute left-[100px] inline-flex items-center"> <span className="absolute left-[100px] inline-flex items-center">
<SearchIcon width={16} height={16} /> <SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </span> <span className="ml-[8px]"> </span>
</span> </span>
<span className="absolute right-[8px] w-[47px] h-[18px] rounded-full bg-white text-[#707070] text-[10px] font-[600] leading-[18px] flex items-center justify-end pr-[6px]">7 </span> </Link>
</button>
</div> </div>
</div> </div>
{(await Promise.all(firstTwo.map((b) => prepareBoardPanelData(b)))).map((panelData, idx) => ( {(await Promise.all(firstTwo.map((b) => prepareBoardPanelData(b)))).map((panelData, idx) => (

View File

@@ -0,0 +1,259 @@
"use client";
import { useEffect, useState, useRef } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
function stripHtml(html: string | null | undefined): string {
if (!html) return "";
return html.replace(/<[^>]*>/g, "").trim();
}
type RelatedPost = {
id: string;
title: string;
createdAt: string;
author: { nickname: string | null } | null;
stat: { views: number; recommendCount: number; commentsCount: number } | null;
};
type Props = {
boardId: string;
boardName: string;
currentPostId: string;
pageSize?: number;
};
export function RelatedPosts({ boardId, boardName, currentPostId, pageSize = 10 }: Props) {
const router = useRouter();
const searchParams = useSearchParams();
const [page, setPage] = useState(1);
const [posts, setPosts] = useState<RelatedPost[]>([]);
const [stablePosts, setStablePosts] = useState<RelatedPost[]>([]);
const [total, setTotal] = useState(0);
const [stableTotal, setStableTotal] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null);
const listContainerRef = useRef<HTMLDivElement>(null);
// URL에서 page 파라미터 읽기
useEffect(() => {
const pageParam = searchParams.get("relatedPage");
if (pageParam) {
const parsed = parseInt(pageParam, 10);
if (!isNaN(parsed) && parsed > 0) {
setPage(parsed);
}
}
}, [searchParams]);
// pageSize 파라미터 읽기
useEffect(() => {
const sizeParam = searchParams.get("relatedPageSize");
if (sizeParam) {
const parsed = parseInt(sizeParam, 10);
if (!isNaN(parsed) && parsed > 0 && parsed <= 100) {
setCurrentPageSize(parsed);
}
}
}, [searchParams]);
// 게시글 목록 가져오기
useEffect(() => {
let isMounted = true;
setIsLoading(true);
(async () => {
try {
const params = new URLSearchParams({
boardId,
page: String(page),
pageSize: String(currentPageSize),
sort: "recent",
});
const res = await fetch(`/api/posts?${params.toString()}`);
if (!res.ok) return;
const data = await res.json();
if (!isMounted) return;
// 현재 게시글 제외
const filtered = data.items.filter((p: RelatedPost) => p.id !== currentPostId);
const filteredTotal = Math.max(0, data.total - 1);
setPosts(filtered);
setTotal(filteredTotal);
// 로딩이 끝나면 안정적인 데이터로 업데이트
if (filtered.length > 0 || filteredTotal === 0) {
setStablePosts(filtered);
setStableTotal(filteredTotal);
}
} catch (e) {
console.error(e);
} finally {
if (isMounted) {
setIsLoading(false);
}
}
})();
return () => {
isMounted = false;
};
}, [boardId, currentPostId, page, currentPageSize]);
// 로딩이 끝나면 높이 고정 해제
useEffect(() => {
if (!isLoading) {
setLockedMinHeight(null);
}
}, [isLoading]);
// 이전 데이터 유지 (깜빡임 방지)
useEffect(() => {
if (posts.length > 0 || total > 0) {
setStablePosts(posts);
setStableTotal(total);
}
}, [posts, total]);
const displayPosts = stablePosts.length > 0 ? stablePosts : posts;
const displayTotal = stableTotal > 0 ? stableTotal : total;
const totalPages = Math.max(1, Math.ceil(displayTotal / currentPageSize));
// 높이 고정 함수
const lockHeight = () => {
const el = listContainerRef.current;
if (!el) return;
const h = el.offsetHeight;
if (h > 0) setLockedMinHeight(h);
};
const handlePageChange = (newPage: number) => {
lockHeight();
setPage(newPage);
const newSp = new URLSearchParams(searchParams.toString());
newSp.set("relatedPage", String(newPage));
router.push(`?${newSp.toString()}`, { scroll: false });
};
const handlePageSizeChange = (newSize: number) => {
lockHeight();
setCurrentPageSize(newSize);
setPage(1);
const newSp = new URLSearchParams(searchParams.toString());
newSp.set("relatedPageSize", String(newSize));
newSp.set("relatedPage", "1");
router.push(`?${newSp.toString()}`, { scroll: false });
};
const isEmpty = !isLoading && displayPosts.length === 0 && stablePosts.length === 0;
return (
<section id="related-posts" className="rounded-xl overflow-hidden bg-white">
<header className="px-4 py-3 border-b border-neutral-200">
<h2 className="text-lg font-bold text-neutral-900">{boardName}</h2>
</header>
{/* 빈 상태 */}
{isEmpty && (
<div className="px-4 py-8 text-center text-sm text-neutral-500"> .</div>
)}
{/* 리스트 컨테이너 (높이 고정) */}
<div ref={listContainerRef} style={{ minHeight: lockedMinHeight ? `${lockedMinHeight}px` : undefined }}>
<ul className="divide-y divide-[#ececec]">
{displayPosts.map((p) => {
const postDate = new Date(p.createdAt);
return (
<li key={p.id} className="px-4 py-2.5 hover:bg-neutral-50 transition-colors">
<Link href={`/posts/${p.id}`} className="block">
<div className="grid grid-cols-1 md:grid-cols-[1fr_120px_120px_80px] items-center gap-2">
<div className="min-w-0">
<div className="truncate text-[15px] md:text-base text-neutral-900">
{stripHtml(p.title)}
</div>
</div>
<div className="md:w-[120px] text-xs text-neutral-600 text-center hidden md:flex items-center justify-center gap-2">
<span className="truncate max-w-[84px]">{p.author?.nickname ?? "익명"}</span>
</div>
<div className="md:w-[120px] text-[11px] text-[#8c8c8c] text-center flex items-center justify-center gap-3">
<span> {p.stat?.views ?? 0}</span>
<span> {p.stat?.recommendCount ?? 0}</span>
<span> {p.stat?.commentsCount ?? 0}</span>
</div>
<div className="md:w-[80px] text-[11px] text-neutral-500 text-right hidden md:block">
{postDate.toLocaleDateString()}
</div>
</div>
</Link>
</li>
);
})}
</ul>
</div>
{/* 페이지네이션 */}
<div className="mt-4 flex items-center justify-between px-4 pb-4">
<div className="flex items-center gap-2">
{/* Previous */}
<button
onClick={() => handlePageChange(Math.max(1, page - 1))}
disabled={page <= 1}
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
>
Previous
</button>
{/* Numbers with ellipsis */}
<div className="flex items-center gap-1">
{(() => {
const nodes: (number | string)[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) nodes.push(i);
} else {
nodes.push(1);
const start = Math.max(2, page - 1);
const end = Math.min(totalPages - 1, page + 1);
if (start > 2) nodes.push("...");
for (let i = start; i <= end; i++) nodes.push(i);
if (end < totalPages - 1) nodes.push("...");
nodes.push(totalPages);
}
return nodes.map((n, idx) =>
typeof n === "number" ? (
<button
key={`p-${n}-${idx}`}
onClick={() => handlePageChange(n)}
aria-current={n === page ? "page" : undefined}
className={`h-9 w-9 rounded-md border ${n === page ? "border-neutral-300 text-[#f94b37] font-semibold" : "border-neutral-300 text-neutral-900"}`}
>
{n}
</button>
) : (
<span key={`e-${idx}`} className="h-9 w-9 inline-flex items-center justify-center text-neutral-900"></span>
)
);
})()}
</div>
{/* Next */}
<button
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
disabled={page >= totalPages}
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
>
Next
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-600"> </span>
<select
value={currentPageSize}
onChange={(e) => handlePageSizeChange(parseInt(e.target.value, 10))}
className="h-9 px-2 rounded-md border border-neutral-300 bg-white text-sm"
>
<option value="10">10</option>
<option value="20">20</option>
<option value="30">30</option>
<option value="50">50</option>
</select>
</div>
</div>
</section>
);
}

View File

@@ -1,13 +1,9 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { HeroBanner } from "@/app/components/HeroBanner"; import { HeroBanner } from "@/app/components/HeroBanner";
import Link from "next/link"; import { RelatedPosts } from "./RelatedPosts";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { CommentSection } from "@/app/components/CommentSection";
function stripHtml(html: string | null | undefined): string {
if (!html) return "";
return html.replace(/<[^>]*>/g, "").trim();
}
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다. // 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
export default async function PostDetail({ params }: { params: any }) { export default async function PostDetail({ params }: { params: any }) {
@@ -21,33 +17,20 @@ export default async function PostDetail({ params }: { params: any }) {
if (!res.ok) return notFound(); if (!res.ok) return notFound();
const { post } = await res.json(); const { post } = await res.json();
const createdAt = post?.createdAt ? new Date(post.createdAt) : null; const createdAt = post?.createdAt ? new Date(post.createdAt) : null;
// 메인배너 표시 설정
// 같은 게시판의 다른 게시글 10개 가져오기 (현재 게시글 제외) const SETTINGS_KEY = "mainpage_settings" as const;
const relatedPosts = post?.boardId const settingRow = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
? await prisma.post.findMany({ const parsed = settingRow ? JSON.parse(settingRow.value as string) : {};
where: { const showBanner: boolean = parsed.showBanner ?? true;
boardId: post.boardId,
id: { not: id },
status: { not: "deleted" },
},
orderBy: { createdAt: "desc" },
take: 10,
select: {
id: true,
title: true,
createdAt: true,
author: { select: { nickname: true } },
stat: { select: { views: true, recommendCount: true, commentsCount: true } },
},
})
: [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 상단 배너 */} {/* 상단 배너 */}
{showBanner && (
<section> <section>
<HeroBanner /> <HeroBanner />
</section> </section>
)}
{/* 본문 카드 */} {/* 본문 카드 */}
<section className="rounded-xl overflow-hidden bg-white"> <section className="rounded-xl overflow-hidden bg-white">
@@ -76,44 +59,17 @@ export default async function PostDetail({ params }: { params: any }) {
</footer> </footer>
</section> </section>
{/* 댓글 섹션 */}
<CommentSection postId={id} />
{/* 같은 게시판 게시글 목록 */} {/* 같은 게시판 게시글 목록 */}
{relatedPosts.length > 0 && ( {post?.boardId && post?.board && (
<section className="rounded-xl overflow-hidden bg-white"> <RelatedPosts
<header className="px-4 py-3 border-b border-neutral-200"> boardId={post.boardId}
<h2 className="text-lg font-bold text-neutral-900"> </h2> boardName={post.board.name}
</header> currentPostId={id}
<div> pageSize={10}
<ul className="divide-y divide-[#ececec]"> />
{relatedPosts.map((p) => {
const postDate = new Date(p.createdAt);
return (
<li key={p.id} className="px-4 py-2.5 hover:bg-neutral-50 transition-colors">
<Link href={`/posts/${p.id}`} className="block">
<div className="grid grid-cols-1 md:grid-cols-[1fr_120px_120px_80px] items-center gap-2">
<div className="min-w-0">
<div className="truncate text-[15px] md:text-base text-neutral-900">
{stripHtml(p.title)}
</div>
</div>
<div className="md:w-[120px] text-xs text-neutral-600 text-center hidden md:flex items-center justify-center gap-2">
<span className="truncate max-w-[84px]">{p.author?.nickname ?? "익명"}</span>
</div>
<div className="md:w-[120px] text-[11px] text-[#8c8c8c] text-center flex items-center justify-center gap-3">
<span> {p.stat?.views ?? 0}</span>
<span> {p.stat?.recommendCount ?? 0}</span>
<span> {p.stat?.commentsCount ?? 0}</span>
</div>
<div className="md:w-[80px] text-[11px] text-neutral-500 text-right hidden md:block">
{postDate.toLocaleDateString()}
</div>
</div>
</Link>
</li>
);
})}
</ul>
</div>
</section>
)} )}
</div> </div>
); );

50
src/app/privacy/page.tsx Normal file
View File

@@ -0,0 +1,50 @@
export const dynamic = "force-static";
export default function PrivacyPolicyPage() {
return (
<div className="space-y-6">
<section className="rounded-xl overflow-hidden bg-white p-6 md:p-8">
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-sm text-neutral-500 mb-6">시행일: 2025-11-02</p>
<div className="prose max-w-none text-sm leading-6 text-neutral-800">
<h2>1. </h2>
<p>
, , , , ,
, , , IP, (/OS) .
</p>
<h2>2. </h2>
<ul>
<li> , </li>
<li> , </li>
<li>/ </li>
<li> </li>
</ul>
<h2>3. </h2>
<p>
. , ( )
, .
</p>
<h2>4. 3 </h2>
<p>
3 .
, .
</p>
<h2>5. </h2>
<p>
··· , .
</p>
<h2>6. </h2>
<p>
문의: privacy@assm.example / 02-0000-0000 ( 10:00~18:00)
</p>
<h2>7. </h2>
<p>
· ·· , .
</p>
</div>
</section>
</div>
);
}

View File

@@ -1,4 +1,6 @@
// 간이 인증 헬퍼: 쿠키(uid) 우선, 없으면 헤더(x-user-id)에서 사용자 식별자를 읽습니다. // 간이 인증 헬퍼: 쿠키(uid) 우선, 없으면 헤더(x-user-id)에서 사용자 식별자를 읽습니다.
import prisma from "@/lib/prisma";
export function getUserIdFromRequest(req: Request): string | null { export function getUserIdFromRequest(req: Request): string | null {
try { try {
const cookieHeader = req.headers.get("cookie") || ""; const cookieHeader = req.headers.get("cookie") || "";
@@ -17,4 +19,23 @@ export function getUserIdFromRequest(req: Request): string | null {
} }
} }
// 개발 환경에서만: 인증 정보가 없으면 admin 사용자 ID를 반환
// DB 조회 결과를 프로세스 전역에 캐싱해 과도한 쿼리를 방지
export async function getUserIdOrAdmin(req: Request): Promise<string | null> {
const uid = getUserIdFromRequest(req);
if (uid) return uid;
if (process.env.NODE_ENV === "production") return null;
try {
const globalAny = global as unknown as { __ADMIN_UID?: string };
if (globalAny.__ADMIN_UID) return globalAny.__ADMIN_UID;
const admin = await prisma.user.findUnique({ where: { nickname: "admin" }, select: { userId: true } });
if (admin?.userId) {
globalAny.__ADMIN_UID = admin.userId;
return admin.userId;
}
} catch {
// ignore
}
return null;
}