261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
import { prisma } from "@/lib/prisma";
|
|
import { notFound } from "next/navigation";
|
|
import Link from "next/link";
|
|
import ForumTopicRow from "@/components/forum/ForumTopicRow";
|
|
import NewTopicDialog from "@/components/forum/NewTopicDialog";
|
|
import FollowButton from "@/components/forum/FollowButton";
|
|
import { getServerSession } from "next-auth";
|
|
import { authOptions } from "@/lib/auth";
|
|
import { ArrowLeft, MessageSquare } from "lucide-react";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
interface Props {
|
|
params: { category: string };
|
|
searchParams: { sort?: string; page?: string };
|
|
}
|
|
|
|
export default async function CategoryPage({ params, searchParams }: Props) {
|
|
const session = await getServerSession(authOptions);
|
|
const category = await prisma.forumCategory.findUnique({
|
|
where: { slug: params.category },
|
|
});
|
|
|
|
if (!category) notFound();
|
|
|
|
const childCategories = await prisma.forumCategory.findMany({
|
|
where: { parentId: category.id },
|
|
select: { id: true, name: true, slug: true, icon: true, topicCount: true },
|
|
orderBy: [{ sortOrder: "asc" }],
|
|
});
|
|
|
|
const sort = searchParams.sort || "latest";
|
|
const page = parseInt(searchParams.page || "1", 10);
|
|
const pageSize = 20;
|
|
|
|
const orderBy: any = {};
|
|
if (sort === "latest") orderBy.createdAt = "desc";
|
|
else if (sort === "hot") orderBy.viewCount = "desc";
|
|
else if (sort === "active") orderBy.lastReplyAt = "desc";
|
|
else if (sort === "likes") orderBy.likeCount = "desc";
|
|
else orderBy.createdAt = "desc";
|
|
|
|
const currentUserId = session?.user?.id
|
|
? parseInt(session.user.id)
|
|
: null;
|
|
|
|
const [topics, totalCount, follow] = await Promise.all([
|
|
prisma.forumTopic.findMany({
|
|
where: { categoryId: category.id },
|
|
include: {
|
|
category: { select: { name: true, slug: true, icon: true } },
|
|
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true, isBot: true } },
|
|
_count: { select: { posts: true } },
|
|
tags: { include: { tag: true } },
|
|
},
|
|
orderBy: [{ isPinned: "desc" }, orderBy],
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
}),
|
|
prisma.forumTopic.count({ where: { categoryId: category.id } }),
|
|
currentUserId
|
|
? prisma.forumCategoryFollow.findUnique({
|
|
where: {
|
|
categoryId_userId: {
|
|
categoryId: category.id,
|
|
userId: currentUserId,
|
|
},
|
|
},
|
|
})
|
|
: Promise.resolve(null),
|
|
]);
|
|
|
|
const sortTabs = [
|
|
{ key: "latest", label: "最新" },
|
|
{ key: "active", label: "活跃" },
|
|
{ key: "hot", label: "热门" },
|
|
{ key: "likes", label: "高赞" },
|
|
];
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-b from-slate-50/30 to-white">
|
|
<div className="max-w-6xl mx-auto px-4 py-6">
|
|
{/* Breadcrumb */}
|
|
<div className="mb-4 flex items-center gap-2 text-sm">
|
|
<Link
|
|
href="/community"
|
|
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<ArrowLeft className="h-3.5 w-3.5" />
|
|
返回社区
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Category header */}
|
|
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden mb-6">
|
|
<div className="px-5 py-5 sm:px-6 sm:py-6 flex items-start gap-4">
|
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-sky-50 to-indigo-50 border border-sky-200/40 text-2xl">
|
|
{category.icon || "📌"}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h1 className="text-xl font-bold text-foreground">
|
|
{category.name}
|
|
</h1>
|
|
<span className="text-[11px] text-muted-foreground/50 tabular-nums">
|
|
· {totalCount} 帖 · {category.followerCount} 关注
|
|
</span>
|
|
</div>
|
|
{category.description && (
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{category.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<FollowButton
|
|
categoryId={category.id}
|
|
initialFollowed={!!follow}
|
|
followerCount={category.followerCount}
|
|
/>
|
|
<NewTopicDialog
|
|
categories={[
|
|
{ id: category.id, name: category.name, slug: category.slug, icon: category.icon },
|
|
...childCategories,
|
|
]}
|
|
defaultCategorySlug={category.slug}
|
|
variant="primary"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_240px] gap-6">
|
|
<div className="min-w-0 space-y-4">
|
|
{/* Sort tabs */}
|
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
<div className="inline-flex items-center rounded-xl border border-border/50 bg-white p-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
|
{sortTabs.map((tab) => (
|
|
<Link
|
|
key={tab.key}
|
|
href={`/community/${category.slug}?sort=${tab.key}`}
|
|
className={`inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
sort === tab.key
|
|
? "bg-sky-500 text-white shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
<span className="text-[11px] text-muted-foreground/50">
|
|
共 {totalCount} 个话题
|
|
</span>
|
|
</div>
|
|
|
|
{/* Topics list */}
|
|
<section className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
|
|
<div className="divide-y divide-border/20">
|
|
{topics.length === 0 ? (
|
|
<div className="py-20 text-center">
|
|
<MessageSquare className="h-8 w-8 text-muted-foreground/30 mx-auto mb-2" />
|
|
<p className="text-sm text-muted-foreground/60">
|
|
暂无话题,来发第一帖吧 ✨
|
|
</p>
|
|
</div>
|
|
) : (
|
|
topics.map((topic) => (
|
|
<ForumTopicRow
|
|
key={topic.id}
|
|
topic={{
|
|
...topic,
|
|
replyCount: topic._count.posts,
|
|
tags: topic.tags?.map((tt) => tt.tag),
|
|
}}
|
|
showCategory={false}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Pagination */}
|
|
{totalCount > pageSize && (
|
|
<div className="flex justify-center items-center gap-1.5 pt-2">
|
|
{page > 1 && (
|
|
<Link
|
|
href={`/community/${category.slug}?sort=${sort}&page=${page - 1}`}
|
|
className="rounded-lg border border-border/40 bg-white px-3 py-1.5 text-xs text-muted-foreground hover:border-sky-200 hover:text-sky-700"
|
|
>
|
|
上一页
|
|
</Link>
|
|
)}
|
|
{Array.from(
|
|
{ length: Math.ceil(totalCount / pageSize) },
|
|
(_, i) => i + 1
|
|
)
|
|
.filter((p) => Math.abs(p - page) <= 2)
|
|
.map((p) => (
|
|
<Link
|
|
key={p}
|
|
href={`/community/${category.slug}?sort=${sort}&page=${p}`}
|
|
className={`min-w-[32px] inline-flex items-center justify-center rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
|
page === p
|
|
? "bg-sky-500 text-white shadow-sm"
|
|
: "border border-border/40 bg-white text-muted-foreground hover:border-sky-200 hover:text-sky-700"
|
|
}`}
|
|
>
|
|
{p}
|
|
</Link>
|
|
))}
|
|
{page < Math.ceil(totalCount / pageSize) && (
|
|
<Link
|
|
href={`/community/${category.slug}?sort=${sort}&page=${page + 1}`}
|
|
className="rounded-lg border border-border/40 bg-white px-3 py-1.5 text-xs text-muted-foreground hover:border-sky-200 hover:text-sky-700"
|
|
>
|
|
下一页
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right sidebar: sub-categories */}
|
|
{childCategories.length > 0 && (
|
|
<aside className="hidden lg:block">
|
|
<div className="sticky top-[68px] rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
|
|
<div className="px-4 py-3 border-b border-border/30">
|
|
<h3 className="text-sm font-semibold text-foreground">子板块</h3>
|
|
</div>
|
|
<div className="p-2">
|
|
<Link
|
|
href={`/community/${category.slug}`}
|
|
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium text-sky-700 bg-sky-50"
|
|
>
|
|
<span>{category.icon || "📌"}</span>
|
|
<span>全部</span>
|
|
</Link>
|
|
{childCategories.map((child) => (
|
|
<Link
|
|
key={child.id}
|
|
href={`/community/${child.slug}`}
|
|
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-slate-50 hover:text-foreground transition-colors"
|
|
>
|
|
<span>{child.icon || "📌"}</span>
|
|
<span className="truncate flex-1">{child.name}</span>
|
|
<span className="text-[10px] text-muted-foreground/40 tabular-nums">
|
|
{child.topicCount}
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|