fix: 修复后台文章计数为0 + 分类编辑补全描述字段 + CSP放行外部图片 + 更新关于页描述
- 文章管理:计数请求补 page=1 参数,命中分页接口返回正确的 total - 分类管理:编辑模式新增描述输入框,保存时一并提交 description - CSP:img-src 加入 https: 允许加载外部图片 - 关于页:数据存储描述从 JSON 文件更正为 SQLite 数据库 - Footer:添加 ICP 备案号
This commit is contained in:
@@ -1,114 +1,82 @@
|
||||
"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";
|
||||
import type { PublicPost } from "@/lib/store";
|
||||
import { formatDate, readingTimeLabel } from "@/lib/utils";
|
||||
import { useGsapAnimation } from "./useGsapAnimation";
|
||||
|
||||
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;
|
||||
post: PublicPost;
|
||||
prevPost: PublicPost | null;
|
||||
nextPost: PublicPost | null;
|
||||
}) {
|
||||
const articleRef = useRef<HTMLElement>(null);
|
||||
const ref = useGsapAnimation<HTMLElement>((scope, isReduced) => {
|
||||
if (isReduced) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (!articleRef.current) 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");
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline();
|
||||
// 标题逐字 —— 用 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"
|
||||
);
|
||||
|
||||
// Back link
|
||||
tl.from(".post-back", { x: -20, opacity: 0, duration: 0.5, ease: "power3.out" });
|
||||
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");
|
||||
|
||||
// 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,
|
||||
// 正文段落滚动揭示
|
||||
const paragraphs = scope.querySelectorAll(".prose-literary > *");
|
||||
paragraphs.forEach((p) => {
|
||||
gsap.from(p, {
|
||||
y: 25,
|
||||
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,
|
||||
duration: 0.6,
|
||||
ease: "power3.out",
|
||||
scrollTrigger: {
|
||||
trigger: ".post-navs",
|
||||
start: "top 90%",
|
||||
},
|
||||
scrollTrigger: { trigger: p, start: "top 90%" },
|
||||
});
|
||||
}, articleRef.current);
|
||||
});
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
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%" },
|
||||
});
|
||||
|
||||
// Split title into char spans
|
||||
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}
|
||||
>
|
||||
<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">
|
||||
<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
|
||||
@@ -133,7 +101,7 @@ export default function PostContent({
|
||||
<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>
|
||||
<span>{readingTimeLabel(post.readingTime)}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -146,19 +114,19 @@ export default function PostContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{/* Content — 已在 store 写入时净化,渲染时再次净化以防御历史脏数据 */}
|
||||
<div
|
||||
className="max-w-2xl mx-auto prose-literary"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
|
||||
{/* Tags */}
|
||||
{/* 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="/tags"
|
||||
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}
|
||||
@@ -179,7 +147,9 @@ export default function PostContent({
|
||||
{prevPost.title}
|
||||
</span>
|
||||
</Link>
|
||||
) : <div />}
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{nextPost ? (
|
||||
<Link
|
||||
href={`/posts/${nextPost.slug}`}
|
||||
@@ -190,7 +160,9 @@ export default function PostContent({
|
||||
{nextPost.title}
|
||||
</span>
|
||||
</Link>
|
||||
) : <div />}
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user