Compare commits
27 Commits
dce8fe62ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 58c27f96bf | |||
| 5ac3617a9e | |||
| a4c265d503 | |||
| 159ec69d3d | |||
| df9dee453f | |||
| 30823e9926 | |||
| 5e129fda86 | |||
| 0268048c62 | |||
| 707d065edb | |||
| bf76975000 | |||
| 57bbf1fece | |||
| 38f2a9823b | |||
| 89f8d6e223 | |||
| 92d190a081 | |||
| e6839da566 | |||
| faa17f0ccd | |||
| 814729df02 | |||
| 7fbfaa9572 | |||
| 3bebf669bd | |||
| 5e77c0fa61 | |||
| 56cd507e81 | |||
| 6de0f6ad3d | |||
| 9cc923e868 | |||
| 2f1a7c906d | |||
| 43e1c2f61d | |||
| 18e915bcbb | |||
| 3707eddfd4 |
@@ -0,0 +1,17 @@
|
|||||||
|
# ── 数据库(SQLite 本地路径)──
|
||||||
|
DATABASE_URL="file:./prisma/dev.db"
|
||||||
|
|
||||||
|
# ── 认证 ──
|
||||||
|
# 会话签名密钥,请改为随机字符串(生产环境务必修改)
|
||||||
|
SESSION_SECRET="change-me-to-a-random-string"
|
||||||
|
# 后台登录密码
|
||||||
|
ADMIN_PASSWORD="asui2026"
|
||||||
|
|
||||||
|
# ── AI 写作助手(可选)──
|
||||||
|
# 不配置 AI_API_KEY 时,AI 功能不可用,但博客其他功能正常
|
||||||
|
AI_BASE_URL="https://api.openai.com/v1"
|
||||||
|
AI_API_KEY=""
|
||||||
|
AI_MODEL="gpt-4o-mini"
|
||||||
|
|
||||||
|
# ── 站点 URL(用于 SEO metadata、OG 标签等)──
|
||||||
|
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||||
+6
-2
@@ -30,11 +30,15 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# admin data storage
|
# prisma database
|
||||||
src/data/storage/
|
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 files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
onlyBuiltDependencies[]=prisma
|
||||||
|
onlyBuiltDependencies[]=@prisma/client
|
||||||
|
onlyBuiltDependencies[]=@prisma/engines
|
||||||
|
onlyBuiltDependencies[]=sharp
|
||||||
|
onlyBuiltDependencies[]=unrs-resolver
|
||||||
@@ -1 +1,105 @@
|
|||||||
# sui_blog
|
# sui_blog
|
||||||
|
|
||||||
|
个人博客 — 水墨纸质杂志风格。
|
||||||
|
|
||||||
|
技术栈:Next.js 16 + React 19 + Tailwind CSS 4 + GSAP ScrollTrigger。
|
||||||
|
|
||||||
|
## 页面
|
||||||
|
|
||||||
|
| 路由 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `/` | 首页 — Hero 逐字揭示 + 精选文章 + 最新文章 |
|
||||||
|
| `/blog` | 文章列表 — 按时间倒序,分类/标签筛选 |
|
||||||
|
| `/posts/[slug]` | 文章详情 — 宋式排版,上下篇导航 |
|
||||||
|
| `/categories` | 分类浏览 — 卡片网格,悬停动效 |
|
||||||
|
| `/tags` | 标签云 |
|
||||||
|
| `/about` | 关于页 — 个人介绍 + 时间轴 + 技术栈 |
|
||||||
|
|
||||||
|
## 后台管理
|
||||||
|
|
||||||
|
| 路由 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `/admin/login` | 登录(密码认证,cookie session) |
|
||||||
|
| `/admin` | 仪表盘 — 统计概览 + 最近文章 |
|
||||||
|
| `/admin/posts` | 文章管理 — 列表筛选、新建、编辑、删除 |
|
||||||
|
| `/admin/categories` | 分类管理 — 增删改 |
|
||||||
|
| `/admin/tags` | 标签管理 — 云式展示,hover 删除 |
|
||||||
|
|
||||||
|
数据存储在 `src/data/storage/*.json`,已加入 `.gitignore`。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # http://localhost:3000
|
||||||
|
pnpm build # 生产构建
|
||||||
|
```
|
||||||
|
|
||||||
|
初始化种子数据(首次运行):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsx src/lib/seed.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── page.tsx # 首页
|
||||||
|
│ ├── layout.tsx # 根布局
|
||||||
|
│ ├── globals.css # 全局样式 + Tailwind v4 设计令牌
|
||||||
|
│ ├── about/ # 关于页
|
||||||
|
│ ├── admin/ # 后台管理
|
||||||
|
│ │ ├── layout.tsx # 侧栏 + 鉴权
|
||||||
|
│ │ ├── login/ # 登录页
|
||||||
|
│ │ ├── posts/ # 文章 CRUD
|
||||||
|
│ │ ├── categories/ # 分类管理
|
||||||
|
│ │ └── tags/ # 标签管理
|
||||||
|
│ ├── api/ # Route Handlers
|
||||||
|
│ │ ├── auth/ # 登录/登出/鉴权
|
||||||
|
│ │ ├── posts/ # 文章 CRUD
|
||||||
|
│ │ ├── categories/ # 分类 CRUD
|
||||||
|
│ │ └── tags/ # 标签 CRUD
|
||||||
|
│ ├── blog/ # 文章列表
|
||||||
|
│ ├── categories/ # 分类浏览
|
||||||
|
│ ├── posts/[slug]/ # 文章详情
|
||||||
|
│ └── tags/ # 标签云
|
||||||
|
├── components/
|
||||||
|
│ ├── Header.tsx # 顶部导航
|
||||||
|
│ ├── Footer.tsx # 页脚
|
||||||
|
│ ├── HeroSection.tsx # 首页 Hero
|
||||||
|
│ ├── BlogList.tsx # 文章列表组件
|
||||||
|
│ ├── PostContent.tsx # 文章正文排版
|
||||||
|
│ ├── PostSections.tsx # 精选/最近文章卡片
|
||||||
|
│ └── GsapReveal.tsx # GSAP 滚动动画组件
|
||||||
|
├── data/
|
||||||
|
│ ├── posts.ts # Mock 数据
|
||||||
|
│ └── storage/ # JSON 数据文件(gitignored)
|
||||||
|
└── lib/
|
||||||
|
├── store.ts # 数据存储层(posts/categories/tags CRUD)
|
||||||
|
└── seed.ts # 种子数据脚本
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设计系统
|
||||||
|
|
||||||
|
详见 [UI.md](./UI.md)。
|
||||||
|
|
||||||
|
核心色彩:parchment(纸张底色)+ ink(墨色文字)+ terracotta(赭石强调)+ sage(成功/发布状态)。
|
||||||
|
|
||||||
|
字体三层:`font-display`(宋体标题)→ `font-body`(宋体正文)→ `font-sans`(无衬线 UI 文字)。
|
||||||
|
|
||||||
|
动画:GSAP ScrollTrigger 驱动页面滚动揭示,逐字 blur 动画用于标题,CSS transition 处理微交互。
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
或部署到 Vercel / 任意 Node.js 主机。
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -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": {}
|
||||||
|
}
|
||||||
+31
-1
@@ -1,7 +1,37 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
+27
-1
@@ -9,16 +9,42 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"gsap": "^3.15.0",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
|
"lucide-react": "^1.21.0",
|
||||||
|
"marked": "^18.0.5",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"prisma": "5",
|
||||||
"react": "19.2.4",
|
"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"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": "4.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.9",
|
"eslint-config-next": "16.2.9",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
Generated
+2693
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
|
||||||
|
}
|
||||||
+116
-31
@@ -3,15 +3,40 @@ import GsapReveal from "@/components/GsapReveal";
|
|||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "关于",
|
title: "关于",
|
||||||
description: "关于胡旭和这个博客",
|
description: "关于我和这个博客",
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeline = [
|
const timeline = [
|
||||||
{ year: "2026", title: "开始写博客", desc: "用 Next.js + Halo CMS 搭建个人博客,记录技术与生活" },
|
{
|
||||||
{ year: "2025", title: "sui_lightbox 项目", desc: "开发3D灯箱模拟平台,探索标识行业的数字化可能" },
|
year: "2026",
|
||||||
{ year: "2024", title: "AI 图像生成", desc: "深入研究 Stable Diffusion,在 Apple Silicon 上部署本地 AI 环境" },
|
title: "AI 图像生成",
|
||||||
{ year: "2023", title: "Web 3D 可视化", desc: "从 Three.js 到 React Three Fiber,进入数字孪生领域" },
|
desc: "深入研究 Stable Diffusion,在 Apple Silicon 上部署本地 AI 环境",
|
||||||
{ year: "2022", title: "前端开发", desc: "从后端转向全栈,React + TypeScript 成为主力技术栈" },
|
},
|
||||||
|
{
|
||||||
|
year: "2025",
|
||||||
|
title: "lightbox 项目",
|
||||||
|
desc: "开发3D灯箱模拟平台,探索标识行业的数字化可能",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: "2024",
|
||||||
|
title: "开始学习AI",
|
||||||
|
desc: "从大模型到Dify,探索AI在开发中的应用",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: "2023",
|
||||||
|
title: "Web 3D 可视化",
|
||||||
|
desc: "从 Three.js 到 React Three Fiber,进入数字孪生领域",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: "2022",
|
||||||
|
title: "搭建博客",
|
||||||
|
desc: "用 React 搭建个人博客1.0,记录技术与生活",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: "2021",
|
||||||
|
title: "入行前端",
|
||||||
|
desc: "从学校走出来,开始在互联网公司做前端开发",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
@@ -29,25 +54,38 @@ export default function AboutPage() {
|
|||||||
</GsapReveal>
|
</GsapReveal>
|
||||||
|
|
||||||
{/* Intro */}
|
{/* 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>
|
<p>
|
||||||
你好,我是<span className="text-ink font-medium">胡旭</span>,一个来自安徽六安的前端开发者。
|
你好,我是<span className="text-ink font-medium">Sui</span>
|
||||||
|
,一个00后废柴青年兼前端开发者。
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
我对 AI 图像生成、Web 3D 可视化、以及将技术落地到实际产品中充满兴趣。目前我正在探索标识灯箱行业的数字化可能,希望用 3D 预览技术帮助标识制作商更高效地展示他们的产品。
|
我对 AI 图像生成、Web 3D
|
||||||
|
可视化、以及将技术落地到实际产品中充满兴趣。目前我正在探索标识灯箱行业的数字化可能,希望用
|
||||||
|
3D 预览技术帮助标识制作商更高效地展示他们的产品。
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
这个博客是我记录技术笔记、旅途见闻和生活感悟的地方。写字对我来说是一种思考的方式 — 当你试图把一个想法写下来的时候,你会发现自己对它的理解远比想象中要浅。
|
这个博客是我记录技术笔记、旅途见闻和生活感悟的地方。写字对我来说是一种思考的方式
|
||||||
|
—
|
||||||
|
当你试图把一个想法写下来的时候,你会发现自己对它的理解远比想象中要浅。
|
||||||
</p>
|
</p>
|
||||||
<p className="text-ink-muted">
|
<p className="text-ink-muted">
|
||||||
如果你有任何想法或合作意向,欢迎通过以下方式联系我。
|
如果你有任何想法或意见,欢迎通过以下方式联系我。
|
||||||
</p>
|
</p>
|
||||||
</GsapReveal>
|
</GsapReveal>
|
||||||
|
|
||||||
{/* Contact */}
|
{/* 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
|
<a
|
||||||
href="https://github.com/huxu"
|
href="http://gitea.asui.xyz/huxu"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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 +93,38 @@ export default function AboutPage() {
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
GitHub
|
Gitea
|
||||||
</a>
|
</a>
|
||||||
<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"
|
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" />
|
<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>
|
</svg>
|
||||||
hi@asui.xyz
|
E-mail
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://asui.xyz"
|
href="https://www.asui.xyz"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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" />
|
<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>
|
</svg>
|
||||||
asui.xyz
|
asui.xyz
|
||||||
@@ -82,7 +134,9 @@ export default function AboutPage() {
|
|||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
<div className="mt-20">
|
<div className="mt-20">
|
||||||
<GsapReveal variant="fade-up" className="mb-8">
|
<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>
|
||||||
<GsapReveal variant="slide-left" stagger={0.12} className="space-y-0">
|
<GsapReveal variant="slide-left" stagger={0.12} className="space-y-0">
|
||||||
{timeline.map((item, i) => (
|
{timeline.map((item, i) => (
|
||||||
@@ -94,9 +148,15 @@ export default function AboutPage() {
|
|||||||
<div className="w-2 h-2 rounded-full bg-terracotta" />
|
<div className="w-2 h-2 rounded-full bg-terracotta" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-sans text-sm text-terracotta tracking-wide">{item.year}</span>
|
<span className="font-sans text-sm text-terracotta tracking-wide">
|
||||||
<h3 className="font-display text-lg font-medium text-ink mt-1">{item.title}</h3>
|
{item.year}
|
||||||
<p className="font-body text-sm text-ink-muted mt-1 leading-relaxed">{item.desc}</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -106,15 +166,33 @@ export default function AboutPage() {
|
|||||||
{/* Tech stack */}
|
{/* Tech stack */}
|
||||||
<div className="mt-16">
|
<div className="mt-16">
|
||||||
<GsapReveal variant="fade-up" className="mb-6">
|
<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>
|
||||||
<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",
|
||||||
"React Three Fiber", "Python", "Stable Diffusion", "Node.js",
|
"TypeScript",
|
||||||
"Vite", "Halo CMS", "Docker"
|
"Next.js",
|
||||||
|
"Tailwind CSS",
|
||||||
|
"Three.js",
|
||||||
|
"React Three Fiber",
|
||||||
|
"Python",
|
||||||
|
"Stable Diffusion",
|
||||||
|
"Node.js",
|
||||||
|
"Vite",
|
||||||
|
"GSAP",
|
||||||
|
"Docker",
|
||||||
].map((tech) => (
|
].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}
|
{tech}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -124,9 +202,16 @@ export default function AboutPage() {
|
|||||||
{/* Colophon */}
|
{/* Colophon */}
|
||||||
<GsapReveal variant="fade-up" className="mt-20">
|
<GsapReveal variant="fade-up" className="mt-20">
|
||||||
<div className="p-8 rounded-2xl bg-cream border border-warm-gray/10">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</GsapReveal>
|
</GsapReveal>
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { Category } from "@/lib/store";
|
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() {
|
export default function CategoriesPage() {
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
@@ -10,105 +15,140 @@ export default function CategoriesPage() {
|
|||||||
const [newDesc, setNewDesc] = useState("");
|
const [newDesc, setNewDesc] = useState("");
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editDesc, setEditDesc] = useState("");
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const res = await fetch("/api/categories");
|
try {
|
||||||
|
const res = await safeFetch("/api/categories", undefined, toast);
|
||||||
setCategories(await res.json());
|
setCategories(await res.json());
|
||||||
|
} catch { /* safeFetch 已弹 toast */ }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, [toast]);
|
||||||
|
|
||||||
async function handleAdd(e: React.FormEvent) {
|
async function handleAdd(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
await fetch("/api/categories", {
|
try {
|
||||||
|
await safeFetch("/api/categories", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
|
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
|
||||||
});
|
}, toast);
|
||||||
|
toast(`已添加分类「${newName.trim()}」`, "success");
|
||||||
setNewName("");
|
setNewName("");
|
||||||
setNewDesc("");
|
setNewDesc("");
|
||||||
load();
|
load();
|
||||||
|
} catch { /* safeFetch 已弹 toast */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string, name: string) {
|
async function confirmDelete() {
|
||||||
if (!confirm(`确定删除分类「${name}」?`)) return;
|
if (!deleteTarget) return;
|
||||||
await fetch(`/api/categories?id=${id}`, { method: "DELETE" });
|
try {
|
||||||
|
await safeFetch(`/api/categories?id=${deleteTarget.id}`, { method: "DELETE" }, toast);
|
||||||
|
toast(`已删除分类「${deleteTarget.name}」`, "success");
|
||||||
load();
|
load();
|
||||||
|
} catch { /* safeFetch 已弹 toast */ }
|
||||||
|
setDeleteTarget(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave(id: string) {
|
async function handleSave(id: string) {
|
||||||
await fetch(`/api/categories?id=${id}`, {
|
try {
|
||||||
|
await safeFetch(`/api/categories?id=${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: editName }),
|
body: JSON.stringify({ name: editName, description: editDesc }),
|
||||||
});
|
}, toast);
|
||||||
|
toast("分类已更新", "success");
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
load();
|
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 (
|
return (
|
||||||
<div>
|
<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 */}
|
{/* Add form */}
|
||||||
<form onSubmit={handleAdd} className="flex gap-3 mb-8">
|
<form onSubmit={handleAdd} className="flex flex-col sm:flex-row gap-3 mb-8">
|
||||||
<input
|
<div className="flex-1 max-w-xs space-y-1">
|
||||||
|
<Label htmlFor="cat-name" className="sr-only">分类名称</Label>
|
||||||
|
<Input
|
||||||
|
id="cat-name"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder="分类名称"
|
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
|
</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}
|
value={newDesc}
|
||||||
onChange={(e) => setNewDesc(e.target.value)}
|
onChange={(e) => setNewDesc(e.target.value)}
|
||||||
placeholder="描述(可选)"
|
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">
|
</div>
|
||||||
添加
|
<Button type="submit" className="shrink-0">添加</Button>
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{categories.map((cat) => (
|
{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 ? (
|
{editingId === cat.id ? (
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<input
|
<Input
|
||||||
value={editName}
|
value={editName}
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button onClick={() => handleSave(cat.id)} className="font-sans text-xs text-sage hover:text-ink transition-colors">保存</button>
|
<Input
|
||||||
<button onClick={() => setEditingId(null)} className="font-sans text-xs text-ink-muted hover:text-ink transition-colors">取消</button>
|
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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-display text-base text-ink">{cat.name}</span>
|
<span className="font-display text-base">{cat.name}</span>
|
||||||
{cat.description && <span className="ml-3 font-sans text-sm text-ink-muted">{cat.description}</span>}
|
{cat.description && <span className="ml-3 font-sans text-sm text-muted-foreground">{cat.description}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingId(cat.id); setEditName(cat.name); }}
|
onClick={() => { setEditingId(cat.id); setEditName(cat.name); setEditDesc(cat.description); }}
|
||||||
className="font-sans text-xs text-ink-muted hover:text-terracotta transition-colors"
|
className="font-sans text-xs text-muted-foreground hover:text-primary transition-colors"
|
||||||
>编辑</button>
|
>编辑</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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{categories.length === 0 && (
|
{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>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
title="删除分类"
|
||||||
|
description={`确定删除分类「${deleteTarget?.name}」?此操作不可撤销。`}
|
||||||
|
confirmText="删除"
|
||||||
|
variant="destructive"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-16
@@ -3,6 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { ToastProvider } from "@/components/Toast";
|
||||||
|
|
||||||
const navItems = [
|
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" },
|
{ 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 router = useRouter();
|
||||||
const [authed, setAuthed] = useState<boolean | null>(null);
|
const [authed, setAuthed] = useState<boolean | null>(null);
|
||||||
|
|
||||||
// Login page — render bare children without sidebar or auth check
|
|
||||||
const isLoginPage = pathname === "/admin/login";
|
const isLoginPage = pathname === "/admin/login";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,15 +26,21 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!data.authenticated) router.push("/admin/login");
|
if (!data.authenticated) router.push("/admin/login");
|
||||||
else setAuthed(true);
|
else setAuthed(true);
|
||||||
});
|
})
|
||||||
|
.catch(() => router.push("/admin/login"));
|
||||||
}, [router, isLoginPage]);
|
}, [router, isLoginPage]);
|
||||||
|
|
||||||
|
// Login page — 渲染 children 不加侧栏
|
||||||
if (isLoginPage) {
|
if (isLoginPage) {
|
||||||
return <>{children}</>;
|
return <ToastProvider>{children}</ToastProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authed === null || !authed) {
|
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() {
|
async function handleLogout() {
|
||||||
@@ -43,15 +49,16 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-parchment flex">
|
<ToastProvider>
|
||||||
{/* Sidebar */}
|
<div className="min-h-screen bg-background flex">
|
||||||
<aside className="w-56 shrink-0 border-r border-warm-gray/15 bg-cream/50 flex flex-col">
|
{/* Sidebar — desktop */}
|
||||||
<div className="p-5 border-b border-warm-gray/10">
|
<aside className="w-56 shrink-0 border-r border-border bg-card/50 hidden md:flex flex-col">
|
||||||
<Link href="/admin" className="font-display text-lg font-medium text-ink hover:text-terracotta transition-colors">
|
<div className="p-5 border-b border-border">
|
||||||
|
<Link href="/admin" className="font-display text-lg font-medium hover:text-primary transition-colors">
|
||||||
随 · Admin
|
随 · Admin
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 p-3 space-y-1">
|
<nav className="flex-1 p-3 space-y-1" aria-label="管理后台导航">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = item.href === "/admin"
|
const isActive = item.href === "/admin"
|
||||||
? pathname === "/admin"
|
? pathname === "/admin"
|
||||||
@@ -60,8 +67,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={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 ${
|
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"
|
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">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -72,14 +80,17 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="p-3 border-t border-warm-gray/10 space-y-1">
|
<div className="p-3 border-t border-border 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">
|
<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">
|
<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" />
|
<path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
</svg>
|
</svg>
|
||||||
查看前台
|
查看前台
|
||||||
</Link>
|
</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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -88,12 +99,47 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Main */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto md:pt-0 pt-12">
|
||||||
<div className="p-8 max-w-5xl">
|
<div className="p-6 md:p-8">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,57 +2,62 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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() {
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<LoginForm />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
try {
|
||||||
const res = await fetch("/api/auth", {
|
await safeFetch("/api/auth", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
});
|
}, toast);
|
||||||
if (res.ok) {
|
|
||||||
router.push("/admin");
|
router.push("/admin");
|
||||||
} else {
|
} catch { /* safeFetch 已弹 toast */ }
|
||||||
const data = await res.json();
|
|
||||||
setError(data.error || "登录失败");
|
|
||||||
}
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="w-full max-w-sm">
|
||||||
<div className="text-center mb-10">
|
<div className="text-center mb-10">
|
||||||
<h1 className="font-display text-3xl font-medium text-ink">后台管理</h1>
|
<h1 className="font-display text-3xl font-medium">后台管理</h1>
|
||||||
<p className="mt-2 font-sans text-sm text-ink-muted">asui.xyz</p>
|
<p className="mt-2 font-sans text-sm text-muted-foreground">asui.xyz</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<input
|
<Label htmlFor="password">管理密码</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="输入管理密码"
|
placeholder="输入管理密码"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="font-sans text-sm text-red-600">{error}</p>}
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{loading ? "验证中..." : "登录"}
|
{loading ? "验证中..." : "登录"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+44
-39
@@ -2,83 +2,88 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
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() {
|
export default function DashboardPage() {
|
||||||
const [posts, setPosts] = useState<Post[]>([]);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [recentPosts, setRecentPosts] = useState<Post[]>([]);
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch("/api/posts").then((r) => r.json()),
|
safeFetch("/api/stats", undefined, toast).then((r) => r.json()),
|
||||||
fetch("/api/categories").then((r) => r.json()),
|
safeFetch("/api/posts?page=1&pageSize=5&sortBy=createdAt&sortDir=desc", undefined, toast).then((r) => r.json()),
|
||||||
fetch("/api/tags").then((r) => r.json()),
|
]).then(([s, postsResult]) => {
|
||||||
]).then(([p, c, t]) => {
|
setStats(s);
|
||||||
setPosts(p);
|
setRecentPosts(postsResult.data ?? postsResult);
|
||||||
setCategories(c);
|
|
||||||
setTags(t);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
}).catch(() => setLoading(false));
|
||||||
}, []);
|
}, [toast]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) return <div className="font-sans text-muted-foreground">加载中...</div>;
|
||||||
return <div className="font-sans text-ink-muted">加载中...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const published = posts.filter((p) => p.status === "published").length;
|
const statItems = stats ? [
|
||||||
const drafts = posts.filter((p) => p.status === "draft").length;
|
{ label: "文章总数", value: stats.total, color: "text-foreground" },
|
||||||
const featured = posts.filter((p) => p.featured).length;
|
{ label: "已发布", value: stats.published, color: "text-accent" },
|
||||||
|
{ label: "草稿", value: stats.draft, color: "text-primary" },
|
||||||
const stats = [
|
{ label: "精选", value: stats.featured, color: "text-primary" },
|
||||||
{ label: "文章总数", value: posts.length, color: "text-ink" },
|
{ label: "分类", value: stats.categories, color: "text-foreground" },
|
||||||
{ label: "已发布", value: published, color: "text-sage" },
|
{ label: "标签", value: stats.tags, color: "text-foreground" },
|
||||||
{ 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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<h1 className="font-display text-3xl font-medium text-ink">仪表盘</h1>
|
<h1 className="font-display text-3xl font-medium">仪表盘</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">
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-10">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-10">
|
||||||
{stats.map((s) => (
|
{statItems.map((s) => (
|
||||||
<div key={s.label} className="p-4 rounded-xl bg-cream border border-warm-gray/10">
|
<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-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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent posts */}
|
{/* 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">
|
<div className="space-y-2">
|
||||||
{posts.slice(0, 5).map((post) => (
|
{recentPosts.map((post) => (
|
||||||
<Link
|
<Link
|
||||||
key={post.id}
|
key={post.id}
|
||||||
href={`/admin/posts/${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="flex-1 min-w-0">
|
||||||
<div className="font-display text-base text-ink truncate">{post.title}</div>
|
<div className="font-display text-base truncate">{post.title}</div>
|
||||||
<div className="font-sans text-xs text-ink-muted mt-0.5">{post.category} · {post.date}</div>
|
<div className="font-sans text-xs text-muted-foreground mt-0.5">{post.category} · {post.date}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`ml-4 shrink-0 font-sans text-xs px-2 py-0.5 rounded-full ${
|
<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" ? "已发布" : "草稿"}
|
{post.status === "published" ? "已发布" : "草稿"}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{recentPosts.length === 0 && (
|
||||||
|
<div className="text-center py-8 font-sans text-muted-foreground">暂无文章</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,185 +3,63 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter, useParams } from "next/navigation";
|
import { useRouter, useParams } from "next/navigation";
|
||||||
import type { Post, Category, Tag } from "@/lib/store";
|
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() {
|
export default function EditPostPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const id = params.id as string;
|
const id = params.id as string;
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [post, setPost] = useState<Post | null>(null);
|
const [post, setPost] = useState<Post | null>(null);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch(`/api/posts/${id}`).then((r) => r.json()),
|
safeFetch(`/api/posts/${id}`, undefined, toast).then((r) => r.json()),
|
||||||
fetch("/api/categories").then((r) => r.json()),
|
safeFetch("/api/categories", undefined, toast).then((r) => r.json()),
|
||||||
fetch("/api/tags").then((r) => r.json()),
|
safeFetch("/api/tags", undefined, toast).then((r) => r.json()),
|
||||||
]).then(([p, c, t]) => {
|
]).then(([p, cats, tgs]) => {
|
||||||
setPost(p);
|
setPost(p);
|
||||||
setCategories(c);
|
setCategories(cats);
|
||||||
setAllTags(t);
|
setAllTags(tgs);
|
||||||
setForm({
|
}).catch(() => {})
|
||||||
title: p.title, slug: p.slug, excerpt: p.excerpt,
|
.finally(() => setLoading(false));
|
||||||
content: p.content, category: p.category, tags: p.tags,
|
}, [id, toast]);
|
||||||
readingTime: p.readingTime, featured: p.featured,
|
|
||||||
status: p.status, date: p.date,
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(data: PostFormData) {
|
||||||
e.preventDefault();
|
await safeFetch(`/api/posts/${id}`, {
|
||||||
const res = await fetch(`/api/posts/${id}`, {
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(form),
|
body: JSON.stringify(data),
|
||||||
});
|
}, toast);
|
||||||
if (res.ok) router.push("/admin/posts");
|
toast("文章已更新", "success");
|
||||||
|
router.push("/admin/posts");
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTag(tagName: string) {
|
if (loading) return <div className="font-sans text-muted-foreground">加载中...</div>;
|
||||||
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 (!post) return <div className="font-sans text-red-600">文章未找到</div>;
|
if (!post) return <div className="font-sans text-red-600">文章未找到</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4 mb-8">
|
<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>
|
<button onClick={() => router.back()} className="font-sans text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||||
<h1 className="font-display text-3xl font-medium text-ink">编辑文章</h1>
|
← 返回
|
||||||
|
</button>
|
||||||
|
<h1 className="font-display text-3xl font-medium">编辑文章</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<PostForm
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
mode="edit"
|
||||||
<div>
|
initialData={post}
|
||||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">标题</label>
|
categories={categories}
|
||||||
<input
|
tags={allTags}
|
||||||
value={form.title}
|
onSubmit={handleSubmit}
|
||||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
onCancel={() => router.back()}
|
||||||
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>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,218 +3,51 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { Category, Tag } from "@/lib/store";
|
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() {
|
export default function NewPostPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
fetch("/api/categories").then((r) => r.json()).then(setCategories);
|
Promise.all([
|
||||||
fetch("/api/tags").then((r) => r.json()).then(setAllTags);
|
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) {
|
async function handleSubmit(data: PostFormData) {
|
||||||
return title
|
await safeFetch("/api/posts", {
|
||||||
.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", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ...form, slug }),
|
body: JSON.stringify(data),
|
||||||
});
|
}, toast);
|
||||||
if (res.ok) router.push("/admin/posts");
|
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],
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4 mb-8">
|
<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>
|
</button>
|
||||||
<h1 className="font-display text-3xl font-medium text-ink">新文章</h1>
|
<h1 className="font-display text-3xl font-medium">新文章</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<PostForm
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
mode="create"
|
||||||
{/* Title */}
|
categories={categories}
|
||||||
<div>
|
tags={allTags}
|
||||||
<label className="block font-sans text-sm text-ink-muted mb-1.5">标题</label>
|
onSubmit={handleSubmit}
|
||||||
<input
|
onCancel={() => router.back()}
|
||||||
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>
|
</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>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+180
-29
@@ -1,97 +1,248 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Post } from "@/lib/store";
|
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() {
|
export default function PostsPage() {
|
||||||
const [posts, setPosts] = useState<Post[]>([]);
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState<"all" | "published" | "draft">("all");
|
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 = 10;
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
async function loadPosts() {
|
// 搜索 debounce:300ms 后才更新 debouncedSearch
|
||||||
const res = await fetch("/api/posts");
|
useEffect(() => {
|
||||||
const data = await res.json();
|
const timer = setTimeout(() => {
|
||||||
setPosts(data);
|
setDebouncedSearch(search);
|
||||||
setLoading(false);
|
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) {
|
async function handleDelete(id: string, title: string) {
|
||||||
if (!confirm(`确定删除「${title}」?`)) return;
|
if (!confirm(`确定删除「${title}」?`)) return;
|
||||||
await fetch(`/api/posts?id=${id}`, { method: "DELETE" });
|
try {
|
||||||
|
await safeFetch(`/api/posts?id=${id}`, { method: "DELETE" }, toast);
|
||||||
|
toast(`已删除「${title}」`, "success");
|
||||||
loadPosts();
|
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 && posts.length === 0) return <div className="font-sans text-muted-foreground">加载中...</div>;
|
||||||
|
|
||||||
if (loading) return <div className="font-sans text-ink-muted">加载中...</div>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<h1 className="font-display text-3xl font-medium text-ink">文章管理</h1>
|
<h1 className="font-display text-3xl font-medium">文章管理</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">
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter tabs */}
|
{/* 搜索 + 筛选 + 排序 */}
|
||||||
<div className="flex gap-4 mb-6 font-sans text-sm">
|
<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) => (
|
{(["all", "published", "draft"] as const).map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f)}
|
||||||
className={`pb-1 border-b-2 transition-colors ${
|
className={`pb-1 border-b-2 transition-colors ${
|
||||||
filter === f ? "border-terracotta text-ink" : "border-transparent text-ink-muted hover:text-ink"
|
filter === f ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{f === "all" ? "全部" : f === "published" ? "已发布" : "草稿"}
|
{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>
|
<span className="ml-1 text-xs text-muted-foreground">({counts[f]})</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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"
|
||||||
|
>
|
||||||
|
<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">
|
<div className="space-y-3">
|
||||||
{filtered.map((post) => (
|
{posts.map((post) => (
|
||||||
<div key={post.id} className="p-5 rounded-xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 transition-colors">
|
<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 items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<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}
|
{post.title}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="font-sans text-sm text-ink-muted mt-1 line-clamp-1">{post.excerpt}</p>
|
<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-ink-muted">
|
<div className="flex items-center gap-3 mt-2 font-sans text-xs text-muted-foreground">
|
||||||
<span>{post.category}</span>
|
<span>{post.category}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{post.date}</span>
|
<span>{post.date}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{post.readingTime} 分钟</span>
|
<span>{post.readingTime} 分钟</span>
|
||||||
{post.featured && <span className="text-terracotta">精选</span>}
|
{post.featured && <span className="text-primary">精选</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className={`font-sans text-xs px-2 py-0.5 rounded-full ${
|
<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" ? "已发布" : "草稿"}
|
{post.status === "published" ? "已发布" : "草稿"}
|
||||||
</span>
|
</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>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && (
|
{posts.length === 0 && !loading && (
|
||||||
<div className="text-center py-16 font-sans text-ink-muted">暂无文章</div>
|
<div className="text-center py-16 font-sans text-muted-foreground">
|
||||||
|
{debouncedSearch ? "未找到匹配的文章" : "暂无文章"}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-19
@@ -2,74 +2,102 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { Tag } from "@/lib/store";
|
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() {
|
export default function TagsPage() {
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const res = await fetch("/api/tags");
|
try {
|
||||||
|
const res = await safeFetch("/api/tags", undefined, toast);
|
||||||
setTags(await res.json());
|
setTags(await res.json());
|
||||||
|
} catch { /* safeFetch 已弹 toast */ }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, [toast]);
|
||||||
|
|
||||||
async function handleAdd(e: React.FormEvent) {
|
async function handleAdd(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
await fetch("/api/tags", {
|
try {
|
||||||
|
await safeFetch("/api/tags", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: newName.trim() }),
|
body: JSON.stringify({ name: newName.trim() }),
|
||||||
});
|
}, toast);
|
||||||
|
toast(`已添加标签「${newName.trim()}」`, "success");
|
||||||
setNewName("");
|
setNewName("");
|
||||||
load();
|
load();
|
||||||
|
} catch { /* safeFetch 已弹 toast */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string, name: string) {
|
async function confirmDelete() {
|
||||||
if (!confirm(`确定删除标签「${name}」?`)) return;
|
if (!deleteTarget) return;
|
||||||
await fetch(`/api/tags?id=${id}`, { method: "DELETE" });
|
try {
|
||||||
|
await safeFetch(`/api/tags?id=${deleteTarget.id}`, { method: "DELETE" }, toast);
|
||||||
|
toast(`已删除标签「${deleteTarget.name}」`, "success");
|
||||||
load();
|
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 (
|
return (
|
||||||
<div>
|
<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 */}
|
{/* Add form */}
|
||||||
<form onSubmit={handleAdd} className="flex gap-3 mb-8">
|
<form onSubmit={handleAdd} className="flex gap-3 mb-8">
|
||||||
<input
|
<div className="flex-1 max-w-xs space-y-1">
|
||||||
|
<Label htmlFor="tag-name" className="sr-only">标签名称</Label>
|
||||||
|
<Input
|
||||||
|
id="tag-name"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder="标签名称"
|
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">
|
</div>
|
||||||
添加
|
<Button type="submit" className="shrink-0">添加</Button>
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Tag cloud */}
|
{/* 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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tags.map((tag) => (
|
{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}
|
{tag.name}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(tag.id, tag.name)}
|
onClick={() => setDeleteTarget({ id: tag.id, name: tag.name })}
|
||||||
className="opacity-0 group-hover:opacity-100 text-ink-muted hover:text-red-600 transition-all text-xs leading-none"
|
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>
|
>×</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{tags.length === 0 && (
|
{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>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
title="删除标签"
|
||||||
|
description={`确定删除标签「${deleteTarget?.name}」?此操作不可撤销。`}
|
||||||
|
confirmText="删除"
|
||||||
|
variant="destructive"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/http";
|
||||||
|
|
||||||
|
const BASE_URL = process.env.AI_BASE_URL || "https://api.openai.com/v1";
|
||||||
|
const API_KEY = process.env.AI_API_KEY || "";
|
||||||
|
const MODEL = process.env.AI_MODEL || "gpt-4o-mini";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const deny = await requireAuth();
|
||||||
|
if (deny) return deny;
|
||||||
|
|
||||||
|
if (!API_KEY) {
|
||||||
|
return new Response(JSON.stringify({ error: "未配置 AI_API_KEY" }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { prompt: string; selectedText?: string; action?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return new Response(JSON.stringify({ error: "请求格式错误" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { prompt, selectedText, action } = body;
|
||||||
|
|
||||||
|
// 构建系统提示
|
||||||
|
const systemPrompt = `你是一个中文博客写作助手。请用简洁、自然的中文回复。
|
||||||
|
- 保持原文风格和语气
|
||||||
|
- 不要添加多余的解释或前缀
|
||||||
|
- 直接输出结果文本`;
|
||||||
|
|
||||||
|
// 根据 action 构建用户提示
|
||||||
|
let userPrompt = prompt;
|
||||||
|
if (action && selectedText) {
|
||||||
|
const actionMap: Record<string, string> = {
|
||||||
|
polish: `请润色以下文字,使其更流畅、更优美,保持原意:\n\n${selectedText}`,
|
||||||
|
expand: `请扩写以下文字,补充更多细节和论述,保持风格一致:\n\n${selectedText}`,
|
||||||
|
shorten: `请精简以下文字,保留核心意思,去除冗余:\n\n${selectedText}`,
|
||||||
|
continue: `请根据以下内容自然地续写下去,保持风格和语气一致:\n\n${selectedText}`,
|
||||||
|
translate_en: `请将以下中文翻译为英文,保持自然流畅:\n\n${selectedText}`,
|
||||||
|
translate_zh: `请将以下英文翻译为中文,保持自然流畅:\n\n${selectedText}`,
|
||||||
|
summarize: `请为以下文章写一段简短的摘要(2-3句话):\n\n${selectedText}`,
|
||||||
|
fix_grammar: `请修正以下文字中的语法错误和错别字,保持原意:\n\n${selectedText}`,
|
||||||
|
};
|
||||||
|
userPrompt = actionMap[action] || `请处理以下文字:\n\n${selectedText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: userPrompt },
|
||||||
|
],
|
||||||
|
stream: true,
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 2000,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
return new Response(JSON.stringify({ error: `AI 请求失败: ${err}` }), {
|
||||||
|
status: res.status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转发 SSE 流
|
||||||
|
return new Response(res.body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `AI 请求异常: ${err instanceof Error ? err.message : "未知错误"}` }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-13
@@ -1,28 +1,59 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
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 ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "asui2026";
|
||||||
const SESSION_KEY = "admin_session";
|
const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 天
|
||||||
const SESSION_VALUE = "authenticated";
|
|
||||||
|
/** 取客户端 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) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = await request.json();
|
const key = clientKey(request);
|
||||||
|
const lock = isLocked(key);
|
||||||
|
if (lock.locked) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `尝试次数过多,请 ${Math.ceil(lock.retryAfterSec / 60)} 分钟后再试` },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (body.password === ADMIN_PASSWORD) {
|
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();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(SESSION_KEY, SESSION_VALUE, {
|
cookieStore.set(SESSION_KEY, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
maxAge: SESSION_MAX_AGE,
|
||||||
path: "/",
|
path: "/",
|
||||||
});
|
});
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "密码错误" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.delete(SESSION_KEY);
|
cookieStore.delete(SESSION_KEY);
|
||||||
@@ -30,7 +61,5 @@ export async function DELETE() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const cookieStore = await cookies();
|
return NextResponse.json({ authenticated: await checkAuth() });
|
||||||
const session = cookieStore.get(SESSION_KEY);
|
|
||||||
return NextResponse.json({ authenticated: session?.value === SESSION_VALUE });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,40 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { getCategories, createCategory, updateCategory, deleteCategory } from "@/lib/store";
|
import { getCategories, createCategory, updateCategory, deleteCategory } from "@/lib/store";
|
||||||
|
import { requireAuth, parseBody } from "@/lib/http";
|
||||||
async function checkAuth(): Promise<boolean> {
|
import { categorySchema } from "@/lib/validation";
|
||||||
const cookieStore = await cookies();
|
|
||||||
return cookieStore.get("admin_session")?.value === "authenticated";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
if (!(await checkAuth())) {
|
const deny = await requireAuth();
|
||||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
if (deny) return deny;
|
||||||
}
|
return NextResponse.json(await getCategories());
|
||||||
return NextResponse.json(getCategories());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
if (!(await checkAuth())) {
|
const deny = await requireAuth();
|
||||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
if (deny) return deny;
|
||||||
}
|
const parsed = await parseBody(request, categorySchema);
|
||||||
const body = await request.json();
|
if (!parsed.ok) return parsed.response;
|
||||||
const cat = createCategory(body);
|
return NextResponse.json(await createCategory(parsed.data), { status: 201 });
|
||||||
return NextResponse.json(cat, { status: 201 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
if (!(await checkAuth())) {
|
const deny = await requireAuth();
|
||||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
if (deny) return deny;
|
||||||
}
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
||||||
const body = await request.json();
|
const parsed = await parseBody(request, categorySchema);
|
||||||
const cat = updateCategory(id, body);
|
if (!parsed.ok) return parsed.response;
|
||||||
|
const cat = await updateCategory(id, parsed.data);
|
||||||
if (!cat) return NextResponse.json({ error: "未找到" }, { status: 404 });
|
if (!cat) return NextResponse.json({ error: "未找到" }, { status: 404 });
|
||||||
return NextResponse.json(cat);
|
return NextResponse.json(cat);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
if (!(await checkAuth())) {
|
const deny = await requireAuth();
|
||||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
if (deny) return deny;
|
||||||
}
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
||||||
const ok = deleteCategory(id);
|
return NextResponse.json({ ok: await deleteCategory(id) });
|
||||||
return NextResponse.json({ ok });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { getPost, updatePost } from "@/lib/store";
|
import { getPost, updatePost } from "@/lib/store";
|
||||||
|
import { requireAuth, parseBody } from "@/lib/http";
|
||||||
async function checkAuth(): Promise<boolean> {
|
import { updatePostSchema } from "@/lib/validation";
|
||||||
const cookieStore = await cookies();
|
|
||||||
return cookieStore.get("admin_session")?.value === "authenticated";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
if (!(await checkAuth())) {
|
const deny = await requireAuth();
|
||||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
if (deny) return deny;
|
||||||
}
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const post = getPost(id);
|
const post = await getPost(id);
|
||||||
if (!post) return NextResponse.json({ error: "未找到" }, { status: 404 });
|
if (!post) return NextResponse.json({ error: "未找到" }, { status: 404 });
|
||||||
return NextResponse.json(post);
|
return NextResponse.json(post);
|
||||||
}
|
}
|
||||||
@@ -24,12 +19,12 @@ export async function PUT(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
if (!(await checkAuth())) {
|
const deny = await requireAuth();
|
||||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
if (deny) return deny;
|
||||||
}
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = await request.json();
|
const parsed = await parseBody(request, updatePostSchema);
|
||||||
const post = updatePost(id, body);
|
if (!parsed.ok) return parsed.response;
|
||||||
|
const post = await updatePost(id, parsed.data);
|
||||||
if (!post) return NextResponse.json({ error: "未找到" }, { status: 404 });
|
if (!post) return NextResponse.json({ error: "未找到" }, { status: 404 });
|
||||||
return NextResponse.json(post);
|
return NextResponse.json(post);
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-21
@@ -1,36 +1,45 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { getPosts, getPostsPaginated, createPost, deletePost } from "@/lib/store";
|
||||||
import { getPosts, createPost, deletePost } from "@/lib/store";
|
import { requireAuth, parseBody } from "@/lib/http";
|
||||||
|
import { createPostSchema } from "@/lib/validation";
|
||||||
|
|
||||||
async function checkAuth(): Promise<boolean> {
|
export async function GET(request: NextRequest) {
|
||||||
const cookieStore = await cookies();
|
const deny = await requireAuth();
|
||||||
return cookieStore.get("admin_session")?.value === "authenticated";
|
if (deny) return deny;
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
// 兼容旧接口:无参数时返回全量
|
||||||
if (!(await checkAuth())) {
|
return NextResponse.json(await getPosts());
|
||||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
|
||||||
}
|
|
||||||
const posts = getPosts();
|
|
||||||
return NextResponse.json(posts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
if (!(await checkAuth())) {
|
const deny = await requireAuth();
|
||||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
if (deny) return deny;
|
||||||
}
|
const parsed = await parseBody(request, createPostSchema);
|
||||||
const body = await request.json();
|
if (!parsed.ok) return parsed.response;
|
||||||
const post = createPost(body);
|
const post = await createPost(parsed.data);
|
||||||
return NextResponse.json(post, { status: 201 });
|
return NextResponse.json(post, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
if (!(await checkAuth())) {
|
const deny = await requireAuth();
|
||||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
if (deny) return deny;
|
||||||
}
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
||||||
const ok = deletePost(id);
|
return NextResponse.json({ ok: await deletePost(id) });
|
||||||
return NextResponse.json({ ok });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { getTags, createTag, deleteTag } from "@/lib/store";
|
import { getTags, createTag, deleteTag } from "@/lib/store";
|
||||||
|
import { requireAuth, parseBody } from "@/lib/http";
|
||||||
async function checkAuth(): Promise<boolean> {
|
import { tagSchema } from "@/lib/validation";
|
||||||
const cookieStore = await cookies();
|
|
||||||
return cookieStore.get("admin_session")?.value === "authenticated";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
if (!(await checkAuth())) {
|
const deny = await requireAuth();
|
||||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
if (deny) return deny;
|
||||||
}
|
return NextResponse.json(await getTags());
|
||||||
return NextResponse.json(getTags());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
if (!(await checkAuth())) {
|
const deny = await requireAuth();
|
||||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
if (deny) return deny;
|
||||||
}
|
const parsed = await parseBody(request, tagSchema);
|
||||||
const body = await request.json();
|
if (!parsed.ok) return parsed.response;
|
||||||
const tag = createTag(body);
|
return NextResponse.json(await createTag(parsed.data), { status: 201 });
|
||||||
return NextResponse.json(tag, { status: 201 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
if (!(await checkAuth())) {
|
const deny = await requireAuth();
|
||||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
if (deny) return deny;
|
||||||
}
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
||||||
const ok = deleteTag(id);
|
return NextResponse.json({ ok: await deleteTag(id) });
|
||||||
return NextResponse.json({ ok });
|
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -1,11 +1,19 @@
|
|||||||
import { posts } from "@/data/posts";
|
import { Suspense } from "react";
|
||||||
|
import { getPublishedPosts } from "@/lib/store";
|
||||||
import BlogList from "@/components/BlogList";
|
import BlogList from "@/components/BlogList";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "文章",
|
title: "文章",
|
||||||
description: "胡旭的博客文章 — 技术、随笔、旅行、阅读",
|
description: "胡旭的博客文章 — 技术、随笔、旅行、阅读",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BlogPage() {
|
export default async function BlogPage() {
|
||||||
return <BlogList posts={posts} />;
|
const posts = await getPublishedPosts();
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<BlogList posts={posts} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { categories, posts } from "@/data/posts";
|
import { getPublicCategories, getPostsByCategory } from "@/lib/store";
|
||||||
import GsapReveal from "@/components/GsapReveal";
|
import GsapReveal from "@/components/GsapReveal";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "分类",
|
title: "分类",
|
||||||
description: "按分类浏览博客文章",
|
description: "按分类浏览博客文章",
|
||||||
@@ -15,7 +17,17 @@ const categoryIcons: Record<string, string> = {
|
|||||||
创业: "M13 10V3L4 14h7v7l9-11h-7z",
|
创业: "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 (
|
return (
|
||||||
<div className="px-page max-w-5xl mx-auto pt-16 pb-24">
|
<div className="px-page max-w-5xl mx-auto pt-16 pb-24">
|
||||||
<GsapReveal variant="fade-up" className="mb-14">
|
<GsapReveal variant="fade-up" className="mb-14">
|
||||||
@@ -27,13 +39,13 @@ export default function CategoriesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</GsapReveal>
|
</GsapReveal>
|
||||||
|
|
||||||
|
{categoriesWithPosts.length > 0 ? (
|
||||||
<GsapReveal variant="fade-up" stagger={0.12} className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-20">
|
<GsapReveal variant="fade-up" stagger={0.12} className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-20">
|
||||||
{categories.map((cat) => {
|
{categoriesWithPosts.map((cat) => {
|
||||||
const icon = categoryIcons[cat.name] || categoryIcons["随笔"];
|
const icon = categoryIcons[cat.name] || categoryIcons["随笔"];
|
||||||
const catPosts = posts.filter((p) => p.category === cat.name);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={cat.name}
|
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"
|
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">
|
<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">
|
||||||
@@ -42,14 +54,19 @@ export default function CategoriesPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-2 mb-2">
|
<div className="flex items-baseline gap-2 mb-2">
|
||||||
<h2 className="font-display text-2xl font-medium text-ink">{cat.name}</h2>
|
<Link
|
||||||
|
href={`/blog?category=${encodeURIComponent(cat.name)}`}
|
||||||
|
className="font-display text-2xl font-medium text-ink hover:text-terracotta transition-colors duration-300"
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</Link>
|
||||||
<span className="font-sans text-xs text-ink-muted">{cat.count} 篇</span>
|
<span className="font-sans text-xs text-ink-muted">{cat.count} 篇</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-body text-sm text-ink-muted leading-relaxed mb-5">
|
<p className="font-body text-sm text-ink-muted leading-relaxed mb-5">
|
||||||
{cat.description}
|
{cat.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{catPosts.slice(0, 3).map((post) => (
|
{cat.posts.map((post) => (
|
||||||
<Link
|
<Link
|
||||||
key={post.slug}
|
key={post.slug}
|
||||||
href={`/posts/${post.slug}`}
|
href={`/posts/${post.slug}`}
|
||||||
@@ -63,6 +80,11 @@ export default function CategoriesPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</GsapReveal>
|
</GsapReveal>
|
||||||
|
) : (
|
||||||
|
<div className="py-24 text-center">
|
||||||
|
<p className="font-display text-2xl text-ink-muted mb-2">暂无分类</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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 "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
/* ── Design Tokens ── */
|
/* ── Design Tokens ── */
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -16,17 +19,57 @@
|
|||||||
--color-warm-gray: #C5BDB4;
|
--color-warm-gray: #C5BDB4;
|
||||||
--color-cream: #FAF9F7;
|
--color-cream: #FAF9F7;
|
||||||
|
|
||||||
/* Typography — 宋式 serif priority */
|
/* Typography — 宋式 serif priority。
|
||||||
--font-display: "Noto Serif SC", "Cormorant Garamond", "Source Han Serif SC", "Songti SC", serif;
|
next/font 注入的 CSS 变量优先,回退到本地系统宋体。 */
|
||||||
--font-body: "Noto Serif SC", "Source Serif 4", "Source Han Serif SC", "Songti SC", serif;
|
--font-display: var(--font-noto-serif), "Cormorant Garamond", "Source Han Serif SC", "Songti SC", serif;
|
||||||
--font-sans: "Noto Sans SC", "DM Sans", system-ui, sans-serif;
|
--font-body: var(--font-noto-serif), "Source Han Serif SC", "Songti SC", serif;
|
||||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
--font-sans: var(--font-sans);
|
||||||
|
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||||
|
|
||||||
/* Spacing scale */
|
/* Spacing scale */
|
||||||
--spacing-page: clamp(1.5rem, 5vw, 6rem);
|
--spacing-page: clamp(1.5rem, 5vw, 6rem);
|
||||||
|
|
||||||
/* Transitions */
|
/* Transitions */
|
||||||
--ease-literary: cubic-bezier(0.25, 0.1, 0.25, 1);
|
--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 ── */
|
/* ── Base Styles ── */
|
||||||
@@ -168,6 +211,11 @@ body::before {
|
|||||||
animation: fade-in 0.5s var(--ease-literary) both;
|
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 brush underline decoration ── */
|
||||||
.ink-underline {
|
.ink-underline {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -204,3 +252,51 @@ body::before {
|
|||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(to right, transparent, var(--color-warm-gray), transparent);
|
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 type { Metadata } from "next";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
|
import { Noto_Serif_SC, Noto_Sans_SC, Cormorant_Garamond, Geist } from "next/font/google";
|
||||||
import "./globals.css";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(SITE_URL),
|
||||||
title: {
|
title: {
|
||||||
default: "随 · asui.xyz",
|
default: "随 · asui.xyz",
|
||||||
template: "%s | 随",
|
template: "%s | 随",
|
||||||
},
|
},
|
||||||
description: "胡旭的个人博客 — 记录技术探索、生活感悟与创业路上的点滴",
|
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({
|
export default function RootLayout({
|
||||||
@@ -18,10 +71,18 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
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">
|
<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 />
|
<Header />
|
||||||
<main className="flex-1">{children}</main>
|
<main id="main-content" className="flex-1">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+5
-2
@@ -1,8 +1,11 @@
|
|||||||
import { posts } from "@/data/posts";
|
import { getPublishedPosts } from "@/lib/store";
|
||||||
import HeroSection from "@/components/HeroSection";
|
import HeroSection from "@/components/HeroSection";
|
||||||
import { FeaturedGrid, RecentList } from "@/components/PostSections";
|
import { FeaturedGrid, RecentList } from "@/components/PostSections";
|
||||||
|
|
||||||
export default function HomePage() {
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const posts = await getPublishedPosts();
|
||||||
const featuredPosts = posts.filter((p) => p.featured);
|
const featuredPosts = posts.filter((p) => p.featured);
|
||||||
const recentPosts = posts.slice(0, 5);
|
const recentPosts = posts.slice(0, 5);
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,73 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { posts } from "@/data/posts";
|
import { getPostBySlug, getPublishedPosts } from "@/lib/store";
|
||||||
import PostContent from "@/components/PostContent";
|
import PostContent from "@/components/PostContent";
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz";
|
||||||
return posts.map((post) => ({ slug: post.slug }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const post = posts.find((p) => p.slug === slug);
|
const post = await getPostBySlug(slug);
|
||||||
if (!post) return {};
|
if (!post) return {};
|
||||||
|
|
||||||
|
const url = `${SITE_URL}/posts/${post.slug}`;
|
||||||
return {
|
return {
|
||||||
title: post.title,
|
title: post.title,
|
||||||
description: post.excerpt,
|
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 }> }) {
|
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const post = posts.find((p) => p.slug === slug);
|
const post = await getPostBySlug(slug);
|
||||||
if (!post) notFound();
|
if (!post) notFound();
|
||||||
|
|
||||||
const currentIndex = posts.findIndex((p) => p.slug === slug);
|
const all = await getPublishedPosts();
|
||||||
const prevPost = currentIndex > 0 ? posts[currentIndex - 1] : null;
|
const currentIndex = all.findIndex((p) => p.slug === slug);
|
||||||
const nextPost = currentIndex < posts.length - 1 ? posts[currentIndex + 1] : null;
|
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,49 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { getPublishedPosts, getPublicCategories, getAllTags } from "@/lib/store";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
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];
|
||||||
|
} catch {
|
||||||
|
return staticRoutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-9
@@ -1,14 +1,16 @@
|
|||||||
import Link from "next/link";
|
import { getAllTags } from "@/lib/store";
|
||||||
import { allTags } from "@/data/posts";
|
|
||||||
import GsapReveal from "@/components/GsapReveal";
|
import GsapReveal from "@/components/GsapReveal";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "标签",
|
title: "标签",
|
||||||
description: "按标签浏览博客文章",
|
description: "按标签浏览博客文章",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TagsPage() {
|
export default async function TagsPage() {
|
||||||
const maxCount = Math.max(...allTags.map((t) => t.count));
|
const allTags = await getAllTags();
|
||||||
|
const maxCount = allTags.length > 0 ? Math.max(...allTags.map((t) => t.count)) : 1;
|
||||||
|
|
||||||
function getTagSize(count: number) {
|
function getTagSize(count: number) {
|
||||||
const ratio = count / maxCount;
|
const ratio = count / maxCount;
|
||||||
@@ -19,8 +21,7 @@ export default function TagsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTagWeight(count: number) {
|
function getTagWeight(count: number) {
|
||||||
const ratio = count / maxCount;
|
return count / maxCount >= 0.5 ? "text-ink" : "text-ink-muted";
|
||||||
return ratio >= 0.5 ? "text-ink" : "text-ink-muted";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,12 +35,13 @@ export default function TagsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</GsapReveal>
|
</GsapReveal>
|
||||||
|
|
||||||
|
{allTags.length > 0 ? (
|
||||||
<GsapReveal variant="scale" stagger={0.04} className="max-w-3xl">
|
<GsapReveal variant="scale" stagger={0.04} className="max-w-3xl">
|
||||||
<div className="flex flex-wrap gap-3 md:gap-4 items-center">
|
<div className="flex flex-wrap gap-3 md:gap-4 items-center">
|
||||||
{allTags.map((tag) => (
|
{allTags.map((tag) => (
|
||||||
<Link
|
<a
|
||||||
key={tag.name}
|
key={tag.name}
|
||||||
href="/blog"
|
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"
|
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`}>
|
<span className={`font-display ${getTagSize(tag.count)} ${getTagWeight(tag.count)} group-hover:text-terracotta transition-colors duration-300`}>
|
||||||
@@ -48,10 +50,15 @@ export default function TagsPage() {
|
|||||||
<span className="font-sans text-xs text-ink-muted group-hover:text-terracotta transition-colors duration-300">
|
<span className="font-sans text-xs text-ink-muted group-hover:text-terracotta transition-colors duration-300">
|
||||||
{tag.count}
|
{tag.count}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</GsapReveal>
|
</GsapReveal>
|
||||||
|
) : (
|
||||||
|
<div className="py-24 text-center">
|
||||||
|
<p className="font-display text-2xl text-ink-muted mb-2">暂无标签</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<GsapReveal variant="fade-in" className="mt-20">
|
<GsapReveal variant="fade-in" className="mt-20">
|
||||||
<div className="divider-ornament">
|
<div className="divider-ornament">
|
||||||
|
|||||||
+194
-52
@@ -1,28 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 gsap from "gsap";
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
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";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
function formatDate(dateStr: string) {
|
interface BlogListProps {
|
||||||
const d = new Date(dateStr);
|
posts: PublicPost[];
|
||||||
return d.toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BlogList({ posts }: { posts: Post[] }) {
|
export default function BlogList({ posts }: BlogListProps) {
|
||||||
const headerRef = useRef<HTMLDivElement>(null);
|
const router = useRouter();
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const searchParams = useSearchParams();
|
||||||
|
const activeCategory = searchParams.get("category") || "";
|
||||||
|
const activeTag = searchParams.get("tag") || "";
|
||||||
|
const page = Number(searchParams.get("page")) || 1;
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
useEffect(() => {
|
const headerRef = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
|
||||||
const ctxs: gsap.Context[] = [];
|
if (isReduced) return;
|
||||||
|
|
||||||
// Header animation
|
|
||||||
if (headerRef.current) {
|
|
||||||
const ctx = gsap.context(() => {
|
|
||||||
gsap.from(".blog-header-el", {
|
gsap.from(".blog-header-el", {
|
||||||
y: 30,
|
y: 30,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
@@ -30,71 +33,142 @@ export default function BlogList({ posts }: { posts: Post[] }) {
|
|||||||
stagger: 0.1,
|
stagger: 0.1,
|
||||||
ease: "power3.out",
|
ease: "power3.out",
|
||||||
});
|
});
|
||||||
}, headerRef.current);
|
}, []);
|
||||||
ctxs.push(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// List items stagger on scroll
|
const listRef = useGsapAnimation<HTMLDivElement>((scope, isReduced) => {
|
||||||
if (listRef.current) {
|
if (isReduced) return;
|
||||||
const ctx = gsap.context(() => {
|
|
||||||
gsap.from(".blog-list-item", {
|
gsap.from(".blog-list-item", {
|
||||||
y: 40,
|
y: 40,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
duration: 0.7,
|
duration: 0.7,
|
||||||
stagger: 0.1,
|
stagger: 0.08,
|
||||||
ease: "power3.out",
|
ease: "power3.out",
|
||||||
scrollTrigger: {
|
scrollTrigger: { trigger: scope, start: "top 88%" },
|
||||||
trigger: listRef.current,
|
|
||||||
start: "top 88%",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}, listRef.current);
|
}, [activeCategory, activeTag]);
|
||||||
ctxs.push(ctx);
|
|
||||||
|
// 从文章动态聚合分类与标签
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||||
|
const currentPage = Math.min(page, totalPages);
|
||||||
|
const paged = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * pageSize;
|
||||||
|
return filtered.slice(start, start + pageSize);
|
||||||
|
}, [filtered, currentPage]);
|
||||||
|
|
||||||
|
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");
|
||||||
|
params.delete("page"); // 切换筛选时重置分页
|
||||||
|
const qs = params.toString();
|
||||||
|
router.push(qs ? `/blog?${qs}` : "/blog", { scroll: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => ctxs.forEach((c) => c.revert());
|
function setPage(p: number) {
|
||||||
}, []);
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (p > 1) params.set("page", String(p));
|
||||||
|
else params.delete("page");
|
||||||
|
const qs = params.toString();
|
||||||
|
router.push(qs ? `/blog?${qs}` : "/blog", { scroll: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFilter = Boolean(activeCategory || activeTag);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-page max-w-5xl mx-auto pt-16 pb-24">
|
<div className="px-page max-w-5xl mx-auto pt-16 pb-24">
|
||||||
{/* Header */}
|
{/* 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 className="blog-header-el font-display text-4xl md:text-5xl font-light text-ink tracking-tight">
|
||||||
文章
|
文章
|
||||||
</h1>
|
</h1>
|
||||||
<p className="blog-header-el mt-3 font-body text-ink-muted max-w-md">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Post list */}
|
||||||
<div ref={listRef} className="space-y-0">
|
{paged.length > 0 ? (
|
||||||
{posts.map((post) => (
|
<div ref={listRef as React.RefObject<HTMLDivElement>} className="space-y-0">
|
||||||
<Link
|
{paged.map((post) => (
|
||||||
key={post.slug}
|
<Link key={post.slug} href={`/posts/${post.slug}`} className="blog-list-item group block">
|
||||||
href={`/posts/${post.slug}`}
|
<article className="relative py-8 border-b border-warm-gray/10 hover:bg-cream -mx-4 px-4 rounded-xl transition-all duration-300">
|
||||||
className="blog-list-item group block"
|
<div className="flex flex-col md:flex-row md:items-start gap-3 md:gap-6">
|
||||||
>
|
|
||||||
<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">
|
<div className="shrink-0 md:w-36 md:pt-1">
|
||||||
<time className="font-sans text-sm text-ink-muted tabular-nums">
|
<time className="font-sans text-sm text-ink-muted tabular-nums">
|
||||||
{formatDate(post.date)}
|
{formatDate(post.date)}
|
||||||
</time>
|
</time>
|
||||||
<div className="mt-1 flex items-center gap-2 md:flex-col md:items-start md:gap-1">
|
<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">
|
<span className="font-sans text-sm text-terracotta">{post.category}</span>
|
||||||
{post.category}
|
|
||||||
</span>
|
|
||||||
<span className="hidden md:block font-sans text-sm text-ink-muted">
|
<span className="hidden md:block font-sans text-sm text-ink-muted">
|
||||||
{post.readingTime} min
|
{readingTimeLabel(post.readingTime)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,6 +187,12 @@ export default function BlogList({ posts }: { posts: Post[] }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{post.coverImage && (
|
||||||
|
<div className="hidden md:block shrink-0 w-28 h-20 rounded-lg overflow-hidden bg-parchment-deep">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={post.coverImage} alt="" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="hidden md:flex shrink-0 items-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 pt-2">
|
<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">
|
<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" />
|
<path d="M7 12h10M13 8l4 4-4 4" />
|
||||||
@@ -123,6 +203,68 @@ export default function BlogList({ posts }: { posts: Post[] }) {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-12 font-sans text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(currentPage - 1)}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
className="p-2 rounded-lg text-ink-muted hover:text-terracotta disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
className={`w-8 h-8 rounded-lg transition-colors ${
|
||||||
|
p === currentPage
|
||||||
|
? "bg-ink text-cream"
|
||||||
|
: "text-ink-muted hover:text-terracotta hover:bg-cream"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(currentPage + 1)}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
className="p-2 rounded-lg text-ink-muted hover:text-terracotta disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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">
|
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-8">
|
||||||
{/* Left - brand */}
|
{/* Left - brand */}
|
||||||
<div>
|
<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
|
随 · asui.xyz
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-2 font-sans text-sm text-ink-muted max-w-xs leading-relaxed">
|
<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>
|
</h4>
|
||||||
<div className="flex flex-col gap-2">
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,14 +56,43 @@ export default function Footer() {
|
|||||||
社交
|
社交
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex flex-col gap-2">
|
<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">
|
<a
|
||||||
GitHub
|
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>
|
||||||
<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
|
Email
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,9 +101,19 @@ export default function Footer() {
|
|||||||
<p className="font-sans text-xs text-ink-muted">
|
<p className="font-sans text-xs text-ink-muted">
|
||||||
© {new Date().getFullYear()} 胡旭. All rights reserved.
|
© {new Date().getFullYear()} 胡旭. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
<p className="font-sans text-xs text-ink-muted">
|
<div className="flex items-center gap-4 font-sans text-xs text-ink-muted">
|
||||||
Powered by <span className="text-terracotta">Next.js</span> & <span className="text-terracotta">Halo</span>
|
<a
|
||||||
</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ interface GsapRevealProps {
|
|||||||
once?: boolean;
|
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({
|
export default function GsapReveal({
|
||||||
children,
|
children,
|
||||||
variant = "fade-up",
|
variant = "fade-up",
|
||||||
@@ -28,25 +36,20 @@ export default function GsapReveal({
|
|||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
|
||||||
|
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
const children = el.children.length > 1 ? el.children : [el];
|
if (!el) return;
|
||||||
|
|
||||||
const variants = {
|
// 关键降级:尊重用户系统设置,无障碍优先,不做任何位移。
|
||||||
"fade-up": { y: 40, opacity: 0 },
|
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
"fade-in": { opacity: 0 },
|
if (reduceMotion) return;
|
||||||
"slide-left": { x: -40, opacity: 0 },
|
|
||||||
"slide-right": { x: 40, opacity: 0 },
|
const targets = el.children.length > 1 ? Array.from(el.children) : [el];
|
||||||
scale: { scale: 0.92, opacity: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
if (stagger > 0 && children.length > 1) {
|
if (stagger > 0 && targets.length > 1) {
|
||||||
// Each child gets its own ScrollTrigger so off-screen items animate when scrolled into view
|
targets.forEach((child, i) => {
|
||||||
Array.from(children).forEach((child, i) => {
|
|
||||||
gsap.from(child, {
|
gsap.from(child, {
|
||||||
...variants[variant],
|
...VARIANTS[variant],
|
||||||
duration,
|
duration,
|
||||||
delay: delay + i * stagger,
|
delay: delay + i * stagger,
|
||||||
ease: "power3.out",
|
ease: "power3.out",
|
||||||
@@ -58,8 +61,8 @@ export default function GsapReveal({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
gsap.from(children, {
|
gsap.from(targets, {
|
||||||
...variants[variant],
|
...VARIANTS[variant],
|
||||||
duration,
|
duration,
|
||||||
delay,
|
delay,
|
||||||
ease: "power3.out",
|
ease: "power3.out",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function Header() {
|
|||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 backdrop-blur-md bg-parchment/80 border-b border-warm-gray/20">
|
<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">
|
<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 */}
|
{/* Logo */}
|
||||||
<Link href="/" className="group flex items-center gap-2">
|
<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">
|
<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
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={`
|
className={`
|
||||||
relative px-4 py-2 font-sans text-sm tracking-wide transition-colors duration-300
|
relative px-4 py-2 font-sans text-sm tracking-wide transition-colors duration-300
|
||||||
${isActive ? "text-terracotta" : "text-ink-muted hover:text-ink"}
|
${isActive ? "text-terracotta" : "text-ink-muted hover:text-ink"}
|
||||||
@@ -58,7 +59,9 @@ export default function Header() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
className="md:hidden p-2 text-ink-muted hover:text-ink transition-colors"
|
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">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
{menuOpen ? (
|
{menuOpen ? (
|
||||||
@@ -77,9 +80,14 @@ export default function Header() {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile menu */}
|
{/* Mobile menu — 用 grid-rows 过渡实现展开/收起动画 */}
|
||||||
{menuOpen && (
|
<div
|
||||||
<div className="md:hidden pb-4 animate-fade-in">
|
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) => {
|
{navItems.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname === item.href ||
|
pathname === item.href ||
|
||||||
@@ -89,6 +97,7 @@ export default function Header() {
|
|||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={() => setMenuOpen(false)}
|
onClick={() => setMenuOpen(false)}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={`
|
className={`
|
||||||
block py-3 px-2 font-sans text-sm tracking-wide border-b border-warm-gray/10 transition-colors duration-300
|
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"}
|
${isActive ? "text-terracotta" : "text-ink-muted"}
|
||||||
@@ -99,7 +108,8 @@ export default function Header() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,30 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
|
import { useGsapAnimation } from "./useGsapAnimation";
|
||||||
|
|
||||||
export default function HeroSection() {
|
export default function HeroSection() {
|
||||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
const ref = useGsapAnimation<HTMLElement>((scope, isReduced) => {
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
if (isReduced) return;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sectionRef.current) return;
|
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
|
||||||
const tl = gsap.timeline({ delay: 0.2 });
|
const tl = gsap.timeline({ delay: 0.2 });
|
||||||
|
|
||||||
// Subtitle fade in
|
tl.from(".hero-subtitle", { y: 20, opacity: 0, duration: 0.6, ease: "power3.out" });
|
||||||
tl.from(".hero-subtitle", {
|
|
||||||
y: 20,
|
|
||||||
opacity: 0,
|
|
||||||
duration: 0.6,
|
|
||||||
ease: "power3.out",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Heading — split chars and stagger
|
const heading = scope.querySelector<HTMLElement>("[data-heading]");
|
||||||
if (headingRef.current) {
|
if (heading) {
|
||||||
const spans = headingRef.current.querySelectorAll(".hero-char");
|
const spans = heading.querySelectorAll(".hero-char");
|
||||||
tl.from(
|
tl.from(
|
||||||
spans,
|
spans,
|
||||||
{
|
{
|
||||||
@@ -39,34 +29,13 @@ export default function HeroSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description
|
tl.from(".hero-desc", { y: 20, opacity: 0, duration: 0.6, ease: "power3.out" }, "-=0.3");
|
||||||
tl.from(
|
tl.from(".hero-btn", { y: 15, opacity: 0, duration: 0.5, stagger: 0.1, ease: "power3.out" }, "-=0.3");
|
||||||
".hero-desc",
|
tl.from(".hero-divider", { scaleX: 0, opacity: 0, duration: 0.8, ease: "power2.inOut" });
|
||||||
{ y: 20, opacity: 0, duration: 0.6, 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();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Split heading into char spans
|
// 逐字拆分标题
|
||||||
const headingChars = (text: string, className?: string) =>
|
const headingChars = (text: string) =>
|
||||||
[...text].map((char, i) => (
|
[...text].map((char, i) => (
|
||||||
<span key={i} className="hero-char inline-block" style={char === " " ? { width: "0.3em" } : undefined}>
|
<span key={i} className="hero-char inline-block" style={char === " " ? { width: "0.3em" } : undefined}>
|
||||||
{char}
|
{char}
|
||||||
@@ -74,23 +43,28 @@ export default function HeroSection() {
|
|||||||
));
|
));
|
||||||
|
|
||||||
return (
|
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">
|
<div className="max-w-2xl">
|
||||||
<p className="hero-subtitle font-sans text-sm tracking-widest text-ink-muted uppercase mb-6">
|
<p className="hero-subtitle font-sans text-sm tracking-widest text-ink-muted uppercase mb-6">
|
||||||
胡旭的个人博客
|
一个来自于Sui的个人Blog
|
||||||
</p>
|
</p>
|
||||||
<h1
|
<h1
|
||||||
ref={headingRef}
|
data-heading
|
||||||
className="font-display text-4xl md:text-6xl font-light text-ink leading-tight tracking-tight"
|
className="font-display text-4xl md:text-6xl font-light text-ink leading-tight tracking-tight"
|
||||||
>
|
>
|
||||||
{headingChars("写字,")}
|
{headingChars("写字,")}
|
||||||
<br />
|
<br />
|
||||||
<span className="text-terracotta">{headingChars("是一种思考的方式")}</span>
|
{/* 仅高亮关键词「思考」,避免整句赭红造成视觉重量过重 */}
|
||||||
|
<span>
|
||||||
|
{headingChars("是一种 ")}
|
||||||
|
<span className="text-terracotta">{headingChars("思考")}</span>
|
||||||
|
{headingChars(" 的方式")}
|
||||||
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="hero-desc mt-6 font-body text-lg text-ink-muted leading-relaxed max-w-lg">
|
<p className="hero-desc mt-6 font-body text-lg text-ink-muted leading-relaxed max-w-lg">
|
||||||
这里记录着技术探索中的发现、旅途中的风景、阅读时的感悟,以及一个小镇青年创业路上的点点滴滴。
|
这里记录着技术探索中的发现、旅途中的风景、阅读时的感悟,以及一个小镇青年创业路上的点点滴滴。
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 flex items-center gap-4">
|
<div className="mt-8 flex flex-wrap items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
href="/blog"
|
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Decorative line */}
|
{/* 装饰分隔线 */}
|
||||||
<div className="hero-divider mt-20 flex items-center gap-4 text-warm-gray origin-center">
|
<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" />
|
<div className="h-px flex-1 bg-gradient-to-r from-warm-gray/20 to-transparent" />
|
||||||
<span className="font-display text-sm italic">精选文章</span>
|
<span className="font-display text-sm italic">精选文章</span>
|
||||||
|
|||||||
@@ -1,114 +1,82 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
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);
|
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({
|
export default function PostContent({
|
||||||
post,
|
post,
|
||||||
prevPost,
|
prevPost,
|
||||||
nextPost,
|
nextPost,
|
||||||
}: {
|
}: {
|
||||||
post: Post;
|
post: PublicPost;
|
||||||
prevPost: Post | null;
|
prevPost: PublicPost | null;
|
||||||
nextPost: Post | null;
|
nextPost: PublicPost | null;
|
||||||
}) {
|
}) {
|
||||||
const articleRef = useRef<HTMLElement>(null);
|
const ref = useGsapAnimation<HTMLElement>((scope, isReduced) => {
|
||||||
|
if (isReduced) return;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!articleRef.current) return;
|
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
|
||||||
const tl = gsap.timeline();
|
const tl = gsap.timeline();
|
||||||
|
|
||||||
// Back link
|
|
||||||
tl.from(".post-back", { x: -20, opacity: 0, duration: 0.5, ease: "power3.out" });
|
tl.from(".post-back", { x: -20, opacity: 0, duration: 0.5, ease: "power3.out" });
|
||||||
|
|
||||||
// Category badge
|
|
||||||
tl.from(".post-category", { y: 15, opacity: 0, duration: 0.5, ease: "power3.out" }, "-=0.2");
|
tl.from(".post-category", { y: 15, opacity: 0, duration: 0.5, ease: "power3.out" }, "-=0.2");
|
||||||
|
|
||||||
// Title — char by char reveal
|
// 标题逐字 —— 用 scope 内选择器,而非全局 document
|
||||||
const titleChars = document.querySelectorAll(".post-title-char");
|
const titleChars = scope.querySelectorAll(".post-title-char");
|
||||||
tl.from(
|
tl.from(
|
||||||
titleChars,
|
titleChars,
|
||||||
{ y: 30, opacity: 0, filter: "blur(4px)", duration: 0.5, stagger: 0.03, ease: "power3.out" },
|
{ y: 30, opacity: 0, filter: "blur(4px)", duration: 0.5, stagger: 0.03, ease: "power3.out" },
|
||||||
"-=0.3"
|
"-=0.3"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Meta
|
|
||||||
tl.from(".post-meta", { y: 10, opacity: 0, duration: 0.4, ease: "power3.out" }, "-=0.2");
|
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-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");
|
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 > *");
|
const paragraphs = scope.querySelectorAll(".prose-literary > *");
|
||||||
paragraphs.forEach((p) => {
|
paragraphs.forEach((p) => {
|
||||||
gsap.from(p, {
|
gsap.from(p, {
|
||||||
y: 25,
|
y: 25,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
duration: 0.6,
|
duration: 0.6,
|
||||||
ease: "power3.out",
|
ease: "power3.out",
|
||||||
scrollTrigger: {
|
scrollTrigger: { trigger: p, start: "top 90%" },
|
||||||
trigger: p,
|
|
||||||
start: "top 90%",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tags
|
|
||||||
gsap.from(".post-tag", {
|
gsap.from(".post-tag", {
|
||||||
scale: 0.8,
|
scale: 0.8,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
duration: 0.4,
|
duration: 0.4,
|
||||||
stagger: 0.05,
|
stagger: 0.05,
|
||||||
ease: "back.out(1.5)",
|
ease: "back.out(1.5)",
|
||||||
scrollTrigger: {
|
scrollTrigger: { trigger: ".post-tags", start: "top 90%" },
|
||||||
trigger: ".post-tags",
|
|
||||||
start: "top 90%",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prev/Next
|
|
||||||
gsap.from(".post-nav", {
|
gsap.from(".post-nav", {
|
||||||
y: 20,
|
y: 20,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
stagger: 0.1,
|
stagger: 0.1,
|
||||||
ease: "power3.out",
|
ease: "power3.out",
|
||||||
scrollTrigger: {
|
scrollTrigger: { trigger: ".post-navs", start: "top 90%" },
|
||||||
trigger: ".post-navs",
|
|
||||||
start: "top 90%",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}, articleRef.current);
|
}, [post.slug]);
|
||||||
|
|
||||||
return () => ctx.revert();
|
// 标题拆字
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Split title into char spans
|
|
||||||
const titleChars = [...post.title].map((char, i) => (
|
const titleChars = [...post.title].map((char, i) => (
|
||||||
<span
|
<span key={i} className="post-title-char inline-block" style={char === " " ? { width: "0.3em" } : undefined}>
|
||||||
key={i}
|
|
||||||
className="post-title-char inline-block"
|
|
||||||
style={char === " " ? { width: "0.3em" } : undefined}
|
|
||||||
>
|
|
||||||
{char}
|
{char}
|
||||||
</span>
|
</span>
|
||||||
));
|
));
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Back link */}
|
||||||
<div className="post-back mb-10">
|
<div className="post-back mb-10">
|
||||||
<Link
|
<Link
|
||||||
@@ -133,10 +101,22 @@ export default function PostContent({
|
|||||||
<div className="post-meta mt-6 flex items-center justify-center gap-3 font-sans text-sm text-ink-muted">
|
<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>
|
<time>{formatDate(post.date)}</time>
|
||||||
<span className="w-1 h-1 rounded-full bg-warm-gray" />
|
<span className="w-1 h-1 rounded-full bg-warm-gray" />
|
||||||
<span>{post.readingTime} min read</span>
|
<span>{readingTimeLabel(post.readingTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Cover image */}
|
||||||
|
{post.coverImage && (
|
||||||
|
<div className="max-w-3xl mx-auto mb-14">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={post.coverImage}
|
||||||
|
alt={post.title}
|
||||||
|
className="w-full rounded-2xl object-cover max-h-[480px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Decorative divider */}
|
{/* Decorative divider */}
|
||||||
<div className="max-w-2xl mx-auto mb-14">
|
<div className="max-w-2xl mx-auto mb-14">
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
@@ -146,19 +126,19 @@ export default function PostContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content — 已在 store 写入时净化,渲染时再次净化以防御历史脏数据 */}
|
||||||
<div
|
<div
|
||||||
className="max-w-2xl mx-auto prose-literary"
|
className="max-w-2xl mx-auto prose-literary"
|
||||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
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="post-tags max-w-2xl mx-auto mt-14 pt-8 border-t border-warm-gray/10">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{post.tags.map((tag) => (
|
{post.tags.map((tag) => (
|
||||||
<Link
|
<Link
|
||||||
key={tag}
|
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"
|
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}
|
#{tag}
|
||||||
@@ -179,7 +159,9 @@ export default function PostContent({
|
|||||||
{prevPost.title}
|
{prevPost.title}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
) : <div />}
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
{nextPost ? (
|
{nextPost ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/posts/${nextPost.slug}`}
|
href={`/posts/${nextPost.slug}`}
|
||||||
@@ -190,7 +172,9 @@ export default function PostContent({
|
|||||||
{nextPost.title}
|
{nextPost.title}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
) : <div />}
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
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);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
function formatDate(dateStr: string) {
|
function FeaturedCard({ post }: { post: PublicPost }) {
|
||||||
const d = new Date(dateStr);
|
|
||||||
return d.toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeaturedCard({ post }: { post: Post }) {
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/posts/${post.slug}`} className="group block featured-card">
|
<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">
|
<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">
|
<div className="flex items-center gap-3 font-sans text-sm text-ink-muted">
|
||||||
<time>{formatDate(post.date)}</time>
|
<time>{formatDate(post.date)}</time>
|
||||||
<span className="w-1 h-1 rounded-full bg-warm-gray" />
|
<span className="w-1 h-1 rounded-full bg-warm-gray" />
|
||||||
<span>{post.readingTime} min read</span>
|
<span>{readingTimeLabel(post.readingTime)}</span>
|
||||||
</div>
|
</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">
|
<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">
|
<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[] }) {
|
export function FeaturedGrid({ posts }: { posts: PublicPost[] }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
|
||||||
|
if (isReduced || posts.length === 0) return;
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
const ctx = gsap.context(() => {
|
|
||||||
gsap.from(".featured-card", {
|
gsap.from(".featured-card", {
|
||||||
y: 50,
|
y: 50,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
duration: 0.8,
|
duration: 0.8,
|
||||||
stagger: 0.15,
|
stagger: 0.15,
|
||||||
ease: "power3.out",
|
ease: "power3.out",
|
||||||
scrollTrigger: {
|
scrollTrigger: { trigger: ref.current, start: "top 85%" },
|
||||||
trigger: ref.current,
|
|
||||||
start: "top 85%",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}, ref.current);
|
}, [posts.length]);
|
||||||
return () => ctx.revert();
|
|
||||||
}, []);
|
if (posts.length === 0) return null;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="grid md:grid-cols-2 gap-5">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<FeaturedCard key={post.slug} post={post} />
|
<FeaturedCard key={post.slug} post={post} />
|
||||||
@@ -73,39 +63,29 @@ export function FeaturedGrid({ posts }: { posts: Post[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecentList({ posts }: { posts: Post[] }) {
|
export function RecentList({ posts }: { posts: PublicPost[] }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
|
||||||
|
if (isReduced || posts.length === 0) return;
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
const ctx = gsap.context(() => {
|
|
||||||
gsap.from(".recent-item", {
|
gsap.from(".recent-item", {
|
||||||
y: 30,
|
y: 30,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
duration: 0.6,
|
duration: 0.6,
|
||||||
stagger: 0.08,
|
stagger: 0.08,
|
||||||
ease: "power3.out",
|
ease: "power3.out",
|
||||||
scrollTrigger: {
|
scrollTrigger: { trigger: ref.current, start: "top 85%" },
|
||||||
trigger: ref.current,
|
|
||||||
start: "top 85%",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}, ref.current);
|
}, [posts.length]);
|
||||||
return () => ctx.revert();
|
|
||||||
}, []);
|
if (posts.length === 0) return null;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="divider-ornament mb-10">
|
||||||
<span className="font-display text-sm italic whitespace-nowrap">最新文章</span>
|
<span className="font-display text-sm italic whitespace-nowrap">最新文章</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<Link
|
<Link key={post.slug} href={`/posts/${post.slug}`} className="recent-item group block">
|
||||||
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">
|
<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">
|
<time className="shrink-0 font-sans text-sm text-ink-muted tabular-nums w-28 pt-0.5">
|
||||||
{formatDate(post.date)}
|
{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,444 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Wand2,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
ArrowRight,
|
||||||
|
Languages,
|
||||||
|
FileText,
|
||||||
|
Bug,
|
||||||
|
Loader2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Heading,
|
||||||
|
Send,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface AiAssistantProps {
|
||||||
|
content: string;
|
||||||
|
selectedText?: string;
|
||||||
|
onInsert: (text: string, mode: "replace" | "append") => void;
|
||||||
|
onGenerateExcerpt?: (text: string) => void;
|
||||||
|
onGenerateTitle?: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action = "polish" | "expand" | "shorten" | "continue" | "translate_en" | "translate_zh" | "summarize" | "fix_grammar";
|
||||||
|
|
||||||
|
export default function AiAssistant({
|
||||||
|
content,
|
||||||
|
selectedText,
|
||||||
|
onInsert,
|
||||||
|
onGenerateExcerpt,
|
||||||
|
onGenerateTitle,
|
||||||
|
}: AiAssistantProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState("");
|
||||||
|
const [resultLabel, setResultLabel] = useState("");
|
||||||
|
const [generateTarget, setGenerateTarget] = useState<"title" | "excerpt" | null>(null);
|
||||||
|
const [customPrompt, setCustomPrompt] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const stopGeneration = useCallback(() => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
setLoading(false);
|
||||||
|
setGenerateTarget(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function getEffectiveText(): string {
|
||||||
|
if (selectedText?.trim()) return selectedText.trim();
|
||||||
|
return content.replace(/<[^>]+>/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSelection = !!(selectedText?.trim());
|
||||||
|
|
||||||
|
async function runAction(action?: Action, label?: string) {
|
||||||
|
const text = getEffectiveText();
|
||||||
|
if (!text && action !== "summarize") {
|
||||||
|
setResult("请先输入文章内容或选中文字");
|
||||||
|
setResultLabel("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setResult("");
|
||||||
|
setResultLabel(label || "");
|
||||||
|
setGenerateTarget(null);
|
||||||
|
abortRef.current = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: customPrompt || undefined,
|
||||||
|
selectedText: text || undefined,
|
||||||
|
action,
|
||||||
|
}),
|
||||||
|
signal: abortRef.current.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
setResult(`错误:${err.error || "请求失败"}`);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let accumulated = "";
|
||||||
|
|
||||||
|
if (reader) {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
for (const line of chunk.split("\n")) {
|
||||||
|
if (line.startsWith("data: ")) {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === "[DONE]") continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const delta = parsed.choices?.[0]?.delta?.content;
|
||||||
|
if (delta) {
|
||||||
|
accumulated += delta;
|
||||||
|
setResult(accumulated);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
setResult((prev) => prev + "\n\n[已停止]");
|
||||||
|
} else {
|
||||||
|
setResult(`错误:${err instanceof Error ? err.message : "未知错误"}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
abortRef.current = null;
|
||||||
|
setGenerateTarget(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGenerate(target: "title" | "excerpt") {
|
||||||
|
const text = content.replace(/<[^>]+>/g, "").trim();
|
||||||
|
if (!text) {
|
||||||
|
setResult("请先输入文章内容");
|
||||||
|
setResultLabel("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setResult("");
|
||||||
|
setResultLabel(target === "title" ? "生成标题" : "生成摘要");
|
||||||
|
setGenerateTarget(target);
|
||||||
|
abortRef.current = new AbortController();
|
||||||
|
|
||||||
|
const promptText = target === "title"
|
||||||
|
? `请为以下文章生成一个简洁的中文标题(不超过30字),直接输出标题,不要引号:\n\n${text}`
|
||||||
|
: `请为以下文章写一段摘要(2-3句话,不超过200字),直接输出摘要:\n\n${text}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ prompt: promptText, selectedText: text }),
|
||||||
|
signal: abortRef.current.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
setResult(`错误:${err.error || "请求失败"}`);
|
||||||
|
setLoading(false);
|
||||||
|
setGenerateTarget(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let accumulated = "";
|
||||||
|
|
||||||
|
if (reader) {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
for (const line of chunk.split("\n")) {
|
||||||
|
if (line.startsWith("data: ")) {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === "[DONE]") continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const delta = parsed.choices?.[0]?.delta?.content;
|
||||||
|
if (delta) {
|
||||||
|
accumulated += delta;
|
||||||
|
setResult(accumulated);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
setResult((prev) => prev + "\n\n[已停止]");
|
||||||
|
} else {
|
||||||
|
setResult(`错误:${err instanceof Error ? err.message : "未知错误"}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
abortRef.current = null;
|
||||||
|
setGenerateTarget(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGenerated() {
|
||||||
|
if (generateTarget === "title" && onGenerateTitle && result) {
|
||||||
|
onGenerateTitle(result);
|
||||||
|
setResult("");
|
||||||
|
setResultLabel("");
|
||||||
|
setGenerateTarget(null);
|
||||||
|
} else if (generateTarget === "excerpt" && onGenerateExcerpt && result) {
|
||||||
|
onGenerateExcerpt(result);
|
||||||
|
setResult("");
|
||||||
|
setResultLabel("");
|
||||||
|
setGenerateTarget(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
navigator.clipboard.writeText(result);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInsert(mode: "replace" | "append") {
|
||||||
|
onInsert(result, mode);
|
||||||
|
setResult("");
|
||||||
|
setResultLabel("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-6 space-y-5">
|
||||||
|
{/* ── 标题栏 ── */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="font-sans text-sm font-semibold text-foreground">AI 写作助手</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 选中提示 ── */}
|
||||||
|
{hasSelection && (
|
||||||
|
<div className="rounded-lg bg-primary/5 border border-primary/20 px-3 py-2">
|
||||||
|
<p className="font-sans text-xs text-primary/70">
|
||||||
|
📌 已选中 <span className="font-medium">{selectedText?.length}</span> 字,AI 将只处理选中内容
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 文本处理 ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50">文本处理</p>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{[
|
||||||
|
{ key: "polish" as Action, label: "润色", icon: <Wand2 className="w-3 h-3" /> },
|
||||||
|
{ key: "expand" as Action, label: "扩写", icon: <Maximize2 className="w-3 h-3" /> },
|
||||||
|
{ key: "shorten" as Action, label: "精简", icon: <Minimize2 className="w-3 h-3" /> },
|
||||||
|
{ key: "continue" as Action, label: "续写", icon: <ArrowRight className="w-3 h-3" /> },
|
||||||
|
{ key: "fix_grammar" as Action, label: "纠错", icon: <Bug className="w-3 h-3" /> },
|
||||||
|
{ key: "summarize" as Action, label: "摘要", icon: <FileText className="w-3 h-3" /> },
|
||||||
|
].map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.key}
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => runAction(a.key, a.label)}
|
||||||
|
className="flex items-center justify-center gap-1.5 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 hover:border-muted-foreground/30 disabled:opacity-40 transition-all active:scale-[0.97]"
|
||||||
|
>
|
||||||
|
{a.icon}
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 翻译 ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50">翻译</p>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{[
|
||||||
|
{ key: "translate_en" as Action, label: "中 → 英", icon: <Languages className="w-3 h-3" /> },
|
||||||
|
{ key: "translate_zh" as Action, label: "英 → 中", icon: <Languages className="w-3 h-3" /> },
|
||||||
|
].map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.key}
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => runAction(a.key, a.label)}
|
||||||
|
className="flex items-center justify-center gap-1.5 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 hover:border-muted-foreground/30 disabled:opacity-40 transition-all active:scale-[0.97]"
|
||||||
|
>
|
||||||
|
{a.icon}
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 智能生成 ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50">智能生成</p>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => runGenerate("title")}
|
||||||
|
className="flex items-center justify-center gap-1.5 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 hover:border-muted-foreground/30 disabled:opacity-40 transition-all active:scale-[0.97]"
|
||||||
|
>
|
||||||
|
<Heading className="w-3 h-3" />
|
||||||
|
生成标题
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => runGenerate("excerpt")}
|
||||||
|
className="flex items-center justify-center gap-1.5 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 hover:border-muted-foreground/30 disabled:opacity-40 transition-all active:scale-[0.97]"
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
生成摘要
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 自定义指令 ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
value={customPrompt}
|
||||||
|
onChange={(e) => setCustomPrompt(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); runAction(undefined, "自定义指令"); }
|
||||||
|
}}
|
||||||
|
placeholder="输入自定义指令..."
|
||||||
|
className="flex-1 h-8 px-3 rounded-lg border border-border bg-transparent font-sans text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading || !customPrompt.trim()}
|
||||||
|
onClick={() => runAction(undefined, "自定义指令")}
|
||||||
|
className="h-8 w-8 p-0 shrink-0"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 结果区 ── */}
|
||||||
|
{(result || loading) && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 分割线 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-px bg-border" />
|
||||||
|
<span className="font-sans text-[10px] text-muted-foreground/50 shrink-0">
|
||||||
|
{loading ? "生成中..." : "结果"}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 结果标签 */}
|
||||||
|
{resultLabel && !loading && result && (
|
||||||
|
<p className="font-sans text-[10px] uppercase tracking-widest text-muted-foreground/50">{resultLabel}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 结果内容 */}
|
||||||
|
<div className="relative rounded-lg border border-border bg-muted/20 p-3 max-h-80 overflow-auto">
|
||||||
|
{loading && !result && (
|
||||||
|
<div className="flex items-center gap-2 py-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||||
|
<span className="font-sans text-sm text-muted-foreground">AI 正在思考...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result && (
|
||||||
|
<div className="font-sans text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||||
|
{result}
|
||||||
|
{loading && <span className="inline-block w-1.5 h-4 bg-primary ml-0.5 animate-pulse align-middle" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading && result && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stopGeneration}
|
||||||
|
className="absolute top-2 right-2 px-2 py-0.5 rounded text-[10px] font-medium bg-red-600 text-white hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
停止
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
{!loading && result && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{generateTarget ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={applyGenerated}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 h-9 rounded-lg bg-primary text-primary-foreground font-sans text-xs font-medium hover:bg-primary/90 transition-colors active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<Send className="w-3 h-3" />
|
||||||
|
应用{generateTarget === "title" ? "标题" : "摘要"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleInsert("replace")}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 h-9 rounded-lg bg-primary text-primary-foreground font-sans text-xs font-medium hover:bg-primary/90 transition-colors active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3 h-3" />
|
||||||
|
替换内容
|
||||||
|
</button>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleInsert("append")}
|
||||||
|
className="flex items-center justify-center gap-1 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 transition-colors"
|
||||||
|
>
|
||||||
|
追加到末尾
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="flex items-center justify-center gap-1 h-8 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/40 transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3 h-3 text-green-600" /> : <Copy className="w-3 h-3" />}
|
||||||
|
{copied ? "已复制" : "复制"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 空状态 ── */}
|
||||||
|
{!result && !loading && (
|
||||||
|
<p className="font-sans text-xs text-muted-foreground/40 leading-relaxed text-center py-4">
|
||||||
|
选中文字可以局部处理<br />
|
||||||
|
或直接点击上方按钮处理全文
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
interface MarkdownEditorProps {
|
||||||
|
value: string; // HTML from parent
|
||||||
|
onChange: (html: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** 当选区变化时回调,传递选中的纯文本(无选区时为空字符串) */
|
||||||
|
onSelectionChange?: (selectedText: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 简易 HTML → Markdown 转换(仅处理常见标签) */
|
||||||
|
function htmlToMarkdown(html: string): string {
|
||||||
|
if (!html.trim()) return "";
|
||||||
|
let md = html;
|
||||||
|
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, "# $1\n\n");
|
||||||
|
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, "## $1\n\n");
|
||||||
|
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, "### $1\n\n");
|
||||||
|
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, "**$1**");
|
||||||
|
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, "**$1**");
|
||||||
|
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, "*$1*");
|
||||||
|
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, "*$1*");
|
||||||
|
md = md.replace(/<s[^>]*>(.*?)<\/s>/gi, "~~$1~~");
|
||||||
|
md = md.replace(/<strike[^>]*>(.*?)<\/strike>/gi, "~~$1~~");
|
||||||
|
md = md.replace(/<del[^>]*>(.*?)<\/del>/gi, "~~$1~~");
|
||||||
|
md = md.replace(/<code[^>]*>(.*?)<\/code>/gi, "`$1`");
|
||||||
|
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)");
|
||||||
|
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, "");
|
||||||
|
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*\/?>/gi, "");
|
||||||
|
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, (_, content) =>
|
||||||
|
content.trim().split("\n").map((l: string) => `> ${l.trim()}`).join("\n") + "\n\n"
|
||||||
|
);
|
||||||
|
md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, "- $1\n");
|
||||||
|
md = md.replace(/<\/?[uo]l[^>]*>/gi, "\n");
|
||||||
|
md = md.replace(/<hr[^>]*\/?>/gi, "\n---\n\n");
|
||||||
|
md = md.replace(/<p[^>]*>(.*?)<\/p>/gis, "$1\n\n");
|
||||||
|
md = md.replace(/<br[^>]*\/?>/gi, "\n");
|
||||||
|
md = md.replace(/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gis, "```\n$1\n```\n\n");
|
||||||
|
md = md.replace(/<[^>]+>/g, "");
|
||||||
|
md = md.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"');
|
||||||
|
md = md.replace(/\n{3,}/g, "\n\n");
|
||||||
|
return md.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarkdownEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "使用 Markdown 语法写作...",
|
||||||
|
onSelectionChange,
|
||||||
|
}: MarkdownEditorProps) {
|
||||||
|
const [markdown, setMarkdown] = useState(() => htmlToMarkdown(value));
|
||||||
|
|
||||||
|
// 外部 value 变化时(如恢复草稿),同步到 markdown
|
||||||
|
useEffect(() => {
|
||||||
|
const converted = htmlToMarkdown(value);
|
||||||
|
// 只在内容真正不同时更新,避免光标跳动
|
||||||
|
if (converted !== markdown && marked.parse(markdown) !== value) {
|
||||||
|
setMarkdown(converted);
|
||||||
|
}
|
||||||
|
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Markdown → HTML,同步给父组件
|
||||||
|
const html = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return marked.parse(markdown) as string;
|
||||||
|
} catch {
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
}, [markdown]);
|
||||||
|
|
||||||
|
function handleChange(md: string) {
|
||||||
|
setMarkdown(md);
|
||||||
|
try {
|
||||||
|
const newHtml = marked.parse(md) as string;
|
||||||
|
onChange(newHtml);
|
||||||
|
} catch {
|
||||||
|
onChange(md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-border h-full">
|
||||||
|
{/* 编辑区 */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="px-3 py-1.5 border-b border-border bg-muted/30">
|
||||||
|
<span className="font-sans text-xs text-muted-foreground">Markdown</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={markdown}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
onSelect={(e) => {
|
||||||
|
if (!onSelectionChange) return;
|
||||||
|
const ta = e.currentTarget;
|
||||||
|
const selected = ta.value.substring(ta.selectionStart, ta.selectionEnd);
|
||||||
|
onSelectionChange(selected);
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1 min-h-0 px-4 py-3 bg-transparent font-mono text-sm text-foreground resize-none focus:outline-none leading-relaxed"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 预览区 */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="px-3 py-1.5 border-b border-border bg-muted/30">
|
||||||
|
<span className="font-sans text-xs text-muted-foreground">预览</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex-1 min-h-0 px-4 py-3 overflow-auto prose-literary"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
"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 });
|
||||||
|
const MarkdownEditor = dynamic(() => import("./MarkdownEditor"), { ssr: false });
|
||||||
|
import AiAssistant from "./AiAssistant";
|
||||||
|
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 ?? "",
|
||||||
|
coverImage: initialData?.coverImage ?? "",
|
||||||
|
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));
|
||||||
|
|
||||||
|
// 全屏 / Markdown 模式
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [isMarkdown, setIsMarkdown] = useState(false);
|
||||||
|
const fullscreenRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 编辑器选中的文本
|
||||||
|
const [selectedText, setSelectedText] = useState("");
|
||||||
|
|
||||||
|
// 撤销机制
|
||||||
|
const [showUndo, setShowUndo] = useState(false);
|
||||||
|
const previousContentRef = useRef("");
|
||||||
|
|
||||||
|
// 自动保存
|
||||||
|
const autoSaveKey = `draft:${mode === "edit" ? initialData?.id ?? "edit" : "new"}`;
|
||||||
|
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
// 离开确认
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// ── 自动保存到 localStorage(debounce 2s) ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return;
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(autoSaveKey, JSON.stringify(form));
|
||||||
|
setLastSaved(new Date());
|
||||||
|
} catch { /* quota exceeded, ignore */ }
|
||||||
|
}, 2000);
|
||||||
|
return () => {
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [form, dirty, autoSaveKey]);
|
||||||
|
|
||||||
|
// ── 页面加载时恢复草稿 ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "edit") return; // 编辑模式不自动恢复,避免覆盖已有内容
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(autoSaveKey);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
// 只恢复有实际内容的草稿
|
||||||
|
if (parsed.title || parsed.content) {
|
||||||
|
setForm((prev) => ({ ...prev, ...parsed }));
|
||||||
|
setLastSaved(new Date());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* corrupt data, ignore */ }
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── 快捷键 ──
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
const isMod = e.metaKey || e.ctrlKey;
|
||||||
|
// Ctrl/Cmd + S → 保存
|
||||||
|
if (isMod && e.key === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
formRef.current?.requestSubmit();
|
||||||
|
}
|
||||||
|
// Ctrl/Cmd + Enter → 保存
|
||||||
|
if (isMod && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
formRef.current?.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── 浏览器原生全屏 API ──
|
||||||
|
function enterFullscreen() {
|
||||||
|
setIsFullscreen(true);
|
||||||
|
// 等 DOM 更新后再调用原生全屏
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
fullscreenRef.current?.requestFullscreen?.().catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitFullscreen() {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
setIsFullscreen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听浏览器全屏变化(用户按 Esc 或 F11 退出时同步状态)
|
||||||
|
useEffect(() => {
|
||||||
|
function onFullscreenChange() {
|
||||||
|
if (!document.fullscreenElement && isFullscreen) {
|
||||||
|
setIsFullscreen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("fullscreenchange", onFullscreenChange);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", onFullscreenChange);
|
||||||
|
}, [isFullscreen]);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
// 保存成功,清除草稿
|
||||||
|
localStorage.removeItem(autoSaveKey);
|
||||||
|
setDirty(false);
|
||||||
|
} 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 全屏专注模式 ──
|
||||||
|
if (isFullscreen) {
|
||||||
|
return (
|
||||||
|
<div ref={fullscreenRef} className="fixed inset-0 z-[100] bg-background flex flex-col">
|
||||||
|
{/* 顶部极简栏 */}
|
||||||
|
<div className="flex items-center gap-4 px-6 py-3 border-b border-border/50 shrink-0">
|
||||||
|
<span className="font-display text-base text-foreground truncate flex-1 opacity-60">
|
||||||
|
{form.title || "未命名文章"}
|
||||||
|
</span>
|
||||||
|
<span className="font-sans text-xs text-muted-foreground tabular-nums">
|
||||||
|
{form.content.replace(/<[^>]+>/g, "").length} 字
|
||||||
|
</span>
|
||||||
|
{lastSaved && (
|
||||||
|
<span className="font-sans text-xs text-muted-foreground tabular-nums">
|
||||||
|
{dirty ? "未保存" : "已保存"} {lastSaved.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={exitFullscreen}
|
||||||
|
className="font-sans text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
退出全屏 Esc
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* 编辑器主体 */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<div className="w-full h-full px-6 flex flex-col">
|
||||||
|
<RichEditor
|
||||||
|
value={form.content}
|
||||||
|
onChange={(html) => update("content", html)}
|
||||||
|
isFullscreen
|
||||||
|
isMarkdown={isMarkdown}
|
||||||
|
onToggleFullscreen={exitFullscreen}
|
||||||
|
onSwitchToMarkdown={() => setIsMarkdown((v) => !v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 隐藏的 form 用于快捷键提交 */}
|
||||||
|
<form ref={formRef} onSubmit={handleSubmit} className="hidden" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form ref={formRef} 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 htmlFor="post-cover">封面图(可选,输入图片地址)</Label>
|
||||||
|
<Input
|
||||||
|
id="post-cover"
|
||||||
|
value={form.coverImage}
|
||||||
|
onChange={(e) => update("coverImage", e.target.value)}
|
||||||
|
placeholder="https://example.com/cover.jpg"
|
||||||
|
/>
|
||||||
|
{form.coverImage && (
|
||||||
|
<div className="mt-2 relative w-full max-w-sm aspect-video rounded-lg overflow-hidden border border-border bg-muted">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={form.coverImage}
|
||||||
|
alt="封面预览"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容 + AI 助手(水平对齐) */}
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<div className="flex-1 min-w-0 space-y-1.5">
|
||||||
|
<Label>内容</Label>
|
||||||
|
<RichEditor
|
||||||
|
value={form.content}
|
||||||
|
onChange={(html) => update("content", html)}
|
||||||
|
isFullscreen={false}
|
||||||
|
isMarkdown={isMarkdown}
|
||||||
|
onToggleFullscreen={enterFullscreen}
|
||||||
|
onSwitchToMarkdown={() => setIsMarkdown((v) => !v)}
|
||||||
|
onSelectionChange={setSelectedText}
|
||||||
|
/>
|
||||||
|
{errors.content && <p className="text-xs text-red-600">{errors.content}</p>}
|
||||||
|
{showUndo && (
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2 rounded-lg border border-primary/30 bg-primary/5">
|
||||||
|
<span className="font-sans text-xs text-primary/80">内容已替换</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
update("content", previousContentRef.current);
|
||||||
|
setShowUndo(false);
|
||||||
|
}}
|
||||||
|
className="font-sans text-xs text-primary font-medium hover:underline"
|
||||||
|
>
|
||||||
|
撤销
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUndo(false)}
|
||||||
|
className="font-sans text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI 助手面板 — 与编辑器水平对齐 */}
|
||||||
|
<aside className="hidden lg:block w-72 shrink-0">
|
||||||
|
<div className="pt-6">
|
||||||
|
<AiAssistant
|
||||||
|
content={form.content}
|
||||||
|
selectedText={selectedText}
|
||||||
|
onInsert={(text, mode) => {
|
||||||
|
if (mode === "replace") {
|
||||||
|
previousContentRef.current = form.content;
|
||||||
|
setShowUndo(true);
|
||||||
|
update("content", text);
|
||||||
|
} else {
|
||||||
|
previousContentRef.current = form.content;
|
||||||
|
setShowUndo(true);
|
||||||
|
update("content", form.content + "\n\n" + text);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onGenerateExcerpt={(text) => {
|
||||||
|
update("excerpt", text);
|
||||||
|
}}
|
||||||
|
onGenerateTitle={(text) => {
|
||||||
|
update("title", text);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</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 items-center gap-3 pt-4">
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "保存中..." : mode === "create" ? "保存" : "保存修改"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{lastSaved && (
|
||||||
|
<span className="font-sans text-xs text-muted-foreground">
|
||||||
|
{dirty ? "未保存" : "已保存"} · {lastSaved.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷键提示 */}
|
||||||
|
<p className="font-sans text-xs text-muted-foreground/60">
|
||||||
|
快捷键:Ctrl+S 保存 · Ctrl+Enter 保存
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"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 dynamic from "next/dynamic";
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Strikethrough,
|
||||||
|
Code,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Quote,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Link as LinkIcon,
|
||||||
|
ImageIcon,
|
||||||
|
CodeSquare,
|
||||||
|
Minus,
|
||||||
|
Undo2,
|
||||||
|
Redo2,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const MarkdownEditor = dynamic(() => import("./MarkdownEditor"), { ssr: false });
|
||||||
|
|
||||||
|
const lowlight = createLowlight(common);
|
||||||
|
|
||||||
|
interface RichEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (html: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
isFullscreen?: boolean;
|
||||||
|
isMarkdown?: boolean;
|
||||||
|
onToggleFullscreen?: () => void;
|
||||||
|
onSwitchToMarkdown?: () => void;
|
||||||
|
/** 当选区变化时回调,传递选中的纯文本(无选区时为空字符串) */
|
||||||
|
onSelectionChange?: (selectedText: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RichEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "开始写文章...",
|
||||||
|
isFullscreen,
|
||||||
|
isMarkdown,
|
||||||
|
onToggleFullscreen,
|
||||||
|
onSwitchToMarkdown,
|
||||||
|
onSelectionChange,
|
||||||
|
}: 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());
|
||||||
|
},
|
||||||
|
onSelectionUpdate: ({ editor }) => {
|
||||||
|
if (!onSelectionChange) return;
|
||||||
|
const { from, to } = editor.state.selection;
|
||||||
|
if (from !== to) {
|
||||||
|
const text = editor.state.doc.textBetween(from, to);
|
||||||
|
onSelectionChange(text);
|
||||||
|
} else {
|
||||||
|
onSelectionChange("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: "prose-literary min-h-[500px] 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFs = !!isFullscreen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={isFs ? "flex flex-col h-full bg-transparent" : "rounded-xl border border-border bg-card overflow-hidden"}>
|
||||||
|
{/* 工具栏 — 始终显示 */}
|
||||||
|
<div className={`flex flex-wrap items-center gap-0.5 border-b border-border/50 shrink-0 ${isFs ? "px-4 py-2.5" : "px-2 py-1.5 bg-muted/30"}`}>
|
||||||
|
{/* 富文本格式按钮 — Markdown 模式下禁用 */}
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive("heading", { level: 1 })}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
|
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()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
|
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()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
|
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()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
|
aria-label="粗体"
|
||||||
|
>
|
||||||
|
<Bold className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive("italic")}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
|
aria-label="斜体"
|
||||||
|
>
|
||||||
|
<Italic className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive("strike")}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
|
aria-label="删除线"
|
||||||
|
>
|
||||||
|
<Strikethrough className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive("code")}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
|
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()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
|
aria-label="引用"
|
||||||
|
>
|
||||||
|
<Quote className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive("bulletList")}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
|
aria-label="无序列表"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive("orderedList")}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
|
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()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
|
aria-label="代码块"
|
||||||
|
>
|
||||||
|
<CodeSquare className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle size="sm" onPressedChange={addLink} disabled={!!isMarkdown} aria-label="链接">
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle size="sm" onPressedChange={addImage} disabled={!!isMarkdown} aria-label="图片">
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
onPressedChange={() => editor.chain().focus().setHorizontalRule().run()}
|
||||||
|
disabled={!!isMarkdown}
|
||||||
|
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={!!isMarkdown || !editor.can().undo()}
|
||||||
|
aria-label="撤销"
|
||||||
|
>
|
||||||
|
<Undo2 className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
onPressedChange={() => editor.chain().focus().redo().run()}
|
||||||
|
disabled={!!isMarkdown || !editor.can().redo()}
|
||||||
|
aria-label="重做"
|
||||||
|
>
|
||||||
|
<Redo2 className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Markdown 切换 */}
|
||||||
|
{onSwitchToMarkdown && (
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={!!isMarkdown}
|
||||||
|
onPressedChange={onSwitchToMarkdown}
|
||||||
|
aria-label={isMarkdown ? "切换到富文本" : "Markdown 模式"}
|
||||||
|
title={isMarkdown ? "切换到富文本" : "Markdown 模式"}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 全屏切换 */}
|
||||||
|
{onToggleFullscreen && (
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
onPressedChange={onToggleFullscreen}
|
||||||
|
aria-label={isFullscreen ? "退出全屏" : "全屏写作"}
|
||||||
|
title={isFullscreen ? "退出全屏" : "全屏写作"}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||||
|
</Toggle>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区域 — 根据模式切换 */}
|
||||||
|
<div className={isFs ? "flex-1 overflow-auto" : ""}>
|
||||||
|
{isMarkdown ? (
|
||||||
|
<MarkdownEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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 {
|
import type { Post } from "@/lib/store";
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
excerpt: string;
|
|
||||||
content: string;
|
|
||||||
date: string;
|
|
||||||
category: string;
|
|
||||||
tags: string[];
|
|
||||||
coverImage?: string;
|
|
||||||
readingTime: number;
|
|
||||||
featured?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
slug: "on-writing-and-silence",
|
||||||
title: "论写作与沉默",
|
title: "论写作与沉默",
|
||||||
@@ -22,6 +19,7 @@ export const posts: Post[] = [
|
|||||||
tags: ["写作", "思考", "生活哲学"],
|
tags: ["写作", "思考", "生活哲学"],
|
||||||
readingTime: 4,
|
readingTime: 4,
|
||||||
featured: true,
|
featured: true,
|
||||||
|
status: "published",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "a-walk-in-the-mountains",
|
slug: "a-walk-in-the-mountains",
|
||||||
@@ -33,6 +31,7 @@ export const posts: Post[] = [
|
|||||||
tags: ["旅行", "自然", "六安"],
|
tags: ["旅行", "自然", "六安"],
|
||||||
readingTime: 6,
|
readingTime: 6,
|
||||||
featured: true,
|
featured: true,
|
||||||
|
status: "published",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "notes-on-digital-twin",
|
slug: "notes-on-digital-twin",
|
||||||
@@ -43,6 +42,8 @@ export const posts: Post[] = [
|
|||||||
category: "技术",
|
category: "技术",
|
||||||
tags: ["Web3D", "React", "Three.js", "前端"],
|
tags: ["Web3D", "React", "Three.js", "前端"],
|
||||||
readingTime: 8,
|
readingTime: 8,
|
||||||
|
featured: false,
|
||||||
|
status: "published",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "reading-list-spring",
|
slug: "reading-list-spring",
|
||||||
@@ -54,6 +55,7 @@ export const posts: Post[] = [
|
|||||||
tags: ["阅读", "书单", "生活"],
|
tags: ["阅读", "书单", "生活"],
|
||||||
readingTime: 5,
|
readingTime: 5,
|
||||||
featured: true,
|
featured: true,
|
||||||
|
status: "published",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "stable-diffusion-local-setup",
|
slug: "stable-diffusion-local-setup",
|
||||||
@@ -64,6 +66,8 @@ export const posts: Post[] = [
|
|||||||
category: "技术",
|
category: "技术",
|
||||||
tags: ["AI", "Stable Diffusion", "Apple Silicon"],
|
tags: ["AI", "Stable Diffusion", "Apple Silicon"],
|
||||||
readingTime: 10,
|
readingTime: 10,
|
||||||
|
featured: false,
|
||||||
|
status: "published",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "lightbox-dream",
|
slug: "lightbox-dream",
|
||||||
@@ -75,6 +79,7 @@ export const posts: Post[] = [
|
|||||||
tags: ["创业", "灯箱", "产品", "sui_lightbox"],
|
tags: ["创业", "灯箱", "产品", "sui_lightbox"],
|
||||||
readingTime: 7,
|
readingTime: 7,
|
||||||
featured: true,
|
featured: true,
|
||||||
|
status: "published",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "rainy-day-thoughts",
|
slug: "rainy-day-thoughts",
|
||||||
@@ -85,50 +90,35 @@ export const posts: Post[] = [
|
|||||||
category: "随笔",
|
category: "随笔",
|
||||||
tags: ["随笔", "生活", "六安"],
|
tags: ["随笔", "生活", "六安"],
|
||||||
readingTime: 3,
|
readingTime: 3,
|
||||||
|
featured: false,
|
||||||
|
status: "published",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "next-js-blog-from-scratch",
|
slug: "next-js-blog-from-scratch",
|
||||||
title: "从零搭建一个博客系统",
|
title: "从零搭建一个博客系统",
|
||||||
excerpt: "为什么选择 Next.js + Halo CMS?为什么不用 WordPress?这篇文章聊聊技术选型的思考过程。",
|
excerpt: "为什么选择 Next.js 来搭建?为什么不用 WordPress?这篇文章聊聊技术选型的思考过程。",
|
||||||
content: `<p>为什么选择 Next.js + Halo CMS?为什么不用 WordPress?</p><p>作为一个前端开发者,我希望能完全掌控博客的UI和交互体验。Halo CMS 提供了足够好的内容管理API,而 Next.js 则让我可以自由设计前端展示。</p>`,
|
content: `<p>为什么选择 Next.js 来搭建?为什么不用 WordPress?</p><p>作为一个前端开发者,我希望能完全掌控博客的UI和交互体验。Next.js 的 App Router 与 Server Components 让我可以自由设计前端展示,同时保持良好的性能。数据则存储在本地的 JSON 文件中,简单而透明。</p>`,
|
||||||
date: "2026-04-28",
|
date: "2026-04-28",
|
||||||
category: "技术",
|
category: "技术",
|
||||||
tags: ["Next.js", "博客", "Halo CMS", "前端"],
|
tags: ["Next.js", "博客", "前端"],
|
||||||
readingTime: 6,
|
readingTime: 6,
|
||||||
|
featured: false,
|
||||||
|
status: "published",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const categories = [
|
export const seedCategories = [
|
||||||
{ name: "技术", count: 3, description: "代码、架构与技术探索" },
|
{ name: "技术", description: "代码、架构与技术探索" },
|
||||||
{ name: "随笔", count: 2, description: "生活感悟与碎片思考" },
|
{ name: "随笔", description: "生活感悟与碎片思考" },
|
||||||
{ name: "旅行", count: 1, description: "在路上看到的风景与人" },
|
{ name: "旅行", description: "在路上看到的风景与人" },
|
||||||
{ name: "阅读", count: 1, description: "书中世界与阅读心得" },
|
{ name: "阅读", description: "书中世界与阅读心得" },
|
||||||
{ name: "创业", count: 1, description: "产品思考与创业记录" },
|
{ name: "创业", description: "产品思考与创业记录" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const allTags = [
|
export const seedTags = [
|
||||||
{ name: "写作", count: 1 },
|
"写作", "思考", "生活哲学", "旅行", "自然", "六安",
|
||||||
{ name: "思考", count: 1 },
|
"Web3D", "React", "Three.js", "前端", "阅读", "书单",
|
||||||
{ name: "生活哲学", count: 1 },
|
"生活", "AI", "Stable Diffusion", "Apple Silicon",
|
||||||
{ name: "旅行", count: 1 },
|
"创业", "灯箱", "产品", "sui_lightbox", "随笔",
|
||||||
{ name: "自然", count: 1 },
|
"Next.js", "博客",
|
||||||
{ 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 },
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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.
|
* Seed 脚本入口 — 可手动运行:`npx tsx src/lib/seed.ts`
|
||||||
* Usage: 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 = [
|
async function main() {
|
||||||
{
|
await ensureSeed();
|
||||||
slug: "on-writing-and-silence",
|
const [posts, categories, tags] = await Promise.all([
|
||||||
title: "论写作与沉默",
|
getPosts(),
|
||||||
excerpt: "有些话适合写在纸上,有些话适合留在风里。写作不是填满空白的过程,而是从空白中提炼意义的旅程。",
|
getCategories(),
|
||||||
content: "<p>有些话适合写在纸上,有些话适合留在风里。</p><p>我常常觉得,沉默是一种被低估的能力。在这个信息过载的时代,我们急于表达、急于分享,却很少给自己留出沉默的空间。写作不是填满空白的过程,而是从空白中提炼意义的旅程。</p><p>每一次落笔,都是一次与自己的对话。那些在深夜里涌现的念头,像潮水一样涌来,又像退潮后的贝壳,最终留下的才是最珍贵的。</p><p>我开始学会在写作之前先沉默。让想法在脑海中沉淀,让语言在时间里发酵。好的文字从来不是急出来的。</p>",
|
getTags(),
|
||||||
date: "2026-06-15",
|
]);
|
||||||
category: "随笔",
|
console.log("Seed complete:", {
|
||||||
tags: ["写作", "思考", "生活哲学"],
|
posts: posts.length,
|
||||||
readingTime: 4,
|
categories: categories.length,
|
||||||
featured: true,
|
tags: tags.length,
|
||||||
status: "published" as const,
|
});
|
||||||
},
|
process.exit(0);
|
||||||
{
|
|
||||||
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`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getCategories().length === 0) {
|
main();
|
||||||
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.");
|
|
||||||
|
|||||||
+304
-78
@@ -1,13 +1,20 @@
|
|||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import path from "path";
|
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
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||||
if (!existsSync(DATA_DIR)) {
|
|
||||||
mkdirSync(DATA_DIR, { recursive: true });
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 类型定义(保持不变) ──
|
||||||
|
|
||||||
|
/** 统一的文章类型,前台与后台共用。 */
|
||||||
export interface Post {
|
export interface Post {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -17,6 +24,8 @@ export interface Post {
|
|||||||
date: string;
|
date: string;
|
||||||
category: string;
|
category: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
/** 可选封面图,前台卡片可在有值时展示。 */
|
||||||
|
coverImage?: string;
|
||||||
readingTime: number;
|
readingTime: number;
|
||||||
featured: boolean;
|
featured: boolean;
|
||||||
status: "draft" | "published";
|
status: "draft" | "published";
|
||||||
@@ -35,119 +44,336 @@ export interface Tag {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readJSON<T>(filename: string, fallback: T): T {
|
/** 文章可见性:前台只展示已发布。 */
|
||||||
const filepath = path.join(DATA_DIR, filename);
|
export type PublicPost = Omit<Post, "status"> & { status: "published" };
|
||||||
if (!existsSync(filepath)) return fallback;
|
|
||||||
try {
|
|
||||||
return JSON.parse(readFileSync(filepath, "utf-8"));
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
function generateId(): string {
|
||||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
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 ──
|
// ── 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;
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: search } },
|
||||||
|
{ excerpt: { contains: search } },
|
||||||
|
{ category: { contains: search } },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPost(data: Omit<Post, "id" | "createdAt" | "updatedAt">): Post {
|
const [rows, total] = await Promise.all([
|
||||||
const posts = getPosts();
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后台用:获取统计数据。 */
|
||||||
|
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 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 now = new Date().toISOString();
|
||||||
const post: Post = {
|
const row = await prisma.post.create({
|
||||||
...data,
|
data: {
|
||||||
id: generateId(),
|
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,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
},
|
||||||
posts.unshift(post);
|
});
|
||||||
writeJSON("posts.json", posts);
|
return toPost(row);
|
||||||
return post;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePost(id: string, data: Partial<Post>): Post | null {
|
export async function updatePost(id: string, data: Partial<Post>): Promise<Post | null> {
|
||||||
const posts = getPosts();
|
const existing = await prisma.post.findUnique({ where: { id } });
|
||||||
const index = posts.findIndex((p) => p.id === id);
|
if (!existing) return null;
|
||||||
if (index === -1) return null;
|
|
||||||
posts[index] = { ...posts[index], ...data, updatedAt: new Date().toISOString() };
|
// 禁止通过 update 覆盖不可变字段
|
||||||
writeJSON("posts.json", posts);
|
const { id: _id, createdAt: _createdAt, ...rest } = data;
|
||||||
return posts[index];
|
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 function deletePost(id: string): boolean {
|
export async function deletePost(id: string): Promise<boolean> {
|
||||||
const posts = getPosts();
|
try {
|
||||||
const filtered = posts.filter((p) => p.id !== id);
|
await prisma.post.delete({ where: { id } });
|
||||||
if (filtered.length === posts.length) return false;
|
|
||||||
writeJSON("posts.json", filtered);
|
|
||||||
return true;
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Categories ──
|
// ── Categories ──
|
||||||
|
|
||||||
export function getCategories(): Category[] {
|
export async function getCategories(): Promise<Category[]> {
|
||||||
return readJSON<Category[]>("categories.json", []);
|
return prisma.category.findMany({ orderBy: { name: "asc" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCategory(data: Omit<Category, "id">): Category {
|
export async function createCategory(data: Omit<Category, "id">): Promise<Category> {
|
||||||
const categories = getCategories();
|
return prisma.category.create({
|
||||||
const cat: Category = { ...data, id: generateId() };
|
data: { id: generateId(), ...data },
|
||||||
categories.push(cat);
|
});
|
||||||
writeJSON("categories.json", categories);
|
|
||||||
return cat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateCategory(id: string, data: Partial<Category>): Category | null {
|
export async function updateCategory(
|
||||||
const categories = getCategories();
|
id: string,
|
||||||
const index = categories.findIndex((c) => c.id === id);
|
data: Partial<Category>
|
||||||
if (index === -1) return null;
|
): Promise<Category | null> {
|
||||||
categories[index] = { ...categories[index], ...data };
|
const existing = await prisma.category.findUnique({ where: { id } });
|
||||||
writeJSON("categories.json", categories);
|
if (!existing) return null;
|
||||||
return categories[index];
|
const { id: _id, ...rest } = data;
|
||||||
|
return prisma.category.update({ where: { id }, data: rest });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteCategory(id: string): boolean {
|
export async function deleteCategory(id: string): Promise<boolean> {
|
||||||
const categories = getCategories();
|
try {
|
||||||
const filtered = categories.filter((c) => c.id !== id);
|
await prisma.category.delete({ where: { id } });
|
||||||
if (filtered.length === categories.length) return false;
|
|
||||||
writeJSON("categories.json", filtered);
|
|
||||||
return true;
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tags ──
|
// ── Tags ──
|
||||||
|
|
||||||
export function getTags(): Tag[] {
|
export async function getTags(): Promise<Tag[]> {
|
||||||
return readJSON<Tag[]>("tags.json", []);
|
return prisma.tag.findMany({ orderBy: { name: "asc" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTag(data: Omit<Tag, "id">): Tag {
|
export async function createTag(data: Omit<Tag, "id">): Promise<Tag> {
|
||||||
const tags = getTags();
|
return prisma.tag.create({
|
||||||
const tag: Tag = { ...data, id: generateId() };
|
data: { id: generateId(), ...data },
|
||||||
tags.push(tag);
|
});
|
||||||
writeJSON("tags.json", tags);
|
|
||||||
return tag;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteTag(id: string): boolean {
|
export async function deleteTag(id: string): Promise<boolean> {
|
||||||
const tags = getTags();
|
try {
|
||||||
const filtered = tags.filter((t) => t.id !== id);
|
await prisma.tag.delete({ where: { id } });
|
||||||
if (filtered.length === tags.length) return false;
|
|
||||||
writeJSON("tags.json", filtered);
|
|
||||||
return true;
|
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,31 @@
|
|||||||
|
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),
|
||||||
|
coverImage: z.string().max(500).optional().default(""),
|
||||||
|
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": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2022",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user