123
This commit is contained in:
@@ -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
22
src/app/api/me/route.ts
Normal 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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
265
src/app/components/CommentSection.tsx
Normal file
265
src/app/components/CommentSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
27
src/app/contact/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
28
src/app/email-deny/page.tsx
Normal file
28
src/app/email-deny/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
31
src/app/guide/page.tsx
Normal 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
33
src/app/legal/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
259
src/app/posts/[id]/RelatedPosts.tsx
Normal file
259
src/app/posts/[id]/RelatedPosts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
50
src/app/privacy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user