178 lines
6.5 KiB
TypeScript
178 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useMemo, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
|
|
type ApiCategory = {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
boards: { id: string; name: string; slug: string; type: string; requiresApproval: boolean }[];
|
|
};
|
|
|
|
type PostItem = {
|
|
id: string;
|
|
title: string;
|
|
createdAt: string;
|
|
boardId: string;
|
|
};
|
|
|
|
interface Props {
|
|
categoryName?: string;
|
|
categorySlug?: string;
|
|
}
|
|
|
|
export default function CategoryBoardBrowser({ categoryName, categorySlug }: Props) {
|
|
const router = useRouter();
|
|
const [categories, setCategories] = useState<ApiCategory[] | null>(null);
|
|
const [selectedBoardId, setSelectedBoardId] = useState<string | null>(null);
|
|
const [posts, setPosts] = useState<PostItem[] | null>(null);
|
|
const [isLoadingPosts, setIsLoadingPosts] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
(async () => {
|
|
const res = await fetch("/api/categories", { cache: "no-store" });
|
|
const data = await res.json();
|
|
if (!isMounted) return;
|
|
setCategories(data.categories ?? []);
|
|
})();
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, []);
|
|
|
|
const selectedCategory = useMemo(() => {
|
|
if (!categories) return null;
|
|
// 우선순위: 전달받은 카테고리명 -> 전달받은 슬러그 -> 기본(암실소문/main)
|
|
const byName = categoryName ? categories.find((c) => c.name === categoryName) : null;
|
|
if (byName) return byName;
|
|
const bySlug = categorySlug ? categories.find((c) => c.slug === categorySlug) : null;
|
|
if (bySlug) return bySlug;
|
|
return (
|
|
categories.find((c) => c.name === "암실소문") ||
|
|
categories.find((c) => c.slug === "main") ||
|
|
null
|
|
);
|
|
}, [categories, categoryName, categorySlug]);
|
|
|
|
const boardIdToName = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
if (selectedCategory) {
|
|
for (const b of selectedCategory.boards) map.set(b.id, b.name);
|
|
}
|
|
return map;
|
|
}, [selectedCategory]);
|
|
|
|
// 기본 보드 자동 선택: 선택된 카테고리의 첫 번째 보드
|
|
useEffect(() => {
|
|
if (!selectedBoardId && selectedCategory?.boards?.length) {
|
|
setSelectedBoardId(selectedCategory.boards[0].id);
|
|
}
|
|
}, [selectedCategory, selectedBoardId]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedBoardId) return;
|
|
let isMounted = true;
|
|
(async () => {
|
|
try {
|
|
setIsLoadingPosts(true);
|
|
const params = new URLSearchParams({ pageSize: String(10), boardId: selectedBoardId });
|
|
const res = await fetch(`/api/posts?${params.toString()}`, { cache: "no-store" });
|
|
const data = await res.json();
|
|
if (!isMounted) return;
|
|
setPosts(data.items ?? []);
|
|
} finally {
|
|
if (isMounted) setIsLoadingPosts(false);
|
|
}
|
|
})();
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, [selectedBoardId]);
|
|
|
|
return (
|
|
<div className="w-full h-full min-h-0 flex flex-col bg-white rounded-xl overflow-hidden">
|
|
{/* 상단: 한 줄(row)로 카테고리 + 화살표 + 보드 pill 버튼들 */}
|
|
<div className="px-3 py-2 border-b border-neutral-200">
|
|
<div className="flex items-center gap-2 overflow-x-auto flex-nowrap">
|
|
<button
|
|
type="button"
|
|
className="shrink-0 text-lg md:text-xl font-bold text-neutral-800 truncate"
|
|
onClick={() => {
|
|
const first = selectedCategory?.boards?.[0];
|
|
if (first?.id) router.push(`/boards/${first.id}`);
|
|
}}
|
|
title={(selectedCategory?.name ?? categoryName ?? "").toString()}
|
|
>
|
|
{selectedCategory?.name ?? categoryName ?? ""}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="카테고리 첫 게시판 이동"
|
|
className="shrink-0 w-6 h-6 rounded-full border border-neutral-300 text-neutral-500 hover:bg-neutral-50 flex items-center justify-center"
|
|
onClick={() => {
|
|
const first = selectedCategory?.boards?.[0];
|
|
if (first?.id) router.push(`/boards/${first.id}`);
|
|
}}
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
<path d="M7 5l5 5-5 5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</button>
|
|
{selectedCategory?.boards?.map((b) => (
|
|
<button
|
|
key={b.id}
|
|
className={`shrink-0 whitespace-nowrap text-xs px-3 py-1 rounded-full border transition-colors ${
|
|
selectedBoardId === b.id
|
|
? "bg-neutral-800 text-white border-neutral-800"
|
|
: "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
|
|
}`}
|
|
onClick={() => setSelectedBoardId(b.id)}
|
|
>
|
|
{b.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 2행: 게시글 리스트 */}
|
|
<div className="flex-1 min-h-0 overflow-y-auto p-3">
|
|
{!selectedBoardId && (
|
|
<div className="text-sm text-neutral-500">보드를 선택해 주세요.</div>
|
|
)}
|
|
{selectedBoardId && isLoadingPosts && (
|
|
<div className="text-sm text-neutral-500">불러오는 중…</div>
|
|
)}
|
|
{selectedBoardId && !isLoadingPosts && posts && posts.length === 0 && (
|
|
<div className="text-sm text-neutral-500">게시글이 없습니다.</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 gap-3">
|
|
{posts?.map((p) => {
|
|
const boardName = boardIdToName.get(p.boardId) ?? "";
|
|
const dateStr = new Date(p.createdAt).toLocaleDateString();
|
|
const imgSrc = `https://picsum.photos/seed/${p.id}/200/140`;
|
|
return (
|
|
<article key={p.id} className="w-full h-[140px] rounded-lg border border-neutral-200 overflow-hidden bg-white">
|
|
<div className="h-full w-full grid grid-cols-[200px_1fr]">
|
|
<div className="w-[200px] h-full bg-neutral-100">
|
|
<img src={imgSrc} alt="썸네일" className="w-full h-full object-cover" />
|
|
</div>
|
|
<div className="flex flex-col justify-center gap-1 px-3">
|
|
<div className="text-[11px] text-neutral-500">{boardName}</div>
|
|
<div className="text-sm font-semibold line-clamp-2">{p.title}</div>
|
|
<div className="text-xs text-neutral-500">{dateStr}</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|