123
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user