fix: 修复后台文章计数为0 + 分类编辑补全描述字段 + CSP放行外部图片 + 更新关于页描述
- 文章管理:计数请求补 page=1 参数,命中分页接口返回正确的 total - 分类管理:编辑模式新增描述输入框,保存时一并提交 description - CSP:img-src 加入 https: 允许加载外部图片 - 关于页:数据存储描述从 JSON 文件更正为 SQLite 数据库 - Footer:添加 ICP 备案号
This commit is contained in:
+5
-2
@@ -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*
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+2679
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
// 搜索 debounce:300ms 后才更新 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">标题 A→Z</option>
|
||||
<option value="title-desc">标题 Z→A</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
@@ -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
@@ -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() });
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
© {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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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", "博客",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
/**
|
||||
* 认证相关常量与校验函数。
|
||||
*
|
||||
* 会话采用 HMAC 签名的 token(payload + "." + 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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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 Fiber,Web 3D 的门槛比想象中低很多,但要做好,需要理解的东西远不止代码。",
|
||||
content: "<p>从 Three.js 到 React Three Fiber,Web 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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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} 分钟阅读`;
|
||||
}
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in New Issue
Block a user