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

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

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

View File

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

View File

@@ -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 (
<div className="space-y-6">
{/* 상단 배너 (서브카테고리 표시) */}
<section>
<HeroBanner
subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.slug}` }))}
activeSubId={id}
/>
</section>
{showBanner ? (
<section>
<HeroBanner
subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.slug}` }))}
activeSubId={id}
/>
</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>

View File

@@ -1,13 +1,15 @@
import Link from "next/link";
export function AppFooter() {
return (
<footer className="py-[72px]">
<div className="text-[#626262] text-[16px] leading-[14px] flex flex-row mb-[30px]">
<div className="flex-1"></div>
<div className="border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></div>
<div className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </div>
<div className=" border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </div>
<div className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></div>
<div className="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>
<Link href="/email-deny" className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </Link>
<Link href="/legal" className=" border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </Link>
<Link href="/guide" className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></Link>
<Link href="/contact" className="px-[8px] cursor-pointer hover:text-[#2BAF7E]"></Link>
<div className="flex-1"></div>
</div>
<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 Link from "next/link";
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 { usePathname, useSearchParams } from "next/navigation";
import { SinglePageLogo } from "@/app/components/SinglePageLogo";
@@ -23,6 +26,11 @@ export function AppHeader() {
const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({});
const closeTimer = React.useRef<number | null>(null);
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();
@@ -416,6 +424,28 @@ export function AppHeader() {
<div className="mb-3 h-10 flex items-center justify-between">
</div>
<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 />
<Link
href="/admin"

View File

@@ -31,7 +31,7 @@ type PostData = {
createdAt: Date;
content?: string | null;
attachments?: { url: string }[];
stat?: { recommendCount: number | null };
stat?: { recommendCount: number | null; commentsCount: number | null };
};
type BoardPanelData = {
@@ -78,7 +78,7 @@ export function BoardPanelClient({
return imgMatch[1];
}
// figure 안의 img 태그도 확인
const figureMatch = content.match(/<figure[^>]*>.*?<img[^>]+src=["']([^"']+)["'][^>]*>/is);
const figureMatch = content.match(/<figure[^>]*>[\s\S]*?<img[^>]+src=["']([^"']+)["'][^>]*>/i);
if (figureMatch && figureMatch[1]) {
return figureMatch[1];
}
@@ -97,7 +97,7 @@ export function BoardPanelClient({
<div className="flex items-center gap-[8px] shrink-0">
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</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) => (
<button
key={sb.id}
@@ -113,12 +113,12 @@ export function BoardPanelClient({
</div>
<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="px-[24px] pt-[8px] pb-[16px]">
<div className="px-[0px] pt-[8px] pb-[16px]">
<div className="flex flex-col gap-[16px]">
{selectedBoardData.specialRankUsers.map((user, idx) => {
const rank = idx + 1;
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">
<UserAvatar
src={user.profileImage}
@@ -131,7 +131,7 @@ export function BoardPanelClient({
<GradeIcon grade={user.grade} width={40} height={40} />
</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 items-center gap-[12px]">
<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>
</div>
</div>
</div>
</Link>
);
})}
</div>
@@ -173,7 +173,7 @@ export function BoardPanelClient({
<div className="flex items-center gap-[8px] shrink-0">
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</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) => (
<button
key={sb.id}
@@ -189,7 +189,7 @@ export function BoardPanelClient({
</div>
<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="px-[24px] pt-[8px] pb-[16px]">
<div className="px-[0px] pt-[8px] pb-[16px]">
<div className="flex flex-col gap-[16px]">
{selectedBoardData.previewPosts.map((post) => {
// attachments에서 이미지를 먼저 찾고, 없으면 content에서 추출
@@ -209,7 +209,7 @@ export function BoardPanelClient({
</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="bg-white border border-[#d5d5d5] px-[16px] py-[5px] rounded-[12px] text-[14px] text-[#5c5c5c] shrink-0 w-fit">
{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>
<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 className="h-[16px] relative">
<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="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</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) => (
<button
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>
<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>
<span className="text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">{formatDateYmd(p.createdAt)}</span>
</div>

View File

@@ -45,8 +45,19 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
};
return (
<div className="flex items-center justify-between px-0 py-2">
<div className="flex items-center gap-2">
<div className="px-0 py-2 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
{/* 검색바: 모바일에서는 상단 전체폭 */}
<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}>
<option value="recent"></option>
<option value="popular"></option>
@@ -58,14 +69,6 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
<option value="1m">1</option>
</select>
</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>
);
}

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());
export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; activeSubId?: string }) {
export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItems?: SubItem[]; activeSubId?: string; hideSubOnMobile?: boolean }) {
// SWR 캐시 사용: 페이지 전환 시 재요청/깜빡임 최소화
const { data } = useSWR<{ banners: Banner[] }>("/api/banners", fetcher, {
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">
<SelectedBanner className="h-56 sm:h-72 md:h-[264px]" />
{/* 분리된 하단 블랙 바: 높이 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 && (
<div className="flex flex-wrap items-center gap-[8px]">
{subItems.map((s) => (
@@ -151,7 +151,7 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
</div>
{/* 분리된 하단 블랙 바: 높이 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 && (
<div className="flex flex-wrap items-center gap-[8px]">
{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">
{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)}
{(p.stat?.commentsCount ?? 0) > 0 && (
<span className="ml-1 text-[12px] md:text-[12px] text-[#f94b37] align-middle">[{p.stat?.commentsCount}]</span>
)}
</Link>
{!!p.postTags?.length && (
<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 && (
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">
{/* Previous */}
<button
@@ -281,35 +285,38 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
Next
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-600"> </span>
<select
value={currentPageSize}
onChange={(e) => {
const newSize = parseInt(e.target.value, 10);
setCurrentPageSize(newSize);
setPage(1);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
nextSp.set("pageSize", String(newSize));
nextSp.set("page", "1");
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `?${nextSp.toString()}`);
}
}}
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="40">40</option>
<option value="50">50</option>
</select>
{/* 하단: 표시 개수 + 글쓰기 (모바일에서 아래로 분리) */}
<div className="flex items-center justify-between md:justify-end gap-2">
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-600"> </span>
<select
value={currentPageSize}
onChange={(e) => {
const newSize = parseInt(e.target.value, 10);
setCurrentPageSize(newSize);
setPage(1);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
nextSp.set("pageSize", String(newSize));
nextSp.set("page", "1");
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `?${nextSp.toString()}`);
}
}}
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="40">40</option>
<option value="50">50</option>
</select>
</div>
{newPostHref && (
<Link href={newPostHref} className="shrink-0">
<button className="h-9 px-4 rounded-[10px] bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95"></button>
</Link>
)}
</div>
{newPostHref && (
<Link href={newPostHref} className="shrink-0">
<button className="h-9 px-4 rounded-[10px] bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95"></button>
</Link>
)}
</div>
) : (
<div className="mt-3 flex justify-center">

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 */
}
/* 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-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 = [
{ key: "posts", label: "내가 쓴 게시글", count: postsCount },
{ 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-sent", label: "보낸 쪽지함", count: sentMessagesCount },
];
@@ -86,26 +87,26 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
<HeroBanner />
</section>
{/* 프로필 섹션 */}
<section className="bg-white rounded-[24px] px-[108px] py-[23px]">
<div className="flex gap-[64px] items-center justify-center">
{/* 프로필 섹션 (모바일 대응) */}
<section className="bg-white rounded-[16px] px-4 py-4 md:rounded-[24px] md:px-[108px] md:py-[23px]">
<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="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">
<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" />
</svg>
</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
src={currentUser.profileImage || null}
alt={currentUser.nickname || "프로필"}
width={140}
height={140}
className="rounded-full"
className="rounded-full w-full h-full object-cover"
/>
</div>
</div>
@@ -120,36 +121,36 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
</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">
<ProfileLabelIcon width={14} height={14} className="shrink-0" />
<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 className="flex items-center gap-[4px] min-w-0">
<ProfileLabelIcon width={14} height={14} className="shrink-0" />
<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 className="flex items-center gap-[4px] min-w-0">
<ProfileLabelIcon width={14} height={14} className="shrink-0" />
<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 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="w-[125px] h-[125px] flex items-center justify-center mb-[16px]">
<div className="flex flex-col items-center justify-center w-[120px] md:w-[161px] shrink-0">
<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} />
</div>
<div className="flex flex-col gap-[16px] items-center">
<div className="text-[20px] font-bold text-[#5c5c5c]">{getGradeName(currentUser.grade)}</div>
<div className="flex items-start gap-[8px] text-[20px] font-bold">
<div className="text-[16px] md:text-[20px] font-bold text-[#5c5c5c]">{getGradeName(currentUser.grade)}</div>
<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-[#8c8c8c] shrink-0">/ {(nextGradePoints / 1000000).toFixed(1)}M</span>
</div>
@@ -158,31 +159,31 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
</div>
</section>
{/* 탭 버튼 */}
<section className="h-[67px]">
<div className="bg-white rounded-[24px] p-[10px] flex gap-[10px] items-center">
{/* 탭 버튼 (모바일 가로 스크롤) */}
<section className="mt-4">
<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) => (
<Link
key={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
? "bg-[#5c5c5c] text-white"
: "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}
</span>
<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
? "bg-white"
: "border border-[#707070]"
}`}
>
<span
className={`text-[10px] font-semibold ${
className={`text-[10px] leading-none font-semibold ${
activeTab === tab.key ? "text-[#707070]" : "text-[#707070]"
}`}
>
@@ -204,11 +205,55 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
variant="board"
/>
)}
{activeTab === "comments" && (
<div className="bg-white rounded-[24px] p-4">
<div className="text-center text-neutral-500 py-10"> .</div>
</div>
)}
{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">
<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>
);
})()}
{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" && (
<div className="bg-white rounded-[24px] p-4">
<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,
select: { url: true },
},
stat: { select: { commentsCount: true } },
},
orderBy: { createdAt: "desc" },
take: 3,
@@ -161,7 +162,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
} else if (isTextMain) {
textPosts = await prisma.post.findMany({
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" },
take: 7,
});
@@ -275,26 +276,24 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
<span className="ml-[8px]"> </span>
</span>
</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">
<SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </span>
</span>
</button>
<button className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center">
</Link>
<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">
<SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </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>
</button>
<button className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center">
</Link>
<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">
<span className="absolute left-[100px] inline-flex items-center">
<SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </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>
</button>
</Link>
</div>
</div>
{(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 { headers } from "next/headers";
import { HeroBanner } from "@/app/components/HeroBanner";
import Link from "next/link";
import { RelatedPosts } from "./RelatedPosts";
import prisma from "@/lib/prisma";
function stripHtml(html: string | null | undefined): string {
if (!html) return "";
return html.replace(/<[^>]*>/g, "").trim();
}
import { CommentSection } from "@/app/components/CommentSection";
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
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();
const { post } = await res.json();
const createdAt = post?.createdAt ? new Date(post.createdAt) : null;
// 같은 게시판의 다른 게시글 10개 가져오기 (현재 게시글 제외)
const relatedPosts = post?.boardId
? await prisma.post.findMany({
where: {
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 } },
},
})
: [];
// 메인배너 표시 설정
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;
return (
<div className="space-y-6">
{/* 상단 배너 */}
<section>
<HeroBanner />
</section>
{showBanner && (
<section>
<HeroBanner />
</section>
)}
{/* 본문 카드 */}
<section className="rounded-xl overflow-hidden bg-white">
@@ -76,44 +59,17 @@ export default async function PostDetail({ params }: { params: any }) {
</footer>
</section>
{/* 댓글 섹션 */}
<CommentSection postId={id} />
{/* 같은 게시판 게시글 목록 */}
{relatedPosts.length > 0 && (
<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"> </h2>
</header>
<div>
<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>
{post?.boardId && post?.board && (
<RelatedPosts
boardId={post.boardId}
boardName={post.board.name}
currentPostId={id}
pageSize={10}
/>
)}
</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>
);
}