️ feat: add post fetching by ID and improve blog home page

- Implemented `getPostById` API function to fetch a post by its ID.
- Refactored the main page to use a new `BlogHome` component for better organization.
- Added loading state and sorting functionality for posts on the blog home page.
- Integrated label fetching and display on the blog home page.
- Enhanced the blog card component with improved layout and statistics display.
- Updated the navbar to use dynamic configuration values.
- Added Docker support with a comprehensive build and push workflow.
- Created a custom hook `useStoredState` for managing local storage state.
- Added a new page for displaying individual posts with metadata generation.
- Removed unused components and files to streamline the codebase.
This commit is contained in:
2025-07-25 03:58:53 +08:00
parent abe1099711
commit 5fac42439a
24 changed files with 752 additions and 338 deletions

View File

@ -1,189 +1,8 @@
"use client";
import { BlogCardGrid } from "@/components/blog-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Search, TrendingUp, Clock, Heart, Eye } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import config from '../../config';
import type { Label } from "@/models/label";
import type { Post } from "@/models/post";
import { listPosts } from "@/api/post";
import { useEffect, useState } from "react";
import BlogHome from "@/components/blog-home";
export default function Home() {
const [labels, setLabels] = useState<Label[]>([]);
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
listPosts().then(data => {
setPosts(data.data);
console.log(posts);
}).catch(error => {
console.error("Failed to fetch posts:", error);
});
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
{/* Hero Section */}
<section className="relative py-10 lg:py-16 overflow-hidden">
{/* 背景装饰 */}
<div className="absolute inset-0 bg-grid-white/[0.02] bg-grid-16 pointer-events-none" />
{/* 容器 - 关键布局 */}
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center max-w-4xl mx-auto">
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold mb-6 bg-gradient-to-r from-slate-900 via-blue-900 to-slate-900 bg-clip-text text-transparent">
Snowykami's Blog
</h1>
<p className="text-xl md:text-2xl text-slate-600 mb-8 max-w-2xl mx-auto leading-relaxed">
{config.metadata.description}
</p>
{/* 搜索框 */}
<div className="relative max-w-md mx-auto mb-12">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5" />
<Input
placeholder="搜索文章..."
className="pl-10 pr-4 py-3 text-lg border-slate-200 focus:border-blue-500 rounded-full"
/>
</div>
{/* 热门标签 */}
<div className="flex flex-wrap justify-center gap-2 mb-8">
{['React', 'TypeScript', 'Next.js', 'Node.js', 'AI', ''].map((tag) => (
<Badge
key={tag}
variant="secondary"
className="px-4 py-2 hover:bg-blue-100 cursor-pointer transition-colors"
>
{tag}
</Badge>
))}
</div>
</div>
</div>
</section>
{/* 主内容区域 */}
<section className="py-16">
{/* 容器 - 关键布局 */}
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* 主要内容区域 */}
<div className="lg:col-span-3">
{/* 文章列表标题 */}
<div className="flex items-center justify-between mb-8">
<h2 className="text-3xl font-bold text-slate-900">最新文章</h2>
<div className="flex items-center gap-4">
<Button variant="outline" size="sm">
<Clock className="w-4 h-4 mr-2" />
最新
</Button>
<Button variant="outline" size="sm">
<TrendingUp className="w-4 h-4 mr-2" />
热门
</Button>
</div>
</div>
{/* 博客卡片网格 */}
<BlogCardGrid posts={posts} />
{/* 加载更多按钮 */}
<div className="text-center mt-12">
<Button size="lg" className="px-8">
加载更多文章
</Button>
</div>
</div>
{/* 侧边栏 */}
<div className="lg:col-span-1 space-y-6">
{/* 关于我 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Heart className="w-5 h-5 text-red-500" />
关于我
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center mb-4">
<div className="w-20 h-20 mx-auto mb-3 bg-gradient-to-br from-blue-400 to-purple-500 rounded-full flex items-center justify-center text-white text-2xl font-bold">
S
</div>
<h3 className="font-semibold text-lg">{config.owner.name}</h3>
<p className="text-sm text-slate-600">{config.owner.motto}</p>
</div>
<p className="text-sm text-slate-600 leading-relaxed">
{config.owner.description}
</p>
</CardContent>
</Card>
{/* 热门文章 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-orange-500" />
热门文章
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{posts.slice(0, 3).map((post, index) => (
<div key={post.id} className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-semibold">
{index + 1}
</span>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm line-clamp-2 mb-1">
{post.title}
</h4>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Eye className="w-3 h-3" />
{post.viewCount}
</span>
<span className="flex items-center gap-1">
<Heart className="w-3 h-3" />
{post.likeCount}
</span>
</div>
</div>
</div>
))}
</CardContent>
</Card>
{/* 标签云 */}
<Card>
<CardHeader>
<CardTitle>标签云</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{['React', 'TypeScript', 'Next.js', 'Node.js', 'JavaScript', 'CSS', 'HTML', 'Vue', 'Angular', 'Webpack'].map((tag) => (
<Badge
key={tag}
variant="outline"
className="text-xs hover:bg-blue-50 cursor-pointer"
>
{tag}
</Badge>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
</div>
);
}
export default function Page(){
return <BlogHome />
}

View File

@ -1,10 +1,9 @@
import { GalleryVerticalEnd } from "lucide-react"
import Image from "next/image"
import { LoginForm } from "@/components/login-form"
import config from "@/config"
import { Suspense } from "react"
export default function LoginPage() {
function LoginPageContent() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
@ -25,3 +24,28 @@ export default function LoginPage() {
</div>
)
}
export default function LoginPage() {
return (
<Suspense fallback={
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="animate-pulse">
<div className="flex items-center gap-3 self-center mb-6">
<div className="size-10 bg-gray-300 rounded-full"></div>
<div className="h-8 bg-gray-300 rounded w-32"></div>
</div>
<div className="bg-white rounded-lg p-6 space-y-4">
<div className="h-4 bg-gray-300 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 rounded w-1/2"></div>
<div className="h-10 bg-gray-300 rounded"></div>
<div className="h-10 bg-gray-300 rounded"></div>
</div>
</div>
</div>
</div>
}>
<LoginPageContent />
</Suspense>
)
}

View File

@ -0,0 +1,82 @@
import { getPostById } from "@/api/post";
import { notFound } from "next/navigation";
interface Props {
params: Promise<{
id: string;
}>
}
export default async function PostPage({ params }: Props) {
const { id } = await params;
try {
const post = await getPostById(id);
if (!post) {
notFound();
}
return (
<article className="max-w-4xl mx-auto px-4 py-8">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="text-gray-600 mb-4">
<time>{new Date(post.createdAt).toLocaleDateString()}</time>
<span className="mx-2">·</span>
<span>: {post.viewCount || 0}</span>
</div>
</header>
<div className="prose max-w-none">
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
{post.labels && post.labels.length > 0 && (
<footer className="mt-8 pt-8 border-t">
<div className="flex flex-wrap gap-2">
{post.labels.map((label) => (
<span key={label.id} className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
#{label.key}
</span>
))}
</div>
</footer>
)}
</article>
);
} catch (error) {
console.error("Failed to fetch post:", error);
notFound();
}
}
// 生成元数据
export async function generateMetadata({ params }: Props) {
const { id } = await params;
try {
const post = await getPostById(id);
if (!post) {
return {
title: '文章未找到',
};
}
return {
title: post.title,
description: post.content?.substring(0, 160),
openGraph: {
title: post.title,
description: post.content?.substring(0, 160),
type: 'article',
publishedTime: post.createdAt,
},
};
} catch (error) {
return {
title: '文章未找到',
};
}
}

View File

@ -0,0 +1,8 @@
export default function Page() {
return (
<div>
<h1>Page Title</h1>
<p>This is the User content.</p>
</div>
)
}