디자인디테일

This commit is contained in:
koreacomp5
2025-11-01 23:16:22 +09:00
parent f84111b9cc
commit 27cf98eef2
20 changed files with 735 additions and 384 deletions

View File

@@ -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>
);
}