43e1c2f61d
写作体验优化: - 自动保存草稿到 localStorage(debounce 2s,刷新不丢内容) - 浏览器原生全屏专注写作模式 - Markdown 编辑模式(左右分栏,实时预览) - 快捷键:Ctrl+S / Ctrl+Enter 保存,ESC 退出全屏 封面图功能: - PostForm 新增封面图 URL 输入 + 实时预览 - BlogList 文章卡片显示封面缩略图 - PostContent 文章详情页显示封面大图 AI 辅助写作: - OpenAI 兼容接口,SSE 流式返回 - 8 种预设操作:润色、扩写、精简、续写、纠错、翻译、摘要 - 自定义指令输入,结果可复制/替换/追加 其他: - 后台文章列表改为一页10篇 - 前台 /blog 页面添加分页功能(一页10篇)
182 lines
6.5 KiB
TypeScript
182 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import gsap from "gsap";
|
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
import type { PublicPost } from "@/lib/store";
|
|
import { formatDate, readingTimeLabel } from "@/lib/utils";
|
|
import { useGsapAnimation } from "./useGsapAnimation";
|
|
|
|
gsap.registerPlugin(ScrollTrigger);
|
|
|
|
export default function PostContent({
|
|
post,
|
|
prevPost,
|
|
nextPost,
|
|
}: {
|
|
post: PublicPost;
|
|
prevPost: PublicPost | null;
|
|
nextPost: PublicPost | null;
|
|
}) {
|
|
const ref = useGsapAnimation<HTMLElement>((scope, isReduced) => {
|
|
if (isReduced) return;
|
|
|
|
const tl = gsap.timeline();
|
|
tl.from(".post-back", { x: -20, opacity: 0, duration: 0.5, ease: "power3.out" });
|
|
tl.from(".post-category", { y: 15, opacity: 0, duration: 0.5, ease: "power3.out" }, "-=0.2");
|
|
|
|
// 标题逐字 —— 用 scope 内选择器,而非全局 document
|
|
const titleChars = scope.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"
|
|
);
|
|
|
|
tl.from(".post-meta", { y: 10, opacity: 0, duration: 0.4, ease: "power3.out" }, "-=0.2");
|
|
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");
|
|
|
|
// 正文段落滚动揭示
|
|
const paragraphs = scope.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%" },
|
|
});
|
|
});
|
|
|
|
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%" },
|
|
});
|
|
|
|
gsap.from(".post-nav", {
|
|
y: 20,
|
|
opacity: 0,
|
|
duration: 0.5,
|
|
stagger: 0.1,
|
|
ease: "power3.out",
|
|
scrollTrigger: { trigger: ".post-navs", start: "top 90%" },
|
|
});
|
|
}, [post.slug]);
|
|
|
|
// 标题拆字
|
|
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={ref as React.RefObject<HTMLElement>} 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>{readingTimeLabel(post.readingTime)}</span>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Cover image */}
|
|
{post.coverImage && (
|
|
<div className="max-w-3xl mx-auto mb-14">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={post.coverImage}
|
|
alt={post.title}
|
|
className="w-full rounded-2xl object-cover max-h-[480px]"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 — 已在 store 写入时净化,渲染时再次净化以防御历史脏数据 */}
|
|
<div
|
|
className="max-w-2xl mx-auto prose-literary"
|
|
dangerouslySetInnerHTML={{ __html: post.content }}
|
|
/>
|
|
|
|
{/* Tags — 点击跳转到 /blog 按标签筛选 */}
|
|
<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={`/blog?tag=${encodeURIComponent(tag)}`}
|
|
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>
|
|
);
|
|
}
|