feat: Implement blog home and post components with sidebar and pagination

- Added BlogHome component for displaying posts with sorting options (latest/popular).
- Integrated Sidebar with About, Hot Posts, Tags, and Misskey Iframe components.
- Created BlogPost component to render individual posts with metadata and content.
- Developed GravatarAvatar component for user avatars.
- Implemented Markdown rendering with syntax highlighting and custom code blocks.
- Added pagination component for navigating through posts.
- Enhanced login form with OpenID Connect options and email/password authentication.
- Utility functions for generating post URLs and calculating reading time.
This commit is contained in:
2025-07-30 00:18:32 +08:00
parent 1b29d50ba4
commit 92c2a58e80
23 changed files with 196 additions and 63 deletions

View File

@ -2,9 +2,9 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { Navbar } from '@/components/navbar' import { Navbar } from '@/components/layout/navbar'
import { BackgroundProvider } from '@/contexts/background-context' import { BackgroundProvider } from '@/contexts/background-context'
import Footer from '@/components/footer' import Footer from '@/components/layout/footer'
export default function RootLayout({ export default function RootLayout({
children, children,

View File

@ -1,6 +1,6 @@
import { getPostById } from '@/api/post' import { getPostById } from '@/api/post'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import BlogPost from '@/components/blog-post' import BlogPost from '@/components/blog-post/blog-post'
interface Props { interface Props {
params: Promise<{ id: string }> params: Promise<{ id: string }>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import BlogHome from "@/components/blog-home"; import BlogHome from "@/components/blog-home/blog-home";
export default function Page(){ export default function Page(){

View File

@ -122,7 +122,7 @@
} }
html, body { html, body {
transition: background-color 0.3s, color 0.3s !important; transition: background-color 0.2s !important;
} }
.sonner-toast { .sonner-toast {

View File

@ -1,6 +1,6 @@
import Image from 'next/image' import Image from 'next/image'
import { Suspense } from 'react' import { Suspense } from 'react'
import { LoginForm } from '@/components/login-form' import { LoginForm } from '@/components/login/login-form'
import config from '@/config' import config from '@/config'
function LoginPageContent() { function LoginPageContent() {

View File

@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import config from '@/config' import config from '@/config'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getPostHref } from '@/utils/common/post'
interface BlogCardProps { interface BlogCardProps {
post: Post post: Post
@ -13,7 +14,6 @@ interface BlogCardProps {
} }
export function BlogCard({ post, className }: BlogCardProps) { export function BlogCard({ post, className }: BlogCardProps) {
// 格式化日期
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString) const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', { return date.toLocaleDateString('zh-CN', {
@ -22,9 +22,6 @@ export function BlogCard({ post, className }: BlogCardProps) {
day: '2-digit', day: '2-digit',
}) })
} }
// TODO: 阅读时间估计
return ( return (
<Card className={cn( <Card className={cn(
'group overflow-hidden hover:shadow-xl transition-all duration-300 h-full flex flex-col cursor-pointer pt-0 pb-4', 'group overflow-hidden hover:shadow-xl transition-all duration-300 h-full flex flex-col cursor-pointer pt-0 pb-4',
@ -220,7 +217,7 @@ export function BlogCardGrid({
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredPosts.map(post => ( {filteredPosts.map(post => (
<Link key={post.id} href={`/p/${post.id}`} className="block h-full"> <Link key={post.id} href={getPostHref(post)} className="block h-full">
<BlogCard post={post} /> <BlogCard post={post} />
</Link> </Link>
))} ))}

View File

@ -1,9 +1,9 @@
"use client"; "use client";
import { BlogCardGrid } from "@/components/blog-card"; import { BlogCardGrid } from "@/components/blog-home/blog-home-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TrendingUp, Clock, } from "lucide-react"; import { TrendingUp, Clock, } from "lucide-react";
import Sidebar, { SidebarAbout, SidebarHotPosts, SidebarMisskeyIframe, SidebarTags } from "./blog-home-sidebar"; import Sidebar, { SidebarAbout, SidebarHotPosts, SidebarMisskeyIframe, SidebarTags } from "../blog/blog-sidebar-card";
import config from '@/config'; import config from '@/config';
import type { Label } from "@/models/label"; import type { Label } from "@/models/label";
import type { Post } from "@/models/post"; import type { Post } from "@/models/post";
@ -22,7 +22,6 @@ export default function BlogHome() {
const [posts, setPosts] = useState<Post[]>([]); const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest'); const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
// 根据排序类型和防抖后的搜索关键词获取文章
useEffect(() => { useEffect(() => {
if (!sortTypeLoaded) return; if (!sortTypeLoaded) return;
const fetchPosts = async () => { const fetchPosts = async () => {

View File

@ -1,9 +1,10 @@
import { Suspense } from "react"; import { Suspense } from "react";
import type { Post } from "@/models/post"; import type { Post } from "@/models/post";
import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, SquarePen } from "lucide-react"; import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, SquarePen } from "lucide-react";
import ScrollToTop from "@/components/scroll-to-top.client"; import { RenderMarkdown } from "@/components/common/markdown";
import { RenderMarkdown } from "@/components/markdown";
import { isMobileByUA } from "@/utils/server/device"; import { isMobileByUA } from "@/utils/server/device";
import { calculateReadingTime } from "@/utils/common/post";
import Link from "next/link";
function PostMeta({ post }: { post: Post }) { function PostMeta({ post }: { post: Post }) {
return ( return (
@ -21,7 +22,7 @@ function PostMeta({ post }: { post: Post }) {
{/* 阅读时间 */} {/* 阅读时间 */}
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
{Math.ceil(post.content.length / 400 || 1)} {calculateReadingTime(post.content)}
</span> </span>
{/* 发布时间 */} {/* 发布时间 */}
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
@ -74,6 +75,9 @@ async function PostHeader({ post }: { post: Post }) {
{post.isOriginal && ( {post.isOriginal && (
<span className="bg-green-100 text-green-600 text-xs px-2 py-1 rounded"> <span className="bg-green-100 text-green-600 text-xs px-2 py-1 rounded">
<Link href="./aa" className="text-green-600 hover:underline">
</Link>
</span> </span>
)} )}
{(post.labels || []).map(label => ( {(post.labels || []).map(label => (

View File

@ -1,12 +1,14 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Heart, TrendingUp, Eye } from "lucide-react"; import { Heart, TrendingUp, Eye } from "lucide-react";
import GravatarAvatar from "./gravatar"; import GravatarAvatar from "@/components/common/gravatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import type { Label } from "@/models/label"; import type { Label } from "@/models/label";
import type { Post } from "@/models/post"; import type { Post } from "@/models/post";
import type configType from '@/config'; import type configType from '@/config';
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link";
import { getPostHref } from "@/utils/common/post";
// 侧边栏父组件,接收卡片组件列表 // 侧边栏父组件,接收卡片组件列表
export default function Sidebar({ cards }: { cards: React.ReactNode[] }) { export default function Sidebar({ cards }: { cards: React.ReactNode[] }) {
@ -57,12 +59,13 @@ export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType:
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{posts.slice(0, 3).map((post, index) => ( {posts.slice(0, 3).map((post, index) => (
<div key={post.id} className="flex items-start gap-3"> <Link href={getPostHref(post)} key={post.id} className="block hover:bg-slate-50 dark:hover:bg-slate-800 rounded-lg p-1 transition-colors">
<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"> <div className="flex items-start gap-3">
{index + 1} <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">
</span> {index + 1}
<div className="flex-1 min-w-0"> </span>
<h4 className="font-medium text-sm line-clamp-2 mb-1"> <div className="flex-1 min-w-0">
<h4 className="font-medium text-sm line-clamp-2 mb-1">
{post.title} {post.title}
</h4> </h4>
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex items-center gap-2 text-xs text-slate-500">
@ -77,7 +80,9 @@ export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType:
</div> </div>
</div> </div>
</div> </div>
))} </Link>
))}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -3,7 +3,7 @@ import { MDXRemote, MDXRemoteProps } from "next-mdx-remote-client/rsc";
import { Suspense } from "react"; import { Suspense } from "react";
import rehypeHighlight from "rehype-highlight"; import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github.css"; // 你可以换成喜欢的主题 import "highlight.js/styles/github.css"; // 你可以换成喜欢的主题
import CodeBlock from "@/components/markdown-codeblock"; import CodeBlock from "@/components/common/markdown-codeblock";
export const markdownComponents = { export const markdownComponents = {
h1: (props: React.ComponentPropsWithoutRef<"h1">) => ( h1: (props: React.ComponentPropsWithoutRef<"h1">) => (

View File

@ -12,13 +12,13 @@ import {
NavigationMenuTrigger, NavigationMenuTrigger,
navigationMenuTriggerStyle, navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu" } from "@/components/ui/navigation-menu"
import GravatarAvatar from "@/components/gravatar" import GravatarAvatar from "@/components/common/gravatar"
import { useDevice } from "@/contexts/device-context" import { useDevice } from "@/contexts/device-context"
import config from "@/config" import config from "@/config"
import { useState } from "react" import { useState } from "react"
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Menu } from "lucide-react" import { Menu } from "lucide-react"
import { Switch } from "./ui/switch" import { Switch } from "../ui/switch"
const navbarMenuComponents = [ const navbarMenuComponents = [
{ {
@ -72,13 +72,13 @@ function NavMenuCenter() {
{item.href ? ( {item.href ? (
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}> <NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href={item.href} className="flex items-center gap-1 font-extrabold"> <Link href={item.href} className="flex items-center gap-1 font-extrabold">
{item.title} <span>{item.title}</span>
</Link> </Link>
</NavigationMenuLink> </NavigationMenuLink>
) : item.children ? ( ) : item.children ? (
<> <>
<NavigationMenuTrigger className="flex items-center gap-1 font-extrabold"> <NavigationMenuTrigger className="flex items-center gap-1 font-extrabold">
{item.title} <span>{item.title}</span>
</NavigationMenuTrigger> </NavigationMenuTrigger>
<NavigationMenuContent> <NavigationMenuContent>
<ul className="grid gap-2 p-0 min-w-[200px] max-w-[600px] grid-cols-[repeat(auto-fit,minmax(120px,1fr))]"> <ul className="grid gap-2 p-0 min-w-[200px] max-w-[600px] grid-cols-[repeat(auto-fit,minmax(120px,1fr))]">

View File

@ -1,11 +0,0 @@
'use client'
import { usePathname } from "next/navigation";
import { useEffect } from "react";
export default function ScrollToTop() {
const pathname = usePathname();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}

View File

@ -1,39 +1,38 @@
import type { VariantProps } from 'class-variance-authority' import * as React from "react"
import { Slot } from '@radix-ui/react-slot' import { Slot } from "@radix-ui/react-slot"
import { cva } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react'
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {
default: default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: 'text-primary underline-offset-4 hover:underline', link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: 'size-9', icon: "size-9",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}, }
) )
function Button({ function Button({
@ -42,11 +41,11 @@ function Button({
size, size,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<'button'> }: React.ComponentProps<"button"> &
& VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean
}) { }) {
const Comp = asChild ? Slot : 'button' const Comp = asChild ? Slot : "button"
return ( return (
<Comp <Comp

View File

@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@ -14,6 +14,7 @@ const config = {
}, },
bodyWidth: "80vw", bodyWidth: "80vw",
bodyWidthMobile: "100vw", bodyWidthMobile: "100vw",
postsPerPage: 12,
footer: { footer: {
text: "Liteyuki ICP备 1145141919810", text: "Liteyuki ICP备 1145141919810",
links: [] links: []

View File

@ -0,0 +1,12 @@
import type {Post} from "@/models/post";
export function getPostHref(post: Post) {
return `/p/${post.id}`;
}
// 阅读分钟数
export function calculateReadingTime(content: string): number {
const words = content.length;
const readingTime = Math.ceil(words / 270);
return readingTime;
}