mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-05 16:56:22 +00:00
✨ 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:
@ -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,
|
||||||
|
@ -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 }>
|
||||||
|
@ -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(){
|
||||||
|
@ -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 {
|
||||||
|
@ -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() {
|
||||||
|
@ -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>
|
||||||
))}
|
))}
|
@ -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 () => {
|
@ -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 => (
|
@ -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,7 +59,8 @@ 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">
|
||||||
|
<div 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">
|
<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}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
@ -77,6 +80,8 @@ export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
@ -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">) => (
|
@ -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))]">
|
@ -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;
|
|
||||||
}
|
|
@ -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
|
||||||
|
127
web/src/components/ui/pagination.tsx
Normal file
127
web/src/components/ui/pagination.tsx
Normal 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,
|
||||||
|
}
|
@ -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: []
|
||||||
|
12
web/src/utils/common/post.ts
Normal file
12
web/src/utils/common/post.ts
Normal 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;
|
||||||
|
}
|
Reference in New Issue
Block a user