mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-05 16:56:22 +00:00
⚡️ feat: Refactor API client to support server-side and client-side configurations
fix: Update post fetching logic to use dynamic ID instead of hardcoded value feat: Enhance layout with animated transitions using framer-motion refactor: Remove old post and user page implementations, introduce new structure feat: Implement sidebar components for blog home with dynamic content feat: Create blog post component with wave header and metadata display feat: Add responsive sidebar menu for navigation on mobile devices chore: Introduce reusable sheet component for modal-like functionality
This commit is contained in:
@ -1,10 +1,14 @@
|
||||
import axios from "axios";
|
||||
import { camelToSnakeObj, snakeToCamelObj } from "field-conv";
|
||||
|
||||
export const BACKEND_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://neo-blog-backend:8888";
|
||||
|
||||
const isServer = typeof window === "undefined";
|
||||
|
||||
const API_SUFFIX = "/api/v1";
|
||||
|
||||
const axiosClient = axios.create({
|
||||
baseURL: API_SUFFIX,
|
||||
baseURL: isServer ? BACKEND_URL + API_SUFFIX : API_SUFFIX,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
|
@ -11,8 +11,9 @@ interface ListPostsParams {
|
||||
}
|
||||
|
||||
export async function getPostById(id: string): Promise<Post | null> {
|
||||
console.log("Fetching post by ID:", id);
|
||||
try {
|
||||
const res = await axiosClient.get<BaseResponse<Post>>(`/post/p/${id}`);
|
||||
const res = await axiosClient.get<BaseResponse<Post>>(`/post/p/19`);
|
||||
return res.data.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching post by ID:", error);
|
||||
|
@ -1,18 +1,35 @@
|
||||
import { Navbar } from "@/components/navbar";
|
||||
"use client";
|
||||
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<>
|
||||
<header className="flex justify-center">
|
||||
<header className="fixed top-0 left-0 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800">
|
||||
<Navbar />
|
||||
</header>
|
||||
<main>
|
||||
{children}
|
||||
</main>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.main
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 16 }}
|
||||
transition={{
|
||||
type: "tween",
|
||||
ease: "easeOut",
|
||||
duration: 0.18
|
||||
}}
|
||||
className="pt-16"
|
||||
>
|
||||
{children}
|
||||
</motion.main>
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
13
web/src/app/(main)/p/[id]/page.tsx
Normal file
13
web/src/app/(main)/p/[id]/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { getPostById } from "@/api/post";
|
||||
import BlogPost from "@/components/blog-post";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function PostPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const post = await getPostById(id);
|
||||
if (!post) return <div>文章不存在</div>;
|
||||
return <BlogPost post={post} />;
|
||||
}
|
11
web/src/app/console/layout.tsx
Normal file
11
web/src/app/console/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
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: '文章未找到',
|
||||
};
|
||||
}
|
||||
}
|
177
web/src/components/blog-home-sidebar.tsx
Normal file
177
web/src/components/blog-home-sidebar.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Heart, TrendingUp, Eye } from "lucide-react";
|
||||
import GravatarAvatar from "./gravatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Label } from "@/models/label";
|
||||
import type { Post } from "@/models/post";
|
||||
import type configType from '@/config';
|
||||
import { t } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// 侧边栏父组件,接收卡片组件列表
|
||||
export default function Sidebar({ cards }: { cards: React.ReactNode[] }) {
|
||||
return (
|
||||
<div className="lg:col-span-1 space-y-6 self-start">
|
||||
{cards.map((card, idx) => (
|
||||
<div key={idx}>{card}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 关于我卡片
|
||||
export function SidebarAbout({ config }: { config: typeof configType }) {
|
||||
return (
|
||||
<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 bg-gradient-to-br from-blue-400 to-purple-500 rounded-full flex items-center justify-center text-white text-2xl font-bold overflow-hidden">
|
||||
<GravatarAvatar email={config.owner.gravatarEmail} className="w-full h-full object-cover" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// 热门文章卡片
|
||||
export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-orange-500" />
|
||||
{sortType === 'latest' ? '热门文章' : '最新文章'}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// 标签云卡片
|
||||
export function SidebarTags({ labels }: { labels: Label[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>标签云</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{labels.map((label) => (
|
||||
<Badge
|
||||
key={label.id}
|
||||
variant="outline"
|
||||
className="text-xs hover:bg-blue-50 cursor-pointer"
|
||||
>
|
||||
{label.key}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function SidebarIframe(props?: { src?: string; scriptSrc?: string; title?: string; height?: string }) {
|
||||
const {
|
||||
src = "",
|
||||
scriptSrc = "",
|
||||
title = "External Content",
|
||||
height = "400px",
|
||||
} = props || {};
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t(title)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<iframe
|
||||
src={src}
|
||||
className="w-full border-none"
|
||||
style={{ height }}
|
||||
height={height}
|
||||
title={title}
|
||||
/>
|
||||
{scriptSrc && (
|
||||
<script
|
||||
src={scriptSrc}
|
||||
async
|
||||
defer
|
||||
className="w-full"
|
||||
></script>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 只在客户端渲染 iframe,避免 hydration 报错
|
||||
export function SidebarMisskeyIframe() {
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => {
|
||||
setShow(true);
|
||||
}, []);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Misskey</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{show && (
|
||||
<>
|
||||
<iframe
|
||||
src="https://lab.liteyuki.org/embed/user-timeline/a2utaz241qx60001?maxHeight=700&border=false"
|
||||
data-misskey-embed-id="v1_aali1lvxt0"
|
||||
loading="lazy"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
style={{
|
||||
border: "none",
|
||||
width: "100%",
|
||||
maxWidth: "500px",
|
||||
height: "300px",
|
||||
colorScheme: "light dark"
|
||||
}}
|
||||
></iframe>
|
||||
<script defer src="https://lab.liteyuki.org/embed.js"></script>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -2,10 +2,8 @@
|
||||
|
||||
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 { TrendingUp, Clock, } from "lucide-react";
|
||||
import Sidebar, { SidebarAbout, SidebarHotPosts, SidebarMisskeyIframe, SidebarTags } from "./blog-home-sidebar";
|
||||
import config from '@/config';
|
||||
import type { Label } from "@/models/label";
|
||||
import type { Post } from "@/models/post";
|
||||
@ -14,7 +12,6 @@ import { listPosts } from "@/api/post";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useStoredState } from '@/hooks/use-storage-state';
|
||||
import { listLabels } from "@/api/label";
|
||||
import GravatarAvatar from "./gravatar";
|
||||
import { POST_SORT_TYPE } from "@/localstore";
|
||||
|
||||
// 定义排序类型
|
||||
@ -25,11 +22,11 @@ export default function BlogHome() {
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
|
||||
// 根据排序类型获取文章
|
||||
// 根据排序类型和防抖后的搜索关键词获取文章
|
||||
useEffect(() => {
|
||||
if (!sortTypeLoaded) return; // 等待从 localStorage 加载完成
|
||||
|
||||
if (!sortTypeLoaded) return;
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@ -48,14 +45,16 @@ export default function BlogHome() {
|
||||
orderedBy = 'updated_at';
|
||||
reverse = false;
|
||||
}
|
||||
// 处理关键词,空格分割转逗号
|
||||
const keywords = debouncedSearch.trim() ? debouncedSearch.trim().split(/\s+/).join(",") : undefined;
|
||||
const data = await listPosts({
|
||||
page: 1,
|
||||
size: 10,
|
||||
orderedBy,
|
||||
reverse
|
||||
reverse,
|
||||
keywords
|
||||
});
|
||||
setPosts(data.data);
|
||||
console.log(`${sortType} posts:`, data.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch posts:", error);
|
||||
} finally {
|
||||
@ -69,14 +68,11 @@ export default function BlogHome() {
|
||||
useEffect(() => {
|
||||
listLabels().then(data => {
|
||||
setLabels(data.data || []);
|
||||
console.log("Labels:", data.data);
|
||||
}).catch(error => {
|
||||
console.error("Failed to fetch labels:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
//
|
||||
|
||||
// 处理排序切换
|
||||
const handleSortChange = (type: SortType) => {
|
||||
if (sortType !== type) {
|
||||
@ -85,54 +81,14 @@ export default function BlogHome() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 dark:bg-gradient-to-br dark:from-slate-900 dark:via-blue-900 dark:to-indigo-900">
|
||||
{/* 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 dark:from-slate-100 dark:via-blue-100 dark:to-slate-100">
|
||||
{config.metadata.name}
|
||||
</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-0">
|
||||
<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-0 text-lg border-slate-200 focus:border-blue-500 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 热门标签 */}
|
||||
{/* <div className="flex flex-wrap justify-center gap-2 mb-8">
|
||||
{labels.map(label => (
|
||||
<Badge
|
||||
key={label.id}
|
||||
variant="outline"
|
||||
className="text-xs hover:bg-blue-50 cursor-pointer"
|
||||
>
|
||||
{label.key}
|
||||
</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="container mx-auto px-4 sm:px-6 lg:px-10 max-w-7xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
{/* 主要内容区域 */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="lg:col-span-3 self-start">
|
||||
{/* 文章列表标题 */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||||
@ -193,88 +149,17 @@ export default function BlogHome() {
|
||||
</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 bg-gradient-to-br from-blue-400 to-purple-500 rounded-full flex items-center justify-center text-white text-2xl font-bold overflow-hidden">
|
||||
<GravatarAvatar email={config.owner.gravatarEmail} className="w-full h-full object-cover" />
|
||||
</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>
|
||||
|
||||
{/* 热门文章 */}
|
||||
{posts.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-orange-500" />
|
||||
{sortType === 'latest' ? '热门文章' : '最新文章'}
|
||||
</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">
|
||||
{labels.map((label) => (
|
||||
<Badge
|
||||
key={label.id}
|
||||
variant="outline"
|
||||
className="text-xs hover:bg-blue-50 cursor-pointer"
|
||||
>
|
||||
{label.key}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Sidebar
|
||||
cards={[
|
||||
<SidebarAbout key="about" config={config} />,
|
||||
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortType} /> : null,
|
||||
<SidebarTags key="tags" labels={labels} />,
|
||||
<SidebarMisskeyIframe key="misskey" />,
|
||||
].filter(Boolean)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
108
web/src/components/blog-post.tsx
Normal file
108
web/src/components/blog-post.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { Post } from "@/models/post";
|
||||
|
||||
function WaveHeader({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="relative h-64 flex flex-col items-center justify-center">
|
||||
{/* 波浪SVG,半透明悬浮 */}
|
||||
<div className="absolute inset-0 w-full h-full pointer-events-none opacity-70 z-0">
|
||||
<svg className="w-full h-full" viewBox="0 0 1440 320" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="wave-gradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#4f8cff" />
|
||||
<stop offset="100%" stopColor="#7b61ff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#wave-gradient)"
|
||||
fillOpacity="1"
|
||||
d="
|
||||
M0,160
|
||||
C360,240 1080,80 1440,160
|
||||
L1440,320
|
||||
L0,320
|
||||
Z
|
||||
"
|
||||
>
|
||||
<animate
|
||||
attributeName="d"
|
||||
dur="8s"
|
||||
repeatCount="indefinite"
|
||||
values="
|
||||
M0,160 C360,240 1080,80 1440,160 L1440,320 L0,320 Z;
|
||||
M0,120 C400,200 1040,120 1440,200 L1440,320 L0,320 Z;
|
||||
M0,160 C360,240 1080,80 1440,160 L1440,320 L0,320 Z
|
||||
"
|
||||
/>
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
{/* 标题 */}
|
||||
<h1 className="relative z-10 text-white text-4xl md:text-5xl font-bold drop-shadow-lg mt-16 text-center">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlogMeta({ post }: { post: Post }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<div className="flex gap-2 mb-2">
|
||||
{post.labels?.map(label => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="bg-white/30 px-3 py-1 rounded-full text-sm font-medium backdrop-blur text-white"
|
||||
>
|
||||
{label.key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-base text-white/90">
|
||||
<span>发表于 {new Date(post.createdAt).toLocaleDateString()}</span>
|
||||
<span>👁️ {post.viewCount}</span>
|
||||
<span>💬 {post.commentCount}</span>
|
||||
<span>🔥 {post.heat}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlogContent({ post }: { post: Post }) {
|
||||
return (
|
||||
<main className="relative z-10 max-w-3xl mx-auto bg-white rounded-xl shadow-lg p-8 -mt-32">
|
||||
{post.cover && (
|
||||
<img
|
||||
src={post.cover}
|
||||
alt="cover"
|
||||
className="w-full h-64 object-cover rounded-lg mb-8"
|
||||
/>
|
||||
)}
|
||||
<article
|
||||
className="prose prose-lg max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function BlogPost({ post }: { post: Post }) {
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 relative">
|
||||
<WaveHeader title={post.title} />
|
||||
<div className="relative z-10 -mt-40">
|
||||
<BlogMeta post={post} />
|
||||
<BlogContent post={post} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlogPost;
|
@ -14,8 +14,10 @@ import {
|
||||
} from "@/components/ui/navigation-menu"
|
||||
import GravatarAvatar from "@/components/gravatar"
|
||||
import { useDevice } from "@/contexts/device-context"
|
||||
import { metadata } from '../app/layout';
|
||||
import config from "@/config"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { Menu } from "lucide-react"
|
||||
|
||||
const navbarMenuComponents = [
|
||||
{
|
||||
@ -43,24 +45,22 @@ const navbarMenuComponents = [
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<nav className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-12 px-4 w-full">
|
||||
<nav className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-16 px-4 w-full">
|
||||
<div className="flex items-center justify-start">
|
||||
{/* 左侧内容 */}
|
||||
<span className="font-bold truncate">{config.metadata.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
{/* 中间内容 - 完全居中 */}
|
||||
<NavMenu />
|
||||
<NavMenuCenter />
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
{/* 右侧内容 */}
|
||||
<GravatarAvatar email="snowykami@outlook.com"/>
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<GravatarAvatar email="snowykami@outlook.com" />
|
||||
<SidebarMenuClientOnly />
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function NavMenu() {
|
||||
function NavMenuCenter() {
|
||||
const { isMobile } = useDevice()
|
||||
console.log("isMobile", isMobile)
|
||||
if (isMobile) return null
|
||||
@ -119,3 +119,64 @@ function ListItem({
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuClientOnly() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
if (!mounted) return null;
|
||||
return <SidebarMenu />;
|
||||
}
|
||||
|
||||
function SidebarMenu() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { isMobile } = useDevice()
|
||||
|
||||
if (!isMobile) return null
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
aria-label="打开菜单"
|
||||
className="p-2 rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="p-0 w-64">
|
||||
{/* 可访问性要求的标题,视觉上隐藏 */}
|
||||
<SheetTitle className="sr-only">侧边栏菜单</SheetTitle>
|
||||
<nav className="flex flex-col gap-2 p-4">
|
||||
{navbarMenuComponents.map((item) =>
|
||||
item.href ? (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="py-2 px-3 rounded hover:bg-accent font-bold transition-colors"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
) : item.children ? (
|
||||
<div key={item.title} className="mb-2">
|
||||
<div className="font-bold px-3 py-2">{item.title}</div>
|
||||
<div className="flex flex-col pl-4">
|
||||
{item.children.map((child) => (
|
||||
<Link
|
||||
key={child.title}
|
||||
href={child.href}
|
||||
className="py-2 px-3 rounded hover:bg-accent transition-colors"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{child.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
@ -1 +0,0 @@
|
||||
|
139
web/src/components/ui/sheet.tsx
Normal file
139
web/src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
Reference in New Issue
Block a user