Files
msgapp/src/app/components/PostList.tsx

280 lines
13 KiB
TypeScript
Raw Normal View History

"use client";
import useSWRInfinite from "swr/infinite";
2025-11-01 23:16:22 +09:00
import useSWR from "swr";
2025-11-02 04:59:04 +09:00
import { useEffect, useMemo, useRef, useState } from "react";
2025-11-02 02:46:20 +09:00
import Link from "next/link";
2025-11-02 04:59:04 +09:00
import { useSearchParams } from "next/navigation";
2025-11-01 23:16:22 +09:00
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 } }[];
2025-10-24 21:24:51 +09:00
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());
2025-11-01 23:16:22 +09:00
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;
2025-11-01 23:16:22 +09:00
const sp = useSearchParams();
2025-11-02 04:59:04 +09:00
const listContainerRef = useRef<HTMLDivElement | null>(null);
const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null);
2025-11-01 23:16:22 +09:00
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()}`;
};
2025-11-01 23:16:22 +09:00
// 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;
2025-11-01 23:16:22 +09:00
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;
2025-11-02 04:59:04 +09:00
// 잠깐 높이 고정: 페이지 변경으로 재로딩될 때 현재 높이를 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]);
2025-11-01 23:16:22 +09:00
const initials = (name?: string | null) => (name ? name.trim().slice(0, 1) : "익");
return (
2025-10-24 21:24:51 +09:00
<div className="w-full">
2025-11-01 23:16:22 +09:00
{/* 정렬 스위치 (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>
)}
2025-10-24 21:24:51 +09:00
{/* 아이템들 */}
2025-11-02 04:59:04 +09:00
<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">
2025-11-01 23:16:22 +09:00
{/* bullet/공지 아이콘 자리 */}
<div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div>
2025-10-24 21:24:51 +09:00
<div className="min-w-0">
2025-11-02 02:46:20 +09:00
<Link href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900">
2025-10-24 21:24:51 +09:00
{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}
2025-11-02 02:46:20 +09:00
</Link>
2025-10-24 21:24:51 +09:00
{!!p.postTags?.length && (
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
{p.postTags?.map((pt) => (
2025-11-02 02:46:20 +09:00
<Link key={pt.tag.slug} href={`/search?tag=${pt.tag.slug}`} className="hover:underline">#{pt.tag.name}</Link>
2025-10-24 21:24:51 +09:00
))}
</div>
)}
</div>
2025-11-01 23:16:22 +09:00
<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>
2025-10-24 21:24:51 +09:00
</div>
2025-11-01 23:16:22 +09:00
<div className="md:w-[80px] text-xs text-neutral-500 text-right">{new Date(p.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
2025-10-24 21:24:51 +09:00
</div>
2025-11-02 04:59:04 +09:00
</li>
))}
</ul>
</div>
2025-10-24 21:24:51 +09:00
2025-11-01 23:16:22 +09:00
{/* 페이지네이션 */}
{!isEmpty && (
variant === "board" ? (
<div className="mt-4 flex items-center justify-between px-4">
<div className="flex items-center gap-2">
{/* Previous */}
<button
onClick={() => {
2025-11-02 04:59:04 +09:00
lockHeight();
2025-11-01 23:16:22 +09:00
const next = Math.max(1, page - 1);
setPage(next);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
nextSp.set("page", String(next));
2025-11-02 04:59:04 +09:00
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `?${nextSp.toString()}`);
}
2025-11-01 23:16:22 +09:00
}}
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={() => {
2025-11-02 04:59:04 +09:00
lockHeight();
2025-11-01 23:16:22 +09:00
setPage(n);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
nextSp.set("page", String(n));
2025-11-02 04:59:04 +09:00
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `?${nextSp.toString()}`);
}
2025-11-01 23:16:22 +09:00
}}
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={() => {
2025-11-02 04:59:04 +09:00
lockHeight();
2025-11-01 23:16:22 +09:00
const next = Math.min(totalPages, page + 1);
setPage(next);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
nextSp.set("page", String(next));
2025-11-02 04:59:04 +09:00
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `?${nextSp.toString()}`);
}
2025-11-01 23:16:22 +09:00
}}
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 && (
2025-11-02 02:46:20 +09:00
<Link href={newPostHref} className="shrink-0">
2025-11-01 23:16:22 +09:00
<button className="h-9 px-4 rounded-[10px] bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95"></button>
2025-11-02 02:46:20 +09:00
</Link>
2025-11-01 23:16:22 +09:00
)}
</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>
);
}