feat: 重构博客为水墨纸质风格 + 搭建后台管理系统

- 重新设计全站 UI:parchment/ink/terracotta 水墨纸质色系,宋式 serif 排版
- 新增页面:文章列表、文章详情、分类、标签、关于
- GSAP ScrollTrigger 滚动动画 + 逐字揭示效果
- 后台管理系统 /admin:文章/分类/标签 CRUD,JSON 文件存储
- 登录认证(cookie session)
- 设计系统文档 UI.md
This commit is contained in:
胡旭
2026-06-24 08:45:28 +08:00
parent 507f12e501
commit dce8fe62ea
35 changed files with 3124 additions and 96 deletions
+197
View File
@@ -0,0 +1,197 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import type { Post } from "@/data/posts";
gsap.registerPlugin(ScrollTrigger);
function formatDate(dateStr: string) {
const d = new Date(dateStr);
return d.toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" });
}
export default function PostContent({
post,
prevPost,
nextPost,
}: {
post: Post;
prevPost: Post | null;
nextPost: Post | null;
}) {
const articleRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!articleRef.current) return;
const ctx = gsap.context(() => {
const tl = gsap.timeline();
// Back link
tl.from(".post-back", { x: -20, opacity: 0, duration: 0.5, ease: "power3.out" });
// Category badge
tl.from(".post-category", { y: 15, opacity: 0, duration: 0.5, ease: "power3.out" }, "-=0.2");
// Title — char by char reveal
const titleChars = document.querySelectorAll(".post-title-char");
tl.from(
titleChars,
{ y: 30, opacity: 0, filter: "blur(4px)", duration: 0.5, stagger: 0.03, ease: "power3.out" },
"-=0.3"
);
// Meta
tl.from(".post-meta", { y: 10, opacity: 0, duration: 0.4, ease: "power3.out" }, "-=0.2");
// Divider — draw from center
tl.from(".post-divider-line", { scaleX: 0, duration: 0.6, ease: "power2.inOut", stagger: 0.1 }, "-=0.2");
tl.from(".post-divider-dot", { scale: 0, duration: 0.3, ease: "back.out(2)" }, "-=0.3");
// Content paragraphs stagger on scroll
const paragraphs = articleRef.current!.querySelectorAll(".prose-literary > *");
paragraphs.forEach((p) => {
gsap.from(p, {
y: 25,
opacity: 0,
duration: 0.6,
ease: "power3.out",
scrollTrigger: {
trigger: p,
start: "top 90%",
},
});
});
// Tags
gsap.from(".post-tag", {
scale: 0.8,
opacity: 0,
duration: 0.4,
stagger: 0.05,
ease: "back.out(1.5)",
scrollTrigger: {
trigger: ".post-tags",
start: "top 90%",
},
});
// Prev/Next
gsap.from(".post-nav", {
y: 20,
opacity: 0,
duration: 0.5,
stagger: 0.1,
ease: "power3.out",
scrollTrigger: {
trigger: ".post-navs",
start: "top 90%",
},
});
}, articleRef.current);
return () => ctx.revert();
}, []);
// Split title into char spans
const titleChars = [...post.title].map((char, i) => (
<span
key={i}
className="post-title-char inline-block"
style={char === " " ? { width: "0.3em" } : undefined}
>
{char}
</span>
));
return (
<article ref={articleRef} className="px-page max-w-5xl mx-auto pt-12 pb-24">
{/* Back link */}
<div className="post-back mb-10">
<Link
href="/blog"
className="inline-flex items-center gap-2 font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 4l-4 4 4 4" />
</svg>
</Link>
</div>
{/* Header */}
<header className="max-w-2xl mx-auto text-center mb-14">
<span className="post-category inline-block font-sans text-sm tracking-widest text-terracotta uppercase mb-4">
{post.category}
</span>
<h1 className="font-display text-3xl md:text-5xl font-light text-ink leading-tight tracking-tight">
{titleChars}
</h1>
<div className="post-meta mt-6 flex items-center justify-center gap-3 font-sans text-sm text-ink-muted">
<time>{formatDate(post.date)}</time>
<span className="w-1 h-1 rounded-full bg-warm-gray" />
<span>{post.readingTime} min read</span>
</div>
</header>
{/* Decorative divider */}
<div className="max-w-2xl mx-auto mb-14">
<div className="flex items-center justify-center gap-3">
<div className="post-divider-line w-8 h-px bg-warm-gray origin-right" />
<div className="post-divider-dot w-2 h-2 rounded-full border border-terracotta" />
<div className="post-divider-line w-8 h-px bg-warm-gray origin-left" />
</div>
</div>
{/* Content */}
<div
className="max-w-2xl mx-auto prose-literary"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
{/* Tags */}
<div className="post-tags max-w-2xl mx-auto mt-14 pt-8 border-t border-warm-gray/10">
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link
key={tag}
href="/tags"
className="post-tag font-sans text-xs px-3 py-1.5 rounded-full border border-warm-gray/20 text-ink-muted hover:border-terracotta/30 hover:text-terracotta transition-colors duration-300"
>
#{tag}
</Link>
))}
</div>
</div>
{/* Prev / Next navigation */}
<div className="post-navs max-w-2xl mx-auto mt-14 grid grid-cols-1 md:grid-cols-2 gap-4">
{prevPost ? (
<Link
href={`/posts/${prevPost.slug}`}
className="post-nav group p-5 rounded-xl border border-warm-gray/10 hover:border-terracotta/20 hover:bg-cream transition-all duration-300"
>
<span className="font-sans text-xs text-ink-muted block mb-1"></span>
<span className="font-display text-base text-ink group-hover:text-terracotta transition-colors duration-300">
{prevPost.title}
</span>
</Link>
) : <div />}
{nextPost ? (
<Link
href={`/posts/${nextPost.slug}`}
className="post-nav group p-5 rounded-xl border border-warm-gray/10 hover:border-terracotta/20 hover:bg-cream transition-all duration-300 text-right"
>
<span className="font-sans text-xs text-ink-muted block mb-1"></span>
<span className="font-display text-base text-ink group-hover:text-terracotta transition-colors duration-300">
{nextPost.title}
</span>
</Link>
) : <div />}
</div>
</article>
);
}