fix: 修复后台文章计数为0 + 分类编辑补全描述字段 + CSP放行外部图片 + 更新关于页描述

- 文章管理:计数请求补 page=1 参数,命中分页接口返回正确的 total
- 分类管理:编辑模式新增描述输入框,保存时一并提交 description
- CSP:img-src 加入 https: 允许加载外部图片
- 关于页:数据存储描述从 JSON 文件更正为 SQLite 数据库
- Footer:添加 ICP 备案号
This commit is contained in:
胡旭
2026-06-24 13:51:48 +08:00
parent 3707eddfd4
commit 18e915bcbb
69 changed files with 6818 additions and 1422 deletions
+5 -2
View File
@@ -30,8 +30,11 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# admin data storage
src/data/storage/
# prisma database
prisma/dev.db
prisma/dev.db-journal
prisma/dev.db-wal
prisma/dev.db-shm
# env files (can opt-in for committing if needed)
.env*
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+30 -1
View File
@@ -1,7 +1,36 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
poweredByHeader: false,
images: {
formats: ["image/avif", "image/webp"],
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob: https:",
"connect-src 'self'",
].join("; "),
},
],
},
];
},
};
export default nextConfig;
+23 -1
View File
@@ -9,16 +9,38 @@
"lint": "eslint"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@prisma/client": "5",
"@tiptap/extension-code-block-lowlight": "^3.27.1",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-link": "^3.27.1",
"@tiptap/extension-placeholder": "^3.27.1",
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"gsap": "^3.15.0",
"lowlight": "^3.3.0",
"lucide-react": "^1.21.0",
"next": "16.2.9",
"next-themes": "^0.4.6",
"prisma": "5",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"sanitize-html": "^2.17.5",
"shadcn": "^4.11.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.1",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"tailwindcss": "^4",
+2679
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Post {
id String @id
slug String @unique
title String
excerpt String @default("")
content String
date String
category String
tags String // JSON 序列化的 string[]
coverImage String?
readingTime Int @default(5)
featured Boolean @default(false)
status String @default("draft")
createdAt String
updatedAt String
}
model Category {
id String @id
name String @unique
description String @default("")
}
model Tag {
id String @id
name String @unique
}
+108 -29
View File
@@ -7,11 +7,31 @@ export const metadata = {
};
const timeline = [
{ year: "2026", title: "开始写博客", desc: "用 Next.js + Halo CMS 搭建个人博客,记录技术与生活" },
{ year: "2025", title: "sui_lightbox 项目", desc: "开发3D灯箱模拟平台,探索标识行业的数字化可能" },
{ year: "2024", title: "AI 图像生成", desc: "深入研究 Stable Diffusion,在 Apple Silicon 上部署本地 AI 环境" },
{ year: "2023", title: "Web 3D 可视化", desc: "从 Three.js 到 React Three Fiber,进入数字孪生领域" },
{ year: "2022", title: "前端开发", desc: "从后端转向全栈,React + TypeScript 成为主力技术栈" },
{
year: "2026",
title: "开始写博客",
desc: "用 Next.js 搭建个人博客,记录技术与生活",
},
{
year: "2025",
title: "sui_lightbox 项目",
desc: "开发3D灯箱模拟平台,探索标识行业的数字化可能",
},
{
year: "2024",
title: "AI 图像生成",
desc: "深入研究 Stable Diffusion,在 Apple Silicon 上部署本地 AI 环境",
},
{
year: "2023",
title: "Web 3D 可视化",
desc: "从 Three.js 到 React Three Fiber,进入数字孪生领域",
},
{
year: "2022",
title: "前端开发",
desc: "从后端转向全栈,React + TypeScript 成为主力技术栈",
},
];
export default function AboutPage() {
@@ -29,15 +49,24 @@ export default function AboutPage() {
</GsapReveal>
{/* Intro */}
<GsapReveal variant="fade-up" stagger={0.1} className="space-y-6 font-body text-base text-ink-light leading-relaxed">
<GsapReveal
variant="fade-up"
stagger={0.1}
className="space-y-6 font-body text-base text-ink-light leading-relaxed"
>
<p>
<span className="text-ink font-medium"></span>
<span className="text-ink font-medium"></span>
</p>
<p>
AI Web 3D 3D
AI Web 3D
3D
</p>
<p>
</p>
<p className="text-ink-muted">
@@ -45,9 +74,13 @@ export default function AboutPage() {
</GsapReveal>
{/* Contact */}
<GsapReveal variant="fade-up" stagger={0.08} className="mt-12 flex flex-wrap gap-3">
<GsapReveal
variant="fade-up"
stagger={0.08}
className="mt-12 flex flex-wrap gap-3"
>
<a
href="https://github.com/huxu"
href="http://gitea.asui.xyz/huxu"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-warm-gray/20 font-sans text-sm text-ink-muted hover:border-terracotta hover:text-terracotta transition-all duration-300"
@@ -55,24 +88,38 @@ export default function AboutPage() {
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
GitHub
Gitea
</a>
<a
href="mailto:hi@asui.xyz"
href="mailto:arieshuxu@163.com"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-warm-gray/20 font-sans text-sm text-ink-muted hover:border-terracotta hover:text-terracotta transition-all duration-300"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
hi@asui.xyz
E-mail
</a>
<a
href="https://asui.xyz"
href="https://www.asui.xyz"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-warm-gray/20 font-sans text-sm text-ink-muted hover:border-terracotta hover:text-terracotta transition-all duration-300"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9" />
</svg>
asui.xyz
@@ -82,7 +129,9 @@ export default function AboutPage() {
{/* Timeline */}
<div className="mt-20">
<GsapReveal variant="fade-up" className="mb-8">
<h2 className="font-display text-2xl font-medium text-ink">线</h2>
<h2 className="font-display text-2xl font-medium text-ink">
线
</h2>
</GsapReveal>
<GsapReveal variant="slide-left" stagger={0.12} className="space-y-0">
{timeline.map((item, i) => (
@@ -94,9 +143,15 @@ export default function AboutPage() {
<div className="w-2 h-2 rounded-full bg-terracotta" />
</div>
<div>
<span className="font-sans text-sm text-terracotta tracking-wide">{item.year}</span>
<h3 className="font-display text-lg font-medium text-ink mt-1">{item.title}</h3>
<p className="font-body text-sm text-ink-muted mt-1 leading-relaxed">{item.desc}</p>
<span className="font-sans text-sm text-terracotta tracking-wide">
{item.year}
</span>
<h3 className="font-display text-lg font-medium text-ink mt-1">
{item.title}
</h3>
<p className="font-body text-sm text-ink-muted mt-1 leading-relaxed">
{item.desc}
</p>
</div>
</div>
))}
@@ -106,15 +161,33 @@ export default function AboutPage() {
{/* Tech stack */}
<div className="mt-16">
<GsapReveal variant="fade-up" className="mb-6">
<h2 className="font-display text-2xl font-medium text-ink"></h2>
<h2 className="font-display text-2xl font-medium text-ink">
</h2>
</GsapReveal>
<GsapReveal variant="scale" stagger={0.04} className="flex flex-wrap gap-2">
<GsapReveal
variant="scale"
stagger={0.04}
className="flex flex-wrap gap-2"
>
{[
"React", "TypeScript", "Next.js", "Tailwind CSS", "Three.js",
"React Three Fiber", "Python", "Stable Diffusion", "Node.js",
"Vite", "Halo CMS", "Docker"
"React",
"TypeScript",
"Next.js",
"Tailwind CSS",
"Three.js",
"React Three Fiber",
"Python",
"Stable Diffusion",
"Node.js",
"Vite",
"GSAP",
"Docker",
].map((tech) => (
<span key={tech} className="px-3.5 py-1.5 rounded-full bg-cream border border-warm-gray/10 font-sans text-sm text-ink-muted">
<span
key={tech}
className="px-3.5 py-1.5 rounded-full bg-cream border border-warm-gray/10 font-sans text-sm text-ink-muted"
>
{tech}
</span>
))}
@@ -124,9 +197,15 @@ export default function AboutPage() {
{/* Colophon */}
<GsapReveal variant="fade-up" className="mt-20">
<div className="p-8 rounded-2xl bg-cream border border-warm-gray/10">
<h3 className="font-display text-lg font-medium text-ink mb-3"></h3>
<h3 className="font-display text-lg font-medium text-ink mb-3">
</h3>
<p className="font-body text-sm text-ink-muted leading-relaxed">
使 <span className="text-terracotta">Next.js 16</span> 使 <span className="text-terracotta">Tailwind CSS 4</span> <span className="text-terracotta">Halo CMS</span> 使 Noto Serif SC Cormorant Garamond
使 <span className="text-terracotta">Next.js 16</span>{" "}
{" "}
<span className="text-terracotta">Tailwind CSS 4</span>
SQLite 使 Noto Serif SC
Cormorant Garamond
</p>
</div>
</GsapReveal>
+91 -51
View File
@@ -2,6 +2,11 @@
import { useState, useEffect } from "react";
import type { Category } from "@/lib/store";
import { useToast, safeFetch } from "@/components/Toast";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/admin/ConfirmDialog";
export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
@@ -10,105 +15,140 @@ export default function CategoriesPage() {
const [newDesc, setNewDesc] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState("");
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const { toast } = useToast();
async function load() {
const res = await fetch("/api/categories");
setCategories(await res.json());
try {
const res = await safeFetch("/api/categories", undefined, toast);
setCategories(await res.json());
} catch { /* safeFetch 已弹 toast */ }
setLoading(false);
}
useEffect(() => { load(); }, []);
useEffect(() => { load(); }, [toast]);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
await fetch("/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
});
setNewName("");
setNewDesc("");
load();
try {
await safeFetch("/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
}, toast);
toast(`已添加分类「${newName.trim()}`, "success");
setNewName("");
setNewDesc("");
load();
} catch { /* safeFetch 已弹 toast */ }
}
async function handleDelete(id: string, name: string) {
if (!confirm(`确定删除分类「${name}」?`)) return;
await fetch(`/api/categories?id=${id}`, { method: "DELETE" });
load();
async function confirmDelete() {
if (!deleteTarget) return;
try {
await safeFetch(`/api/categories?id=${deleteTarget.id}`, { method: "DELETE" }, toast);
toast(`已删除分类「${deleteTarget.name}`, "success");
load();
} catch { /* safeFetch 已弹 toast */ }
setDeleteTarget(null);
}
async function handleSave(id: string) {
await fetch(`/api/categories?id=${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName }),
});
setEditingId(null);
load();
try {
await safeFetch(`/api/categories?id=${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName, description: editDesc }),
}, toast);
toast("分类已更新", "success");
setEditingId(null);
load();
} catch { /* safeFetch 已弹 toast */ }
}
if (loading) return <div className="font-sans text-ink-muted">...</div>;
if (loading) return <div className="font-sans text-muted-foreground">...</div>;
return (
<div>
<h1 className="font-display text-3xl font-medium text-ink mb-8"></h1>
<h1 className="font-display text-3xl font-medium mb-8"></h1>
{/* Add form */}
<form onSubmit={handleAdd} className="flex gap-3 mb-8">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="分类名称"
className="flex-1 max-w-xs px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
/>
<input
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
placeholder="描述(可选)"
className="flex-1 max-w-sm px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
/>
<button type="submit" className="px-5 py-2.5 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors shrink-0">
</button>
<form onSubmit={handleAdd} className="flex flex-col sm:flex-row gap-3 mb-8">
<div className="flex-1 max-w-xs space-y-1">
<Label htmlFor="cat-name" className="sr-only"></Label>
<Input
id="cat-name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="分类名称"
/>
</div>
<div className="flex-1 max-w-sm space-y-1">
<Label htmlFor="cat-desc" className="sr-only"></Label>
<Input
id="cat-desc"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
placeholder="描述(可选)"
/>
</div>
<Button type="submit" className="shrink-0"></Button>
</form>
{/* List */}
<div className="space-y-2">
{categories.map((cat) => (
<div key={cat.id} className="flex items-center justify-between p-4 rounded-xl bg-cream border border-warm-gray/10">
<div key={cat.id} className="flex items-center justify-between p-4 rounded-xl bg-card border border-border">
{editingId === cat.id ? (
<div className="flex items-center gap-2 flex-1">
<input
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-warm-gray/20 bg-white font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40"
className="h-8 max-w-xs"
autoFocus
/>
<button onClick={() => handleSave(cat.id)} className="font-sans text-xs text-sage hover:text-ink transition-colors"></button>
<button onClick={() => setEditingId(null)} className="font-sans text-xs text-ink-muted hover:text-ink transition-colors"></button>
<Input
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
className="h-8 max-w-sm"
placeholder="描述(可选)"
/>
<Button size="sm" variant="ghost" onClick={() => handleSave(cat.id)} className="text-accent"></Button>
<Button size="sm" variant="ghost" onClick={() => setEditingId(null)}></Button>
</div>
) : (
<>
<div>
<span className="font-display text-base text-ink">{cat.name}</span>
{cat.description && <span className="ml-3 font-sans text-sm text-ink-muted">{cat.description}</span>}
<span className="font-display text-base">{cat.name}</span>
{cat.description && <span className="ml-3 font-sans text-sm text-muted-foreground">{cat.description}</span>}
</div>
<div className="flex items-center gap-3">
<button
onClick={() => { setEditingId(cat.id); setEditName(cat.name); }}
className="font-sans text-xs text-ink-muted hover:text-terracotta transition-colors"
onClick={() => { setEditingId(cat.id); setEditName(cat.name); setEditDesc(cat.description); }}
className="font-sans text-xs text-muted-foreground hover:text-primary transition-colors"
></button>
<button onClick={() => handleDelete(cat.id, cat.name)} className="font-sans text-xs text-ink-muted hover:text-red-600 transition-colors"></button>
<button onClick={() => setDeleteTarget({ id: cat.id, name: cat.name })} className="font-sans text-xs text-muted-foreground hover:text-red-600 transition-colors"></button>
</div>
</>
)}
</div>
))}
{categories.length === 0 && (
<div className="text-center py-16 font-sans text-ink-muted"></div>
<div className="text-center py-16 font-sans text-muted-foreground"></div>
)}
</div>
<ConfirmDialog
open={!!deleteTarget}
title="删除分类"
description={`确定删除分类「${deleteTarget?.name}」?此操作不可撤销。`}
confirmText="删除"
variant="destructive"
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}
+99 -53
View File
@@ -3,6 +3,7 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { ToastProvider } from "@/components/Toast";
const navItems = [
{ label: "仪表盘", href: "/admin", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4" },
@@ -16,7 +17,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const router = useRouter();
const [authed, setAuthed] = useState<boolean | null>(null);
// Login page — render bare children without sidebar or auth check
const isLoginPage = pathname === "/admin/login";
useEffect(() => {
@@ -26,15 +26,21 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
.then((data) => {
if (!data.authenticated) router.push("/admin/login");
else setAuthed(true);
});
})
.catch(() => router.push("/admin/login"));
}, [router, isLoginPage]);
// Login page — 渲染 children 不加侧栏
if (isLoginPage) {
return <>{children}</>;
return <ToastProvider>{children}</ToastProvider>;
}
if (authed === null || !authed) {
return <div className="min-h-screen bg-parchment flex items-center justify-center font-sans text-ink-muted">...</div>;
return (
<div className="min-h-screen bg-background flex items-center justify-center font-sans text-muted-foreground" role="status">
...
</div>
);
}
async function handleLogout() {
@@ -43,57 +49,97 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
}
return (
<div className="min-h-screen bg-parchment flex">
{/* Sidebar */}
<aside className="w-56 shrink-0 border-r border-warm-gray/15 bg-cream/50 flex flex-col">
<div className="p-5 border-b border-warm-gray/10">
<Link href="/admin" className="font-display text-lg font-medium text-ink hover:text-terracotta transition-colors">
· Admin
</Link>
</div>
<nav className="flex-1 p-3 space-y-1">
{navItems.map((item) => {
const isActive = item.href === "/admin"
? pathname === "/admin"
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm transition-colors ${
isActive ? "bg-terracotta/10 text-terracotta" : "text-ink-muted hover:text-ink hover:bg-warm-gray/10"
}`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d={item.icon} />
</svg>
{item.label}
</Link>
);
})}
</nav>
<div className="p-3 border-t border-warm-gray/10 space-y-1">
<Link href="/" className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm text-ink-muted hover:text-ink hover:bg-warm-gray/10 transition-colors">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
<button onClick={handleLogout} className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm text-ink-muted hover:text-red-600 hover:bg-red-50 transition-colors text-left">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
退
</button>
</div>
</aside>
<ToastProvider>
<div className="min-h-screen bg-background flex">
{/* Sidebar — desktop */}
<aside className="w-56 shrink-0 border-r border-border bg-card/50 hidden md:flex flex-col">
<div className="p-5 border-b border-border">
<Link href="/admin" className="font-display text-lg font-medium hover:text-primary transition-colors">
· Admin
</Link>
</div>
<nav className="flex-1 p-3 space-y-1" aria-label="管理后台导航">
{navItems.map((item) => {
const isActive = item.href === "/admin"
? pathname === "/admin"
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm transition-colors ${
isActive ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted/30"
}`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d={item.icon} />
</svg>
{item.label}
</Link>
);
})}
</nav>
<div className="p-3 border-t border-border space-y-1">
<Link href="/" target="_blank" className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg font-sans text-sm text-muted-foreground hover:text-red-600 hover:bg-red-50 transition-colors text-left"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
退
</button>
</div>
</aside>
{/* Main */}
<div className="flex-1 overflow-auto">
<div className="p-8 max-w-5xl">
{children}
{/* Mobile top bar */}
<div className="md:hidden fixed top-0 inset-x-0 z-50 bg-background/90 backdrop-blur-sm border-b border-border flex items-center justify-between px-4 h-12">
<Link href="/admin" className="font-display text-base font-medium">Admin</Link>
<nav className="flex items-center gap-2" aria-label="管理后台导航">
{navItems.map((item) => {
const isActive = item.href === "/admin"
? pathname === "/admin"
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-label={item.label}
aria-current={isActive ? "page" : undefined}
className={`p-1.5 rounded-lg transition-colors ${isActive ? "text-primary" : "text-muted-foreground"}`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d={item.icon} />
</svg>
</Link>
);
})}
<button
onClick={handleLogout}
aria-label="退出登录"
className="p-1.5 rounded-lg text-muted-foreground hover:text-red-600 transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</nav>
</div>
{/* Main */}
<div className="flex-1 overflow-auto md:pt-0 pt-12">
<div className="p-6 md:p-8 max-w-5xl">
{children}
</div>
</div>
</div>
</div>
</ToastProvider>
);
}
+30 -25
View File
@@ -2,57 +2,62 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useToast, safeFetch } from "@/components/Toast";
import { ToastProvider } from "@/components/Toast";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
export default function LoginPage() {
return (
<ToastProvider>
<LoginForm />
</ToastProvider>
);
}
function LoginForm() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.ok) {
try {
await safeFetch("/api/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
}, toast);
router.push("/admin");
} else {
const data = await res.json();
setError(data.error || "登录失败");
}
} catch { /* safeFetch 已弹 toast */ }
setLoading(false);
}
return (
<div className="min-h-screen bg-parchment flex items-center justify-center px-4">
<div className="min-h-screen bg-background flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-10">
<h1 className="font-display text-3xl font-medium text-ink"></h1>
<p className="mt-2 font-sans text-sm text-ink-muted">asui.xyz</p>
<h1 className="font-display text-3xl font-medium"></h1>
<p className="mt-2 font-sans text-sm text-muted-foreground">asui.xyz</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<input
<div className="space-y-1.5">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
placeholder="输入管理密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
autoFocus
/>
</div>
{error && <p className="font-sans text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-3 rounded-xl bg-ink text-cream font-sans text-sm tracking-wide hover:bg-terracotta transition-colors disabled:opacity-50"
>
<Button type="submit" disabled={loading} className="w-full">
{loading ? "验证中..." : "登录"}
</button>
</Button>
</form>
</div>
</div>
+44 -39
View File
@@ -2,83 +2,88 @@
import { useState, useEffect } from "react";
import Link from "next/link";
import type { Post, Category, Tag } from "@/lib/store";
import type { Post } from "@/lib/store";
import { useToast, safeFetch } from "@/components/Toast";
interface Stats {
total: number;
published: number;
draft: number;
featured: number;
categories: number;
tags: number;
}
export default function DashboardPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
const [recentPosts, setRecentPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
Promise.all([
fetch("/api/posts").then((r) => r.json()),
fetch("/api/categories").then((r) => r.json()),
fetch("/api/tags").then((r) => r.json()),
]).then(([p, c, t]) => {
setPosts(p);
setCategories(c);
setTags(t);
safeFetch("/api/stats", undefined, toast).then((r) => r.json()),
safeFetch("/api/posts?page=1&pageSize=5&sortBy=createdAt&sortDir=desc", undefined, toast).then((r) => r.json()),
]).then(([s, postsResult]) => {
setStats(s);
setRecentPosts(postsResult.data ?? postsResult);
setLoading(false);
});
}, []);
}).catch(() => setLoading(false));
}, [toast]);
if (loading) {
return <div className="font-sans text-ink-muted">...</div>;
}
if (loading) return <div className="font-sans text-muted-foreground">...</div>;
const published = posts.filter((p) => p.status === "published").length;
const drafts = posts.filter((p) => p.status === "draft").length;
const featured = posts.filter((p) => p.featured).length;
const stats = [
{ label: "文章总数", value: posts.length, color: "text-ink" },
{ label: "已发布", value: published, color: "text-sage" },
{ label: "草稿", value: drafts, color: "text-terracotta" },
{ label: "精选", value: featured, color: "text-terracotta" },
{ label: "分类", value: categories.length, color: "text-ink" },
{ label: "标签", value: tags.length, color: "text-ink" },
];
const statItems = stats ? [
{ label: "文章总数", value: stats.total, color: "text-foreground" },
{ label: "已发布", value: stats.published, color: "text-accent" },
{ label: "草稿", value: stats.draft, color: "text-primary" },
{ label: "精选", value: stats.featured, color: "text-primary" },
{ label: "分类", value: stats.categories, color: "text-foreground" },
{ label: "标签", value: stats.tags, color: "text-foreground" },
] : [];
return (
<div>
<div className="flex items-center justify-between mb-8">
<h1 className="font-display text-3xl font-medium text-ink"></h1>
<Link href="/admin/posts/new" className="px-4 py-2 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors">
<h1 className="font-display text-3xl font-medium"></h1>
<Link href="/admin/posts/new" className="inline-flex items-center px-4 py-2 rounded-lg bg-primary text-primary-foreground font-sans text-sm hover:bg-primary/90 transition-colors">
+
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-10">
{stats.map((s) => (
<div key={s.label} className="p-4 rounded-xl bg-cream border border-warm-gray/10">
{statItems.map((s) => (
<div key={s.label} className="p-4 rounded-xl bg-card border border-border">
<div className={`font-display text-2xl font-medium ${s.color}`}>{s.value}</div>
<div className="font-sans text-xs text-ink-muted mt-1">{s.label}</div>
<div className="font-sans text-xs text-muted-foreground mt-1">{s.label}</div>
</div>
))}
</div>
{/* Recent posts */}
<h2 className="font-display text-xl font-medium text-ink mb-4"></h2>
<h2 className="font-display text-xl font-medium mb-4"></h2>
<div className="space-y-2">
{posts.slice(0, 5).map((post) => (
{recentPosts.map((post) => (
<Link
key={post.id}
href={`/admin/posts/${post.id}`}
className="flex items-center justify-between p-4 rounded-xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 transition-colors"
className="flex items-center justify-between p-4 rounded-xl bg-card border border-border hover:border-primary/20 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="font-display text-base text-ink truncate">{post.title}</div>
<div className="font-sans text-xs text-ink-muted mt-0.5">{post.category} · {post.date}</div>
<div className="font-display text-base truncate">{post.title}</div>
<div className="font-sans text-xs text-muted-foreground mt-0.5">{post.category} · {post.date}</div>
</div>
<span className={`ml-4 shrink-0 font-sans text-xs px-2 py-0.5 rounded-full ${
post.status === "published" ? "bg-sage/10 text-sage" : "bg-terracotta/10 text-terracotta"
post.status === "published" ? "bg-accent/10 text-accent" : "bg-primary/10 text-primary"
}`}>
{post.status === "published" ? "已发布" : "草稿"}
</span>
</Link>
))}
{recentPosts.length === 0 && (
<div className="text-center py-8 font-sans text-muted-foreground"></div>
)}
</div>
</div>
);
+32 -154
View File
@@ -3,185 +3,63 @@
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import type { Post, Category, Tag } from "@/lib/store";
import { useToast, safeFetch } from "@/components/Toast";
import PostForm from "@/components/admin/PostForm";
import type { PostFormData } from "@/components/admin/PostForm";
export default function EditPostPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const { toast } = useToast();
const [post, setPost] = useState<Post | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [allTags, setAllTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({
title: "", slug: "", excerpt: "", content: "",
category: "", tags: [] as string[], readingTime: 5,
featured: false, status: "draft" as "draft" | "published",
date: "",
});
useEffect(() => {
Promise.all([
fetch(`/api/posts/${id}`).then((r) => r.json()),
fetch("/api/categories").then((r) => r.json()),
fetch("/api/tags").then((r) => r.json()),
]).then(([p, c, t]) => {
safeFetch(`/api/posts/${id}`, undefined, toast).then((r) => r.json()),
safeFetch("/api/categories", undefined, toast).then((r) => r.json()),
safeFetch("/api/tags", undefined, toast).then((r) => r.json()),
]).then(([p, cats, tgs]) => {
setPost(p);
setCategories(c);
setAllTags(t);
setForm({
title: p.title, slug: p.slug, excerpt: p.excerpt,
content: p.content, category: p.category, tags: p.tags,
readingTime: p.readingTime, featured: p.featured,
status: p.status, date: p.date,
});
setLoading(false);
});
}, [id]);
setCategories(cats);
setAllTags(tgs);
}).catch(() => {})
.finally(() => setLoading(false));
}, [id, toast]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch(`/api/posts/${id}`, {
async function handleSubmit(data: PostFormData) {
await safeFetch(`/api/posts/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (res.ok) router.push("/admin/posts");
body: JSON.stringify(data),
}, toast);
toast("文章已更新", "success");
router.push("/admin/posts");
}
function toggleTag(tagName: string) {
setForm((prev) => ({
...prev,
tags: prev.tags.includes(tagName)
? prev.tags.filter((t) => t !== tagName)
: [...prev.tags, tagName],
}));
}
if (loading) return <div className="font-sans text-ink-muted">...</div>;
if (loading) return <div className="font-sans text-muted-foreground">...</div>;
if (!post) return <div className="font-sans text-red-600"></div>;
return (
<div>
<div className="flex items-center gap-4 mb-8">
<button onClick={() => router.back()} className="font-sans text-sm text-ink-muted hover:text-ink transition-colors"> </button>
<h1 className="font-display text-3xl font-medium text-ink"></h1>
<button onClick={() => router.back()} className="font-sans text-sm text-muted-foreground hover:text-foreground transition-colors">
</button>
<h1 className="font-display text-3xl font-medium"></h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-display text-lg text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5">Slug</label>
<input
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
/>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
/>
</div>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<textarea
value={form.excerpt}
onChange={(e) => setForm({ ...form, excerpt: e.target.value })}
rows={2}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors resize-none"
/>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5">HTML</label>
<textarea
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
rows={12}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors resize-y font-mono"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<select
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
required
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.name}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
type="number" min={1}
value={form.readingTime}
onChange={(e) => setForm({ ...form, readingTime: Number(e.target.value) })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
/>
</div>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => (
<button key={tag.id} type="button" onClick={() => toggleTag(tag.name)}
className={`font-sans text-xs px-3 py-1.5 rounded-full border transition-colors ${
form.tags.includes(tag.name)
? "bg-terracotta/10 border-terracotta/30 text-terracotta"
: "border-warm-gray/20 text-ink-muted hover:border-terracotta/20"
}`}
>{tag.name}</button>
))}
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input type="checkbox" checked={form.featured} onChange={(e) => setForm({ ...form, featured: e.target.checked })} className="accent-terracotta" />
</label>
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input type="radio" name="status" checked={form.status === "draft"} onChange={() => setForm({ ...form, status: "draft" })} className="accent-terracotta" />
稿
</label>
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input type="radio" name="status" checked={form.status === "published"} onChange={() => setForm({ ...form, status: "published" })} className="accent-terracotta" />
</label>
</div>
<div className="flex gap-3 pt-4">
<button type="submit" className="px-6 py-2.5 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors"></button>
<button type="button" onClick={() => router.back()} className="px-6 py-2.5 rounded-lg border border-warm-gray/20 font-sans text-sm text-ink-muted hover:text-ink transition-colors"></button>
</div>
</form>
<PostForm
mode="edit"
initialData={post}
categories={categories}
tags={allTags}
onSubmit={handleSubmit}
onCancel={() => router.back()}
/>
</div>
);
}
+27 -194
View File
@@ -3,218 +3,51 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import type { Category, Tag } from "@/lib/store";
import { useToast, safeFetch } from "@/components/Toast";
import PostForm from "@/components/admin/PostForm";
import type { PostFormData } from "@/components/admin/PostForm";
export default function NewPostPage() {
const router = useRouter();
const { toast } = useToast();
const [categories, setCategories] = useState<Category[]>([]);
const [allTags, setAllTags] = useState<Tag[]>([]);
const [form, setForm] = useState({
title: "",
slug: "",
excerpt: "",
content: "",
category: "",
tags: [] as string[],
readingTime: 5,
featured: false,
status: "draft" as "draft" | "published",
date: new Date().toISOString().slice(0, 10),
});
useEffect(() => {
fetch("/api/categories").then((r) => r.json()).then(setCategories);
fetch("/api/tags").then((r) => r.json()).then(setAllTags);
}, []);
Promise.all([
safeFetch("/api/categories", undefined, toast).then((r) => r.json()),
safeFetch("/api/tags", undefined, toast).then((r) => r.json()),
]).then(([cats, tgs]) => {
setCategories(cats);
setAllTags(tgs);
}).catch(() => {});
}, [toast]);
function autoSlug(title: string) {
return title
.toLowerCase()
.replace(/[^\w\u4e00-\u9fff]+/g, "-")
.replace(/^-|-$/g, "");
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const slug = form.slug || autoSlug(form.title);
const res = await fetch("/api/posts", {
async function handleSubmit(data: PostFormData) {
await safeFetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, slug }),
});
if (res.ok) router.push("/admin/posts");
}
function toggleTag(tagName: string) {
setForm((prev) => ({
...prev,
tags: prev.tags.includes(tagName)
? prev.tags.filter((t) => t !== tagName)
: [...prev.tags, tagName],
}));
body: JSON.stringify(data),
}, toast);
toast("文章创建成功", "success");
router.push("/admin/posts");
}
return (
<div>
<div className="flex items-center gap-4 mb-8">
<button onClick={() => router.back()} className="font-sans text-sm text-ink-muted hover:text-ink transition-colors">
<button onClick={() => router.back()} className="font-sans text-sm text-muted-foreground hover:text-foreground transition-colors">
</button>
<h1 className="font-display text-3xl font-medium text-ink"></h1>
<h1 className="font-display text-3xl font-medium"></h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Title */}
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-display text-lg text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
placeholder="文章标题"
required
/>
</div>
{/* Slug + Date */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5">Slug</label>
<input
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
placeholder="my-post-slug"
/>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
/>
</div>
</div>
{/* Excerpt */}
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<textarea
value={form.excerpt}
onChange={(e) => setForm({ ...form, excerpt: e.target.value })}
rows={2}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors resize-none"
placeholder="文章摘要..."
/>
</div>
{/* Content */}
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5">HTML</label>
<textarea
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
rows={12}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors resize-y font-mono"
placeholder="<p>文章内容...</p>"
required
/>
</div>
{/* Category + Reading Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<select
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
required
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.name}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
type="number"
min={1}
value={form.readingTime}
onChange={(e) => setForm({ ...form, readingTime: Number(e.target.value) })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
/>
</div>
</div>
{/* Tags */}
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.name)}
className={`font-sans text-xs px-3 py-1.5 rounded-full border transition-colors ${
form.tags.includes(tag.name)
? "bg-terracotta/10 border-terracotta/30 text-terracotta"
: "border-warm-gray/20 text-ink-muted hover:border-terracotta/20"
}`}
>
{tag.name}
</button>
))}
</div>
</div>
{/* Status + Featured */}
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input
type="checkbox"
checked={form.featured}
onChange={(e) => setForm({ ...form, featured: e.target.checked })}
className="accent-terracotta"
/>
</label>
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input
type="radio"
name="status"
checked={form.status === "draft"}
onChange={() => setForm({ ...form, status: "draft" })}
className="accent-terracotta"
/>
稿
</label>
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input
type="radio"
name="status"
checked={form.status === "published"}
onChange={() => setForm({ ...form, status: "published" })}
className="accent-terracotta"
/>
</label>
</div>
{/* Submit */}
<div className="flex gap-3 pt-4">
<button type="submit" className="px-6 py-2.5 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors">
</button>
<button type="button" onClick={() => router.back()} className="px-6 py-2.5 rounded-lg border border-warm-gray/20 font-sans text-sm text-ink-muted hover:text-ink transition-colors">
</button>
</div>
</form>
<PostForm
mode="create"
categories={categories}
tags={allTags}
onSubmit={handleSubmit}
onCancel={() => router.back()}
/>
</div>
);
}
+190 -39
View File
@@ -1,97 +1,248 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link";
import type { Post } from "@/lib/store";
import { useToast, safeFetch } from "@/components/Toast";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, ChevronLeft, ChevronRight } from "lucide-react";
export default function PostsPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<"all" | "published" | "draft">("all");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [sortKey, setSortKey] = useState<"date" | "createdAt" | "title" | "readingTime">("date");
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [counts, setCounts] = useState({ all: 0, published: 0, draft: 0 });
const pageSize = 20;
const { toast } = useToast();
async function loadPosts() {
const res = await fetch("/api/posts");
const data = await res.json();
setPosts(data);
// 搜索 debounce300ms 后才更新 debouncedSearch
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, 300);
return () => clearTimeout(timer);
}, [search]);
// 筛选/排序变化时重置到第 1 页
useEffect(() => { setPage(1); }, [filter, sortKey, sortDir]);
// 加载文章列表
const loadPosts = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
sortBy: sortKey,
sortDir,
});
if (filter !== "all") params.set("status", filter);
if (debouncedSearch.trim()) params.set("search", debouncedSearch.trim());
const res = await safeFetch(`/api/posts?${params}`, undefined, toast);
const result = await res.json();
if (result.data) {
setPosts(result.data);
setTotal(result.total);
setTotalPages(result.totalPages);
} else {
setPosts(result);
setTotal(result.length);
setTotalPages(1);
}
} catch { /* safeFetch 已弹 toast */ }
setLoading(false);
}
}, [page, pageSize, sortKey, sortDir, filter, debouncedSearch, toast]);
useEffect(() => { loadPosts(); }, []);
useEffect(() => { loadPosts(); }, [loadPosts]);
// 加载各状态计数(独立请求,不受筛选影响)
useEffect(() => {
Promise.all([
safeFetch("/api/posts?page=1&pageSize=1", undefined, toast).then((r) => r.json()),
safeFetch("/api/posts?pageSize=1&status=published", undefined, toast).then((r) => r.json()),
safeFetch("/api/posts?pageSize=1&status=draft", undefined, toast).then((r) => r.json()),
]).then(([all, pub, draft]) => {
setCounts({
all: all.total ?? 0,
published: pub.total ?? 0,
draft: draft.total ?? 0,
});
}).catch(() => {});
}, [toast]); // 只在 mount 时加载一次
async function handleDelete(id: string, title: string) {
if (!confirm(`确定删除「${title}」?`)) return;
await fetch(`/api/posts?id=${id}`, { method: "DELETE" });
loadPosts();
try {
await safeFetch(`/api/posts?id=${id}`, { method: "DELETE" }, toast);
toast(`已删除「${title}`, "success");
loadPosts();
// 重新加载计数
const [all, pub, draft] = await Promise.all([
safeFetch("/api/posts?page=1&pageSize=1", undefined, toast).then((r) => r.json()),
safeFetch("/api/posts?pageSize=1&status=published", undefined, toast).then((r) => r.json()),
safeFetch("/api/posts?pageSize=1&status=draft", undefined, toast).then((r) => r.json()),
]);
setCounts({ all: all.total ?? 0, published: pub.total ?? 0, draft: draft.total ?? 0 });
} catch { /* safeFetch 已弹 toast */ }
}
const filtered = filter === "all" ? posts : posts.filter((p) => p.status === filter);
if (loading) return <div className="font-sans text-ink-muted">...</div>;
if (loading && posts.length === 0) return <div className="font-sans text-muted-foreground">...</div>;
return (
<div>
<div className="flex items-center justify-between mb-8">
<h1 className="font-display text-3xl font-medium text-ink"></h1>
<Link href="/admin/posts/new" className="px-4 py-2 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors">
<h1 className="font-display text-3xl font-medium"></h1>
<Link href="/admin/posts/new" className="inline-flex items-center px-4 py-2 rounded-lg bg-primary text-primary-foreground font-sans text-sm hover:bg-primary/90 transition-colors">
+
</Link>
</div>
{/* Filter tabs */}
<div className="flex gap-4 mb-6 font-sans text-sm">
{(["all", "published", "draft"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`pb-1 border-b-2 transition-colors ${
filter === f ? "border-terracotta text-ink" : "border-transparent text-ink-muted hover:text-ink"
}`}
{/* 搜索 + 筛选 + 排序 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<div className="flex gap-4 font-sans text-sm">
{(["all", "published", "draft"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`pb-1 border-b-2 transition-colors ${
filter === f ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{f === "all" ? "全部" : f === "published" ? "已发布" : "草稿"}
<span className="ml-1 text-xs text-muted-foreground">({counts[f]})</span>
</button>
))}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="relative flex-1 sm:w-56">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索标题、摘要、分类..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-9"
/>
</div>
<select
value={`${sortKey}-${sortDir}`}
onChange={(e) => {
const [k, d] = e.target.value.split("-") as [typeof sortKey, typeof sortDir];
setSortKey(k);
setSortDir(d);
}}
className="h-9 px-2 rounded-md border border-border bg-card text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
>
{f === "all" ? "全部" : f === "published" ? "已发布" : "草稿"}
<span className="ml-1 text-xs text-ink-muted">({f === "all" ? posts.length : posts.filter((p) => p.status === f).length})</span>
</button>
))}
<option value="date-desc"> </option>
<option value="date-asc"> </option>
<option value="createdAt-desc"> </option>
<option value="createdAt-asc"> </option>
<option value="title-asc"> AZ</option>
<option value="title-desc"> ZA</option>
<option value="readingTime-desc"> </option>
<option value="readingTime-asc"> </option>
</select>
</div>
</div>
{/* Posts list */}
{/* 文章列表 */}
<div className="space-y-3">
{filtered.map((post) => (
<div key={post.id} className="p-5 rounded-xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 transition-colors">
{posts.map((post) => (
<div key={post.id} className="p-5 rounded-xl bg-card border border-border hover:border-primary/20 transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<Link href={`/admin/posts/${post.id}`} className="font-display text-lg text-ink hover:text-terracotta transition-colors">
<Link href={`/admin/posts/${post.id}`} className="font-display text-lg text-foreground hover:text-primary transition-colors">
{post.title}
</Link>
<p className="font-sans text-sm text-ink-muted mt-1 line-clamp-1">{post.excerpt}</p>
<div className="flex items-center gap-3 mt-2 font-sans text-xs text-ink-muted">
<p className="font-sans text-sm text-muted-foreground mt-1 line-clamp-1">{post.excerpt}</p>
<div className="flex items-center gap-3 mt-2 font-sans text-xs text-muted-foreground">
<span>{post.category}</span>
<span>·</span>
<span>{post.date}</span>
<span>·</span>
<span>{post.readingTime} </span>
{post.featured && <span className="text-terracotta"></span>}
{post.featured && <span className="text-primary"></span>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`font-sans text-xs px-2 py-0.5 rounded-full ${
post.status === "published" ? "bg-sage/10 text-sage" : "bg-terracotta/10 text-terracotta"
post.status === "published" ? "bg-accent/10 text-accent" : "bg-primary/10 text-primary"
}`}>
{post.status === "published" ? "已发布" : "草稿"}
</span>
<Link href={`/admin/posts/${post.id}`} className="font-sans text-xs text-ink-muted hover:text-terracotta transition-colors px-2">
<Link href={`/admin/posts/${post.id}`} className="font-sans text-xs text-muted-foreground hover:text-primary transition-colors px-2">
</Link>
<button onClick={() => handleDelete(post.id, post.title)} className="font-sans text-xs text-ink-muted hover:text-red-600 transition-colors px-2">
<button onClick={() => handleDelete(post.id, post.title)} className="font-sans text-xs text-muted-foreground hover:text-red-600 transition-colors px-2">
</button>
</div>
</div>
</div>
))}
{filtered.length === 0 && (
<div className="text-center py-16 font-sans text-ink-muted"></div>
{posts.length === 0 && !loading && (
<div className="text-center py-16 font-sans text-muted-foreground">
{debouncedSearch ? "未找到匹配的文章" : "暂无文章"}
</div>
)}
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-8 font-sans text-sm text-muted-foreground">
<span> {total} {page}/{totalPages} </span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<Button
key={pageNum}
variant={pageNum === page ? "default" : "outline"}
size="sm"
onClick={() => setPage(pageNum)}
className="w-8"
>
{pageNum}
</Button>
);
})}
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
+58 -30
View File
@@ -2,74 +2,102 @@
import { useState, useEffect } from "react";
import type { Tag } from "@/lib/store";
import { useToast, safeFetch } from "@/components/Toast";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/admin/ConfirmDialog";
export default function TagsPage() {
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState("");
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const { toast } = useToast();
async function load() {
const res = await fetch("/api/tags");
setTags(await res.json());
try {
const res = await safeFetch("/api/tags", undefined, toast);
setTags(await res.json());
} catch { /* safeFetch 已弹 toast */ }
setLoading(false);
}
useEffect(() => { load(); }, []);
useEffect(() => { load(); }, [toast]);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
await fetch("/api/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
});
setNewName("");
load();
try {
await safeFetch("/api/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
}, toast);
toast(`已添加标签「${newName.trim()}`, "success");
setNewName("");
load();
} catch { /* safeFetch 已弹 toast */ }
}
async function handleDelete(id: string, name: string) {
if (!confirm(`确定删除标签「${name}」?`)) return;
await fetch(`/api/tags?id=${id}`, { method: "DELETE" });
load();
async function confirmDelete() {
if (!deleteTarget) return;
try {
await safeFetch(`/api/tags?id=${deleteTarget.id}`, { method: "DELETE" }, toast);
toast(`已删除标签「${deleteTarget.name}`, "success");
load();
} catch { /* safeFetch 已弹 toast */ }
setDeleteTarget(null);
}
if (loading) return <div className="font-sans text-ink-muted">...</div>;
if (loading) return <div className="font-sans text-muted-foreground">...</div>;
return (
<div>
<h1 className="font-display text-3xl font-medium text-ink mb-8"></h1>
<h1 className="font-display text-3xl font-medium mb-8"></h1>
{/* Add form */}
<form onSubmit={handleAdd} className="flex gap-3 mb-8">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="标签名称"
className="flex-1 max-w-xs px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink placeholder:text-ink-muted/50 focus:outline-none focus:border-terracotta/40 transition-colors"
/>
<button type="submit" className="px-5 py-2.5 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors shrink-0">
</button>
<div className="flex-1 max-w-xs space-y-1">
<Label htmlFor="tag-name" className="sr-only"></Label>
<Input
id="tag-name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="标签名称"
/>
</div>
<Button type="submit" className="shrink-0"></Button>
</form>
{/* Tag cloud */}
<div className="p-6 rounded-xl bg-cream border border-warm-gray/10">
<div className="p-6 rounded-xl bg-card border border-border">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span key={tag.id} className="group inline-flex items-center gap-1.5 font-sans text-sm px-3 py-1.5 rounded-full border border-warm-gray/15 text-ink hover:border-terracotta/30 transition-colors">
<span key={tag.id} className="group inline-flex items-center gap-1.5 font-sans text-sm px-3 py-1.5 rounded-full border border-border text-foreground hover:border-primary/30 transition-colors">
{tag.name}
<button
onClick={() => handleDelete(tag.id, tag.name)}
className="opacity-0 group-hover:opacity-100 text-ink-muted hover:text-red-600 transition-all text-xs leading-none"
onClick={() => setDeleteTarget({ id: tag.id, name: tag.name })}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-red-600 transition-all text-xs leading-none"
aria-label={`删除标签 ${tag.name}`}
>×</button>
</span>
))}
</div>
{tags.length === 0 && (
<div className="text-center py-8 font-sans text-ink-muted"></div>
<div className="text-center py-8 font-sans text-muted-foreground"></div>
)}
</div>
<ConfirmDialog
open={!!deleteTarget}
title="删除标签"
description={`确定删除标签「${deleteTarget?.name}」?此操作不可撤销。`}
confirmText="删除"
variant="destructive"
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}
+48 -19
View File
@@ -1,26 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { checkAuth, createSession, SESSION_KEY } from "@/lib/auth";
import { registerFailedAttempt, clearAttempts, isLocked } from "@/lib/rate-limit";
const ADMIN_PASSWORD = "asui2026"; // 后续可改环境变量
const SESSION_KEY = "admin_session";
const SESSION_VALUE = "authenticated";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "asui2026";
const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 天
/** 取客户端 IP 作为限流 key,兼容代理转发头。 */
function clientKey(request: NextRequest): string {
const fwd = request.headers.get("x-forwarded-for");
const ip = fwd ? fwd.split(",")[0].trim() : request.headers.get("x-real-ip") || "unknown";
return `login:${ip}`;
}
export async function POST(request: NextRequest) {
const body = await request.json();
if (body.password === ADMIN_PASSWORD) {
const cookieStore = await cookies();
cookieStore.set(SESSION_KEY, SESSION_VALUE, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/",
});
return NextResponse.json({ ok: true });
const key = clientKey(request);
const lock = isLocked(key);
if (lock.locked) {
return NextResponse.json(
{ error: `尝试次数过多,请 ${Math.ceil(lock.retryAfterSec / 60)} 分钟后再试` },
{ status: 429 }
);
}
return NextResponse.json({ error: "密码错误" }, { status: 401 });
let body: { password?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "请求格式错误" }, { status: 400 });
}
if (typeof body.password !== "string" || body.password !== ADMIN_PASSWORD) {
const result = registerFailedAttempt(key);
if (result.locked) {
return NextResponse.json(
{ error: "密码错误次数过多,账户已锁定 15 分钟" },
{ status: 429 }
);
}
return NextResponse.json({ error: "密码错误" }, { status: 401 });
}
clearAttempts(key);
const token = await createSession(SESSION_MAX_AGE);
const cookieStore = await cookies();
cookieStore.set(SESSION_KEY, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SESSION_MAX_AGE,
path: "/",
});
return NextResponse.json({ ok: true });
}
export async function DELETE() {
@@ -30,7 +61,5 @@ export async function DELETE() {
}
export async function GET() {
const cookieStore = await cookies();
const session = cookieStore.get(SESSION_KEY);
return NextResponse.json({ authenticated: session?.value === SESSION_VALUE });
return NextResponse.json({ authenticated: await checkAuth() });
}
+18 -26
View File
@@ -1,48 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getCategories, createCategory, updateCategory, deleteCategory } from "@/lib/store";
async function checkAuth(): Promise<boolean> {
const cookieStore = await cookies();
return cookieStore.get("admin_session")?.value === "authenticated";
}
import { requireAuth, parseBody } from "@/lib/http";
import { categorySchema } from "@/lib/validation";
export async function GET() {
if (!(await checkAuth())) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
return NextResponse.json(getCategories());
const deny = await requireAuth();
if (deny) return deny;
return NextResponse.json(await getCategories());
}
export async function POST(request: NextRequest) {
if (!(await checkAuth())) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const body = await request.json();
const cat = createCategory(body);
return NextResponse.json(cat, { status: 201 });
const deny = await requireAuth();
if (deny) return deny;
const parsed = await parseBody(request, categorySchema);
if (!parsed.ok) return parsed.response;
return NextResponse.json(await createCategory(parsed.data), { status: 201 });
}
export async function PUT(request: NextRequest) {
if (!(await checkAuth())) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const deny = await requireAuth();
if (deny) return deny;
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
const body = await request.json();
const cat = updateCategory(id, body);
const parsed = await parseBody(request, categorySchema);
if (!parsed.ok) return parsed.response;
const cat = await updateCategory(id, parsed.data);
if (!cat) return NextResponse.json({ error: "未找到" }, { status: 404 });
return NextResponse.json(cat);
}
export async function DELETE(request: NextRequest) {
if (!(await checkAuth())) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const deny = await requireAuth();
if (deny) return deny;
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
const ok = deleteCategory(id);
return NextResponse.json({ ok });
return NextResponse.json({ ok: await deleteCategory(id) });
}
+10 -15
View File
@@ -1,21 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getPost, updatePost } from "@/lib/store";
async function checkAuth(): Promise<boolean> {
const cookieStore = await cookies();
return cookieStore.get("admin_session")?.value === "authenticated";
}
import { requireAuth, parseBody } from "@/lib/http";
import { updatePostSchema } from "@/lib/validation";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await checkAuth())) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const deny = await requireAuth();
if (deny) return deny;
const { id } = await params;
const post = getPost(id);
const post = await getPost(id);
if (!post) return NextResponse.json({ error: "未找到" }, { status: 404 });
return NextResponse.json(post);
}
@@ -24,12 +19,12 @@ export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await checkAuth())) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const deny = await requireAuth();
if (deny) return deny;
const { id } = await params;
const body = await request.json();
const post = updatePost(id, body);
const parsed = await parseBody(request, updatePostSchema);
if (!parsed.ok) return parsed.response;
const post = await updatePost(id, parsed.data);
if (!post) return NextResponse.json({ error: "未找到" }, { status: 404 });
return NextResponse.json(post);
}
+30 -21
View File
@@ -1,36 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getPosts, createPost, deletePost } from "@/lib/store";
import { getPosts, getPostsPaginated, createPost, deletePost } from "@/lib/store";
import { requireAuth, parseBody } from "@/lib/http";
import { createPostSchema } from "@/lib/validation";
async function checkAuth(): Promise<boolean> {
const cookieStore = await cookies();
return cookieStore.get("admin_session")?.value === "authenticated";
}
export async function GET(request: NextRequest) {
const deny = await requireAuth();
if (deny) return deny;
export async function GET() {
if (!(await checkAuth())) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
const { searchParams } = new URL(request.url);
// 如果有分页参数,使用分页查询
if (searchParams.has("page") || searchParams.has("search") || searchParams.has("status") || searchParams.has("sortBy")) {
const result = await getPostsPaginated({
page: Number(searchParams.get("page")) || 1,
pageSize: Number(searchParams.get("pageSize")) || 20,
status: searchParams.get("status") as "draft" | "published" | undefined,
search: searchParams.get("search") || undefined,
sortBy: (searchParams.get("sortBy") as "date" | "createdAt" | "title" | "readingTime") || "createdAt",
sortDir: (searchParams.get("sortDir") as "asc" | "desc") || "desc",
});
return NextResponse.json(result);
}
const posts = getPosts();
return NextResponse.json(posts);
// 兼容旧接口:无参数时返回全量
return NextResponse.json(await getPosts());
}
export async function POST(request: NextRequest) {
if (!(await checkAuth())) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const body = await request.json();
const post = createPost(body);
const deny = await requireAuth();
if (deny) return deny;
const parsed = await parseBody(request, createPostSchema);
if (!parsed.ok) return parsed.response;
const post = await createPost(parsed.data);
return NextResponse.json(post, { status: 201 });
}
export async function DELETE(request: NextRequest) {
if (!(await checkAuth())) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const deny = await requireAuth();
if (deny) return deny;
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
const ok = deletePost(id);
return NextResponse.json({ ok });
return NextResponse.json({ ok: await deletePost(id) });
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getStats } from "@/lib/store";
import { requireAuth } from "@/lib/http";
export async function GET() {
const deny = await requireAuth();
if (deny) return deny;
const stats = await getStats();
return NextResponse.json(stats);
}
+13 -21
View File
@@ -1,35 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getTags, createTag, deleteTag } from "@/lib/store";
async function checkAuth(): Promise<boolean> {
const cookieStore = await cookies();
return cookieStore.get("admin_session")?.value === "authenticated";
}
import { requireAuth, parseBody } from "@/lib/http";
import { tagSchema } from "@/lib/validation";
export async function GET() {
if (!(await checkAuth())) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
return NextResponse.json(getTags());
const deny = await requireAuth();
if (deny) return deny;
return NextResponse.json(await getTags());
}
export async function POST(request: NextRequest) {
if (!(await checkAuth())) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const body = await request.json();
const tag = createTag(body);
return NextResponse.json(tag, { status: 201 });
const deny = await requireAuth();
if (deny) return deny;
const parsed = await parseBody(request, tagSchema);
if (!parsed.ok) return parsed.response;
return NextResponse.json(await createTag(parsed.data), { status: 201 });
}
export async function DELETE(request: NextRequest) {
if (!(await checkAuth())) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const deny = await requireAuth();
if (deny) return deny;
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
const ok = deleteTag(id);
return NextResponse.json({ ok });
return NextResponse.json({ ok: await deleteTag(id) });
}
+9 -3
View File
@@ -1,4 +1,5 @@
import { posts } from "@/data/posts";
import { Suspense } from "react";
import { getPublishedPosts } from "@/lib/store";
import BlogList from "@/components/BlogList";
export const metadata = {
@@ -6,6 +7,11 @@ export const metadata = {
description: "胡旭的博客文章 — 技术、随笔、旅行、阅读",
};
export default function BlogPage() {
return <BlogList posts={posts} />;
export default async function BlogPage() {
const posts = await getPublishedPosts();
return (
<Suspense fallback={null}>
<BlogList posts={posts} />
</Suspense>
);
}
+54 -34
View File
@@ -1,5 +1,5 @@
import Link from "next/link";
import { categories, posts } from "@/data/posts";
import { getPublicCategories, getPostsByCategory } from "@/lib/store";
import GsapReveal from "@/components/GsapReveal";
export const metadata = {
@@ -15,7 +15,17 @@ const categoryIcons: Record<string, string> = {
: "M13 10V3L4 14h7v7l9-11h-7z",
};
export default function CategoriesPage() {
export default async function CategoriesPage() {
const categories = await getPublicCategories();
// 预取每个分类的文章,避免在 JSX 中使用 await
const categoriesWithPosts = await Promise.all(
categories.map(async (cat) => ({
...cat,
posts: (await getPostsByCategory(cat.name)).slice(0, 3),
}))
);
return (
<div className="px-page max-w-5xl mx-auto pt-16 pb-24">
<GsapReveal variant="fade-up" className="mb-14">
@@ -27,42 +37,52 @@ export default function CategoriesPage() {
</p>
</GsapReveal>
<GsapReveal variant="fade-up" stagger={0.12} className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-20">
{categories.map((cat) => {
const icon = categoryIcons[cat.name] || categoryIcons["随笔"];
const catPosts = posts.filter((p) => p.category === cat.name);
return (
<div
key={cat.name}
className="group relative p-7 rounded-2xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 hover:shadow-lg hover:shadow-terracotta/5 transition-all duration-500"
>
<div className="w-10 h-10 rounded-xl bg-parchment-deep flex items-center justify-center mb-5 group-hover:bg-terracotta/10 transition-colors duration-300">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-ink-muted group-hover:text-terracotta transition-colors duration-300">
<path d={icon} />
</svg>
</div>
<div className="flex items-baseline gap-2 mb-2">
<h2 className="font-display text-2xl font-medium text-ink">{cat.name}</h2>
<span className="font-sans text-xs text-ink-muted">{cat.count} </span>
</div>
<p className="font-body text-sm text-ink-muted leading-relaxed mb-5">
{cat.description}
</p>
<div className="space-y-2">
{catPosts.slice(0, 3).map((post) => (
{categoriesWithPosts.length > 0 ? (
<GsapReveal variant="fade-up" stagger={0.12} className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-20">
{categoriesWithPosts.map((cat) => {
const icon = categoryIcons[cat.name] || categoryIcons["随笔"];
return (
<div
key={cat.id}
className="group relative p-7 rounded-2xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 hover:shadow-lg hover:shadow-terracotta/5 transition-all duration-500"
>
<div className="w-10 h-10 rounded-xl bg-parchment-deep flex items-center justify-center mb-5 group-hover:bg-terracotta/10 transition-colors duration-300">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-ink-muted group-hover:text-terracotta transition-colors duration-300">
<path d={icon} />
</svg>
</div>
<div className="flex items-baseline gap-2 mb-2">
<Link
key={post.slug}
href={`/posts/${post.slug}`}
className="block font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-200 truncate"
href={`/blog?category=${encodeURIComponent(cat.name)}`}
className="font-display text-2xl font-medium text-ink hover:text-terracotta transition-colors duration-300"
>
{post.title}
{cat.name}
</Link>
))}
<span className="font-sans text-xs text-ink-muted">{cat.count} </span>
</div>
<p className="font-body text-sm text-ink-muted leading-relaxed mb-5">
{cat.description}
</p>
<div className="space-y-2">
{cat.posts.map((post) => (
<Link
key={post.slug}
href={`/posts/${post.slug}`}
className="block font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-200 truncate"
>
{post.title}
</Link>
))}
</div>
</div>
</div>
);
})}
</GsapReveal>
);
})}
</GsapReveal>
) : (
<div className="py-24 text-center">
<p className="font-display text-2xl text-ink-muted mb-2"></p>
</div>
)}
</div>
);
}
+102 -6
View File
@@ -1,5 +1,8 @@
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400;1,500&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&family=Source+Serif+4:ital,wght@0,300;0,400;0,500;0,600;1,400&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500&display=swap');
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
/* ── Design Tokens ── */
@theme inline {
@@ -16,17 +19,57 @@
--color-warm-gray: #C5BDB4;
--color-cream: #FAF9F7;
/* Typography — 宋式 serif priority */
--font-display: "Noto Serif SC", "Cormorant Garamond", "Source Han Serif SC", "Songti SC", serif;
--font-body: "Noto Serif SC", "Source Serif 4", "Source Han Serif SC", "Songti SC", serif;
--font-sans: "Noto Sans SC", "DM Sans", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
/* Typography — 宋式 serif priority
next/font 注入的 CSS 变量优先,回退到本地系统宋体。 */
--font-display: var(--font-noto-serif), "Cormorant Garamond", "Source Han Serif SC", "Songti SC", serif;
--font-body: var(--font-noto-serif), "Source Han Serif SC", "Songti SC", serif;
--font-sans: var(--font-sans);
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
/* Spacing scale */
--spacing-page: clamp(1.5rem, 5vw, 6rem);
/* Transitions */
--ease-literary: cubic-bezier(0.25, 0.1, 0.25, 1);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/* ── Base Styles ── */
@@ -168,6 +211,11 @@ body::before {
animation: fade-in 0.5s var(--ease-literary) both;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ── Ink brush underline decoration ── */
.ink-underline {
position: relative;
@@ -204,3 +252,51 @@ body::before {
height: 1px;
background: linear-gradient(to right, transparent, var(--color-warm-gray), transparent);
}
:root {
/* 水墨纸质风格 — 映射到 shadcn CSS 变量 */
--background: #FDFCFA; /* parchment */
--foreground: #050404; /* ink */
--card: #FAF9F7; /* cream */
--card-foreground: #050404;
--popover: #FAF9F7;
--popover-foreground: #050404;
--primary: #A63D2F; /* terracotta */
--primary-foreground: #FDFCFA;
--secondary: #F5F2EE; /* parchment-deep */
--secondary-foreground: #050404;
--muted: #C5BDB4; /* warm-gray */
--muted-foreground: #2A2624; /* ink-muted */
--accent: #6E8264; /* sage */
--accent-foreground: #FDFCFA;
--destructive: #B91C1C;
--border: #C5BDB433; /* warm-gray/20 */
--input: #C5BDB433;
--ring: #A63D2F; /* terracotta */
--chart-1: #A63D2F;
--chart-2: #6E8264;
--chart-3: #C46B5E;
--chart-4: #A3B59B;
--chart-5: #2A2624;
--radius: 0.75rem;
--sidebar: #F5F2EE;
--sidebar-foreground: #050404;
--sidebar-primary: #A63D2F;
--sidebar-primary-foreground: #FDFCFA;
--sidebar-accent: #FAF9F7;
--sidebar-accent-foreground: #050404;
--sidebar-border: #C5BDB433;
--sidebar-ring: #A63D2F;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+64 -3
View File
@@ -1,15 +1,68 @@
import type { Metadata } from "next";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { Noto_Serif_SC, Noto_Sans_SC, Cormorant_Garamond, Geist } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz";
// ── 字体:用 next/font 自托管,避免 @import 阻塞渲染 ──
// Cormorant 仅含拉丁字符,display: swap 防止 FOIT
const cormorant = Cormorant_Garamond({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
style: ["normal", "italic"],
display: "swap",
variable: "--font-cormorant",
});
// Noto Serif SC / Sans SC 体积大,预连接 + swap
const notoSerif = Noto_Serif_SC({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
display: "swap",
variable: "--font-noto-serif",
preload: false,
});
const notoSans = Noto_Sans_SC({
subsets: ["latin"],
weight: ["300", "400", "500"],
display: "swap",
variable: "--font-noto-sans",
preload: false,
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: "随 · asui.xyz",
template: "%s | 随",
},
description: "胡旭的个人博客 — 记录技术探索、生活感悟与创业路上的点滴",
keywords: ["博客", "技术", "生活", "创业", "Web开发", "AI"],
keywords: ["博客", "技术", "生活", "创业", "Web开发", "AI", "前端"],
authors: [{ name: "胡旭", url: SITE_URL }],
creator: "胡旭",
openGraph: {
type: "website",
locale: "zh_CN",
url: SITE_URL,
siteName: "随 · asui.xyz",
title: "随 · asui.xyz",
description: "胡旭的个人博客 — 记录技术探索、生活感悟与创业路上的点滴",
},
twitter: {
card: "summary_large_image",
title: "随 · asui.xyz",
description: "胡旭的个人博客 — 记录技术探索、生活感悟与创业路上的点滴",
},
robots: {
index: true,
follow: true,
},
};
export default function RootLayout({
@@ -18,10 +71,18 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" className="h-full">
<html lang="zh-CN" className={cn("h-full", notoSerif.variable, notoSans.variable, cormorant.variable, "font-sans", geist.variable)}>
<body className="min-h-full flex flex-col">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:top-3 focus:left-3 focus:px-4 focus:py-2 focus:rounded-lg focus:bg-ink focus:text-cream focus:font-sans focus:text-sm"
>
</a>
<Header />
<main className="flex-1">{children}</main>
<main id="main-content" className="flex-1">
{children}
</main>
<Footer />
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
import Link from "next/link";
export const metadata = {
title: "页面未找到",
};
export default function NotFound() {
return (
<div className="px-page max-w-5xl mx-auto pt-24 pb-32 text-center">
<p className="font-display text-7xl md:text-9xl font-light text-ink-muted/30 tracking-tight">
</p>
<h1 className="mt-8 font-display text-3xl md:text-4xl font-light text-ink">
</h1>
<p className="mt-4 font-body text-ink-muted max-w-md mx-auto leading-relaxed">
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
<Link
href="/"
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-ink text-cream font-sans text-sm tracking-wide hover:bg-terracotta transition-colors duration-300"
>
</Link>
<Link
href="/blog"
className="inline-flex items-center gap-2 px-6 py-3 rounded-full border border-warm-gray/30 text-ink-muted font-sans text-sm tracking-wide hover:border-terracotta/40 hover:text-terracotta transition-colors duration-300"
>
</Link>
</div>
</div>
);
}
+3 -2
View File
@@ -1,8 +1,9 @@
import { posts } from "@/data/posts";
import { getPublishedPosts } from "@/lib/store";
import HeroSection from "@/components/HeroSection";
import { FeaturedGrid, RecentList } from "@/components/PostSections";
export default function HomePage() {
export default async function HomePage() {
const posts = await getPublishedPosts();
const featuredPosts = posts.filter((p) => p.featured);
const recentPosts = posts.slice(0, 5);
+55 -8
View File
@@ -1,29 +1,76 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { posts } from "@/data/posts";
import { getPostBySlug, getPublishedPosts } from "@/lib/store";
import PostContent from "@/components/PostContent";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz";
export async function generateStaticParams() {
const posts = await getPublishedPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = posts.find((p) => p.slug === slug);
const post = await getPostBySlug(slug);
if (!post) return {};
const url = `${SITE_URL}/posts/${post.slug}`;
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: url },
openGraph: {
title: post.title,
description: post.excerpt,
url,
type: "article",
publishedTime: post.date,
authors: ["胡旭"],
tags: post.tags,
siteName: "随 · asui.xyz",
locale: "zh_CN",
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
},
};
}
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = posts.find((p) => p.slug === slug);
const post = await getPostBySlug(slug);
if (!post) notFound();
const currentIndex = posts.findIndex((p) => p.slug === slug);
const prevPost = currentIndex > 0 ? posts[currentIndex - 1] : null;
const nextPost = currentIndex < posts.length - 1 ? posts[currentIndex + 1] : null;
const all = await getPublishedPosts();
const currentIndex = all.findIndex((p) => p.slug === slug);
const prevPost = currentIndex > 0 ? all[currentIndex - 1] : null;
const nextPost = currentIndex < all.length - 1 ? all[currentIndex + 1] : null;
return <PostContent post={post} prevPost={prevPost} nextPost={nextPost} />;
// BlogPosting 结构化数据,利于搜索引擎富摘要
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.excerpt,
datePublished: post.date,
dateModified: post.updatedAt,
author: { "@type": "Person", name: "胡旭", url: SITE_URL },
publisher: { "@type": "Person", name: "胡旭" },
mainEntityOfPage: `${SITE_URL}/posts/${post.slug}`,
keywords: post.tags.join(", "),
};
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<PostContent post={post} prevPost={prevPost} nextPost={nextPost} />
</>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/admin", "/api"],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
};
}
+43
View File
@@ -0,0 +1,43 @@
import type { MetadataRoute } from "next";
import { getPublishedPosts, getPublicCategories, getAllTags } from "@/lib/store";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticRoutes: MetadataRoute.Sitemap = [
{ url: SITE_URL, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
{ url: `${SITE_URL}/blog`, changeFrequency: "weekly", priority: 0.9 },
{ url: `${SITE_URL}/categories`, changeFrequency: "monthly", priority: 0.6 },
{ url: `${SITE_URL}/tags`, changeFrequency: "monthly", priority: 0.6 },
{ url: `${SITE_URL}/about`, changeFrequency: "yearly", priority: 0.5 },
];
const [publishedPosts, tags, categories] = await Promise.all([
getPublishedPosts(),
getAllTags(),
getPublicCategories(),
]);
const postRoutes: MetadataRoute.Sitemap = publishedPosts.map((post) => ({
url: `${SITE_URL}/posts/${post.slug}`,
lastModified: new Date(post.updatedAt || post.date),
changeFrequency: "monthly",
priority: 0.8,
}));
const tagRoutes: MetadataRoute.Sitemap = tags.map((tag) => ({
url: `${SITE_URL}/blog?tag=${encodeURIComponent(tag.name)}`,
changeFrequency: "monthly",
priority: 0.4,
}));
const categoryRoutes: MetadataRoute.Sitemap = categories
.filter((c) => c.count > 0)
.map((cat) => ({
url: `${SITE_URL}/blog?category=${encodeURIComponent(cat.name)}`,
changeFrequency: "monthly" as const,
priority: 0.4,
}));
return [...staticRoutes, ...postRoutes, ...tagRoutes, ...categoryRoutes];
}
+28 -23
View File
@@ -1,5 +1,4 @@
import Link from "next/link";
import { allTags } from "@/data/posts";
import { getAllTags } from "@/lib/store";
import GsapReveal from "@/components/GsapReveal";
export const metadata = {
@@ -7,8 +6,9 @@ export const metadata = {
description: "按标签浏览博客文章",
};
export default function TagsPage() {
const maxCount = Math.max(...allTags.map((t) => t.count));
export default async function TagsPage() {
const allTags = await getAllTags();
const maxCount = allTags.length > 0 ? Math.max(...allTags.map((t) => t.count)) : 1;
function getTagSize(count: number) {
const ratio = count / maxCount;
@@ -19,8 +19,7 @@ export default function TagsPage() {
}
function getTagWeight(count: number) {
const ratio = count / maxCount;
return ratio >= 0.5 ? "text-ink" : "text-ink-muted";
return count / maxCount >= 0.5 ? "text-ink" : "text-ink-muted";
}
return (
@@ -34,24 +33,30 @@ export default function TagsPage() {
</p>
</GsapReveal>
<GsapReveal variant="scale" stagger={0.04} className="max-w-3xl">
<div className="flex flex-wrap gap-3 md:gap-4 items-center">
{allTags.map((tag) => (
<Link
key={tag.name}
href="/blog"
className="group inline-flex items-center gap-1.5 px-4 py-2 rounded-full border border-warm-gray/15 bg-cream hover:border-terracotta hover:bg-terracotta/5 transition-all duration-300"
>
<span className={`font-display ${getTagSize(tag.count)} ${getTagWeight(tag.count)} group-hover:text-terracotta transition-colors duration-300`}>
{tag.name}
</span>
<span className="font-sans text-xs text-ink-muted group-hover:text-terracotta transition-colors duration-300">
{tag.count}
</span>
</Link>
))}
{allTags.length > 0 ? (
<GsapReveal variant="scale" stagger={0.04} className="max-w-3xl">
<div className="flex flex-wrap gap-3 md:gap-4 items-center">
{allTags.map((tag) => (
<a
key={tag.name}
href={`/blog?tag=${encodeURIComponent(tag.name)}`}
className="group inline-flex items-center gap-1.5 px-4 py-2 rounded-full border border-warm-gray/15 bg-cream hover:border-terracotta hover:bg-terracotta/5 transition-all duration-300"
>
<span className={`font-display ${getTagSize(tag.count)} ${getTagWeight(tag.count)} group-hover:text-terracotta transition-colors duration-300`}>
{tag.name}
</span>
<span className="font-sans text-xs text-ink-muted group-hover:text-terracotta transition-colors duration-300">
{tag.count}
</span>
</a>
))}
</div>
</GsapReveal>
) : (
<div className="py-24 text-center">
<p className="font-display text-2xl text-ink-muted mb-2"></p>
</div>
</GsapReveal>
)}
<GsapReveal variant="fade-in" className="mt-20">
<div className="divider-ornament">
+185 -101
View File
@@ -1,128 +1,212 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
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" });
interface BlogListProps {
posts: PublicPost[];
}
export default function BlogList({ posts }: { posts: Post[] }) {
const headerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
export default function BlogList({ posts }: BlogListProps) {
const router = useRouter();
const searchParams = useSearchParams();
const activeCategory = searchParams.get("category") || "";
const activeTag = searchParams.get("tag") || "";
useEffect(() => {
const ctxs: gsap.Context[] = [];
// Header animation
if (headerRef.current) {
const ctx = gsap.context(() => {
gsap.from(".blog-header-el", {
y: 30,
opacity: 0,
duration: 0.7,
stagger: 0.1,
ease: "power3.out",
});
}, headerRef.current);
ctxs.push(ctx);
}
// List items stagger on scroll
if (listRef.current) {
const ctx = gsap.context(() => {
gsap.from(".blog-list-item", {
y: 40,
opacity: 0,
duration: 0.7,
stagger: 0.1,
ease: "power3.out",
scrollTrigger: {
trigger: listRef.current,
start: "top 88%",
},
});
}, listRef.current);
ctxs.push(ctx);
}
return () => ctxs.forEach((c) => c.revert());
const headerRef = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
if (isReduced) return;
gsap.from(".blog-header-el", {
y: 30,
opacity: 0,
duration: 0.7,
stagger: 0.1,
ease: "power3.out",
});
}, []);
const listRef = useGsapAnimation<HTMLDivElement>((scope, isReduced) => {
if (isReduced) return;
gsap.from(".blog-list-item", {
y: 40,
opacity: 0,
duration: 0.7,
stagger: 0.08,
ease: "power3.out",
scrollTrigger: { trigger: scope, start: "top 88%" },
});
}, [activeCategory, activeTag]);
// 从文章动态聚合分类与标签
const { categories, tags } = useMemo(() => {
const catSet = new Map<string, number>();
const tagSet = new Map<string, number>();
for (const p of posts) {
catSet.set(p.category, (catSet.get(p.category) || 0) + 1);
for (const t of p.tags) tagSet.set(t, (tagSet.get(t) || 0) + 1);
}
return {
categories: [...catSet.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name),
tags: [...tagSet.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name),
};
}, [posts]);
const filtered = useMemo(() => {
return posts.filter((p) => {
if (activeCategory && p.category !== activeCategory) return false;
if (activeTag && !p.tags.includes(activeTag)) return false;
return true;
});
}, [posts, activeCategory, activeTag]);
function setFilter(key: "category" | "tag", value: string) {
const params = new URLSearchParams(searchParams.toString());
if (value) params.set(key, value);
else params.delete(key);
// 切换一个维度时清除另一个,避免组合空结果困惑
if (key === "category") params.delete("tag");
if (key === "tag") params.delete("category");
const qs = params.toString();
router.push(qs ? `/blog?${qs}` : "/blog", { scroll: false });
}
const hasFilter = Boolean(activeCategory || activeTag);
return (
<div className="px-page max-w-5xl mx-auto pt-16 pb-24">
{/* Header */}
<div ref={headerRef} className="mb-14">
<div ref={headerRef as React.RefObject<HTMLDivElement>} className="mb-10">
<h1 className="blog-header-el font-display text-4xl md:text-5xl font-light text-ink tracking-tight">
</h1>
<p className="blog-header-el mt-3 font-body text-ink-muted max-w-md">
{" "}
<Link href="/categories" className="text-terracotta hover:underline underline-offset-2">
</Link>{" "}
{" "}
<Link href="/tags" className="text-terracotta hover:underline underline-offset-2">
</Link>{" "}
</p>
</div>
{/* Post list */}
<div ref={listRef} className="space-y-0">
{posts.map((post) => (
<Link
key={post.slug}
href={`/posts/${post.slug}`}
className="blog-list-item group block"
>
<article className="relative py-8 border-b border-warm-gray/10 hover:bg-cream -mx-6 px-6 rounded-xl transition-all duration-400">
<div className="flex flex-col md:flex-row md:items-start gap-3 md:gap-8">
<div className="shrink-0 md:w-36 md:pt-1">
<time className="font-sans text-sm text-ink-muted tabular-nums">
{formatDate(post.date)}
</time>
<div className="mt-1 flex items-center gap-2 md:flex-col md:items-start md:gap-1">
<span className="font-sans text-sm text-terracotta">
{post.category}
</span>
<span className="hidden md:block font-sans text-sm text-ink-muted">
{post.readingTime} min
</span>
</div>
</div>
<div className="flex-1 min-w-0">
<h2 className="font-display text-xl md:text-2xl font-medium text-ink group-hover:text-terracotta transition-colors duration-300 leading-snug">
{post.title}
</h2>
<p className="mt-2 font-body text-base text-ink-muted leading-relaxed line-clamp-2 max-w-xl">
{post.excerpt}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{post.tags.slice(0, 4).map((tag) => (
<span key={tag} className="font-sans text-xs px-2.5 py-0.5 rounded-full bg-parchment-deep text-ink-muted">
{tag}
</span>
))}
</div>
</div>
<div className="hidden md:flex shrink-0 items-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 pt-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="text-terracotta">
<path d="M7 12h10M13 8l4 4-4 4" />
</svg>
</div>
</div>
</article>
</Link>
))}
{/* Filters */}
<div className="mb-10 space-y-4">
{/* Category filter */}
<div className="flex flex-wrap items-center gap-2">
<span className="font-sans text-xs text-ink-muted tracking-widest uppercase mr-1"></span>
<FilterChip active={!activeCategory} onClick={() => setFilter("category", "")}>
</FilterChip>
{categories.map((cat) => (
<FilterChip key={cat} active={activeCategory === cat} onClick={() => setFilter("category", cat)}>
{cat}
</FilterChip>
))}
</div>
{/* Tag filter */}
<div className="flex flex-wrap items-center gap-2">
<span className="font-sans text-xs text-ink-muted tracking-widest uppercase mr-1"></span>
<FilterChip active={!activeTag} onClick={() => setFilter("tag", "")}>
</FilterChip>
{tags.slice(0, 12).map((tag) => (
<FilterChip key={tag} active={activeTag === tag} onClick={() => setFilter("tag", tag)}>
{tag}
</FilterChip>
))}
</div>
</div>
{/* Result meta */}
<div className="mb-6 font-sans text-sm text-ink-muted">
{hasFilter ? (
<span>
{activeCategory && <><span className="text-terracotta">{activeCategory}</span> </>}
{activeTag && <><span className="text-terracotta">#{activeTag}</span> </>}
· {filtered.length}
<button onClick={() => router.push("/blog", { scroll: false })} className="ml-3 text-ink-muted hover:text-terracotta transition-colors underline underline-offset-2">
</button>
</span>
) : (
<span> {filtered.length} </span>
)}
</div>
{/* Post list */}
{filtered.length > 0 ? (
<div ref={listRef as React.RefObject<HTMLDivElement>} className="space-y-0">
{filtered.map((post) => (
<Link key={post.slug} href={`/posts/${post.slug}`} className="blog-list-item group block">
<article className="relative py-8 border-b border-warm-gray/10 hover:bg-cream -mx-4 px-4 rounded-xl transition-all duration-300">
<div className="flex flex-col md:flex-row md:items-start gap-3 md:gap-8">
<div className="shrink-0 md:w-36 md:pt-1">
<time className="font-sans text-sm text-ink-muted tabular-nums">
{formatDate(post.date)}
</time>
<div className="mt-1 flex items-center gap-2 md:flex-col md:items-start md:gap-1">
<span className="font-sans text-sm text-terracotta">{post.category}</span>
<span className="hidden md:block font-sans text-sm text-ink-muted">
{readingTimeLabel(post.readingTime)}
</span>
</div>
</div>
<div className="flex-1 min-w-0">
<h2 className="font-display text-xl md:text-2xl font-medium text-ink group-hover:text-terracotta transition-colors duration-300 leading-snug">
{post.title}
</h2>
<p className="mt-2 font-body text-base text-ink-muted leading-relaxed line-clamp-2 max-w-xl">
{post.excerpt}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{post.tags.slice(0, 4).map((tag) => (
<span key={tag} className="font-sans text-xs px-2.5 py-0.5 rounded-full bg-parchment-deep text-ink-muted">
{tag}
</span>
))}
</div>
</div>
<div className="hidden md:flex shrink-0 items-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 pt-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="text-terracotta">
<path d="M7 12h10M13 8l4 4-4 4" />
</svg>
</div>
</div>
</article>
</Link>
))}
</div>
) : (
<div className="py-24 text-center">
<p className="font-display text-2xl text-ink-muted mb-2"></p>
<p className="font-sans text-sm text-ink-muted"></p>
</div>
)}
</div>
);
}
function FilterChip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`font-sans text-xs px-3 py-1.5 rounded-full border transition-colors duration-300 ${
active
? "bg-ink text-cream border-ink"
: "border-warm-gray/20 text-ink-muted hover:border-terracotta/40 hover:text-terracotta"
}`}
>
{children}
</button>
);
}
+65 -11
View File
@@ -7,7 +7,10 @@ export default function Footer() {
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-8">
{/* Left - brand */}
<div>
<Link href="/" className="font-display text-xl font-semibold text-ink hover:text-terracotta transition-colors duration-300">
<Link
href="/"
className="font-display text-xl font-semibold text-ink hover:text-terracotta transition-colors duration-300"
>
· asui.xyz
</Link>
<p className="mt-2 font-sans text-sm text-ink-muted max-w-xs leading-relaxed">
@@ -22,16 +25,28 @@ export default function Footer() {
</h4>
<div className="flex flex-col gap-2">
<Link href="/blog" className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300">
<Link
href="/blog"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
</Link>
<Link href="/categories" className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300">
<Link
href="/categories"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
</Link>
<Link href="/tags" className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300">
<Link
href="/tags"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
</Link>
<Link href="/about" className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300">
<Link
href="/about"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
</Link>
</div>
@@ -41,14 +56,43 @@ export default function Footer() {
</h4>
<div className="flex flex-col gap-2">
<a href="https://github.com/huxu" target="_blank" rel="noopener noreferrer" className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300">
GitHub
<a
href="http://gitea.asui.xyz/huxu"
target="_blank"
rel="noopener noreferrer"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
Gitea
</a>
<a href="mailto:hi@asui.xyz" className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300">
<a
href="mailto:arieshuxu@163.com"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
Email
</a>
</div>
</div>
<div>
<h4 className="font-sans text-xs text-ink-muted tracking-widest uppercase mb-3">
</h4>
<div className="flex flex-col gap-2">
{/* <a
href="http://gitea.asui.xyz/huxu"
target="_blank"
rel="noopener noreferrer"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
Gitea
</a>
<a
href="mailto:arieshuxu@163.com"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
Email
</a> */}
</div>
</div>
</div>
</div>
@@ -57,9 +101,19 @@ export default function Footer() {
<p className="font-sans text-xs text-ink-muted">
&copy; {new Date().getFullYear()} . All rights reserved.
</p>
<p className="font-sans text-xs text-ink-muted">
Powered by <span className="text-terracotta">Next.js</span> & <span className="text-terracotta">Halo</span>
</p>
<div className="flex items-center gap-4 font-sans text-xs text-ink-muted">
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
className="hover:text-terracotta transition-colors duration-300"
>
ICP备2024032972号-3
</a>
<span>
Powered by <span className="text-terracotta">Next.js</span>
</span>
</div>
</div>
</div>
</footer>
+19 -16
View File
@@ -16,6 +16,14 @@ interface GsapRevealProps {
once?: boolean;
}
const VARIANTS = {
"fade-up": { y: 40, opacity: 0 },
"fade-in": { opacity: 0 },
"slide-left": { x: -40, opacity: 0 },
"slide-right": { x: 40, opacity: 0 },
scale: { scale: 0.92, opacity: 0 },
} as const;
export default function GsapReveal({
children,
variant = "fade-up",
@@ -28,25 +36,20 @@ export default function GsapReveal({
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const el = ref.current;
const children = el.children.length > 1 ? el.children : [el];
if (!el) return;
const variants = {
"fade-up": { y: 40, opacity: 0 },
"fade-in": { opacity: 0 },
"slide-left": { x: -40, opacity: 0 },
"slide-right": { x: 40, opacity: 0 },
scale: { scale: 0.92, opacity: 0 },
};
// 关键降级:尊重用户系统设置,无障碍优先,不做任何位移。
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) return;
const targets = el.children.length > 1 ? Array.from(el.children) : [el];
const ctx = gsap.context(() => {
if (stagger > 0 && children.length > 1) {
// Each child gets its own ScrollTrigger so off-screen items animate when scrolled into view
Array.from(children).forEach((child, i) => {
if (stagger > 0 && targets.length > 1) {
targets.forEach((child, i) => {
gsap.from(child, {
...variants[variant],
...VARIANTS[variant],
duration,
delay: delay + i * stagger,
ease: "power3.out",
@@ -58,8 +61,8 @@ export default function GsapReveal({
});
});
} else {
gsap.from(children, {
...variants[variant],
gsap.from(targets, {
...VARIANTS[variant],
duration,
delay,
ease: "power3.out",
+34 -24
View File
@@ -19,7 +19,7 @@ export default function Header() {
return (
<header className="sticky top-0 z-50 backdrop-blur-md bg-parchment/80 border-b border-warm-gray/20">
<div className="mx-auto px-page max-w-5xl">
<nav className="flex items-center justify-between h-16">
<nav className="flex items-center justify-between h-16" aria-label="主导航">
{/* Logo */}
<Link href="/" className="group flex items-center gap-2">
<span className="font-display text-2xl font-semibold tracking-wide text-ink group-hover:text-terracotta transition-colors duration-300">
@@ -40,6 +40,7 @@ export default function Header() {
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={`
relative px-4 py-2 font-sans text-sm tracking-wide transition-colors duration-300
${isActive ? "text-terracotta" : "text-ink-muted hover:text-ink"}
@@ -58,7 +59,9 @@ export default function Header() {
<button
onClick={() => setMenuOpen(!menuOpen)}
className="md:hidden p-2 text-ink-muted hover:text-ink transition-colors"
aria-label="Toggle menu"
aria-label="切换菜单"
aria-expanded={menuOpen}
aria-controls="mobile-menu"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
{menuOpen ? (
@@ -77,29 +80,36 @@ export default function Header() {
</button>
</nav>
{/* Mobile menu */}
{menuOpen && (
<div className="md:hidden pb-4 animate-fade-in">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMenuOpen(false)}
className={`
block py-3 px-2 font-sans text-sm tracking-wide border-b border-warm-gray/10 transition-colors duration-300
${isActive ? "text-terracotta" : "text-ink-muted"}
`}
>
{item.label}
</Link>
);
})}
{/* Mobile menu — 用 grid-rows 过渡实现展开/收起动画 */}
<div
id="mobile-menu"
className="md:hidden grid transition-all duration-300 ease-out"
style={{ gridTemplateRows: menuOpen ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<div className="pb-4">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMenuOpen(false)}
aria-current={isActive ? "page" : undefined}
className={`
block py-3 px-2 font-sans text-sm tracking-wide border-b border-warm-gray/10 transition-colors duration-300
${isActive ? "text-terracotta" : "text-ink-muted"}
`}
>
{item.label}
</Link>
);
})}
</div>
</div>
)}
</div>
</div>
</header>
);
+34 -60
View File
@@ -1,72 +1,41 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { useGsapAnimation } from "./useGsapAnimation";
export default function HeroSection() {
const headingRef = useRef<HTMLHeadingElement>(null);
const sectionRef = useRef<HTMLElement>(null);
const ref = useGsapAnimation<HTMLElement>((scope, isReduced) => {
if (isReduced) return;
useEffect(() => {
if (!sectionRef.current) return;
const tl = gsap.timeline({ delay: 0.2 });
const ctx = gsap.context(() => {
const tl = gsap.timeline({ delay: 0.2 });
tl.from(".hero-subtitle", { y: 20, opacity: 0, duration: 0.6, ease: "power3.out" });
// Subtitle fade in
tl.from(".hero-subtitle", {
y: 20,
opacity: 0,
duration: 0.6,
ease: "power3.out",
});
// Heading — split chars and stagger
if (headingRef.current) {
const spans = headingRef.current.querySelectorAll(".hero-char");
tl.from(
spans,
{
y: 40,
opacity: 0,
filter: "blur(6px)",
duration: 0.6,
stagger: 0.035,
ease: "power3.out",
},
"-=0.3"
);
}
// Description
const heading = scope.querySelector<HTMLElement>("[data-heading]");
if (heading) {
const spans = heading.querySelectorAll(".hero-char");
tl.from(
".hero-desc",
{ y: 20, opacity: 0, duration: 0.6, ease: "power3.out" },
spans,
{
y: 40,
opacity: 0,
filter: "blur(6px)",
duration: 0.6,
stagger: 0.035,
ease: "power3.out",
},
"-=0.3"
);
}
// Buttons
tl.from(
".hero-btn",
{ y: 15, opacity: 0, duration: 0.5, stagger: 0.1, ease: "power3.out" },
"-=0.3"
);
// Decorative line
tl.from(".hero-divider", {
scaleX: 0,
opacity: 0,
duration: 0.8,
ease: "power2.inOut",
});
}, sectionRef.current);
return () => ctx.revert();
tl.from(".hero-desc", { y: 20, opacity: 0, duration: 0.6, ease: "power3.out" }, "-=0.3");
tl.from(".hero-btn", { y: 15, opacity: 0, duration: 0.5, stagger: 0.1, ease: "power3.out" }, "-=0.3");
tl.from(".hero-divider", { scaleX: 0, opacity: 0, duration: 0.8, ease: "power2.inOut" });
}, []);
// Split heading into char spans
const headingChars = (text: string, className?: string) =>
// 逐字拆分标题
const headingChars = (text: string) =>
[...text].map((char, i) => (
<span key={i} className="hero-char inline-block" style={char === " " ? { width: "0.3em" } : undefined}>
{char}
@@ -74,23 +43,28 @@ export default function HeroSection() {
));
return (
<section ref={sectionRef} className="px-page max-w-5xl mx-auto pt-20 pb-16 md:pt-28 md:pb-24">
<section ref={ref as React.RefObject<HTMLElement>} className="px-page max-w-5xl mx-auto pt-20 pb-16 md:pt-28 md:pb-24">
<div className="max-w-2xl">
<p className="hero-subtitle font-sans text-sm tracking-widest text-ink-muted uppercase mb-6">
Sui的个人Blog
</p>
<h1
ref={headingRef}
data-heading
className="font-display text-4xl md:text-6xl font-light text-ink leading-tight tracking-tight"
>
{headingChars("写字,")}
<br />
<span className="text-terracotta">{headingChars("是一种思考的方式")}</span>
{/* 仅高亮关键词「思考」,避免整句赭红造成视觉重量过重 */}
<span>
{headingChars("是一种 ")}
<span className="text-terracotta">{headingChars("思考")}</span>
{headingChars(" 的方式")}
</span>
</h1>
<p className="hero-desc mt-6 font-body text-lg text-ink-muted leading-relaxed max-w-lg">
</p>
<div className="mt-8 flex items-center gap-4">
<div className="mt-8 flex flex-wrap items-center gap-4">
<Link
href="/blog"
className="hero-btn inline-flex items-center gap-2 px-6 py-3 rounded-full bg-ink text-cream font-sans text-sm tracking-wide hover:bg-terracotta transition-colors duration-300"
@@ -109,7 +83,7 @@ export default function HeroSection() {
</div>
</div>
{/* Decorative line */}
{/* 装饰分隔线 */}
<div className="hero-divider mt-20 flex items-center gap-4 text-warm-gray origin-center">
<div className="h-px flex-1 bg-gradient-to-r from-warm-gray/20 to-transparent" />
<span className="font-display text-sm italic"></span>
+60 -88
View File
@@ -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>
);
+34 -54
View File
@@ -1,19 +1,15 @@
"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" });
}
function FeaturedCard({ post }: { post: Post }) {
function FeaturedCard({ post }: { post: PublicPost }) {
return (
<Link href={`/posts/${post.slug}`} className="group block featured-card">
<article className="relative p-7 md:p-10 rounded-2xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 hover:shadow-lg hover:shadow-terracotta/5 transition-all duration-500">
@@ -29,7 +25,7 @@ function FeaturedCard({ post }: { post: Post }) {
<div className="flex items-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>
<div className="absolute top-7 right-7 md:top-10 md:right-10 opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-terracotta">
@@ -41,29 +37,23 @@ function FeaturedCard({ post }: { post: Post }) {
);
}
export function FeaturedGrid({ posts }: { posts: Post[] }) {
const ref = useRef<HTMLDivElement>(null);
export function FeaturedGrid({ posts }: { posts: PublicPost[] }) {
const ref = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
if (isReduced || posts.length === 0) return;
gsap.from(".featured-card", {
y: 50,
opacity: 0,
duration: 0.8,
stagger: 0.15,
ease: "power3.out",
scrollTrigger: { trigger: ref.current, start: "top 85%" },
});
}, [posts.length]);
useEffect(() => {
if (!ref.current) return;
const ctx = gsap.context(() => {
gsap.from(".featured-card", {
y: 50,
opacity: 0,
duration: 0.8,
stagger: 0.15,
ease: "power3.out",
scrollTrigger: {
trigger: ref.current,
start: "top 85%",
},
});
}, ref.current);
return () => ctx.revert();
}, []);
if (posts.length === 0) return null;
return (
<section ref={ref} className="px-page max-w-5xl mx-auto pb-16">
<section ref={ref as React.RefObject<HTMLDivElement>} className="px-page max-w-5xl mx-auto pb-16">
<div className="grid md:grid-cols-2 gap-5">
{posts.map((post) => (
<FeaturedCard key={post.slug} post={post} />
@@ -73,39 +63,29 @@ export function FeaturedGrid({ posts }: { posts: Post[] }) {
);
}
export function RecentList({ posts }: { posts: Post[] }) {
const ref = useRef<HTMLDivElement>(null);
export function RecentList({ posts }: { posts: PublicPost[] }) {
const ref = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
if (isReduced || posts.length === 0) return;
gsap.from(".recent-item", {
y: 30,
opacity: 0,
duration: 0.6,
stagger: 0.08,
ease: "power3.out",
scrollTrigger: { trigger: ref.current, start: "top 85%" },
});
}, [posts.length]);
useEffect(() => {
if (!ref.current) return;
const ctx = gsap.context(() => {
gsap.from(".recent-item", {
y: 30,
opacity: 0,
duration: 0.6,
stagger: 0.08,
ease: "power3.out",
scrollTrigger: {
trigger: ref.current,
start: "top 85%",
},
});
}, ref.current);
return () => ctx.revert();
}, []);
if (posts.length === 0) return null;
return (
<section ref={ref} className="px-page max-w-5xl mx-auto pb-24">
<section ref={ref as React.RefObject<HTMLDivElement>} className="px-page max-w-5xl mx-auto pb-24">
<div className="divider-ornament mb-10">
<span className="font-display text-sm italic whitespace-nowrap"></span>
</div>
<div className="space-y-0">
{posts.map((post) => (
<Link
key={post.slug}
href={`/posts/${post.slug}`}
className="recent-item group block"
>
<Link key={post.slug} href={`/posts/${post.slug}`} className="recent-item group block">
<article className="flex items-baseline gap-6 py-7 border-b border-warm-gray/10 hover:bg-cream -mx-4 px-4 rounded-lg transition-all duration-300">
<time className="shrink-0 font-sans text-sm text-ink-muted tabular-nums w-28 pt-0.5">
{formatDate(post.date)}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { createContext, useCallback, useContext, useState } from "react";
import { X } from "lucide-react";
type ToastType = "success" | "error" | "info";
interface Toast {
id: number;
type: ToastType;
message: string;
}
const ToastContext = createContext<{
toast: (message: string, type?: ToastType) => void;
}>({ toast: () => {} });
let nextId = 0;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const toast = useCallback((message: string, type: ToastType = "info") => {
const id = nextId++;
setToasts((prev) => [...prev, { id, type, message }]);
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3500);
}, []);
function dismiss(id: number) {
setToasts((prev) => prev.filter((t) => t.id !== id));
}
return (
<ToastContext value={{ toast }}>
{children}
<div
aria-live="assertive"
role="alert"
className="fixed bottom-6 right-6 z-[200] flex flex-col gap-2 pointer-events-none"
>
{toasts.map((t) => (
<div
key={t.id}
className={`pointer-events-auto flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg font-sans text-sm animate-[toast-in_0.3s_ease-out] ${
t.type === "success"
? "bg-accent text-accent-foreground"
: t.type === "error"
? "bg-red-600 text-white"
: "bg-foreground text-background"
}`}
>
<span className="flex-1">{t.message}</span>
<button
onClick={() => dismiss(t.id)}
className="shrink-0 opacity-70 hover:opacity-100 transition-opacity"
aria-label="关闭"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</ToastContext>
);
}
export function useToast() {
return useContext(ToastContext);
}
/**
* 带自动错误处理的安全 fetch。
* 非 2xx 响应自动弹出错误 toast 并抛出异常,调用方无需自行处理。
*/
export async function safeFetch(
url: string,
options: RequestInit | undefined,
toast: (msg: string, type?: ToastType) => void
): Promise<Response> {
const res = await fetch(url, options);
if (!res.ok) {
let msg = `请求失败 (${res.status})`;
try {
const body = await res.clone().json();
if (body.error) msg = body.error;
if (body.issues) msg += `${body.issues.join("")}`;
} catch {
/* 非 JSON 响应,用默认消息 */
}
toast(msg, "error");
throw new Error(msg);
}
return res;
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface ConfirmDialogProps {
open: boolean;
onConfirm: () => void;
onCancel: () => void;
title: string;
description: string;
confirmText?: string;
variant?: "default" | "destructive";
}
export default function ConfirmDialog({
open,
onConfirm,
onCancel,
title,
description,
confirmText = "确认",
variant = "default",
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onCancel()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
</Button>
<Button
variant={variant === "destructive" ? "destructive" : "default"}
onClick={onConfirm}
>
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+283
View File
@@ -0,0 +1,283 @@
"use client";
import { useState, useEffect, useRef } from "react";
import type { Post, Category, Tag } from "@/lib/store";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import dynamic from "next/dynamic";
const RichEditor = dynamic(() => import("./RichEditor"), { ssr: false });
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
export type PostFormData = Omit<Post, "id" | "createdAt" | "updatedAt">;
interface PostFormProps {
mode: "create" | "edit";
initialData?: Partial<Post>;
categories: Category[];
tags: Tag[];
onSubmit: (data: PostFormData) => Promise<void>;
onCancel: () => void;
}
function autoSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^\w\u4e00-\u9fff]+/g, "-")
.replace(/^-|-$/g, "");
}
export default function PostForm({
mode,
initialData,
categories,
tags,
onSubmit,
onCancel,
}: PostFormProps) {
const [form, setForm] = useState({
title: initialData?.title ?? "",
slug: initialData?.slug ?? "",
excerpt: initialData?.excerpt ?? "",
content: initialData?.content ?? "",
category: initialData?.category ?? "",
tags: initialData?.tags ?? ([] as string[]),
readingTime: initialData?.readingTime ?? 5,
featured: initialData?.featured ?? false,
status: initialData?.status ?? ("draft" as "draft" | "published"),
date: initialData?.date ?? new Date().toISOString().slice(0, 10),
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [dirty, setDirty] = useState(false);
const originalRef = useRef(JSON.stringify(form));
// 离开确认
useEffect(() => {
if (!dirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [dirty]);
// 标记 dirty
useEffect(() => {
if (JSON.stringify(form) !== originalRef.current) {
setDirty(true);
}
}, [form]);
function validate(): boolean {
const errs: Record<string, string> = {};
if (!form.title.trim()) errs.title = "请输入标题";
if (form.title.length > 200) errs.title = "标题不能超过 200 字";
if (!form.content.trim()) errs.content = "请输入内容";
if (!form.category) errs.category = "请选择分类";
if (form.excerpt.length > 500) errs.excerpt = "摘要不能超过 500 字";
if (form.readingTime < 1) errs.readingTime = "阅读时间至少 1 分钟";
setErrors(errs);
return Object.keys(errs).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
setSubmitting(true);
try {
const slug = form.slug || autoSlug(form.title);
await onSubmit({ ...form, slug });
} finally {
setSubmitting(false);
}
}
function toggleTag(tagName: string) {
setForm((prev) => ({
...prev,
tags: prev.tags.includes(tagName)
? prev.tags.filter((t) => t !== tagName)
: [...prev.tags, tagName],
}));
}
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
// 清除对应字段的错误
if (errors[key as string]) {
setErrors((prev) => {
const next = { ...prev };
delete next[key as string];
return next;
});
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* 标题 */}
<div className="space-y-1.5">
<Label htmlFor="post-title"></Label>
<Input
id="post-title"
value={form.title}
onChange={(e) => update("title", e.target.value)}
placeholder="文章标题"
className="font-display text-lg"
/>
{errors.title && <p className="text-xs text-red-600">{errors.title}</p>}
</div>
{/* Slug + 日期 */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="post-slug">Slug</Label>
<Input
id="post-slug"
value={form.slug}
onChange={(e) => update("slug", e.target.value)}
placeholder="my-post-slug"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="post-date"></Label>
<Input
id="post-date"
type="date"
value={form.date}
onChange={(e) => update("date", e.target.value)}
/>
</div>
</div>
{/* 摘要 */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="post-excerpt"></Label>
<span className="text-xs text-muted-foreground">{form.excerpt.length}/500</span>
</div>
<Textarea
id="post-excerpt"
value={form.excerpt}
onChange={(e) => update("excerpt", e.target.value)}
rows={2}
placeholder="文章摘要..."
className="resize-none"
/>
{errors.excerpt && <p className="text-xs text-red-600">{errors.excerpt}</p>}
</div>
{/* 内容 */}
<div className="space-y-1.5">
<Label></Label>
<RichEditor
value={form.content}
onChange={(html) => update("content", html)}
/>
{errors.content && <p className="text-xs text-red-600">{errors.content}</p>}
</div>
{/* 分类 + 阅读时间 */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label></Label>
<Select value={form.category} onValueChange={(v) => update("category", v ?? "")}>
<SelectTrigger>
<SelectValue placeholder="选择分类" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.id} value={c.name}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.category && <p className="text-xs text-red-600">{errors.category}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="post-readingTime"></Label>
<Input
id="post-readingTime"
type="number"
min={1}
max={600}
value={form.readingTime}
onChange={(e) => update("readingTime", Number(e.target.value))}
/>
{errors.readingTime && <p className="text-xs text-red-600">{errors.readingTime}</p>}
</div>
</div>
{/* 标签 */}
<div className="space-y-1.5">
<Label></Label>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.name)}
aria-pressed={form.tags.includes(tag.name)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
form.tags.includes(tag.name)
? "bg-primary/10 border-primary/30 text-primary"
: "border-border text-muted-foreground hover:border-primary/20"
}`}
>
{tag.name}
</button>
))}
</div>
</div>
{/* 精选 + 状态 */}
<div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<Switch
id="post-featured"
checked={form.featured}
onCheckedChange={(checked) => update("featured", checked)}
/>
<Label htmlFor="post-featured" className="cursor-pointer"></Label>
</div>
<RadioGroup
value={form.status}
onValueChange={(v) => update("status", v as "draft" | "published")}
className="flex items-center gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="draft" id="status-draft" />
<Label htmlFor="status-draft" className="cursor-pointer">稿</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="published" id="status-published" />
<Label htmlFor="status-published" className="cursor-pointer"></Label>
</div>
</RadioGroup>
</div>
{/* 操作按钮 */}
<div className="flex gap-3 pt-4">
<Button type="submit" disabled={submitting}>
{submitting ? "保存中..." : mode === "create" ? "保存" : "保存修改"}
</Button>
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
</div>
</form>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from "lowlight";
import { Toggle } from "@/components/ui/toggle";
import {
Bold,
Italic,
Strikethrough,
Code,
Heading1,
Heading2,
Heading3,
Quote,
List,
ListOrdered,
Link as LinkIcon,
ImageIcon,
CodeSquare,
Minus,
Undo2,
Redo2,
} from "lucide-react";
const lowlight = createLowlight(common);
interface RichEditorProps {
value: string;
onChange: (html: string) => void;
placeholder?: string;
}
export default function RichEditor({
value,
onChange,
placeholder = "开始写文章...",
}: RichEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
codeBlock: false,
}),
Image.configure({
HTMLAttributes: { class: "rounded-lg my-4" },
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: "text-primary underline underline-offset-2",
},
}),
Placeholder.configure({ placeholder }),
CodeBlockLowlight.configure({ lowlight }),
],
content: value,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
editorProps: {
attributes: {
class:
"prose-literary min-h-[300px] px-4 py-3 focus:outline-none",
},
},
});
if (!editor) return null;
function addLink() {
const url = window.prompt("输入链接地址:");
if (url) {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.run();
}
}
function addImage() {
const url = window.prompt("输入图片地址:");
if (url) {
editor.chain().focus().setImage({ src: url }).run();
}
}
return (
<div className="rounded-xl border border-border bg-card overflow-hidden">
{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b border-border bg-muted/30">
{/* 格式 */}
<Toggle
size="sm"
pressed={editor.isActive("heading", { level: 1 })}
onPressedChange={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
aria-label="标题 1"
>
<Heading1 className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("heading", { level: 2 })}
onPressedChange={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
aria-label="标题 2"
>
<Heading2 className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("heading", { level: 3 })}
onPressedChange={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
aria-label="标题 3"
>
<Heading3 className="h-4 w-4" />
</Toggle>
<div className="w-px h-5 bg-border mx-1" />
<Toggle
size="sm"
pressed={editor.isActive("bold")}
onPressedChange={() => editor.chain().focus().toggleBold().run()}
aria-label="粗体"
>
<Bold className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("italic")}
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
aria-label="斜体"
>
<Italic className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("strike")}
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
aria-label="删除线"
>
<Strikethrough className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("code")}
onPressedChange={() => editor.chain().focus().toggleCode().run()}
aria-label="行内代码"
>
<Code className="h-4 w-4" />
</Toggle>
<div className="w-px h-5 bg-border mx-1" />
<Toggle
size="sm"
pressed={editor.isActive("blockquote")}
onPressedChange={() =>
editor.chain().focus().toggleBlockquote().run()
}
aria-label="引用"
>
<Quote className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("bulletList")}
onPressedChange={() =>
editor.chain().focus().toggleBulletList().run()
}
aria-label="无序列表"
>
<List className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("orderedList")}
onPressedChange={() =>
editor.chain().focus().toggleOrderedList().run()
}
aria-label="有序列表"
>
<ListOrdered className="h-4 w-4" />
</Toggle>
<div className="w-px h-5 bg-border mx-1" />
<Toggle
size="sm"
pressed={editor.isActive("codeBlock")}
onPressedChange={() =>
editor.chain().focus().toggleCodeBlock().run()
}
aria-label="代码块"
>
<CodeSquare className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("link")}
onPressedChange={addLink}
aria-label="链接"
>
<LinkIcon className="h-4 w-4" />
</Toggle>
<Toggle size="sm" onPressedChange={addImage} aria-label="图片">
<ImageIcon className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
onPressedChange={() =>
editor.chain().focus().setHorizontalRule().run()
}
aria-label="分割线"
>
<Minus className="h-4 w-4" />
</Toggle>
<div className="w-px h-5 bg-border mx-1" />
{/* 撤销/重做 */}
<Toggle
size="sm"
onPressedChange={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
aria-label="撤销"
>
<Undo2 className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
onPressedChange={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
aria-label="重做"
>
<Redo2 className="h-4 w-4" />
</Toggle>
</div>
{/* 编辑区域 */}
<EditorContent editor={editor} />
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+29
View File
@@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+20
View File
@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+38
View File
@@ -0,0 +1,38 @@
"use client"
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "@/lib/utils"
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
)
}
export { RadioGroup, RadioGroupItem }
+201
View File
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+89
View File
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
+45
View File
@@ -0,0 +1,45 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
/**
* GSAP 动画的统一入口。
*
* 核心原则:
* 1. 尊重 prefers-reduced-motion —— 开启时直接跳过动画,内容保持可见。
* 2. 在 gsap.context 内执行,卸载时自动 revert,避免泄露。
*
* 用法:
* const ref = useGsapAnimation<HTMLElement>((scope, isReduced) => {
* if (isReduced) return;
* gsap.from(".item", { y: 40, opacity: 0, stagger: 0.1, scrollTrigger: {...} });
* });
*/
export function useGsapAnimation<T extends HTMLElement = HTMLElement>(
setup: (scope: T, isReduced: boolean) => void,
deps: unknown[] = []
) {
const ref = useRef<T>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const isReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => setup(el, isReduced), el);
return () => ctx.revert();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return ref;
}
+37 -47
View File
@@ -1,17 +1,14 @@
export interface Post {
slug: string;
title: string;
excerpt: string;
content: string;
date: string;
category: string;
tags: string[];
coverImage?: string;
readingTime: number;
featured?: boolean;
}
import type { Post } from "@/lib/store";
export const posts: Post[] = [
/**
* 种子数据来源。仅用于首次初始化(见 src/lib/seed.ts)。
* 前台/后台一律从 store.ts 读取,不再直接 import 本文件。
*
* 这里只声明可由用户填写的字段,id/createdAt/updatedAt 由 store 生成。
*/
export type SeedPost = Omit<Post, "id" | "createdAt" | "updatedAt">;
export const seedPosts: SeedPost[] = [
{
slug: "on-writing-and-silence",
title: "论写作与沉默",
@@ -22,6 +19,7 @@ export const posts: Post[] = [
tags: ["写作", "思考", "生活哲学"],
readingTime: 4,
featured: true,
status: "published",
},
{
slug: "a-walk-in-the-mountains",
@@ -33,6 +31,7 @@ export const posts: Post[] = [
tags: ["旅行", "自然", "六安"],
readingTime: 6,
featured: true,
status: "published",
},
{
slug: "notes-on-digital-twin",
@@ -43,6 +42,8 @@ export const posts: Post[] = [
category: "技术",
tags: ["Web3D", "React", "Three.js", "前端"],
readingTime: 8,
featured: false,
status: "published",
},
{
slug: "reading-list-spring",
@@ -54,6 +55,7 @@ export const posts: Post[] = [
tags: ["阅读", "书单", "生活"],
readingTime: 5,
featured: true,
status: "published",
},
{
slug: "stable-diffusion-local-setup",
@@ -64,6 +66,8 @@ export const posts: Post[] = [
category: "技术",
tags: ["AI", "Stable Diffusion", "Apple Silicon"],
readingTime: 10,
featured: false,
status: "published",
},
{
slug: "lightbox-dream",
@@ -75,6 +79,7 @@ export const posts: Post[] = [
tags: ["创业", "灯箱", "产品", "sui_lightbox"],
readingTime: 7,
featured: true,
status: "published",
},
{
slug: "rainy-day-thoughts",
@@ -85,50 +90,35 @@ export const posts: Post[] = [
category: "随笔",
tags: ["随笔", "生活", "六安"],
readingTime: 3,
featured: false,
status: "published",
},
{
slug: "next-js-blog-from-scratch",
title: "从零搭建一个博客系统",
excerpt: "为什么选择 Next.js + Halo CMS?为什么不用 WordPress?这篇文章聊聊技术选型的思考过程。",
content: `<p>为什么选择 Next.js + Halo CMS?为什么不用 WordPress</p><p>作为一个前端开发者,我希望能完全掌控博客的UI和交互体验。Halo CMS 提供了足够好的内容管理API,而 Next.js 则让我可以自由设计前端展示。</p>`,
excerpt: "为什么选择 Next.js 来搭建?为什么不用 WordPress?这篇文章聊聊技术选型的思考过程。",
content: `<p>为什么选择 Next.js 来搭建?为什么不用 WordPress</p><p>作为一个前端开发者,我希望能完全掌控博客的UI和交互体验。Next.js 的 App Router 与 Server Components 让我可以自由设计前端展示,同时保持良好的性能。数据则存储在本地的 JSON 文件中,简单而透明。</p>`,
date: "2026-04-28",
category: "技术",
tags: ["Next.js", "博客", "Halo CMS", "前端"],
tags: ["Next.js", "博客", "前端"],
readingTime: 6,
featured: false,
status: "published",
},
];
export const categories = [
{ name: "技术", count: 3, description: "代码、架构与技术探索" },
{ name: "随笔", count: 2, description: "生活感悟与碎片思考" },
{ name: "旅行", count: 1, description: "在路上看到的风景与人" },
{ name: "阅读", count: 1, description: "书中世界与阅读心得" },
{ name: "创业", count: 1, description: "产品思考与创业记录" },
export const seedCategories = [
{ name: "技术", description: "代码、架构与技术探索" },
{ name: "随笔", description: "生活感悟与碎片思考" },
{ name: "旅行", description: "在路上看到的风景与人" },
{ name: "阅读", description: "书中世界与阅读心得" },
{ name: "创业", description: "产品思考与创业记录" },
];
export const allTags = [
{ name: "写作", count: 1 },
{ name: "思考", count: 1 },
{ name: "生活哲学", count: 1 },
{ name: "旅行", count: 1 },
{ name: "自然", count: 1 },
{ name: "六安", count: 2 },
{ name: "Web3D", count: 1 },
{ name: "React", count: 1 },
{ name: "Three.js", count: 1 },
{ name: "前端", count: 2 },
{ name: "阅读", count: 1 },
{ name: "书单", count: 1 },
{ name: "生活", count: 2 },
{ name: "AI", count: 1 },
{ name: "Stable Diffusion", count: 1 },
{ name: "Apple Silicon", count: 1 },
{ name: "创业", count: 1 },
{ name: "灯箱", count: 1 },
{ name: "产品", count: 1 },
{ name: "sui_lightbox", count: 1 },
{ name: "随笔", count: 1 },
{ name: "Next.js", count: 1 },
{ name: "博客", count: 1 },
{ name: "Halo CMS", count: 1 },
export const seedTags = [
"写作", "思考", "生活哲学", "旅行", "自然", "六安",
"Web3D", "React", "Three.js", "前端", "阅读", "书单",
"生活", "AI", "Stable Diffusion", "Apple Silicon",
"创业", "灯箱", "产品", "sui_lightbox", "随笔",
"Next.js", "博客",
];
+70
View File
@@ -0,0 +1,70 @@
import { cookies } from "next/headers";
/**
* 认证相关常量与校验函数。
*
* 会话采用 HMAC 签名的 tokenpayload + "." + signature),服务端校验签名,
* 避免使用固定字符串 cookie 被伪造。签名密钥来自环境变量,缺失时回退到
* 开发用默认值(仅用于本地)。
*/
const SESSION_KEY = "admin_session";
function getSecret(): string {
return process.env.SESSION_SECRET || "dev-only-insecure-secret-change-me";
}
/**
* 用 Web Crypto API 计算字符串的 HMAC-SHA256,返回 base64url。
* 不依赖外部库,Node 18+ 与 edge runtime 均可用。
*/
async function hmac(message: string, secret: string): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
const bytes = new Uint8Array(sig);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** 签发一个带过期时间的会话 token。 */
export async function createSession(maxAgeSeconds: number): Promise<string> {
const payload = JSON.stringify({
v: "1",
exp: Date.now() + maxAgeSeconds * 1000,
});
const b64 = btoa(unescape(encodeURIComponent(payload)));
const sig = await hmac(b64, getSecret());
return `${b64}.${sig}`;
}
/** 校验 token 签名与过期时间,通过返回 true。 */
async function verifySession(token: string): Promise<boolean> {
const [b64, sig] = token.split(".");
if (!b64 || !sig) return false;
const expected = await hmac(b64, getSecret());
if (sig !== expected) return false;
try {
const payload = JSON.parse(decodeURIComponent(escape(atob(b64))));
return typeof payload.exp === "number" && payload.exp > Date.now();
} catch {
return false;
}
}
/** 在 Server Component / Route Handler 中检查当前是否已登录。 */
export async function checkAuth(): Promise<boolean> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_KEY)?.value;
if (!token) return false;
return verifySession(token);
}
export { SESSION_KEY };
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { ZodError } from "zod";
import { checkAuth } from "./auth";
/** 统一的未授权响应。 */
export function unauthorized() {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
/** 解析并校验请求体,失败时返回 422 响应(由调用方 return)。 */
export async function parseBody<T>(
request: Request,
schema: { parse: (d: unknown) => T }
): Promise<{ ok: true; data: T } | { ok: false; response: NextResponse }> {
try {
const json = await request.json();
const data = schema.parse(json);
return { ok: true, data };
} catch (err) {
if (err instanceof ZodError) {
return {
ok: false,
response: NextResponse.json(
{ error: "输入校验失败", issues: err.issues.map((i) => i.message) },
{ status: 422 }
),
};
}
return {
ok: false,
response: NextResponse.json({ error: "请求格式错误" }, { status: 400 }),
};
}
}
/** 在受保护路由开头做鉴权,未通过则返回 401 响应。 */
export async function requireAuth(): Promise<NextResponse | null> {
if (!(await checkAuth())) return unauthorized();
return null;
}
+45
View File
@@ -0,0 +1,45 @@
/**
* 极简的内存级速率限制,针对登录接口防暴力破解。
*
* 注:基于进程内存,多实例部署下不共享;对单实例个人博客足够。
* 生产环境若需更强保护,可换用 Redis 或 upstash ratelimit。
*/
const attempts = new Map<string, { count: number; lockedUntil: number }>();
const MAX_ATTEMPTS = 5;
const LOCK_MS = 15 * 60 * 1000; // 锁定 15 分钟
/** 记录一次失败尝试;返回当前是否已被锁定。 */
export function registerFailedAttempt(key: string): { locked: boolean; retryAfterSec: number } {
const now = Date.now();
const entry = attempts.get(key);
if (entry && entry.lockedUntil > now) {
return { locked: true, retryAfterSec: Math.ceil((entry.lockedUntil - now) / 1000) };
}
const count = entry && entry.lockedUntil > 0 ? entry.count + 1 : 1;
if (count >= MAX_ATTEMPTS) {
attempts.set(key, { count, lockedUntil: now + LOCK_MS });
return { locked: true, retryAfterSec: Math.ceil(LOCK_MS / 1000) };
}
attempts.set(key, { count, lockedUntil: 0 });
return { locked: false, retryAfterSec: 0 };
}
/** 登录成功后清除记录。 */
export function clearAttempts(key: string): void {
attempts.delete(key);
}
/** 检查 key 是否仍处于锁定状态。 */
export function isLocked(key: string): { locked: boolean; retryAfterSec: number } {
const entry = attempts.get(key);
const now = Date.now();
if (entry && entry.lockedUntil > now) {
return { locked: true, retryAfterSec: Math.ceil((entry.lockedUntil - now) / 1000) };
}
return { locked: false, retryAfterSec: 0 };
}
+34
View File
@@ -0,0 +1,34 @@
import sanitize from "sanitize-html";
/**
* 净化用户提交的 HTML 内容,移除脚本、事件处理器等危险标签,
* 但保留博客正文所需的语义化标签与 class(用于 prose 排版)。
*/
const SANITIZE_CONFIG: sanitize.IOptions = {
allowedTags: [
"p", "br", "hr", "span", "div", "section", "article",
"h1", "h2", "h3", "h4", "h5", "h6",
"strong", "b", "em", "i", "u", "s", "del", "ins", "mark", "small", "sub", "sup",
"blockquote", "q", "cite",
"ul", "ol", "li", "dl", "dt", "dd",
"a", "img",
"pre", "code",
"table", "thead", "tbody", "tr", "th", "td",
"figure", "figcaption",
"abbr", "address", "time", "kbd", "var", "samp",
],
allowedAttributes: {
"a": ["href", "target", "rel"],
"img": ["src", "alt", "title", "width", "height"],
"*": ["class", "datetime", "cite", "lang", "dir"],
"td": ["colspan", "rowspan"],
"th": ["colspan", "rowspan"],
},
allowedSchemes: ["http", "https", "mailto"],
disallowedTagsMode: "discard",
};
export function sanitizeHtml(dirty: string): string {
if (!dirty) return "";
return sanitize(dirty, SANITIZE_CONFIG);
}
+19 -136
View File
@@ -1,141 +1,24 @@
/**
* Seed script — run once to populate initial data from mock posts.
* Usage: npx tsx src/lib/seed.ts
* Seed 脚本入口 — 可手动运行:`npx tsx src/lib/seed.ts`
*
* 运行时自动初始化已在 store.ts 的 ensureSeed() 中处理,
* 本脚本仅作为显式重置/查看用途。
*/
import { createPost, createCategory, createTag, getPosts, getCategories, getTags } from "./store";
import { ensureSeed, getPosts, getCategories, getTags } from "./store";
const seedPosts = [
{
slug: "on-writing-and-silence",
title: "论写作与沉默",
excerpt: "有些话适合写在纸上,有些话适合留在风里。写作不是填满空白的过程,而是从空白中提炼意义的旅程。",
content: "<p>有些话适合写在纸上,有些话适合留在风里。</p><p>我常常觉得,沉默是一种被低估的能力。在这个信息过载的时代,我们急于表达、急于分享,却很少给自己留出沉默的空间。写作不是填满空白的过程,而是从空白中提炼意义的旅程。</p><p>每一次落笔,都是一次与自己的对话。那些在深夜里涌现的念头,像潮水一样涌来,又像退潮后的贝壳,最终留下的才是最珍贵的。</p><p>我开始学会在写作之前先沉默。让想法在脑海中沉淀,让语言在时间里发酵。好的文字从来不是急出来的。</p>",
date: "2026-06-15",
category: "随笔",
tags: ["写作", "思考", "生活哲学"],
readingTime: 4,
featured: true,
status: "published" as const,
},
{
slug: "a-walk-in-the-mountains",
title: "山中漫步",
excerpt: "大别山的清晨,雾气从谷底升起,像是大地在缓缓呼吸。脚下的石板路被露水打湿,每一步都需要格外小心。",
content: "<p>大别山的清晨,雾气从谷底升起,像是大地在缓缓呼吸。</p><p>脚下的石板路被露水打湿,每一步都需要格外小心。路旁的野花在薄雾中若隐若现,紫色和白色交替出现,像是大自然精心编排的欢迎仪式。</p><p>山里的时间过得格外慢。没有手机的信号,没有城市的喧嚣,只有鸟鸣和溪流的声音。这种安静让我想起小时候在外婆家的日子。</p>",
date: "2026-06-10",
category: "旅行",
tags: ["旅行", "自然", "六安"],
readingTime: 6,
featured: true,
status: "published" as const,
},
{
slug: "notes-on-digital-twin",
title: "数字孪生笔记:从3D建模到Web可视化",
excerpt: "从 Three.js 到 React Three FiberWeb 3D 的门槛比想象中低很多,但要做好,需要理解的东西远不止代码。",
content: "<p>从 Three.js 到 React Three FiberWeb 3D 的门槛比想象中低很多。</p><p>但要做好,需要理解的东西远不止代码。光照、材质、相机、性能优化,每一个都是深坑。这篇文章记录我在数字孪生项目中的一些实践和思考。</p>",
date: "2026-06-05",
category: "技术",
tags: ["Web3D", "React", "Three.js", "前端"],
readingTime: 8,
featured: false,
status: "published" as const,
},
{
slug: "reading-list-spring",
title: "春日书单:五本改变我看世界方式的书",
excerpt: "春天适合读一些柔软的书。不是那种让人醍醐灌顶的大部头,而是像春雨一样润物细无声的文字。",
content: "<p>春天适合读一些柔软的书。</p><p>不是那种让人醍醐灌顶的大部头,而是像春雨一样润物细无声的文字。以下五本书,在这个春天给了我很多安静的力量。</p>",
date: "2026-05-28",
category: "阅读",
tags: ["阅读", "书单", "生活"],
readingTime: 5,
featured: true,
status: "published" as const,
},
{
slug: "stable-diffusion-local-setup",
title: "本地部署 Stable Diffusion 踩坑记",
excerpt: "M3 16GB 的统一内存是优势也是限制。记录在 Apple Silicon 上跑 SD 1.5 和 SDXL 的完整过程。",
content: "<p>M3 16GB 的统一内存是优势也是限制。</p><p>这篇文章记录在 Apple Silicon 上跑 SD 1.5 和 SDXL 的完整过程,包括环境搭建、模型选择、LoRA 训练的一些尝试。</p>",
date: "2026-05-20",
category: "技术",
tags: ["AI", "Stable Diffusion", "Apple Silicon"],
readingTime: 10,
featured: false,
status: "published" as const,
},
{
slug: "lightbox-dream",
title: "灯箱:一个小城青年的创业梦",
excerpt: "标识灯箱这个行业,外行人觉得简单,内行人知道水深。从3D预览到商业模式,记录 sui_lightbox 的诞生过程。",
content: "<p>标识灯箱这个行业,外行人觉得简单,内行人知道水深。</p><p>从最初的一个想法,到3D预览原型的实现,再到商业模式的探索。这篇文章记录 sui_lightbox 项目从0到1的过程。</p>",
date: "2026-05-12",
category: "创业",
tags: ["创业", "灯箱", "产品", "sui_lightbox"],
readingTime: 7,
featured: true,
status: "published" as const,
},
{
slug: "rainy-day-thoughts",
title: "雨天杂记",
excerpt: "六月的梅雨季,窗外的梧桐叶被雨打得东倒西歪。泡一壶六安瓜片,坐在窗前看雨,什么都不想。",
content: "<p>六月的梅雨季,窗外的梧桐叶被雨打得东倒西歪。</p><p>泡一壶六安瓜片,坐在窗前看雨,什么都不想。这种无所事事的下午,反而是一周中最有创造力的时刻。</p>",
date: "2026-05-05",
category: "随笔",
tags: ["随笔", "生活", "六安"],
readingTime: 3,
featured: false,
status: "published" as const,
},
{
slug: "next-js-blog-from-scratch",
title: "从零搭建一个博客系统",
excerpt: "为什么选择 Next.js + Halo CMS?为什么不用 WordPress?这篇文章聊聊技术选型的思考过程。",
content: "<p>为什么选择 Next.js + Halo CMS?为什么不用 WordPress</p><p>作为一个前端开发者,我希望能完全掌控博客的UI和交互体验。Halo CMS 提供了足够好的内容管理API,而 Next.js 则让我可以自由设计前端展示。</p>",
date: "2026-04-28",
category: "技术",
tags: ["Next.js", "博客", "Halo CMS", "前端"],
readingTime: 6,
featured: false,
status: "published" as const,
},
];
const seedCategories = [
{ name: "技术", description: "代码、架构与技术探索" },
{ name: "随笔", description: "生活感悟与碎片思考" },
{ name: "旅行", description: "在路上看到的风景与人" },
{ name: "阅读", description: "书中世界与阅读心得" },
{ name: "创业", description: "产品思考与创业记录" },
];
const seedTags = [
"写作", "思考", "生活哲学", "旅行", "自然", "六安",
"Web3D", "React", "Three.js", "前端", "阅读", "书单",
"生活", "AI", "Stable Diffusion", "Apple Silicon",
"创业", "灯箱", "产品", "sui_lightbox", "随笔",
"Next.js", "博客", "Halo CMS",
];
// Only seed if empty
if (getPosts().length === 0) {
console.log("Seeding posts...");
seedPosts.forEach((p) => createPost(p));
console.log(` Created ${seedPosts.length} posts`);
async function main() {
await ensureSeed();
const [posts, categories, tags] = await Promise.all([
getPosts(),
getCategories(),
getTags(),
]);
console.log("Seed complete:", {
posts: posts.length,
categories: categories.length,
tags: tags.length,
});
process.exit(0);
}
if (getCategories().length === 0) {
console.log("Seeding categories...");
seedCategories.forEach((c) => createCategory(c));
console.log(` Created ${seedCategories.length} categories`);
}
if (getTags().length === 0) {
console.log("Seeding tags...");
seedTags.forEach((t) => createTag({ name: t }));
console.log(` Created ${seedTags.length} tags`);
}
console.log("Seed complete.");
main();
+311 -85
View File
@@ -1,13 +1,20 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import path from "path";
import { PrismaClient } from "@prisma/client";
import { sanitizeHtml } from "./sanitize";
import { seedPosts, seedCategories, seedTags } from "@/data/posts";
const DATA_DIR = path.join(process.cwd(), "src/data/storage");
// ── Prisma 单例 ──
// Ensure storage directory exists
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true });
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// ── 类型定义(保持不变) ──
/** 统一的文章类型,前台与后台共用。 */
export interface Post {
id: string;
slug: string;
@@ -17,6 +24,8 @@ export interface Post {
date: string;
category: string;
tags: string[];
/** 可选封面图,前台卡片可在有值时展示。 */
coverImage?: string;
readingTime: number;
featured: boolean;
status: "draft" | "published";
@@ -35,119 +44,336 @@ export interface Tag {
name: string;
}
function readJSON<T>(filename: string, fallback: T): T {
const filepath = path.join(DATA_DIR, filename);
if (!existsSync(filepath)) return fallback;
try {
return JSON.parse(readFileSync(filepath, "utf-8"));
} catch {
return fallback;
}
}
/** 文章可见性:前台只展示已发布。 */
export type PublicPost = Omit<Post, "status"> & { status: "published" };
function writeJSON(filename: string, data: unknown) {
const filepath = path.join(DATA_DIR, filename);
writeFileSync(filepath, JSON.stringify(data, null, 2), "utf-8");
}
// ── 内部工具 ──
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
/** 写入前对内容做净化,剥离任意可执行脚本。 */
function sanitizePostContent<T extends { content?: string }>(data: T): T {
if (typeof data.content === "string") {
return { ...data, content: sanitizeHtml(data.content) };
}
return data;
}
/** 将数据库行转为应用层 Post 类型(tags JSON → string[])。 */
function toPost(row: {
id: string;
slug: string;
title: string;
excerpt: string;
content: string;
date: string;
category: string;
tags: string;
coverImage: string | null;
readingTime: number;
featured: boolean;
status: string;
createdAt: string;
updatedAt: string;
}): Post {
return {
...row,
coverImage: row.coverImage ?? undefined,
tags: JSON.parse(row.tags) as string[],
status: row.status as Post["status"],
};
}
// ── Posts ──
export function getPosts(): Post[] {
return readJSON<Post[]>("posts.json", []);
/** 分页查询结果。 */
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export function getPost(id: string): Post | undefined {
return getPosts().find((p) => p.id === id);
/** 后台用:读取全部文章(含草稿)。 */
export async function getPosts(): Promise<Post[]> {
const rows = await prisma.post.findMany({ orderBy: { createdAt: "desc" } });
return rows.map(toPost);
}
export function getPostBySlug(slug: string): Post | undefined {
return getPosts().find((p) => p.slug === slug);
}
/** 后台用:分页 + 搜索 + 排序查询。 */
export async function getPostsPaginated(options: {
page?: number;
pageSize?: number;
status?: "draft" | "published";
search?: string;
sortBy?: "date" | "createdAt" | "title" | "readingTime";
sortDir?: "asc" | "desc";
}): Promise<PaginatedResult<Post>> {
const {
page = 1,
pageSize = 20,
status,
search,
sortBy = "date",
sortDir = "desc",
} = options;
export function createPost(data: Omit<Post, "id" | "createdAt" | "updatedAt">): Post {
const posts = getPosts();
const now = new Date().toISOString();
const post: Post = {
...data,
id: generateId(),
createdAt: now,
updatedAt: now,
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (search) {
where.OR = [
{ title: { contains: search } },
{ excerpt: { contains: search } },
{ category: { contains: search } },
];
}
const [rows, total] = await Promise.all([
prisma.post.findMany({
where,
orderBy: { [sortBy]: sortDir },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.post.count({ where }),
]);
return {
data: rows.map(toPost),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
posts.unshift(post);
writeJSON("posts.json", posts);
return post;
}
export function updatePost(id: string, data: Partial<Post>): Post | null {
const posts = getPosts();
const index = posts.findIndex((p) => p.id === id);
if (index === -1) return null;
posts[index] = { ...posts[index], ...data, updatedAt: new Date().toISOString() };
writeJSON("posts.json", posts);
return posts[index];
/** 后台用:获取统计数据。 */
export async function getStats(): Promise<{
total: number;
published: number;
draft: number;
featured: number;
categories: number;
tags: number;
}> {
const [total, published, draft, featured, categories, tags] = await Promise.all([
prisma.post.count(),
prisma.post.count({ where: { status: "published" } }),
prisma.post.count({ where: { status: "draft" } }),
prisma.post.count({ where: { featured: true } }),
prisma.category.count(),
prisma.tag.count(),
]);
return { total, published, draft, featured, categories, tags };
}
export function deletePost(id: string): boolean {
const posts = getPosts();
const filtered = posts.filter((p) => p.id !== id);
if (filtered.length === posts.length) return false;
writeJSON("posts.json", filtered);
return true;
/** 前台用:只返回已发布文章,按日期倒序。 */
export async function getPublishedPosts(): Promise<PublicPost[]> {
const rows = await prisma.post.findMany({
where: { status: "published" },
orderBy: { date: "desc" },
});
return rows.map(toPost) as PublicPost[];
}
export async function getPost(id: string): Promise<Post | undefined> {
const row = await prisma.post.findUnique({ where: { id } });
return row ? toPost(row) : undefined;
}
export async function getPostBySlug(slug: string): Promise<PublicPost | undefined> {
const row = await prisma.post.findFirst({
where: { slug, status: "published" },
});
return row ? (toPost(row) as PublicPost) : undefined;
}
/** 按分类名过滤已发布文章。 */
export async function getPostsByCategory(category: string): Promise<PublicPost[]> {
const rows = await prisma.post.findMany({
where: { category, status: "published" },
orderBy: { date: "desc" },
});
return rows.map(toPost) as PublicPost[];
}
/** 按标签名过滤已发布文章。 */
export async function getPostsByTag(tag: string): Promise<PublicPost[]> {
// SQLite 的 tags 字段是 JSON 字符串,需要全量读取后在内存过滤
const all = await getPublishedPosts();
return all.filter((p) => p.tags.includes(tag));
}
/** 全部已发布文章用到的标签及计数,按计数倒序。 */
export async function getAllTags(): Promise<{ name: string; count: number }[]> {
const map = new Map<string, number>();
for (const p of await getPublishedPosts()) {
for (const t of p.tags) {
map.set(t, (map.get(t) || 0) + 1);
}
}
return [...map.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "zh"));
}
/** 前台用分类列表(含文章计数,按计数倒序)。 */
export async function getPublicCategories(): Promise<(Category & { count: number })[]> {
const cats = await getCategories();
const published = await getPublishedPosts();
return cats
.map((c) => ({
...c,
count: published.filter((p) => p.category === c.name).length,
}))
.sort((a, b) => b.count - a.count);
}
export async function createPost(
data: Omit<Post, "id" | "createdAt" | "updatedAt">
): Promise<Post> {
const sanitized = sanitizePostContent(data);
const now = new Date().toISOString();
const row = await prisma.post.create({
data: {
id: generateId(),
slug: sanitized.slug,
title: sanitized.title,
excerpt: sanitized.excerpt ?? "",
content: sanitized.content,
date: sanitized.date,
category: sanitized.category,
tags: JSON.stringify(sanitized.tags ?? []),
coverImage: sanitized.coverImage ?? null,
readingTime: sanitized.readingTime ?? 5,
featured: sanitized.featured ?? false,
status: sanitized.status ?? "draft",
createdAt: now,
updatedAt: now,
},
});
return toPost(row);
}
export async function updatePost(id: string, data: Partial<Post>): Promise<Post | null> {
const existing = await prisma.post.findUnique({ where: { id } });
if (!existing) return null;
// 禁止通过 update 覆盖不可变字段
const { id: _id, createdAt: _createdAt, ...rest } = data;
const sanitized = sanitizePostContent(rest);
const updateData: Record<string, unknown> = { updatedAt: new Date().toISOString() };
if (sanitized.slug !== undefined) updateData.slug = sanitized.slug;
if (sanitized.title !== undefined) updateData.title = sanitized.title;
if (sanitized.excerpt !== undefined) updateData.excerpt = sanitized.excerpt;
if (sanitized.content !== undefined) updateData.content = sanitized.content;
if (sanitized.date !== undefined) updateData.date = sanitized.date;
if (sanitized.category !== undefined) updateData.category = sanitized.category;
if (sanitized.tags !== undefined) updateData.tags = JSON.stringify(sanitized.tags);
if (sanitized.coverImage !== undefined) updateData.coverImage = sanitized.coverImage;
if (sanitized.readingTime !== undefined) updateData.readingTime = sanitized.readingTime;
if (sanitized.featured !== undefined) updateData.featured = sanitized.featured;
if (sanitized.status !== undefined) updateData.status = sanitized.status;
const row = await prisma.post.update({ where: { id }, data: updateData });
return toPost(row);
}
export async function deletePost(id: string): Promise<boolean> {
try {
await prisma.post.delete({ where: { id } });
return true;
} catch {
return false;
}
}
// ── Categories ──
export function getCategories(): Category[] {
return readJSON<Category[]>("categories.json", []);
export async function getCategories(): Promise<Category[]> {
return prisma.category.findMany({ orderBy: { name: "asc" } });
}
export function createCategory(data: Omit<Category, "id">): Category {
const categories = getCategories();
const cat: Category = { ...data, id: generateId() };
categories.push(cat);
writeJSON("categories.json", categories);
return cat;
export async function createCategory(data: Omit<Category, "id">): Promise<Category> {
return prisma.category.create({
data: { id: generateId(), ...data },
});
}
export function updateCategory(id: string, data: Partial<Category>): Category | null {
const categories = getCategories();
const index = categories.findIndex((c) => c.id === id);
if (index === -1) return null;
categories[index] = { ...categories[index], ...data };
writeJSON("categories.json", categories);
return categories[index];
export async function updateCategory(
id: string,
data: Partial<Category>
): Promise<Category | null> {
const existing = await prisma.category.findUnique({ where: { id } });
if (!existing) return null;
const { id: _id, ...rest } = data;
return prisma.category.update({ where: { id }, data: rest });
}
export function deleteCategory(id: string): boolean {
const categories = getCategories();
const filtered = categories.filter((c) => c.id !== id);
if (filtered.length === categories.length) return false;
writeJSON("categories.json", filtered);
return true;
export async function deleteCategory(id: string): Promise<boolean> {
try {
await prisma.category.delete({ where: { id } });
return true;
} catch {
return false;
}
}
// ── Tags ──
export function getTags(): Tag[] {
return readJSON<Tag[]>("tags.json", []);
export async function getTags(): Promise<Tag[]> {
return prisma.tag.findMany({ orderBy: { name: "asc" } });
}
export function createTag(data: Omit<Tag, "id">): Tag {
const tags = getTags();
const tag: Tag = { ...data, id: generateId() };
tags.push(tag);
writeJSON("tags.json", tags);
return tag;
export async function createTag(data: Omit<Tag, "id">): Promise<Tag> {
return prisma.tag.create({
data: { id: generateId(), ...data },
});
}
export function deleteTag(id: string): boolean {
const tags = getTags();
const filtered = tags.filter((t) => t.id !== id);
if (filtered.length === tags.length) return false;
writeJSON("tags.json", filtered);
return true;
export async function deleteTag(id: string): Promise<boolean> {
try {
await prisma.tag.delete({ where: { id } });
return true;
} catch {
return false;
}
}
// ── Auto seed ──
/**
* 模块加载时确保种子数据存在。幂等:仅在数据库为空时写入。
* 这样首次运行 / 部署后无需手动执行 seed 脚本。
*/
export async function ensureSeed(): Promise<void> {
const postCount = await prisma.post.count();
if (postCount === 0) {
for (const p of seedPosts) {
await createPost(p);
}
}
const catCount = await prisma.category.count();
if (catCount === 0) {
for (const c of seedCategories) {
await createCategory(c);
}
}
const tagCount = await prisma.tag.count();
if (tagCount === 0) {
for (const t of seedTags) {
await createTag({ name: t });
}
}
}
// 模块加载时自动 seed(异步,不阻塞导入)
ensureSeed().catch((err) => {
console.error("[store] ensureSeed failed:", err);
});
+26
View File
@@ -0,0 +1,26 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** 合并类名(不做 tailwind merge,保留简单拼接)。 */
export function cx(...inputs: (string | false | null | undefined)[]) {
return inputs.filter(Boolean).join(" ");
}
/** 将 ISO 日期格式化为中文友好的显示形式。 */
export function formatDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
});
}
/** 阅读时间标签,如 "5 分钟阅读"。 */
export function readingTimeLabel(minutes: number): string {
return `${minutes} 分钟阅读`;
}
+30
View File
@@ -0,0 +1,30 @@
import { z } from "zod";
/**
* 输入校验 schema。所有 API 写操作都必须先通过对应 schema 解析,
* 拒绝越权字段(如 id / createdAt / updatedAt)。
*/
export const createPostSchema = z.object({
title: z.string().min(1).max(200),
slug: z.string().min(1).max(200).regex(/^[a-zA-Z0-9\u4e00-\u9fff_-]+$/),
excerpt: z.string().max(500).optional().default(""),
content: z.string().min(1),
date: z.string().min(1),
category: z.string().min(1).max(50),
tags: z.array(z.string().max(50)).max(20).default([]),
readingTime: z.number().int().min(1).max(600).default(5),
featured: z.boolean().default(false),
status: z.enum(["draft", "published"]).default("draft"),
});
export const updatePostSchema = createPostSchema.partial();
export const categorySchema = z.object({
name: z.string().min(1).max(50),
description: z.string().max(200).optional().default(""),
});
export const tagSchema = z.object({
name: z.string().min(1).max(50),
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,