디자인디테일
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
"use client";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import useSWR from "swr";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import ViewsIcon from "@/app/svgs/ViewsIcon";
|
||||
import LikeIcon from "@/app/svgs/LikeIcon";
|
||||
import CommentIcon from "@/app/svgs/CommentIcon";
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
@@ -22,8 +28,11 @@ type Resp = {
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export function PostList({ boardId, sort = "recent", q, tag, author, start, end }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; start?: string; end?: string }) {
|
||||
export function PostList({ boardId, sort = "recent", q, tag, author, start, end, variant = "default", newPostHref }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string }) {
|
||||
const pageSize = 10;
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
|
||||
const getKey = (index: number, prev: Resp | null) => {
|
||||
if (prev && prev.items.length === 0) return null;
|
||||
const page = index + 1;
|
||||
@@ -36,46 +45,89 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end
|
||||
if (end) sp.set("end", end);
|
||||
return `/api/posts?${sp.toString()}`;
|
||||
};
|
||||
const { data, size, setSize, isLoading } = useSWRInfinite<Resp>(getKey, fetcher);
|
||||
const items = data?.flatMap((d) => d.items) ?? [];
|
||||
// default(무한 스크롤 형태)
|
||||
const { data, size, setSize, isLoading } = useSWRInfinite<Resp>(getKey, fetcher, { revalidateFirstPage: false });
|
||||
const itemsInfinite = data?.flatMap((d) => d.items) ?? [];
|
||||
const canLoadMore = (data?.at(-1)?.items.length ?? 0) === pageSize;
|
||||
const isEmptyInfinite = !isLoading && itemsInfinite.length === 0;
|
||||
|
||||
// board 변형: 번호 페이지네이션
|
||||
const initialPage = useMemo(() => Math.max(1, parseInt(sp.get("page") || "1", 10)), [sp]);
|
||||
const [page, setPage] = useState(initialPage);
|
||||
useEffect(() => { setPage(initialPage); }, [initialPage]);
|
||||
const singleKey = useMemo(() => {
|
||||
if (variant !== "board") return null;
|
||||
const usp = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sort });
|
||||
if (boardId) usp.set("boardId", boardId);
|
||||
if (q) usp.set("q", q);
|
||||
if (tag) usp.set("tag", tag);
|
||||
if (author) usp.set("author", author);
|
||||
if (start) usp.set("start", start);
|
||||
if (end) usp.set("end", end);
|
||||
return `/api/posts?${usp.toString()}`;
|
||||
}, [variant, page, pageSize, sort, boardId, q, tag, author, start, end]);
|
||||
const { data: singlePageResp, isLoading: isLoadingSingle } = useSWR<Resp>(singleKey, fetcher);
|
||||
const itemsSingle = singlePageResp?.items ?? [];
|
||||
const totalSingle = singlePageResp?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(totalSingle / pageSize));
|
||||
const isEmptySingle = !isLoadingSingle && itemsSingle.length === 0;
|
||||
|
||||
const items = variant === "board" ? itemsSingle : itemsInfinite;
|
||||
const isEmpty = variant === "board" ? isEmptySingle : isEmptyInfinite;
|
||||
|
||||
const initials = (name?: string | null) => (name ? name.trim().slice(0, 1) : "익");
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* 정렬 스위치 */}
|
||||
<div className="px-4 py-2 border-b border-neutral-200 mb-2 flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-500 mr-1">정렬</span>
|
||||
<a
|
||||
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
sort === "recent" ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
|
||||
}`}
|
||||
href={`${q ? "/search" : "/"}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); if (boardId) p.set("boardId", boardId); p.set("sort", "recent"); return p.toString(); })()}`}
|
||||
>
|
||||
최신
|
||||
</a>
|
||||
<a
|
||||
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
sort === "popular" ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
|
||||
}`}
|
||||
href={`${q ? "/search" : "/"}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); if (boardId) p.set("boardId", boardId); p.set("sort", "popular"); return p.toString(); })()}`}
|
||||
>
|
||||
인기
|
||||
</a>
|
||||
</div>
|
||||
{/* 정렬 스위치 (board 변형에서는 상단 툴바를 숨김) */}
|
||||
{variant !== "board" && (
|
||||
<div className="px-4 py-2 border-b border-neutral-200 mb-2 flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-500 mr-1">정렬</span>
|
||||
<a
|
||||
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
sort === "recent" ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
|
||||
}`}
|
||||
href={`${q ? "/search" : "/"}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); if (boardId) p.set("boardId", boardId); p.set("sort", "recent"); return p.toString(); })()}`}
|
||||
>
|
||||
최신
|
||||
</a>
|
||||
<a
|
||||
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
sort === "popular" ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
|
||||
}`}
|
||||
href={`${q ? "/search" : "/"}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); if (boardId) p.set("boardId", boardId); p.set("sort", "popular"); return p.toString(); })()}`}
|
||||
>
|
||||
인기
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 리스트 테이블 헤더 */}
|
||||
<div className="hidden md:grid grid-cols-[1fr_auto_auto_auto] items-center px-4 py-2 text-xs text-neutral-500 border-b border-neutral-200">
|
||||
<div>제목</div>
|
||||
<div className="w-28 text-center">작성자</div>
|
||||
<div className="w-24 text-center">지표</div>
|
||||
<div className="w-24 text-right">작성일</div>
|
||||
</div>
|
||||
{/* 리스트 테이블 헤더 (board 변형에서는 숨김) */}
|
||||
{variant !== "board" && (
|
||||
<div className="hidden md:grid grid-cols-[20px_1fr_120px_120px_80px] items-center px-4 py-2 text-[12px] text-[#8c8c8c] bg-[#f6f4f4] border-b border-[#e6e6e6] rounded-t-xl">
|
||||
<div />
|
||||
<div>제목</div>
|
||||
<div className="text-center">작성자</div>
|
||||
<div className="text-center">지표</div>
|
||||
<div className="text-right">작성일</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{isEmpty && (
|
||||
<div className="h-[400px] flex items-center justify-center border-t border-b border-[#8c8c8c]">
|
||||
<p className="text-[20px] leading-[20px] text-[#161616]">글이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 아이템들 */}
|
||||
<ul className="divide-y divide-neutral-100">
|
||||
<ul className="divide-y divide-[#ececec]">
|
||||
{items.map((p) => (
|
||||
<li key={p.id} className="px-4 py-3 hover:bg-neutral-50 transition-colors">
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_auto_auto] items-center gap-2">
|
||||
<li key={p.id} className={`px-4 ${variant === "board" ? "py-2.5" : "py-3 md:py-3"} hover:bg-neutral-50 transition-colors`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-[20px_1fr_120px_120px_80px] items-center gap-2">
|
||||
{/* bullet/공지 아이콘 자리 */}
|
||||
<div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<a 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>}
|
||||
@@ -89,28 +141,109 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:w-28 text-xs text-neutral-600 text-center">{p.author?.nickname ?? "익명"}</div>
|
||||
<div className="md:w-24 text-[11px] text-neutral-600 text-center flex md:block gap-3 md:gap-0">
|
||||
<span>👍 {p.stat?.recommendCount ?? 0}</span>
|
||||
<span>👁️ {p.stat?.views ?? 0}</span>
|
||||
<span>💬 {p.stat?.commentsCount ?? 0}</span>
|
||||
<div className="md:w-[120px] text-xs text-neutral-600 text-center hidden md:flex items-center justify-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-200 text-[11px] text-neutral-700">{initials(p.author?.nickname)}</span>
|
||||
<span className="truncate max-w-[84px]">{p.author?.nickname ?? "익명"}</span>
|
||||
</div>
|
||||
<div className="md:w-24 text-xs text-neutral-500 text-right">{new Date(p.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
|
||||
<div className="md:w-[120px] text-[11px] text-[#8c8c8c] text-center flex items-center justify-center gap-3">
|
||||
<span className="inline-flex items-center gap-1"><ViewsIcon width={16} height={16} />{p.stat?.views ?? 0}</span>
|
||||
<span className="inline-flex items-center gap-1"><LikeIcon width={16} height={16} />{p.stat?.recommendCount ?? 0}</span>
|
||||
<span className="inline-flex items-center gap-1"><CommentIcon width={16} height={16} />{p.stat?.commentsCount ?? 0}</span>
|
||||
</div>
|
||||
<div className="md:w-[80px] text-xs text-neutral-500 text-right">{new Date(p.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* 페이지 더보기 */}
|
||||
<div className="mt-3 flex justify-center">
|
||||
<button
|
||||
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100 disabled:opacity-50"
|
||||
disabled={!canLoadMore || isLoading}
|
||||
onClick={() => setSize(size + 1)}
|
||||
>
|
||||
{isLoading ? "로딩 중..." : canLoadMore ? "더 보기" : "끝"}
|
||||
</button>
|
||||
</div>
|
||||
{/* 페이지네이션 */}
|
||||
{!isEmpty && (
|
||||
variant === "board" ? (
|
||||
<div className="mt-4 flex items-center justify-between px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Previous */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = Math.max(1, page - 1);
|
||||
setPage(next);
|
||||
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
||||
nextSp.set("page", String(next));
|
||||
router.push(`?${nextSp.toString()}`);
|
||||
}}
|
||||
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={() => {
|
||||
setPage(n);
|
||||
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
||||
nextSp.set("page", String(n));
|
||||
router.push(`?${nextSp.toString()}`);
|
||||
}}
|
||||
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={() => {
|
||||
const next = Math.min(totalPages, page + 1);
|
||||
setPage(next);
|
||||
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
||||
nextSp.set("page", String(next));
|
||||
router.push(`?${nextSp.toString()}`);
|
||||
}}
|
||||
disabled={page >= totalPages}
|
||||
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
{newPostHref && (
|
||||
<a 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>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<button
|
||||
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100 disabled:opacity-50"
|
||||
disabled={!canLoadMore || isLoading}
|
||||
onClick={() => setSize(size + 1)}
|
||||
>
|
||||
{isLoading ? "로딩 중..." : canLoadMore ? "더 보기" : "끝"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user