mosaicmap/app/blog/page.tsx
2025-08-18 18:47:30 +08:00

195 lines
6.2 KiB
TypeScript

"use client"
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { gql, useQuery } from "@apollo/client";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
const BLOG = gql`
query Blog {
blogs(filter: {
isActive: true
status: "published"
},
sort: {
field: "createdAt"
direction: "DESC"
}
) {
items {
id
title
excerpt
slug
createdAt
createdBy
categoryId
featuredImage
}
}
}
`
const INFO = gql`
query Info($tagId: String!, $categoryId: String!, $authorId: String!) {
blogTag(id: $tagId) {
name
slug
color
}
blogCategory(id: $categoryId) {
name
slug
}
userWithGroups(id: $authorId) {
user {
username
}
}
}
`
export default function Blog() {
const { data, loading, error } = useQuery(BLOG);
const items = data?.blogs?.items || [];
if (loading) {
return (
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto">
<div className="text-center">Loading...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto">
<div className="text-center text-red-500">Error: {error.message}</div>
</div>
</div>
);
}
return (
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto flex flex-col gap-14">
<div className="flex w-full flex-col sm:flex-row sm:justify-between sm:items-center gap-8">
<h4 className="text-3xl md:text-5xl tracking-tighter max-w-xl font-regular">
Latest articles
</h4>
</div>
{items.length === 0 ? (
<div className="text-center text-muted-foreground">No articles found</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{items.map((blog: any, index: number) => (
<BlogItem
key={blog.id}
blog={blog}
featured={index === 0}
/>
))}
</div>
)}
</div>
</div>
);
}
interface BlogItemProps {
blog: {
id: string;
title: string;
excerpt: string;
slug: string;
createdAt: string;
createdBy: string;
categoryId: string;
featuredImage?: string;
};
featured?: boolean;
}
function BlogItem({ blog, featured = false }: BlogItemProps) {
const timeAgo = formatDistanceToNow(new Date(blog.createdAt), { addSuffix: true });
// 使用 INFO 查询获取详细信息
const { data: infoData, loading: infoLoading } = useQuery(INFO, {
variables: {
tagId: blog.categoryId || "",
categoryId: blog.categoryId,
authorId: blog.createdBy
},
skip: !blog.categoryId || !blog.createdBy,
});
const category = infoData?.blogCategory;
const author = infoData?.userWithGroups?.user;
const tag = infoData?.blogTag;
return (
<Link href={`/blog/${blog.slug}`}>
<div className={`flex flex-col gap-4 hover:opacity-75 cursor-pointer transition-opacity ${featured ? 'md:col-span-2' : ''
}`}>
{/* Featured Image */}
<div className="bg-muted rounded-md aspect-video overflow-hidden">
{blog.featuredImage ? (
<img
src={blog.featuredImage}
alt={blog.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-muted flex items-center justify-center text-muted-foreground">
No image
</div>
)}
</div>
{/* Meta Information */}
<div className="flex flex-row gap-4 items-center">
<Badge
variant="secondary"
style={{
backgroundColor: tag?.color || undefined,
color: tag?.color ? '#ffffff' : undefined
}}
>
{category?.name || tag?.name || 'Article'}
</Badge>
<div className="flex flex-row gap-2 text-sm items-center">
<span className="text-muted-foreground">By</span>
<Avatar className="h-6 w-6">
<AvatarFallback>
{author?.username?.charAt(0)?.toUpperCase() || 'A'}
</AvatarFallback>
</Avatar>
<span>{infoLoading ? 'Loading...' : (author?.username || 'Anonymous')}</span>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{timeAgo}</span>
</div>
</div>
{/* Content */}
<div className={`flex flex-col ${featured ? 'gap-2' : 'gap-1'}`}>
<h3 className={`max-w-3xl tracking-tight ${featured ? 'text-4xl' : 'text-2xl'
}`}>
{blog.title}
</h3>
<p className="max-w-3xl text-muted-foreground text-base">
{blog.excerpt || 'No excerpt available...'}
</p>
</div>
</div>
</Link>
);
}