280 lines
13 KiB
TypeScript
280 lines
13 KiB
TypeScript
"use client";
|
|
import useSWRInfinite from "swr/infinite";
|
|
import useSWR from "swr";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import Link from "next/link";
|
|
import { 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;
|
|
title: string;
|
|
createdAt: string;
|
|
isPinned: boolean;
|
|
status: string;
|
|
stat?: { recommendCount: number; views: number; commentsCount: number } | null;
|
|
postTags?: { tag: { name: string; slug: string } }[];
|
|
author?: { nickname: string } | null;
|
|
};
|
|
|
|
type Resp = {
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
items: Item[];
|
|
sort: "recent" | "popular";
|
|
};
|
|
|
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
|
|
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 sp = useSearchParams();
|
|
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null);
|
|
|
|
const getKey = (index: number, prev: Resp | null) => {
|
|
if (prev && prev.items.length === 0) return null;
|
|
const page = index + 1;
|
|
const sp = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sort });
|
|
if (boardId) sp.set("boardId", boardId);
|
|
if (q) sp.set("q", q);
|
|
if (tag) sp.set("tag", tag);
|
|
if (author) sp.set("author", author);
|
|
if (start) sp.set("start", start);
|
|
if (end) sp.set("end", end);
|
|
return `/api/posts?${sp.toString()}`;
|
|
};
|
|
// 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;
|
|
|
|
// 잠깐 높이 고정: 페이지 변경으로 재로딩될 때 현재 높이를 min-height로 유지
|
|
const lockHeight = () => {
|
|
const el = listContainerRef.current;
|
|
if (!el) return;
|
|
const h = el.offsetHeight;
|
|
if (h > 0) setLockedMinHeight(h);
|
|
};
|
|
|
|
// 로딩이 끝나면 해제
|
|
useEffect(() => {
|
|
if (variant === "board" && !isLoadingSingle) {
|
|
setLockedMinHeight(null);
|
|
}
|
|
}, [variant, isLoadingSingle]);
|
|
|
|
const initials = (name?: string | null) => (name ? name.trim().slice(0, 1) : "익");
|
|
|
|
return (
|
|
<div className="w-full">
|
|
{/* 정렬 스위치 (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>
|
|
)}
|
|
|
|
{/* 리스트 테이블 헤더 (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>
|
|
)}
|
|
|
|
{/* 아이템들 */}
|
|
<div ref={listContainerRef} style={{ minHeight: lockedMinHeight ? `${lockedMinHeight}px` : undefined }}>
|
|
<ul className="divide-y divide-[#ececec]">
|
|
{items.map((p) => (
|
|
<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">
|
|
<Link href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900">
|
|
{p.isPinned && <span className="mr-2 inline-flex items-center rounded bg-orange-100 text-orange-700 px-1.5 py-0.5 text-[11px]">공지</span>}
|
|
{p.title}
|
|
</Link>
|
|
{!!p.postTags?.length && (
|
|
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
|
|
{p.postTags?.map((pt) => (
|
|
<Link key={pt.tag.slug} href={`/search?tag=${pt.tag.slug}`} className="hover:underline">#{pt.tag.name}</Link>
|
|
))}
|
|
</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="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-[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>
|
|
|
|
{/* 페이지네이션 */}
|
|
{!isEmpty && (
|
|
variant === "board" ? (
|
|
<div className="mt-4 flex items-center justify-between px-4">
|
|
<div className="flex items-center gap-2">
|
|
{/* Previous */}
|
|
<button
|
|
onClick={() => {
|
|
lockHeight();
|
|
const next = Math.max(1, page - 1);
|
|
setPage(next);
|
|
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
|
nextSp.set("page", String(next));
|
|
if (typeof window !== "undefined") {
|
|
window.history.replaceState(null, "", `?${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={() => {
|
|
lockHeight();
|
|
setPage(n);
|
|
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
|
nextSp.set("page", String(n));
|
|
if (typeof window !== "undefined") {
|
|
window.history.replaceState(null, "", `?${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={() => {
|
|
lockHeight();
|
|
const next = Math.min(totalPages, page + 1);
|
|
setPage(next);
|
|
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
|
nextSp.set("page", String(next));
|
|
if (typeof window !== "undefined") {
|
|
window.history.replaceState(null, "", `?${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 && (
|
|
<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">
|
|
<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>
|
|
);
|
|
}
|
|
|
|
|